From bdd96c6b3efcb54c44dfee25a0df9d9a768bda0c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:19:35 -0400 Subject: [PATCH 1/2] fix(cli): inline string-width into the CLI bundle and gate packaged module resolution The packaged ade CLI resolves tsup externals through NODE_PATH into the desktop package's production tree. cli.ts now reaches tuiClient/displayWidth at module scope, which requires string-width, a package the desktop app does not ship, so v1.2.75's release run failed its packaged `ade --help` smoke on mac and Windows. Inline string-width, and make verify-built-cli.mjs run --help with only the desktop production tree on NODE_PATH so this class fails at build time instead of after notarization. Co-Authored-By: Claude Fable 5.1 --- apps/ade-cli/scripts/verify-built-cli.mjs | 85 +++++++++++++++++++++++ apps/ade-cli/tsup.config.ts | 7 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index 014ff8c3d0..4fcf76ed89 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -71,6 +71,90 @@ async function assertIsolatedTuiHelp() { } } +/** + * Run `cli.cjs --help` the way the packaged app resolves it. + * + * tsup externalizes every package dependency, and the packaged CLI sits at + * Resources/ade-cli/dist/cli.cjs with no node_modules beside it, so its + * externals resolve ONLY through NODE_PATH into the desktop package's + * production tree (apps/desktop/src/main/services/runtime/packagedNodePath.ts). + * This checkout hoists dev-only packages next to the production ones, so an + * unshipped module-scope require passes every in-checkout run here and only + * fails inside release-core's packaged smoke, after signing and notarization. + * v1.2.75's first release run died exactly that way on `string-width`. + * + * So: copy the bundle into a directory with no node_modules, expose the + * desktop package's production tree — the flattened top-level packages + * electron-builder ships into app.asar, read from the lock file — in a + * SIBLING directory on NODE_PATH, and run --help. + * + * Scope: this proves module-scope requires on the --help path only. A deeper + * command path can still reach an unshipped external, and the packaged CLI + * runs under Electron's Node rather than the CI Node used here; a green gate + * is a preflight, not proof of the packaged runtime. + */ +async function assertPackagedResolutionCliHelp() { + const desktopRoot = path.join(packageRoot, "..", "desktop"); + const lock = JSON.parse(await fs.readFile(path.join(desktopRoot, "package-lock.json"), "utf8")); + const productionNames = Object.keys(lock.packages ?? {}) + .filter((key) => /^node_modules\/(@[^/]+\/)?[^/]+$/.test(key) && !lock.packages[key].dev) + .map((key) => key.slice("node_modules/".length)); + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "ade-cli-packaged-resolution-")); + const createdLinks = []; + try { + const nodeModules = path.join(tempRoot, "shipped-node-modules"); + const appDir = path.join(tempRoot, "app"); + await fs.mkdir(appDir, { recursive: true }); + const notInstalled = []; + for (const name of productionNames) { + const source = path.join(desktopRoot, "node_modules", name); + try { + await fs.access(source); + } catch { + notInstalled.push(name); + continue; + } + const target = path.join(nodeModules, name); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.symlink(source, target, process.platform === "win32" ? "junction" : "dir"); + createdLinks.push(target); + } + const isolatedCliPath = path.join(appDir, "cli.cjs"); + await fs.copyFile(cliPath, isolatedCliPath); + let stdout = ""; + try { + ({ stdout } = await execFileAsync(process.execPath, [isolatedCliPath, "--help"], { + cwd: appDir, + env: { ...process.env, NODE_PATH: nodeModules }, + })); + } catch (error) { + const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr) : ""; + const missing = stderr.match(/Cannot find module '([^']+)'/)?.[1]; + const bare = missing?.replace(/^(@[^/]+\/[^/]+|[^/]+).*$/, "$1"); + let detail = `: ${stderr.trim().split("\n")[0]}`; + if (missing && bare && notInstalled.includes(bare)) { + detail = `: "${bare}" is a shipped production dependency that is not installed in this checkout; ` + + "run npm ci in apps/desktop and rebuild."; + } else if (missing) { + detail = `: bare require("${missing}") is reached at module scope but is not in apps/desktop's ` + + "production dependency tree. Inline it in tsup noExternal (see string-width), or make it a " + + "production dependency of the desktop package."; + } + throw new Error(`[ade-cli:build] dist/cli.cjs --help failed under packaged module resolution${detail}`); + } + if (!stdout.includes("Agent-focused command-line interface for ADE")) { + throw new Error("[ade-cli:build] packaged-resolution CLI help output did not include the ADE banner text"); + } + } finally { + // Remove the links themselves before the tree, so no rm implementation + // can be tempted to descend into apps/desktop/node_modules through one. + for (const link of createdLinks) { + await fs.unlink(link).catch(() => {}); + } + await fs.rm(tempRoot, { recursive: true, force: true }); + } +} + const contents = await fs.readFile(cliPath, "utf8"); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); const expectedVersion = process.env.ADE_CLI_VERSION?.trim() || packageJson.version; @@ -122,6 +206,7 @@ if (process.platform !== "win32" && (stat.mode & 0o111) === 0) { await runHelp(process.execPath, [cliPath, "--help"]); await assertVersion(process.execPath, [cliPath, "--version"], expectedVersion); await assertIsolatedTuiHelp(); +await assertPackagedResolutionCliHelp(); if (process.platform !== "win32") { await runHelp(cliPath, ["--help"]); diff --git a/apps/ade-cli/tsup.config.ts b/apps/ade-cli/tsup.config.ts index 7241f8b17b..b3dc9531eb 100644 --- a/apps/ade-cli/tsup.config.ts +++ b/apps/ade-cli/tsup.config.ts @@ -54,7 +54,12 @@ export default defineConfig([ // @opencode-ai/sdk is ESM-only (no "require" export); force-inline it so // the CJS runtime bundle does not emit a bare require() that packaged // Electron-as-node cannot resolve. - noExternal: ["@factory/droid-sdk", "@opencode-ai/sdk", "yaml"], + // string-width: cli.ts reaches it at module scope through + // tuiClient/displayWidth (table formatters); the packaged CLI resolves + // externals through NODE_PATH into apps/desktop's production tree, which + // does not ship it (v1.2.75 release smoke). verify-built-cli.mjs guards + // this class. + noExternal: ["@factory/droid-sdk", "@opencode-ai/sdk", "yaml", "string-width"], outExtension: () => ({ js: ".cjs" }), From 3924e1e8a9caabd76706b879e20a229b0f518569 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:34:38 -0400 Subject: [PATCH 2/2] fix(cli): let the packaged-resolution gate run without the desktop tree installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-binary jobs build the CLI without installing apps/desktop, so the gate saw every shipped package as not installed. Resolve names from the desktop tree first and this package's own copy second; when a shipped package is installed in neither, skip with a notice instead of failing — the packaging jobs install the desktop app and run the same check. Co-Authored-By: Claude Fable 5.1 --- apps/ade-cli/scripts/verify-built-cli.mjs | 33 ++++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/ade-cli/scripts/verify-built-cli.mjs b/apps/ade-cli/scripts/verify-built-cli.mjs index 4fcf76ed89..1e96e65b80 100644 --- a/apps/ade-cli/scripts/verify-built-cli.mjs +++ b/apps/ade-cli/scripts/verify-built-cli.mjs @@ -105,12 +105,24 @@ async function assertPackagedResolutionCliHelp() { const nodeModules = path.join(tempRoot, "shipped-node-modules"); const appDir = path.join(tempRoot, "app"); await fs.mkdir(appDir, { recursive: true }); + // The shipped NAMES come from the desktop lock file. The bytes behind a + // name may come from the desktop tree or, when a job builds the CLI + // without installing the desktop app (the runtime-binary jobs), from this + // package's own copy. A dev-only hoisted package is never exposed either + // way, because the name list is production-only. const notInstalled = []; for (const name of productionNames) { - const source = path.join(desktopRoot, "node_modules", name); - try { - await fs.access(source); - } catch { + let source = null; + for (const candidate of [path.join(desktopRoot, "node_modules", name), path.join(packageRoot, "node_modules", name)]) { + try { + await fs.access(candidate); + source = candidate; + break; + } catch { + // try the next location + } + } + if (!source) { notInstalled.push(name); continue; } @@ -133,9 +145,16 @@ async function assertPackagedResolutionCliHelp() { const bare = missing?.replace(/^(@[^/]+\/[^/]+|[^/]+).*$/, "$1"); let detail = `: ${stderr.trim().split("\n")[0]}`; if (missing && bare && notInstalled.includes(bare)) { - detail = `: "${bare}" is a shipped production dependency that is not installed in this checkout; ` + - "run npm ci in apps/desktop and rebuild."; - } else if (missing) { + // Neither tree has this shipped package, so the gate cannot be + // faithful here; the packaging jobs install the desktop app and run + // this same check with the full tree. + console.warn( + `[ade-cli:build] skipping packaged-resolution check: shipped dependency "${bare}" is not installed ` + + "in apps/desktop or apps/ade-cli on this host", + ); + return; + } + if (missing) { detail = `: bare require("${missing}") is reached at module scope but is not in apps/desktop's ` + "production dependency tree. Inline it in tsup noExternal (see string-width), or make it a " + "production dependency of the desktop package.";