diff --git a/README.md b/README.md index cffc8ce99..dc85b520d 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/package-lock.json b/package-lock.json index 4aa89239f..24abcde03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index ae90f8c91..f947df753 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/scripts/build-package.sh b/scripts/build-package.sh index 7d2201f2e..016497733 100755 --- a/scripts/build-package.sh +++ b/scripts/build-package.sh @@ -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: -# /node_modules/@openrig/daemon. (The /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 /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 /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 /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/. 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 ===" diff --git a/scripts/build-testbed-cli-pack.test.mjs b/scripts/build-testbed-cli-pack.test.mjs index fe55bf120..beefd53ab 100644 --- a/scripts/build-testbed-cli-pack.test.mjs +++ b/scripts/build-testbed-cli-pack.test.mjs @@ -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 /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 /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 /node_modules/@openrig/daemon"); + assert.ok(!existsSync(join(CLI_DIR, "node_modules", "@openrig", "daemon")), + "build-package.sh must not leave a bundled /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 /node_modules/better-sqlite3 (no binding.gyp → // 'prebuild-install: not found' + 'binding.gyp not found'). The pack-CONTENT assertion above diff --git a/scripts/rewrite-daemon-imports.mjs b/scripts/rewrite-daemon-imports.mjs new file mode 100644 index 000000000..d3eb01bbb --- /dev/null +++ b/scripts/rewrite-daemon-imports.mjs @@ -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/` 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 = "") { + 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; + } +} diff --git a/scripts/rewrite-daemon-imports.test.mjs b/scripts/rewrite-daemon-imports.test.mjs new file mode 100644 index 000000000..00320408c --- /dev/null +++ b/scripts/rewrite-daemon-imports.test.mjs @@ -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 }); + } +});