diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccfbd15d..7f2dd32f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,26 @@ jobs: with: { node-version: 20 } - name: drift gate — overlay/manifest sync (committed state; re-derivation when the fork is reachable) run: node packages/app-bundle/scripts/drift_gate.mjs + bundle-build-gate: + # #643: the CLI bundles (packages/amico-run/dist/*.js) are gitignored build + # artifacts — the deployed verb-router bundle sat 46 days stale because + # nothing gated the build. This lane builds every declared bundle from + # current source on every push/PR: a broken entry or unresolvable import + # fails the build step (esbuild exits non-zero), and a build that silently + # drops or truncates a bundle reds the assert step, which also smoke-runs + # the built verb router (--help must exit 0) — the vitest suite transpiles + # and never executes the shipped artifact, so this is where a + # builds-but-dies bundle is caught. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 # version pinned by packageManager in package.json + - uses: actions/setup-node@v7 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @amicode/amico-run build + - name: bundle-gate — every declared bundle built + the verb-router smoke answers (#643) + run: node packages/amico-run/scripts/assert_built_bundles.mjs fast: runs-on: ubuntu-latest steps: diff --git a/packages/amico-run/scripts/assert_built_bundles.d.mts b/packages/amico-run/scripts/assert_built_bundles.d.mts new file mode 100644 index 00000000..f265f569 --- /dev/null +++ b/packages/amico-run/scripts/assert_built_bundles.d.mts @@ -0,0 +1,21 @@ +// Typed surface of scripts/assert_built_bundles.mjs for the test suite (the +// amico-run tsconfig includes test/, unlike the extension package's — hence +// this declaration rather than an untyped import). + +export interface DeclaredDistBundle { + /** bin key (npm `bin` map) or shadow-bin name (`amicode.shadowBins`) */ + name: string; + /** dist file basename, e.g. "amico.js" (launcher basename + .js) */ + dist: string; +} + +export interface BundleGateRow { + bin: string; + check: string; + ok: boolean; + detail: string; +} + +export function declaredDistBundles(pkgDir?: string): DeclaredDistBundle[]; + +export function runBundleGate(opts?: { pkgDir?: string }): Promise<{ ok: boolean; results: BundleGateRow[] }>; diff --git a/packages/amico-run/scripts/assert_built_bundles.mjs b/packages/amico-run/scripts/assert_built_bundles.mjs new file mode 100644 index 00000000..956c443b --- /dev/null +++ b/packages/amico-run/scripts/assert_built_bundles.mjs @@ -0,0 +1,134 @@ +// The #643 bundle-build gate: the CLI package's dist bundles are gitignored +// build artifacts, and the deployed verb-router bundle went 46 days stale +// because nothing gated the build — a broken entry or unresolvable import +// only failed on whichever machine last tried to build (the incident: the +// checkout's node_modules had drifted, the local build died, the stale dist +// kept shipping). CI calls this after `pnpm --filter @amicode/amico-run build` +// (see .github/workflows/ci.yml bundle-build-gate), and the vitest suite +// exercises both directions (test/bundle_gate.test.ts). +// +// What it asserts, per DECLARED bin of the CLI package (packages/amico-run +// package.json `bin` map + `amicode.shadowBins` — the single source of truth, +// the same map the extension staging and scripts/assert_packaged_cli.mjs +// re-read): +// 1. the build produced dist/.js, non-empty (a declared-but- +// unbuilt bin reds — half-built sets are the stale-bundle signature); +// 2. the built verb router still ANSWERS: `amico --help` exits 0 printing +// the usage surface. The unit suite never proves this — vitest transpiles +// instead of bundling, so the shipped artifact is only executed here and +// by the packaging gates (the createRequire/yaml seam shipped exactly +// this way: every test green, the binary dead on first import). +// +// Usage: node scripts/assert_built_bundles.mjs [--pkg-dir ] +// --pkg-dir the amico-run package dir to gate (default: this script's ../) +import { execFile } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Declared bins → dist file layout. Staging convention (extension + * esbuild.config.mjs): bin key K ships from launcher basename B as + * dist/.js — for both the npm `bin` map and the `amicode.shadowBins` + * map (whose key and launcher basename coincide, e.g. `gh`). */ +export function declaredDistBundles(pkgDir = PKG_ROOT) { + const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")); + const bin = pkg.bin; + if (!bin || typeof bin !== "object" || Object.keys(bin).length === 0) + throw new Error(`${pkgDir}/package.json: no \`bin\` map — nothing to gate is a failure, not a pass`); + const fromMap = Object.entries(bin).map(([name, launcherPath]) => ({ + name, + dist: `${basename(String(launcherPath))}.js`, + })); + const shadow = Object.entries(pkg.amicode?.shadowBins ?? {}).map(([name, launcherPath]) => ({ + name, + dist: `${basename(String(launcherPath))}.js`, + })); + // dedup by dist path (a shadow key colliding with a declared basename) + const seen = new Set(); + return [...fromMap, ...shadow].filter((b) => (seen.has(b.dist) ? false : (seen.add(b.dist), true))); +} + +/** The verb-router smoke: `amico --help` is the side-effect-free invocation — + * pure usage print before any env or surface read (src/amico.ts main). */ +const ROUTER_BIN = "amico"; + +function execCapture(file, args) { + return new Promise((resolveP) => { + execFile(file, args, { timeout: 30_000, encoding: "utf8" }, (err, stdout, stderr) => { + const code = err ? (typeof err.code === "number" ? err.code : -1) : 0; + resolveP({ code, stdout: stdout ?? "", stderr: stderr ?? "" }); + }); + }); +} + +/** Run the gate. Returns { ok, results } with one row per check — { bin, + * check, ok, detail } — never throwing on a failing bundle (the caller gets + * the full picture, mirroring assert_packaged_cli.mjs). */ +export async function runBundleGate({ pkgDir = PKG_ROOT } = {}) { + const results = []; + const push = (bin, check, detail) => + results.push({ bin, check, ok: detail === null, detail: detail ?? "ok" }); + const distDir = join(pkgDir, "dist"); + const bundles = declaredDistBundles(pkgDir); + + // 1. every declared bundle built + non-empty + for (const b of bundles) { + const p = join(distDir, b.dist); + if (!existsSync(p)) { + push(b.name, "dist bundle built", `missing from the build output: ${p}`); + continue; + } + if (statSync(p).size === 0) { + push(b.name, "dist bundle built", `empty bundle (zero bytes): ${p}`); + continue; + } + push(b.name, "dist bundle built", null); + } + + // 2. the verb-router smoke — only meaningful when the router built + const router = bundles.find((b) => b.name === ROUTER_BIN); + if (!router) { + push(ROUTER_BIN, "verb-router smoke (--help)", `no "${ROUTER_BIN}" bin declared — the verb router is the gate's subject`); + } else if (existsSync(join(distDir, router.dist))) { + const r = await execCapture(process.execPath, [join(distDir, router.dist), "--help"]); + if (r.code !== 0) push(ROUTER_BIN, "verb-router smoke (--help)", `amico --help exited ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim().split("\n")[0]}` : ""}`); + else if (!/usage:/.test(r.stdout)) push(ROUTER_BIN, "verb-router smoke (--help)", "--help exited 0 but printed no usage surface — wrong bundle?"); + else push(ROUTER_BIN, "verb-router smoke (--help)", null); + } + return { ok: results.every((r) => r.ok), results }; +} + +async function main(argv) { + let pkgDir = PKG_ROOT; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--pkg-dir") pkgDir = resolve(argv[++i]); + else { + console.error(`assert_built_bundles: unknown arg ${argv[i]} (usage: [--pkg-dir ])`); + return 2; + } + } + console.log(`[bundle-gate] package dir: ${pkgDir}`); + const { ok, results } = await runBundleGate({ pkgDir }); + for (const r of results) + console.log(`[bundle-gate] ${r.ok ? "PASS" : "FAIL"} ${r.bin.padEnd(20)} ${r.check}${r.ok ? "" : ` — ${r.detail}`}`); + console.log( + ok + ? `[bundle-gate] OK — every declared bundle is built and the verb router answers` + : `[bundle-gate] FAILED — the bundle build is stale or incomplete (see FAIL lines)`, + ); + return ok ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then( + (c) => { + process.exitCode = c; + }, + (e) => { + console.error(`[bundle-gate] ${e.message}`); + process.exitCode = 1; + }, + ); +} diff --git a/packages/amico-run/src/upgrade.ts b/packages/amico-run/src/upgrade.ts index 579ec911..fd5d9bc1 100644 --- a/packages/amico-run/src/upgrade.ts +++ b/packages/amico-run/src/upgrade.ts @@ -2,7 +2,8 @@ // D2): the four upgrade chains as receipt-emitting, idempotent runbooks. // // amico upgrade server-binary [--skip-build ] [--ref ] [--kick-command ] -// [--health-command ] [--no-kick] [--root-server ] +// [--health-command ] [--no-kick] [--dist-build-command ] +// [--root-server ] // amico upgrade extension [--package-command ] [--install-command ] // [--root-vscext ] [--root-repo-amicode ] // amico upgrade agents [--root-config ] [--root-staging ] [--root-repo-amicode ] @@ -28,9 +29,10 @@ // and matches it field-for-field. // // STUB COMMAND CONTRACT (the hermetic seams; tokens + env, both available): -// {frozen} {running} {prev} {server} {version} {vsix} {repo} (path tokens) +// {frozen} {running} {prev} {server} {version} {vscext} {vsix} {repo} {amicoRun} // AMICO_UPGRADE_FROZEN_BIN / _RUNNING_BIN / _PREV_BIN / _ROOT_SERVER / // AMICO_UPGRADE_ROOT_VSCEXT / _TARGET_VERSION / _REPO_AMICODE / _VSIX / +// AMICO_UPGRADE_AMICO_RUN_DIR / // AMICO_UPGRADE_PHASE ∈ kick | verify | verify-retry | restore-kick | restore // // The KICK STUB's contract (spec D2): make the health command succeed AND @@ -45,7 +47,7 @@ import { execFile } from "node:child_process"; import { mkdir, open, readFile, rm, copyFile, readdir, stat, writeFile, chmod } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { surfaceInventory, newestExtensionDir, @@ -88,7 +90,7 @@ export interface UpgradeReceipt { const USAGE = "amico upgrade [--root-…] " + "[--skip-build

] [--ref ] [--kick-command ] [--health-command ] [--no-kick] " + - "[--package-command ] [--install-command ] [--root-receipts

]"; + "[--package-command ] [--install-command ] [--dist-build-command ] [--root-receipts ]"; const SURFACES: readonly UpgradeSurface[] = ["server-binary", "extension", "agents", "skills"]; @@ -175,6 +177,7 @@ const TOKEN_ENV: Record = { vscext: "AMICO_UPGRADE_ROOT_VSCEXT", vsix: "AMICO_UPGRADE_VSIX", repo: "AMICO_UPGRADE_REPO_AMICODE", + amicoRun: "AMICO_UPGRADE_AMICO_RUN_DIR", }; async function runShell(cmd: string, opts: ShellOpts = {}): Promise<{ code: number; stdout: string; stderr: string }> { @@ -243,6 +246,7 @@ export function parseUpgradeArgs(argv: string[]): { ok: true; args: ParsedVerbAr "--verify-timeout-ms", "--package-command", "--install-command", + "--dist-build-command", "--root-receipts", ...Object.keys(ROOT_FLAGS), "--running-binary", @@ -740,7 +744,7 @@ const extensionVerb = (argv: string[]): Promise => }); /** The stub/live command env contract (see the module header). */ -function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix?: string; frozen?: string; running?: string; prev?: string; server?: string; phase?: string }): Record { +function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix?: string; frozen?: string; running?: string; prev?: string; server?: string; phase?: string; amicoRun?: string }): Record { const env: Record = {}; if (vars.repo !== undefined) env.AMICO_UPGRADE_REPO_AMICODE = vars.repo; if (vars.version !== undefined) env.AMICO_UPGRADE_TARGET_VERSION = vars.version; @@ -751,9 +755,150 @@ function stubEnv(vars: { repo?: string; version?: string; vscext?: string; vsix? if (vars.prev !== undefined) env.AMICO_UPGRADE_PREV_BIN = vars.prev; if (vars.server !== undefined) env.AMICO_UPGRADE_ROOT_SERVER = vars.server; if (vars.phase !== undefined) env.AMICO_UPGRADE_PHASE = vars.phase; + if (vars.amicoRun !== undefined) env.AMICO_UPGRADE_AMICO_RUN_DIR = vars.amicoRun; return env; } +// ── the verb-router dist rebuild (#643): a deployed upgrade never leaves a +// stale verb router behind ─────────────────────────────────────────────────── +// +// The incident: the deployed amico bundle sat 46 days stale while server- +// binary upgrades ran — the ledger verbs existed in source, were absent from +// the deployed binary, and every ledger call silently degraded for weeks. +// The router's freshness must not depend on whoever last ran a local build, +// so the server-binary upgrade (the deployed-code lane) rebuilds the CLI +// bundles from the amicode checkout's current source and refreshes BOTH +// copies: the build output (packages/amico-run/dist) and the extension-side +// byte-copy the PATH-first launcher execs (packages/extension/bin/dist). +// +// Fail-closed by placement: the rebuild runs BEFORE the freeze, so a failed +// or incomplete rebuild aborts the upgrade with NO server surface touched — +// the receipt never has to lie about a half-deployed state. The bin map of +// packages/amico-run/package.json is the single source of truth for the +// declared bundle set (the extension staging and CI's bundle-build-gate +// re-read the same map). + +interface DistRebuildFailure { + ok: false; + outcome: "aborted-environment" | "aborted-build"; + reason: string; +} + +async function rebuildVerbRouterDists( + ctx: VerbCtx, + digests: Record, +): Promise<{ ok: true } | DistRebuildFailure> { + const rootRepoAmicode = ctx.roots.rootRepoAmicode!; + const pkgDir = join(rootRepoAmicode, "packages", "amico-run"); + const buildCommand = ctx.args.flags["--dist-build-command"]; + const usingBuildStub = buildCommand !== undefined; + + // the declared bundle set — the package's bin map (+ shadowBins), by + // launcher basename: the staging convention everywhere in this repo + let declared: string[]; + try { + const pkg = JSON.parse(await readFile(join(pkgDir, "package.json"), "utf8")) as { + bin?: Record; + amicode?: { shadowBins?: Record }; + }; + const basenames = [ + ...Object.values(pkg.bin ?? {}), + ...Object.values(pkg.amicode?.shadowBins ?? {}), + ].map((p) => basename(String(p))); + declared = [...new Set(basenames)]; + if (declared.length === 0) throw new Error("no `bin` map"); + } catch (e) { + return { + ok: false, + outcome: "aborted-environment", + reason: `no readable amico-run package (bin map) at ${join(pkgDir, "package.json")}: ${e instanceof Error ? e.message : String(e)}`, + }; + } + + // environment: the live build path needs pnpm; the stub seam does not + if (!usingBuildStub) { + const pnpm = await runShell("pnpm --version"); + if (pnpm.code !== 0) { + return { + ok: false, + outcome: "aborted-environment", + reason: "pnpm not available on PATH (the verb-router dist rebuild needs it — or pass --dist-build-command )", + }; + } + } + + // the rebuild itself — from the checkout's CURRENT source (the commit is + // recorded below, so the receipt shows exactly what was built) + digests.amicode_head = (await runGit(rootRepoAmicode, ["rev-parse", "HEAD"])).stdout.trim() || null; + const cmd = buildCommand ?? "pnpm run build"; + ctx.log(`verb-router dist rebuild: ${cmd} (cwd ${pkgDir}, amicode@${String(digests.amicode_head).slice(0, 12)})`); + const build = await runShell(cmd, { cwd: pkgDir, env: stubEnv({ amicoRun: pkgDir }), timeoutMs: 600_000 }); + if (build.code !== 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `verb-router dist rebuild failed (exit ${build.code}): ${firstLine(build.stderr || build.stdout)}`, + }; + } + + // a "successful" build that drops a declared bundle is the stale-bundle + // signature — verify the complete set before deploying anything + const distDir = join(pkgDir, "dist"); + const missing: string[] = []; + for (const b of declared) { + try { + const st = await stat(join(distDir, `${b}.js`)); + if (st.size === 0) missing.push(`${b}.js (empty)`); + } catch { + missing.push(`${b}.js`); + } + } + if (missing.length > 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `dist rebuild produced an incomplete set — missing ${missing.join(", ")}`, + }; + } + + // refresh the extension-side byte-copy (the PATH-first launcher's target) + const extBin = join(rootRepoAmicode, "packages", "extension", "bin"); + const extBinDist = join(extBin, "dist"); + await mkdir(extBinDist, { recursive: true }); + for (const b of declared) { + await copyFile(join(distDir, `${b}.js`), join(extBinDist, `${b}.js`)); + } + // the module-type marker: the ESM bundle under a typeless package.json (the + // VS Code extension manifest — never add "type" there; it would flip the + // CJS extension-host entry) reparse-warns on EVERY invocation. The + // bin/-scoped {"type":"module"} marker is the same convention the extension + // build's staging writes (esbuild.config.mjs) — identical bytes, so a + // refresh by this hook and a refresh by the extension build converge. + await writeFile(join(extBin, "package.json"), `${JSON.stringify({ type: "module" })}\n`); + + // verify the refresh: every staged copy byte-matches the build output; + // the router's pair becomes the receipt's evidence + const mismatched: string[] = []; + for (const b of declared) { + const builtSha = await fileSha(join(distDir, `${b}.js`)); + const stagedSha = await fileSha(join(extBinDist, `${b}.js`)); + if (builtSha === null || stagedSha !== builtSha) mismatched.push(b); + } + if (mismatched.length > 0) { + return { + ok: false, + outcome: "aborted-build", + reason: `staged verb-router copies do not byte-match the build output: ${mismatched.join(", ")}`, + }; + } + if (declared.includes("amico")) { + digests.verb_router_sha256 = await fileSha(join(distDir, "amico.js")); + digests.verb_router_staged_sha256 = await fileSha(join(extBinDist, "amico.js")); + } + ctx.log(`refreshed ${declared.length} CLI bundles → both copies (router sha ${String(digests.verb_router_sha256).slice(0, 12)})`); + return { ok: true }; +} + // ── server-binary: the 9-step chain (build → freeze → sidecar → kick) ─────── const serverBinaryVerb = (argv: string[]): Promise => @@ -886,6 +1031,17 @@ const serverBinaryVerb = (argv: string[]): Promise => digests.artifact_version = artifactVersion; ctx.log(`smoke ok: ${artifact} --version → ${artifactVersion}`); + // (5b) the verb-router dist rebuild (#643) — BEFORE the freeze, so a + // failed or incomplete rebuild aborts with no server surface touched: + // a deployed upgrade must never proceed with a stale verb router behind + // it. Runs on every path (--skip-build, --no-kick): the router's source + // is the amicode checkout, independent of the server-binary artifact. + const dist = await rebuildVerbRouterDists(ctx, digests); + if (!dist.ok) { + ctx.log(dist.reason); + return { outcome: dist.outcome, verification: null, post: null, sourceDigests: digests }; + } + // (6) freeze: preserve the current binary as opencode.prev, copy the new // one, write the sidecar — never a live-path swap under a running server await mkdir(binDir, { recursive: true }); diff --git a/packages/amico-run/test/bundle_gate.test.ts b/packages/amico-run/test/bundle_gate.test.ts new file mode 100644 index 00000000..fea882ce --- /dev/null +++ b/packages/amico-run/test/bundle_gate.test.ts @@ -0,0 +1,95 @@ +// bundle_gate.test.ts — the #643 bundle-build gate (scripts/assert_built_bundles.mjs). +// +// The deployed verb-router bundle went 46 days stale because nothing gated the +// build: the dists are gitignored artifacts, and a broken entry or unresolvable +// import only failed on whichever machine last tried to build. The gate asserts, +// against the package's DECLARED bin map (the single source of truth — the same +// map the extension staging and assert_packaged_cli.mjs re-read): +// (a) every declared bundle exists in dist/ and is non-empty (absence or a +// half-built set reds); +// (b) the built verb router still ANSWERS — `amico --help` exits 0 with the +// usage surface (a bundle that builds-but-dies reds; vitest transpiles, +// so the unit suite never executes the shipped artifact — this gate does). +// The mutation direction is proven with fabricated dist dirs: a missing bundle +// and a dead router both red the gate. +import { describe, test, expect, beforeAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { declaredDistBundles, runBundleGate } from "../scripts/assert_built_bundles.mjs"; + +const ROOT = join(__dirname, ".."); + +// the suite's build convention (amico.test.ts et al.): build the real bundles +// before probing them — the esbuild config is atomic + idempotent by design. +beforeAll(() => { + execFileSync("node", [join(ROOT, "esbuild.config.mjs")], { cwd: ROOT }); +}); + +describe("bundle-build gate (#643)", () => { + test("the real build satisfies the gate: every declared bundle present, verb-router smoke answers", async () => { + const { ok, results } = await runBundleGate({ pkgDir: ROOT }); + expect(ok).toBe(true); + expect(results.filter((r) => !r.ok)).toEqual([]); // no failing rows — detail says why if not + + // fail-closed coverage: every DECLARED bin (bin map + shadowBins) is gated + const bins = declaredDistBundles(ROOT).map((b) => b.name).sort(); + expect(bins).toContain("amico"); // the verb router is the incident's subject + const gated = new Set(results.map((r) => r.bin)); + for (const b of bins) expect(gated.has(b)).toBe(true); + }); +}); + +// ── fabricated dist dirs: the mutation direction ───────────────────────────── + +/** A fabricated package dir whose dist/ the gate runs against: a package.json + * carrying the REAL bin map (the gate's input contract) + `files` mapping a + * dist basename to its bytes. */ +function fabricatedPkg(files: Record): string { + const pkg = mkdtempSync(join(tmpdir(), "amico-bundle-gate-")); + writeFileSync( + join(pkg, "package.json"), + JSON.stringify({ + name: "@amicode/amico-run-fixture", + bin: { + "amico-run": "./launcher/amico-run", + amico: "./launcher/amico", + "amico-pasqal": "./launcher/amico-pasqal", + "amico-git-credential": "./launcher/amico-git-credential", + }, + amicode: { shadowBins: { gh: "./launcher/gh" } }, + }), + ); + mkdirSync(join(pkg, "dist"), { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(pkg, "dist", name), body); + } + return pkg; +} + +const ALL_BUNDLES = ["amico-run.js", "amico.js", "amico-pasqal.js", "amico-git-credential.js", "gh.js"]; +/** A router stub that answers --help like the real one (exit 0, usage text). */ +const LIVE_ROUTER = 'console.log("usage:\\n amico run [--spec ]");\n'; + +describe("bundle-build gate — fabricated dists (the red direction)", () => { + test("a missing declared bundle reds the gate, naming the bundle", async () => { + const files: Record = {}; + for (const b of ALL_BUNDLES) files[b] = b === "amico.js" ? LIVE_ROUTER : "export {};\n"; + delete files["amico-pasqal.js"]; // one declared bundle absent + const { ok, results } = await runBundleGate({ pkgDir: fabricatedPkg(files) }); + expect(ok).toBe(false); + const row = results.find((r) => r.bin === "amico-pasqal" && /built/.test(r.check)); + expect(row?.ok).toBe(false); + expect(row?.detail).toMatch(/amico-pasqal\.js/); + }); + + test("a router that no longer answers reds the gate (builds-but-dies)", async () => { + const files: Record = {}; + for (const b of ALL_BUNDLES) files[b] = b === "amico.js" ? "process.exit(3);\n" : "export {};\n"; + const { ok, results } = await runBundleGate({ pkgDir: fabricatedPkg(files) }); + expect(ok).toBe(false); + const row = results.find((r) => r.bin === "amico" && /smoke/.test(r.check)); + expect(row?.ok).toBe(false); + }); +}); diff --git a/packages/amico-run/test/helpers.ts b/packages/amico-run/test/helpers.ts index 6f16ff9c..0c2714f4 100644 --- a/packages/amico-run/test/helpers.ts +++ b/packages/amico-run/test/helpers.ts @@ -193,6 +193,27 @@ export function buildDoctorWorld(opts: DoctorWorldOpts = {}): DoctorWorld { "opencode", "1.18.10", ); + // the amico-run package with its REAL bin map (#643): the server-binary + // upgrade's dist-rebuild reads this map to know which bundles to verify + + // refresh, exactly as the extension staging and assert_packaged_cli.mjs do. + mkdirSync(join(repoAmicode, "packages", "amico-run"), { recursive: true }); + writeFileSync( + join(repoAmicode, "packages", "amico-run", "package.json"), + JSON.stringify( + { + name: "@amicode/amico-run", + bin: { + "amico-run": "./launcher/amico-run", + amico: "./launcher/amico", + "amico-pasqal": "./launcher/amico-pasqal", + "amico-git-credential": "./launcher/amico-git-credential", + }, + amicode: { shadowBins: { gh: "./launcher/gh" } }, + }, + null, + 2, + ) + "\n", + ); fixtureGit(repoAmicode, ["init", "-b", "main"]); fixtureGit(repoAmicode, ["add", "-A"]); fixtureGit(repoAmicode, ["commit", "-m", "amicode fixture"]); diff --git a/packages/amico-run/test/upgrade-server-binary.test.ts b/packages/amico-run/test/upgrade-server-binary.test.ts index 0e651334..97ad3367 100644 --- a/packages/amico-run/test/upgrade-server-binary.test.ts +++ b/packages/amico-run/test/upgrade-server-binary.test.ts @@ -9,6 +9,7 @@ // --running-binary path; the health stub shapes the verify phases. import { describe, test, expect } from "vitest"; import { readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; import { join } from "node:path"; import { surfaceInventory, fileSha, type SurfaceRecord } from "../src/surfaces.js"; import { upgradeVerb } from "../src/upgrade.js"; @@ -20,6 +21,7 @@ import { fakeBin, fixtureGit, bumpForkHead, + sha256File, FUTURE_BUILD, PAST_BUILD, type DoctorWorld, @@ -41,6 +43,15 @@ const HEALTH_FAIL_VERIFY_ONLY = 'case "$AMICO_UPGRADE_PHASE" in verify*) exit 1;; *) exit 0;; esac'; const HEALTH_FAIL_ALWAYS = "exit 1"; +/** THE DIST-BUILD STUB (#643): fabricates every declared bundle in the + * fixture checkout's packages/amico-run/dist — the hermetic seam for the + * verb-router dist rebuild the server-binary upgrade now performs (the real + * build is `pnpm run build` in that dir; CI's bundle-build-gate lane proves + * the real one on every push). Each stub bundle is ESM (`export {};`) so the + * module-type warning test discriminates. */ +const DIST_BUILD_STUB = + 'mkdir -p dist && for n in amico-run amico amico-pasqal amico-git-credential gh; do printf "export {};\\n" > "dist/$n.js"; done'; + function verbArgs(w: DoctorWorld, extra: string[]): string[] { return [ "server-binary", @@ -51,6 +62,7 @@ function verbArgs(w: DoctorWorld, extra: string[]): string[] { "--root-repo-fork", w.repoFork, "--root-staging", w.staging, "--running-binary", w.running, + "--dist-build-command", DIST_BUILD_STUB, ...extra, ]; } @@ -251,6 +263,100 @@ describe("upgrade server-binary — --no-kick (freeze only)", () => { }); }); +// ── the verb-router dist rebuild (#643) ────────────────────────────────────── + +describe("upgrade server-binary — the verb-router dist rebuild (#643)", () => { + test("a deployed upgrade rebuilds the dists and refreshes BOTH copies, with receipt evidence", async () => { + const w = stageVersionStale(); + const artifact = freshArtifact(); + + const r = await upgradeVerb(successArgs(w, artifact)); + expect(r.code).toBe(0); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("upgraded"); + + // BOTH copies refreshed: the build output AND the extension-side byte-copy + // the PATH-first launcher execs — byte-identical, for every declared bundle + const arDist = join(w.repoAmicode, "packages", "amico-run", "dist"); + const extBin = join(w.repoAmicode, "packages", "extension", "bin"); + for (const n of ["amico-run", "amico", "amico-pasqal", "amico-git-credential", "gh"]) { + const built = readFileSync(join(arDist, `${n}.js`)); + const staged = readFileSync(join(extBin, "dist", `${n}.js`)); + expect(staged.equals(built)).toBe(true); + } + + // receipt evidence: the router's sha on BOTH sides + the source commit + const routerSha = sha256File(join(arDist, "amico.js")); + expect(receipt.source_digests.verb_router_sha256).toBe(routerSha); + expect(receipt.source_digests.verb_router_staged_sha256).toBe(routerSha); + expect(receipt.source_digests.amicode_head).toBe( + execFileSync("git", ["-C", w.repoAmicode, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + ); + // the human story names the refresh + expect(receipt.detail.join(" ")).toMatch(/verb-router|dist/i); + cleanup(); + }); + + test("dist build failure → aborted-build BEFORE the freeze: server surface untouched, no receipt lie", async () => { + const w = stageVersionStale(); + const frozenShaBefore = await fileSha(join(w.server, "bin", "opencode")); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", freshArtifact(), + "--kick-command", KICK_STUB, + "--health-command", HEALTH_OK, + "--verify-timeout-ms", "2000", + "--dist-build-command", "exit 3", + ]); + + const r = await upgradeVerb(args); + expect(r.code).toBe(1); + const receipt = r.json as Record; + expect(receipt.outcome).toBe("aborted-build"); + // nothing frozen: the old binary + sidecar still agree, no prev, no staged copy + expect(await fileSha(join(w.server, "bin", "opencode"))).toBe(frozenShaBefore); + expect(readFileSync(join(w.server, "bin", "opencode.sha256"), "utf8")).toContain(frozenShaBefore!); + expect(existsSync(join(w.server, "bin", "opencode.prev"))).toBe(false); + expect(existsSync(join(w.repoAmicode, "packages", "extension", "bin", "dist", "amico.js"))).toBe(false); + expect(lastReceipt(w).outcome).toBe("aborted-build"); + cleanup(); + }); + + test("a half-built dist set (declared bundle missing) → aborted-build", async () => { + const w = stageVersionStale(); + const args = verbArgs(w, [ + "--root-receipts", receiptsDir(w), + "--skip-build", freshArtifact(), + "--kick-command", KICK_STUB, + "--health-command", HEALTH_OK, + "--verify-timeout-ms", "2000", + "--dist-build-command", 'mkdir -p dist && printf "export {};\\n" > dist/amico.js', + ]); + + const r = await upgradeVerb(args); + expect(r.code).toBe(1); + expect((r.json as Record).outcome).toBe("aborted-build"); + expect((r.json as Record).detail.join(" ")).toMatch(/amico-pasqal/); + cleanup(); + }); + + test("invoking the refreshed extension-side bundle emits no module-type reparse warning (#643)", async () => { + const w = stageVersionStale(); + const r = await upgradeVerb(successArgs(w, freshArtifact())); + expect(r.code).toBe(0); + + // the staged stub bundle is ESM-only (`export {};`); the fixture's + // packages/extension/package.json (like the real VS Code manifest) has no + // "type" field — without a scoped marker, node reparse-warns on EVERY + // invocation (the deployed live machine paid exactly this). + const stagedRouter = join(w.repoAmicode, "packages", "extension", "bin", "dist", "amico.js"); + const run = spawnSync(process.execPath, [stagedRouter, "--help"], { encoding: "utf8" }); + expect(run.status).toBe(0); + expect(run.stderr).not.toMatch(/MODULE_TYPELESS_PACKAGE_JSON/); + cleanup(); + }); +}); + // ── the restore paths ──────────────────────────────────────────────────────── describe("upgrade server-binary — restore (verify fails → rollback to prev)", () => { diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index ac066354..9aa40064 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -21,6 +21,12 @@ const REQUIRED = [ "extension/bin/launcher/amico-git-credential", "extension/bin/dist/gh.js", "extension/bin/launcher/gh", + // #643 — the bin/-scoped module-type marker: the ESM CLI bundles sit under + // the typeless VS Code extension manifest; without this file every packaged + // invocation pays a MODULE_TYPELESS_PACKAGE_JSON reparse. Both refresh + // paths write it (the extension build's staging AND the server-binary + // upgrade's dist rebuild) — this pin is the packaged side of that contract. + "extension/bin/package.json", // Pasqal connector assets — staged to /scripts/pasqal-connector at // activation (the Connections panel's default validator path, #161). Kept in // the vsix by explicit .vscodeignore negations against scripts/**.