mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
* fix(package): install without the unpublished @openrig/daemon (#66) The CLI package declared the private @openrig/daemon in dependencies and shipped it as a bundled dependency. npm used the bundle, but package managers that resolve every dependency from the registry, such as Bun, failed with a 404. The package already ships the daemon under daemon/. The package build now rewrites the compiled CLI and TUI imports of @openrig/daemon/<subpath> to that copy, using the daemon's exports map, and fails on any unmapped or unstaged subpath or any daemon import left afterwards. The dependency, the bundled copy and their lockfile entries are removed. Source imports and development resolution are unchanged. Reported by @drewpayment. * fix(package): rewrite daemon imports from the syntax tree, not text Parse each staged file with the TypeScript compiler and rewrite only real import/export specifiers and literal import()/require() arguments. Text in comments, strings and templates is no longer changed, and imports that carry comments or use template literals are no longer missed. Unparseable files and non-literal module arguments naming the package now fail the build. * docs: note Bun installation and its postinstall limit Bun can install the CLI, but OpenRig still runs on Node.js and Bun may block the package's postinstall check. * fix(package): run the daemon import rewrite from any checkout path The direct-run check compared import.meta.url with a hand-built file:// URL, which never matched when the path needed percent-encoding (spaces, #) or went through a symlink. The script then exited 0 without rewriting anything. Compare the decoded module path with the real path of argv[1] instead. --------- Co-authored-by: OpenRig contributors <[email protected]>
This commit is contained in:
1 parent
5c54703035
commit
c9be421b9c
7 files changed
+445
-62
No files matched your search
@@ -15,6 +15,8 @@ npm install -g @openrig/cli
|
||||
rig setup --dry-run
|
||||
```
|
||||
|
||||
To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs on Node.js, so install Node.js 22 as well. Bun may block this package's postinstall script, in which case the Node.js and SQLite check described under [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) does not run at install time.
|
||||
|
||||
Review setup's plan before applying `rig setup`: it checks both native harnesses and cmux. This starter requires tmux and authenticated Codex; the other harness and terminal provider are optional for its repository task.
|
||||
|
||||
Before launching, ask your agent to [configure your chosen permissions](docs/reference/getting-started.md#have-your-agent-configure-permissions): keep prompts, remember selected commands, or deliberately choose broader access. The agent handles setup and verification; OpenRig's shipped defaults stay unchanged.
|
||||
@@ -70,7 +72,7 @@ using a published package, since repository guidance can be ahead of npm.
|
||||
|
||||
| When | What changes and why |
|
||||
| --- | --- |
|
||||
| **npm installation** | Installs the CLI, bundled components and dependencies under your npm prefix. OpenRig's postinstall checks Node/SQLite compatibility; it does not run daemon or provider setup. |
|
||||
| **npm installation** | Installs the CLI, bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
|
||||
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
|
||||
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
|
||||
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. |
|
||||
|
||||
Generated
-4
@@ -7415,16 +7415,12 @@
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.5.16",
|
||||
"bundleDependencies": [
|
||||
"@openrig/daemon"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@openrig/daemon": "*",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@openrig/daemon": "*",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
@@ -61,9 +60,6 @@
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bundledDependencies": [
|
||||
"@openrig/daemon"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -136,35 +136,10 @@ if [ -d "$REPO_ROOT/docs/reference" ]; then
|
||||
cp -r "$REPO_ROOT/docs/reference/"* "$CLI_DIR/daemon/docs/reference/"
|
||||
fi
|
||||
|
||||
# RESOLVER-VISIBLE daemon (Q2 packaging fix). The cli's RUNTIME bare-specifier value-imports —
|
||||
# await import("@openrig/daemon/{crash-cart,gateway-protocol,gateway-human-registry}") — resolve via
|
||||
# @openrig/daemon's EXPORTS MAP, so the daemon must live where node's resolver looks:
|
||||
# <cli>/node_modules/@openrig/daemon. (The <cli>/daemon dir above is resolver-INVISIBLE — it is the
|
||||
# path the cli SPAWNS the daemon PROCESS from, not an import target.) @openrig/daemon is UNPUBLISHED,
|
||||
# so packages/cli lists it in "bundledDependencies": npm pack ships THIS copy in the tarball and npm
|
||||
# install uses it, never the 404 registry. We assemble a CLEAN copy (package.json + dist only) that
|
||||
# REPLACES the dev workspace symlink. The daemon's own runtime deps (hono/tar/ulid/yaml/@hono/*) and
|
||||
# better-sqlite3 (NATIVE — builds fresh on target, never prebuilt) are NOT nested here: the daemon dep
|
||||
# set is a SUBSET of the cli's, so they stay HOISTED as the cli's own deps and resolve by node walking
|
||||
# up to <cli>/node_modules.
|
||||
DAEMON_NM="$CLI_DIR/node_modules/@openrig/daemon"
|
||||
rm -rf "$DAEMON_NM"
|
||||
mkdir -p "$DAEMON_NM/dist"
|
||||
# Q2 break #5 fix: STRIP the bundled daemon's dependency fields. The daemon's runtime deps
|
||||
# (better-sqlite3 + @hono/node-server,@hono/node-ws,hono,tar,ulid,yaml) are a strict SUBSET of the
|
||||
# cli's own deps, so on target they must be fetched ONCE at the cli level from the registry (COMPLETE,
|
||||
# with better-sqlite3's binding.gyp) and resolved by node walking UP to <cli>/node_modules. If the
|
||||
# BUNDLED daemon package.json still DECLARES them, `npm install -g` treats them as bundle-provided
|
||||
# UNDER @openrig/daemon and leaves an EMPTY <cli>/node_modules/better-sqlite3 (no binding.gyp →
|
||||
# 'prebuild-install: not found' + 'binding.gyp not found' = break #5). Runtime is unaffected: node
|
||||
# resolves by hoisting; a package.json `dependencies` field is only consulted by npm at INSTALL time.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
delete p.dependencies; delete p.devDependencies; delete p.optionalDependencies; delete p.peerDependencies;
|
||||
fs.writeFileSync(process.argv[2], JSON.stringify(p, null, 2) + "\n");
|
||||
' "$DAEMON_DIR/package.json" "$DAEMON_NM/package.json"
|
||||
cp -r "$DAEMON_DIR/dist/"* "$DAEMON_NM/dist/"
|
||||
# Earlier builds assembled a bundled copy of the unpublished @openrig/daemon here. The package now
|
||||
# imports the shipped daemon/dist directly (rewrite step below), so remove any stale copy: it would
|
||||
# otherwise shadow the development workspace link for code under packages/cli.
|
||||
rm -rf "$CLI_DIR/node_modules/@openrig/daemon"
|
||||
|
||||
# UI: dist
|
||||
mkdir -p "$CLI_DIR/ui/dist"
|
||||
@@ -174,6 +149,11 @@ cp -r "$UI_DIR/dist/"* "$CLI_DIR/ui/dist/"
|
||||
mkdir -p "$CLI_DIR/tui/dist"
|
||||
cp -r "$TUI_DIR/dist/"* "$CLI_DIR/tui/dist/"
|
||||
|
||||
# The CLI and TUI import daemon code as @openrig/daemon/<subpath>. That package is not published, so
|
||||
# rewrite those specifiers in the staged JS to the daemon/dist copy shipped above (#66). Fails the
|
||||
# build on an unmapped or unstaged subpath and on any daemon import left afterwards.
|
||||
node "$REPO_ROOT/scripts/rewrite-daemon-imports.mjs"
|
||||
|
||||
# Report
|
||||
echo ""
|
||||
echo "=== Package Assembled ==="
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { remainingDaemonImports } from "./rewrite-daemon-imports.mjs";
|
||||
|
||||
// 51-04 Q2 — the pack-step proof. The old guard only string-matched `npm pack`, so it stayed GREEN
|
||||
// over THREE stacked breaks (root package / no bin / non-standalone-installable). Tiers:
|
||||
// 1. fast OFFLINE pre-check — the tarball is @openrig/cli, ships the `rig` bin, and BUNDLES the
|
||||
// (unpublished) @openrig/daemon;
|
||||
// 2. BUNDLE PROOF (host-runnable) — assemble via build-package.sh + pack, and prove the tarball
|
||||
// ships <cli>/node_modules/@openrig/daemon with the EXACT exports-map surfaces the cli's four
|
||||
// bare-specifier value-imports resolve to. This is what makes the standalone install stop 404ing
|
||||
// on @openrig/daemon (verified: the install now progresses past it to the native build);
|
||||
// 1. fast OFFLINE pre-check — the tarball is @openrig/cli, ships the `rig` bin, and does NOT depend
|
||||
// on the unpublished @openrig/daemon (#66: package managers that ignore bundling tried the
|
||||
// registry and got a 404);
|
||||
// 2. PACKAGE PROOF (host-runnable) — assemble via build-package.sh + pack, and prove the tarball
|
||||
// ships the daemon exports-map surfaces under daemon/dist and that no packaged JS still imports
|
||||
// @openrig/daemon;
|
||||
// 3. FULL install+LOAD GATE (opt-in RUN_TESTBED_PACK_GATE=1) — the desk-ruled effect proof on a
|
||||
// CLEAN target: install + run a command that LOADS the daemon. It requires TARGET build tools
|
||||
// because better-sqlite3 is NEVER prebuilt and builds fresh on target (desk caveat 1); on a host
|
||||
@@ -22,47 +23,67 @@ import { dirname, join } from "node:path";
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = join(HERE, "..");
|
||||
const CLI_DIR = join(REPO_ROOT, "packages", "cli");
|
||||
// The 3 daemon exports-map subpaths the cli's runtime value-imports resolve through.
|
||||
// Daemon exports-map surfaces the cli's runtime value-imports resolve to after packaging.
|
||||
const DAEMON_SURFACES = [
|
||||
"node_modules/@openrig/daemon/dist/gateway-protocol-surface.js",
|
||||
"node_modules/@openrig/daemon/dist/gateway-human-registry-surface.js",
|
||||
"node_modules/@openrig/daemon/dist/crash-cart-surface.js",
|
||||
"daemon/dist/gateway-human-registry-surface.js",
|
||||
"daemon/dist/crash-cart-surface.js",
|
||||
"daemon/dist/gateway-slack-surface.js",
|
||||
];
|
||||
|
||||
test("pre-check: @openrig/cli, ships the `rig` bin, and BUNDLES the unpublished @openrig/daemon", () => {
|
||||
test("pre-check: @openrig/cli, ships the `rig` bin, and does not depend on the unpublished @openrig/daemon", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(CLI_DIR, "package.json"), "utf8"));
|
||||
assert.equal(pkg.name, "@openrig/cli", "must pack @openrig/cli, not the root openrig");
|
||||
assert.equal(pkg.bin?.rig, "dist/bin-wrapper.js", "@openrig/cli must declare the `rig` bin");
|
||||
assert.ok(Array.isArray(pkg.bundledDependencies) && pkg.bundledDependencies.includes("@openrig/daemon"),
|
||||
"@openrig/daemon (unpublished) must be in bundledDependencies so npm install uses the bundle, never the 404 registry");
|
||||
for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) {
|
||||
assert.equal(pkg[field]?.["@openrig/daemon"], undefined, `@openrig/daemon must not be in ${field} (it is unpublished)`);
|
||||
}
|
||||
for (const field of ["bundledDependencies", "bundleDependencies"]) {
|
||||
assert.ok(!(pkg[field] ?? []).includes("@openrig/daemon"), `@openrig/daemon must not be in ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("bundle proof: the assembled tarball ships @openrig/daemon's exports-map surfaces (standalone resolution)", () => {
|
||||
// assemble (bundles daemon into <cli>/node_modules/@openrig/daemon) — heavy but no install/native build
|
||||
test("package proof: the tarball ships the daemon surfaces and no packaged JS imports @openrig/daemon", () => {
|
||||
// assemble (stages daemon/, rewrites daemon imports) — heavy but no install/native build
|
||||
execFileSync("bash", [join(REPO_ROOT, "scripts", "build-package.sh")], { cwd: REPO_ROOT, stdio: "inherit" });
|
||||
assert.ok(existsSync(join(CLI_DIR, "node_modules", "@openrig", "daemon", "package.json")),
|
||||
"build-package.sh must assemble a resolver-visible <cli>/node_modules/@openrig/daemon");
|
||||
assert.ok(!existsSync(join(CLI_DIR, "node_modules", "@openrig", "daemon")),
|
||||
"build-package.sh must not leave a bundled <cli>/node_modules/@openrig/daemon copy");
|
||||
|
||||
const tgz = execFileSync("npm", ["pack", "--silent"], { cwd: CLI_DIR, encoding: "utf8" }).trim().split("\n").filter(Boolean).pop();
|
||||
const tgzPath = join(CLI_DIR, tgz);
|
||||
const extract = mkdtempSync(join(tmpdir(), "q2-pack-content-"));
|
||||
try {
|
||||
const listing = execFileSync("tar", ["-tzf", tgzPath], { encoding: "utf8" });
|
||||
assert.match(listing, /^package\/dist\/bin-wrapper\.js$/m, "the tarball must ship the `rig` bin");
|
||||
assert.match(listing, /^package\/node_modules\/@openrig\/daemon\/package\.json$/m, "must BUNDLE @openrig/daemon (its package.json + exports map)");
|
||||
assert.match(listing, /^package\/tui\/dist\/main\.js$/m, "the tarball must ship the `openrig-tui` bin");
|
||||
assert.doesNotMatch(listing, /^package\/node_modules\//m, "the tarball must not bundle node_modules");
|
||||
for (const surface of DAEMON_SURFACES) {
|
||||
assert.match(listing, new RegExp(`^package/${surface.replace(/[.]/g, "\\.")}$`, "m"),
|
||||
`the bundled daemon must ship ${surface} (a value-import resolution target)`);
|
||||
`the shipped daemon must include ${surface} (a value-import resolution target)`);
|
||||
}
|
||||
// better-sqlite3 (native) must NOT be nested under the bundled daemon — it stays hoisted (caveat 1)
|
||||
assert.doesNotMatch(listing, /node_modules\/@openrig\/daemon\/node_modules\/better-sqlite3/,
|
||||
"better-sqlite3 must NOT be nested under the bundled daemon (it stays a hoisted cli dep, builds fresh on target)");
|
||||
const packed = JSON.parse(execFileSync("tar", ["-xzOf", tgzPath, "package/package.json"], { encoding: "utf8" }));
|
||||
assert.equal(packed.dependencies?.["@openrig/daemon"], undefined, "the packed manifest must not depend on @openrig/daemon");
|
||||
assert.equal(packed.bundledDependencies, undefined, "the packed manifest must not bundle dependencies");
|
||||
|
||||
execFileSync("tar", ["-xzf", tgzPath, "-C", extract]);
|
||||
const left = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) walk(path);
|
||||
else if (path.endsWith(".js")) left.push(...remainingDaemonImports(readFileSync(path, "utf8")).map((m) => `${path}: ${m}`));
|
||||
}
|
||||
};
|
||||
walk(join(extract, "package", "dist"));
|
||||
walk(join(extract, "package", "tui", "dist"));
|
||||
assert.deepEqual(left, [], "no packaged cli/tui JS may import @openrig/daemon");
|
||||
} finally {
|
||||
rmSync(tgzPath, { force: true });
|
||||
rmSync(extract, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("install RED (resident, docker-free): a clean install materializes a COMPLETE better-sqlite3 (binding.gyp present)", () => {
|
||||
// Q2 break #5: the BUNDLED @openrig/daemon package.json declared its cli-SUBSET deps
|
||||
// Q2 break #5 (historical: the daemon is no longer bundled): the BUNDLED @openrig/daemon package.json declared its cli-SUBSET deps
|
||||
// (better-sqlite3 + hono/tar/ulid/yaml/@hono/*), so `npm install -g` treated them as bundle-provided
|
||||
// UNDER @openrig/daemon and left an EMPTY <cli>/node_modules/better-sqlite3 (no binding.gyp →
|
||||
// 'prebuild-install: not found' + 'binding.gyp not found'). The pack-CONTENT assertion above
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
// Packaging step (build-package.sh): point the CLI's and TUI's compiled imports of
|
||||
// `@openrig/daemon/<subpath>` at the daemon copy the CLI package already ships in
|
||||
// `daemon/dist`, so the published package has no dependency on the unpublished
|
||||
// `@openrig/daemon` and any package manager can install it (#66).
|
||||
//
|
||||
// Files are parsed with the TypeScript compiler the build already uses, and only real
|
||||
// module specifiers are rewritten: import and export declarations, and `import()` or
|
||||
// `require()` calls with a literal argument. Comments and strings are left alone.
|
||||
// Targets come from packages/daemon/package.json `exports`. The step fails on a parse
|
||||
// error, on a specifier with no exports entry, on a target that was not staged, on a
|
||||
// non-literal `import()`/`require()` argument naming the package, and on any daemon
|
||||
// import left afterwards. Running it twice is a no-op. Source imports and development
|
||||
// resolution are unchanged.
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const PACKAGE = "@openrig/daemon";
|
||||
const isDaemonSpecifier = (value) => value === PACKAGE || value.startsWith(`${PACKAGE}/`);
|
||||
|
||||
// The daemon module specifiers in one file, from its syntax tree.
|
||||
function daemonSpecifiers(text, file) {
|
||||
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
||||
if (source.parseDiagnostics.length > 0) {
|
||||
throw new Error(`${file}: cannot parse (${source.parseDiagnostics[0].messageText})`);
|
||||
}
|
||||
const found = [];
|
||||
const visit = (node) => {
|
||||
let literal;
|
||||
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
|
||||
literal = node.moduleSpecifier;
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| (ts.isIdentifier(node.expression) && node.expression.text === "require"))) {
|
||||
const argument = node.arguments[0];
|
||||
if (argument && ts.isStringLiteralLike(argument)) literal = argument;
|
||||
else if (argument && argument.getText(source).includes(PACKAGE)) {
|
||||
throw new Error(`${file}: cannot rewrite non-literal module argument ${argument.getText(source)}`);
|
||||
}
|
||||
}
|
||||
if (literal && ts.isStringLiteralLike(literal) && isDaemonSpecifier(literal.text)) {
|
||||
found.push({ value: literal.text, start: literal.getStart(source), end: literal.getEnd() });
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function loadSubpathTargets(daemonPackageJsonPath) {
|
||||
const exportsMap = JSON.parse(readFileSync(daemonPackageJsonPath, "utf8")).exports ?? {};
|
||||
const targets = new Map();
|
||||
for (const [key, value] of Object.entries(exportsMap)) {
|
||||
const target = typeof value === "string" ? value : value?.import;
|
||||
if (typeof target === "string") targets.set(key, target);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Rewrites one file's text. `targetFor(subpath)` returns the absolute target path or throws.
|
||||
export function rewriteSource(text, file, targetFor) {
|
||||
const found = daemonSpecifiers(text, file);
|
||||
let output = text;
|
||||
// Replace from the end so earlier offsets stay valid; keep each literal's own quotes.
|
||||
for (const { value, start, end } of [...found].reverse()) {
|
||||
const target = targetFor(value.slice(PACKAGE.length), file);
|
||||
let specifier = relative(dirname(file), target).split(sep).join("/");
|
||||
if (!specifier.startsWith(".")) specifier = `./${specifier}`;
|
||||
output = `${output.slice(0, start + 1)}${specifier}${output.slice(end - 1)}`;
|
||||
}
|
||||
return { output, count: found.length };
|
||||
}
|
||||
|
||||
export function remainingDaemonImports(text, file = "<input>") {
|
||||
return daemonSpecifiers(text, file).map(({ value }) => value);
|
||||
}
|
||||
|
||||
function javascriptFiles(root) {
|
||||
if (!existsSync(root)) return [];
|
||||
const files = [];
|
||||
for (const entry of readdirSync(root)) {
|
||||
const path = join(root, entry);
|
||||
if (statSync(path).isDirectory()) files.push(...javascriptFiles(path));
|
||||
else if (path.endsWith(".js")) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function rewriteDaemonImports({ cliDir, daemonPackageJsonPath }) {
|
||||
const targets = loadSubpathTargets(daemonPackageJsonPath);
|
||||
const stagedDaemon = join(cliDir, "daemon");
|
||||
const targetFor = (subpath, file) => {
|
||||
const target = targets.get(`.${subpath}`);
|
||||
if (!target) {
|
||||
throw new Error(`${file}: @openrig/daemon${subpath} has no entry in the daemon exports map`);
|
||||
}
|
||||
const absolute = resolve(stagedDaemon, target);
|
||||
if (!existsSync(absolute)) {
|
||||
throw new Error(`${file}: @openrig/daemon${subpath} maps to ${target}, which is not staged at ${absolute}`);
|
||||
}
|
||||
return absolute;
|
||||
};
|
||||
|
||||
let rewritten = 0;
|
||||
let files = 0;
|
||||
const roots = [join(cliDir, "dist"), join(cliDir, "tui", "dist")];
|
||||
for (const file of roots.flatMap(javascriptFiles)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
const { output, count } = rewriteSource(text, file, targetFor);
|
||||
if (count === 0) continue;
|
||||
writeFileSync(file, output);
|
||||
rewritten += count;
|
||||
files += 1;
|
||||
}
|
||||
|
||||
const left = roots.flatMap(javascriptFiles).flatMap((file) =>
|
||||
remainingDaemonImports(readFileSync(file, "utf8"), file).map((found) => `${file}: ${found}`));
|
||||
if (left.length > 0) throw new Error(`daemon imports left after rewrite:\n${left.join("\n")}`);
|
||||
return { rewritten, files };
|
||||
}
|
||||
|
||||
// Run only when executed directly. Compare real file paths, not a hand-built URL:
|
||||
// import.meta.url is percent-encoded (spaces, "#") and names the symlink-resolved file.
|
||||
function invokedDirectly() {
|
||||
try {
|
||||
return Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === realpathSync(process.argv[1]);
|
||||
} catch {
|
||||
return false; // argv[1] is not an existing file, so this module was imported, not run
|
||||
}
|
||||
}
|
||||
|
||||
if (invokedDirectly()) {
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
try {
|
||||
const { rewritten, files } = rewriteDaemonImports({
|
||||
cliDir: join(repoRoot, "packages", "cli"),
|
||||
daemonPackageJsonPath: join(repoRoot, "packages", "daemon", "package.json"),
|
||||
});
|
||||
console.log(`Rewrote ${rewritten} @openrig/daemon import(s) in ${files} file(s) to the shipped daemon/dist.`);
|
||||
} catch (error) {
|
||||
console.error(`rewrite-daemon-imports: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { rewriteDaemonImports } from "./rewrite-daemon-imports.mjs";
|
||||
|
||||
// A miniature staged CLI package: daemon exports map, staged daemon/dist and compiled CLI/TUI JS.
|
||||
function fixture(files) {
|
||||
const root = mkdtempSync(join(tmpdir(), "rewrite-daemon-imports-"));
|
||||
const daemonPackageJsonPath = join(root, "daemon-src", "package.json");
|
||||
const cliDir = join(root, "cli");
|
||||
const write = (path, text) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
};
|
||||
write(daemonPackageJsonPath, JSON.stringify({
|
||||
name: "@openrig/daemon",
|
||||
exports: {
|
||||
"./attention": { types: "./dist/attention-surface.d.ts", import: "./dist/attention-surface.js" },
|
||||
"./crash-cart": { types: "./dist/crash-cart-surface.js", import: "./dist/crash-cart-surface.js" },
|
||||
"./missing-file": { import: "./dist/not-staged.js" },
|
||||
},
|
||||
}));
|
||||
write(join(cliDir, "daemon", "dist", "attention-surface.js"), "export const a = 1;\n");
|
||||
write(join(cliDir, "daemon", "dist", "crash-cart-surface.js"), "export const c = 1;\n");
|
||||
for (const [path, text] of Object.entries(files)) write(join(cliDir, path), text);
|
||||
const read = (path) => readFileSync(join(cliDir, path), "utf8");
|
||||
return { root, cliDir, daemonPackageJsonPath, read };
|
||||
}
|
||||
|
||||
test("rewrites static, side-effect, re-export and dynamic imports in nested CLI and TUI paths", () => {
|
||||
const f = fixture({
|
||||
"dist/commands/attention.js": [
|
||||
'import { a } from "@openrig/daemon/attention";',
|
||||
"import '@openrig/daemon/crash-cart';",
|
||||
'export { a as b } from "@openrig/daemon/attention";',
|
||||
'const m = await import("@openrig/daemon/crash-cart");',
|
||||
"",
|
||||
].join("\n"),
|
||||
"dist/bin-wrapper.js": 'const c = await import( "@openrig/daemon/crash-cart" );\n',
|
||||
"tui/dist/views/health.js": 'import { a } from "@openrig/daemon/attention";\n',
|
||||
});
|
||||
try {
|
||||
const result = rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath });
|
||||
assert.deepEqual(result, { rewritten: 6, files: 3 });
|
||||
assert.equal(f.read("dist/commands/attention.js"), [
|
||||
'import { a } from "../../daemon/dist/attention-surface.js";',
|
||||
"import '../../daemon/dist/crash-cart-surface.js';",
|
||||
'export { a as b } from "../../daemon/dist/attention-surface.js";',
|
||||
'const m = await import("../../daemon/dist/crash-cart-surface.js");',
|
||||
"",
|
||||
].join("\n"));
|
||||
assert.equal(f.read("dist/bin-wrapper.js"), 'const c = await import( "../daemon/dist/crash-cart-surface.js" );\n');
|
||||
assert.equal(f.read("tui/dist/views/health.js"), 'import { a } from "../../../daemon/dist/attention-surface.js";\n');
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves prose, comments and non-import strings unchanged", () => {
|
||||
const text = [
|
||||
"// LAZY-imports the narrow @openrig/daemon/crash-cart surface.",
|
||||
"/** the bytes live in `@openrig/daemon`. */",
|
||||
'const label = "@openrig/daemon/attention";',
|
||||
"",
|
||||
].join("\n");
|
||||
const f = fixture({ "dist/prose.js": text });
|
||||
try {
|
||||
assert.deepEqual(rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }), { rewritten: 0, files: 0 });
|
||||
assert.equal(f.read("dist/prose.js"), text);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails on a subpath with no exports entry, including the bare package name", () => {
|
||||
for (const specifier of ["@openrig/daemon/not-exported", "@openrig/daemon"]) {
|
||||
const f = fixture({ "dist/x.js": `import { x } from "${specifier}";\n` });
|
||||
try {
|
||||
assert.throws(
|
||||
() => rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }),
|
||||
/has no entry in the daemon exports map/,
|
||||
);
|
||||
assert.equal(f.read("dist/x.js"), `import { x } from "${specifier}";\n`);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("fails when the mapped target was not staged", () => {
|
||||
const f = fixture({ "dist/x.js": 'import { x } from "@openrig/daemon/missing-file";\n' });
|
||||
try {
|
||||
assert.throws(
|
||||
() => rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }),
|
||||
/which is not staged/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a second run is a no-op", () => {
|
||||
const f = fixture({ "dist/x.js": 'import { a } from "@openrig/daemon/attention";\n' });
|
||||
try {
|
||||
rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath });
|
||||
const once = f.read("dist/x.js");
|
||||
assert.deepEqual(rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }), { rewritten: 0, files: 0 });
|
||||
assert.equal(f.read("dist/x.js"), once);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves import-shaped text in comments, strings and templates unchanged", () => {
|
||||
const text = [
|
||||
'// import x from "@openrig/daemon/attention";',
|
||||
"const example = 'import x from \"@openrig/daemon/attention\"';",
|
||||
'const other = `import x from "@openrig/daemon/attention"`;',
|
||||
"export {};",
|
||||
"",
|
||||
].join("\n");
|
||||
const f = fixture({ "dist/prose-imports.js": text });
|
||||
try {
|
||||
assert.deepEqual(rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }), { rewritten: 0, files: 0 });
|
||||
assert.equal(f.read("dist/prose-imports.js"), text);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rewrites real imports that carry comments or use a template literal", () => {
|
||||
const f = fixture({
|
||||
"dist/x.js": [
|
||||
'const a = import /* kept */ ("@openrig/daemon/attention");',
|
||||
'import b from /* kept */ "@openrig/daemon/attention";',
|
||||
"const c = import(`@openrig/daemon/crash-cart`);",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }), { rewritten: 3, files: 1 });
|
||||
assert.equal(f.read("dist/x.js"), [
|
||||
'const a = import /* kept */ ("../daemon/dist/attention-surface.js");',
|
||||
'import b from /* kept */ "../daemon/dist/attention-surface.js";',
|
||||
"const c = import(`../daemon/dist/crash-cart-surface.js`);",
|
||||
"",
|
||||
].join("\n"));
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails on an unmapped subpath hidden behind a comment", () => {
|
||||
const f = fixture({ "dist/x.js": 'const x = import /* kept */ ("@openrig/daemon/unknown");\n' });
|
||||
try {
|
||||
assert.throws(
|
||||
() => rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }),
|
||||
/has no entry in the daemon exports map/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails on a non-literal module argument naming the package, and on unparseable files", () => {
|
||||
const cases = [
|
||||
['const x = import("@openrig/daemon/" + name);\n', /non-literal module argument/],
|
||||
["const x = import(`@openrig/daemon/${name}`);\n", /non-literal module argument/],
|
||||
['const x = require("@openrig/daemon/" + name);\n', /non-literal module argument/],
|
||||
['import { from "@openrig/daemon/attention";\n', /cannot parse/],
|
||||
];
|
||||
for (const [text, expected] of cases) {
|
||||
const f = fixture({ "dist/x.js": text });
|
||||
try {
|
||||
assert.throws(() => rewriteDaemonImports({ cliDir: f.cliDir, daemonPackageJsonPath: f.daemonPackageJsonPath }), expected);
|
||||
assert.equal(f.read("dist/x.js"), text);
|
||||
} finally {
|
||||
rmSync(f.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Direct execution (`node scripts/rewrite-daemon-imports.mjs`, as build-package.sh runs it) from
|
||||
// miniature repositories whose paths need percent-encoding or pass through a symlink.
|
||||
const SCRIPT = fileURLToPath(new URL("./rewrite-daemon-imports.mjs", import.meta.url));
|
||||
const REPO_NODE_MODULES = fileURLToPath(new URL("../node_modules", import.meta.url));
|
||||
|
||||
function miniatureRepo(parent, name, compiled) {
|
||||
const root = join(parent, name);
|
||||
const write = (path, text) => {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true });
|
||||
writeFileSync(join(root, path), text);
|
||||
};
|
||||
mkdirSync(join(root, "scripts"), { recursive: true });
|
||||
copyFileSync(SCRIPT, join(root, "scripts", "rewrite-daemon-imports.mjs"));
|
||||
symlinkSync(REPO_NODE_MODULES, join(root, "node_modules")); // the build's TypeScript, read-only
|
||||
write("packages/daemon/package.json", JSON.stringify({ exports: { "./attention": { import: "./dist/attention-surface.js" } } }));
|
||||
write("packages/cli/daemon/dist/attention-surface.js", "export const a = 1;\n");
|
||||
write("packages/cli/dist/x.js", compiled);
|
||||
return root;
|
||||
}
|
||||
|
||||
const runDirect = (root) => spawnSync(process.execPath, [join(root, "scripts", "rewrite-daemon-imports.mjs")],
|
||||
{ encoding: "utf8", env: { PATH: process.env.PATH } });
|
||||
|
||||
test("runs when executed directly from paths with spaces, # or a symlinked parent", () => {
|
||||
const parent = realpathSync(mkdtempSync(join(tmpdir(), "rewrite-direct-")));
|
||||
const linked = join(realpathSync(tmpdir()), `rewrite-direct-link-${process.pid}`);
|
||||
try {
|
||||
symlinkSync(parent, linked);
|
||||
const cases = [["plain", parent], ["with space", parent], ["with#hash", parent], ["space and #hash", parent], ["via link", linked]];
|
||||
for (const [name, base] of cases) {
|
||||
const root = miniatureRepo(parent, name, 'import { a } from "@openrig/daemon/attention";\n');
|
||||
const result = runDirect(join(base, name));
|
||||
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
||||
assert.match(result.stdout, /Rewrote 1 @openrig\/daemon import\(s\) in 1 file\(s\)/, name);
|
||||
assert.equal(readFileSync(join(root, "packages/cli/dist/x.js"), "utf8"), 'import { a } from "../daemon/dist/attention-surface.js";\n', name);
|
||||
}
|
||||
} finally {
|
||||
rmSync(linked, { force: true });
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("still fails the build when executed directly with an unmapped import", () => {
|
||||
const parent = realpathSync(mkdtempSync(join(tmpdir(), "rewrite-direct-")));
|
||||
try {
|
||||
const compiled = 'import { x } from "@openrig/daemon/not-exported";\n';
|
||||
const root = miniatureRepo(parent, "space and #hash", compiled);
|
||||
const result = runDirect(root);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /has no entry in the daemon exports map/);
|
||||
assert.equal(readFileSync(join(root, "packages/cli/dist/x.js"), "utf8"), compiled);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user