From 7b559058ee3b9fc7a10e8eb377f6c440c9835525 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:47:48 +0000 Subject: [PATCH 01/19] feat(build): package the canonical npm root --- .changeset/canonical-npm-root.md | 5 + docs/diagnostics.md | 18 +- docs/effect-conventions.md | 4 +- docs/entry-conventions.md | 129 ++-- docs/framework-mode.md | 10 +- packages/agent-bundle/README.md | 16 +- .../fixtures/web-surface/package.json | 1 + packages/agent-bundle/src/api.ts | 4 +- packages/agent-bundle/src/build/build.ts | 10 +- packages/agent-bundle/src/build/cli-bins.ts | 11 +- .../agent-bundle/src/build/entry-shell.ts | 19 - .../src/build/launch-env-shell.ts | 8 +- .../agent-bundle/src/build/pack-inventory.ts | 143 +++-- .../agent-bundle/src/build/package-build.ts | 462 ++++++++------ packages/agent-bundle/src/dev/coordinator.ts | 5 +- .../src/dev/package-build-service.ts | 13 +- packages/agent-bundle/src/install-entry.ts | 230 ------- .../tests/cli-routes-build.test.ts | 8 +- .../agent-bundle/tests/entry-shell.test.ts | 11 - .../tests/installer-entry.test.ts | 308 ---------- .../tests/packed-consumer.test.ts | 3 - .../tests/packed-web-command.test.ts | 79 ++- packages/agent-bundle/tests/prepack.test.ts | 573 ++++++++++++------ rstest.integration-tests.ts | 1 - .../en/guide/authoring/package-entries.mdx | 68 ++- website/docs/en/guide/distribution/index.mdx | 18 +- .../en/guide/distribution/installation.mdx | 26 +- .../docs/en/guide/distribution/validation.mdx | 138 +++-- website/docs/en/reference/cli.mdx | 8 +- .../zh/guide/authoring/package-entries.mdx | 56 +- website/docs/zh/guide/distribution/index.mdx | 15 +- .../zh/guide/distribution/installation.mdx | 21 +- .../docs/zh/guide/distribution/validation.mdx | 92 +-- website/docs/zh/reference/cli.mdx | 4 +- 34 files changed, 1201 insertions(+), 1316 deletions(-) create mode 100644 .changeset/canonical-npm-root.md delete mode 100644 packages/agent-bundle/src/install-entry.ts delete mode 100644 packages/agent-bundle/tests/installer-entry.test.ts diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md new file mode 100644 index 000000000..c2fad1783 --- /dev/null +++ b/.changeset/canonical-npm-root.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#TBD). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 30f54d314..2dd2d7329 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -835,12 +835,13 @@ exactly where `build` would refuse. ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) -A generated-mode `src/cli/**` surface compiles into the npm package bin -(`dist/bin/.js`) **and** into every host artifact whose adapter +A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus `bin/-flight.mjs` when any command renders). Every built-in target hosts it; the two codes cover a target that does not and a host file -that claims the same path. See “The routed CLI shell” in +that claims the same path. The npm root copies the manifest-declared bin +unchanged and points `package.json` at it rather than recompiling the graph. +See “The routed CLI shell” in `docs/entry-conventions.md` for the layout and the sibling-path convention. | Code | Severity | Trigger | @@ -1081,8 +1082,8 @@ Conventional `src/cli/**` routes compile into one collision-checked command graph (#102 stages 2-3): the file path below the CLI root is the command nesting (`src/cli/library/audit.ts` runs as ` library audit`), the static `config` export supplies `description`, `aliases`, `positionals`, and -the `exitCode` policy, and the graph feeds one framework-generated package -executable named after the plugin (`dist/bin/.js`), replacing +the `exitCode` policy, and the graph feeds one framework-generated artifact +executable named after the plugin (`bin/.mjs`), replacing the `src/cli.ts` convention for that project. Every command route exports `inputSchema` and `resultSchema` zod schemas plus one async default function receiving `{ input, signal }`, and runs inside the typed Agent request @@ -1511,8 +1512,7 @@ consumes it as the recovery. `agent-bundle uninstall [--from ] [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] -[--force] [--plan] [--json]`, the package-relative installer bin's -`uninstall `, and the emitted `install.mjs --uninstall` are the +[--force] [--plan] [--json]` and the emitted `install.mjs --uninstall` are the receipt-owned reverse of `install` (#101; the maintainer's 2026-09-01 G4 deferral of mutation was reversed on 2026-09-03 with the request to fix every open issue). Every mutation is opt-in and bounded by the receipt: @@ -1891,7 +1891,7 @@ The `AB7000`–`AB7004` codes are shared by two families of emitters with distinct meanings. `agent-bundle install` and `agent-bundle uninstall` (`install/install.ts`, `install/uninstall.ts`, `install/cursor-marketplace.ts`) throw them as `DiagnosticError`s with `target` set to the host; the -`install-entry` CLI wraps any non-diagnostic failure as `AB7004`. The +installation command boundary wraps any non-diagnostic failure as `AB7004`. The development project service (`dev/project-service.ts`) and `inspectProject` emit them as **error** diagnostics on a failed preparation with `sourcePath` set to the config file and a fixed `recovery` ending in "then inspect again". @@ -1904,7 +1904,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the installation command boundary. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 8b5196698..7d652dc3b 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -645,8 +645,8 @@ Wiring rules: - **Protocol stdout stays raw.** MCP stdio JSON-RPC (`mcp-entry.ts`, `mcp run`), hook result JSON (`adapters/hook-contract.ts`), the emitted routed-CLI shell (`cli-entry.ts`'s `writeOut`/`writeErr` ports and the - `entry-shell.ts` bin template), generated installers (`install-entry.ts`, - `install/surface.ts`), and child/worker stderr forwarding keep their direct + `entry-shell.ts` bin template), installers (`install/surface.ts`), and + child/worker stderr forwarding keep their direct `process.stdout`/`process.stderr` adapters: emitted artifacts must not carry a platform runtime, and byte-exact protocol frames are not terminal text. - **The route-facing terminal capability is plain Node, not `Terminal`.** diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 728f11618..6fbdbb871 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -13,25 +13,20 @@ rendering. `agent-bundle build` always emits the composite plugin root. When the project declares `bin`/`lib` (or provides them by convention), the CLI build also -produces the node-consumable package build under `dist/` — the outputs -`package.json` `bin` and `exports` point at: +produces the node-consumable npm root under `dist/`. It copies the validated +composite artifact there and adds `package.json`, standard package docs, and +package-only entries: | Config | Output | Notes | | --- | --- | --- | -| `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | +| `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Authored self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | +| generated `src/cli/**` routes | `dist/bin/.mjs` | The manifest-declared artifact executable, copied byte-for-byte; `package.json` `bin` points directly at it. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | -- When package outputs are built inside the project and the plugin root - carries at least one of the `claude`, `codex`, or `cursor` projections, the - framework also emits one self-contained - package-relative installer. It is `dist/bin/.js` when that name - is free, otherwise `dist/bin/-install.js`; if both are occupied, - a numeric suffix (`-install-2`, `-install-3`, …) guarantees a free name. - Declare the matching `package.json` `bin` value. Its grammar is - `install [--scope ] [--json]`; help lists only built hosts. - The baked URL resolves the shipped plugin root from `import.meta.url`, - never the caller's working directory, and delegates to the same - `installBundle` implementation as `agent-bundle install`. +- The npm root is not a parallel plugin compilation and contains no nested + `artifact/` directory. Generated routed CLI commands, including `web`, run + from the one artifact executable; no package-relative installer wrapper is + generated. - `agent-bundle prepack [--root ] [--output ] [--json]` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package/artifact inventory, manifest hashes, package bin @@ -728,7 +723,7 @@ Per surface, the value the generated request scope mounts: | Surface | `hostSurface` | `stdout` / `stderr` | Source | | --- | --- | --- | --- | -| Routed CLI executable (`dist/bin/.js`, plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | +| Routed CLI executable (npm-root and plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | | Rendered script (`scripts/.mjs` from `src/scripts/.tsx`) | `script` | Probed, as above. | `native` | | Generated MCP server (any transport) | `mcp` | `none` on both, `color: 'none'`, `sharesTarget: false` — stdout is the protocol wire and stderr the host's log. Never probed, whatever the descriptors are. | `derived` | | Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` | @@ -797,7 +792,8 @@ through Node's top-level failure path (stack to stderr, exit code 1). (#511), probed once before `main` runs by the dependency-free `agent-bundle/terminal-capability` module the envelope aliases in — plain scripts and bins load no Effect runtime and no `@agent-bundle/runtime` for it. -Its `hostSurface` is `cli` for a package bin (`dist/bin/.js`) and +Its `hostSurface` is `cli` for a package bin (`dist/bin/.js` for an authored bin, +`dist/bin/.mjs` for the manifest-selected routed CLI) and `script` for an artifact script (`scripts/.mjs`); a module shipped on both surfaces sees the surface it was launched from. A `main` declared with one parameter keeps working — the second argument is simply unread. @@ -835,8 +831,8 @@ The compiler statically projects `inputSchema` onto argv (the bounded grammar and every policy rule are documented in [Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4842-ab4940ab4942)), generates nested help (`--help` at every level, `--version` at the root), and emits -`dist/bin/.js` with the shebang and executable bit through the -same Rslib synthesis as every other bin. At run time the shell resolves the +`bin/.mjs` in the composite artifact with the shebang and +executable bit. At run time the shell resolves the command path, parses and coerces argv, validates through the module's own zod schemas, executes the default function inside the typed Agent request context (`invocation.kind: 'cli'`), writes one canonical JSON line to @@ -884,7 +880,7 @@ is 2 in every mode. A `.tsx` command route swaps the default function for an async default Server Component with the same `{ input, signal }` props and renders through the runtime dispatcher's public `stream()` against a sibling -`dist/bin/-flight.mjs` react-server worker (one warm worker per +`bin/-flight.mjs` react-server worker (one warm worker per invocation; raw Flight bytes never reach the terminal). The four output modes: an interactive TTY updates progress in place and prints the final document as Markdown; piped output emits exactly one final Markdown document @@ -896,11 +892,10 @@ machine output owns stdout. Rendered scripts (`src/scripts/.tsx`) share the same shell and output contract with `{ argv, signal }` component props and status-derived exit codes. -#### The routed CLI inside the plugin root +#### The routed CLI inside every distribution -The package bin only reaches users who install the npm package. Hooks, -skills, and script routes ship with the **plugin root**, so the build also -emits the same compiled command graph into that root whenever a selected host's +Hooks, skills, script routes, and npm consumers all use the **plugin root**, +so the build emits one compiled command graph there whenever a selected host's adapter publishes the `cli` capability — all built-in hosts do (`claude`, `codex`, `cursor`, `portable`) — because the artifact root is already a plain directory Node executes `mcp/` and `scripts/` files from. @@ -916,17 +911,16 @@ artifact/ mcp/… ``` -The artifact bin is a self-contained ESM module with no shebang or -executable bit — invoke it as `node /bin/.mjs -`, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, -exit codes, and signals are identical to the package bin. One deliberate -difference: workspace-durable state without a host-supplied +The artifact bin is a self-contained executable ESM module with a Node +shebang. The npm root copies it unchanged and points `package.json` `bin` +directly at it, so help, argv parsing, output modes, exit codes, signals, and +the full command set are byte-for-byte the same. Both forms resolve plugin +code from the root above `bin/`. Workspace-durable state without a host-supplied `AGENT_BUNDLE_STATE_ROOT` uses `stateAnchor: 'user-data'`, deriving `~/.agent-bundle/state/-` (or the `XDG_STATE_HOME` equivalent) from the artifact code root. The generated MCP worker beside it makes the same derivation, so a co-installed CLI and server observe one store without -writing beneath a read-only artifact. The npm package bin keeps -`stateAnchor: 'root'` and its `cwd` fallback, `$PWD/.agent-bundle/state`. +writing beneath a read-only artifact. Reaching the bin from the other surfaces: @@ -957,7 +951,8 @@ capability without that layout, or with a `cliBin` layout naming another directory or omitting `.mjs`, is rejected at registration. A target without the capability omits the bin and reports `AB4765`; a host-emitted file at the same path (a Claude `claude.bin` directory shipping `.mjs`) is -`AB4766`. The package build's `dist/bin/.js` is unchanged. +`AB4766`. The npm root copies this manifest-declared executable; it does not +compile another routed CLI. #### Project generated MCP tools into the CLI (power tier) @@ -1261,8 +1256,8 @@ export default defineConfig({ native command as `node "/" ` — one config declaration replaces a hand-rolled `hooks/hooks.json` per host. Prebuilt hook `args` (for example `--host claude`) accept shell-safe strings only. -- **Prebuilt means opaque.** Payload files are exempt from compiler dependency - evidence, the emitted-module walk, and strict generated-JSON validation but +- **Prebuilt means opaque.** Payload files are exempt from generated-output + content validation (bundled-ESM import graphs, strict generated JSON) but remain hash-locked to the manifest. Declaration provenance is recorded as `kind: 'prebuilt'`. Hooks with prebuilt handlers are packaged like native hook documents: they do not compile wrappers and do not appear in the @@ -1315,41 +1310,25 @@ module specifiers are protected the same way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated module specifier (`agent-bundle/meta`, or a registry specifier such as `agent-bundle/mcp-apps`) fails the build with a hard diagnostic — at config inspection for statically visible `externals`, -from the build-time guard that wraps function-form `externals`, and from the -compilation's externals evidence (`AB6005`) for anything that still reaches -the module graph; the emitted bytes are no longer scanned for reserved text. -The hatch customizes *how code compiles*, never *what the artifact promises*. -The framework's own profile keeps the same promise: `output.autoExternal` is -`false`, `bundle: true`, `splitChunks: false`, and no `externals` are added. -The compiler service lowers every host-pack surface and package-build entry. -The framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` -and records every module Rspack kept external, and the service reads that -evidence before trusting an asset. `AB6005` rejects anything Rspack kept -external except a Node built-in, `pnpapi`, or an emitted sibling of the same -artifact, whatever spelling the bundle uses. `agent-bundle build` writes that -evidence as `agent-bundle.compile-evidence.json` at the artifact root (listed -in `agent-bundle.manifest.json` as a `generated` file); `agent-bundle validate +and from the emitted bundle's residual imports for function-form `externals`. +The hatch customizes *how code compiles*, never *what the artifact promises*. The framework's own +profile keeps the same promise: `output.autoExternal` is `false`, `bundle: +true`, `splitChunks: false`, and no `externals` are added. The compiler service +lowers every host-pack surface and package-build entry. The framework-owned +`ArtifactDependencyAuditPlugin` taps `thisCompilation` and records every module +Rspack kept external, and the service reads that evidence before trusting an +asset. `AB6005` rejects anything Rspack kept external except a Node built-in, +`pnpapi`, or an emitted sibling of the same artifact, whatever spelling the +bundle uses. `agent-bundle build` writes that evidence as +`agent-bundle.compile-evidence.json` at the artifact root (listed in +`agent-bundle.manifest.json` as a `generated` file); `agent-bundle validate --artifact` re-checks a listed record against the file table without reading -JavaScript (`AB6039`). An expression request -(`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's -profile leaves it verbatim, and the compile evidence record lists those forms -as unobserved. The emitted-module walk -(`src/build/validate-artifact-modules.ts`) remains for exactly what the -compiler cannot see: it fails an expression `import()` in any emitted module -(`AB6005 has a non-literal dynamic import`), parses in full and resolves the -imports of JavaScript the framework did not compile (`install.mjs`, copied -scripts), and does the same for every module of a build whose `tools` hatch -may have rewritten the emitted bytes (`coverage.rewritable`). A compiled -module the record covers is lexed, not parsed, and each literal import it -still carries is held to the record: a Node built-in or one of the file's -recorded externals passes; any other request is one the build was told to -ignore (`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with no -module, external, or warning) and fails `AB6005 loads "", which the -compiler neither bundled nor recorded as an external`. A matching digest -proves the bytes are the compiler's, not that every import in them was -resolved. Content the compiler did not compile is opaque and must declare -what it needs. Run-time -path references are kept the same way: a `new URL(…, import.meta.url)` or +JavaScript (`AB6039`). The emitted-module walk remains behind that check as +defense in depth. A `require`, +`createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler does not +resolve is not a module dependency; content the compiler did not compile is +opaque and must declare what it needs. Run-time path references are kept the +same way: a `new URL(…, import.meta.url)` or `new Worker(new URL(…))` in consumer or generated code names a file beside the artifact, so the invariant layer turns the bundler's URL and worker asset processing off after the hatch and the expression reaches the artifact @@ -1534,8 +1513,9 @@ treats a missing file as the normal case and an unreadable one as skipped. The dotenv grammar has no `${VAR}` interpolation. Under `mcp run` the plugin root is the project root, so the shell's pass is a no-op; `--env-file` and `--no-env` are handed down as `AGENT_BUNDLE_ENV_FILE` so the shell follows the -operator's choice. The npm package bin reads no pack file. Doctor reports the -presence and variable count of each file (`AB7331`). +operator's choice. Because the npm bin is this same artifact executable, it +reads the same pack files. Doctor reports the presence and variable count of +each file (`AB7331`). Two details keep the installed order equal to the `mcp run` table above: @@ -1641,12 +1621,11 @@ resolve '../events'`). The route graph reports such an import first, as `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and `agent-bundle/test/browser`, matched exactly; `import type` and type-only -usage are not reported), while an external bare import fails `AB6005` from -the compiler's externals evidence and a non-literal `import(spec)`, which the -compiler leaves verbatim, still fails artifact validation from the -emitted-module walk (`AB6005 has a non-literal dynamic import`). From an -installed artifact the supported command is ` web` -on `bin/.mjs` (emitted when `web` is configured, even with no +usage are not reported), while an external bare import (`AB6005 uses +unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a +non-literal dynamic import`) still fails artifact validation. From an +installed artifact the supported command is ` web` on +`bin/.mjs` (emitted when `web` is configured, even with no `src/cli/**` commands). It reads the manifest `web` section beside `bin/`, launches the plugin's own packed MCP server, and prints the same ready line `MCP App / at (tool ; Ctrl-C stops the server)` — diff --git a/docs/framework-mode.md b/docs/framework-mode.md index b2a75484d..cdb410adc 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -271,11 +271,11 @@ the handwritten `runRscCli` compatibility path still serializes validated results and never renders JSX. Routed `src/cli/**` commands and `src/scripts/**` scripts follow one sentence: `.tsx` renders through the Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` -is plain. The routed CLI ships twice from one build: as the npm package bin -(`dist/bin/.js`) for users who install the package, and as -`bin/.mjs` inside the plugin root so the plugin's own skills, -hooks, and scripts can run it with `node` from the installed plugin root -(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-the-plugin-root)). +is plain. The routed CLI compiles once as `bin/.mjs` in the plugin root; +the npm root copies that proven executable unchanged and points its +`package.json` `bin` at it, so npm users and the plugin's own skills, hooks, +and scripts run the same command surface (see +[Entry conventions](entry-conventions.md#the-routed-cli-inside-every-distribution)). ## Release identity in source: `agent-bundle/meta` diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 52f46a8ee..187ce1ac1 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -291,15 +291,13 @@ content)` (`AB7308`), `version mismatch` (`AB7309`), `foreign install` (`AB7321`), `not installed` (`AB7307`), or `unknown` when the host inventory could not be read. -When package outputs ship one of those host packs, the build also emits a -package-relative installer bin. It uses the plugin name when no configured bin -claims it and `-install` otherwise. Map that name to the generated -`dist/bin/*.js` file in `package.json`; consumers run -` install [--scope ] [--replace|--force] [--json]` and -` uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`. -The executable locates the artifact directory beside the installed package, so -it works from `node_modules` regardless of the current directory. No npm -lifecycle performs an installation. +When package outputs ship one of those host packs, `dist/` becomes the npm +root containing that complete composite artifact. A generated routed CLI is +not compiled again: `package.json` points its `bin` at the manifest-declared +`bin/.mjs` copied unchanged from the validated artifact. Consumers +therefore get the same complete command surface, including `web`, whether they +run the artifact or the npm-installed bin. No npm lifecycle performs a host +installation. ### Uninstall by receipt diff --git a/packages/agent-bundle/fixtures/web-surface/package.json b/packages/agent-bundle/fixtures/web-surface/package.json index ffdeedac2..40a0a6131 100644 --- a/packages/agent-bundle/fixtures/web-surface/package.json +++ b/packages/agent-bundle/fixtures/web-surface/package.json @@ -2,6 +2,7 @@ "name": "web-surface-fixture", "private": true, "type": "module", + "version": "1.0.0", "devDependencies": { "@modelcontextprotocol/server": "2.0.0" } diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index ace61c136..cfeca40a6 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1323,7 +1323,7 @@ export const build = async (options: BuildOptions): Promise let packageBuild: PackageBuildResult | undefined; if (packageOutputRoot !== undefined) { packageBuild = await buildPackageOutputs({ - ...(isInsideOrEqual(prepared.root, output) ? { artifactRoot: output } : {}), + artifactRoot: output, model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), @@ -1397,7 +1397,7 @@ export const prepack = async (options: BuildOptions): Promise => }]); } const { stdout } = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: resolve(options.root), + cwd: result.packageBuild.outputRoot, }); const pack = packOutputFromJson(stdout); const diagnostics = await packInventoryDiagnostics({ diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 131871199..9964e2aa5 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; import { rspack } from '@rslib/core'; @@ -22,7 +22,12 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; -import { planCliBinsSurface, planCompiledCliBins, type CompiledCliBin } from './cli-bins.ts'; +import { + cliBinExecutableMode, + planCliBinsSurface, + planCompiledCliBins, + type CompiledCliBin, +} from './cli-bins.ts'; import { composeProjections, type CompositePlan } from './compose.ts'; import { projectMeta } from './meta.ts'; import { @@ -804,6 +809,7 @@ export const build = async (options: BuildOptions): Promise => { } } } + await Promise.all(compiledCliBins.map((entry) => chmod(entry.output, cliBinExecutableMode))); const publishedCompiledEntries = deepFreeze(compiledEntries.map((entry) => ({ ...entry, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index c6339b877..d3f9f086e 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -27,18 +27,18 @@ const webHostPageScript = await readWebHostPageScript(); /** * The artifact-hosted routed CLI (#387). A generated-mode `src/cli/**` - * surface already compiles into the npm package bin (`dist/bin/.js`); - * this module emits the same compiled command graph into every host artifact + * surface compiles here into every host artifact * whose adapter publishes the `cli` capability, as `bin/.mjs` beside * an optional `bin/-flight.mjs` react-server worker. The bin is a - * plain self-contained ESM module invoked as `node /bin/.mjs`, - * exactly like the artifact's `scripts/*.mjs`, so hooks, skills, and script + * self-contained executable ESM module, so hooks, skills, scripts, and the npm + * package bin * routes installed with the plugin can reach the routed CLI without a - * separate npm install. The package build's own bin emission is untouched. + * share one proven command surface without a second compilation. */ /** The one directory the compiler emits the routed CLI into; the registry pins every `cliBin` layout to it. */ export const cliBinDirectory: string = routedCliBinLayout.directory; +export const cliBinExecutableMode = 0o755; /** The artifact-relative executable path for one routed-CLI bin. */ export const cliBinArtifactPath = (name: string): string => `${cliBinDirectory}/${name}.mjs`; @@ -156,6 +156,7 @@ export const cliBinRslibEntries = ( [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), ...(entry.bin.web === true ? { [webHostRuntimeSpecifier]: webHostRuntimePath() } : {}), }, + banner: '#!/usr/bin/env node', name: `bin-${entry.name}`, virtualModules: [ operatorEnvLayerVirtualModule(), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 500c5875a..8119df743 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -169,25 +169,6 @@ export const webHostRuntimeSpecifier = 'agent-bundle/web-host'; export const webHostRuntimePath = (): string => runtimeModulePath('web-host'); -export const installEntryRuntimeSpecifier = 'agent-bundle/install-entry'; - -export const installEntryRuntimePath = (): string => runtimeModulePath('install-entry'); - -export const generatedInstallBinEntrySource = (options: { - readonly artifactRelativeUrl: string; - readonly hosts: readonly ('claude' | 'codex' | 'cursor')[]; - readonly name: string; -}): string => [ - `import { runGeneratedInstallProcess } from ${JSON.stringify(installEntryRuntimeSpecifier)};`, - '', - 'process.exitCode = await runGeneratedInstallProcess(process.argv.slice(2), Object.freeze({', - ` artifactRoot: new URL(${JSON.stringify(options.artifactRelativeUrl)}, import.meta.url),`, - ` hosts: Object.freeze(${stableJson(options.hosts)}),`, - ` name: ${JSON.stringify(options.name)},`, - '}));', - '', -].join('\n'); - /** * The code root a generated module falls back to when the host supplies no * `AGENT_BUNDLE_PLUGIN_ROOT`, and with it where its state root derives absent diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index 5419dca09..cfd0db231 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -8,10 +8,10 @@ */ export const launchEnvRuntimeSpecifier = 'agent-bundle/launch-env'; -// This module is imported by the hook contract, which the installer bundle -// (`install-entry`) also carries and a consumer's own Rspack run re-bundles, -// so it must stay free of filesystem probing and `new URL(…, import.meta.url)`: -// `launchEnvRuntimePath` lives in `entry-shell.ts` beside the other paths. +// This module is imported by the hook contract and may be re-bundled by a +// consumer, so it stays free of filesystem probing and +// `new URL(…, import.meta.url)`: `launchEnvRuntimePath` lives in +// `entry-shell.ts` beside the other paths. /** * The generated module that applies the layer, served virtually to each diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 2830ba470..130825d99 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -1,23 +1,27 @@ import { lstat } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import type { NormalizedPlugin } from '../core/types.ts'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; -import { isErrno } from '../core/errors.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isRecord } from '../core/strict-json.ts'; import { readFileBytes, readFileString, runWithPlatform } from '../effect/platform.ts'; +import { + compileEvidenceDiagnostics, + packageCompileEvidenceFileName, + parseCompileEvidenceRecord, +} from './compile-evidence.ts'; import { artifactManifestName } from './emit.ts'; import { parseArtifactManifest } from './manifest.ts'; import { declaredDependencies, type DeclaredDependency, type InstalledDependencyField } from '../core/package-dependencies.ts'; import { classifyDependency, - installScriptDependencies, + importedPackageNames, isWorkspaceProtocol, packagedSourceInstallable, packagedSourcePath, - packedDeclarationReferences, type DependencyKind, } from './pack-dependencies.ts'; import type { PackageBuildResult } from './package-build.ts'; @@ -82,9 +86,6 @@ export const packOutputFromJson = (stdout: string, packageName?: string): PackOu return Object.freeze({ filename: entry.filename, files: Object.freeze(files) }); }; -const toPosixRelative = (root: string, path: string): string => - relative(resolve(root), resolve(path)).replaceAll('\\', '/'); - /** Stays on `lstat`: a dangling symlink at a host manifest path still counts as present. */ const exists = async (path: string): Promise => { try { @@ -123,14 +124,22 @@ const perField = ( .map(([field, own]) => emit(field, own.toSorted((left, right) => left.name.localeCompare(right.name)))); /** - * `AB7014`/`AB7015`: an installed-dependency entry counts as used only when a - * consumer must have it installed — a prebuilt payload declares it in - * `runtimeDependencies`, a packed declaration file references it, or a - * consumer-side install script names or runs it. The build inlines every - * other import (`AB6005`), so a bundled package is still unused: every - * consumer's `npm install` fetches it for nothing — and fails outright when - * the specifier is one a consumer's npm cannot resolve (git, remote tarball, - * path, or an unrewritten workspace protocol). + * `AB7014`/`AB7015`: the build inlines every dependency into `dist` and the + * host packs, so an installed-dependency entry no packed file references + * only makes every consumer's `npm install` fetch a build-time package — and + * fail outright when the specifier is one a consumer's npm cannot resolve + * (git, remote tarball, path, or an unrewritten workspace protocol). A + * compiled bundle cannot `import` a bare package at all: `AB6005` fails the + * build on any import specifier that is not a Node built-in, and `prepack` + * runs that build before this inventory, so the import evidence `AB7014` + * accepts comes only from modules the framework copied rather than compiled + * — prebuilt payload modules and other scripts the `files` allowlist packs — + * never from a `dist` bundle or a host-pack module. A `require`, + * `createRequire(…)(…)`, or `import.meta.resolve(…)` call is not an import + * and `AB6005` does not walk it, so that evidence is read from every packed + * file, compiled bundles included. A prebuilt payload's `runtimeDependencies` + * declaration is evidence of the same standing: the compiler never opens a + * payload file, so the author states what it loads. */ const unresolvableMessage = (field: InstalledDependencyField, own: readonly DeclaredDependency[]): string => `package.json ${field} names packages a consumer's npm cannot resolve through a registry (an invalid name or a non-registry specifier): ${own.map((dependency) => @@ -141,9 +150,8 @@ const unresolvableRecovery = 'Depend on a published registry version, or bundle + 'which only pnpm, Yarn, or Bun rewrite while packing.'; const dependencyDiagnostics = async (options: { - /** Package → the packed bundles the compiler inlined it into, each sorted. */ - readonly bundledInto: ReadonlyMap; readonly declaredRuntimeDependencies: ReadonlySet; + readonly dependencyRoot: string; readonly packageDocument: Readonly>; readonly packedPaths: readonly string[]; readonly packerRewritesWorkspaceProtocols: boolean; @@ -151,17 +159,16 @@ const dependencyDiagnostics = async (options: { }): Promise => { const declared = declaredDependencies(options.packageDocument); if (declared.length === 0) return []; - const declarationReferences = await packedDeclarationReferences({ - packageDocument: options.packageDocument, - paths: options.packedPaths, - projectRoot: options.projectRoot, - }); - const installScripts = await installScriptDependencies({ + // `prepack` runs the build before this inventory, and `AB6005` there refuses every bare import in a compiled + // bundle, so any `import` evidence found here belongs to a packed module the framework did not compile; the + // `require`/`createRequire`/`import.meta.resolve` evidence is not an import and may come from any packed file. + const imported = await importedPackageNames({ declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), + dependencyRoot: options.dependencyRoot, packageDocument: options.packageDocument, + paths: options.packedPaths, projectRoot: options.projectRoot, }); - const used = new Set([...options.declaredRuntimeDependencies, ...declarationReferences, ...installScripts.names]); // The tarball itself may carry the dependency: `bundleDependencies` exempts an entry only when npm actually // packed it (a name absent from node_modules at pack time is silently dropped, and the consumer neither // fetches nor finds it), and a `file:` path inside the package is installed from the consumer's own copy @@ -188,31 +195,29 @@ const dependencyDiagnostics = async (options: { const unresolvable = declared.filter((dependency) => !embedded(dependency) && (dependency.installed ? kinds.get(dependency) !== 'registry' : kinds.get(dependency) === 'unparseable')); // An optional dependency npm parses but cannot fetch: the install continues without it — unless a consumer - // install script then runs it and fails on the missing package. + // install script then runs it, or loads it from a packed file it runs, and fails on the missing package. const survivable = (dependency: DeclaredDependency): boolean => dependency.field === 'optionalDependencies' && kinds.get(dependency) === 'fetched' - && !installScripts.needed.has(dependency.name); - const unused = declared.filter((dependency) => dependency.installed && !used.has(dependency.name)); - const inlinedSentence = (own: readonly DeclaredDependency[]): string | undefined => { - const inlined = own.flatMap((dependency) => { - const bundles = options.bundledInto.get(dependency.name); - return bundles === undefined ? [] : [`${JSON.stringify(dependency.name)} into ${bundles.join(' and ')}`]; - }); - return inlined.length === 0 ? undefined : `The build inlined ${inlined.join(', and ')}; every consumer installs them for nothing.`; - }; + && !imported.installScripts.has(dependency.name); + // A computed import() may load any declared package; nothing can then be called unused. + const unused = imported.complete + ? declared.filter((dependency) => dependency.installed + && !imported.names.has(dependency.name) + && !options.declaredRuntimeDependencies.has(dependency.name)) + : []; return [ // A peer nothing imports may be a deliberate compatibility contract with the host that loads the package; // npm 7+ still installs it for every consumer, so it is worth a look, not a refusal. ...perField(unused, (field, own) => diagnostic( 'AB7014', - `package.json ${field} names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ${quoteAll(own.map((dependency) => dependency.name))}. ` - + (inlinedSentence(own) ?? (field === 'peerDependencies' + `package.json ${field} names packages no packed JavaScript or declaration file references, runs, or install script needs, and no prebuilt payload declares: ${quoteAll(own.map((dependency) => dependency.name))}. ` + + (field === 'peerDependencies' ? 'If they only constrain the host version, that is a compatibility contract; npm 7+ still installs them for every consumer.' - : 'Nothing packed reaches them at runtime; every consumer installs them for nothing.')), + : 'Every consumer installs them for nothing; the emitted outputs already inline what they use.'), field === 'peerDependencies' ? 'Keep a deliberate compatibility peer, mark it optional in peerDependenciesMeta so npm stops installing it, or move a build-only package to devDependencies.' - : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).', + : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed file requires or resolves (createRequire, import.meta.resolve), a packed declaration file references, a #subpath import reaches through the imports map, an install script or packed JavaScript runs, or a prebuilt payload names in runtimeDependencies (definePrebuilt); a computed import() or require() in packed code withholds this check.', field === 'peerDependencies' ? 'warning' : 'error', )), // npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed @@ -233,7 +238,6 @@ const dependencyDiagnostics = async (options: { }; export const packInventoryDiagnostics = async (options: { - readonly artifactRoot: string; readonly model: NormalizedPlugin; readonly packageBuild: PackageBuildResult; readonly packOutput: PackOutput; @@ -241,21 +245,13 @@ export const packInventoryDiagnostics = async (options: { readonly packerRewritesWorkspaceProtocols: boolean; readonly projectRoot: string; }): Promise => { - const projectRoot = resolve(options.projectRoot); - const artifactRoot = resolve(options.artifactRoot); - const artifactPrefix = toPosixRelative(projectRoot, artifactRoot); - const packagePrefix = toPosixRelative(projectRoot, options.packageBuild.outputRoot); - const manifestPath = join(artifactRoot, artifactManifestName); + const packageRoot = resolve(options.packageBuild.outputRoot); + const manifestPath = join(packageRoot, artifactManifestName); const manifest = parseArtifactManifest(await runWithPlatform(readFileString(manifestPath))); - const packageDocument = await jsonRecord(join(projectRoot, 'package.json')); + const packageDocument = await jsonRecord(join(packageRoot, 'package.json')); const packed = new Set(options.packOutput.files.map((file) => file.path.replace(/^\.\//u, ''))); const expected = new Set([ - ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), - `${artifactPrefix}/${artifactManifestName}`, - // Every emitted file is manifested, the install surface included: the - // artifact validator (`AB6023`/`AB6024`) already judged its presence by - // adapter identity, so the pack expects exactly what the manifest lists. - ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), + ...options.packageBuild.files.map((file) => file.path), 'README.md', ]); @@ -265,34 +261,53 @@ export const packInventoryDiagnostics = async (options: { diagnostics.push(diagnostic( 'AB7010', `npm pack omits expected files: ${quoteAll(missing)}.`, - 'Add the exact paths (including dist and the artifact directory) to the package.json "files" allowlist.', + 'Pack the generated npm root without excluding its files.', )); } const stale: string[] = []; for (const file of manifest.files) { - const bytes = await runWithPlatform(readFileBytes(join(artifactRoot, file.path))); - if (sha256Hex(bytes) !== file.sha256) stale.push(`${artifactPrefix}/${file.path}`); + const bytes = await runWithPlatform(readFileBytes(join(packageRoot, file.path))); + if (sha256Hex(bytes) !== file.sha256) stale.push(file.path); } if (stale.length > 0) { diagnostics.push(diagnostic( 'AB7011', - `Artifact files no longer match their manifest hashes: ${quoteAll(stale.sort())}.`, - 'Run agent-bundle prepack again without modifying generated artifacts.', + `Packed npm-root files no longer match their manifest hashes: ${quoteAll(stale.sort())}.`, + 'Run agent-bundle prepack again without modifying the generated npm root.', + )); + } + + try { + const evidence = parseCompileEvidenceRecord( + await runWithPlatform(readFileString(join(packageRoot, packageCompileEvidenceFileName))), + ); + const artifactPaths = new Set(manifest.files.map((file) => file.path)); + diagnostics.push(...compileEvidenceDiagnostics( + evidence, + new Map(options.packageBuild.files + .filter((file) => file.kind === 'bundle' && !artifactPaths.has(file.path)) + .map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), + )); + } catch (error) { + diagnostics.push(diagnostic( + 'AB6039', + `Package compile evidence cannot be read: ${errorMessage(error)}.`, + 'Rebuild the generated npm root and do not modify its compile evidence sidecar.', )); } const invalidBins = binEntries(packageDocument.bin) .filter(([, target]) => { const normalized = target.replace(/^\.\//u, ''); - return !normalized.startsWith(`${packagePrefix}/`) || normalized.startsWith('src/') || !packed.has(normalized); + return normalized.startsWith('src/') || !packed.has(normalized); }); if (invalidBins.length > 0) { diagnostics.push(diagnostic( 'AB7012', - `package.json bins must name packed dist outputs: ${invalidBins.map(([name, target]) => + `package.json bins must name files in the packed npm root: ${invalidBins.map(([name, target]) => `${JSON.stringify(name)} -> ${JSON.stringify(target)}`).join(', ')}.`, - 'Point every package.json bin value at its generated file under dist/bin and include that file in "files".', + 'Point routed CLIs at their manifest-declared bin/.mjs and authored bins at generated bin/*.js files.', )); } @@ -307,7 +322,7 @@ export const packInventoryDiagnostics = async (options: { for (const projection of manifest.projections) { const path = projection.documents.plugin; if (path === undefined) continue; - const absolute = join(artifactRoot, path); + const absolute = join(packageRoot, path); if (await exists(absolute)) { versions.push([path, (await jsonRecord(absolute)).version]); } @@ -325,17 +340,13 @@ export const packInventoryDiagnostics = async (options: { )); } - const bundledInto = new Map([...Map.groupBy( - options.packageBuild.evidence.assets.flatMap((asset) => asset.packages.map((name) => ({ name, path: asset.path }))), - (entry) => entry.name, - )].map(([name, entries]) => [name, entries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right))])); diagnostics.push(...await dependencyDiagnostics({ - bundledInto, declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)), + dependencyRoot: resolve(options.projectRoot), packageDocument, packedPaths: [...packed], packerRewritesWorkspaceProtocols: options.packerRewritesWorkspaceProtocols, - projectRoot, + projectRoot: packageRoot, })); return deepFreeze(diagnostics.sort((left, right) => left.code.localeCompare(right.code))); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index e10f10e69..61215b53a 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -1,16 +1,25 @@ import { existsSync } from 'node:fs'; -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { rspack } from '@rslib/core'; -import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; +import { + parseJsonWithoutDuplicateKeys, + snapshotStrictJsonValue, + type JsonValue, +} from '../core/strict-json.ts'; +import type { + AgentBundleToolsConfig, + NormalizedPackageBuild, + NormalizedPlugin, +} from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; -import { cliBinSourceInputs } from './cli-bins.ts'; import { - accountedRequestsOf, createCompileEvidenceRecord, + packageCompileEvidenceFileName, + serializeCompileEvidenceRecord, type CompileEvidenceRecord, } from './compile-evidence.ts'; import type { CompileResult } from './compile-result.ts'; @@ -19,30 +28,23 @@ import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaratio import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; import { - cliEntryRuntimePath, - cliEntryRuntimeSpecifier, - generatedCliBinEntrySource, generatedExecutableEntrySource, - generatedInstallBinEntrySource, - generatedRenderedRouteWorkerSource, - installEntryRuntimePath, - installEntryRuntimeSpecifier, terminalCapabilityRuntimePath, terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; +import { readArtifactManifest } from './manifest-file.ts'; +import { artifactManifestName, type ArtifactManifest, type ArtifactManifestFileKind } from './manifest.ts'; import { projectMeta } from './meta.ts'; +import { bundleSyntaxCheckFor } from './module-imports.ts'; import { isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; import { validateJavaScriptModules } from './validate-artifact-modules.ts'; /** - * The framework-owned npm package build: `bin` entries become self-executing - * `dist/bin/.js` bundles (shebang + executable bit) and the `lib` entry - * becomes `dist/.js` (+ a bundleless `.d.ts` declaration graph), all - * through the same Rslib synthesis, invariant assertions, staged atomic - * publication, and self-containment rule (`AB6005`) as artifact executables. - * This is the build audiobook-curator previously needed a second bundler - * config, a tsconfig, and a hand-written bin shim to produce. + * The framework-owned npm root: the proven artifact tree is copied unchanged, + * then authored package-only bins and the optional library entry are added. + * Routed CLI bins are never recompiled here; package.json points at the + * artifact executable recorded by agent-bundle.manifest.json. */ const binShebang = '#!/usr/bin/env node'; @@ -50,7 +52,7 @@ const executableMode = 0o755; export interface PackageOutputFile { readonly bytes: number; - readonly kind: 'bundle' | 'generated'; + readonly kind: ArtifactManifestFileKind; /** Present only for executable outputs. */ readonly mode?: number; /** POSIX path relative to the package output root. */ @@ -61,7 +63,7 @@ export interface PackageOutputFile { } export interface PackageBuildResult { - /** Compile evidence for the emitted files, paths as a consumer sees them (`dist/bin/.js`); in process only, since `dist` has no manifest to bind it to. */ + /** Persisted compile evidence for package-only entries, with paths relative to the npm root. */ readonly evidence: CompileEvidenceRecord; readonly files: readonly PackageOutputFile[]; readonly outputRoot: string; @@ -116,72 +118,12 @@ export const synthesizeDtsTsconfig = async (options: { export const planPackageEntries = async ( model: NormalizedPlugin, dtsTsconfigPath: string | undefined, - options: { - readonly artifactRoot?: string; - readonly packageOutputRoot?: string; - } = {}, ): Promise => { const packageBuild = model.packageBuild; if (packageBuild === undefined) return Object.freeze([]); const entries: PlannedPackageEntry[] = []; for (const bin of packageBuild.bins) { - if (bin.generatedCli !== undefined) { - // A routed-CLI bin compiles the framework-generated command program; - // the cli-entry runtime shell is aliased in so the emitted executable - // stays self-contained, exactly like generated stdio MCP entries. - // Rendered commands add one sibling react-server Flight worker. - const rendered = bin.generatedCli.commands.some((command) => command.rendered); - const workerFile = `${bin.name}-flight.mjs`; - const sourceInputs = cliBinSourceInputs(model, bin); - const generatedCli = bin.generatedCli; - entries.push({ - aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, - banner: binShebang, - executable: true, - name: `bin-${bin.name}`, - outputRelativePath: `bin/${bin.name}.js`, - ...(rendered ? { rscManifest: true as const } : {}), - source: bin.source, - sourceInputs, - virtualSource: generatedCliBinEntrySource({ - commands: generatedCli.commands, - plugin: { - ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), - name: model.metadata.name, - version: model.metadata.version, - }, - ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), - providers: model.providers ?? [], - ...(generatedCli.projectionSources === undefined - ? {} - : { projectionSources: generatedCli.projectionSources }), - routes: generatedCli.routes, - ...(model.state === undefined ? {} : { state: model.state }), - ...(rendered ? { workerFile } : {}), - }), - }); - if (rendered) { - const renderedRoutes = bin.generatedCli.routes.filter((route) => - bin.generatedCli!.commands.some((command) => command.rendered && command.routeId === route.id)); - entries.push({ - executable: false, - name: `bin-${bin.name}-flight`, - outputRelativePath: `bin/${workerFile}`, - reactServer: true, - rscManifest: true, - source: bin.source, - sourceInputs, - virtualSource: generatedRenderedRouteWorkerSource({ - layouts: model.layouts ?? [], - ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), - providers: model.providers ?? [], - routes: renderedRoutes, - ...(model.state === undefined ? {} : { state: model.state }), - }), - }); - } - continue; - } + if (bin.generatedCli !== undefined) continue; // A bin entry exporting `main` (or a default function) receives the // generated process envelope; a self-executing module bundles directly. const exports = await scanEntryExports(bin.source); @@ -203,42 +145,6 @@ export const planPackageEntries = async ( }), }); } - const installHosts = Object.freeze((['claude', 'codex', 'cursor'] as const) - .filter((host) => model.targets.some((target) => target.name === host))); - if ( - installHosts.length > 0 && - options.artifactRoot !== undefined && - options.packageOutputRoot !== undefined - ) { - const occupiedNames = new Set(packageBuild.bins.map((bin) => bin.name)); - let name = model.metadata.name; - if (occupiedNames.has(name)) { - name = `${model.metadata.name}-install`; - let suffix = 2; - while (occupiedNames.has(name)) { - name = `${model.metadata.name}-install-${String(suffix)}`; - suffix += 1; - } - } - const outputRelativePath = `bin/${name}.js`; - const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath)); - const relativeArtifact = toPosixRelative(emittedBinDirectory, options.artifactRoot); - const source = packageBuild.bins[0]?.source ?? packageBuild.lib!.source; - entries.push({ - aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() }, - banner: binShebang, - executable: true, - name: `bin-${name}`, - outputRelativePath, - source, - sourceInputs: Object.freeze([model.metadata.provenance.sourcePath, source]), - virtualSource: generatedInstallBinEntrySource({ - artifactRelativeUrl: relativeArtifact === '' ? './' : `${relativeArtifact}/`, - hosts: installHosts, - name, - }), - }); - } if (packageBuild.lib !== undefined) { const lib = packageBuild.lib; entries.push({ @@ -292,8 +198,174 @@ const declarationSource = (sourceDir: string, declarationPath: string): string | return undefined; }; +const packagePath = (outputDir: string, value: JsonValue): JsonValue => { + if (typeof value === 'string') { + const directory = outputDir.replaceAll('\\', '/'); + const explicitPrefix = `./${directory}/`; + if (value.startsWith(explicitPrefix)) return `./${value.slice(explicitPrefix.length)}`; + const prefix = `${directory}/`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; + } + if (Array.isArray(value)) return value.map((entry) => packagePath(outputDir, entry)); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, packagePath(outputDir, entry)])); + } + return value; +}; + +const consumerLifecycleScripts = new Set(['install', 'postinstall', 'preinstall']); +const omittedPackageScripts = new Set([ + 'postpack', + 'postpublish', + 'prepare', + 'prepack', + 'prepublish', + 'prepublishOnly', + 'publish', +]); + +const sourceBins = (value: JsonValue | undefined): ReadonlyMap => { + if (value === undefined || value === null || Array.isArray(value) || typeof value !== 'object') return new Map(); + return new Map(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); +}; + +const packageScripts = (options: { + readonly bins: Readonly>; + readonly outputDir: string; + readonly packageJsonPath: string; + readonly stagedPaths: ReadonlySet; + readonly source: JsonValue; + readonly sourceBin: JsonValue | undefined; +}): JsonValue => { + const { source } = options; + if (source === null || Array.isArray(source) || typeof source !== 'object') return source; + const authoredBins = sourceBins(options.sourceBin); + const generatedBySource = new Map([...authoredBins].flatMap(([name, path]) => { + const generated = options.bins[name]; + return generated === undefined ? [] : [[path.replace(/^\.\//u, ''), generated] as const]; + })); + const scripts: Record = {}; + for (const [name, value] of Object.entries(source)) { + if (omittedPackageScripts.has(name)) continue; + if (!consumerLifecycleScripts.has(name) || typeof value !== 'string') { + scripts[name] = value; + continue; + } + const node = /^node\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s*$/u.exec(value); + if (node === null) { + if (/\bnode(?:\s|$)/u.test(value)) { + throw new DiagnosticError([{ + code: 'AB4768', + message: `package.json scripts.${name} uses an unsupported Node lifecycle command: ${JSON.stringify(value)}.`, + recovery: 'Use one `node ` command, or invoke an executable supplied by a declared runtime dependency.', + severity: 'error', + sourcePath: options.packageJsonPath, + }]); + } + scripts[name] = value; + continue; + } + const capturedPath = node[1] ?? node[2] ?? node[3]; + if (capturedPath === undefined) throw new TypeError('Matched package lifecycle command has no path.'); + const sourcePath = capturedPath.replace(/^\.\//u, ''); + const generated = generatedBySource.get(sourcePath); + const rebased = generated ?? packagePath(options.outputDir, sourcePath); + if (typeof rebased !== 'string') throw new TypeError('Package lifecycle path rebasing must return a string.'); + const relativePath = rebased.replace(/^\.\//u, ''); + if (!options.stagedPaths.has(relativePath)) { + throw new DiagnosticError([{ + code: 'AB4768', + message: `package.json scripts.${name} runs ${JSON.stringify(sourcePath)}, but the generated npm root does not contain ${JSON.stringify(relativePath)}.`, + recovery: 'Point the lifecycle at a generated bin or library output, or move the command into a declared runtime dependency.', + severity: 'error', + sourcePath: options.packageJsonPath, + }]); + } + scripts[name] = `node ${JSON.stringify(`./${relativePath}`)}`; + } + return scripts; +}; + +const packageDocument = async ( + projectRoot: string, + packageBuild: NormalizedPackageBuild, + manifest: ArtifactManifest, + stagedPaths: ReadonlySet, +): Promise>> => { + const source = snapshotStrictJsonValue( + parseJsonWithoutDuplicateKeys(await readFile(join(projectRoot, 'package.json'), 'utf8')), + ); + if (source === null || Array.isArray(source) || typeof source !== 'object') { + throw new Error('package.json must contain a JSON object.'); + } + const sourceObject = source as Readonly>; + const bins = Object.fromEntries(packageBuild.bins.map((bin) => { + if (bin.generatedCli === undefined) return [bin.name, `./bin/${bin.name}.js`]; + const executable = manifest.executables.bins.find((entry) => entry.name === bin.name); + if (executable === undefined) { + throw new DiagnosticError([{ + code: 'AB4767', + message: `The npm package declares routed CLI ${JSON.stringify(bin.name)}, but the artifact manifest has no executable for it.`, + recovery: 'Select a target whose adapter publishes the cli capability, or set bin: false.', + severity: 'error', + sourcePath: bin.provenance.sourcePath, + }]); + } + return [bin.name, `./${executable.path}`]; + })); + const transformed = Object.fromEntries(Object.entries(sourceObject) + .filter(([key]) => !['bin', 'files'].includes(key)) + .map(([key, value]) => [ + key, + key === 'scripts' + ? packageScripts({ + bins, + outputDir: packageBuild.outputDir, + packageJsonPath: join(projectRoot, 'package.json'), + source: value, + sourceBin: sourceObject.bin, + stagedPaths, + }) + : ['exports', 'imports', 'main', 'module', 'types', 'typesVersions'].includes(key) + ? packagePath(packageBuild.outputDir, value) + : value, + ])); + return Object.freeze({ + ...transformed, + ...(Object.keys(bins).length === 0 ? {} : { bin: bins }), + }); +}; + +const copyArtifactRoot = async ( + artifactRoot: string, + stageRoot: string, + manifest: ArtifactManifest, +): Promise => { + for (const file of [...manifest.files, { path: artifactManifestName }]) { + const destination = resolveArtifactDestination(stageRoot, file.path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolveArtifactDestination(artifactRoot, file.path), destination); + } +}; + +const copyStandardPackageFiles = async ( + projectRoot: string, + stageRoot: string, +): Promise> => { + const sources = new Map(); + for (const name of ['README.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']) { + const source = join(projectRoot, name); + if (!existsSync(source)) continue; + const destination = join(stageRoot, name); + if (existsSync(destination)) throw new Error(`Package metadata collides with artifact file ${JSON.stringify(name)}.`); + await copyFile(source, destination); + sources.set(name, source); + } + return sources; +}; + export const buildPackageOutputs = async (options: { - readonly artifactRoot?: string; + readonly artifactRoot: string; readonly model: NormalizedPlugin; readonly projectRoot: string; readonly tools?: AgentBundleToolsConfig; @@ -301,73 +373,121 @@ export const buildPackageOutputs = async (options: { const packageBuild = options.model.packageBuild; if (packageBuild === undefined) return undefined; const projectRoot = resolve(options.projectRoot); + const artifactRoot = resolve(options.artifactRoot); + const manifestRead = await readArtifactManifest(artifactRoot); + if (manifestRead.status !== 'ok') { + throw new Error(`Cannot build npm root from ${JSON.stringify(manifestRead.path)}: ${manifestRead.status}.`); + } + const manifest = manifestRead.manifest; const outputRoot = assertInside(projectRoot, resolve(projectRoot, packageBuild.outputDir)); const libSourceDir = packageBuild.lib === undefined ? undefined : dirname(packageBuild.lib.source); const dtsTsconfig = packageBuild.lib?.dts === true && libSourceDir !== undefined ? await synthesizeDtsTsconfig({ projectRoot, sourceDir: libSourceDir }) : undefined; - const entries = await planPackageEntries(options.model, dtsTsconfig?.path, { - ...(options.artifactRoot === undefined ? {} : { artifactRoot: resolve(options.artifactRoot) }), - packageOutputRoot: outputRoot, - }); - if (entries.length === 0) { - await dtsTsconfig?.cleanup(); - return undefined; - } + const entries = await planPackageEntries(options.model, dtsTsconfig?.path); const stageParent = dirname(outputRoot); await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); + const compileRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.compile-`)); try { const ignoredRuntimeRoots = Object.freeze([...new Set([ - ...(entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) - ? [runtimeIgnoredRoot(cliEntryRuntimePath())] - : []), - ...(entries.some((entry) => entry.aliases?.[installEntryRuntimeSpecifier] !== undefined) - ? [runtimeIgnoredRoot(installEntryRuntimePath())] - : []), ...(entries.some((entry) => entry.aliases?.[terminalCapabilityRuntimeSpecifier] !== undefined) ? [runtimeIgnoredRoot(terminalCapabilityRuntimePath())] : []), ])]); - const publishedPrefix = toPosixRelative(projectRoot, outputRoot); - const compileResult = await buildPackageEntries({ - cwd: projectRoot, - diagnosticPathPrefix: publishedPrefix, - entries, - ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), - logLevel: 'error', - meta: projectMeta(options.model.metadata), - outputRoot: stageRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }, dtsTsconfig === undefined || packageBuild.lib === undefined - ? undefined - : { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path }); - const evidenceByPath = new Map(compileResult.assets.map((entry) => [entry.path, entry.sourceInputs])); + const compileResults = entries.length === 0 + ? [] + : [await buildPackageEntries({ + cwd: projectRoot, + diagnosticPathPrefix: toPosixRelative(projectRoot, outputRoot), + entries, + ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), + logLevel: 'error', + meta: projectMeta(options.model.metadata), + outputRoot: compileRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }, dtsTsconfig === undefined || packageBuild.lib === undefined + ? undefined + : { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path })]; + const evidenceByPath = new Map(compileResults.flatMap((result) => result.assets) + .map((entry) => [entry.path, entry.sourceInputs])); await Promise.all(entries .filter((entry) => entry.executable) - .map((entry) => chmod(resolveArtifactDestination(stageRoot, entry.outputRelativePath), executableMode))); + .map((entry) => chmod(resolveArtifactDestination(compileRoot, entry.outputRelativePath), executableMode))); + + await copyArtifactRoot(artifactRoot, stageRoot, manifest); + for (const file of await listArtifactFiles(compileRoot)) { + const destination = resolveArtifactDestination(stageRoot, file.path); + if (existsSync(destination)) throw new Error(`Package output collides with artifact file ${JSON.stringify(file.path)}.`); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolveArtifactDestination(compileRoot, file.path), destination); + } + const packageJsonSource = join(projectRoot, 'package.json'); + if (existsSync(join(stageRoot, 'package.json'))) { + throw new Error('Package metadata collides with artifact file "package.json".'); + } + const stagedPaths = new Set((await listArtifactFiles(stageRoot)).map((file) => file.path)); + await writeFile( + join(stageRoot, 'package.json'), + `${JSON.stringify(await packageDocument(projectRoot, packageBuild, manifest, stagedPaths), null, 2)}\n`, + 'utf8', + ); + const packageSources = await copyStandardPackageFiles(projectRoot, stageRoot); + packageSources.set('package.json', packageJsonSource); + const evidence = await createCompileEvidenceRecord({ + results: compileResults, + rewritable: options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined, + root: stageRoot, + rspackVersion: rspack.rspackVersion, + }); + await writeFile( + join(stageRoot, packageCompileEvidenceFileName), + serializeCompileEvidenceRecord(evidence), + 'utf8', + ); + packageSources.set(packageCompileEvidenceFileName, packageJsonSource); const lib = packageBuild.lib; + const provenanceByPath = new Map(manifest.compiler.provenance.map((entry) => [entry.path, entry.sourceInputs])); + const artifactByPath = new Map(manifest.files.map((entry) => [entry.path, entry])); const staged = await listArtifactFiles(stageRoot); const files = staged.map((file): PackageOutputFile => { const bundled = evidenceByPath.get(file.path); + const artifact = artifactByPath.get(file.path); + const packageSource = packageSources.get(file.path); const declared = lib?.dts === true && libSourceDir !== undefined && file.path.endsWith('.d.ts') ? declarationSource(libSourceDir, file.path) ?? lib.source : undefined; - if (bundled === undefined && declared === undefined) { + if ( + bundled === undefined && + declared === undefined && + artifact === undefined && + packageSource === undefined && + file.path !== artifactManifestName + ) { throw new Error(`Package build emitted unexpected output ${JSON.stringify(file.path)}.`); } + let generatedSourceInputs: readonly string[]; + if (bundled !== undefined) { + generatedSourceInputs = bundled; + } else if (declared !== undefined) { + if (lib === undefined) { + throw new Error(`Declaration output has no package library entry: ${JSON.stringify(file.path)}.`); + } + generatedSourceInputs = [lib.provenance.sourcePath, declared]; + } else { + generatedSourceInputs = [packageSource ?? options.model.metadata.provenance.sourcePath]; + } return { bytes: file.bytes, - kind: bundled === undefined ? 'generated' : 'bundle', + kind: artifact?.kind ?? (bundled === undefined ? 'generated' : 'bundle'), ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), path: file.path, sha256: file.sha256, - sourceInputs: relativeSourceInputs( - projectRoot, - bundled ?? [lib!.provenance.sourcePath, declared!], - ), + sourceInputs: artifact === undefined + ? relativeSourceInputs(projectRoot, generatedSourceInputs) + : provenanceByPath.get(file.path) ?? Object.freeze([]), }; }).sort((left, right) => left.path.localeCompare(right.path)); for (const entry of entries) { @@ -379,35 +499,33 @@ export const buildPackageOutputs = async (options: { throw new Error(`Package build did not emit expected declarations ${JSON.stringify(`${lib.name}.d.ts`)}.`); } - const rewritable = options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined; - const evidence = await createCompileEvidenceRecord({ - pathPrefix: publishedPrefix, - results: [compileResult], - rewritable, - root: stageRoot, - rspackVersion: rspack.rspackVersion, - }); - // The npm form of the plugin is held to the same line as its host packs. - // The bundles the compiler emitted are lexed and their imports held to - // the record — a Node built-in or a recorded external passes, an - // expression `import()` or an import the build ignored is reported — - // unless a `tools` hatch may have rewritten the emitted bytes, in which - // case every module is parsed in full and its imports resolved. - // Declarations are not modules and are not walked; they may still - // reference declared dependencies. + // The npm form of the plugin is held to the same line as its host packs: + // every emitted `dist` module is walked as an ES module, and a bare + // specifier that is not a Node built-in — an import the `tools` hatch + // kept external — fails the build (`AB6005`) before `dist` is published, + // so a `dist/bin` executable imports nothing from a consumer's + // `node_modules`. The walk reads import specifiers (static and literal + // dynamic); a `createRequire(…)(…)` or `import.meta.resolve(…)` call is + // not an import and is outside it, in `dist` as in a host pack — the + // prepack gate reads those as dependency evidence. Declarations are not + // modules and are not walked; they may still reference declared + // dependencies. const selfContainment = await validateJavaScriptModules({ artifactRoot: stageRoot, - files: staged, - provenModules: rewritable ? new Map() : accountedRequestsOf(evidence, publishedPrefix), - reportedRoot: publishedPrefix, + bundledPaths: new Set(files.filter((file) => file.kind === 'bundle').map((file) => file.path)), + bundleSyntaxCheck: bundleSyntaxCheckFor(options.tools), + files: staged.filter((file) => !artifactByPath.has(file.path)), + reportedRoot: toPosixRelative(projectRoot, outputRoot), validJson: new Set(), }); if (selfContainment.length > 0) throw new DiagnosticError(selfContainment); + await publishArtifact({ outputRoot, stageRoot }); return Object.freeze({ evidence, files: Object.freeze(files), outputRoot }); } finally { // publishArtifact removes the stage on success; a failed build leaves it. await rm(stageRoot, { force: true, recursive: true }); + await rm(compileRoot, { force: true, recursive: true }); await dtsTsconfig?.cleanup(); } }; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index cf4b92038..deca1d4d7 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -1,5 +1,5 @@ import { Cause, Deferred, Effect, Exit, Result, Semaphore } from 'effect'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { freezeDiagnostics, hasErrors } from '../core/diagnostics.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; @@ -552,7 +552,8 @@ export class DevCoordinator { // after the artifact epoch committed: its failure never invalidates the // epoch and surfaces as warning diagnostics on the succeeded attempt. const packageDiagnostics = result.outcome === 'succeeded' - ? (yield* liftPromise(() => this.#packageBuildService.build(prepared, invalidation))).diagnostics + ? (yield* liftPromise(() => + this.#packageBuildService.build(prepared, invalidation, dirname(result.epoch.manifestPath)))).diagnostics : Object.freeze([]); const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics, ...packageDiagnostics]); if (result.outcome === 'succeeded') { diff --git a/packages/agent-bundle/src/dev/package-build-service.ts b/packages/agent-bundle/src/dev/package-build-service.ts index e88a7ace3..817a13743 100644 --- a/packages/agent-bundle/src/dev/package-build-service.ts +++ b/packages/agent-bundle/src/dev/package-build-service.ts @@ -45,7 +45,11 @@ export interface DevPackageBuildOutcome { } export interface DevPackageBuilder { - build(prepared: PreparedProject, invalidation: Invalidation): Promise; + build( + prepared: PreparedProject, + invalidation: Invalidation, + artifactRoot?: string, + ): Promise; } export interface DevPackageBuildServiceOptions { @@ -106,7 +110,11 @@ export class DevPackageBuildService implements DevPackageBuilder { this.#run = platformRunOf(options.platformRuntime); } - async build(prepared: PreparedProject, invalidation: Invalidation): Promise { + async build( + prepared: PreparedProject, + invalidation: Invalidation, + artifactRoot = join(prepared.root, prepared.artifactDistPath), + ): Promise { const model = prepared.model; if (model?.packageBuild === undefined) { this.#last = undefined; @@ -121,6 +129,7 @@ export class DevPackageBuildService implements DevPackageBuilder { } try { const result = await this.#buildOutputs({ + artifactRoot, model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts deleted file mode 100644 index 759db515d..000000000 --- a/packages/agent-bundle/src/install-entry.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { lstat } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; - -import { stableJson } from './core/digest.ts'; -import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; -import { formatInstallResult, formatUninstallResult } from './install/format.ts'; -import { - installBundle, - type InstallHost, - type InstallMode, - type InstallResult, - type InstallScope, -} from './install/install.ts'; -import { uninstallBundle, type UninstallResult } from './install/uninstall.ts'; - -export interface GeneratedInstallProcessOptions { - readonly artifactRoot: string | URL; - readonly hosts: readonly InstallHost[]; - readonly name: string; -} - -const usage = (options: GeneratedInstallProcessOptions): string => [ - `Usage: ${options.name} install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]`, - ` ${options.name} uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, - '', - `Built hosts: ${options.hosts.join(', ')}`, - '', - '--replace (alias --force) replaces an existing agent-bundle install of this plugin even when', - 'its version differs. Same-version content drift is replaced automatically; foreign installs', - 'are always refused.', - '', - 'uninstall removes exactly what the install receipt owns (files, directories, host registrations)', - 'and keeps durable runtime state unless --purge-data --confirm-purge is passed. It refuses a', - 'missing receipt or an owned-content mismatch unless --force; foreign directories are always', - 'refused. --plan prints the exact paths without changing anything.', - '', -].join('\n'); - -const diagnosticsFor = (error: unknown): readonly Diagnostic[] => - error instanceof DiagnosticError - ? error.diagnostics - : Object.freeze([Object.freeze({ - code: 'AB7004', - message: error instanceof Error ? error.message : String(error), - severity: 'error' as const, - })]); - -const isHost = (value: string): value is InstallHost => - value === 'claude' || value === 'codex' || value === 'cursor'; - -const isScope = (value: string): value is InstallScope => - value === 'local' || value === 'project' || value === 'user'; - -const isMode = (value: string): value is InstallMode => - value === 'local' || value === 'marketplace'; - -type InstallerVerb = 'install' | 'uninstall'; - -interface ParsedInstallArguments { - readonly confirmPurge: boolean; - readonly force: boolean; - readonly host: InstallHost; - readonly json: boolean; - readonly keepData: boolean; - readonly mode?: InstallMode; - readonly plan: boolean; - readonly purgeData: boolean; - readonly replace: boolean; - readonly scope: InstallScope; - readonly verb: InstallerVerb; -} - -const parseArguments = ( - argv: readonly string[], - options: GeneratedInstallProcessOptions, -): ParsedInstallArguments => { - const verb = argv[0]; - if (verb !== 'install' && verb !== 'uninstall') { - throw new TypeError(`Expected "install " or "uninstall "; built hosts: ${options.hosts.join(', ')}.`); - } - const candidate = argv[1]; - if (candidate === undefined || !isHost(candidate) || !options.hosts.includes(candidate)) { - throw new TypeError( - `Cannot ${verb} host ${JSON.stringify(candidate ?? '')}; built hosts: ${options.hosts.join(', ')}.`, - ); - } - let json = false; - let replace = false; - let force = false; - let keepData = false; - let purgeData = false; - let confirmPurge = false; - let plan = false; - let scope: InstallScope = 'user'; - let mode: InstallMode | undefined; - for (let index = 2; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === '--json') { - json = true; - continue; - } - if (argument === '--replace' && verb === 'install') { - replace = true; - continue; - } - if (argument === '--force') { - // `install --force` is the --replace alias; `uninstall --force` overrides a missing or mismatched receipt. - replace = true; - force = true; - continue; - } - if (argument === '--keep-data' && verb === 'uninstall') { - keepData = true; - continue; - } - if (argument === '--purge-data' && verb === 'uninstall') { - purgeData = true; - continue; - } - if (argument === '--confirm-purge' && verb === 'uninstall') { - confirmPurge = true; - continue; - } - if (argument === '--plan' && verb === 'uninstall') { - plan = true; - continue; - } - if (argument === '--mode') { - const value = argv[index + 1]; - if (value === undefined || !isMode(value)) { - throw new TypeError('Install mode must be local or marketplace.'); - } - mode = value; - index += 1; - continue; - } - if (argument === '--scope') { - const value = argv[index + 1]; - if (value === undefined || !isScope(value)) { - throw new TypeError('Install scope must be user, project, or local.'); - } - scope = value; - index += 1; - continue; - } - throw new TypeError(`Unknown installer argument ${JSON.stringify(argument)}.`); - } - return Object.freeze({ - confirmPurge, - force, - host: candidate, - json, - keepData, - ...(mode === undefined ? {} : { mode }), - plan, - purgeData, - replace, - scope, - verb, - }); -}; - -const writeResult = (parsed: ParsedInstallArguments, result: InstallResult | UninstallResult, human: string): void => { - process.stdout.write(parsed.json ? `${stableJson(result)}\n` : human); -}; - -export const runGeneratedInstallProcess = async ( - argv: readonly string[], - options: GeneratedInstallProcessOptions, -): Promise => { - if (argv.length === 0 || argv.includes('--help')) { - process.stdout.write(usage(options)); - return 0; - } - let parsed: ParsedInstallArguments | undefined; - try { - parsed = parseArguments(argv, options); - const artifactRoot = options.artifactRoot instanceof URL - ? fileURLToPath(options.artifactRoot) - : options.artifactRoot; - const metadata = await lstat(artifactRoot).catch(() => undefined); - if (metadata === undefined || !metadata.isDirectory()) { - throw new Error( - `Package artifact root is missing at ${JSON.stringify(artifactRoot)}; ` + - 'the package must ship its generated artifact directory.', - ); - } - switch (parsed.verb) { - case 'install': { - const result = await installBundle({ - from: artifactRoot, - host: parsed.host, - replace: parsed.replace, - ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), - scope: parsed.scope, - }); - writeResult(parsed, result, formatInstallResult(result)); - break; - } - case 'uninstall': { - const result = await uninstallBundle({ - confirmPurge: parsed.confirmPurge, - force: parsed.force, - from: artifactRoot, - host: parsed.host, - keepData: parsed.keepData, - ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), - plan: parsed.plan, - purgeData: parsed.purgeData, - scope: parsed.scope, - }); - writeResult(parsed, result, formatUninstallResult(result)); - break; - } - default: { - const exhaustive: never = parsed.verb; - throw new TypeError(`Unknown installer verb ${String(exhaustive)}.`); - } - } - return 0; - } catch (error) { - if (parsed?.json === true) { - process.stderr.write(`${stableJson(diagnosticsFor(error))}\n`); - } else { - const diagnostics = diagnosticsFor(error); - process.stderr.write(`${diagnostics.map((entry) => `${entry.code}: ${entry.message}`).join('\n')}\n`); - } - return 1; - } -}; diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 6f0b5f7b3..704c46689 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -257,13 +257,13 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(result.model.packageBuild?.bins).toMatchObject([ { name: 'cli-bin-fixture', provenance: { kind: 'conventional' } }, ]); - const binPath = join(root, 'dist', 'bin', 'cli-bin-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'cli-bin-fixture.mjs'); const binSource = await readFile(binPath, 'utf8'); expect(binSource.startsWith('#!/usr/bin/env node\n')).toBe(true); expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); expect((await stat(binPath)).mode & 0o111).not.toBe(0); // The emitted executable's provenance names every command route module. - const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/cli-bin-fixture.js'); + const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/cli-bin-fixture.mjs'); expect(binEvidence?.sourceInputs).toEqual(expect.arrayContaining([ 'src/cli/doctor.ts', 'src/cli/library/audit.ts', @@ -506,7 +506,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () beforeAll(async () => { // `process.cwd()` in the child is the resolved path; the fixture compares against it. root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-cli-projection-'))); - binPath = join(root, 'dist', 'bin', 'cli-projection-fixture.js'); + binPath = join(root, 'dist', 'bin', 'cli-projection-fixture.mjs'); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); await Promise.all([ writeProjectFile(root, 'package.json', JSON.stringify({ @@ -624,7 +624,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () { name: 'cli-projection-fixture', provenance: { kind: 'conventional' } }, ]); await expect(stat(binPath)).resolves.toMatchObject({}); - const evidence = built.packageBuild!.files.find((file) => file.path === 'bin/cli-projection-fixture.js'); + const evidence = built.packageBuild!.files.find((file) => file.path === 'bin/cli-projection-fixture.mjs'); expect(evidence?.sourceInputs).toEqual(expect.arrayContaining([ 'src/mcp/demo/tools/ping.tsx', 'src/mcp/demo/tools/purge.cli.ts', diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index d2d65ff84..5cc13a91f 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -368,17 +368,6 @@ describe('generated entry templates', () => { expect(path.endsWith('terminal-capability.ts') || path.endsWith('terminal-capability.js')).toBe(true); }); - it('defers installer filesystem URL conversion to the guarded runtime', () => { - const source = entryShellModule.generatedInstallBinEntrySource({ - artifactRelativeUrl: '../../artifact/', - hosts: ['cursor'], - name: 'installer', - }); - - expect(source).toContain('artifactRoot: new URL("../../artifact/", import.meta.url)'); - expect(source).not.toContain('fileURLToPath'); - }); - it('routes a rejected progress report into the generated request failure path', async () => { const generated = generatedRenderedScriptEntrySource({ name: 'report', diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts deleted file mode 100644 index ff4134c86..000000000 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -import { afterEach, expect, it } from '@rstest/core'; - -import { build } from '../src/api.ts'; -import { runDoctor } from '../src/install/doctor.ts'; -import { installReceiptFile } from '../src/install/receipt.ts'; -import { installBundle } from '../src/install/install.ts'; - -const execFile = promisify(executeFile); -const roots: string[] = []; -const workspaceNodeModules = join(process.cwd(), 'node_modules'); - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); -}); - -const fixture = async (options: { - readonly author?: string; - readonly bin?: false | readonly string[]; - readonly target: 'cursor' | 'portable' | readonly ('claude' | 'codex' | 'cursor')[]; -}): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-installer-entry-')); - roots.push(root); - await mkdir(join(root, 'src'), { recursive: true }); - await symlink(workspaceNodeModules, join(root, 'node_modules'), 'dir'); - await Promise.all([ - writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installer-fixture', - type: 'module', - version: '1.2.3', - })), - writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - ...(options.bin === undefined - ? [] - : options.bin === false - ? [' bin: false,'] - : [` bin: { ${options.bin.map((name) => `${JSON.stringify(name)}: './src/cli.ts'`).join(', ')} },`]), - ...(options.author === undefined ? [] : [` cursor: { author: { name: ${JSON.stringify(options.author)} } },`]), - " lib: './src/index.ts',", - " plugin: { name: 'installer-fixture' },", - ` targets: ${JSON.stringify(typeof options.target === 'string' ? [options.target] : options.target)},`, - '};', - '', - ].join('\n')), - writeFile(join(root, 'src', 'cli.ts'), 'export const main = async () => 0;\n'), - writeFile(join(root, 'src', 'index.ts'), 'export const value = 1;\n'), - ]); - return root; -}; - -const run = async ( - executable: string, - args: readonly string[], - options: { readonly cwd: string; readonly env?: NodeJS.ProcessEnv }, -): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { - try { - const result = await execFile(executable, [...args], options); - return { code: 0, stderr: result.stderr, stdout: result.stdout }; - } catch (error) { - const failure = error as { readonly code?: number; readonly stderr?: string; readonly stdout?: string }; - return { - code: typeof failure.code === 'number' ? failure.code : 1, - stderr: failure.stderr ?? '', - stdout: failure.stdout ?? '', - }; - } -}; - -it('builds a package-relative installer with fallback naming and built-host argv validation', async () => { - const root = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); - const result = await build({ - output: 'nested/non-default-host-packs', - packageOutputs: true, - root, - }); - const installer = join(root, 'dist', 'bin', 'installer-fixture-install.js'); - - expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture-install.js'); - expect((await stat(installer)).mode & 0o111).not.toBe(0); - expect(await readFile(installer, 'utf8')).not.toMatch(/from\s*['"]agent-bundle/u); - - const help = await run(installer, [], { cwd: tmpdir() }); - expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]'); - expect(help.stdout).toContain('uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]'); - expect(help.stdout).toContain('cursor'); - expect(help.stdout).not.toContain('claude'); - - const rejected = await run(installer, ['install', 'claude'], { cwd: tmpdir() }); - expect(rejected.code).toBe(1); - expect(rejected.stderr).toContain('claude'); - expect(rejected.stderr).toContain('cursor'); - - const artifactRoot = join(root, 'nested', 'non-default-host-packs'); - const hiddenArtifact = `${artifactRoot}-hidden`; - await rename(artifactRoot, hiddenArtifact); - const missing = await run(installer, ['install', 'cursor'], { cwd: tmpdir() }); - expect(missing.code).toBe(1); - expect(missing.stderr).toContain('Package artifact root is missing'); - expect(missing.stderr).toContain('must ship its generated artifact directory'); - await rename(hiddenArtifact, artifactRoot); - - const home = join(root, 'home'); - await mkdir(join(home, '.cursor'), { recursive: true }); - const installed = await run(installer, ['install', 'cursor', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(installed).toMatchObject({ code: 0, stderr: '' }); - const installedDocument = JSON.parse(installed.stdout) as { readonly contentHash?: string }; - expect(installedDocument).toMatchObject({ - contentHash: expect.stringMatching(/^[0-9a-f]{64}$/u), - host: 'cursor', - plugin: 'installer-fixture', - state: 'installed', - version: '1.2.3', - }); - const destination = join(home, '.cursor', 'plugins', 'local', 'installer-fixture'); - await expect(stat(destination)).resolves.toBeDefined(); - await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); - - // Both replace spellings parse; an identical artifact stays a no-op even when forced. - for (const flag of ['--replace', '--force']) { - const noop = await run(installer, ['install', 'cursor', flag, '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(noop, flag).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(noop.stdout), flag).toMatchObject({ contentHash: installedDocument.contentHash, state: 'already-installed' }); - } - - // Same-version content drift of the receipt-managed copy is replaced without a flag. - await writeFile(join(destination, 'INSTALL.md'), '# stale\n'); - const replaced = await run(installer, ['install', 'cursor'], { cwd: tmpdir(), env: { ...process.env, HOME: home } }); - expect(replaced).toMatchObject({ code: 0, stderr: '' }); - expect(replaced.stdout).toMatch(/^Replaced installer-fixture@1\.2\.3 for cursor \(local mode\) at .* \(content [0-9a-f]{12} -> [0-9a-f]{12}\)\n$/u); - - const unknown = await run(installer, ['install', 'cursor', '--overwrite'], { cwd: tmpdir() }); - expect(unknown.code).toBe(1); - expect(unknown.stderr).toContain('Unknown installer argument "--overwrite"'); - - const staged = await run(installer, ['install', 'cursor', '--mode', 'marketplace', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(staged).toMatchObject({ code: 0, stderr: '' }); - const repository = join(home, '.cursor', 'agent-bundle', 'marketplaces', 'installer-fixture'); - expect(JSON.parse(staged.stdout)).toMatchObject({ - commit: expect.stringMatching(/^[0-9a-f]{40}$/u), - destination: repository, - marketplace: 'installer-fixture-marketplace', - mode: 'marketplace', - state: 'staged', - }); - await expect(stat(join(repository, '.cursor-plugin', 'marketplace.json'))).resolves.toBeDefined(); - await expect(stat(join(repository, 'plugins', 'installer-fixture', '.cursor-plugin', 'plugin.json'))).resolves.toBeDefined(); - - const badMode = await run(installer, ['install', 'cursor', '--mode', 'remote'], { cwd: tmpdir() }); - expect(badMode.code).toBe(1); - expect(badMode.stderr).toContain('Install mode must be local or marketplace.'); - - // The same bin uninstalls: --plan is a no-op that names every path, the real run consumes the receipt and - // leaves nothing of the local copy behind, and --purge-data is refused without --confirm-purge. - const env = { ...process.env, HOME: home }; - const plan = await run(installer, ['uninstall', 'cursor', '--plan', '--json'], { cwd: tmpdir(), env }); - expect(plan).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(plan.stdout)).toMatchObject({ - destination, - mode: 'local', - receipt: { status: 'consumed' }, - state: 'planned', - }); - await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); - const unconfirmed = await run(installer, ['uninstall', 'cursor', '--purge-data'], { cwd: tmpdir(), env }); - expect(unconfirmed.code).toBe(1); - expect(unconfirmed.stderr).toContain('AB7008'); - const uninstalled = await run(installer, ['uninstall', 'cursor'], { cwd: tmpdir(), env }); - expect(uninstalled).toMatchObject({ code: 0, stderr: '' }); - expect(uninstalled.stdout).toMatch(/^Uninstalled installer-fixture@1\.2\.3 for cursor \(local mode\) at /u); - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }); - const again = await run(installer, ['uninstall', 'cursor', '--json'], { cwd: tmpdir(), env }); - expect(JSON.parse(again.stdout)).toMatchObject({ state: 'not-installed' }); - // Marketplace-mode staging from earlier in this test is removed the same way. - const marketplaceGone = await run(installer, ['uninstall', 'cursor', '--mode', 'marketplace', '--json'], { cwd: tmpdir(), env }); - expect(marketplaceGone).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(marketplaceGone.stdout)).toMatchObject({ mode: 'marketplace', state: 'uninstalled' }); - await expect(stat(repository)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(stat(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - const unknownVerbFlag = await run(installer, ['install', 'cursor', '--plan'], { cwd: tmpdir(), env }); - expect(unknownVerbFlag.code).toBe(1); - expect(unknownVerbFlag.stderr).toContain('Unknown installer argument "--plan"'); -}, 120_000); - -it('chooses an unused installer name when both primary candidates are bins', async () => { - const root = await fixture({ - bin: ['installer-fixture', 'installer-fixture-install'], - target: 'cursor', - }); - const result = await build({ output: 'host-packs', packageOutputs: true, root }); - - expect(result.packageBuild?.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'bin/installer-fixture.js', - 'bin/installer-fixture-install.js', - 'bin/installer-fixture-install-2.js', - ])); - const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install-2.js'), ['--help'], { cwd: tmpdir() }); - expect(help).toMatchObject({ code: 0, stderr: '' }); -}, 120_000); - -it('handles installer help when the project path contains a percent sign', async () => { - const originalRoot = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); - const root = `${originalRoot}%build`; - await rename(originalRoot, root); - roots.splice(roots.indexOf(originalRoot), 1, root); - - await build({ output: 'host-packs', packageOutputs: true, root }); - const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install.js'), ['--help'], { cwd: tmpdir() }); - - expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]'); -}, 120_000); - -it('uses the plugin name when free and skips portable-only artifacts', async () => { - const cursorRoot = await fixture({ author: 'Fixture Owner', bin: false, target: 'cursor' }); - const cursor = await build({ output: 'host-packs', packageOutputs: true, root: cursorRoot }); - expect(cursor.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); - - const portableRoot = await fixture({ bin: false, target: 'portable' }); - const portable = await build({ output: 'host-packs', packageOutputs: true, root: portableRoot }); - expect(portable.packageBuild?.files.map((file) => file.path)) - .not.toContain('bin/installer-fixture.js'); - // The Agent Plugins pack has no .cursor-plugin manifest, so its install.mjs must refuse marketplace mode. - const portableHome = join(portableRoot, 'home'); - await mkdir(join(portableHome, '.cursor'), { recursive: true }); - const portableMarketplace = await run( - process.execPath, - [join(portableRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: portableHome } }, - ); - expect(portableMarketplace.code).toBe(1); - expect(portableMarketplace.stderr).toContain('--mode marketplace requires a Cursor Plugin'); - await expect(stat(join(portableHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - - // A bundle carrying nested Git metadata would be committed as an empty gitlink; the emitted installer refuses it. - const cursorHome = join(cursorRoot, 'home'); - await mkdir(join(cursorHome, '.cursor'), { recursive: true }); - await mkdir(join(cursorRoot, 'host-packs', 'vendor', '.git'), { recursive: true }); - const nestedGit = await run( - process.execPath, - [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, - ); - expect(nestedGit.code).toBe(1); - expect(nestedGit.stderr).toContain('refuses bundle-internal Git metadata at "vendor/.git"'); - await expect(stat(join(cursorHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - await rm(join(cursorRoot, 'host-packs', 'vendor'), { recursive: true }); - - // The emitted install.mjs and `agent-bundle install cursor --mode marketplace` derive owner/description from the - // same emitted manifest (authored cursor.author here), so staging with one and rerunning the other is idempotent. - const stagedByScript = await run( - process.execPath, - [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, - ); - expect(stagedByScript).toMatchObject({ code: 0, stderr: '' }); - const stagedManifest = JSON.parse(await readFile( - join(cursorHome, '.cursor', 'agent-bundle', 'marketplaces', 'installer-fixture', '.cursor-plugin', 'marketplace.json'), - 'utf8', - )); - expect(stagedManifest.owner).toEqual({ name: 'Fixture Owner' }); - const rerunByCli = await installBundle({ - from: join(cursorRoot, 'host-packs'), - home: cursorHome, - host: 'cursor', - mode: 'marketplace', - }); - expect(rerunByCli).toMatchObject({ mode: 'marketplace', state: 'already-installed' }); - - const pluginRoot = await fixture({ bin: false, target: ['claude', 'codex', 'cursor'] }); - const plugin = await build({ output: 'host-packs', packageOutputs: true, root: pluginRoot }); - const pluginInstaller = join(pluginRoot, 'dist', 'bin', 'installer-fixture.js'); - expect(plugin.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); - const help = await run(pluginInstaller, ['--help'], { cwd: tmpdir() }); - expect(help.stdout).toContain('claude, codex, cursor'); - const home = join(pluginRoot, 'home'); - await mkdir(join(home, '.cursor'), { recursive: true }); - const doctor = await runDoctor({ - from: join(pluginRoot, 'host-packs'), - home, - hosts: ['cursor'], - }); - expect(doctor.hosts[0]?.bundle).toMatchObject({ - bundleRoot: join(pluginRoot, 'host-packs'), - name: 'installer-fixture', - version: '1.2.3', - }); - const installed = await run(pluginInstaller, ['install', 'cursor', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(installed).toMatchObject({ code: 0, stderr: '' }); -}, 120_000); diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index b3c35d4e5..fca3b1e69 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -442,15 +442,12 @@ it('uses only an installed tarball after source deletion', async () => { expect(packageModules.violations).toEqual([]); expect(artifactModules.modules.filter((module) => /^mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(1); expect(packageModules.modules.map((module) => module.path)).toEqual(expect.arrayContaining([ - 'bin/framework-build-fixture-install.js', 'bin/framework-build-fixture.js', 'index.js', ])); const packedBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture.js'); - const packedInstallerBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture-install.js'); expect((await stat(packedBin)).mode & 0o111).not.toBe(0); - expect((await stat(packedInstallerBin)).mode & 0o111).not.toBe(0); expect((await readFile(packedBin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true); await expect(execFile(packedBin, ['alpha'], { cwd: frameworkRoot, env: installedEnvironment() })) .resolves.toMatchObject({ stdout: 'packed bin ran:alpha\n' }); diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index c69d27560..920c510de 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile, type ChildProcess } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -13,7 +13,12 @@ import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from '../src/web-host/ import { WEB_HOST_TOKEN_HEADER } from '../src/web-host/page.ts'; import { awaitStdoutLine, connectionRefused, isProcessGone, killAll, runBin, type BinRun } from './support/bin-process.ts'; import { eventuallyPasses, within } from './support/eventually.ts'; -import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; +import { + cachedNpmInstallArguments, + installedEnvironment, + packOutputFromJson, + sharedPackedTarball, +} from './support/shared-pack.ts'; import { timeScale } from './support/time-scale.ts'; const execFile = promisify(executeFile); @@ -45,7 +50,10 @@ const webReadyKeys: readonly (keyof WebReadyDocument)[] = ['app', 'port', 'resou let consumer = ''; let project = ''; let artifact = ''; +let packageRoot = ''; +let artifactBin = ''; let bin = ''; +let packedPaths: readonly string[] = []; const spawned = new Set(); const observedProcessIds = new Set(); @@ -80,6 +88,7 @@ beforeAll(async () => { consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-web-command-')); project = join(consumer, 'project'); artifact = join(project, 'artifact'); + packageRoot = join(project, 'dist'); await cp(fixtureRoot, project, { recursive: true }); // The generated routed-CLI bin resolves `@agent-bundle/runtime` (and its // React peer) from the consumer, exactly like the packed stdio proof. @@ -96,7 +105,38 @@ beforeAll(async () => { cwd: project, env: installedEnvironment(), }); - bin = join(artifact, 'bin', `${pluginName}.mjs`); + artifactBin = join(artifact, 'bin', `${pluginName}.mjs`); + + const tarballs = join(consumer, 'tarballs'); + const installedConsumer = join(consumer, 'installed-consumer'); + await Promise.all([ + mkdir(tarballs), + mkdir(installedConsumer), + ]); + await writeFile(join(installedConsumer, 'package.json'), '{"private":true}\n'); + const { stdout: packJson } = await execFile('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + tarballs, + ], { cwd: packageRoot, env: installedEnvironment() }); + const packed = packOutputFromJson(packJson, pluginName); + packedPaths = packed.files.map((file) => file.path); + await execFile('npm', [ + 'install', + ...cachedNpmInstallArguments, + join(tarballs, packed.filename), + ], { cwd: installedConsumer, env: installedEnvironment() }); + const installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); + const packageDocument = JSON.parse( + await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), + ) as { readonly bin?: Readonly> }; + const declaredBin = packageDocument.bin?.[pluginName]; + if (declaredBin === undefined) { + throw new Error(`Packed ${pluginName} package does not declare its executable.`); + } + bin = resolve(installedPackageRoot, declaredBin); // `packed-deleted-source`: the bin serves out of the artifact alone, so the // config, the routes, the server, and the App view are removed and verified // absent before any process runs. @@ -159,6 +199,39 @@ it('builds the exposed App into the composite root: a manifest web section, one expect(source).toContain(WEB_HOST_SEED_ELEMENT_ID); }); +it('packs the composite root as the npm root and runs the package.json bin with the artifact command surface byte-for-byte', async () => { + expect(packedPaths).toContain('agent-bundle.manifest.json'); + expect(packedPaths).toContain(`bin/${pluginName}.mjs`); + expect(packedPaths.some((path) => path.startsWith('artifact/'))).toBe(false); + await expect(readFile(bin)).resolves.toEqual(await readFile(artifactBin)); + + const [artifactHelp, packedHelp] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['--help'])); + await Promise.all([ + expect(within(artifactHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedHelp.stdout()).toBe(artifactHelp.stdout()); + expect(packedHelp.stdout()).toMatch(/^\s+dashboard\b/mu); + expect(packedHelp.stdout()).toMatch(/^\s+web\b/mu); + + const [artifactDashboard, packedDashboard] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['dashboard', '--json'])); + await Promise.all([ + expect(within(artifactDashboard.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedDashboard.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedDashboard.stdout()).toBe(artifactDashboard.stdout()); + + const [artifactWebHelp, packedWebHelp] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['web', '--help'])); + await Promise.all([ + expect(within(artifactWebHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedWebHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedWebHelp.stdout()).toBe(artifactWebHelp.stdout()); +}); + it('serves the App from `web --json --no-open` as a real process out of the deleted-source consumer, gates its routes by token, and tears down on SIGINT', { timeout: 120_000 }, async () => { const run = spawnBin(bin, ['web', '--no-open', '--json']); const line = await awaitStdoutLine(run, (candidate) => candidate.startsWith('{'), startupBudget); diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 0983a234f..ada7cb91a 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -1,13 +1,12 @@ -import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { promisify } from 'node:util'; +import { dirname, join, relative } from 'node:path'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; import { prepack } from '../src/api.ts'; +import { packageCompileEvidenceFileName } from '../src/build/compile-evidence.ts'; import { runCli } from '../src/cli.ts'; import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; import type { NormalizedPayload } from '../src/core/types.ts'; @@ -17,9 +16,7 @@ import { packOutputFromJson, type PackOutput, } from '../src/build/pack-inventory.ts'; -import type { PackageBuildResult } from '../src/build/package-build.ts'; -const execFile = promisify(executeFile); const workspaceNodeModules = join(process.cwd(), 'node_modules'); /** A gzipped ustar archive in npm's layout: one `package/package.json` entry with the given manifest text. */ @@ -53,8 +50,6 @@ beforeAll(async () => { await symlink(workspaceNodeModules, join(projectRoot, 'node_modules'), 'dir'); await Promise.all([ writeFile(join(projectRoot, 'package.json'), `${JSON.stringify({ - bin: { 'installer-fixture': './dist/bin/installer-fixture.js' }, - files: ['dist', 'host-packs', 'README.md'], name: 'installer-fixture', type: 'module', version: '1.2.3', @@ -73,7 +68,7 @@ beforeAll(async () => { writeFile(join(projectRoot, 'src', 'index.ts'), 'export const value = 1;\n'), ]); result = await prepack({ root: projectRoot }); - payloadPath = join(projectRoot, 'host-packs', 'INSTALL.md'); + payloadPath = join(projectRoot, 'dist', 'INSTALL.md'); payloadBytes = await readFile(payloadPath, 'utf8'); }); @@ -86,7 +81,6 @@ const diagnostics = ( packerRewritesWorkspaceProtocols = false, ): Promise => packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, model: result.build.model, packageBuild: result.build.packageBuild!, packOutput, @@ -126,8 +120,8 @@ it('selects the intended pack entry by package name when npm lists sibling works it('prepack validates the complete dry-run inventory', async () => { expect(await diagnostics()).toEqual([]); - expect(result.pack.files.map((file) => file.path)).toContain('dist/bin/installer-fixture.js'); - expect(result.pack.files.map((file) => file.path)).toContain('host-packs/agent-bundle.manifest.json'); + expect(result.pack.files.map((file) => file.path)).toContain('index.js'); + expect(result.pack.files.map((file) => file.path)).toContain('agent-bundle.manifest.json'); }); it('exposes --root, --output, and --json through the prepack command', async () => { @@ -158,7 +152,7 @@ it('exposes --root, --output, and --json through the prepack command', async () it('reports missing allowlisted artifacts as AB7010', async () => { const pack = { ...result.pack, - files: result.pack.files.filter((file) => file.path !== 'host-packs/INSTALL.md'), + files: result.pack.files.filter((file) => file.path !== 'INSTALL.md'), }; expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ code: 'AB7010' })); }); @@ -180,7 +174,7 @@ it('requires README.md even when the source file is absent', async () => { } }); -it('reports stale artifact hashes as AB7011', async () => { +it('reports stale npm-root hashes as AB7011', async () => { await writeFile(payloadPath, `${payloadBytes}stale\n`); try { expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB7011' })); @@ -189,19 +183,36 @@ it('reports stale artifact hashes as AB7011', async () => { } }); +it('reports package-only compile evidence drift as AB6039', async () => { + const path = join(projectRoot, 'dist', packageCompileEvidenceFileName); + const original = await readFile(path, 'utf8'); + try { + await writeFile(path, '{"assets":[]}\n'); + expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB6039' })); + } finally { + await writeFile(path, original); + } +}); + /** Runs `run` against `package.json` rewritten by `mutate`, restoring the original afterwards. */ const withPackageDocument = async ( mutate: (document: Record) => void, run: () => Promise, ): Promise => { - const packagePath = join(projectRoot, 'package.json'); + const sourcePackagePath = join(projectRoot, 'package.json'); + const packagePath = join(projectRoot, 'dist', 'package.json'); + const sourceOriginal = await readFile(sourcePackagePath, 'utf8'); const original = await readFile(packagePath, 'utf8'); + const sourceDocument = JSON.parse(sourceOriginal) as Record; const document = JSON.parse(original) as Record; + mutate(sourceDocument); mutate(document); + await writeFile(sourcePackagePath, `${JSON.stringify(sourceDocument, null, 2)}\n`); await writeFile(packagePath, `${JSON.stringify(document, null, 2)}\n`); try { await run(); } finally { + await writeFile(sourcePackagePath, sourceOriginal); await writeFile(packagePath, original); } }; @@ -223,7 +234,7 @@ it('reports package, model, host, and provenance version disagreement as AB7013' }, )); -it('reports installed dependencies a consumer never needs as AB7014, per field', () => withPackageDocument( +it('reports installed dependencies no packed JavaScript imports as AB7014, per field', () => withPackageDocument( (document) => { document.dependencies = { zod: '4.5.4', effect: '4.0.0' }; document.peerDependencies = { react: '19.2.8', 'optional-host': '^1.0.0' }; @@ -233,70 +244,19 @@ it('reports installed dependencies a consumer never needs as AB7014, per field', async () => { const reported = withCode(await diagnostics(), 'AB7014'); // One diagnostic per field; devDependencies never reach a consumer and optional peers are never installed, so - // nothing has to use optional-host. The generated install bin inlines `effect`, so the compiler's evidence names - // that bundle; `zod` reached no bundle and is only listed. - expect(result.build.packageBuild!.evidence.assets.find((asset) => asset.path === 'dist/bin/installer-fixture.js')?.packages) - .toContain('effect'); + // nothing has to use optional-host. expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ - 'package.json dependencies names packages a consumer never needs installed: no packed declaration file references them, ' - + 'no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ' - + '"effect", "zod". The build inlined "effect" into dist/bin/installer-fixture.js; every consumer installs them for nothing.', - expect.stringMatching(/^package\.json peerDependencies .*"react"\. If they only constrain the host version/u), + expect.stringMatching(/^package\.json dependencies .*"effect", "zod"/u), + expect.stringMatching(/^package\.json peerDependencies .*"react"/u), ]); // A required peer nothing imports may be a deliberate host-compatibility contract: a warning, not a refusal. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'warning']); - expect(reported[0]?.recovery).toBe('Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). ' - + 'Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, ' - + 'or a prebuilt payload declares in runtimeDependencies (definePrebuilt).'); + expect(reported[0]?.recovery).toContain('devDependencies'); expect(reported[1]?.message).toContain('compatibility contract'); expect(reported[1]?.recovery).toContain('peerDependenciesMeta'); }, )); -/** The shared fixture's package build, its evidence record's `packages` set per recorded asset path (`dist/…`). */ -const packageBuildBundling = (bundled: Readonly>): PackageBuildResult => { - const packageBuild = result.build.packageBuild!; - return { - ...packageBuild, - evidence: { - ...packageBuild.evidence, - assets: packageBuild.evidence.assets.map((asset) => ({ ...asset, packages: bundled[asset.path] ?? [] })), - }, - }; -}; - -it('reports a dependency only compiled dist bundles inlined as AB7014, naming the bundles', () => withPackageDocument( - (document) => { - document.dependencies = { 'left-pad': '^1.3.0', 'never-loaded': '^1.0.0', 'tiny-pkg': '^1.0.0' }; - document.optionalDependencies = { 'optional-extra': '^1.0.0' }; - document.peerDependencies = { react: '19.2.8' }; - }, - async () => { - const paths = result.build.packageBuild!.files.map((file) => file.path); - expect(paths).toEqual(expect.arrayContaining(['bin/installer-fixture.js', 'index.js'])); - const reported = withCode(await packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, - model: result.build.model, - packageBuild: packageBuildBundling({ - 'dist/bin/installer-fixture.js': ['left-pad', 'tiny-pkg'], - 'dist/index.js': ['left-pad', 'react'], - }), - packOutput: result.pack, - packerRewritesWorkspaceProtocols: false, - projectRoot, - }), 'AB7014'); - // Bundled is not used: the names are still reported, and the tail says which bundles inlined them, names in - // field order and each name's bundles sorted. A peer the build inlined gets the same sentence; a field the - // compiler never touched gets the plain tail. - expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ - expect.stringMatching(/^package\.json dependencies .*: "left-pad", "never-loaded", "tiny-pkg"\. The build inlined "left-pad" into dist\/bin\/installer-fixture\.js and dist\/index\.js, and "tiny-pkg" into dist\/bin\/installer-fixture\.js; every consumer installs them for nothing\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*: "optional-extra"\. Nothing packed reaches them at runtime; every consumer installs them for nothing\.$/u), - expect.stringMatching(/^package\.json peerDependencies .*: "react"\. The build inlined "react" into dist\/index\.js; every consumer installs them for nothing\.$/u), - ]); - expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); - }, -)); - it('accepts a dependency declared by a prebuilt payload runtimeDependencies list', () => withPackageDocument( (document) => { document.dependencies = { sharp: '^0.33.0', 'never-loaded': '^1.0.0' }; @@ -312,7 +272,6 @@ it('accepts a dependency declared by a prebuilt payload runtimeDependencies list targets: ['claude'], }; const reported = withCode(await packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, model: { ...result.build.model, payloads: [payload] }, packageBuild: result.build.packageBuild!, packOutput: result.pack, @@ -349,6 +308,97 @@ it('reports an optional peer only for a protocol npm cannot parse, which fails t }, )); +it('accepts a package loaded through a createRequire() binding, literal or computed', () => withPackageDocument( + (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', 'aliased.mjs'); + await writeFile(consumer, [ + // The factory renamed on import is still a factory. + 'import { createRequire as makeRequire } from "node:module";', + 'const load = makeRequire(import.meta.url);', + 'export const driver = load("driver-package");', + '', + ].join('\n')); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'aliased.mjs' }] }; + const [reported] = withCode(await diagnostics(pack), 'AB7014'); + expect(reported?.message).toContain('"never-loaded"'); + expect(reported?.message).not.toContain('"driver-package"'); + // Namespace-qualified factory, computed argument. + await writeFile(consumer, 'import * as Module from "node:module";\nconst load = Module.createRequire(import.meta.url);\nexport const any = (name) => load(name);\n'); + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + // Factory chained off a CommonJS load, literal argument. + await writeFile(consumer, 'const load = require("node:module").createRequire(__filename);\nmodule.exports = load("driver-package");\n'); + const [chained] = withCode(await diagnostics(pack), 'AB7014'); + expect(chained?.message).toContain('"never-loaded"'); + expect(chained?.message).not.toContain('"driver-package"'); + // Loader called inline with a literal. + await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(import.meta.url)("driver-package");\n'); + const [inline] = withCode(await diagnostics(pack), 'AB7014'); + expect(inline?.message).toContain('"never-loaded"'); + expect(inline?.message).not.toContain('"driver-package"'); + // Factory argument with nested calls, literal target. + await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(new URL("./entry.js", import.meta.url))("driver-package");\n'); + const [nested] = withCode(await diagnostics(pack), 'AB7014'); + expect(nested?.message).toContain('"never-loaded"'); + expect(nested?.message).not.toContain('"driver-package"'); + // The same factory argument, computed target: nothing can be called unused. + await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const any = (name) => createRequire(new URL("./entry.js", import.meta.url))(name);\n'); + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + // Comments between the loader and its parentheses, and around the literal, are trivia — and not a computed argument. + await writeFile(consumer, 'module.exports = require /* driver */ ( // which\n /* a */ "driver-package" /* b */ );\n'); + const [commented] = withCode(await diagnostics(pack), 'AB7014'); + expect(commented?.message).toContain('"never-loaded"'); + expect(commented?.message).not.toContain('"driver-package"'); + // Comment trivia before a computed argument still leaves the load computed. + await writeFile(consumer, 'module.exports = (name) => require /* any */ (/* of */ name);\n'); + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it.each([ + ['a namespace import', 'import * as Module from "node:module";\nexport const driver = Module.createRequire(import.meta.url)("driver-package");'], + ['the default import', 'import module from "node:module";\nexport const driver = module.createRequire(import.meta.url)("driver-package");'], + ['require("node:module")', 'module.exports = require("node:module").createRequire(__filename)("driver-package");'], + ["require('module')", "module.exports = require('module').createRequire(__filename)('driver-package');"], + ['require("node:module") and .resolve', 'module.exports = require("node:module").createRequire(__filename).resolve("driver-package");'], + ['a two-level namespace, bound first', 'import * as ns from "node:module";\nconst load = ns.default.createRequire(import.meta.url);\nexport const driver = load("driver-package");'], +])('accepts a package loaded by a createRequire() call qualified through %s, direct or bound', (_form, source) => withPackageDocument( + (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); + await writeFile(consumer, `${source}\n`); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; + const [reported] = withCode(await diagnostics(pack), 'AB7014'); + expect(reported?.message).toContain('"never-loaded"'); + expect(reported?.message).not.toContain('"driver-package"'); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it.each([ + ['a namespace import', 'import * as Module from "node:module";\nexport const load = (name) => Module.createRequire(import.meta.url)(name);'], + ['require("node:module")', 'module.exports = (name) => require("node:module").createRequire(__filename)(name);'], +])('withholds AB7014 for a computed direct createRequire()() call qualified through %s', (_form, source) => withPackageDocument( + (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); + await writeFile(consumer, `${source}\n`); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifiers as AB7015', () => withPackageDocument( (document) => { document.dependencies = { @@ -380,49 +430,71 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier document.bundleDependencies = ['embedded', 'not-embedded']; document.optionalDependencies = { scp: 'git@github.com:owner/repo.git', - // npm skips these five after the failed fetch too — and then postinstall fails on the missing package: - // `newline-tool` is the first command of postinstall's second line, `setup-tool` the second of the first; - // `node node_modules/optional-driver/install.js` runs a file of the third directly, reached through `npm test` - // with no whitespace around the shell operator; `node` preloads the last two, a bare `-r` package behind the - // valued `--conditions` option and the one a `NODE_OPTIONS=--require=…` assignment on the `node` command names. + // npm skips these ten after the failed fetch too — and then postinstall fails: on the missing commands, and on + // the missing modules packed files it runs require. `newline-tool` is the first command of postinstall's second + // line, `setup-tool` the second of the first. The files are reached as `node scripts/install` (Node resolves + // `scripts/install.js`), as `npm test` running a script whose quoted path contains a space, and as + // `node scripts/hooks.cjs&&…` with no whitespace around the shell operator; the last loads its dependencies + // through a wildcard `imports` entry mapped to the package's own file, and through a directory whose packed + // manifest names its `main`. An inline `node -e` program that requires a package needs it too — its quotes + // escaped for the shell — as do the modules `node` preloads — a bare `-r` package and a packed `--import=` + // file, behind the valued `--conditions` option — before running `.`, the root `main`, and the one a + // `NODE_OPTIONS=--require=…` assignment on the `node` command preloads. 'setup-tool': 'git+https://github.com/owner/setup-tool.git', 'newline-tool': 'github:owner/newline-tool', 'optional-driver': 'github:owner/optional-driver', + 'optional-tester': 'github:owner/optional-tester', + 'optional-hook': 'github:owner/optional-hook', + 'optional-main': 'github:owner/optional-main', + 'optional-inline': 'github:owner/optional-inline', 'optional-preload': 'github:owner/optional-preload', + 'optional-imported': 'github:owner/optional-imported', + 'optional-root': 'github:owner/optional-root', 'optional-env-preload': 'github:owner/optional-env-preload', // Merely mentioned by the script — an `echo` argument; the operand of `rm -r`, whose `-r` is not Node's - // preload option; the value of a `--require` after the program, which Node hands to the program as an - // argument; and the name of a packed file `node` runs, which the gate no longer opens — so npm's skipping - // them breaks nothing: a warning. + // preload option; and the value of a `--require` after the program, which Node hands to the program as an + // argument — so npm's skipping them breaks nothing: a warning. 'optional-mentioned': 'github:owner/optional-mentioned', 'optional-removed': 'github:owner/optional-removed', 'optional-argument': 'github:owner/optional-argument', - 'optional-script': 'github:owner/optional-script', // npm parses these only to fail, so optional or not, the consumer's install dies. 'typo-optional': 'foo:bar', 'tag-optional': 'not a valid spec', 'url-optional': 'http:%zz', 'bad name': '^1.0.0', }; + document.imports = { '#hooks/*': './scripts/*-setup.cjs' }; + document.main = './scripts/root-setup.cjs'; document.scripts = { ...(document.scripts as Record | undefined), postinstall: 'echo start\nnewline-tool --init && setup-tool --init' - + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/optional-script.cjs --require optional-argument && npm test', - // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes; - // a relative `--import=` preload names no package. - test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node node_modules/optional-driver/install.js&&echo optional-mentioned' + + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/install --require optional-argument && npm test', + // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes. + test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node scripts/hooks.cjs&&echo optional-mentioned' + + ' && node -e "require(\\"optional-inline\\")"' + ' && rm -r optional-removed && node --conditions react-server -r optional-preload/register --import="./scripts/preload.mjs" .', }; }, async () => { - await mkdir(join(projectRoot, 'vendor', 'vendored'), { recursive: true }); - await mkdir(join(projectRoot, 'vendor', 'bad-manifest'), { recursive: true }); + const packageRoot = join(projectRoot, 'dist'); + await mkdir(join(packageRoot, 'scripts', 'lib'), { recursive: true }); + await mkdir(join(packageRoot, 'vendor', 'vendored'), { recursive: true }); + await mkdir(join(packageRoot, 'vendor', 'bad-manifest'), { recursive: true }); await Promise.all([ - writeFile(join(projectRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), - writeFile(join(projectRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), - writeFile(join(projectRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), - writeFile(join(projectRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), - writeFile(join(projectRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), + writeFile(join(packageRoot, 'scripts', 'install.js'), 'import "./driver-setup.cjs";\n'), + writeFile(join(packageRoot, 'scripts', 'driver-setup.cjs'), 'module.exports = require("optional-driver");\n'), + writeFile(join(packageRoot, 'scripts', 'my install.cjs'), 'require("optional-tester");\n'), + writeFile(join(packageRoot, 'scripts', 'hooks.cjs'), 'require("#hooks/hook");\nrequire("./lib");\n'), + writeFile(join(packageRoot, 'scripts', 'hook-setup.cjs'), 'require("optional-hook");\n'), + writeFile(join(packageRoot, 'scripts', 'lib', 'package.json'), '{ "main": "setup.cjs" }\n'), + writeFile(join(packageRoot, 'scripts', 'lib', 'setup.cjs'), 'require("optional-main");\n'), + writeFile(join(packageRoot, 'scripts', 'preload.mjs'), 'import "optional-imported";\n'), + writeFile(join(packageRoot, 'scripts', 'root-setup.cjs'), 'require("optional-root");\n'), + writeFile(join(packageRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), + writeFile(join(packageRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), + writeFile(join(packageRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), + writeFile(join(packageRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), + writeFile(join(packageRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), ]); const pack = { ...result.pack, files: [...result.pack.files, { path: 'node_modules/embedded/package.json' }, @@ -431,30 +503,53 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier { path: 'vendor/tarred.tgz' }, { path: 'vendor/bad-tarred-manifest.tgz' }, { path: 'vendor/not-archive.tgz' }, + { path: 'scripts/install.js' }, + { path: 'scripts/driver-setup.cjs' }, + { path: 'scripts/my install.cjs' }, + { path: 'scripts/hooks.cjs' }, + { path: 'scripts/hook-setup.cjs' }, + { path: 'scripts/lib/package.json' }, + { path: 'scripts/lib/setup.cjs' }, + { path: 'scripts/preload.mjs' }, + { path: 'scripts/root-setup.cjs' }, ] }; const reported = withCode(await diagnostics(pack), 'AB7015'); expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ expect.stringMatching(/^package\.json dependencies .*consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-preload" -> "github:owner\/optional-preload", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "optional-script" -> "github:owner\/optional-script", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), + expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-hook" -> "github:owner\/optional-hook", "optional-imported" -> "github:owner\/optional-imported", "optional-inline" -> "github:owner\/optional-inline", "optional-main" -> "github:owner\/optional-main", "optional-preload" -> "github:owner\/optional-preload", "optional-root" -> "github:owner\/optional-root", "optional-tester" -> "github:owner\/optional-tester", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), + expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), ]); // npm survives an optional dependency it parsed but cannot fetch, so that entry warns rather than blocks the // release; a specifier it cannot parse fails the manifest read and stays fatal, as does a skipped package an - // install script then runs. + // install script then runs or loads. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); - for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed', 'optional-script']) { + for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed']) { expect(reported[1]?.message).not.toContain(JSON.stringify(name)); } - for (const name of ['setup-tool', 'newline-tool', 'optional-driver', 'optional-preload', 'optional-env-preload']) { + for (const name of [ + 'setup-tool', 'newline-tool', 'optional-driver', 'optional-tester', 'optional-hook', 'optional-main', 'optional-inline', + 'optional-preload', 'optional-imported', 'optional-root', 'optional-env-preload', + ]) { expect(reported[2]?.message).not.toContain(JSON.stringify(name)); } + // To npm, `.` is the working directory (`--prefix .`), not a program: only `node .` runs the root `main`. + await withPackageDocument( + (document) => { + document.scripts = { ...(document.scripts as Record), postinstall: 'npm --prefix . run setup', setup: 'echo setup' }; + }, + async () => { + const survivable = withCode(await diagnostics(pack), 'AB7015').find((diagnostic) => diagnostic.severity === 'warning'); + expect(survivable?.message).toContain('"optional-root"'); + }, + ); for (const name of ['@agent-bundle/runtime', 'bashjsast', 'local', 'sibling', 'not-embedded', 'not-vendored', 'not-archive', 'bad-manifest', 'bad-tarred-manifest']) { expect(reported[0]?.message).toContain(`${JSON.stringify(name)} -> `); } for (const name of ['alias', 'tilde', 'versioned', 'embedded', 'vendored', 'tarred']) { expect(reported[0]?.message).not.toContain(JSON.stringify(name)); } - await rm(join(projectRoot, 'vendor'), { force: true, recursive: true }); + await rm(join(packageRoot, 'scripts'), { force: true, recursive: true }); + await rm(join(packageRoot, 'vendor'), { force: true, recursive: true }); expect(reported[0]?.recovery).toContain('registry'); const underPnpm = withCode(await diagnostics(pack, true), 'AB7015'); @@ -462,10 +557,122 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier }, )); -it('accepts a dependency a consumer install script names or runs, through delegated scripts and their hooks', () => withPackageDocument( +it('withholds AB7014 when packed JavaScript has a computed import() that could load any declared package', () => withPackageDocument( + (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', 'computed.mjs'); + await writeFile(consumer, 'export const load = (name) => import(name);\n'); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'computed.mjs' }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + // Without that file the same declaration is reported. + expect(withCode(await diagnostics(), 'AB7014')).toHaveLength(1); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it('withholds AB7014 when packed JavaScript the lexer rejects may hide an import()', () => withPackageDocument( + (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', 'unlexable.mjs'); + // An unbalanced call: the lexer throws before reporting any import, so nothing proves the package unused. + await writeFile(consumer, 'export const load = () => import("chosen-at-runtime"\n'); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'unlexable.mjs' }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it.each([ + ['require()', 'module.exports = (name) => require(name);'], + ['require.resolve()', 'module.exports = (name) => require.resolve(name);'], + ['import.meta.resolve()', 'export const where = (name) => import.meta.resolve(name);'], + ['a direct createRequire()()', 'import { createRequire } from "node:module";\nexport const load = (name) => createRequire(import.meta.url)(name);'], + ['require() of a literal-prefixed expression', 'module.exports = (variant) => require("chosen-at-runtime/" + variant);'], + ['require.resolve() of a template literal', 'module.exports = (variant) => require.resolve(`chosen-at-runtime/${variant}`);'], +])('withholds AB7014 for a computed CommonJS %s just as for a computed import()', (_form, source) => withPackageDocument( + (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', source.startsWith('module.exports') ? 'computed.cjs' : 'computed.mjs'); + await writeFile(consumer, `${source}\n`); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it.each([ + ['const load = require;', 'const load = require;\nmodule.exports = load("chosen-at-runtime");'], + ['fn(require)', 'module.exports = (fn) => fn(require);'], + ['module.exports = require', 'module.exports = require'], + ['[require]', 'module.exports = [require];'], + ['a ? require : b', 'module.exports = typeof require === "function" ? require : null;'], + ['a createRequire() binding passed on', 'import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nexport const use = (fn) => fn(load);'], +])('withholds AB7014 when a loader is passed on as a value (%s), since it may load anything under another name', (_form, source) => withPackageDocument( + (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'alias.mjs' : 'alias.cjs'); + await writeFile(consumer, `${source}\n`); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it.each([ + ['require("…")', 'module.exports = require("node:path");'], + ['require.resolve("…")', 'module.exports = require.resolve("node:path");'], + ['typeof require', 'module.exports = typeof require;'], + ['the string "require"', 'module.exports = "require";'], + ['prose in comments', '/**\n * Use when a getter may fail, require\n * services, or run asynchronously.\n */\n// factory(module, require)\nmodule.exports = 1;'], + ['a bundler runtime named like require', 'const load = __webpack_require__;\nmodule.exports = load;'], +])('still reports AB7014 when require is only called, resolved through, type-tested, or named in a string or comment (%s)', (_form, source) => withPackageDocument( + (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', 'not-alias.cjs'); + await writeFile(consumer, `${source}\n`); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'not-alias.cjs' }] }; + const [reported] = withCode(await diagnostics(pack), 'AB7014'); + expect(reported?.message).toContain('"never-loaded"'); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it('still reports AB7014 when the only resolve() calls are path or Promise resolution, literal or not', () => withPackageDocument( + (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, + async () => { + const consumer = join(projectRoot, 'dist', 'resolvers.mjs'); + await writeFile(consumer, [ + 'import path, { resolve } from "node:path";', + 'export const f = (a, b) => [resolve(a, b), Promise.resolve(a), path.resolve("never-loaded"), Promise.resolve("never-loaded")];', + '', + ].join('\n')); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'resolvers.mjs' }] }; + expect(withCode(await diagnostics(pack), 'AB7014')[0]?.message).toContain('"never-loaded"'); + } finally { + await rm(consumer, { force: true }); + } + }, +)); + +it('accepts a dependency reached through a package imports map or run by a consumer install script', () => withPackageDocument( (document) => { document.dependencies = { - // Reached only through the manifest's imports map, which no packed declaration file resolves: unused. 'driver-package': '^1.0.0', 'named-in-script': '^1.0.0', typescript: '^5.0.0', @@ -505,13 +712,21 @@ it('accepts a dependency a consumer install script names or runs, through delega }; }, async () => { + const consumer = join(projectRoot, 'dist', 'mapped.mjs'); + await writeFile(consumer, 'export { default } from "#driver";\n'); // The fixture's node_modules is the workspace's; the manifest is removed again below. const wrapper = join(workspaceNodeModules, 'prepack-test-wrapper'); await mkdir(wrapper, { recursive: true }); await writeFile(join(wrapper, 'package.json'), JSON.stringify({ name: '@scope/real', version: '1.0.0', bin: 'cli.js' })); try { - // The script names named-in-script directly, typescript through its `tsc` bin, and the alias through `real`, - // the bin npm derives from the installed manifest's name. `prepare` proves nothing, nor does the map alone. + const pack = { ...result.pack, files: [...result.pack.files, { path: 'mapped.mjs' }] }; + // `#driver` reaches driver-package; the script names named-in-script directly, typescript through its `tsc` bin, + // and the alias through `real`, the bin npm derives from the installed manifest's name. `prepare` proves nothing. + const [withImport] = withCode(await diagnostics(pack), 'AB7014'); + expect(withImport?.message).toContain('"prepare-only"'); + expect(withImport?.message).toContain('"dormant-only"'); + expect(withImport?.message).not.toContain('"driver-package"'); + // Without the `#` import the map alone proves nothing. const [reported] = withCode(await diagnostics(), 'AB7014'); expect(reported?.message).toContain('"driver-package"'); expect(reported?.message).toContain('"prepare-only"'); @@ -534,10 +749,31 @@ it('accepts a dependency a consumer install script names or runs, through delega ); } finally { await rm(wrapper, { force: true, recursive: true }); + await rm(consumer, { force: true }); } }, )); +it.each([ + ['a literal import()', 'node -e "import(\'optional-driver\')"', 'error'], + ['an awaited import() in an ES module program', 'node --input-type=module -e "await import(\'optional-driver\')"', 'error'], + ['a computed import(), which may load any declared package', 'node -e "import(process.argv[1])"', 'error'], + ['a literal require()', 'node -e "require(\'optional-driver\')"', 'error'], + ['source the lexer rejects, which may hide an import()', 'node -e "import(\'optional-driver\'"', 'error'], + ['import.meta, which loads nothing', 'node --input-type=module -p "typeof import.meta"', 'warning'], + ['the package name in a string', 'node -p "\'optional-driver\'"', 'warning'], +])('an inline node program with %s (%s) leaves a skipped optional dependency at severity %s', (_form, postinstall, severity) => withPackageDocument( + (document) => { + document.optionalDependencies = { 'optional-driver': 'github:owner/optional-driver' }; + document.scripts = { ...(document.scripts as Record | undefined), postinstall }; + }, + async () => { + const [reported] = withCode(await diagnostics(), 'AB7015'); + expect(reported?.message).toContain('"optional-driver"'); + expect(reported?.severity).toBe(severity); + }, +)); + it('reads a dependency whose installed manifest is not JSON as an unknown executable instead of failing the gate', () => withPackageDocument( (document) => { document.dependencies = { 'broken-dep': '^1.0.0', 'never-loaded': '^1.0.0' }; @@ -585,54 +821,76 @@ it('reads an installed manifest as npm does, so the last of duplicate name keys }, )); -it('prepack succeeds and surfaces the warning when the only finding is an unresolvable optional dependency', () => withPackageDocument( +it('prepack rejects an unused unresolvable optional dependency and retains its fetch warning', () => withPackageDocument( (document) => { document.optionalDependencies = { 'optional-native': 'github:owner/optional-native' }; - // The build rewrites dist, so the packed declaration that references the optional package lives in its own - // packed directory; an install script running it instead would make the failed fetch fatal. - document.files = [...(document.files as readonly string[]), 'extras']; }, async () => { - const extras = join(projectRoot, 'extras'); - await mkdir(extras, { recursive: true }); - await writeFile(join(extras, 'optional.d.ts'), 'export type { Native } from "optional-native";\n'); - try { - const packed = await prepack({ root: projectRoot }); - expect(packed.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity])).toEqual([['AB7015', 'warning']]); - } finally { - await rm(extras, { force: true, recursive: true }); - } + await expect(prepack({ root: projectRoot })).rejects.toMatchObject({ + diagnostics: [ + expect.objectContaining({ code: 'AB7014', severity: 'error' }), + expect.objectContaining({ code: 'AB7015', severity: 'warning' }), + ], + }); }, )); it('accepts a dependency that only packed declaration files reference, including @types for a type directive', () => withPackageDocument( - (document) => { - document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0', 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; - document.imports = { '#driver': { node: 'driver-package/node', default: 'driver-package' } }; - }, + (document) => { document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0' }; }, async () => { const declaration = join(projectRoot, 'dist', 'consumer.d.ts'); - const modern = join(projectRoot, 'dist', 'driver.d.mts'); await writeFile(declaration, [ '/// ', "import type { ZodType } from 'zod';", 'export declare const schema: ZodType;', 'export declare const buffer: Buffer;', - // A comment or string is not a reference, and a `declare module` in a module file augments a package. - '// import { Function } from "effect" -- never counts.', - 'export declare const text: "import x from \\"effect\\"";', '', ].join('\n')); - // A `#` specifier reaches the package the imports map names. - await writeFile(modern, 'export type { Driver } from "#driver";\n'); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.d.ts' }, { path: 'dist/driver.d.mts' }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - for (const name of ['zod', '@types/node', 'driver-package']) expect(reported?.message).not.toContain(JSON.stringify(name)); + const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.d.ts' }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(declaration, { force: true }); - await rm(modern, { force: true }); + } + }, +)); + +it('accepts a dependency that packed JavaScript imports, requires, or only resolves', () => withPackageDocument( + (document) => { + document.dependencies = { + 'left-pad': '^1.3.0', + '@scope/required': '^2.0.0', + 'asset-pkg': '^1.0.0', + 'tool-pkg': '^1.0.0', + // Named only through escaped literals, which Node decodes before resolving. + 'hex-pkg': '^1.0.0', + 'unicode-pkg': '^1.0.0', + // Run as an executable, never loaded: by the `tsc` bin its installed manifest declares. + typescript: '^5.0.0', + }; + }, + async () => { + const consumer = join(projectRoot, 'dist', 'consumer.mjs'); + await writeFile(consumer, [ + 'import { execSync, spawnSync } from "node:child_process";', + 'const ran = [spawnSync("tsc", ["--version"]), execSync("tsc --noEmit")];', + 'import leftPad from "left-pad/lib/index.js";', + 'const { createRequire } = await import("node:module");', + 'const require = createRequire(import.meta.url);', + 'const required = require("@scope/required/subpath");', + 'const asset = require.resolve("asset-pkg/package.json");', + 'const tool = import.meta.resolve("tool-pkg/bin/tool");', + String.raw`const hex = require("\x68ex-pkg");`, + String.raw`const unicode = require('unicode-pkg\u002fsubpath');`, + '// import { Function } from "effect" -- a comment never counts.', + 'export { leftPad, required, asset, tool, hex, unicode, ran };', + '', + ].join('\n')); + try { + const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.mjs' }] }; + expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + } finally { + await rm(consumer, { force: true }); } }, )); @@ -664,38 +922,28 @@ const createSiblingProject = async ( return root; }; -it('accepts a dependency a prebuilt payload declares in runtimeDependencies: prepack passes, the payload stays opaque', async () => { +it('accepts a dependency that only a prebuilt payload module imports: prepack passes, AB6005 does not walk prebuilt payloads', async () => { const root = await createSiblingProject('prebuilt-project', { bin: { 'prebuilt-fixture': './dist/bin/prebuilt-fixture.js' }, - dependencies: { 'body-parser': '^2.0.0', cors: '^2.8.5', express: '^5.0.0' }, + dependencies: { express: '^5.0.0' }, files: ['dist', 'host-packs', 'README.md'], name: 'prebuilt-fixture', type: 'module', version: '1.2.3', }, [ - "import { definePrebuilt } from 'agent-bundle';", - '', 'export default {', ' bin: false,', " lib: './src/index.ts',", " mcp: { servers: { timeline: { entry: { prebuilt: './built/runtime/mcp/server.js' }, transport: 'stdio' } } },", " output: { distPath: 'host-packs' },", - " payload: { runtime: definePrebuilt({ runtimeDependencies: ['body-parser', 'cors', 'express'], source: './built/runtime' }) },", + " payload: { runtime: './built/runtime' },", " plugin: { name: 'prebuilt-fixture' },", " targets: ['cursor'],", '};', ], { - // A bare import, a `require()`, and a `require.resolve()` in a module the framework copies rather than compiles: - // nothing opens the file — AB6005 never walks it, and AB7014 reads no packed JavaScript — so the declaration - // above is what keeps `express`, `body-parser`, and `cors` out of AB7014. - 'built/runtime/mcp/server.js': [ - 'import express from "express";', - 'const body = require("body-parser");', - 'const where = require.resolve("cors");', - 'export default express;', - 'export { body, where };', - '', - ].join('\n'), + // A bare import in a module the framework copies rather than compiles: AB6005 never walks it, and the + // import is the usage evidence that keeps `express` out of AB7014. + 'built/runtime/mcp/server.js': 'import express from "express";\nexport default express;\n', 'src/index.ts': 'export const value = 1;\n', }); const packed = await prepack({ root }); @@ -703,7 +951,7 @@ it('accepts a dependency a prebuilt payload declares in runtimeDependencies: pre expect(withCode(reported, 'AB6005')).toHaveLength(0); expect(withCode(reported, 'AB7014')).toHaveLength(0); // The payload is copied once into the composite root (#555): no `/` partition under `distPath`. - expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/runtime/mcp/server.js'); + expect(packed.pack.files.map((file) => file.path)).toContain('runtime/mcp/server.js'); }, 180_000); it('fails prepack with compile-time AB6005, never AB7014, when only a compiled dist bundle imports a declared dependency', async () => { @@ -741,34 +989,3 @@ it('fails prepack with compile-time AB6005, never AB7014, when only a compiled d }); expect(withCode(reported, 'AB7014')).toHaveLength(0); }, 180_000); - -it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { - const tarballs = join(cleanupRoot, 'tarballs'); - const consumer = join(cleanupRoot, 'consumer'); - const home = join(cleanupRoot, 'home'); - await Promise.all([ - mkdir(tarballs), - mkdir(consumer), - mkdir(join(home, '.cursor'), { recursive: true }), - ]); - const { stdout } = await execFile('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], { - cwd: projectRoot, - }); - const packed = packOutputFromJson(stdout); - await writeFile(join(consumer, 'package.json'), '{"private":true}\n'); - await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(tarballs, packed.filename)], { - cwd: consumer, - }); - const sourceCopy = join(cleanupRoot, 'source-copy'); - await cp(projectRoot, sourceCopy, { recursive: true, filter: (source) => source !== join(projectRoot, 'node_modules') }); - await rm(projectRoot, { force: true, recursive: true }); - - const installedBin = join(consumer, 'node_modules', '.bin', 'installer-fixture'); - const installed = await execFile(installedBin, ['install', 'cursor', '--json'], { - cwd: consumer, - env: { ...process.env, HOME: home }, - }); - expect(JSON.parse(installed.stdout)).toMatchObject({ host: 'cursor', state: 'installed' }); - await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); - expect(await readFile(join(sourceCopy, 'src', 'index.ts'), 'utf8')).toBe('export const value = 1;\n'); -}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 21137d5d5..1e22c6f3b 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -59,7 +59,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-mcp-proxy.test.ts', 'packages/agent-bundle/tests/host-install-proof.test.ts', 'packages/agent-bundle/tests/host-install-session.test.ts', - 'packages/agent-bundle/tests/installer-entry.test.ts', 'packages/agent-bundle/tests/integration-matrix.test.ts', 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index a08cd7020..6ac146af1 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -112,8 +112,8 @@ export default async function inspect( ``` The compiler statically projects `inputSchema` onto argv, generates nested help (`--help` at -every level, `--version` at the root), and emits `dist/bin/.js` through the same -bundler synthesis as every other bin. At run time the shell resolves the command path, parses and +every level, `--version` at the root), and emits `bin/.mjs` in the composite +artifact. At run time the shell resolves the command path, parses and coerces argv, validates through the module's own schemas, executes the default function inside the typed Agent request context, writes one canonical JSON line to stdout, and maps exit codes deterministically: @@ -370,19 +370,19 @@ projection field; `ToolConfig` gains nothing. Short `-x` aliases (the shell rejects single-dash tokens today) and an async `mapInput` are deferred. -### The routed CLI inside host artifacts +### The same routed CLI in every distribution -The package bin only reaches users who install the npm package, while hooks, Skills, and scripts -ship with the host artifact. So the build also emits the same compiled command graph once into -the artifact root as `bin/.mjs` (plus `bin/-flight.mjs` when any +Hooks, Skills, scripts, and npm consumers all use the plugin root, so the build emits the compiled +command graph once into the artifact root as `bin/.mjs` (plus +`bin/-flight.mjs` when any command renders), shared by every selected host. Every built-in target publishes the `cli` -capability that admits it. The artifact bin is a self-contained ESM module with no shebang or -executable bit — run it as `node /bin/.mjs `, exactly like -`scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; -the one difference is that -the artifact-hosted CLI and MCP server derive the same framework state root from the same code -root — `~/.agent-bundle/state/-`, or `AGENT_BUNDLE_STATE_ROOT` when set — so they -share one store. The npm package bin keeps `$PWD/.agent-bundle/state`. +capability that admits it. The artifact bin is a self-contained executable ESM module with a Node +shebang. The npm root copies it unchanged and points `package.json` `bin` directly at it, so help, +argv parsing, output modes, exit codes, signals, and the complete command set are byte-for-byte +the same. Both forms resolve plugin code from the root above `bin/` and derive the same framework +state root from that code root — `~/.agent-bundle/state/-` (or the +`XDG_STATE_HOME` equivalent), or `AGENT_BUNDLE_STATE_ROOT` when set — so the CLI and MCP server +share one store. Reach it from the other surfaces with the plugin-root token — `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` in Claude Skill Markdown and hook commands, `${PLUGIN_ROOT}/…` in Codex hooks, `${CURSOR_PLUGIN_ROOT}/…` @@ -390,8 +390,8 @@ in Cursor hooks — or, from a compiled script, as the sibling `new URL('../bin/.mjs', import.meta.url)`. `inspect` accounts for the bin as one `cli` component per target and the artifact manifest records both files. A target without the `cli` capability omits the bin and reports `AB4765`; a host-emitted file at the same path (a `claude.bin` -directory shipping `.mjs`) is `AB4766`. The package build's `dist/bin/.js` -is unchanged. +directory shipping `.mjs`) is `AB4766`. The npm root copies this manifest-declared +executable; it does not compile another routed CLI. ### Projecting MCP tools into the CLI @@ -433,24 +433,37 @@ Outside an agent-bundle compilation the published module throws rather than repo fabricated identity, and a release build refuses a project with no release version at all (`AB4013`). -## Packaging and installers +## Packaging the npm root -When package outputs and at least one Claude, Codex, or Cursor projection are built inside the -project, the framework also emits one self-contained package-relative installer at -`dist/bin/.js` — or `-install.js` when that name is taken, with a -numeric suffix if both are. Declare the matching `package.json` `bin` value. Its grammar is -`install [--scope ] [--json]`, help lists only built hosts, and it resolves the -shipped artifact directory from `import.meta.url` rather than the caller's working directory, so -it works from `node_modules` regardless of the current directory. No npm lifecycle performs an +When package outputs are enabled, `dist/` contains the complete validated composite artifact plus +the package metadata, standard documentation, and package-only entries. A generated routed CLI is +not compiled a second time: the installed `package.json` points at the +`bin/.mjs` executable declared by `agent-bundle.manifest.json` and copied unchanged +from the artifact. The tarball therefore has one plugin root and one routed CLI, with no nested +`artifact/` directory or package-relative installer wrapper. No npm lifecycle performs a host installation. +The generated root owns its inventory. Source `files` and `bin` fields are replaced; package entry +paths (`exports`, `imports`, `main`, `module`, `types`, and `typesVersions`) are rebased from +`dist/` to the generated root. Pack and publish hooks are removed, while other scripts are copied +unchanged and must already name paths relative to that root. Only manifest-owned artifact files, +compiled package entries, and standard README, license, and notice files are carried; arbitrary +source-project extras are not. + ```sh npx agent-bundle prepack --root . --output artifact --json ``` `prepack` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package and artifact inventory, manifest hashes, package bin targets, and release-version -agreement. Use it as an npm `prepack` script; `--ignore-scripts` prevents recursion. +agreement. It removes pack and publish lifecycle hooks from the generated package metadata, so a +source-project `prepack` cannot recurse from `dist/`; consumer install scripts remain intact. Run +the gate directly rather than relying on a source-package lifecycle hook, then publish the npm root +it validated: + +```sh +npm publish ./dist --ignore-scripts +``` ## Prebuilt payloads @@ -501,12 +514,7 @@ silently overriding the contract. Reserved module specifiers are protected the s that externalizes `agent-bundle/mcp-entry`, `agent-bundle/meta`, or `agent-bundle/mcp-apps` fails the build. Externalization in general is bounded too: `AB4725` rejects statically visible package `externals`, while relative, function-form, and mutator externals are judged from the -compilation's own evidence (`AB6005`). A hatch also runs after the compiler judged the module graph -and can rewrite the emitted bytes, so the build's compile evidence record marks -`coverage.rewritable` and artifact validation parses every emitted module in full and resolves its -imports, instead of trusting the record; without a hatch a compiled module is lexed and its literal -imports held to the record's externals (an `rspackIgnore` import fails `AB6005`). Run-time -path references are kept the same way: a +compilation's own evidence (`AB6005`). Run-time path references are kept the same way: a `new URL(…, import.meta.url)` or `new Worker(new URL(…))` in your code or a generated entry names a file beside the artifact, so the invariant layer turns the bundler's URL and worker asset processing off after the hatch and the expression reaches the artifact verbatim. The hatch diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 753db93ca..05510ee04 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -43,8 +43,8 @@ once the artifact validates: Compiled surfaces are built once and attributed to the **composite identity** — the selected hosts sorted and joined by `+`, such as `claude+codex` — never once per host. Both stages and the -`dist/` package build compose their bundler config the same way — profile, `tools.rsbuild`, -`tools.rspack`, then the framework invariants — as described under +package-only library and authored-bin entries compose their bundler config the same way — +profile, `tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under [`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the lowered Rspack configuration of every compiled output; its `output.path` is the artifact output itself. @@ -80,10 +80,16 @@ occupies that directory. ## The npm-facing half -A project that also ships as an npm package has a second output: `dist/bin/.js` executables -and a library entry, described in [Package entries](../authoring/package-entries.mdx). When -package outputs and a root with at least one built-in host are built together, the framework also -emits a package-relative installer bin — see [Host installation](./installation.mdx). +A project that also ships as an npm package has a second output, `dist/`, but it is not a second +plugin build. `dist/` is the npm package root: it contains the complete composite artifact, +`package.json`, the standard package documentation files, and any package-only library or +authored-bin entries described in [Package entries](../authoring/package-entries.mdx). + +For a generated routed CLI, the installed package's `bin` points directly at +`bin/.mjs`, copied unchanged from the validated artifact. The npm CLI therefore has the +same commands — including `web` — and the same bytes as the artifact CLI. The tarball has no +nested `artifact/` directory, and the framework does not compile a parallel `dist/bin/.js` +application or generate a package-relative installer bin. `agent-bundle prepack` is the gate for that half: it runs the release build, dry-runs `npm pack` without scripts, and verifies packaged outputs, artifact hashes, bins, and versions. diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index bc663c7e3..b30c29ba4 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: the install command, host scopes, the standalone install.mjs, the installer bin, and Doctor.' +description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: the install command, host scopes, the standalone install.mjs, npm CLI parity, and Doctor.' --- # Host installation @@ -75,21 +75,13 @@ Cursor behaviour: the provenance is `derived`, and a root that carries `.cursor- Artifact validation rejects a root whose required install surface is missing, so a bundle cannot ship without the installer it promises. -## The package-relative installer bin +## The npm-installed CLI -When package outputs ship beside a root with a built-in host selected, the build also emits a -package-relative installer bin. It uses the plugin name when no configured bin claims it, and -`-install` otherwise (with a numeric suffix if both are taken). Map that name to the -generated `dist/bin/*.js` file in `package.json`; consumers then run: - -```sh - install [--scope ] [--json] -``` - -Help lists only the hosts that were actually built. The executable locates the artifact directory -beside the installed package from `import.meta.url`, never the caller's working directory, so it -works from `node_modules` regardless of where it is invoked. **No npm lifecycle performs an -installation** — installing a package never mutates a host's plugin state. +The npm package root contains the composite plugin root itself. For a generated routed CLI, +`package.json` points its `bin` at the same `bin/.mjs` executable the artifact exposes, +without a second compilation or wrapper. Its complete command surface, including `web`, therefore +works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a +host installation** — installing a package never mutates a host's plugin state. ## Development installs are a different thing @@ -100,8 +92,8 @@ covered in [Developer Workbench](../development/workbench.mdx) and is not the sa ## Reinstalling after a rebuild -Every emitted installer — `agent-bundle install `, the package-relative bin, and the -standalone `install.mjs` — shares one replace policy. An identical copy is an `already-installed` +Every emitted installer — `agent-bundle install ` and the standalone `install.mjs` — +shares one replace policy. An identical copy is an `already-installed` no-op. A copy of the **same version whose content hash differs** is replaced automatically, so rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 973b9df69..582339a6b 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -28,9 +28,9 @@ the deploy tree reports `AB6025`. A listed compile evidence record that does not manifest file table reports `AB6039`. Self-containment is proven from the compilation's externals (`AB6005` at compile time). The -compiler service lowers every host-pack surface and package-build entry (`dist/bin/*.js`, a -rendered route's Flight worker `.mjs`, and the `lib` entry), plus every MCP App view. The -framework-owned +compiler service lowers every host-pack surface and package-only entry (an authored bin or the +`lib` entry). The npm root reuses the manifest-declared routed CLI and its rendered route Flight +worker from the validated artifact. The framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` to record every module Rspack kept external, and the service reads that evidence before trusting an asset. Only Node built-ins (`node:fs`, `fs`), `pnpapi`, and an emitted sibling of the same artifact may remain external; any @@ -38,26 +38,18 @@ other request fails whatever spelling Rspack emitted (`import`, `require`, or it shim). An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. -The emitted-module walk remains behind that compiler evidence for exactly what the compiler cannot -see. It reads every emitted `.js`/`.mjs` module as an ES module and, in any of them, compiled ones -included, fails an expression `import()` (`AB6005 has a non-literal dynamic import`). A module the -compile evidence record proves — a manifest `bundle` file the record covers with the same bytes, -from a build without a [`tools` hatch](../../reference/configuration.mdx#tools) — is lexed, not -parsed, and each literal import it still carries is held to the record: a Node built-in or one of -the file's recorded externals passes; any other request is one the build was told to ignore -(`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with no module, external, or warning) -and fails `AB6005 loads "", which the compiler neither bundled nor recorded as an -external`. A matching digest proves the bytes are the compiler's, not that every import in them was -resolved. Every other module is parsed in full and its -imports resolved against the manifest — a generated `install.mjs`, a copied consumer script, every -module of an artifact without a record, and every module of a build whose hatch could have rewritten -the emitted assets (the record's `coverage.rewritable`). Those findings are the residual `AB6005` -forms: unsupported or invalid specifiers, targets missing from or outside the emitted tree, invalid -syntax; a `dist` finding names its file as `dist/`. Prebuilt payloads (`kind: 'prebuilt'`) -stay opaque and hash-locked only, and declaration files (`.d.ts`) are not walked. - -The route graph guards the same self-containment before the bundler runs: a route module, layout, -or provider — or a module one of +The emitted-module walk remains behind that compiler evidence as defense in depth. It walks each +emitted JavaScript module as an ES module and rejects residual unsupported imports, non-literal +dynamic imports, and targets missing from or outside the emitted tree; a `dist` finding names its +file as `dist/`. How thoroughly syntax is checked follows who produced the bytes. A module +the framework compiled (manifest kind `bundle`) is the bundler's own output, so only the ESM lexer +runs over it, rejecting unterminated strings, templates, comments, and regexps and unbalanced +braces. A module the framework did not compile — a copied consumer script or standalone installer +— is parsed in full, as is every bundle of a build whose +[`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the emitted assets. +Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only, and declaration files +(`.d.ts`) are not walked. The route graph guards the same +self-containment before the bundler runs: a route module, layout, or provider — or a module one of them reaches through relative imports — that value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`) is reported as `AB4837` @@ -252,14 +244,29 @@ dependency into `dist/bin` and the host packs, so a published plugin should inst declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of the stack under `devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot keep a bare package external: the compiler reports `AB6005` before `prepack` reaches the inventory. So -bundling a package does not prove that consumers need it installed. `--output` is an artifact path -relative to `--root` that overrides the configured -`output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` -prevents recursion, and no npm lifecycle ever performs a host install. +`dependencies` is only for what the packed files demonstrably need by other means — a prebuilt +payload module's import, a `require`, `createRequire`, or `import.meta.resolve` call in a packed +file that the compiler did not resolve as a module dependency, a packed declaration's reference, +a consumer install script, or a `bin` command a packed file runs — since only JavaScript the +framework did not compile can still import one. `--output` is an artifact path relative to `--root` that overrides the configured +`output.distPath`, defaulting to `artifact`. The generated npm root omits pack and publish +lifecycle hooks to prevent source-project recursion while retaining consumer install scripts. +For `preinstall`, `install`, and `postinstall`, a single `node ` command is rebased from an +authored `dist/` bin or library path to the generated root and must name a staged output. +`AB4768` rejects unsupported Node command forms and source files the generated root does not +contain; arbitrary project source is never copied. Commands supplied by declared runtime +dependencies remain unchanged. +After the gate succeeds, publish exactly the root it validated with +`npm publish ./dist --ignore-scripts`; no npm lifecycle performs a host install. + +The npm root keeps the artifact's `agent-bundle.compile-evidence.json` and generated plugin bytes +unchanged. Package-only authored bins and libraries have their own persisted +`agent-bundle.package-compile-evidence.json`, with paths relative to the npm root. `prepack` +checks that record against the package-only `bundle` inventory with `AB6039`. That inlining is the compiler's contract, not a bundler default a toolchain upgrade may flip. -Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and the -package build's JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, +Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and +package-only JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, and `splitChunks: false`, and the framework adds no `externals` of its own; Rslib's `node` target leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) external. MCP App views inline every script and style into one HTML file, and the same audit plugin sits in their Rsbuild @@ -270,38 +277,57 @@ external — a `react` mapped to a global through the hatch, say — fails the v `externals` entries that name packages. RegExp, function-form, relative, and mutator externals are judged from the compilation's own evidence: `AB6005` fails a generated executable in a host pack or `dist` when Rspack kept anything except a built-in, `pnpapi`, or an emitted sibling external. -An external kept through the [`tools` hatch](../../reference/configuration.mdx#tools) is therefore -not a way around the contract and never reaches the prepack inventory. - -`AB7014` accepts exactly three kinds of consumer-runtime evidence: - -1. `runtimeDependencies` on a prebuilt payload declared with `definePrebuilt`; -2. a packed declaration file reference, as TypeScript's `preProcessFile` reads it: imports, - exports, import-equals `require`, `import("…")` types, module augmentations in an - external-module file, and `/// ` (including its `@types` twin); an ambient - `declare module` in a file with no imports counts for nothing, while a `#subpath` follows the - package `imports` map; -3. a consumer-side `preinstall`, `install`, or `postinstall` script, including delegated scripts, - that names the dependency or runs one of its `bin` commands. - -The framework's own runtime modules load no package at run time, so there is no framework -process-dependency record to read as a fourth source. - -A package merely inlined into a compiled bundle is not used. When that is the only evidence, -`AB7014` names the `dist` bundles that inlined it, for example: -`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` Move -build-only packages to `devDependencies`; compiled bundles inline their imports (`AB6005`). Keep a -runtime dependency only for what a packed declaration file references, a consumer install script -names or runs, or a prebuilt payload declares in `runtimeDependencies` (`definePrebuilt`). +The emitted-module walk remains behind this compile-time check. An external kept through the +[`tools` hatch](../../reference/configuration.mdx#tools) is therefore not a way around the +contract and never reaches the prepack inventory. A +`dependencies` entry is therefore only for what the packed files demonstrably need from outside a +compiled bundle — a package a prebuilt `.js`/`.mjs`/`.cjs` payload module imports (prebuilt files +are opaque to `AB6005`, and `AB7014` scans only those extensions, so an extensionless prebuilt +module counts for nothing), one a packed file loads through a `require`, `createRequire`, or +`import.meta.resolve` call that the compiler does not resolve as a module dependency, one a +consumer-side install script runs, one whose `bin` a packed file executes, or one a +packed declaration file references (`.d.ts` outputs are not walked by `AB6005`), or one a +prebuilt payload names in `runtimeDependencies` — and `AB7014` +reports a declared dependency with none of that evidence, while `AB7015` reports one a consumer's +npm cannot install. | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest or file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | +| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest or file, install surface, or README. Pack the generated `dist/` npm root without excluding its files. | | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild, and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | +| `AB7012` | A `package.json` bin points outside the packed npm root (including `src/`) or names a file npm omitted. Point a routed CLI at its manifest-declared `bin/.mjs`; point an authored bin at its generated `bin/*.js`. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed. Use requires one of the three evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. | -| `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns because npm can continue without it, but escalates to an error when an install script needs the package: its `bin` command appears in command position, a `node_modules//` file is run, or a bare package is preloaded with `-r`/`--require`/`--import`/`--loader` (including through `NODE_OPTIONS`). A peer also present in `dependencies` or `optionalDependencies` is judged by that concrete entry. `workspace:`/`catalog:` are allowed only when the pack lifecycle rewrites them; packed `bundleDependencies` and installable in-package `file:` sources are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so `import` evidence comes from prebuilt payload modules and other scripts the framework did not compile, while `require`, `createRequire`, and `import.meta.resolve` calls count from any packed file), no packed declaration references them, no `imports` mapping or consumer install script reaches them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | +| `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | + +For the canonical `dist/` npm root, `prepack` validates the exact input to +`npm publish ./dist`; it therefore always reports `workspace:` and `catalog:` +runtime specifiers, regardless of the package manager that launched the command. + +A prebuilt payload's `runtimeDependencies` declaration counts as use, since the compiler never opens a +payload file. The remaining dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would +publish is lexed for `import` specifiers and scanned for literal `require("…")`, `require.resolve("…")`, and +`import.meta.resolve("…")` calls (a `createRequire(…)` binding counts as `require`, even with the factory +renamed on import, as do direct `Module.createRequire(…)("…")` and `require("node:module").createRequire(…)("…")` calls; `path.resolve("…")` does not count), and every +packed `.d.ts` is scanned for the modules its types reference (a `/// ` directive +counts for the package and its `@types/*` twin); specifiers are reduced to package names, string escapes +decoded first, with Node built-ins ignored. A dependency packed code runs as an executable — a string literal +that is one of the `bin` commands its installed manifest declares, such as `spawnSync("tsc", ["--version"])` — +counts as used, as does one a +`#subpath` import may reach through the `imports` map, and a +dependency a consumer-side `preinstall`/`install`/`postinstall` script — not `prepare`, which npm never runs +for a published tarball — or any script it delegates to with `npm run` (the first positional after `run`; options +before or after `run`, valued or not, are skipped, and later words such as `npm run setup -- dormant` are that +script's arguments) or a direct `npm test`/`start`/`stop`/`restart` names (`npm restart` without a `restart` script running `stop` then `start`) (or whose `bin` command it runs; a +string-form `bin` is named after the installed manifest, read as npm reads it — the last of duplicate keys +wins — and the unscoped name stands in when the dependency is not installed locally or its manifest is not +JSON). A +computed `import(expression)` or `require(expression)` (likewise `require.resolve`, `import.meta.resolve`, +a direct `createRequire(…)(…)`, or a `createRequire` binding) in packed code could load any declared +package, so it withholds `AB7014` altogether; so does packed source the ESM lexer rejects, whose `import()` +calls it cannot report, and so does `require` passed on as a value (`const load = require`, +`fn(require)`) rather than called, since packages may then be loaded under a name the scan never sees. A mention inside a comment can only keep a dependency, never +report one; `devDependencies` are never inspected, and an `npm:` alias counts as a registry specifier when its target does. A release build also refuses a project with **no** release version at all (`AB4013`), so a published artifact never carries the `0.0.0-dev.` development fallback. A diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index ede079b1d..6824df289 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -187,8 +187,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | -The emitted standalone `install.mjs` and the package-relative installer bin accept the same -`--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement +The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install +receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling and Codex runs `codex plugin remove` before `add`. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: @@ -229,8 +229,8 @@ second run is a `not-installed` no-op. The typed `data.outcome` says what happen state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is orphaned for Claude's ~14-day grace period; a purge removes the effective framework state root, derived web-data, legacy `state/`, and `plugins/data//`); Codex keeps external state by default -and removes it on a confirmed purge while `codex plugin remove` deletes the cached tree. The package-relative installer bin accepts -`uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with +and removes it on a confirmed purge while `codex plugin remove` deletes the cached tree. The +emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. State receipts keep three facts separate. The installed MCP documents determine each server's diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index fd7abfaea..c52ca72d9 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -104,7 +104,7 @@ export default async function inspect( ``` 编译器把 `inputSchema` 静态投影到 argv 上,生成分层帮助(每一层都有 `--help`,根层有 `--version`), -并通过与其他 bin 相同的打包器合成流程输出 `dist/bin/.js`。运行时,外壳解析命令路径、 +并在组合产物中输出 `bin/.mjs`。运行时,外壳解析命令路径、 解析并强制转换 argv、用模块自身的 schema 校验、在带类型的 Agent 请求上下文中执行默认函数、向 stdout 写出一行规范 JSON,并确定性地映射退出码: @@ -130,7 +130,7 @@ Run 'curator doctor --help' for usage. 携带相同 `error` 对象的 `type: "error"` 事件。 `.tsx` 命令路由把默认函数换成一个具有相同 props 的 async 默认 Server Component,并通过运行时分发器 -针对同级的 `dist/bin/-flight.mjs` worker 渲染。它由此获得 +针对同级的 `bin/-flight.mjs` worker 渲染。它由此获得 [脚本与资源](./scripts-assets.mdx)中描述的四种输出模式。路由式 CLI 项目需要把 `@agent-bundle/runtime` 作为依赖,因为生成的可执行文件通过它装配请求上下文。 @@ -339,24 +339,24 @@ id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种 短格式 `-x` 别名(shell 目前会拒绝单短横线 token)与异步 `mapInput` 暂不支持。 -### 宿主产物中的路由式 CLI +### 每种分发中的同一个路由式 CLI -包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 -编译后的命令图只发射一次到产物根目录,路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 +hook、Skill、脚本与 npm 消费者都使用插件根目录,因此构建把编译后的命令图只发射一次到产物根目录, +路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 `bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 -自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 -`node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 -包 bin 一致;唯一的区别是,产物宿主的 CLI 与 MCP 服务器从同一个代码根目录推导出同一个框架状态根目录—— -`~/.agent-bundle/state/-`,或已设置的 `AGENT_BUNDLE_STATE_ROOT`——因此共享同一份存储。 -npm 包 bin 仍使用 `$PWD/.agent-bundle/state`。 +带 Node shebang 和可执行位的自包含 ESM 模块。npm 根目录原样复制它,并让 `package.json` 的 `bin` +直接指向它,因此帮助、argv 解析、输出模式、退出码、信号与完整命令集合都逐字节相同。两种形式都从 +`bin/` 的上级根目录解析插件代码,并从该代码根目录推导出同一个框架状态根目录—— +`~/.agent-bundle/state/-`(或对应的 `XDG_STATE_HOME` 路径),或已设置的 +`AGENT_BUNDLE_STATE_ROOT`——因此 CLI 与 MCP 服务器共享同一份存储。 从其他表面引用它时使用插件根 token——Claude Skill Markdown 与 hook 命令中写 `${CLAUDE_PLUGIN_ROOT}/bin/.mjs`,Codex hook 中写 `${PLUGIN_ROOT}/…`,Cursor hook 中写 `${CURSOR_PLUGIN_ROOT}/…`——或者在编译后的脚本里用同级引用 `new URL('../bin/.mjs', import.meta.url)`。`inspect` 把该 bin 记为每个 target 的一个 `cli` 组件,产物清单会记录这两个文件。不具备 `cli` 能力的 target 会省略该 bin 并报告 `AB4765`;宿主在同一路径 -发射的文件(例如 `claude.bin` 目录中交付的 `.mjs`)是 `AB4766`。包构建的 -`dist/bin/.js` 保持不变。 +发射的文件(例如 `claude.bin` 目录中交付的 `.mjs`)是 `AB4766`。npm 根目录复制清单声明的 +这个可执行文件,不再编译另一份路由式 CLI。 ### 把 MCP 工具投影进 CLI @@ -389,22 +389,31 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m 在 agent-bundle 编译之外,已发布的该模块会抛出错误,而不是报告一个伪造的标识;完全没有发布版本号的 发布构建会被拒绝(`AB4013`)。 -## 打包与安装器 +## 打包 npm 根目录 -当包输出与至少一个 Claude、Codex 或 Cursor 投影在同一项目内构建时,框架还会输出一个自包含、相对 -包路径的安装器,位于 `dist/bin/.js`——若该名称已被占用则为 `-install.js`, -两者都被占用时再追加数字后缀。请在 `package.json` 的 `bin` 中声明对应取值。它的语法是 -`install [--scope ] [--json]`,帮助中只列出已构建的宿主;它通过 `import.meta.url` 而 -不是调用者的工作目录来定位随包发布的产物目录,因此无论当前目录在哪里,从 `node_modules` 中都能正常 -工作。没有任何 npm 生命周期会执行安装。 +启用包输出后,`dist/` 包含完整的已校验组合产物,以及包元数据、标准文档和包专用入口。生成的路由式 CLI +不会再次编译:已安装的 `package.json` 指向 `agent-bundle.manifest.json` 声明并从产物原样复制的 +`bin/.mjs`。因此 tarball 只有一个插件根和一个路由式 CLI,没有嵌套的 `artifact/` 目录或 +相对包的安装器包装器。没有任何 npm 生命周期会执行宿主安装。 + +生成的根目录拥有自己的文件清单。源项目的 `files` 与 `bin` 字段会被替换;包入口路径 +(`exports`、`imports`、`main`、`module`、`types` 与 `typesVersions`)会从 `dist/` 重定位到 +生成根目录。pack 与 publish 钩子会被移除,其他脚本保持原样,因此必须已经使用相对该根目录的路径。 +只有清单拥有的产物文件、编译后的包入口以及标准 README、license 与 notice 文件会被携带;任意的 +源项目额外文件不会被复制。 ```sh npx agent-bundle prepack --root . --output artifact --json ``` `prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、 -清单哈希、包 bin 目标以及发布版本一致性设卡。把它用作 npm 的 `prepack` 脚本;`--ignore-scripts` -可防止递归。 +清单哈希、包 bin 目标以及发布版本一致性设卡。生成的包元数据会移除 pack 与 publish 生命周期钩子, +避免源项目的 `prepack` 从 `dist/` 递归;消费者安装脚本保持不变。请直接运行门禁,不要依赖源包的 +生命周期钩子,然后发布它已经校验的 npm 根目录: + +```sh +npm publish ./dist --ignore-scripts +``` ## 预构建 payload @@ -448,10 +457,7 @@ export default defineConfig({ 悄悄覆盖契约。保留的模块 specifier 也以同样方式受到保护:把 `agent-bundle/mcp-entry`、 `agent-bundle/meta` 或 `agent-bundle/mcp-apps` 外部化的逃生舱会让构建失败。外部化整体上也受到同样的 边界约束:`AB4725` 会拒绝静态可见、点名某个包的 `externals`,相对路径、函数形式以及 mutator 添加的 -externals 则由编译过程自身的证据判断(`AB6005`)。逃生舱还运行在编译器判定模块图之后,可以改写输出 -字节,因此该构建的编译证据记录会标记 `coverage.rewritable`,产物校验会完整解析每个已输出模块并解析 -其导入,而不是信任记录;没有逃生舱时,编译产物只做词法分析,其字面量导入对照记录在案的外部依赖核对 -(`rspackIgnore` 导入会以 `AB6005` 失败)。运行时路径引用也以同样方式得到保留:你的代码或 +externals 则由编译过程自身的证据判断(`AB6005`)。运行时路径引用也以同样方式得到保留:你的代码或 生成入口中的 `new URL(…, import.meta.url)` 或 `new Worker(new URL(…))` 指向的是产物旁的一个文件,因此 不变量层会在逃生舱之后关闭打包器对 URL 与 worker 的静态资源处理,让该表达式原样进入产物。逃生舱定制的是 *代码如何编译*,绝不是*产物承诺了什么*。 diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 1cee726b3..975d533a1 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -37,8 +37,9 @@ npx agent-bundle build --root . --output artifact 保留各自的源码证据。 编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`—— -绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、 -`tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。 +绝不会逐宿主各编译一次。两个阶段与包专用的库入口、手写 bin 以同样的方式合成打包器配置——profile、 +`tools.rsbuild`、`tools.rspack`,最后是框架不变量——见 +[`tools`](../../reference/configuration.mdx#tools)。 `agent-bundle inspect --bundler` 会打印每个编译输出降级后的 Rspack 配置;它的 `output.path` 就是产物输出本身。 ## 根目录里发布了什么 @@ -67,9 +68,13 @@ Claude 与 Codex 投影始终包含本地市场清单,正是这一点让它们 ## 面向 npm 的那一半 -同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见 -[包入口](../authoring/package-entries.mdx)。当包输出与至少选中了一个内置宿主的根目录被一起构建时,框架 -还会输出一个相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 +同时作为 npm 包发布的项目还有第二份输出 `dist/`,但它不是第二次插件构建。`dist/` 就是 npm 包根目录: +它包含完整的组合产物、`package.json`、标准包文档,以及[包入口](../authoring/package-entries.mdx)所述的 +包专用库入口或手写 bin。 + +对于生成的路由式 CLI,已安装包的 `bin` 直接指向 `bin/.mjs`;该文件从已校验产物原样复制。因此 +npm CLI 与产物 CLI 拥有完全相同的命令(包括 `web`)和完全相同的字节。tarball 中没有嵌套的 +`artifact/` 目录;框架不会并行编译另一份 `dist/bin/.js` 应用,也不会生成相对包的安装器 bin。 `agent-bundle prepack` 是这一半的门禁:它运行发布构建、以不执行脚本的方式 dry-run `npm pack`,并核实 打包后的输出、产物哈希、bin 与版本号。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 6aac69566..b4d01e101 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cursor:install 命令、宿主作用域、独立的 install.mjs、相对包的安装器 bin,以及 Doctor。' +description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cursor:install 命令、宿主作用域、独立的 install.mjs、npm CLI 一致性,以及 Doctor。' --- # 宿主安装 @@ -61,19 +61,12 @@ node ./install.mjs 产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 -## 相对包的安装器 bin +## npm 安装的 CLI -当包输出与选中了内置宿主的根目录一起发布时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 -插件名,否则使用 `-install`(两者都被占用时追加数字后缀)。请在 `package.json` 中把这个 -名字映射到生成的 `dist/bin/*.js` 文件;消费者随后运行: - -```sh - install [--scope ] [--json] -``` - -帮助信息只列出实际构建出来的宿主。该可执行文件通过 `import.meta.url` 在已安装包旁边定位产物目录,绝不 -依赖调用方的工作目录,因此无论从哪里调用,它在 `node_modules` 中都能工作。**任何 npm 生命周期都不会执行 -安装**——安装一个包绝不会改动宿主的插件状态。 +npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI,`package.json` 的 `bin` 指向产物公开的同一个 +`bin/.mjs` 可执行文件,不会再次编译,也没有包装器。因此它的完整命令面(包括 `web`)从 +`node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 +绝不会改动宿主的插件状态。 ## 开发期安装是另一回事 @@ -83,7 +76,7 @@ node ./install.mjs ## 重建后重新安装 -每个输出的安装器——`agent-bundle install `、相对包的 bin 与独立的 `install.mjs`——共用同一套替换策略。 +每个输出的安装器——`agent-bundle install ` 与独立的 `install.mjs`——共用同一套替换策略。 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 9d3d5ea45..9fcdc6fb8 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -23,29 +23,22 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。已列入清单的编译证据记录若 与清单文件表不一致,会报告 `AB6039`。 -自包含性由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面、 -每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口),以及每个 MCP App -视图。框架自有的 +自包含性首先由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面 +以及包专用入口(手写 bin 或 `lib` 入口)。npm 根目录复用已校验产物中由清单声明的路由式 CLI 及其渲染式 +路由 Flight worker。框架自有的 `ArtifactDependencyAuditPlugin` 会接入 `thisCompilation`,记录 Rspack 保持外部化的每个模块,服务则在 信任任何产物之前读取这份证据。只有 Node 内建模块(`node:fs`、`fs`)、`pnpapi` 以及同一产物已输出的同级 文件可以保持外部化;其他请求无论被 Rspack 输出成 `import`、`require` 还是其 `createRequire` shim,都会 失败。表达式请求(`import(expr)`、`require(expr)`)不在编译器视野之内:Rslib 的配置不解析它,而是原样保留, 因此它既不会被打包,也不算外部模块。 -已输出模块的遍历仍留在这份编译器证据之后,只负责编译器看不见的部分。它把每个已输出的 `.js`/`.mjs` -模块按 ES 模块读取,并在其中任何一个——包括编译产物——里出现表达式 `import()` 时失败 -(`AB6005 has a non-literal dynamic import`)。被编译证据记录证明的模块——记录以相同字节覆盖的清单 -`bundle` 文件,且构建未使用 [`tools` 逃生舱](../../reference/configuration.mdx#tools)——只做词法 -分析而不完整解析,但它仍带有的每个字面量导入都要对照记录核对:Node 内建模块或该文件记录在案的 -外部依赖通过;其他任何请求都是构建被要求忽略的导入(`rspackIgnore`/`webpackIgnore`——Rspack -原样保留它,没有模块、外部依赖或警告),并以 `AB6005 loads "", which the compiler neither -bundled nor recorded as an external` 失败。摘要匹配只证明字节是编译器输出的,不证明其中每个导入都 -已被解析。其他所有模块都会被完整解析,导入也会对照清单解析——生成的 `install.mjs`、被复制的消费者脚本、 -没有记录的产物中的每个模块,以及逃生舱可能改写了输出资源的构建(记录中的 `coverage.rewritable`) -里的每个模块。这些发现就是 `AB6005` 的残余形态:不受支持或无效的说明符、缺失于或逃逸出输出树的目标、 -无效语法;`dist` 的发现以 `dist/` 命名文件。预构建负载(`kind: 'prebuilt'`)保持不透明、仅做哈希 -锁定,声明文件(`.d.ts`)不会被遍历。 - +输出模块遍历仍作为纵深防线留在编译证据之后。它把每个输出的 JavaScript 模块当作 ES 模块遍历,拒绝残留的 +不受支持导入、非字面量动态 import,以及输出树中缺失或逃逸到树外的目标;`dist` 中的发现以 +`dist/` 点名其文件。模块语法检查的深度取决于它的字节由谁产出。框架编译的模块(清单 kind 为 +`bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,拒绝未终止的字符串、模板、注释与正则以及 +不配对的花括号。框架没有编译的模块——被复制的消费者脚本或独立安装器——会被完整解析;若一次构建的 +[`tools` 逃生口](../../reference/configuration.mdx#tools)可能改写了输出资源,该构建的每个 bundle 也会 +被完整解析。预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定;声明文件(`.d.ts`)不会被遍历。 路由图会在打包器运行之前守住同一份自包含性: 路由模块、布局或 provider——或它们之一通过相对导入触达的模块——若值导入了携带编译器的框架入口 (`agent-bundle`、`agent-bundle/api`、`agent-bundle/config`、`agent-bundle/eval`、`agent-bundle/rstest`、 @@ -218,12 +211,23 @@ npx agent-bundle prepack --root . --output artifact --json `dist/bin` 与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及清单之前报告 `AB6005`。因此 -仅仅把包内联进 bundle 并不能证明消费者需要安装它。`--output` 是相对 -`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` -脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 +`dependencies` 只留给打包后的文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后 +文件中未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用、打包后声明文件 +的引用、消费者侧安装脚本,或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然 +可能导入它。`--output` 是相对 +`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。生成的 npm 根目录会移除 +pack 与 publish 生命周期钩子以防止源项目递归,同时保留消费者安装脚本。 +对于 `preinstall`、`install` 与 `postinstall`,单条 `node ` 命令会从作者声明的 `dist/` bin +或库路径重定位到生成根目录,且必须指向已暂存的输出。`AB4768` 会拒绝不受支持的 Node 命令形式以及 +生成根目录中不存在的源码文件;任意项目源码绝不会被复制。由已声明运行时依赖提供的命令保持不变。 +门禁成功后,用 `npm publish ./dist --ignore-scripts` 发布它校验过的准确根目录;任何 npm 生命周期都绝不会执行宿主安装。 + +npm 根目录原样保留产物的 `agent-bundle.compile-evidence.json` 与生成插件字节。包专用的作者 bin +与库另有持久化的 `agent-bundle.package-compile-evidence.json`,其中路径相对于 npm 根目录。 +`prepack` 会以 `AB6039` 对照包专用 `bundle` 清单检查该记录。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 -CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 +CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`,以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都 内联进同一个 HTML 文件,同一个审计插件也位于它们的 Rsbuild 编译器中:浏览器文档不允许任何 external, @@ -232,36 +236,34 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 的 `tools.rsbuild.output.autoExternal`,以及静态可见、 点名某个包的字符串或对象 `externals` 条目。RegExp、函数形式、相对路径与 mutator 添加的 externals 则由编译过程 自身的证据判断:若 Rspack 在宿主包或 `dist` 的生成可执行文件中保持了内建模块、`pnpapi` 或已输出同级文件 -之外的任何 external,`AB6005` 就会失败。通过 +之外的任何 external,`AB6005` 就会失败。输出模块遍历仍留在这项编译期检查之后。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化并不能绕过契约,也永远到不了 prepack -的清单检查。 - -`AB7014` 只接受三类消费者运行时证据: - -1. 通过 `definePrebuilt` 声明的预构建 payload 上的 `runtimeDependencies`; -2. TypeScript 的 `preProcessFile` 从打包后声明文件读出的引用:import、export、import-equals - `require`、`import("…")` 类型、外部模块文件中的模块扩充,以及 - `/// `(连同其 `@types` 对应包);没有 import 的文件中的环境式 - `declare module` 不算证据,而 `#子路径` 会经由包的 `imports` 映射解析; -3. 消费者侧 `preinstall`、`install` 或 `postinstall` 脚本(包括其委托的脚本)点名该依赖,或运行其 - `bin` 命令。 - -框架自身的运行时模块在运行时不加载任何包,因此不存在可作为第四类证据读取的框架进程依赖记录。 - -仅仅内联进已编译 bundle 的包不算已使用。当这是唯一证据时,`AB7014` 会点名内联它的 `dist` bundle, -例如:`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` -请把仅构建期使用的包移到 `devDependencies`;编译后的 bundle 会内联其 import(`AB6005`)。仅当打包后的 -声明文件引用、消费者安装脚本点名或运行,或预构建 payload 在 `runtimeDependencies` -(`definePrebuilt`)中声明某个包时,才把它保留为运行时依赖。 +的清单检查。因此,`dependencies` 条目只留给打包后的文件 +有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建 +文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),打包后的 +文件通过未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用加载的包, +消费者侧安装脚本运行的包,打包后的文件执行其 `bin` 的包,打包后的声明文件引用的包 +(`.d.ts` 输出不会被 `AB6005` 遍历),或预构建 payload 在 `runtimeDependencies` 中声明的包——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告 +消费者的 npm 无法安装的依赖。 | 代码 | 含义 | | --- | --- | -| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请把 `dist` 与产物目录加入包的 `files` 允许列表。 | +| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请打包生成的 `dist/` npm 根目录,且不要排除其中的文件。 | | `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | -| `AB7012` | 某个 `package.json` bin 指向了打包后的 `dist` 输出之外(包括 `src/`),或指名了一个被 npm 忽略的文件。请把它指向生成的 `dist/bin` 文件。 | +| `AB7012` | 某个 `package.json` bin 指向了打包后的 npm 根目录之外(包括 `src/`),或指名了一个被 npm 忽略的文件。路由式 CLI 应指向清单声明的 `bin/.mjs`;手写 bin 应指向生成的 `bin/*.js`。 | | `AB7013` | `package.json`、规范化后的插件元数据、某份宿主清单或产物 provenance 报告了不同的发布版本。请让每处发布标识一致。 | -| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段点名了消费者无需安装的包。必须有上列三类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 | -| `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。抓取类的 `optionalDependencies` 项只发出警告,因为 npm 可以在没有它时继续;但消费者安装脚本需要该包时会升级为错误:其 `bin` 命令位于命令位置、运行了 `node_modules//` 文件,或通过 `-r`/`--require`/`--import`/`--loader`(包括 `NODE_OPTIONS`)预加载了裸包。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断。`workspace:`/`catalog:` 仅在打包生命周期会重写它们时允许;已打包的 `bundleDependencies` 与包内可安装的 `file:` 来源不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它,也没有任何预构建 payload 在 `runtimeDependencies` 中声明它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | +| `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | + +对于规范的 `dist/` npm 根目录,`prepack` 校验的是 +`npm publish ./dist` 的准确输入;因此无论哪个包管理器启动该命令,运行时依赖中的 +`workspace:` 与 `catalog:` 说明符都会被报告。 + +预构建 payload 的 `runtimeDependencies` 声明视为已使用,因为编译器从不打开 payload 文件。其余依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` +说明符,并扫描字面量 `require("…")`、`require.resolve("…")` 与 `import.meta.resolve("…")` 调用(`createRequire(…)` 的绑定视同 `require`,即使导入时重命名了该工厂,直接调用的 `Module.createRequire(…)("…")` 与 `require("node:module").createRequire(…)("…")` 亦然;`path.resolve("…")` 不计入);每个打包后的 `.d.ts` 会被扫描其类型所引用的模块(`/// ` 指令同时计入该包及其 +`@types/*` 对应包)。说明符先解码字符串转义再归约为包名,忽略 Node 内建模块。打包代码作为可执行文件运行的依赖——字符串字面量正是其已安装清单所声明的某个 `bin` 命令,如 `spawnSync("tsc", ["--version"])`——视为已使用;`#子路径` 导入计入 `imports` 映射所指向的每个包;消费者侧 `preinstall`/`install`/`postinstall` 脚本(不含 `prepare`——npm 从不为已发布的 tarball 运行它)或其通过 `npm run`(取 `run` 之后的第一个位置参数;`run` 前后的选项,带值与否均被跳过,其后的词如 `npm run setup -- dormant` 中的 `dormant` 是该脚本的参数)或直接的 `npm test`/`start`/`stop`/`restart`(没有 `restart` 脚本时,`npm restart` 依次运行 `stop` 与 `start`)委托的任何脚本点名(或运行其 `bin` 命令;字符串形式的 `bin` 以已安装清单的名字命名——清单按 npm 的方式读取,重复键以最后一个为准——依赖未在本地安装或其清单不是 JSON 时以去掉作用域的包名代替)的依赖 +同样视为已使用。打包代码中的计算型 `import(表达式)` 或 `require(表达式)`(同样包括 `require.resolve`、`import.meta.resolve`、直接的 `createRequire(…)(…)` 或 `createRequire` 绑定)可能加载任何已声明的包,因此会整体撤回 `AB7014`;被 ESM 词法分析器拒绝的打包源码亦然,因为其中的 `import()` 调用无法被报告;把 `require` 当作值传递而非调用(`const load = require`、`fn(require)`)亦然,因为此后包可能以扫描看不到的名字被加载。注释中的提及只会保留某个依赖,绝不会报告它;`devDependencies` 永不检查, +`npm:` 别名在其目标是注册表说明符时才视为注册表说明符。 发布构建同样会拒绝**完全没有**发布版本的项目(`AB4013`),因此已发布的产物绝不会携带 `0.0.0-dev.` 这个开发期回退值。声明的 `plugin.version` 与 `package.json` 不一致时会 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 133856b8b..618038619 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -179,7 +179,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | -输出的独立 `install.mjs` 与相对包的安装器 bin 接受同样的 `--replace`。Cursor 副本携带安装回执 +输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装,Codex 则先 `codex plugin remove` 再 `add`。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 @@ -215,7 +215,7 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 会删除有效框架状态根、 推导出的 web-data、旧版 `state/` 与 `plugins/data//`);Codex 默认保留外部状态,在确认 purge 时删除它, -而 `codex plugin remove` 会删除缓存树。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 +而 `codex plugin remove` 会删除缓存树。输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 状态回执把三件事分开:已安装的 MCP 文档决定每个服务器的运行时位置(相对覆盖值从该服务器的执行目录解析); From 0d247558312710937f35b9065dc1a8adbd5d30d4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:48:10 +0000 Subject: [PATCH 02/19] docs(changeset): reference PR 639 --- .changeset/canonical-npm-root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index c2fad1783..739e5add8 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#TBD). +Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#639). From e61aaddb1371de1e2cf174e3ff554ae6c69fedf8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:18:49 +0000 Subject: [PATCH 03/19] fix(package): close npm-root review gaps --- .changeset/canonical-npm-root.md | 2 +- docs/diagnostics.md | 11 ++--- packages/agent-bundle/README.md | 5 ++- packages/agent-bundle/src/api.ts | 1 - packages/agent-bundle/src/install/format.ts | 2 +- .../tests/artifact-cli-bin.test.ts | 19 ++++++-- .../agent-bundle/tests/layout-build.test.ts | 4 +- .../agent-bundle/tests/package-build.test.ts | 13 +++++- .../tests/packed-host-install-proof.test.ts | 45 ++++++++----------- .../tests/route-contract-imports.test.ts | 2 +- .../tests/support/host-install.ts | 2 +- .../en/guide/distribution/installation.mdx | 6 +++ .../zh/guide/distribution/installation.mdx | 5 +++ 13 files changed, 72 insertions(+), 45 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 739e5add8..42bb8f58f 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 2dd2d7329..73c0f036d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ even when no error diagnostic was reported. | `AB473x` | Migration nudges (informational; see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | -| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | +| `AB4765`–`AB4767` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | @@ -423,9 +423,9 @@ Validation happens at three moments, all fail-closed: | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest/file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | -| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | +| `AB7010` | The dry-run npm inventory omits an expected npm-root file. Pack the generated npm root without excluding its files. | +| `AB7011` | A file in the generated npm root no longer matches its manifest SHA-256. Rebuild and do not modify the generated npm root. | +| `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | | `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | @@ -833,7 +833,7 @@ exactly where `build` would refuse. | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | -## Artifact-hosted routed CLI (`AB4765`–`AB4766`) +## Artifact-hosted routed CLI (`AB4765`–`AB4767`) A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus @@ -848,6 +848,7 @@ See “The routed CLI shell” in | --- | --- | --- | | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | +| `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | ## Config beside a route-generated MCP server (`AB4340`) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 187ce1ac1..253c48406 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1049,8 +1049,9 @@ self-contained module run as `node /bin/.mjs route can spawn its `../bin/.mjs` sibling and a Claude skill can point at `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in host publishes the `cli` capability that admits it; `inspect` accounts for it as a `cli` component, and -the manifest records both files with bundle provenance. The npm package bin under `dist/bin/` is -unchanged. See `docs/entry-conventions.md` for the layout and diagnostics (`AB4765`, `AB4766`). +the manifest records both files with bundle provenance. The generated npm root copies this +executable unchanged and points `package.json` `bin` at it. See `docs/entry-conventions.md` for the +layout and diagnostics (`AB4765`–`AB4767`). ### What gets hashed diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index cfeca40a6..f993b91a0 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1401,7 +1401,6 @@ export const prepack = async (options: BuildOptions): Promise => }); const pack = packOutputFromJson(stdout); const diagnostics = await packInventoryDiagnostics({ - artifactRoot: result.build.outputRoot, model: result.model, packageBuild: result.packageBuild, packOutput: pack, diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 029633062..c103d9ca0 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -23,7 +23,7 @@ const installVerb = (state: InstallResult['state'], mode: InstallResult['mode']) } }; -/** Human-readable install summary shared by the CLI and generated installer bins. */ +/** Human-readable install summary shared by the CLI and standalone artifact installer. */ export const formatInstallResult = (result: InstallResult): string => { const destination = result.destination ?? result.bundleRoot; const mode = result.mode === undefined ? '' : ` (${result.mode} mode)`; diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index ea8ad10c0..eb2772ca5 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -288,6 +288,18 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew target: 'legacy-host', })); expect(legacyResult.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + await expect(build({ + output: 'package-artifact', + packageOutputs: true, + registry, + root: legacyRoot, + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB4767', + severity: 'error', + })], + name: 'DiagnosticError', + }); // Help, version, and the rendered .tsx command ride the same executable. const claudeBin = binPath; @@ -347,14 +359,15 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew reason: 'unsupported-capability', }); - // `inspect --bundler` dumps the composite bin composition beside the - // scripts; the npm package bin (no target) keeps its own entry. + // `inspect --bundler` dumps the one composite bin composition beside the + // scripts. The npm root copies that executable, so there is no second + // target-less package compilation to report. const bundler = await inspect({ focus: 'bundler', registry, root }); if (bundler.state !== 'ready') throw new Error('unreachable'); const binEntries = (bundler.selected?.bundler?.entries ?? []) .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); expect((bundler.selected?.bundler?.entries ?? []).some((entry) => - entry.kind === 'bin' && entry.target === undefined && entry.outputPath === `dist/bin/${pluginName}.js`)).toBe(true); + entry.kind === 'bin' && entry.target === undefined)).toBe(false); expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual([ `bin/${pluginName}-flight.mjs`, `bin/${pluginName}.mjs`, diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index 5c32ae382..d507bd4ef 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -190,7 +190,7 @@ it('composes the root and server layouts around every rendered surface of one bu } // The routed CLI: a rendered command takes only the root layout. - const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'layout-fixture.mjs'); const piped = await execFile(binPath, ['report', '/library']); expect(piped.stdout).toBe('Found **2** books under /library.\n\n> shell: cli report\n'); const reportJson = await execFile(binPath, ['report', '/library', '--json']); @@ -256,7 +256,7 @@ it('ships byte-identical surfaces when no layout exists and refuses an invalid l const output = join(root, 'artifact'); const result = await build({ output, packageOutputs: true, root }); expect(result.model.layouts).toBeUndefined(); - const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'layout-fixture.mjs'); const piped = await execFile(binPath, ['report', '/library']); expect(piped.stdout).toBe('Found **2** books under /library.\n'); const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"plain"}']); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 9c8bb152e..93568f282 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -143,7 +143,10 @@ const unpublishedPackageOutput = async (root: string): Promise => { describe('framework-owned package build', () => { it('builds bin, lib, and dts outputs from conventions and stays deterministic', async () => { - const root = await fixtureRoot(conventionFixture()); + const root = await fixtureRoot({ + ...conventionFixture(), + 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n', + }); await installTypescriptToolchain(root); const result = await build({ output: 'artifact', packageOutputs: true, root }); @@ -154,6 +157,14 @@ describe('framework-owned package build', () => { const packageBuild = result.packageBuild; expect(packageBuild).toBeDefined(); expect(packageBuild!.outputRoot).toBe(join(root, 'dist')); + const packageDocument = JSON.parse(await readFile(join(root, 'dist', 'package.json'), 'utf8')); + expect(packageDocument).toMatchObject({ + bin: { 'package-build-fixture': './bin/package-build-fixture.js' }, + name: 'package-build-fixture', + private: true, + type: 'module', + }); + expect(packageDocument).not.toHaveProperty('scripts'); const paths = packageBuild!.files.map((file) => file.path); expect(paths).toContain('bin/package-build-fixture.js'); expect(paths).toContain('index.js'); diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index de6bc9ed6..504e67786 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile, spawnSync } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -15,7 +15,6 @@ import { runCursorHostInstallProof, runHostUninstallProof, type BuiltHostInstallFixture, - type HostInstallCommand, } from './support/host-install.ts'; import { installedEnvironment, @@ -49,7 +48,6 @@ const codexPluginIt = codexAvailable ? it : it.skip; let cleanupRoot: string | undefined; let sourceFixture: BuiltHostInstallFixture | undefined; let packedFixture: BuiltHostInstallFixture | undefined; -let packedInstallCommand: HostInstallCommand | undefined; let fixturePackageVersion: string | undefined; beforeAll(async () => { @@ -65,8 +63,6 @@ beforeAll(async () => { } fixturePackageVersion = packageDocument.version; delete packageDocument.private; - packageDocument.bin = { [pluginName]: `./dist/bin/${pluginName}.js` }; - packageDocument.files = ['artifact', 'dist', 'README.md']; await Promise.all([ writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), writeFile(join(projectRoot, 'README.md'), '# Host install proof fixture\n'), @@ -90,7 +86,7 @@ beforeAll(async () => { const packed = await execFile( 'npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], - { cwd: projectRoot, env: installedEnvironment() }, + { cwd: join(projectRoot, 'dist'), env: installedEnvironment() }, ); const packOutput = packOutputFromJson(packed.stdout); const tarball = join(tarballs, packOutput.filename); @@ -100,10 +96,17 @@ beforeAll(async () => { }); const installedPackageRoot = join(consumer, 'node_modules', packageName); - const installedArtifactRoot = join(installedPackageRoot, 'artifact'); - const installedBin = join(consumer, 'node_modules', '.bin', pluginName); + const installedArtifactRoot = join(consumer, 'packed-artifact'); + const manifestName = 'agent-bundle.manifest.json'; + const manifestText = await readFile(join(installedPackageRoot, manifestName), 'utf8'); + const manifest = JSON.parse(manifestText) as { readonly files: readonly { readonly path: string }[] }; + await mkdir(installedArtifactRoot); + for (const file of [...manifest.files, { path: manifestName }]) { + const destination = join(installedArtifactRoot, file.path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(installedPackageRoot, file.path), destination); + } await Promise.all([ - access(installedBin), access(join(installedArtifactRoot, '.claude-plugin', 'plugin.json')), access(join(installedArtifactRoot, '.codex-plugin', 'plugin.json')), access(join(installedArtifactRoot, '.cursor-plugin', 'plugin.json')), @@ -111,7 +114,7 @@ beforeAll(async () => { await rm(projectRoot, { force: true, recursive: true }); await expect(stat(projectRoot), proofLabel).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(access(join(projectRoot, 'dist', 'bin', `${pluginName}.js`)), proofLabel) + await expect(access(join(projectRoot, 'dist', 'bin', `${pluginName}.mjs`)), proofLabel) .rejects.toMatchObject({ code: 'ENOENT' }); packedFixture = Object.freeze({ @@ -124,7 +127,6 @@ beforeAll(async () => { cli: sourceFixture.cli, root: cleanupRoot, }); - packedInstallCommand = Object.freeze({ cwd: consumer, executable: installedBin }); }, 300_000); afterAll(async () => { @@ -139,11 +141,6 @@ const builtFixture = (): BuiltHostInstallFixture => { return packedFixture; }; -const installCommand = (): HostInstallCommand => { - if (packedInstallCommand === undefined) throw new Error(`[${proofLabel}] packed installer setup did not complete.`); - return packedInstallCommand; -}; - const expectHygienicReport = (report: unknown): void => { expect(JSON.stringify(report), proofLabel).not.toMatch( /(?:API_KEY|AUTH_TOKEN|ACCESS_TOKEN|authorization|credential|password|secret|sk-[A-Za-z0-9_-]{16,}|\/home\/|\/Users\/|\/tmp\/|stdout|stderr)/iu, @@ -157,7 +154,6 @@ claudePluginIt( async () => { const report = await runClaudeHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -189,7 +185,6 @@ codexPluginIt( async () => { const report = await runCodexHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -226,7 +221,6 @@ codexPluginIt( it('installs the packed tarball into an isolated Cursor home, validates schemas, and is idempotent', async () => { const report = await runCursorHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -277,12 +271,11 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, claudePluginIt( claudeAvailable - ? 'uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping' - : `uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping [${claudeMissingEvidence}]`, + ? 'uninstalls the packed tarball through Claude, leaving only host-owned bookkeeping' + : `uninstalls the packed tarball through Claude, leaving only host-owned bookkeeping [${claudeMissingEvidence}]`, async () => { const report = await runHostUninstallProof(builtFixture(), 'claude', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toMatchObject({ agentBundleResidue: [], @@ -298,12 +291,11 @@ claudePluginIt( codexPluginIt( codexAvailable - ? 'uninstalls the packed tarball through Codex with the package bin' - : `uninstalls the packed tarball through Codex with the package bin [${codexMissingEvidence}]`, + ? 'uninstalls the packed tarball through Codex' + : `uninstalls the packed tarball through Codex [${codexMissingEvidence}]`, async () => { const report = await runHostUninstallProof(builtFixture(), 'codex', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toMatchObject({ agentBundleResidue: [], @@ -317,10 +309,9 @@ codexPluginIt( 300_000, ); -it('uninstalls the packed tarball from an isolated Cursor home with the package bin and leaves it byte-identical', async () => { +it('uninstalls the packed tarball from an isolated Cursor home and leaves it byte-identical', async () => { const report = await runHostUninstallProof(builtFixture(), 'cursor', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ agentBundleResidue: [], diff --git a/packages/agent-bundle/tests/route-contract-imports.test.ts b/packages/agent-bundle/tests/route-contract-imports.test.ts index ec9b547ae..85e7158dd 100644 --- a/packages/agent-bundle/tests/route-contract-imports.test.ts +++ b/packages/agent-bundle/tests/route-contract-imports.test.ts @@ -270,7 +270,7 @@ it('shares imported route contracts across graph, argv, runtime, and generated t const built = await build({ output: 'artifact', packageOutputs: true, root: importedRoot }); expect(built.diagnostics).toEqual([]); - const binPath = join(importedRoot, 'dist', 'bin', 'route-contract-imported-fixture.js'); + const binPath = join(importedRoot, 'dist', 'bin', 'route-contract-imported-fixture.mjs'); const help = await execFile(binPath, ['status', '--help']); for (const option of ['--lane-key', '--limit', '--statuses', '--tickets']) { expect(help.stdout).toContain(option); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 6e89e4402..9279f75c2 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -536,7 +536,7 @@ const assertInstallResult = ( assertProof(document.host === host, `${host} install result did not identify the host.`); assertProof(document.plugin === plugin, `${host} install result did not identify ${plugin}.`); assertProof(document.version === version, `${host} install result did not identify version ${version}.`); - assertProof(document.state === state, `${host} install result state was not ${state}.`); + assertProof(document.state === state, `${host} install result state was ${document.state}, expected ${state}.`); assertProof( typeof document.contentHash === 'string' && /^[0-9a-f]{64}$/u.test(document.contentHash), `${host} install result carried no artifact content hash.`, diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index b30c29ba4..848004cfe 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -83,6 +83,12 @@ without a second compilation or wrapper. Its complete command surface, including works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a host installation** — installing a package never mutates a host's plugin state. +Install Claude or Codex explicitly from that root with +`npx agent-bundle install --from node_modules/`. For Cursor, the npm root also +carries the artifact's standalone `install.mjs`; run it from the installed package root. The +packed host proof reconstructs the manifest-owned artifact from the installed package and exercises +`agent-bundle install --from` after deleting the source checkout. + ## Development installs are a different thing `agent-bundle dev --install-host ` maintains a *marked development* install that follows diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index b4d01e101..3cb44c449 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -68,6 +68,11 @@ npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI, `node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 绝不会改动宿主的插件状态。 +请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装 Claude 或 +Codex 集成。对于 Cursor,npm 根目录还包含产物的独立 `install.mjs`;请从已安装包的根目录运行它。 +打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 +`agent-bundle install --from`。 + ## 开发期安装是另一回事 `agent-bundle dev --install-host ` 维护的是一个*标记为开发用*的安装,它跟随成功的重建 epoch, From c209793552b594dc4c3ccc85acb2807cac1b1324 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:49:06 +0000 Subject: [PATCH 04/19] fix(install): compare manifest-owned package bytes --- .changeset/canonical-npm-root.md | 2 +- packages/agent-bundle/src/install/install.ts | 3 ++- .../agent-bundle/src/install/uninstall.ts | 4 +-- .../tests/dev-contract-adoption.test.ts | 25 ++++++++++--------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 42bb8f58f..765cc3a07 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, keep npm metadata outside host install comparisons, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 89153f660..d67843dbf 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -15,6 +15,7 @@ import { stageCursorMarketplace } from './cursor-marketplace.ts'; import { bundleInventory, failure, + installedBundleInventory, readBundleIdentity, type BundleIdentityHost, type PluginIdentity, @@ -521,7 +522,7 @@ const installPublicCli = async ( destination = join(dirname(entry.installPath), identity.version); let installed: TreeInventory | undefined; try { - installed = await treeInventory(entry.installPath); + installed = await installedBundleInventory(entry.installPath, host); } catch (error) { // The host says a copy is installed but it cannot be compared: never let that pass as "no drift". if (options.replace !== true) { diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 953ef63ee..7342d89db 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -32,7 +32,7 @@ import { type InstallScope, type PublicHostInstalledEntry, } from './install.ts'; -import { readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -1325,7 +1325,7 @@ const uninstallPublicCli = async ( } else { let installedHash: string | undefined; try { - installedHash = (await treeInventory(entry.installPath)).hash; + installedHash = (await bundleInventory(await readBundleIdentity(entry.installPath, host))).hash; } catch (error) { if (!isErrno(error, 'ENOENT')) { if (!force) { diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index 815a56568..c067c82c8 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -119,8 +119,11 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h await within(events.until('"summary":"Development contract matrix passed."'), 30_000, 'initial matrix'); expect(await versionOf(client)).toBe('v1'); - let listChanged = 0; - client.setNotificationHandler('notifications/tools/list_changed', async () => { listChanged += 1; }); + await waitFor(async () => { + const status = server?.status(); + return status?.artifact.state === 'active' && + status.hostAdoption?.adoptedEpochId === status.artifact.activeEpoch.id; + }); const initialEpoch = server.status().artifact; if (initialEpoch.state !== 'active') throw new Error('Expected an initial active epoch.'); @@ -141,7 +144,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h expect(failedWire).toContain('"state":"failed"'); expect(failedWire).toContain('"routeId":"tool:fixture/unknown"'); expect(failedWire).toContain('"checks":["coverage"]'); - expect(listChanged).toBe(0); expect(await versionOf(client)).toBe('v1'); lateClient = await openProxy(project.root, server.url); expect(await versionOf(lateClient)).toBe('v1'); @@ -160,11 +162,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h mode: 'gated', }); - const changed = Promise.withResolvers(); - client.setNotificationHandler('notifications/tools/list_changed', async () => { - listChanged += 1; - changed.resolve(); - }); await Promise.all([ replaceWatchedSource(project.root, source, toolSource('v3', project.root)), replaceWatchedSource( @@ -173,8 +170,13 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h contractFixtureSource(DEV_CONTRACT_TOOL_ROUTE), ), ]); - await within(changed.promise, 30_000, 'changed notification'); - expect(await versionOf(client)).toBe('v3'); + await waitFor(async () => { + const adoption = server?.status().hostAdoption; + return adoption?.mode === 'gated' && + adoption.contracts?.state === 'passed' && + adoption.adoptedEpochId !== initialEpoch.activeEpoch.id; + }); + await waitFor(async () => await versionOf(client) === 'v3'); const passingEpoch = server.status().artifact; if (passingEpoch.state !== 'active') throw new Error('Expected the repaired build to publish an artifact.'); const passingWire = await within( @@ -183,7 +185,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h 'passing matrix', ); expect(passingWire).toContain('"state":"passed"'); - expect(listChanged).toBe(1); expect(server.status().hostAdoption).toMatchObject({ adoptedEpochId: passingEpoch.activeEpoch.id, contracts: { epochId: passingEpoch.activeEpoch.id, failures: [], state: 'passed' }, @@ -214,7 +215,7 @@ it('adopts artifact.available directly when development contracts are not declar await replaceWatchedSource(project.root, source, toolSource('v3', project.root)); await within(changed.promise); - expect(await versionOf(client)).toBe('v3'); + await waitFor(async () => await versionOf(client) === 'v3'); const artifact = server.status().artifact; if (artifact.state !== 'active') throw new Error('Expected the rebuilt artifact to be active.'); expect(server.status().hostAdoption).toEqual({ adoptedEpochId: artifact.activeEpoch.id, mode: 'direct' }); From d06670cdbe321af2c2fa4a668fe536aa0cd4da9e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:01:54 +0000 Subject: [PATCH 05/19] fix(build): preserve host install comparisons --- packages/agent-bundle/src/install/identity.ts | 26 ++++++++++++++++++- packages/agent-bundle/src/install/receipt.ts | 3 ++- .../agent-bundle/src/install/uninstall.ts | 4 +-- .../tests/dev-contract-adoption.test.ts | 6 +++-- .../tests/packed-readonly-state-root.test.ts | 21 ++++++++------- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index a5d5a0336..3bd36bedb 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -8,7 +8,7 @@ import type { } from '../build/manifest.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; -import { manifestInventory, type TreeInventory } from './receipt.ts'; +import { manifestInventory, treeInventory, type TreeInventory } from './receipt.ts'; export type BundleIdentityHost = 'claude' | 'codex' | 'cursor'; @@ -133,3 +133,27 @@ export const bundleInventory = async (identity: PluginIdentity): Promise => { + const read = await readArtifactManifest(root); + switch (read.status) { + case 'missing': + return treeInventory(read.root); + case 'invalid': + throw failure( + 'AB7001', + `agent-bundle.manifest.json in ${read.root} is not a valid canonical artifact manifest: ${read.detail}`, + host, + ); + case 'ok': + return manifestInventory(read.root, read.manifest, { verifyHashes: false }); + default: { + const exhaustive: never = read; + throw new TypeError(`Unknown artifact manifest read result ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index d43004178..f7c714950 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -335,6 +335,7 @@ export const treeInventory = async (root: string): Promise => { export const manifestInventory = async ( root: string, manifest: ArtifactManifest, + options: { readonly verifyHashes?: boolean } = {}, ): Promise => { const rootMetadata = await lstat(root); if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupportedEntry('.'); @@ -367,7 +368,7 @@ export const manifestInventory = async ( throw error; } const row = rows.get(relativePath); - if (row !== undefined && !matchesManifestFile({ + if (options.verifyHashes !== false && row !== undefined && !matchesManifestFile({ bytes: metadata.size, mode: metadata.mode & 0o777, path: relativePath, diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 7342d89db..6b3727973 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -32,7 +32,7 @@ import { type InstallScope, type PublicHostInstalledEntry, } from './install.ts'; -import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -1325,7 +1325,7 @@ const uninstallPublicCli = async ( } else { let installedHash: string | undefined; try { - installedHash = (await bundleInventory(await readBundleIdentity(entry.installPath, host))).hash; + installedHash = (await installedBundleInventory(entry.installPath, host)).hash; } catch (error) { if (!isErrno(error, 'ENOENT')) { if (!force) { diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index c067c82c8..e1717b634 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -114,6 +114,7 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h }); await waitForActive(server); client = await openProxy(project.root, server.url); + const activeClient = client; events = await projectEvents(server.url); await events.opened; await within(events.until('"summary":"Development contract matrix passed."'), 30_000, 'initial matrix'); @@ -176,7 +177,7 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h adoption.contracts?.state === 'passed' && adoption.adoptedEpochId !== initialEpoch.activeEpoch.id; }); - await waitFor(async () => await versionOf(client) === 'v3'); + await waitFor(async () => await versionOf(activeClient) === 'v3'); const passingEpoch = server.status().artifact; if (passingEpoch.state !== 'active') throw new Error('Expected the repaired build to publish an artifact.'); const passingWire = await within( @@ -208,6 +209,7 @@ it('adopts artifact.available directly when development contracts are not declar server = await startDevServer({ open: false, port: 0, root: project.root }); await waitForActive(server); client = await openProxy(project.root, server.url); + const activeClient = client; expect(await versionOf(client)).toBe('v1'); const changed = Promise.withResolvers(); client.setNotificationHandler('notifications/tools/list_changed', async () => changed.resolve()); @@ -215,7 +217,7 @@ it('adopts artifact.available directly when development contracts are not declar await replaceWatchedSource(project.root, source, toolSource('v3', project.root)); await within(changed.promise); - await waitFor(async () => await versionOf(client) === 'v3'); + await waitFor(async () => await versionOf(activeClient) === 'v3'); const artifact = server.status().artifact; if (artifact.state !== 'active') throw new Error('Expected the rebuilt artifact to be active.'); expect(server.status().hostAdoption).toEqual({ adoptedEpochId: artifact.activeEpoch.id, mode: 'direct' }); diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 2aa7943a0..1232e9f9c 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -8,10 +8,11 @@ import { userDataStateRoot } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; import { rstestWorkerRoot } from '../../../rstest.worker-isolation.ts'; +import { parseArtifactManifest } from '../src/api.ts'; import { exists } from '../src/core/paths.ts'; import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; import { resolveWebLaunch } from '../src/web-host/launch.ts'; -import { readWebManifestDocument } from '../src/web-host/manifest.ts'; +import { readWebManifest } from '../src/web-host/manifest.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); @@ -130,15 +131,15 @@ it('serves a state-writing tool from a read-only installed artifact without writ } // Resolve the launch the way ` web` does: the manifest's web - // section names the App and its server, whose `executables.mcpServers[]` - // launch record holds the artifact-relative entry, and resolveWebLaunch - // anchors the code root without naming a state root. - const document = await readWebManifestDocument(join(installedRoot, 'agent-bundle.manifest.json')); - const declaredApp = document.web?.apps.find((candidate) => candidate.app === app); - if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(document.web)}`); - const serverLaunch = document.launches.get(declaredApp.server); - if (serverLaunch === undefined) throw new Error(`The artifact manifest has no launch record for ${declaredApp.server}.`); - const launch = await resolveWebLaunch({ app: declaredApp, env, launch: serverLaunch, pluginRoot: installedRoot }); + // section names the App and its artifact-relative entry, and + // resolveWebLaunch anchors the code root without naming a state root. + const webManifest = await readWebManifest(join(installedRoot, 'agent-bundle.manifest.json')); + const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); + if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); + const manifest = parseArtifactManifest(await readFile(join(installedRoot, 'agent-bundle.manifest.json'), 'utf8')); + const server = manifest.executables.mcpServers.find((candidate) => candidate.name === declaredApp.server); + if (server?.launch === undefined) throw new Error(`The artifact manifest declares no launch for ${declaredApp.server}.`); + const launch = await resolveWebLaunch({ app: declaredApp, env, launch: server.launch, pluginRoot: installedRoot }); expect(launch.command).toBe(process.execPath); expect(launch.cwd).toBe(installedRoot); expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(installedRoot); From bd3de9bc5cc3cb4df12e83e0928c0e4c77d50974 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:27:56 +0000 Subject: [PATCH 06/19] fix(build): align canonical root consumers --- .changeset/canonical-npm-root.md | 3 +- docs/diagnostics.md | 111 +++++++++------ examples/audiobook-curator/README.md | 4 +- .../audiobook-curator/agent-bundle.config.ts | 2 +- examples/audiobook-curator/package.json | 7 - examples/host-test/README.md | 2 +- examples/host-test/agent-bundle.config.ts | 2 +- examples/host-test/package.json | 7 - packages/agent-bundle/src/api.ts | 3 +- .../agent-bundle/src/build/module-imports.ts | 33 +++-- packages/agent-bundle/src/install/doctor.ts | 8 +- packages/agent-bundle/src/install/format.ts | 4 +- .../tests/artifact-validator.test.ts | 131 +++++------------- .../tests/support/host-install.ts | 52 ++++--- packages/create-agent-bundle/src/scaffold.ts | 75 ++++------ .../templates/cli-tool/README.md | 16 ++- .../templates/cli-tool/agent-bundle.config.ts | 2 +- .../templates/cli-tool/package_json | 11 +- .../tests/projection/cli-dispatch.test.ts | 2 +- .../templates/mcp-server/README.md | 8 +- .../templates/mcp-server/package_json | 10 +- .../tests/scaffold-packed-matrix.e2e.test.ts | 18 +-- .../tests/scaffold.test.ts | 72 ++++------ .../docs/en/examples/audiobook-curator.mdx | 4 +- .../docs/zh/examples/audiobook-curator.mdx | 4 +- 25 files changed, 249 insertions(+), 342 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 765cc3a07..9457f4abf 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -1,5 +1,6 @@ --- "agent-bundle": minor +"create-agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, keep npm metadata outside host install comparisons, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 73c0f036d..9fb37c9b1 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,12 +27,12 @@ even when no error diagnostic was reported. | `AB473x` | Migration nudges (informational; see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | -| `AB4765`–`AB4767` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest (see below). | +| `AB4765`–`AB4768` | Artifact-hosted routed CLI and npm lifecycle paths: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest; or a consumer lifecycle names an unsupported or absent Node path (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains only for what the compiler cannot see — an expression `import()` in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6039`: the compile evidence record does not match the manifest file table; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure — the same five codes are also the development project service's preparation failures; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json` — the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes, size, or executable bit differ from the row, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | @@ -427,38 +427,57 @@ Validation happens at three moments, all fail-closed: | `AB7011` | A file in the generated npm root no longer matches its manifest SHA-256. Rebuild and do not modify the generated npm root. | | `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | -| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | - -`AB7014` counts exactly three sources of evidence that a consumer needs a -declared package: - -1. `runtimeDependencies` on a prebuilt payload (`definePrebuilt`); -2. a packed `.d.ts`, `.d.mts`, or `.d.cts` reference as TypeScript's - `preProcessFile` reads it: `import`/`export … from`, - `import x = require("…")`, `import("…")` types, module augmentations in an - external-module file, or `/// ` (the named package - and its `@types` twin). An ambient `declare module "x"` in a file with no - imports declares that module and counts for nothing. A `#subpath` reaches - packages through the manifest's `imports` map; relative and built-in - specifiers name nothing; -3. the consumer-side `preinstall`, `install`, or `postinstall` script grammar, - including scripts reached through package-manager run and direct script - commands, naming the package or running one of its `bin` commands. - -The framework's own runtime modules bundled into a generated executable load -no package at run time (`generated-module-evidence.test.ts` holds that), so -there is no framework process-dependency record to read as a fourth source. - -A package the compiler bundled is not used. When one is otherwise unused, the -diagnostic names the `dist` bundles that inlined it. Expression requests -(`import(expr)`, `require(expr)`) remain outside the compiler's view; the -emitted-module walk still fails a compiled module on an expression `import()` -(`AB6005 has a non-literal dynamic import`) and on a literal import the -build was told to ignore (`rspackIgnore`/`webpackIgnore`; `AB6005 loads -"", which the compiler neither bundled nor recorded as an external`), -while `require(expr)`, `require.resolve(…)`, `createRequire(…)(…)`, and -`import.meta.resolve(…)` are not imports and are outside both. +| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply `import` evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript `import` evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts — while `require`, `createRequire`, and `import.meta.resolve` calls, which `AB6005` does not walk, count from any packed file. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed file requires or resolves, a packed declaration references, a `#subpath` import reaches, an install script or packed file runs, or a prebuilt payload names in `runtimeDependencies` (`definePrebuilt`). For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | +| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` are always reported because the canonical npm root is validated for `npm publish ./dist`; npm publishes those protocols verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`. Use a registry version before running `agent-bundle prepack`. | + +Compiled bundles reach this gate without bare imports: `prepack` builds before it packs, and the compiler +reports `AB6005` when a `dist` bundle or host-pack module keeps anything other than a Node built-in, +`pnpapi`, or an emitted sibling external, and when an MCP App view's compilation keeps anything external +at all. The emitted-module walk remains behind that compile-time check, so the lexed `import` evidence below describes prebuilt payload modules and other packed scripts the +framework did not compile. A `require`, `createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler +does not resolve is not a module dependency; its evidence is read from every packed file, compiled bundles +included. Because the compiler never opens a payload file, its +`runtimeDependencies` declaration also counts as dependency evidence. The +dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file +`npm pack --dry-run` lists is lexed for static and dynamic `import` specifiers and scanned for +literal `require("…")` and `.resolve("…")` calls (`require.resolve`, `createRequire(…).resolve`, +`import.meta.resolve`: a package located only to find an asset is still a runtime dependency; a +binding such as `const load = createRequire(import.meta.url)` is a loader and `load("…")` counts like +`require("…")`, whether the factory is imported under its own name, renamed with `as`, reached through a +namespace import, or chained off `require("node:module")`; `createRequire(…)("…")` inline counts too, qualified the same +ways — `Module.createRequire(…)("…")`, `require("node:module").createRequire(…)("…")` — the +factory's argument nesting calls such as `new URL("./entry.js", import.meta.url)`; comments between a loader +and its parentheses or around the argument, `require /* x */ ("y")`, are trivia), and +every `.d.ts`/`.d.mts`/`.d.cts` file is scanned for `from "…"`, +`import("…")`, `import x = require("…")`, `declare module "…"` (a module augmentation), and `/// ` (a consumer needs the +package that provides those types even without a runtime import; a type directive counts for the +named package and its `@types/*` twin, `@types/scope__name` for a scoped name); bare specifiers are reduced to +their package name (`@scope/name` or `name`), string escapes decoded first (`require("\x66oo")` loads `foo`), +and Node built-ins are ignored. A dependency packed JavaScript runs rather than loads — a string literal that +is one of the `bin` commands its manifest under `node_modules` declares, bare or followed by arguments +(`spawnSync("tsc", ["--version"])`, `execSync("tsc --noEmit")`) — counts as used too; a dependency not installed +at pack time has no known commands, so its bare name in a string proves nothing here. A mention inside a +comment or string can only keep a dependency, never report one, and `devDependencies` are never +inspected. A packed `#subpath` import counts for every package the `imports` entry Node would pick for it targets +(the exact key, or the wildcard key with the longest matching prefix, its `*` substituted — every conditional target +of that entry, since conditions are not settled here), and a dependency named — anywhere in the text, since a mention +can only keep a declaration — by a consumer-side `preinstall`/`install`/`postinstall` script (not `prepare`, which npm +runs on `pack`, local installs, and git dependencies but never for a published tarball), or by any +script those reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is +the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, +`npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, +so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved +(`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through +npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`) — by +package name or by one of its `bin` commands, read from `node_modules//package.json` (a string-form `bin` +is one command named after the installed manifest's unscoped `name` — `real` for an alias `"wrapper": +"npm:@scope/real@1"`; the manifest is read as npm reads it, so the last of duplicate keys wins), with the unscoped dependency name standing in when that manifest is unreadable or not JSON — counts as used. A computed `import(expression)` or +`require(expression)` — also `require.resolve`, `import.meta.resolve`, a direct `createRequire(…)(…)`, or a +`createRequire` binding — with a non-literal argument — in packed JavaScript could load any declared package, so its presence withholds +`AB7014` entirely; the recovery text says so. So does packed source the ESM lexer rejects, whose `import()` calls it cannot report, and so does a loader passed on as a value rather than called — `const load = require`, `fn(require)`, +`module.exports = require`, `x ? require : y` — since packages may then be loaded under a name the scan never sees (`require("x")`, +`require.resolve("x")`, `typeof require`, and a `require` inside a string or comment are not that). Only those resolvers count, literal argument or not: `path.resolve("foo")` +and `Promise.resolve("foo")` are not package resolution and never keep `foo`. ## Declaration generation (`AB4716`) @@ -759,7 +778,7 @@ simply not been built yet is a validation **warning** that only | Code | Severity | Trigger | | --- | --- | --- | | `AB4740` | error | The `payload` block, one entry, or its `targets` list is malformed, or a payload selects an unknown target. A `runtimeDependencies` list that is not an array of nonempty strings is also `AB4740`. | -| `AB4741` | error | A payload destination is not a safe directory name, shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents), or names the runtime-owned `state` root in any letter case — the manifest never indexes a file under `state/`, and installers neither copy nor own that root. | +| `AB4741` | error | A payload destination is not a safe directory name, or shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents). | | `AB4742` | error | A payload source escapes the project root, is not a directory, or contains another payload's source. | | `AB4743` | warning | A declared payload directory does not exist yet or contains no files. Run the project's own build first. | | `AB4744` | error | A `{ prebuilt: ... }` entry (MCP server or hook handler) does not resolve inside a declared payload, or its payload does not select every target the component needs. | @@ -833,7 +852,7 @@ exactly where `build` would refuse. | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | -## Artifact-hosted routed CLI (`AB4765`–`AB4767`) +## Artifact-hosted routed CLI and npm lifecycle paths (`AB4765`–`AB4768`) A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus @@ -849,6 +868,11 @@ See “The routed CLI shell” in | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | | `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | +| `AB4768` | error (package build) | A consumer `preinstall`, `install`, or `postinstall` script uses a Node command the package builder cannot safely normalize, or names a file absent from the generated npm root. Use one `node ` command (source `dist/` bin and library paths are rebased), or invoke an executable supplied by a declared runtime dependency. Arbitrary source files are never copied into the npm root. | + +Package outputs are staged after the composite artifact is published, so an +`AB4767` failure leaves that validated artifact available while refusing the +npm root. ## Config beside a route-generated MCP server (`AB4340`) @@ -1812,8 +1836,7 @@ placeholders itself. ## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`, `AB6039`) `agent-bundle build` validates the staged tree before it writes the manifest -(`validateArtifactFiles`: filesystem entries, generated JSON documents, the -compile evidence record against the planned file table (`AB6039`), and +(`validateArtifactFiles`: filesystem entries, generated JSON documents, and generated JavaScript modules), validates the finished artifact against its manifest, and re-checks the validated snapshot after the staging tree is renamed into place. `agent-bundle validate --artifact ` runs the same @@ -1839,8 +1862,12 @@ producer `{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` bytes may differ from the module graph), and `coverage.unobserved`. `agent-bundle validate --artifact` re-checks a listed record against the manifest file table without reading JavaScript (`AB6039`). The package build -returns the same record in process (`PackageBuildResult.evidence`, paths -`dist/bin/…`) and writes nothing to disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in +keeps those artifact bytes and that record unchanged, and writes +`agent-bundle.package-compile-evidence.json` beside them for package-only +authored bins and libraries. `PackageBuildResult.evidence` is that second +record; its paths are relative to the npm root (`bin/…`, not `dist/bin/…`). +The prepack gate checks it against the package-only `bundle` inventory. +`coverage.unobserved` lists the load forms Rslib leaves verbatim in the bundle, so the compiler neither bundles nor records them: `import()`, `require()`, `require.resolve(…)`, `createRequire(…)(…)`, `import.meta.resolve(…)`. No externals recorded @@ -1852,7 +1879,7 @@ therefore does not prove the absence of such a load. | `AB6001` | error | `Artifact manifest is not a strict canonical manifest.` — `agent-bundle.manifest.json` does not parse as a strict canonical artifact manifest. `Artifact manifest changed during validation.` — its bytes or identity differ between the first read and the re-read after validation. | Regenerate the strict canonical manifest without concurrent writes, then rerun validation. | | `AB6002`–`AB6003` | error | Reserved: both codes are declared in the artifact diagnostic registry, but no validator emits either today. | `AB6002`: Rebuild the artifact from complete project source, then rerun validation. `AB6003`: Rebuild the artifact with canonical generated output, then rerun validation. | | `AB6004` | error | `Artifact files do not match the manifest.` — the regular files on disk differ from the manifest file table (a path, byte length, mode, or SHA-256; a missing or unmanifested file). `Artifact file changed during validation: "".` — a file differed between the initial and final inspection, or between a validated staging tree and its re-check after `build` renamed it into place. `Artifact file table changed during validation.` — the final inspection could not be taken. | Rebuild the artifact so its file table and contents match the manifest. | -| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external; so is a literal import marked `rspackIgnore`/`webpackIgnore`, which Rspack leaves verbatim with no module, external, or warning. The emitted-module walk reports both in a compiled module the record proves: `Generated JavaScript import from "" has a non-literal dynamic import.` and `Generated JavaScript import from "" loads "", which the compiler neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.` (a literal request that is neither a Node built-in nor one of the record's externals for that file). MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. Residual walk forms, `Generated JavaScript import from "" .`, reported only for what the compiler cannot see: `has a non-literal dynamic import` for an expression `import()` in any emitted module, compiled ones included, since Rslib's profile leaves that form verbatim; and, for a module the compile evidence record does not prove — JavaScript the framework did not compile (`install.mjs`, a copied script), every module of an artifact without a record, and every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`) — `has invalid syntax` from a full parse, `uses unsupported specifier` or `uses invalid specifier` for a bare or malformed import, `cannot be read`, and the relative-target findings `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`. A compiled module the record covers with the same bytes is lexed for syntax and its literal imports are not resolved again — the compiler resolved them (bundled, built-in, or an emitted sibling). Prebuilt payloads are not walked; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | +| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. The emitted-module walk remains as defense in depth and reports `Generated JavaScript import from "" .` for a residual unsupported or invalid specifier, unreadable module, invalid syntax, or non-literal dynamic import. Its relative-target findings remain `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | | `AB6006` | error | `Generated JSON cannot be parsed.` — a `.json` file in the artifact is not valid JSON (prebuilt payload files are exempt). Doctor's Claude document lane reports the same code inside an `AB7319` message for a Claude bundle document that is unreadable or not valid JSON. | Regenerate the affected JSON document as valid JSON, then rebuild the artifact. | | `AB6007` | error | `MCP manifest references missing generated server "".` — a root-level MCP manifest (pre-manifest pass) or a target's MCP manifest names a local server entry that the artifact does not contain. | Repair MCP manifest references to generated servers, then rebuild the artifact. | | `AB6008` | error | `Artifact Agent Skills provenance does not match the pinned schema contract.` — the manifest's `agentSkills` schema SHA-256, source revision, or specification differs from the framework's pinned Agent Skills revision. | Rebuild the artifact with the pinned Agent Skills contract. | @@ -1869,7 +1896,7 @@ therefore does not prove the absence of such a load. | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | -| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. A record with any `AB6039` finding, or with `coverage.rewritable` set, proves nothing to the emitted-module walk, which then parses every module in full. | Rebuild the artifact so its compile evidence record describes the emitted files. | +| `AB6039` | error | `Compile evidence record .` — the listed artifact or package-only evidence failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` / `Package compile evidence cannot be read: .` — a required record is unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the file table does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. | Rebuild the artifact or generated npm root so its compile evidence describes the emitted files. | ## Workbench artifact inspection (`AB6200`–`AB6202`) diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 5200856c7..d9a2e1561 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -33,7 +33,7 @@ Build and globally link the workspace package without a tarball: ```sh pnpm --filter @agent-bundle-example/audiobook-curator build cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` @@ -44,7 +44,7 @@ One `agent-bundle build` produces everything: one plugin root at `artifact/` that both Claude Code and Codex install (each host's manifest directory, `.claude-plugin/` and `.codex-plugin/`, over the shared Skill, bundled CLI script, and lifecycle-wrapped MCP server) plus the npm package beneath `dist/` -(`dist/bin/audiobook-curator.js` for `package.json` `bin`, and `dist/index.js` +(`dist/bin/audiobook-curator.mjs` for `package.json` `bin`, and `dist/index.js` plus declarations for `exports`). The example uses only public `agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` dependencies. diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 4441cf0f9..a3e2ffae5 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ // #102 stage 4 adopts the in-house G7 projection for every curator tool. routes: { mcpCommands: true }, // No `scripts` or `bin` fields needed: the routed `src/cli/` commands - // compile into the package executable (dist/bin/audiobook-curator.js) by + // compile into the shared artifact/package executable (bin/audiobook-curator.mjs) by // convention (#102 stages 2-3). // No `skills` field needed: `src/skills/curate-audiobooks/SKILL.md` is // discovered by convention. diff --git a/examples/audiobook-curator/package.json b/examples/audiobook-curator/package.json index 51604f8b7..3907dcafb 100644 --- a/examples/audiobook-curator/package.json +++ b/examples/audiobook-curator/package.json @@ -8,13 +8,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "README.md" - ], - "bin": { - "audiobook-curator": "./dist/bin/audiobook-curator.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/examples/host-test/README.md b/examples/host-test/README.md index b2ee8b34b..0aa9c1a1b 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -51,7 +51,7 @@ Two MCP servers ship in the plugin: judged against the wire. The rendered CLI `host-test dump [--conversation ] [--full] [--log ]` -(`dist/bin/host-test.js`) reads the same log outside any host. +(`dist/bin/host-test.mjs`) reads the same log outside any host. The log lives at `$HOST_TEST_LOG_DIR/captures.ndjson` when that variable is set, otherwise `$AGENT_BUNDLE_PLUGIN_ROOT/state/host-test/captures.ndjson` diff --git a/examples/host-test/agent-bundle.config.ts b/examples/host-test/agent-bundle.config.ts index 4696972c5..2250307f3 100644 --- a/examples/host-test/agent-bundle.config.ts +++ b/examples/host-test/agent-bundle.config.ts @@ -23,6 +23,6 @@ export default defineConfig({ // The generated `host-test` MCP server (src/mcp/host-test/tools) hosts the // shared event runtime; `src/mcp/host-test-raw.ts` is a hand-rolled stdio // server that records the raw MCP request envelope for correlation. - // The rendered `src/cli/dump.tsx` command compiles into dist/bin/host-test.js. + // The rendered `src/cli/dump.tsx` command compiles into dist/bin/host-test.mjs. targets: ['claude', 'codex', 'cursor', 'portable'], }); diff --git a/examples/host-test/package.json b/examples/host-test/package.json index d3b3d8809..257e65c66 100644 --- a/examples/host-test/package.json +++ b/examples/host-test/package.json @@ -8,13 +8,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "artifact", - "README.md" - ], - "bin": { - "host-test": "./dist/bin/host-test.js" - }, "scripts": { "build": "agent-bundle build --output artifact", "check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index f993b91a0..f1a885ce6 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -13,7 +13,6 @@ import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { routedCliBins } from './build/cli-bins.ts'; import { planComposite } from './build/compose.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; -import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts'; import { packInventoryDiagnostics, packOutputFromJson, @@ -1404,7 +1403,7 @@ export const prepack = async (options: BuildOptions): Promise => model: result.model, packageBuild: result.packageBuild, packOutput: pack, - packerRewritesWorkspaceProtocols: rewritesWorkspaceProtocols(process.env.npm_config_user_agent), + packerRewritesWorkspaceProtocols: false, projectRoot: options.root, }); if (hasErrors(diagnostics)) throw new DiagnosticError(diagnostics); diff --git a/packages/agent-bundle/src/build/module-imports.ts b/packages/agent-bundle/src/build/module-imports.ts index 9d06ae02d..f0e7057bd 100644 --- a/packages/agent-bundle/src/build/module-imports.ts +++ b/packages/agent-bundle/src/build/module-imports.ts @@ -1,6 +1,8 @@ import { parse as parseJavaScript } from 'acorn'; import { init, parse } from 'es-module-lexer'; +import type { AgentBundleToolsConfig } from '../core/types.ts'; + /** * One import of an ES module as the lexer reports it: `specifier` is the * literal module specifier (absent for a non-literal dynamic import), and @@ -17,25 +19,34 @@ export interface ModuleImport { * * - `lexed`: the ESM lexer is the only pass. It rejects unterminated strings, * templates, comments, and regexps and unbalanced braces — enough for a - * module the compiler emitted and its evidence record covers, whose syntax - * is the bundler's to guarantee. Re-parsing megabytes of bundler output to - * prove it is JavaScript was the dominant cost of every build. - * - `parsed`: a full `acorn` parse runs first, so a module the compiler's - * evidence does not cover — a copied consumer script, a generated - * installer, a bundle a `tools` hatch may have rewritten — keeps the - * complete syntax check. + * module the framework's own bundler emitted, whose syntax is the + * bundler's to guarantee. Re-parsing megabytes of bundler output to prove + * it is JavaScript was the dominant cost of every build. + * - `parsed`: a full `acorn` parse runs first, so a module the framework did + * not compile — a copied consumer script or standalone installer — keeps + * the complete syntax check. */ export type ModuleSyntaxCheck = 'lexed' | 'parsed'; +/** + * How a build checks the syntax of the modules its own bundler emitted. The + * bundler's output is trusted to the ESM lexer; once a consumer `tools` hatch + * can rewrite emitted assets (a banner, a `processAssets` pass), the final + * bytes are no longer the bundler's proof and are parsed in full. The + * artifact build and the package build decide this the same way. + */ +export const bundleSyntaxCheckFor = (tools: AgentBundleToolsConfig | undefined): ModuleSyntaxCheck => + tools?.rspack === undefined && tools?.rsbuild === undefined ? 'lexed' : 'parsed'; + const importKind = (dynamic: number): ModuleImport['kind'] => dynamic === -2 ? 'meta' : dynamic === -1 ? 'static' : 'dynamic'; /** * Imports already read from bytes with a known SHA-256, keyed by check level - * and digest. Within one process the same emitted module is scanned by - * artifact validation twice (before and after the manifest is written); the - * bytes never change between those passes, so the imports of a - * multi-megabyte bundle are lexed once. + * and digest. Within one process the same emitted bundle is scanned by the + * post-compile self-containment check and then by artifact validation, twice + * (before and after the manifest is written); the bytes never change between + * those passes, so the imports of a multi-megabyte bundle are lexed once. * The records are a few dozen specifiers per module; the map stays bounded. */ const importsByDigest = new Map(); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index d5a0cf836..294567a70 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -13,7 +13,7 @@ import { import { mapConcurrent } from '../core/async.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; import { readArtifactManifest } from '../build/manifest-file.ts'; -import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { exists } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import { validateClaudePlugin, @@ -49,12 +49,12 @@ import { installReceiptFile, installReceiptFormat, installReceiptStoreDirectory, + isPreservedRuntimeRoot, isRemnantReceipt, isRuntimeStateRemnant, listStoredInstallReceipts, readInstallReceipt, readInstallReceiptFile, - treeInventory, type InstalledTreeComparison, type InstalledTreeOwnership, type InstallReceipt, @@ -75,7 +75,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; -import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { inspectInstalledStateOwnership, resolveInstalledStateRoots, @@ -1798,7 +1798,7 @@ const publicHostInstallComparison = async ( } let installed: TreeInventory; try { - installed = await treeInventory(entry.installPath); + installed = await installedBundleInventory(entry.installPath, host); } catch (error) { comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index c103d9ca0..8c9530034 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -64,8 +64,8 @@ const registrationLabel = (registration: UninstallResult['registrations'][number registration.id ?? registration.name ?? registration.kind; /** - * Human-readable uninstall summary shared by the CLI and generated installer - * bins. `--plan` output lists every exact path the run would remove, so an + * Human-readable uninstall summary shared by the CLI and standalone artifact + * installer. `--plan` output lists every exact path the run would remove, so an * operator can audit the mutation before allowing it. */ export const formatUninstallResult = (result: UninstallResult): string => { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 4a37e3a03..c7497a8b8 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -17,15 +17,13 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactWrite, } from '../src/adapters/types.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { compileEvidenceFileName, - externalPolicy, serializeCompileEvidenceRecord, - unobservedLoadForms, type CompileEvidenceAsset, type CompileEvidenceExternal, } from '../src/build/compile-evidence.ts'; -import { composeProjections } from '../src/build/compose.ts'; import { artifactCompilerRecordVersion, artifactManifestVersion, @@ -2121,112 +2119,47 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as } }); -/** A compile evidence record covering every `bundle` fixture file with its exact bytes and the externals given per path. */ -const compileEvidenceFor = ( - files: readonly ArtifactFixtureFile[], - rewritable = false, - externals: Readonly> = {}, -): ArtifactFixtureFile => ({ - contents: serializeCompileEvidenceRecord({ - assets: files - .filter((file) => file.kind === 'bundle') - .map((file) => ({ externals: externals[file.path] ?? [], packages: [], path: file.path, sha256: hash(file.contents) })) - .sort((left, right) => left.path.localeCompare(right.path)), - coverage: { rewritable, unobserved: unobservedLoadForms }, - policy: externalPolicy, - producer: { name: 'agent-bundle', rspack: '2.2.2', version: '0.1.0' }, - }), - kind: 'generated', - path: compileEvidenceFileName, -}); - /** - * The check a module gets follows what the compiler proved. A manifest - * `bundle` file the compile evidence record covers — same bytes, from a build - * without a `tools` hatch — is the compiler's output: only the ESM lexer runs - * over it, so a bare `export const broken = ;` (which no bundler emits) passes - * while unterminated input still fails, and each literal import it carries is - * held to the record rather than resolved — a Node built-in or a recorded - * external passes, anything else is an import the build ignored. Every other - * module — a copied consumer script, a generated installer, a bundle without - * a record or from a build whose hatch may have rewritten it — is parsed in - * full and its imports resolved against the file table. + * The syntax check a module gets follows who produced it. A module the + * framework compiled (manifest kind `bundle`) is the bundler's own output: + * only the ESM lexer runs over it, so re-parsing megabytes of bundler output + * no longer dominates every build, and a bare `export const broken = ;` — + * which no bundler emits — passes while unterminated input still fails. A + * module the framework did not compile (a copied consumer script or + * standalone installer) is parsed in full and keeps the complete check. */ -it('lexes compiled modules the evidence record proves and walks every other module in full', async () => { +it('parses copied and generated modules in full and trusts compiler bundles to the ESM lexer', async () => { const brokenStatement = 'export const broken = ;\n'; - const modules: readonly ArtifactFixtureFile[] = [ + const root = await writeArtifact([ { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: brokenStatement, kind: 'copy', path: 'scripts/copied.mjs' }, { contents: brokenStatement, kind: 'generated', path: 'scripts/generated.mjs' }, { contents: brokenStatement, kind: 'bundle', path: 'scripts/bundled.mjs' }, { contents: 'export const unterminated = `;\n', kind: 'bundle', path: 'scripts/unterminated.mjs' }, { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'scripts/dangling.mjs' }, - { contents: "import 'unbundled-package';\n", kind: 'bundle', path: 'scripts/bare.mjs' }, - { contents: "import 'unbundled-package';\n", kind: 'generated', path: 'scripts/uncompiled.mjs' }, - { contents: "import 'node:fs';\nimport './sibling.mjs';\n", kind: 'bundle', path: 'scripts/accounted.mjs' }, - { contents: 'export const sibling = true;\n', kind: 'bundle', path: 'scripts/sibling.mjs' }, - ]; - const accountedExternals = { - 'scripts/accounted.mjs': [{ - externalType: 'module', - issuers: ['src/accounted.ts'], - kind: 'artifact-relative' as const, - request: './sibling.mjs', - target: 'scripts/sibling.mjs', - userRequest: './sibling.mjs', - }], - }; - const reported = async (files: readonly ArtifactFixtureFile[]): Promise => { - const root = await writeArtifact(files, true, [customManifestTarget]); - try { - return (await validateArtifact({ artifactRoot: root, registry: customRegistry() })) - .filter((entry) => entry.code === 'AB6005' || entry.code === 'AB6039') - .map((entry) => [entry.code, entry.generatedPath, entry.message]); - } finally { - await rm(root, { force: true, recursive: true }); - } - }; - const walkedInFull = [ - ['AB6005', 'scripts/bare.mjs', 'Generated JavaScript import from "scripts/bare.mjs" uses unsupported specifier "unbundled-package".'], - ['AB6005', 'scripts/bundled.mjs', 'Generated JavaScript import from "scripts/bundled.mjs" has invalid syntax.'], - ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], - ['AB6005', 'scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], - ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], - ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], - ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], - ]; + ], true, [customManifestTarget]); - // Without a record nothing is proven: every module is parsed and resolved. - expect(await reported(modules)).toEqual(walkedInFull); - // A record covering the bundles proves their bytes, not their imports: the - // lexer still rejects unterminated input, a built-in and a recorded sibling - // pass, and a bare specifier or a dangling sibling the record does not - // account for — what an `rspackIgnore` import leaves behind — is reported - // without being resolved. - const unaccounted = (importer: string, request: string): readonly string[] => [ - 'AB6005', - importer, - `Generated JavaScript import from ${JSON.stringify(importer)} loads ${JSON.stringify(request)}, which the compiler ` - + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', - ]; - expect(await reported([...modules, compileEvidenceFor(modules, false, accountedExternals)])).toEqual([ - unaccounted('scripts/bare.mjs', 'unbundled-package'), - ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], - unaccounted('scripts/dangling.mjs', './missing.mjs'), - ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], - ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], - ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], - ]); - // Without the sibling external in the record, the same import is unaccounted for. - expect(await reported([...modules, compileEvidenceFor(modules)])).toContainEqual(unaccounted('scripts/accounted.mjs', './sibling.mjs')); - // A hatch may have rewritten the emitted bytes after the compiler judged - // them: the record says so and proves nothing. - expect(await reported([...modules, compileEvidenceFor(modules, true, accountedExternals)])).toEqual(walkedInFull); - // A record that does not parse is reported once and proves nothing. - expect(await reported([...modules, { contents: '{', kind: 'generated', path: compileEvidenceFileName }])).toEqual([ - ['AB6039', compileEvidenceFileName, 'Compile evidence record is not valid JSON.'], - ...walkedInFull, - ]); + try { + const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); + expect(diagnostics.filter((entry) => entry.code === 'AB6005').map((entry) => [entry.generatedPath, entry.message])).toEqual([ + ['scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + ['scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], + ['scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], + ]); + // A build whose consumer hatch may have rewritten the emitted assets asks + // for the full parse of bundles too; nothing else changes. + const parsed = await validateArtifact({ artifactRoot: root, bundleSyntaxCheck: 'parsed', registry: customRegistry() }); + expect(parsed.filter((entry) => entry.code === 'AB6005').map((entry) => entry.generatedPath)).toEqual([ + 'scripts/bundled.mjs', + 'scripts/copied.mjs', + 'scripts/dangling.mjs', + 'scripts/generated.mjs', + 'scripts/unterminated.mjs', + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } }); it('does not import copied non-JavaScript resources', async () => { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 9279f75c2..d79d5873e 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -184,12 +184,6 @@ export interface DevHostInstallProofReport { readonly status: 'passed'; } -export interface HostInstallCommand { - readonly cwd?: string; - readonly executable: string; - readonly prefixArguments?: readonly string[]; -} - export interface InstalledHostContractMatrixProofOptions { readonly environment: Readonly; readonly fixtures: Readonly>; @@ -200,7 +194,6 @@ export interface InstalledHostContractMatrixProofOptions { interface HostInstallProofOptions { readonly environment: Readonly; - readonly installCommand?: HostInstallCommand; } /** The same-version rebuild round trip every host proof performs after its first install. */ @@ -437,7 +430,7 @@ const runNodeCli = ( { ...options, timeout: 180_000 }, ); -/** Runs `install` or `uninstall` through the source-built CLI (`--from `) or the packed package bin. */ +/** Runs `install` or `uninstall` through the source-built CLI with `--from `. */ const runLifecycleCommand = ( fixture: BuiltHostInstallFixture, verb: 'install' | 'uninstall', @@ -445,29 +438,14 @@ const runLifecycleCommand = ( bundle: string, options: HostInstallProofOptions, extraArguments: readonly string[] = [], -): Promise => { - if (options.installCommand === undefined) { - return runNodeCli(fixture, [ - verb, - host, - '--from', - bundle, - ...extraArguments, - '--json', - ], { cwd: bundle, environment: isolatedEnvironment(options.environment, {}) }); - } - return run(options.installCommand.executable, [ - ...(options.installCommand.prefixArguments ?? []), +): Promise => runNodeCli(fixture, [ verb, host, + '--from', + bundle, ...extraArguments, '--json', - ], { - cwd: options.installCommand.cwd ?? bundle, - environment: isolatedEnvironment(options.environment, {}), - timeout: 180_000, - }); -}; + ], { cwd: bundle, environment: isolatedEnvironment(options.environment, {}) }); const runInstallCommand = ( fixture: BuiltHostInstallFixture, @@ -998,6 +976,16 @@ export const runClaudeHostInstallProof = async ( }, installedRoot: expectedInstallPath, }); + const doctor = await runDoctor({ + environment, + from: fixture.artifactRoot, + home, + hosts: ['claude'], + }); + assertProof( + doctor.hosts.find((entry) => entry.host === 'claude')?.bundle?.comparison?.status === 'current', + `Doctor did not report the installed Claude npm root as current: ${JSON.stringify(doctor.diagnostics)}`, + ); return Object.freeze({ host: 'claude', install: Object.freeze({ sameVersionRebuild, state: 'installed', version }), @@ -1124,6 +1112,16 @@ export const runCodexHostInstallProof = async ( }, installedRoot: cachePath, }); + const doctor = await runDoctor({ + environment, + from: fixture.artifactRoot, + home, + hosts: ['codex'], + }); + assertProof( + doctor.hosts.find((entry) => entry.host === 'codex')?.bundle?.comparison?.status === 'current', + `Doctor did not report the installed Codex npm root as current: ${JSON.stringify(doctor.diagnostics)}`, + ); return Object.freeze({ host: 'codex', diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index dfe59fdc2..9b925857b 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -30,7 +30,7 @@ const renderTargets = (targets: readonly TargetName[]): string => /** Derived from the shared default list so a change there cannot silently break the drift check. */ const defaultTargetsLiteral = `targets: [${renderTargets(defaultTargets)}]`; -const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; +const installableHostNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; export interface ScaffoldRequest { readonly frameworkSpec: string; @@ -61,7 +61,6 @@ export const assertScaffoldTarget = Effect.fnUntraced(function* ( }); interface TemplateManifest { - bin?: Record; dependencies?: Record; devDependencies?: Record; name?: string; @@ -84,17 +83,6 @@ const rewriteManifest = (contents: string, request: ScaffoldRequest, runtimeSpec } } } - if ( - manifest.bin !== undefined && - !request.targets.some((target) => installerTargetNames.includes(target)) - ) { - const suffixedInstallerName = `${request.pluginName}-install`; - const installerName = Object.hasOwn(manifest.bin, suffixedInstallerName) - ? suffixedInstallerName - : request.pluginName; - delete manifest.bin[installerName]; - if (Object.keys(manifest.bin).length === 0) delete manifest.bin; - } return `${JSON.stringify(manifest, null, 2)}\n`; }; @@ -105,28 +93,33 @@ const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): return contents.replace(defaultTargetsLiteral, `targets: [${renderTargets(targets)}]`); }; -/** The hosts the generated installer bin accepts, in the package build's order. */ +/** Installable hosts in the package build's order. */ const installableHosts = (targets: readonly TargetName[]): readonly TargetName[] => - (['claude', 'codex', 'cursor'] as const) - .filter((host) => targets.includes(host)); + installableHostNames.filter((host) => targets.includes(host)); /** * Template READMEs are written against the default targets, so their install * example names `claude`. The checked-in shape is one shell comment followed - * by `npx install claude`, plus the prose sentence naming the - * ` install ` command; both markers are drift-checked. + * by the public install command for Claude, plus the prose sentence naming the + * generic public install command; both markers are drift-checked. */ -const readmeInstallExample = /^(# after publishing[^\n]*)\n(npx \S+) install claude\n/mu; -const readmeInstallProse = /^Installing the npm package does not mutate any host; run the generated\n`(\S+) install ` command explicitly\.\n/mu; +const readmeInstallExample = + /^(# after publishing[^\n]*)\nnpx agent-bundle install claude --from node_modules\/\S+\n/mu; +const readmeInstallProse = + /^Installing the npm package does not mutate any host; run\n`npx agent-bundle install --from node_modules\/` explicitly\.\n/mu; /** * Rewrite a template README's install instructions for the selected targets: * one example line per installable host, or — when no `claude`, `codex`, or - * `cursor` target is selected and therefore no installer bin is generated — - * an explanation of how to get one. Templates without an install + * `cursor` target is selected — an explanation of how to enable one. + * Templates without an install * section (the skills-only template) pass through unchanged. */ -const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): string => { +const rewriteReadmeInstall = ( + contents: string, + packageName: string, + targets: readonly TargetName[], +): string => { const example = readmeInstallExample.exec(contents); const prose = readmeInstallProse.exec(contents); if (example === null && prose === null) return contents; @@ -136,40 +129,31 @@ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): const hosts = installableHosts(targets); // Every group is unconditional in the patterns above. const comment = example[1] ?? ''; - const exampleBin = example[2] ?? ''; - const proseBin = prose[1] ?? ''; if (hosts.length === 0) { - // The scaffold also dropped this bin mapping from package.json, and the - // build never restores manifest entries, so re-enabling installers needs - // both edits: the config target and the bin entry the npx command resolves. - const binEntry = `"${proseBin}": "./dist/bin/${proseBin}.js"`; return contents .replace(readmeInstallExample, [ - '# no installer bin is generated for these targets; add claude, codex, or cursor', - '# to `targets` in agent-bundle.config.ts and restore the package.json bin entry', - `# ${binEntry} to get one`, + '# no installable host is selected; add claude, codex, or cursor', + '# to `targets` in agent-bundle.config.ts before installing', '', ].join('\n')) .replace(readmeInstallProse, [ 'Installing the npm package does not mutate any host. This project selects', - `no installable host target (${renderTargets(targets)}), so no \`${proseBin} install\``, - 'command is generated and its `bin` entry was dropped from `package.json`. To', - 'generate one, add `claude`, `codex`, or `cursor` to `targets` in', - `\`agent-bundle.config.ts\` and restore \`${binEntry}\` under \`bin\` in`, - '`package.json`; the build emits the installer file but never edits the manifest.', + `no installable host target (${renderTargets(targets)}). Add \`claude\`, \`codex\`,`, + 'or `cursor` to `targets` in `agent-bundle.config.ts` before using', + '`agent-bundle install`.', '', ].join('\n')); } return contents .replace(readmeInstallExample, [ comment, - ...hosts.map((host) => `${exampleBin} install ${host}`), + ...hosts.map((host) => `npx agent-bundle install ${host} --from node_modules/${packageName}`), '', ].join('\n')) .replace(readmeInstallProse, [ - 'Installing the npm package does not mutate any host; run the generated', - `\`${proseBin} install \` command explicitly. The installer accepts the`, - `selected host targets only: ${hosts.map((host) => `\`${host}\``).join(', ')}.`, + 'Installing the npm package does not mutate any host; run', + `\`npx agent-bundle install --from node_modules/${packageName}\` explicitly.`, + `The package contains these selected host targets: ${hosts.map((host) => `\`${host}\``).join(', ')}.`, '', ].join('\n')); }; @@ -177,9 +161,8 @@ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): /** * Copy one template directory into the target, substituting the placeholder * project name in every file, rewriting `package.json` (real package name, - * `workspace:*` framework placeholder pinned to the resolved spec, installer - * bins omitted when no installable host is selected), the config's target - * list, and the README's install instructions. Returns the emitted + * `workspace:*` framework placeholder pinned to the resolved spec), the + * config's target list, and the README's install instructions. Returns the emitted * project-relative paths, sorted. */ export const scaffold = Effect.fnUntraced(function* ( @@ -219,7 +202,9 @@ export const scaffold = Effect.fnUntraced(function* ( // the defect crosses the boundary as the same Error it always was. if (relativePath === 'package.json') contents = rewriteManifest(contents, request, runtimeSpec); if (relativePath === 'agent-bundle.config.ts') contents = rewriteConfigTargets(contents, request.targets); - if (relativePath === 'README.md') contents = rewriteReadmeInstall(contents, request.targets); + if (relativePath === 'README.md') { + contents = rewriteReadmeInstall(contents, request.packageName, request.targets); + } yield* fs.writeFileString(destination, contents); emitted.push(relativePath); } diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index ff8a4d165..2ca7c89f1 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -4,7 +4,7 @@ A routed command-line tool and library built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). There is no second bundler config, no hand-written bin shim, and no argv parser: the `src/cli/**` convention compiles each command module into one generated -executable, `dist/bin/my-agent-plugin.js`, with help, argv grammar, input +executable, `dist/bin/my-agent-plugin.mjs`, with help, argv grammar, input validation, and exit codes derived from the module's own `config` and zod schemas. `src/index.ts` is the library export with declarations, and `src/scripts/hello.ts` ships as a plain script inside every host artifact. One @@ -20,17 +20,19 @@ npm run test # plain module tests npm run test:projection # cli-dispatch + script-dispatch pool # after a build -node dist/bin/my-agent-plugin.js greet World -node dist/bin/my-agent-plugin.js greet World --shout -node dist/bin/my-agent-plugin.js greet --help +node dist/bin/my-agent-plugin.mjs greet World +node dist/bin/my-agent-plugin.mjs greet World --shout +node dist/bin/my-agent-plugin.mjs greet --help node artifact/scripts/hello.mjs World # after publishing/installing the package -npx my-agent-plugin-install install claude +npx agent-bundle install claude --from node_modules/my-agent-plugin ``` -Installing the npm package does not mutate any host; run the generated -`my-agent-plugin-install install ` command explicitly. +Installing the npm package does not mutate any host; run +`npx agent-bundle install --from node_modules/` explicitly. +Validate and publish the generated npm root with +`npm run pack:check && npm publish ./dist --ignore-scripts`. ## Layout diff --git a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts index 81eab410f..dafded6ca 100644 --- a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ // `--output` flag still wins. // output: { distPath: 'artifact' }, // No `bin` or `scripts` fields needed: the routed `src/cli/**` commands - // compile into the package executable (dist/bin/my-agent-plugin.js), the + // compile into the shared artifact/package executable (bin/my-agent-plugin.mjs), the // conventional `src/scripts/hello.ts` ships as `scripts/hello.mjs` inside // every host artifact, and `src/index.ts` becomes the library export with // declarations — all by convention. diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json index 6680c8ef7..cf917cae6 100644 --- a/packages/create-agent-bundle/templates/cli-tool/package_json +++ b/packages/create-agent-bundle/templates/cli-tool/package_json @@ -6,15 +6,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "artifact", - "README.md" - ], - "bin": { - "my-agent-plugin": "./dist/bin/my-agent-plugin.js", - "my-agent-plugin-install": "./dist/bin/my-agent-plugin-install.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -25,7 +16,7 @@ "build": "agent-bundle build --json --output artifact", "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:projection", "dev": "agent-bundle dev", - "prepack": "agent-bundle prepack --json --output artifact", + "pack:check": "agent-bundle prepack --json --output artifact", "test": "rstest tests --exclude \"tests/projection/**\"", "test:projection": "rstest --config rstest.projection.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts b/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts index a5a5ff573..3726787df 100644 --- a/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts +++ b/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts @@ -6,7 +6,7 @@ import { cliJson, invokeCli, testManifest } from 'agent-bundle/test'; * CLI's own shell — command resolution, the compiled argv grammar, generated * help, input validation, and exit-code mapping are the product's — in this * process. Nothing is bundled or spawned; the generated - * `dist/bin/my-agent-plugin.js` executable is proven by `npm run build`. + * `dist/bin/my-agent-plugin.mjs` executable is proven by `npm run build`. */ it('compiles the greet command without a build', () => { const manifest = testManifest(); diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md index fd03656f1..b6db8193b 100644 --- a/packages/create-agent-bundle/templates/mcp-server/README.md +++ b/packages/create-agent-bundle/templates/mcp-server/README.md @@ -17,11 +17,13 @@ npm run test:projection # in-memory MCP projection pool npx agent-bundle mcp list --server status --target portable --artifact artifact # after publishing/removing "private" and installing the package -npx my-agent-plugin install claude +npx agent-bundle install claude --from node_modules/my-agent-plugin ``` -Installing the npm package does not mutate any host; run the generated -`my-agent-plugin install ` command explicitly. +Installing the npm package does not mutate any host; run +`npx agent-bundle install --from node_modules/` explicitly. +Validate and publish the generated npm root with +`npm run pack:check && npm publish ./dist --ignore-scripts`. ## Layout diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json index ae5bcca22..dc12ec721 100644 --- a/packages/create-agent-bundle/templates/mcp-server/package_json +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -7,14 +7,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "artifact", - "README.md" - ], - "bin": { - "my-agent-plugin": "./dist/bin/my-agent-plugin.js" - }, "exports": { ".": { "types": "./dist/status.d.ts", @@ -25,7 +17,7 @@ "build": "agent-bundle build --json --output artifact", "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection", "dev": "agent-bundle dev", - "prepack": "agent-bundle prepack --json --output artifact", + "pack:check": "agent-bundle prepack --json --output artifact", "test": "rstest tests --exclude \"tests/{route-unit,projection}/**\"", "test:projection": "rstest --config rstest.projection.config.ts", "test:routes": "rstest --config rstest.route-unit.config.ts", diff --git a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts index 71f633f17..0d9358063 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts @@ -37,7 +37,7 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent const checked = await npmRun(projectRoot, 'check'); await expectCleanValidate(projectRoot); - await npmRun(projectRoot, 'prepack'); + await npmRun(projectRoot, 'pack:check'); // The template's own harness pools ran inside `check`, and they are asserted // positively — a silent `check` would also pass if the pools were dropped or @@ -88,7 +88,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif expect(checked).toContain('tests/projection/cli-dispatch.test.ts'); expect(checked).toContain('tests/projection/script-dispatch.test.ts'); await expectCleanValidate(projectRoot); - await npmRun(projectRoot, 'prepack'); + await npmRun(projectRoot, 'pack:check'); // The projection pool dispatches through the framework's own generated // setup, resolved from the packed tarball's `agent-bundle/rstest` export. await expectPassedPool(projectRoot, 'test:projection', [ @@ -98,7 +98,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // The src/cli/** convention produced the routed executable package bin: // generated help, the compiled argv grammar, and one canonical JSON line. - const bin = join(projectRoot, 'dist', 'bin', 'greeter.js'); + const bin = join(projectRoot, 'dist', 'bin', 'greeter.mjs'); expect((await stat(bin)).mode & 0o111).not.toBe(0); expect((await readFile(bin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true); const environment = installedEnvironment(); @@ -131,15 +131,15 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif try { const { stdout } = await execFile('npm', [ 'pack', '--json', '--ignore-scripts', '--pack-destination', packDestination, - ], { cwd: projectRoot, env: installedEnvironment() }); + ], { cwd: join(projectRoot, 'dist'), env: installedEnvironment() }); // packOutputFromJson handles both npm pack --json shapes (array and // package-keyed object), unlike a bare array destructure. const packedPaths = packOutputFromJson(stdout).files.map((file) => file.path); - expect(packedPaths).toContain('artifact/agent-bundle.manifest.json'); - expect(packedPaths).toContain('artifact/plugin.json'); - expect(packedPaths).toContain('artifact/.codex-plugin/plugin.json'); - expect(packedPaths).toContain('artifact/.claude-plugin/plugin.json'); - expect(packedPaths).toContain('dist/bin/greeter-install.js'); + expect(packedPaths).toContain('agent-bundle.manifest.json'); + expect(packedPaths).toContain('plugin.json'); + expect(packedPaths).toContain('.codex-plugin/plugin.json'); + expect(packedPaths).toContain('.claude-plugin/plugin.json'); + expect(packedPaths).toContain('bin/greeter.mjs'); } finally { await rm(packDestination, { force: true, recursive: true }); } diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 5a7e541f3..b51b721b3 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -118,51 +118,46 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys ]); })); - it.effect('scaffolds publishable package fields for templates with package builds', () => Effect.gen(function* () { + it.effect('scaffolds canonical npm-root package commands', () => Effect.gen(function* () { const path = yield* Path.Path; const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin' }), ], { concurrency: 'unbounded' }); const cliManifest = yield* readJson<{ - readonly bin: Record; - readonly files: readonly string[]; + readonly bin?: Record; + readonly files?: readonly string[]; readonly scripts: Record; }>(path.join(cliTool.root, 'package.json')); - expect(cliManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - 'greeter-install': './dist/bin/greeter-install.js', - }); - expect(cliManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + expect(cliManifest.files).toBeUndefined(); + expect(cliManifest.bin).toBeUndefined(); + expect(cliManifest.scripts['pack:check']).toBe('agent-bundle prepack --json --output artifact'); const mcpManifest = yield* readJson<{ - readonly bin: Record; + readonly bin?: Record; readonly exports: Record; - readonly files: readonly string[]; + readonly files?: readonly string[]; readonly private: boolean; readonly scripts: Record; }>(path.join(mcpServer.root, 'package.json')); expect(mcpManifest.private).toBe(true); - expect(mcpManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(mcpManifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); + expect(mcpManifest.files).toBeUndefined(); + expect(mcpManifest.bin).toBeUndefined(); expect(mcpManifest.exports).toEqual({ '.': { types: './dist/status.d.ts', import: './dist/status.js' }, }); - expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + expect(mcpManifest.scripts['pack:check']).toBe('agent-bundle prepack --json --output artifact'); expect(yield* readText(path.join(mcpServer.root, 'agent-bundle.config.ts'))).toContain("lib: './src/status.ts'"); })); - it.effect('drops generated installer bins when no installable host target is selected', () => Effect.gen(function* () { + it.effect('keeps source package metadata independent of selected hosts', () => Effect.gen(function* () { const path = yield* Path.Path; const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['portable'] }), ], { concurrency: 'unbounded' }); const cliManifest = yield* readJson<{ readonly bin?: Record }>(path.join(cliTool.root, 'package.json')); - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - }); + expect(cliManifest.bin).toBeUndefined(); const mcpManifest = yield* readJson<{ readonly bin?: Record }>(path.join(mcpServer.root, 'package.json')); expect(mcpManifest.bin).toBeUndefined(); @@ -183,44 +178,32 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys const defaultReadme = yield* readText(path.join(defaults.root, 'README.md')); expect(defaultReadme).toContain([ '# after publishing/installing the package', - 'npx greeter-install install claude', - 'npx greeter-install install codex', + 'npx agent-bundle install claude --from node_modules/status-plugin', + 'npx agent-bundle install codex --from node_modules/status-plugin', '', ].join('\n')); expect(defaultReadme).not.toContain('install cursor'); - expect(defaultReadme).toContain('The installer accepts the\nselected host targets only: `claude`, `codex`.'); + expect(defaultReadme).toContain('The package contains these selected host targets: `claude`, `codex`.'); - // A cursor-only scaffold's installer rejects `claude`, so the README must - // not suggest it. + // A cursor-only scaffold must not suggest unavailable host roots. const cursorReadme = yield* readText(path.join(cursorOnly.root, 'README.md')); - expect(cursorReadme).toContain('npx status-plugin install cursor\n'); + expect(cursorReadme).toContain('npx agent-bundle install cursor --from node_modules/status-plugin\n'); expect(cursorReadme).not.toContain('install claude'); expect(cursorReadme).not.toContain('install codex'); // Selecting every host installs into every host. const everyHostReadme = yield* readText(path.join(everyHost.root, 'README.md')); expect(everyHostReadme).toContain([ - 'npx status-plugin install claude', - 'npx status-plugin install codex', - 'npx status-plugin install cursor', + 'npx agent-bundle install claude --from node_modules/status-plugin', + 'npx agent-bundle install codex --from node_modules/status-plugin', + 'npx agent-bundle install cursor --from node_modules/status-plugin', ].join('\n')); - // Portable-only scaffolds ship no installer bin at all. + // Portable-only scaffolds name no install command. const portableReadme = yield* readText(path.join(portableOnly.root, 'README.md')); - expect(portableReadme).not.toMatch(/^npx \S+ install /mu); + expect(portableReadme).not.toMatch(/^npx agent-bundle install /mu); expect(portableReadme).toContain("no installable host target ('portable')"); - expect(portableReadme).toContain('add `claude`, `codex`, or `cursor` to `targets`'); - // Re-enabling installers needs the dropped package.json bin entry back too, - // and the README names exactly the mapping the template shipped. - const templateManifest = yield* readJson<{ readonly bin: Record }>( - path.join(templateRoot(path, 'cli-tool'), 'package_json'), - ); - const installerBin = `${placeholderName}-install`; - expect(templateManifest.bin[installerBin]).toBeDefined(); - const droppedEntry = `"greeter-install": "${templateManifest.bin[installerBin]?.replaceAll(placeholderName, 'greeter')}"`; - expect(portableReadme).toContain(`# ${droppedEntry} to get one`); - expect(portableReadme).toContain(`restore \`${droppedEntry}\` under \`bin\` in`); - expect(portableReadme).toContain('never edits the manifest'); + expect(portableReadme).toContain('Add `claude`, `codex`,'); // The skills-only template has no install section and passes through. const minimalReadme = yield* readText(path.join(minimal.root, 'README.md')); @@ -280,7 +263,7 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(contents).not.toContain('workspace:*'); } const manifest = yield* readJson<{ - readonly bin: Record; + readonly bin?: Record; readonly dependencies?: Record; readonly devDependencies: Record; readonly name: string; @@ -290,10 +273,7 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(files).toContain('src/cli/greet.ts'); expect(manifest.dependencies).toBeUndefined(); expect(manifest.devDependencies['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); - expect(manifest.bin).toEqual({ - 'status-plugin': './dist/bin/status-plugin.js', - 'status-plugin-install': './dist/bin/status-plugin-install.js', - }); + expect(manifest.bin).toBeUndefined(); const config = yield* readText(path.join(root, 'agent-bundle.config.ts')); expect(config).toContain("name: 'status-plugin'"); // Routed CLI: no `scripts` or `bin` entry names the executable; the diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 694d30a22..a5921ca7d 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -57,7 +57,7 @@ those features, not the build. This example has no hooks. progress token, then replaces it with the completed analysis without changing the final structured receipt. No `progress.report()` call repeats the message. - **The framework owns the package build.** The config declares no `bin` or `scripts` field. The routed - `src/cli/` commands compile into `dist/bin/audiobook-curator.js` for `package.json` `bin`, and + `src/cli/` commands compile into `dist/bin/audiobook-curator.mjs` for `package.json` `bin`, and `dist/index.js` plus declarations serve `exports`. See [CLI and library package entries](../guide/authoring/package-entries.mdx). - **`package.json` is the single version source.** The config declares no `plugin.version`; the @@ -110,7 +110,7 @@ directory already on `PATH`: ```sh cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index a40d23435..9b8aafa14 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -46,7 +46,7 @@ description: '有声书策展器示例:一个由路由模块、请求上下文 发送给传入了 progress token 的客户端,再用完成后的分析替换它,而最终的结构化收据不变。没有任何 `progress.report()` 调用重复这条消息。 - **包构建归框架所有。** 配置没有声明 `bin`,也没有声明 `scripts`。`src/cli/` 下的路由命令编译为 - `dist/bin/audiobook-curator.js` 以服务 `package.json` 的 `bin`,而 `dist/index.js` 加声明文件服务 + `dist/bin/audiobook-curator.mjs` 以服务 `package.json` 的 `bin`,而 `dist/index.js` 加声明文件服务 `exports`。见 [CLI 与库包入口](../guide/authoring/package-entries.mdx)。 - **`package.json` 是唯一的版本来源。** 配置没有声明 `plugin.version`;解析出的版本会流入项目上下文、 产物清单、`inspect` 输出、dev 状态,以及这个插件导入的 `agent-bundle/meta` 常量。 @@ -92,7 +92,7 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ```sh cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` From a25aea3558d450914d8e6d88ed80f97b719b5b58 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:38:56 +0000 Subject: [PATCH 07/19] fix(build): reconcile latest artifact metadata --- .../src/build/validate-artifact.ts | 55 +++++++++---------- packages/agent-bundle/tests/uninstall.test.ts | 45 +++++++-------- 2 files changed, 47 insertions(+), 53 deletions(-) diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 17fd65385..59fdcf67b 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -21,7 +21,6 @@ import { matchesManifestFile, } from './artifact-layout.ts'; import { - accountedRequestsOf, compileEvidenceDiagnostics, compileEvidenceFileName, parseCompileEvidenceRecord, @@ -34,6 +33,7 @@ import { type ManifestFile, } from './emit.ts'; import { parseArtifactManifest, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; +import type { ModuleSyntaxCheck } from './module-imports.ts'; import type { ValidateArtifactOptions, ValidatedArtifactMcpServerEvidence, @@ -52,6 +52,7 @@ export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './art export type * from './artifact-validation-types.ts'; const epochStagingMarkerName = '.agent-bundle-epoch-stage.json'; +const artifactRootMetadata = new Set([compileEvidenceFileName]); const matchesManifestFileTable = ( files: readonly ArtifactFile[], @@ -533,7 +534,8 @@ const validateArtifactOwnership = (options: { const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (file.path === compileEvidenceFileName || admitsEverything) continue; + if (admitsEverything) continue; + if (artifactRootMetadata.has(file.path)) continue; if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. @@ -590,43 +592,32 @@ const validateArtifactStructure = (options: { return Object.freeze(diagnostics); }; -/** - * Reads the compile evidence record the manifest lists and re-checks it - * against the file table (`AB6039`). A clean record from a build without a - * `tools` hatch proves every manifest `bundle` file: the module walk lexes - * them and holds their imports to the recorded externals. A missing, - * failing, or rewritable record proves nothing and every module is walked - * in full. - */ const validateCompileEvidence = async (options: { readonly artifactRoot: string; - readonly manifestFiles: readonly ManifestFile[]; -}): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly provenModules: ReadonlyMap> }> => { - const unproven = (diagnostics: readonly Diagnostic[]) => - Object.freeze({ diagnostics: Object.freeze(diagnostics), provenModules: new Map>() }); - if (!options.manifestFiles.some((file) => file.path === compileEvidenceFileName)) return unproven([]); + readonly manifest: ArtifactManifest; +}): Promise => { + if (!options.manifest.files.some((file) => file.path === compileEvidenceFileName)) return Object.freeze([]); const bytes = await runWithPlatform(readFileString(resolve(options.artifactRoot, compileEvidenceFileName))) .catch(() => undefined); if (bytes === undefined) { - return unproven([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); + return Object.freeze([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); } let record: ReturnType; try { record = parseCompileEvidenceRecord(bytes); } catch (error) { if (!(error instanceof TypeError)) throw error; - return unproven([diagnostic('AB6039', error.message, compileEvidenceFileName)]); + return Object.freeze([diagnostic('AB6039', error.message, compileEvidenceFileName)]); } - const diagnostics = compileEvidenceDiagnostics( + return compileEvidenceDiagnostics( record, - new Map(options.manifestFiles.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), + new Map(options.manifest.files.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), ); - if (diagnostics.length > 0 || record.coverage.rewritable) return unproven(diagnostics); - return Object.freeze({ diagnostics, provenModules: accountedRequestsOf(record) }); }; const validateGeneratedFiles = async (options: { readonly artifactRoot: string; + readonly bundleSyntaxCheck?: ModuleSyntaxCheck; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: readonly ManifestFile[]; readonly prebuiltPaths?: ReadonlySet; @@ -654,16 +645,17 @@ const validateGeneratedFiles = async (options: { } } - const evidence = options.manifestFiles === undefined - ? { diagnostics: [], provenModules: new Map>() } - : await validateCompileEvidence({ artifactRoot: options.artifactRoot, manifestFiles: options.manifestFiles }); - diagnostics.push(...evidence.diagnostics); diagnostics.push(...await validateJavaScriptModules({ artifactRoot: options.artifactRoot, + ...(options.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: options.bundleSyntaxCheck }), files: options.files, - ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), + ...(options.manifestFiles === undefined + ? {} + : { + bundledPaths: new Set(options.manifestFiles.filter((file) => file.kind === 'bundle').map((file) => file.path)), + manifestFiles: new Set(options.manifestFiles.map((file) => file.path)), + }), prebuiltPaths, - provenModules: evidence.provenModules, validJson, })); @@ -673,8 +665,8 @@ const validateGeneratedFiles = async (options: { /** * The pre-manifest content pass `build` runs over a staged tree before it * writes the manifest. The planned manifest file table, when given, tells - * the JavaScript validator which modules the compile evidence record proves; - * without it every module is parsed in full. + * the JavaScript validator which modules the compiler emitted; without it + * every module is parsed in full. */ export const validateArtifactFiles = async ( context: ValidateArtifactOptions & { readonly manifestFiles?: readonly ManifestFile[] }, @@ -684,6 +676,7 @@ export const validateArtifactFiles = async ( ...filesystemDiagnostics(inspection.filesystem), ...await validateGeneratedFiles({ artifactRoot: context.artifactRoot, + ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, ...(context.manifestFiles === undefined ? {} : { manifestFiles: context.manifestFiles }), ...(context.prebuiltPaths === undefined ? {} : { prebuiltPaths: context.prebuiltPaths }), @@ -782,6 +775,7 @@ export const validateArtifactWithSnapshot = async ( // Read-only validators over the same immutable inspection run concurrently; // collecting in this fixed order keeps the diagnostics sequence deterministic. const [ + compileEvidenceRecordDiagnostics, targetContractDiagnostics, portableTargetDiagnostics, mcpCoherenceDiagnostics, @@ -789,6 +783,7 @@ export const validateArtifactWithSnapshot = async ( emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ + validateCompileEvidence({ artifactRoot, manifest }), validateTargetContracts({ artifactRoot, files: inspection.files, @@ -822,11 +817,13 @@ export const validateArtifactWithSnapshot = async ( }), validateGeneratedFiles({ artifactRoot, + ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, manifestFiles: manifest.files, }), ]); diagnostics.push( + ...compileEvidenceRecordDiagnostics, ...targetContractDiagnostics, ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 5e8a3d558..5a15a1f86 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -39,16 +39,7 @@ interface Fixture { readonly home: string; } -const mcpDocuments = { - claude: '.mcp.json', - codex: '.codex-plugin/mcp.json', - cursor: '.cursor-plugin/mcp.json', -} as const; - -const createFixture = async ( - host: 'claude' | 'codex' | 'cursor', - options: { readonly mcpDocument?: unknown } = {}, -): Promise => { +const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-')); const bundleRoot = join(cleanupRoot, 'bundle'); const home = join(cleanupRoot, 'home'); @@ -79,15 +70,12 @@ const createFixture = async ( } else { await writeJson(join(bundleRoot, '.cursor-plugin/plugin.json'), { name: 'uninstall-fixture', version: '1.2.3' }); } - const mcp = options.mcpDocument === undefined ? undefined : mcpDocuments[host]; - if (mcp !== undefined) await writeJson(join(bundleRoot, mcp), options.mcpDocument); await writeInstallFixtureManifest( bundleRoot, { name: 'uninstall-fixture', version: '1.2.3' }, [{ host, ...(host === 'cursor' ? {} : { marketplace: 'uninstall-fixture-marketplace' }), - ...(mcp === undefined ? {} : { mcp }), }], ); return { bundleRoot, cleanupRoot, home }; @@ -439,16 +427,28 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a } }); -it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed manifest points at', async () => { - const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-state-')); - const declaredStateRoot = join(cleanupRoot, 'declared-state'); - const fixture = await createFixture('cursor', { - mcpDocument: { mcpServers: { stateful: { command: 'node', env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot } } } }, - }); +it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () => { + const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); + const declaredStateRoot = join(fixture.cleanupRoot, 'declared-state'); const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; try { - await mkdir(cursorRoot, { recursive: true }); + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, + }, + }, + }), + ]); + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'uninstall-fixture', version: '1.2.3' }, + [{ host: 'cursor' }], + ); await installBundle(options); expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) .toMatchObject({ @@ -477,10 +477,7 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed mani expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - await Promise.all([ - rm(fixture.cleanupRoot, { force: true, recursive: true }), - rm(cleanupRoot, { force: true, recursive: true }), - ]); + await rm(fixture.cleanupRoot, { force: true, recursive: true }); } }); From 7519c39d11218a3ec34e0fc82731d807c1d459bd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:25:19 +0000 Subject: [PATCH 08/19] fix(build): validate package lifecycle assets --- .changeset/canonical-npm-root.md | 2 +- .../web-surface/agent-bundle.config.ts | 1 + .../fixtures/web-surface/package.json | 9 ++ .../web-surface/src/lifecycle-proof.ts | 12 ++ .../src/build/compile-evidence.ts | 4 +- .../agent-bundle/tests/package-build.test.ts | 153 +++++------------- .../tests/packed-web-command.test.ts | 40 ++++- 7 files changed, 104 insertions(+), 117 deletions(-) create mode 100644 packages/agent-bundle/fixtures/web-surface/src/lifecycle-proof.ts diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 9457f4abf..165467717 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, persist package-only compile evidence, rebase supported consumer lifecycle paths and report `AB4768` for absent source assets, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts index b37f4ef4c..82ca22d4c 100644 --- a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts @@ -1,5 +1,6 @@ // Plain object export keeps this packed fixture independent of the package build (#564). export default { + bin: { 'web-surface-lifecycle': './src/lifecycle-proof.ts' }, mcp: { servers: { status: { diff --git a/packages/agent-bundle/fixtures/web-surface/package.json b/packages/agent-bundle/fixtures/web-surface/package.json index 40a0a6131..5913a905f 100644 --- a/packages/agent-bundle/fixtures/web-surface/package.json +++ b/packages/agent-bundle/fixtures/web-surface/package.json @@ -3,6 +3,15 @@ "private": true, "type": "module", "version": "1.0.0", + "bin": { + "web-surface-lifecycle": "./dist/bin/web-surface-lifecycle.js" + }, + "scripts": { + "postinstall": "node ./dist/bin/web-surface-lifecycle.js" + }, + "dependencies": { + "typescript": "7.0.2" + }, "devDependencies": { "@modelcontextprotocol/server": "2.0.0" } diff --git a/packages/agent-bundle/fixtures/web-surface/src/lifecycle-proof.ts b/packages/agent-bundle/fixtures/web-surface/src/lifecycle-proof.ts new file mode 100644 index 000000000..d03749c19 --- /dev/null +++ b/packages/agent-bundle/fixtures/web-surface/src/lifecycle-proof.ts @@ -0,0 +1,12 @@ +import { writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +export const main = async (): Promise => { + const typescript = require('typescript') as { readonly version: string }; + await writeFile(join(dirname(fileURLToPath(import.meta.url)), '..', 'lifecycle-ran.txt'), `${typescript.version}\n`); + return 0; +}; diff --git a/packages/agent-bundle/src/build/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts index 5a7ee16e6..a6eab60f3 100644 --- a/packages/agent-bundle/src/build/compile-evidence.ts +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -17,6 +17,8 @@ import { classifyExternal } from './external-policy.ts'; * states plainly what the compiler could not see. */ export const compileEvidenceFileName = 'agent-bundle.compile-evidence.json'; +/** Package-only compiler evidence beside the canonical npm root's artifact evidence. */ +export const packageCompileEvidenceFileName = 'agent-bundle.package-compile-evidence.json'; /** The self-containment policy the record was judged under; bump `revision` when `external-policy.ts` changes what it permits. */ export const externalPolicy = Object.freeze({ name: 'closed-world-externals', revision: 1 }); @@ -339,7 +341,7 @@ export const compileEvidenceDiagnostics = ( const nodeBundles = new Set([...compiled].filter((path) => !isViewAsset(path))); for (const asset of record.assets) { if (!compiled.has(asset.path)) { - diagnostics.push(evidenceDiagnostic(`names ${JSON.stringify(asset.path)}, which the manifest does not list as a compiled file.`)); + diagnostics.push(evidenceDiagnostic(`names ${JSON.stringify(asset.path)}, which the file table does not list as a compiled file.`)); } for (const external of asset.externals) { if (isViewAsset(asset.path)) { diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 93568f282..003902020 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -9,6 +9,7 @@ import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from '@rstest/core'; import { build, runMcp } from '../src/api.ts'; +import { packageCompileEvidenceFileName } from '../src/build/compile-evidence.ts'; import { runCli } from '../src/cli.ts'; import { DiagnosticError, type Diagnostic } from '../src/core/diagnostics.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; @@ -37,15 +38,6 @@ const fixtureRoot = async (files: Readonly>): Promise> => ({ '};', '', ].join('\n'), - 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"dependencies":{"linked-dep":"1.0.0"}}\n', + 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', 'node_modules/evidence-package/index.js': 'globalThis.__evidencePackageLoaded = true;\n', 'node_modules/evidence-package/package.json': '{"name":"evidence-package","type":"module","version":"1.0.0"}\n', - // Linked into node_modules below, as a package manager links a workspace dependency. - 'packages/linked-dep/index.js': "import 'linked-nested';\nglobalThis.__linkedDepLoaded = true;\n", - 'packages/linked-dep/package.json': - '{"name":"linked-dep","type":"module","version":"1.0.0","dependencies":{"linked-nested":"1.0.0"}}\n', - // A linked dependency's own linked dependency, whose real root sits inside the parent's. - 'packages/linked-dep/vendor/linked-nested/index.js': 'globalThis.__linkedNestedLoaded = true;\n', - 'packages/linked-dep/vendor/linked-nested/package.json': '{"name":"linked-nested","type":"module","version":"1.0.0"}\n', 'tsconfig.json': JSON.stringify({ compilerOptions: { module: 'esnext', @@ -79,7 +64,6 @@ const conventionFixture = (): Readonly> => ({ }), 'src/cli.ts': [ "import 'evidence-package';", - "import 'linked-dep';", '', 'export const main = async (argv: readonly string[]): Promise => {', " process.stdout.write(`ran:${argv.join(',')}\\n`);", @@ -145,7 +129,7 @@ describe('framework-owned package build', () => { it('builds bin, lib, and dts outputs from conventions and stays deterministic', async () => { const root = await fixtureRoot({ ...conventionFixture(), - 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n', + 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"imports":{"#fixture":"./dist/index.js"},"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n', }); await installTypescriptToolchain(root); const result = await build({ output: 'artifact', packageOutputs: true, root }); @@ -160,25 +144,27 @@ describe('framework-owned package build', () => { const packageDocument = JSON.parse(await readFile(join(root, 'dist', 'package.json'), 'utf8')); expect(packageDocument).toMatchObject({ bin: { 'package-build-fixture': './bin/package-build-fixture.js' }, + imports: { '#fixture': './index.js' }, name: 'package-build-fixture', private: true, + scripts: { test: 'rstest' }, type: 'module', }); - expect(packageDocument).not.toHaveProperty('scripts'); const paths = packageBuild!.files.map((file) => file.path); expect(paths).toContain('bin/package-build-fixture.js'); expect(paths).toContain('index.js'); expect(paths).toContain('index.d.ts'); + expect(paths).toContain(packageCompileEvidenceFileName); expect(packageBuild!.evidence.assets.map((asset) => asset.path)).toEqual( - expect.arrayContaining(['dist/bin/package-build-fixture.js', 'dist/index.js']), + expect.arrayContaining(['bin/package-build-fixture.js', 'index.js']), ); expect(packageBuild!.evidence.assets.flatMap((asset) => asset.externals) .every((external) => external.kind === 'builtin')).toBe(true); - // A package under `node_modules` is named by its path; a workspace-linked one, which Rspack records at its - // real path, by the declaration that reached it — the deepest such root when one sits inside another. expect(packageBuild!.evidence.assets - .find((asset) => asset.path === 'dist/bin/package-build-fixture.js')?.packages) - .toEqual(['evidence-package', 'linked-dep', 'linked-nested']); + .find((asset) => asset.path === 'bin/package-build-fixture.js')?.packages) + .toContain('evidence-package'); + expect(JSON.parse(await readFile(join(root, 'dist', packageCompileEvidenceFileName), 'utf8'))) + .toEqual(packageBuild!.evidence); for (const file of packageBuild!.files) { expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } @@ -198,6 +184,27 @@ describe('framework-owned package build', () => { expect(rebuilt.packageBuild?.files).toEqual(packageBuild!.files); }, 120_000); + it('rejects lifecycle paths absent from the generated npm root as AB4768', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'package.json': JSON.stringify({ + name: 'package-build-fixture', + private: true, + scripts: { postinstall: 'node setup.js' }, + type: 'module', + }), + }); + await installTypescriptToolchain(root); + await expect(build({ output: 'artifact', packageOutputs: true, root })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB4768', + severity: 'error', + sourcePath: join(root, 'package.json'), + })], + name: 'DiagnosticError', + }); + }, 120_000); + it('ships a bin-claimed src/scripts module as both the npm bin and the artifact script (#389)', async () => { const root = await fixtureRoot({ 'agent-bundle.config.ts': [ @@ -208,7 +215,13 @@ describe('framework-owned package build', () => { '};', '', ].join('\n'), - 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + 'package.json': JSON.stringify({ + bin: { hauler: './dist/bin/hauler.js' }, + name: 'package-build-fixture', + private: true, + scripts: { postinstall: 'node ./dist/bin/hauler.js' }, + type: 'module', + }), 'src/scripts/hauler.ts': [ 'export const main = async (argv: readonly string[]): Promise => {', " process.stdout.write(`hauled:${argv.join(',')}\\n`);", @@ -231,6 +244,9 @@ describe('framework-owned package build', () => { // Both outputs exist and run. expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/hauler.js'); + expect(JSON.parse(await readFile(join(root, 'dist', 'package.json'), 'utf8'))).toMatchObject({ + scripts: { postinstall: 'node "./bin/hauler.js"' }, + }); await expect(execFile(join(root, 'dist', 'bin', 'hauler.js'), ['alpha'])).resolves.toMatchObject({ stdout: 'hauled:alpha\n' }); expect(result.build.outputProvenance.map((record) => record.path)).toContain('scripts/hauler.mjs'); const script = join(root, 'artifact', 'scripts', 'hauler.mjs'); @@ -508,91 +524,6 @@ describe('framework-owned package build', () => { expect(binSource).toContain('./shipped.cjs'); }, 120_000); - it('fails the package build with AB6005 on a literal import the compiler was told to ignore in a dist bundle', async () => { - // `rspackIgnore`/`webpackIgnore` leave the call verbatim with no module, external, or warning; - // the walk over the record-proven bundle holds each lexed import to the recorded externals. - const root = await fixtureRoot({ - ...conventionFixture(), - 'agent-bundle.config.ts': [ - 'export default {', - " lib: { entry: './src/index.ts', dts: false },", - " mcp: { servers: { echoer: {} } },", - " plugin: { name: 'package-build-fixture', version: '1.0.0' },", - " targets: ['portable'],", - '};', - '', - ].join('\n'), - 'src/cli.ts': [ - "export const pad = () => import('left-pad' /* rspackIgnore: true */);", - "export const missing = () => import(/* webpackIgnore: true */ './missing.js');", - "export const outside = () => import(/* rspackIgnore: true */ '../../outside.js');", - '', - 'export const main = async (): Promise => {', - ' process.stdout.write(`${typeof pad}${typeof missing}${typeof outside}\\n`);', - ' return 0;', - '};', - '', - ].join('\n'), - 'src/index.ts': [ - "export const sibling = () => import(/* rspackIgnore: true */ './bin/package-build-fixture.js');", - '', - ].join('\n'), - }); - - const failure = await packageBuildFailure(root); - expect(failure).toBeInstanceOf(DiagnosticError); - const ignored = (asset: string, request: string): Diagnostic => ({ - code: 'AB6005', - generatedPath: asset, - message: `Generated JavaScript import from ${JSON.stringify(asset)} loads ${JSON.stringify(request)}, which the compiler ` - + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', - recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', - severity: 'error', - }); - expect([...withCode((failure as DiagnosticError).diagnostics, 'AB6005')].sort(byMessage)).toEqual([ - ignored('dist/bin/package-build-fixture.js', '../../outside.js'), - ignored('dist/bin/package-build-fixture.js', './missing.js'), - ignored('dist/bin/package-build-fixture.js', 'left-pad'), - ignored('dist/index.js', './bin/package-build-fixture.js'), - ].sort(byMessage)); - await unpublishedPackageOutput(root); - }, 120_000); - - it('fails the package build with AB6005 on an expression import the compiler left verbatim in a dist bundle', async () => { - const root = await fixtureRoot({ - ...conventionFixture(), - 'agent-bundle.config.ts': [ - 'export default {', - ' lib: false,', - " mcp: { servers: { echoer: {} } },", - " plugin: { name: 'package-build-fixture', version: '1.0.0' },", - " targets: ['portable'],", - '};', - '', - ].join('\n'), - 'src/cli.ts': [ - 'export const load = (name: string) => import(name);', - '', - 'export const main = async (argv: readonly string[]): Promise => {', - " process.stdout.write(`${Object.keys(await load(argv[0] ?? 'node:os')).length}\\n`);", - ' return 0;', - '};', - '', - ].join('\n'), - }); - - const failure = await packageBuildFailure(root); - expect(failure).toBeInstanceOf(DiagnosticError); - expect(withCode((failure as DiagnosticError).diagnostics, 'AB6005')).toEqual([{ - code: 'AB6005', - generatedPath: 'dist/bin/package-build-fixture.js', - message: 'Generated JavaScript import from "dist/bin/package-build-fixture.js" has a non-literal dynamic import.', - recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', - severity: 'error', - }]); - await unpublishedPackageOutput(root); - }, 120_000); - it('accepts a sibling authored module that the package build bundles into the executable', async () => { const root = await fixtureRoot({ ...conventionFixture(), diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index 920c510de..89ee5f129 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -35,6 +35,11 @@ const seedElementPattern = new RegExp(`]*\\btype="application/js const startupBudget = 60_000 * timeScale; const exitBudget = 5_000 * timeScale; const teardownBudget = { attempts: 50 * timeScale, delayMs: 100 } as const; +const lifecycleInstallArguments = [ + ...cachedNpmInstallArguments.filter((argument) => argument !== '--ignore-scripts'), + '--foreground-scripts', + '--ignore-scripts=false', +] as const; interface WebReadyDocument { readonly app: string; @@ -113,7 +118,6 @@ beforeAll(async () => { mkdir(tarballs), mkdir(installedConsumer), ]); - await writeFile(join(installedConsumer, 'package.json'), '{"private":true}\n'); const { stdout: packJson } = await execFile('npm', [ 'pack', '--json', @@ -122,21 +126,49 @@ beforeAll(async () => { tarballs, ], { cwd: packageRoot, env: installedEnvironment() }); const packed = packOutputFromJson(packJson, pluginName); + const tarball = join(tarballs, packed.filename); packedPaths = packed.files.map((file) => file.path); + await writeFile(join(installedConsumer, 'package.json'), '{"private":true}\n'); + const npmMajor = Number.parseInt((await execFile('npm', ['--version'])).stdout, 10); + if (npmMajor >= 12) { + // npm 12 blocks dependency install scripts until the consumer approves + // the exact installed source. Discover and approve the local tarball, + // then remove that scriptless copy so the next install is the proof. + await execFile('npm', [ + 'install', + ...cachedNpmInstallArguments, + tarball, + ], { cwd: installedConsumer, env: installedEnvironment() }); + await execFile('npm', [ + 'install-scripts', + 'approve', + pluginName, + ], { cwd: installedConsumer, env: installedEnvironment() }); + await rm(join(installedConsumer, 'node_modules'), { force: true, recursive: true }); + } await execFile('npm', [ 'install', - ...cachedNpmInstallArguments, - join(tarballs, packed.filename), + ...lifecycleInstallArguments, + ...(npmMajor >= 12 ? [] : [tarball]), ], { cwd: installedConsumer, env: installedEnvironment() }); const installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); const packageDocument = JSON.parse( await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), - ) as { readonly bin?: Readonly> }; + ) as { + readonly bin?: Readonly>; + readonly dependencies?: Readonly>; + readonly scripts?: Readonly>; + }; const declaredBin = packageDocument.bin?.[pluginName]; if (declaredBin === undefined) { throw new Error(`Packed ${pluginName} package does not declare its executable.`); } bin = resolve(installedPackageRoot, declaredBin); + expect(packageDocument.bin?.['web-surface-lifecycle']).toBe('./bin/web-surface-lifecycle.js'); + expect(packageDocument.dependencies?.['typescript']).toBe('7.0.2'); + expect(packageDocument.scripts?.postinstall).toBe('node "./bin/web-surface-lifecycle.js"'); + expect(await readFile(join(installedPackageRoot, 'lifecycle-ran.txt'), 'utf8').catch(() => undefined)) + .toBe('7.0.2\n'); // `packed-deleted-source`: the bin serves out of the artifact alone, so the // config, the routes, the server, and the App view are removed and verified // absent before any process runs. From faae26027020b2271daa184cee869d5f24d4f9ba Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 21:27:59 +0000 Subject: [PATCH 09/19] fix(build): reconcile canonical npm root with manifest v2 --- docs/diagnostics.md | 136 +++++++----------- packages/agent-bundle/rslib.config.ts | 1 - .../agent-bundle/src/build/module-imports.ts | 33 ++--- .../agent-bundle/src/build/pack-inventory.ts | 75 +++++----- .../agent-bundle/src/build/package-build.ts | 6 +- .../src/build/validate-artifact.ts | 55 +++---- packages/agent-bundle/src/install/doctor.ts | 3 +- .../tests/artifact-validator.test.ts | 131 ++++++++++++----- .../tests/compile-evidence.test.ts | 2 +- .../tests/generated-module-evidence.test.ts | 17 --- .../tests/packed-web-command.test.ts | 5 +- packages/agent-bundle/tests/uninstall.test.ts | 45 +++--- 12 files changed, 263 insertions(+), 246 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9fb37c9b1..30f54d314 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,12 +27,12 @@ even when no error diagnostic was reported. | `AB473x` | Migration nudges (informational; see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | -| `AB4765`–`AB4768` | Artifact-hosted routed CLI and npm lifecycle paths: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest; or a consumer lifecycle names an unsupported or absent Node path (see below). | +| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6039`: the compile evidence record does not match the manifest file table; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains only for what the compiler cannot see — an expression `import()` in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure — the same five codes are also the development project service's preparation failures; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json` — the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes, size, or executable bit differ from the row, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | @@ -423,61 +423,42 @@ Validation happens at three moments, all fail-closed: | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits an expected npm-root file. Pack the generated npm root without excluding its files. | -| `AB7011` | A file in the generated npm root no longer matches its manifest SHA-256. Rebuild and do not modify the generated npm root. | -| `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | +| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest/file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | +| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | +| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply `import` evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript `import` evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts — while `require`, `createRequire`, and `import.meta.resolve` calls, which `AB6005` does not walk, count from any packed file. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed file requires or resolves, a packed declaration references, a `#subpath` import reaches, an install script or packed file runs, or a prebuilt payload names in `runtimeDependencies` (`definePrebuilt`). For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | -| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` are always reported because the canonical npm root is validated for `npm publish ./dist`; npm publishes those protocols verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`. Use a registry version before running `agent-bundle prepack`. | - -Compiled bundles reach this gate without bare imports: `prepack` builds before it packs, and the compiler -reports `AB6005` when a `dist` bundle or host-pack module keeps anything other than a Node built-in, -`pnpapi`, or an emitted sibling external, and when an MCP App view's compilation keeps anything external -at all. The emitted-module walk remains behind that compile-time check, so the lexed `import` evidence below describes prebuilt payload modules and other packed scripts the -framework did not compile. A `require`, `createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler -does not resolve is not a module dependency; its evidence is read from every packed file, compiled bundles -included. Because the compiler never opens a payload file, its -`runtimeDependencies` declaration also counts as dependency evidence. The -dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file -`npm pack --dry-run` lists is lexed for static and dynamic `import` specifiers and scanned for -literal `require("…")` and `.resolve("…")` calls (`require.resolve`, `createRequire(…).resolve`, -`import.meta.resolve`: a package located only to find an asset is still a runtime dependency; a -binding such as `const load = createRequire(import.meta.url)` is a loader and `load("…")` counts like -`require("…")`, whether the factory is imported under its own name, renamed with `as`, reached through a -namespace import, or chained off `require("node:module")`; `createRequire(…)("…")` inline counts too, qualified the same -ways — `Module.createRequire(…)("…")`, `require("node:module").createRequire(…)("…")` — the -factory's argument nesting calls such as `new URL("./entry.js", import.meta.url)`; comments between a loader -and its parentheses or around the argument, `require /* x */ ("y")`, are trivia), and -every `.d.ts`/`.d.mts`/`.d.cts` file is scanned for `from "…"`, -`import("…")`, `import x = require("…")`, `declare module "…"` (a module augmentation), and `/// ` (a consumer needs the -package that provides those types even without a runtime import; a type directive counts for the -named package and its `@types/*` twin, `@types/scope__name` for a scoped name); bare specifiers are reduced to -their package name (`@scope/name` or `name`), string escapes decoded first (`require("\x66oo")` loads `foo`), -and Node built-ins are ignored. A dependency packed JavaScript runs rather than loads — a string literal that -is one of the `bin` commands its manifest under `node_modules` declares, bare or followed by arguments -(`spawnSync("tsc", ["--version"])`, `execSync("tsc --noEmit")`) — counts as used too; a dependency not installed -at pack time has no known commands, so its bare name in a string proves nothing here. A mention inside a -comment or string can only keep a dependency, never report one, and `devDependencies` are never -inspected. A packed `#subpath` import counts for every package the `imports` entry Node would pick for it targets -(the exact key, or the wildcard key with the longest matching prefix, its `*` substituted — every conditional target -of that entry, since conditions are not settled here), and a dependency named — anywhere in the text, since a mention -can only keep a declaration — by a consumer-side `preinstall`/`install`/`postinstall` script (not `prepare`, which npm -runs on `pack`, local installs, and git dependencies but never for a published tarball), or by any -script those reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is -the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, -`npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, -so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved -(`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through -npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`) — by -package name or by one of its `bin` commands, read from `node_modules//package.json` (a string-form `bin` -is one command named after the installed manifest's unscoped `name` — `real` for an alias `"wrapper": -"npm:@scope/real@1"`; the manifest is read as npm reads it, so the last of duplicate keys wins), with the unscoped dependency name standing in when that manifest is unreadable or not JSON — counts as used. A computed `import(expression)` or -`require(expression)` — also `require.resolve`, `import.meta.resolve`, a direct `createRequire(…)(…)`, or a -`createRequire` binding — with a non-literal argument — in packed JavaScript could load any declared package, so its presence withholds -`AB7014` entirely; the recovery text says so. So does packed source the ESM lexer rejects, whose `import()` calls it cannot report, and so does a loader passed on as a value rather than called — `const load = require`, `fn(require)`, -`module.exports = require`, `x ? require : y` — since packages may then be loaded under a name the scan never sees (`require("x")`, -`require.resolve("x")`, `typeof require`, and a `require` inside a string or comment are not that). Only those resolvers count, literal argument or not: `path.resolve("foo")` -and `Promise.resolve("foo")` are not package resolution and never keep `foo`. +| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | +| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | + +`AB7014` counts exactly three sources of evidence that a consumer needs a +declared package: + +1. `runtimeDependencies` on a prebuilt payload (`definePrebuilt`); +2. a packed `.d.ts`, `.d.mts`, or `.d.cts` reference as TypeScript's + `preProcessFile` reads it: `import`/`export … from`, + `import x = require("…")`, `import("…")` types, module augmentations in an + external-module file, or `/// ` (the named package + and its `@types` twin). An ambient `declare module "x"` in a file with no + imports declares that module and counts for nothing. A `#subpath` reaches + packages through the manifest's `imports` map; relative and built-in + specifiers name nothing; +3. the consumer-side `preinstall`, `install`, or `postinstall` script grammar, + including scripts reached through package-manager run and direct script + commands, naming the package or running one of its `bin` commands. + +The framework's own runtime modules bundled into a generated executable load +no package at run time (`generated-module-evidence.test.ts` holds that), so +there is no framework process-dependency record to read as a fourth source. + +A package the compiler bundled is not used. When one is otherwise unused, the +diagnostic names the `dist` bundles that inlined it. Expression requests +(`import(expr)`, `require(expr)`) remain outside the compiler's view; the +emitted-module walk still fails a compiled module on an expression `import()` +(`AB6005 has a non-literal dynamic import`) and on a literal import the +build was told to ignore (`rspackIgnore`/`webpackIgnore`; `AB6005 loads +"", which the compiler neither bundled nor recorded as an external`), +while `require(expr)`, `require.resolve(…)`, `createRequire(…)(…)`, and +`import.meta.resolve(…)` are not imports and are outside both. ## Declaration generation (`AB4716`) @@ -778,7 +759,7 @@ simply not been built yet is a validation **warning** that only | Code | Severity | Trigger | | --- | --- | --- | | `AB4740` | error | The `payload` block, one entry, or its `targets` list is malformed, or a payload selects an unknown target. A `runtimeDependencies` list that is not an array of nonempty strings is also `AB4740`. | -| `AB4741` | error | A payload destination is not a safe directory name, or shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents). | +| `AB4741` | error | A payload destination is not a safe directory name, shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents), or names the runtime-owned `state` root in any letter case — the manifest never indexes a file under `state/`, and installers neither copy nor own that root. | | `AB4742` | error | A payload source escapes the project root, is not a directory, or contains another payload's source. | | `AB4743` | warning | A declared payload directory does not exist yet or contains no files. Run the project's own build first. | | `AB4744` | error | A `{ prebuilt: ... }` entry (MCP server or hook handler) does not resolve inside a declared payload, or its payload does not select every target the component needs. | @@ -852,27 +833,20 @@ exactly where `build` would refuse. | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | -## Artifact-hosted routed CLI and npm lifecycle paths (`AB4765`–`AB4768`) +## Artifact-hosted routed CLI (`AB4765`–`AB4766`) -A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter +A generated-mode `src/cli/**` surface compiles into the npm package bin +(`dist/bin/.js`) **and** into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus `bin/-flight.mjs` when any command renders). Every built-in target hosts it; the two codes cover a target that does not and a host file -that claims the same path. The npm root copies the manifest-declared bin -unchanged and points `package.json` at it rather than recompiling the graph. -See “The routed CLI shell” in +that claims the same path. See “The routed CLI shell” in `docs/entry-conventions.md` for the layout and the sibling-path convention. | Code | Severity | Trigger | | --- | --- | --- | | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | -| `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | -| `AB4768` | error (package build) | A consumer `preinstall`, `install`, or `postinstall` script uses a Node command the package builder cannot safely normalize, or names a file absent from the generated npm root. Use one `node ` command (source `dist/` bin and library paths are rebased), or invoke an executable supplied by a declared runtime dependency. Arbitrary source files are never copied into the npm root. | - -Package outputs are staged after the composite artifact is published, so an -`AB4767` failure leaves that validated artifact available while refusing the -npm root. ## Config beside a route-generated MCP server (`AB4340`) @@ -1107,8 +1081,8 @@ Conventional `src/cli/**` routes compile into one collision-checked command graph (#102 stages 2-3): the file path below the CLI root is the command nesting (`src/cli/library/audit.ts` runs as ` library audit`), the static `config` export supplies `description`, `aliases`, `positionals`, and -the `exitCode` policy, and the graph feeds one framework-generated artifact -executable named after the plugin (`bin/.mjs`), replacing +the `exitCode` policy, and the graph feeds one framework-generated package +executable named after the plugin (`dist/bin/.js`), replacing the `src/cli.ts` convention for that project. Every command route exports `inputSchema` and `resultSchema` zod schemas plus one async default function receiving `{ input, signal }`, and runs inside the typed Agent request @@ -1537,7 +1511,8 @@ consumes it as the recovery. `agent-bundle uninstall [--from ] [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] -[--force] [--plan] [--json]` and the emitted `install.mjs --uninstall` are the +[--force] [--plan] [--json]`, the package-relative installer bin's +`uninstall `, and the emitted `install.mjs --uninstall` are the receipt-owned reverse of `install` (#101; the maintainer's 2026-09-01 G4 deferral of mutation was reversed on 2026-09-03 with the request to fix every open issue). Every mutation is opt-in and bounded by the receipt: @@ -1836,7 +1811,8 @@ placeholders itself. ## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`, `AB6039`) `agent-bundle build` validates the staged tree before it writes the manifest -(`validateArtifactFiles`: filesystem entries, generated JSON documents, and +(`validateArtifactFiles`: filesystem entries, generated JSON documents, the +compile evidence record against the planned file table (`AB6039`), and generated JavaScript modules), validates the finished artifact against its manifest, and re-checks the validated snapshot after the staging tree is renamed into place. `agent-bundle validate --artifact ` runs the same @@ -1862,12 +1838,8 @@ producer `{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` bytes may differ from the module graph), and `coverage.unobserved`. `agent-bundle validate --artifact` re-checks a listed record against the manifest file table without reading JavaScript (`AB6039`). The package build -keeps those artifact bytes and that record unchanged, and writes -`agent-bundle.package-compile-evidence.json` beside them for package-only -authored bins and libraries. `PackageBuildResult.evidence` is that second -record; its paths are relative to the npm root (`bin/…`, not `dist/bin/…`). -The prepack gate checks it against the package-only `bundle` inventory. -`coverage.unobserved` lists the load forms Rslib leaves verbatim in +returns the same record in process (`PackageBuildResult.evidence`, paths +`dist/bin/…`) and writes nothing to disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in the bundle, so the compiler neither bundles nor records them: `import()`, `require()`, `require.resolve(…)`, `createRequire(…)(…)`, `import.meta.resolve(…)`. No externals recorded @@ -1879,7 +1851,7 @@ therefore does not prove the absence of such a load. | `AB6001` | error | `Artifact manifest is not a strict canonical manifest.` — `agent-bundle.manifest.json` does not parse as a strict canonical artifact manifest. `Artifact manifest changed during validation.` — its bytes or identity differ between the first read and the re-read after validation. | Regenerate the strict canonical manifest without concurrent writes, then rerun validation. | | `AB6002`–`AB6003` | error | Reserved: both codes are declared in the artifact diagnostic registry, but no validator emits either today. | `AB6002`: Rebuild the artifact from complete project source, then rerun validation. `AB6003`: Rebuild the artifact with canonical generated output, then rerun validation. | | `AB6004` | error | `Artifact files do not match the manifest.` — the regular files on disk differ from the manifest file table (a path, byte length, mode, or SHA-256; a missing or unmanifested file). `Artifact file changed during validation: "".` — a file differed between the initial and final inspection, or between a validated staging tree and its re-check after `build` renamed it into place. `Artifact file table changed during validation.` — the final inspection could not be taken. | Rebuild the artifact so its file table and contents match the manifest. | -| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. The emitted-module walk remains as defense in depth and reports `Generated JavaScript import from "" .` for a residual unsupported or invalid specifier, unreadable module, invalid syntax, or non-literal dynamic import. Its relative-target findings remain `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | +| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external; so is a literal import marked `rspackIgnore`/`webpackIgnore`, which Rspack leaves verbatim with no module, external, or warning. The emitted-module walk reports both in a compiled module the record proves: `Generated JavaScript import from "" has a non-literal dynamic import.` and `Generated JavaScript import from "" loads "", which the compiler neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.` (a literal request that is neither a Node built-in nor one of the record's externals for that file). MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. Residual walk forms, `Generated JavaScript import from "" .`, reported only for what the compiler cannot see: `has a non-literal dynamic import` for an expression `import()` in any emitted module, compiled ones included, since Rslib's profile leaves that form verbatim; and, for a module the compile evidence record does not prove — JavaScript the framework did not compile (`install.mjs`, a copied script), every module of an artifact without a record, and every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`) — `has invalid syntax` from a full parse, `uses unsupported specifier` or `uses invalid specifier` for a bare or malformed import, `cannot be read`, and the relative-target findings `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`. A compiled module the record covers with the same bytes is lexed for syntax and its literal imports are not resolved again — the compiler resolved them (bundled, built-in, or an emitted sibling). Prebuilt payloads are not walked; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | | `AB6006` | error | `Generated JSON cannot be parsed.` — a `.json` file in the artifact is not valid JSON (prebuilt payload files are exempt). Doctor's Claude document lane reports the same code inside an `AB7319` message for a Claude bundle document that is unreadable or not valid JSON. | Regenerate the affected JSON document as valid JSON, then rebuild the artifact. | | `AB6007` | error | `MCP manifest references missing generated server "".` — a root-level MCP manifest (pre-manifest pass) or a target's MCP manifest names a local server entry that the artifact does not contain. | Repair MCP manifest references to generated servers, then rebuild the artifact. | | `AB6008` | error | `Artifact Agent Skills provenance does not match the pinned schema contract.` — the manifest's `agentSkills` schema SHA-256, source revision, or specification differs from the framework's pinned Agent Skills revision. | Rebuild the artifact with the pinned Agent Skills contract. | @@ -1896,7 +1868,7 @@ therefore does not prove the absence of such a load. | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | -| `AB6039` | error | `Compile evidence record .` — the listed artifact or package-only evidence failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` / `Package compile evidence cannot be read: .` — a required record is unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the file table does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. | Rebuild the artifact or generated npm root so its compile evidence describes the emitted files. | +| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. A record with any `AB6039` finding, or with `coverage.rewritable` set, proves nothing to the emitted-module walk, which then parses every module in full. | Rebuild the artifact so its compile evidence record describes the emitted files. | ## Workbench artifact inspection (`AB6200`–`AB6202`) @@ -1919,7 +1891,7 @@ The `AB7000`–`AB7004` codes are shared by two families of emitters with distinct meanings. `agent-bundle install` and `agent-bundle uninstall` (`install/install.ts`, `install/uninstall.ts`, `install/cursor-marketplace.ts`) throw them as `DiagnosticError`s with `target` set to the host; the -installation command boundary wraps any non-diagnostic failure as `AB7004`. The +`install-entry` CLI wraps any non-diagnostic failure as `AB7004`. The development project service (`dev/project-service.ts`) and `inspectProject` emit them as **error** diagnostics on a failed preparation with `sourcePath` set to the config file and a fixed `recovery` ending in "then inspect again". @@ -1932,7 +1904,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the installation command boundary. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 848b37b52..1cc224b54 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -90,7 +90,6 @@ const runtimeEntries = { 'cli-entry': './src/cli-entry.ts', 'event-ipc': './src/events/ipc.ts', 'event-project': './src/events/project.ts', - 'install-entry': './src/install-entry.ts', 'launch-env': './src/launch-env.ts', 'mcp-entry': './src/mcp-entry.ts', 'mcp-server-runtime': process.env['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE'] === '1' diff --git a/packages/agent-bundle/src/build/module-imports.ts b/packages/agent-bundle/src/build/module-imports.ts index f0e7057bd..9d06ae02d 100644 --- a/packages/agent-bundle/src/build/module-imports.ts +++ b/packages/agent-bundle/src/build/module-imports.ts @@ -1,8 +1,6 @@ import { parse as parseJavaScript } from 'acorn'; import { init, parse } from 'es-module-lexer'; -import type { AgentBundleToolsConfig } from '../core/types.ts'; - /** * One import of an ES module as the lexer reports it: `specifier` is the * literal module specifier (absent for a non-literal dynamic import), and @@ -19,34 +17,25 @@ export interface ModuleImport { * * - `lexed`: the ESM lexer is the only pass. It rejects unterminated strings, * templates, comments, and regexps and unbalanced braces — enough for a - * module the framework's own bundler emitted, whose syntax is the - * bundler's to guarantee. Re-parsing megabytes of bundler output to prove - * it is JavaScript was the dominant cost of every build. - * - `parsed`: a full `acorn` parse runs first, so a module the framework did - * not compile — a copied consumer script or standalone installer — keeps - * the complete syntax check. + * module the compiler emitted and its evidence record covers, whose syntax + * is the bundler's to guarantee. Re-parsing megabytes of bundler output to + * prove it is JavaScript was the dominant cost of every build. + * - `parsed`: a full `acorn` parse runs first, so a module the compiler's + * evidence does not cover — a copied consumer script, a generated + * installer, a bundle a `tools` hatch may have rewritten — keeps the + * complete syntax check. */ export type ModuleSyntaxCheck = 'lexed' | 'parsed'; -/** - * How a build checks the syntax of the modules its own bundler emitted. The - * bundler's output is trusted to the ESM lexer; once a consumer `tools` hatch - * can rewrite emitted assets (a banner, a `processAssets` pass), the final - * bytes are no longer the bundler's proof and are parsed in full. The - * artifact build and the package build decide this the same way. - */ -export const bundleSyntaxCheckFor = (tools: AgentBundleToolsConfig | undefined): ModuleSyntaxCheck => - tools?.rspack === undefined && tools?.rsbuild === undefined ? 'lexed' : 'parsed'; - const importKind = (dynamic: number): ModuleImport['kind'] => dynamic === -2 ? 'meta' : dynamic === -1 ? 'static' : 'dynamic'; /** * Imports already read from bytes with a known SHA-256, keyed by check level - * and digest. Within one process the same emitted bundle is scanned by the - * post-compile self-containment check and then by artifact validation, twice - * (before and after the manifest is written); the bytes never change between - * those passes, so the imports of a multi-megabyte bundle are lexed once. + * and digest. Within one process the same emitted module is scanned by + * artifact validation twice (before and after the manifest is written); the + * bytes never change between those passes, so the imports of a + * multi-megabyte bundle are lexed once. * The records are a few dozen specifiers per module; the map stays bounded. */ const importsByDigest = new Map(); diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 130825d99..476f1d724 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -18,10 +18,11 @@ import { parseArtifactManifest } from './manifest.ts'; import { declaredDependencies, type DeclaredDependency, type InstalledDependencyField } from '../core/package-dependencies.ts'; import { classifyDependency, - importedPackageNames, + installScriptDependencies, isWorkspaceProtocol, packagedSourceInstallable, packagedSourcePath, + packedDeclarationReferences, type DependencyKind, } from './pack-dependencies.ts'; import type { PackageBuildResult } from './package-build.ts'; @@ -124,22 +125,14 @@ const perField = ( .map(([field, own]) => emit(field, own.toSorted((left, right) => left.name.localeCompare(right.name)))); /** - * `AB7014`/`AB7015`: the build inlines every dependency into `dist` and the - * host packs, so an installed-dependency entry no packed file references - * only makes every consumer's `npm install` fetch a build-time package — and - * fail outright when the specifier is one a consumer's npm cannot resolve - * (git, remote tarball, path, or an unrewritten workspace protocol). A - * compiled bundle cannot `import` a bare package at all: `AB6005` fails the - * build on any import specifier that is not a Node built-in, and `prepack` - * runs that build before this inventory, so the import evidence `AB7014` - * accepts comes only from modules the framework copied rather than compiled - * — prebuilt payload modules and other scripts the `files` allowlist packs — - * never from a `dist` bundle or a host-pack module. A `require`, - * `createRequire(…)(…)`, or `import.meta.resolve(…)` call is not an import - * and `AB6005` does not walk it, so that evidence is read from every packed - * file, compiled bundles included. A prebuilt payload's `runtimeDependencies` - * declaration is evidence of the same standing: the compiler never opens a - * payload file, so the author states what it loads. + * `AB7014`/`AB7015`: an installed-dependency entry counts as used only when a + * consumer must have it installed — a prebuilt payload declares it in + * `runtimeDependencies`, a packed declaration file references it, or a + * consumer-side install script names or runs it. The build inlines every + * other import (`AB6005`), so a bundled package is still unused: every + * consumer's `npm install` fetches it for nothing — and fails outright when + * the specifier is one a consumer's npm cannot resolve (git, remote tarball, + * path, or an unrewritten workspace protocol). */ const unresolvableMessage = (field: InstalledDependencyField, own: readonly DeclaredDependency[]): string => `package.json ${field} names packages a consumer's npm cannot resolve through a registry (an invalid name or a non-registry specifier): ${own.map((dependency) => @@ -150,8 +143,9 @@ const unresolvableRecovery = 'Depend on a published registry version, or bundle + 'which only pnpm, Yarn, or Bun rewrite while packing.'; const dependencyDiagnostics = async (options: { + /** Package → the packed bundles the compiler inlined it into, each sorted. */ + readonly bundledInto: ReadonlyMap; readonly declaredRuntimeDependencies: ReadonlySet; - readonly dependencyRoot: string; readonly packageDocument: Readonly>; readonly packedPaths: readonly string[]; readonly packerRewritesWorkspaceProtocols: boolean; @@ -159,16 +153,17 @@ const dependencyDiagnostics = async (options: { }): Promise => { const declared = declaredDependencies(options.packageDocument); if (declared.length === 0) return []; - // `prepack` runs the build before this inventory, and `AB6005` there refuses every bare import in a compiled - // bundle, so any `import` evidence found here belongs to a packed module the framework did not compile; the - // `require`/`createRequire`/`import.meta.resolve` evidence is not an import and may come from any packed file. - const imported = await importedPackageNames({ - declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), - dependencyRoot: options.dependencyRoot, + const declarationReferences = await packedDeclarationReferences({ packageDocument: options.packageDocument, paths: options.packedPaths, projectRoot: options.projectRoot, }); + const installScripts = await installScriptDependencies({ + declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), + packageDocument: options.packageDocument, + projectRoot: options.projectRoot, + }); + const used = new Set([...options.declaredRuntimeDependencies, ...declarationReferences, ...installScripts.names]); // The tarball itself may carry the dependency: `bundleDependencies` exempts an entry only when npm actually // packed it (a name absent from node_modules at pack time is silently dropped, and the consumer neither // fetches nor finds it), and a `file:` path inside the package is installed from the consumer's own copy @@ -195,29 +190,31 @@ const dependencyDiagnostics = async (options: { const unresolvable = declared.filter((dependency) => !embedded(dependency) && (dependency.installed ? kinds.get(dependency) !== 'registry' : kinds.get(dependency) === 'unparseable')); // An optional dependency npm parses but cannot fetch: the install continues without it — unless a consumer - // install script then runs it, or loads it from a packed file it runs, and fails on the missing package. + // install script then runs it and fails on the missing package. const survivable = (dependency: DeclaredDependency): boolean => dependency.field === 'optionalDependencies' && kinds.get(dependency) === 'fetched' - && !imported.installScripts.has(dependency.name); - // A computed import() may load any declared package; nothing can then be called unused. - const unused = imported.complete - ? declared.filter((dependency) => dependency.installed - && !imported.names.has(dependency.name) - && !options.declaredRuntimeDependencies.has(dependency.name)) - : []; + && !installScripts.needed.has(dependency.name); + const unused = declared.filter((dependency) => dependency.installed && !used.has(dependency.name)); + const inlinedSentence = (own: readonly DeclaredDependency[]): string | undefined => { + const inlined = own.flatMap((dependency) => { + const bundles = options.bundledInto.get(dependency.name); + return bundles === undefined ? [] : [`${JSON.stringify(dependency.name)} into ${bundles.join(' and ')}`]; + }); + return inlined.length === 0 ? undefined : `The build inlined ${inlined.join(', and ')}; every consumer installs them for nothing.`; + }; return [ // A peer nothing imports may be a deliberate compatibility contract with the host that loads the package; // npm 7+ still installs it for every consumer, so it is worth a look, not a refusal. ...perField(unused, (field, own) => diagnostic( 'AB7014', - `package.json ${field} names packages no packed JavaScript or declaration file references, runs, or install script needs, and no prebuilt payload declares: ${quoteAll(own.map((dependency) => dependency.name))}. ` - + (field === 'peerDependencies' + `package.json ${field} names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ${quoteAll(own.map((dependency) => dependency.name))}. ` + + (inlinedSentence(own) ?? (field === 'peerDependencies' ? 'If they only constrain the host version, that is a compatibility contract; npm 7+ still installs them for every consumer.' - : 'Every consumer installs them for nothing; the emitted outputs already inline what they use.'), + : 'Nothing packed reaches them at runtime; every consumer installs them for nothing.')), field === 'peerDependencies' ? 'Keep a deliberate compatibility peer, mark it optional in peerDependenciesMeta so npm stops installing it, or move a build-only package to devDependencies.' - : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed file requires or resolves (createRequire, import.meta.resolve), a packed declaration file references, a #subpath import reaches through the imports map, an install script or packed JavaScript runs, or a prebuilt payload names in runtimeDependencies (definePrebuilt); a computed import() or require() in packed code withholds this check.', + : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).', field === 'peerDependencies' ? 'warning' : 'error', )), // npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed @@ -340,9 +337,13 @@ export const packInventoryDiagnostics = async (options: { )); } + const bundledInto = new Map([...Map.groupBy( + options.packageBuild.evidence.assets.flatMap((asset) => asset.packages.map((name) => ({ name, path: asset.path }))), + (entry) => entry.name, + )].map(([name, entries]) => [name, entries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right))])); diagnostics.push(...await dependencyDiagnostics({ + bundledInto, declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)), - dependencyRoot: resolve(options.projectRoot), packageDocument, packedPaths: [...packed], packerRewritesWorkspaceProtocols: options.packerRewritesWorkspaceProtocols, diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 61215b53a..d4a3558f8 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -17,6 +17,7 @@ import type { import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; import { + accountedRequestsOf, createCompileEvidenceRecord, packageCompileEvidenceFileName, serializeCompileEvidenceRecord, @@ -35,7 +36,6 @@ import { import { readArtifactManifest } from './manifest-file.ts'; import { artifactManifestName, type ArtifactManifest, type ArtifactManifestFileKind } from './manifest.ts'; import { projectMeta } from './meta.ts'; -import { bundleSyntaxCheckFor } from './module-imports.ts'; import { isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; import { validateJavaScriptModules } from './validate-artifact-modules.ts'; @@ -510,11 +510,11 @@ export const buildPackageOutputs = async (options: { // prepack gate reads those as dependency evidence. Declarations are not // modules and are not walked; they may still reference declared // dependencies. + const rewritable = options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined; const selfContainment = await validateJavaScriptModules({ artifactRoot: stageRoot, - bundledPaths: new Set(files.filter((file) => file.kind === 'bundle').map((file) => file.path)), - bundleSyntaxCheck: bundleSyntaxCheckFor(options.tools), files: staged.filter((file) => !artifactByPath.has(file.path)), + provenModules: rewritable ? new Map() : accountedRequestsOf(evidence), reportedRoot: toPosixRelative(projectRoot, outputRoot), validJson: new Set(), }); diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 59fdcf67b..17fd65385 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -21,6 +21,7 @@ import { matchesManifestFile, } from './artifact-layout.ts'; import { + accountedRequestsOf, compileEvidenceDiagnostics, compileEvidenceFileName, parseCompileEvidenceRecord, @@ -33,7 +34,6 @@ import { type ManifestFile, } from './emit.ts'; import { parseArtifactManifest, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; -import type { ModuleSyntaxCheck } from './module-imports.ts'; import type { ValidateArtifactOptions, ValidatedArtifactMcpServerEvidence, @@ -52,7 +52,6 @@ export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './art export type * from './artifact-validation-types.ts'; const epochStagingMarkerName = '.agent-bundle-epoch-stage.json'; -const artifactRootMetadata = new Set([compileEvidenceFileName]); const matchesManifestFileTable = ( files: readonly ArtifactFile[], @@ -534,8 +533,7 @@ const validateArtifactOwnership = (options: { const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (admitsEverything) continue; - if (artifactRootMetadata.has(file.path)) continue; + if (file.path === compileEvidenceFileName || admitsEverything) continue; if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. @@ -592,32 +590,43 @@ const validateArtifactStructure = (options: { return Object.freeze(diagnostics); }; +/** + * Reads the compile evidence record the manifest lists and re-checks it + * against the file table (`AB6039`). A clean record from a build without a + * `tools` hatch proves every manifest `bundle` file: the module walk lexes + * them and holds their imports to the recorded externals. A missing, + * failing, or rewritable record proves nothing and every module is walked + * in full. + */ const validateCompileEvidence = async (options: { readonly artifactRoot: string; - readonly manifest: ArtifactManifest; -}): Promise => { - if (!options.manifest.files.some((file) => file.path === compileEvidenceFileName)) return Object.freeze([]); + readonly manifestFiles: readonly ManifestFile[]; +}): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly provenModules: ReadonlyMap> }> => { + const unproven = (diagnostics: readonly Diagnostic[]) => + Object.freeze({ diagnostics: Object.freeze(diagnostics), provenModules: new Map>() }); + if (!options.manifestFiles.some((file) => file.path === compileEvidenceFileName)) return unproven([]); const bytes = await runWithPlatform(readFileString(resolve(options.artifactRoot, compileEvidenceFileName))) .catch(() => undefined); if (bytes === undefined) { - return Object.freeze([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); + return unproven([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); } let record: ReturnType; try { record = parseCompileEvidenceRecord(bytes); } catch (error) { if (!(error instanceof TypeError)) throw error; - return Object.freeze([diagnostic('AB6039', error.message, compileEvidenceFileName)]); + return unproven([diagnostic('AB6039', error.message, compileEvidenceFileName)]); } - return compileEvidenceDiagnostics( + const diagnostics = compileEvidenceDiagnostics( record, - new Map(options.manifest.files.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), + new Map(options.manifestFiles.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), ); + if (diagnostics.length > 0 || record.coverage.rewritable) return unproven(diagnostics); + return Object.freeze({ diagnostics, provenModules: accountedRequestsOf(record) }); }; const validateGeneratedFiles = async (options: { readonly artifactRoot: string; - readonly bundleSyntaxCheck?: ModuleSyntaxCheck; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: readonly ManifestFile[]; readonly prebuiltPaths?: ReadonlySet; @@ -645,17 +654,16 @@ const validateGeneratedFiles = async (options: { } } + const evidence = options.manifestFiles === undefined + ? { diagnostics: [], provenModules: new Map>() } + : await validateCompileEvidence({ artifactRoot: options.artifactRoot, manifestFiles: options.manifestFiles }); + diagnostics.push(...evidence.diagnostics); diagnostics.push(...await validateJavaScriptModules({ artifactRoot: options.artifactRoot, - ...(options.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: options.bundleSyntaxCheck }), files: options.files, - ...(options.manifestFiles === undefined - ? {} - : { - bundledPaths: new Set(options.manifestFiles.filter((file) => file.kind === 'bundle').map((file) => file.path)), - manifestFiles: new Set(options.manifestFiles.map((file) => file.path)), - }), + ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), prebuiltPaths, + provenModules: evidence.provenModules, validJson, })); @@ -665,8 +673,8 @@ const validateGeneratedFiles = async (options: { /** * The pre-manifest content pass `build` runs over a staged tree before it * writes the manifest. The planned manifest file table, when given, tells - * the JavaScript validator which modules the compiler emitted; without it - * every module is parsed in full. + * the JavaScript validator which modules the compile evidence record proves; + * without it every module is parsed in full. */ export const validateArtifactFiles = async ( context: ValidateArtifactOptions & { readonly manifestFiles?: readonly ManifestFile[] }, @@ -676,7 +684,6 @@ export const validateArtifactFiles = async ( ...filesystemDiagnostics(inspection.filesystem), ...await validateGeneratedFiles({ artifactRoot: context.artifactRoot, - ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, ...(context.manifestFiles === undefined ? {} : { manifestFiles: context.manifestFiles }), ...(context.prebuiltPaths === undefined ? {} : { prebuiltPaths: context.prebuiltPaths }), @@ -775,7 +782,6 @@ export const validateArtifactWithSnapshot = async ( // Read-only validators over the same immutable inspection run concurrently; // collecting in this fixed order keeps the diagnostics sequence deterministic. const [ - compileEvidenceRecordDiagnostics, targetContractDiagnostics, portableTargetDiagnostics, mcpCoherenceDiagnostics, @@ -783,7 +789,6 @@ export const validateArtifactWithSnapshot = async ( emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ - validateCompileEvidence({ artifactRoot, manifest }), validateTargetContracts({ artifactRoot, files: inspection.files, @@ -817,13 +822,11 @@ export const validateArtifactWithSnapshot = async ( }), validateGeneratedFiles({ artifactRoot, - ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, manifestFiles: manifest.files, }), ]); diagnostics.push( - ...compileEvidenceRecordDiagnostics, ...targetContractDiagnostics, ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 294567a70..de4af1f73 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -13,7 +13,7 @@ import { import { mapConcurrent } from '../core/async.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; import { readArtifactManifest } from '../build/manifest-file.ts'; -import { exists } from '../core/paths.ts'; +import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import { validateClaudePlugin, @@ -49,7 +49,6 @@ import { installReceiptFile, installReceiptFormat, installReceiptStoreDirectory, - isPreservedRuntimeRoot, isRemnantReceipt, isRuntimeStateRemnant, listStoredInstallReceipts, diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index c7497a8b8..4a37e3a03 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -17,13 +17,15 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactWrite, } from '../src/adapters/types.ts'; -import { composeProjections } from '../src/build/compose.ts'; import { compileEvidenceFileName, + externalPolicy, serializeCompileEvidenceRecord, + unobservedLoadForms, type CompileEvidenceAsset, type CompileEvidenceExternal, } from '../src/build/compile-evidence.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { artifactCompilerRecordVersion, artifactManifestVersion, @@ -2119,47 +2121,112 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as } }); +/** A compile evidence record covering every `bundle` fixture file with its exact bytes and the externals given per path. */ +const compileEvidenceFor = ( + files: readonly ArtifactFixtureFile[], + rewritable = false, + externals: Readonly> = {}, +): ArtifactFixtureFile => ({ + contents: serializeCompileEvidenceRecord({ + assets: files + .filter((file) => file.kind === 'bundle') + .map((file) => ({ externals: externals[file.path] ?? [], packages: [], path: file.path, sha256: hash(file.contents) })) + .sort((left, right) => left.path.localeCompare(right.path)), + coverage: { rewritable, unobserved: unobservedLoadForms }, + policy: externalPolicy, + producer: { name: 'agent-bundle', rspack: '2.2.2', version: '0.1.0' }, + }), + kind: 'generated', + path: compileEvidenceFileName, +}); + /** - * The syntax check a module gets follows who produced it. A module the - * framework compiled (manifest kind `bundle`) is the bundler's own output: - * only the ESM lexer runs over it, so re-parsing megabytes of bundler output - * no longer dominates every build, and a bare `export const broken = ;` — - * which no bundler emits — passes while unterminated input still fails. A - * module the framework did not compile (a copied consumer script or - * standalone installer) is parsed in full and keeps the complete check. + * The check a module gets follows what the compiler proved. A manifest + * `bundle` file the compile evidence record covers — same bytes, from a build + * without a `tools` hatch — is the compiler's output: only the ESM lexer runs + * over it, so a bare `export const broken = ;` (which no bundler emits) passes + * while unterminated input still fails, and each literal import it carries is + * held to the record rather than resolved — a Node built-in or a recorded + * external passes, anything else is an import the build ignored. Every other + * module — a copied consumer script, a generated installer, a bundle without + * a record or from a build whose hatch may have rewritten it — is parsed in + * full and its imports resolved against the file table. */ -it('parses copied and generated modules in full and trusts compiler bundles to the ESM lexer', async () => { +it('lexes compiled modules the evidence record proves and walks every other module in full', async () => { const brokenStatement = 'export const broken = ;\n'; - const root = await writeArtifact([ + const modules: readonly ArtifactFixtureFile[] = [ { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: brokenStatement, kind: 'copy', path: 'scripts/copied.mjs' }, { contents: brokenStatement, kind: 'generated', path: 'scripts/generated.mjs' }, { contents: brokenStatement, kind: 'bundle', path: 'scripts/bundled.mjs' }, { contents: 'export const unterminated = `;\n', kind: 'bundle', path: 'scripts/unterminated.mjs' }, { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'scripts/dangling.mjs' }, - ], true, [customManifestTarget]); + { contents: "import 'unbundled-package';\n", kind: 'bundle', path: 'scripts/bare.mjs' }, + { contents: "import 'unbundled-package';\n", kind: 'generated', path: 'scripts/uncompiled.mjs' }, + { contents: "import 'node:fs';\nimport './sibling.mjs';\n", kind: 'bundle', path: 'scripts/accounted.mjs' }, + { contents: 'export const sibling = true;\n', kind: 'bundle', path: 'scripts/sibling.mjs' }, + ]; + const accountedExternals = { + 'scripts/accounted.mjs': [{ + externalType: 'module', + issuers: ['src/accounted.ts'], + kind: 'artifact-relative' as const, + request: './sibling.mjs', + target: 'scripts/sibling.mjs', + userRequest: './sibling.mjs', + }], + }; + const reported = async (files: readonly ArtifactFixtureFile[]): Promise => { + const root = await writeArtifact(files, true, [customManifestTarget]); + try { + return (await validateArtifact({ artifactRoot: root, registry: customRegistry() })) + .filter((entry) => entry.code === 'AB6005' || entry.code === 'AB6039') + .map((entry) => [entry.code, entry.generatedPath, entry.message]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }; + const walkedInFull = [ + ['AB6005', 'scripts/bare.mjs', 'Generated JavaScript import from "scripts/bare.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/bundled.mjs', 'Generated JavaScript import from "scripts/bundled.mjs" has invalid syntax.'], + ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + ['AB6005', 'scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], + ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], + ]; - try { - const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); - expect(diagnostics.filter((entry) => entry.code === 'AB6005').map((entry) => [entry.generatedPath, entry.message])).toEqual([ - ['scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], - ['scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], - ['scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], - ['scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], - ]); - // A build whose consumer hatch may have rewritten the emitted assets asks - // for the full parse of bundles too; nothing else changes. - const parsed = await validateArtifact({ artifactRoot: root, bundleSyntaxCheck: 'parsed', registry: customRegistry() }); - expect(parsed.filter((entry) => entry.code === 'AB6005').map((entry) => entry.generatedPath)).toEqual([ - 'scripts/bundled.mjs', - 'scripts/copied.mjs', - 'scripts/dangling.mjs', - 'scripts/generated.mjs', - 'scripts/unterminated.mjs', - ]); - } finally { - await rm(root, { force: true, recursive: true }); - } + // Without a record nothing is proven: every module is parsed and resolved. + expect(await reported(modules)).toEqual(walkedInFull); + // A record covering the bundles proves their bytes, not their imports: the + // lexer still rejects unterminated input, a built-in and a recorded sibling + // pass, and a bare specifier or a dangling sibling the record does not + // account for — what an `rspackIgnore` import leaves behind — is reported + // without being resolved. + const unaccounted = (importer: string, request: string): readonly string[] => [ + 'AB6005', + importer, + `Generated JavaScript import from ${JSON.stringify(importer)} loads ${JSON.stringify(request)}, which the compiler ` + + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', + ]; + expect(await reported([...modules, compileEvidenceFor(modules, false, accountedExternals)])).toEqual([ + unaccounted('scripts/bare.mjs', 'unbundled-package'), + ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + unaccounted('scripts/dangling.mjs', './missing.mjs'), + ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], + ]); + // Without the sibling external in the record, the same import is unaccounted for. + expect(await reported([...modules, compileEvidenceFor(modules)])).toContainEqual(unaccounted('scripts/accounted.mjs', './sibling.mjs')); + // A hatch may have rewritten the emitted bytes after the compiler judged + // them: the record says so and proves nothing. + expect(await reported([...modules, compileEvidenceFor(modules, true, accountedExternals)])).toEqual(walkedInFull); + // A record that does not parse is reported once and proves nothing. + expect(await reported([...modules, { contents: '{', kind: 'generated', path: compileEvidenceFileName }])).toEqual([ + ['AB6039', compileEvidenceFileName, 'Compile evidence record is not valid JSON.'], + ...walkedInFull, + ]); }); it('does not import copied non-JavaScript resources', async () => { diff --git a/packages/agent-bundle/tests/compile-evidence.test.ts b/packages/agent-bundle/tests/compile-evidence.test.ts index 942ed3da7..3c1f5376f 100644 --- a/packages/agent-bundle/tests/compile-evidence.test.ts +++ b/packages/agent-bundle/tests/compile-evidence.test.ts @@ -144,7 +144,7 @@ describe('compile evidence records', () => { expect.stringContaining('was judged under policy'), expect.stringContaining('does not cover compiled file "uncovered.js"'), expect.stringContaining('for "mismatch.js" describes different bytes'), - expect.stringContaining('manifest does not list as a compiled file'), + expect.stringContaining('file table does not list as a compiled file'), expect.stringContaining('"left-pad" as a built-in; it is not one'), expect.stringContaining('sibling "./missing.js", which the artifact does not contain'), ])); diff --git a/packages/agent-bundle/tests/generated-module-evidence.test.ts b/packages/agent-bundle/tests/generated-module-evidence.test.ts index 73077f385..64768f2bc 100644 --- a/packages/agent-bundle/tests/generated-module-evidence.test.ts +++ b/packages/agent-bundle/tests/generated-module-evidence.test.ts @@ -12,10 +12,7 @@ import { cliEntryRuntimeSpecifier, generatedCliBinEntrySource, generatedExecutableEntrySource, - generatedInstallBinEntrySource, generatedStdioMcpEntrySource, - installEntryRuntimePath, - installEntryRuntimeSpecifier, launchEnvRuntimePath, mcpEntryRuntimePath, mcpEntryRuntimeSpecifier, @@ -147,20 +144,6 @@ const generators: ReadonlyArray<{ }), }), }, - { - label: 'generatedInstallBinEntrySource', - plan: (root) => entryOf({ - aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() }, - name: 'bin-install', - outputRelativePath: 'bin/installer.js', - source: join(root, 'src', 'cli.ts'), - virtualSource: generatedInstallBinEntrySource({ - artifactRelativeUrl: '../../artifact/', - hosts: ['claude', 'codex', 'cursor'], - name: 'installer', - }), - }), - }, { label: 'generatedStdioMcpEntrySource', plan: (root) => { diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index 89ee5f129..468c34723 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -58,6 +58,7 @@ let artifact = ''; let packageRoot = ''; let artifactBin = ''; let bin = ''; +let installedPackageRoot = ''; let packedPaths: readonly string[] = []; const spawned = new Set(); const observedProcessIds = new Set(); @@ -151,7 +152,7 @@ beforeAll(async () => { ...lifecycleInstallArguments, ...(npmMajor >= 12 ? [] : [tarball]), ], { cwd: installedConsumer, env: installedEnvironment() }); - const installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); + installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); const packageDocument = JSON.parse( await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), ) as { @@ -305,7 +306,7 @@ it('serves the App from `web --json --no-open` as a real process out of the dele expect(seed.result).toMatchObject({ structuredContent: { launch: { - args: ['--config', join(artifact, 'config', 'status.json')], + args: ['--config', join(installedPackageRoot, 'config', 'status.json')], cache: expect.stringMatching(/^(?!.*\/artifact\/).*\/\.agent-bundle\/web-data\/[^/]+\/status\/cache$/u), mode: 'packed', }, diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 5a15a1f86..5e8a3d558 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -39,7 +39,16 @@ interface Fixture { readonly home: string; } -const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise => { +const mcpDocuments = { + claude: '.mcp.json', + codex: '.codex-plugin/mcp.json', + cursor: '.cursor-plugin/mcp.json', +} as const; + +const createFixture = async ( + host: 'claude' | 'codex' | 'cursor', + options: { readonly mcpDocument?: unknown } = {}, +): Promise => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-')); const bundleRoot = join(cleanupRoot, 'bundle'); const home = join(cleanupRoot, 'home'); @@ -70,12 +79,15 @@ const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise { - const fixture = await createFixture('cursor'); +it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed manifest points at', async () => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-state-')); + const declaredStateRoot = join(cleanupRoot, 'declared-state'); + const fixture = await createFixture('cursor', { + mcpDocument: { mcpServers: { stateful: { command: 'node', env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot } } } }, + }); const cursorRoot = join(fixture.home, '.cursor'); - const declaredStateRoot = join(fixture.cleanupRoot, 'declared-state'); const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; try { - await Promise.all([ - mkdir(cursorRoot, { recursive: true }), - writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { - mcpServers: { - stateful: { - command: 'node', - env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, - }, - }, - }), - ]); - await writeInstallFixtureManifest( - fixture.bundleRoot, - { name: 'uninstall-fixture', version: '1.2.3' }, - [{ host: 'cursor' }], - ); + await mkdir(cursorRoot, { recursive: true }); await installBundle(options); expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) .toMatchObject({ @@ -477,7 +477,10 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - await rm(fixture.cleanupRoot, { force: true, recursive: true }); + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(cleanupRoot, { force: true, recursive: true }), + ]); } }); From 92c7abfcc0f41471411d3ae9eaa38af94377050f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 21:58:15 +0000 Subject: [PATCH 10/19] fix(build): align npm root with compiler evidence policy --- docs/diagnostics.md | 33 +- docs/entry-conventions.md | 54 +- .../src/build/pack-dependencies.ts | 12 +- .../agent-bundle/src/build/pack-inventory.ts | 3 + .../tests/pack-dependencies.test.ts | 8 +- packages/agent-bundle/tests/prepack.test.ts | 535 ++++++------------ .../docs/en/guide/distribution/validation.mdx | 157 +++-- .../docs/zh/guide/distribution/validation.mdx | 107 ++-- 8 files changed, 333 insertions(+), 576 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 30f54d314..b276ab597 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ even when no error diagnostic was reported. | `AB473x` | Migration nudges (informational; see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | -| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | +| `AB4765`–`AB4768` | Artifact-hosted routed CLI and npm lifecycle paths: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest; or a consumer lifecycle names an unsupported or absent Node path (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | @@ -423,9 +423,9 @@ Validation happens at three moments, all fail-closed: | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest/file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | -| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | +| `AB7010` | The dry-run npm inventory omits an expected npm-root file. Pack the generated npm root without excluding its files. | +| `AB7011` | A copied artifact file in the npm root no longer matches its manifest SHA-256. Rebuild and do not modify the generated npm root. | +| `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | | `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | @@ -833,20 +833,23 @@ exactly where `build` would refuse. | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | -## Artifact-hosted routed CLI (`AB4765`–`AB4766`) +## Artifact-hosted routed CLI and npm lifecycle paths (`AB4765`–`AB4768`) -A generated-mode `src/cli/**` surface compiles into the npm package bin -(`dist/bin/.js`) **and** into every host artifact whose adapter +A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus `bin/-flight.mjs` when any command renders). Every built-in target hosts it; the two codes cover a target that does not and a host file -that claims the same path. See “The routed CLI shell” in +that claims the same path. The npm root copies the manifest-declared bin +unchanged and points `package.json` at it rather than recompiling the graph. +See “The routed CLI shell” in `docs/entry-conventions.md` for the layout and the sibling-path convention. | Code | Severity | Trigger | | --- | --- | --- | | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | +| `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | +| `AB4768` | error (package build) | A consumer `preinstall`, `install`, or `postinstall` script uses a Node command the package builder cannot safely normalize, or names a file absent from the generated npm root. Use one `node ` command (source `dist/` bin and library paths are rebased), or invoke an executable supplied by a declared runtime dependency. Arbitrary source files are never copied into the npm root. | ## Config beside a route-generated MCP server (`AB4340`) @@ -1511,8 +1514,7 @@ consumes it as the recovery. `agent-bundle uninstall [--from ] [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] -[--force] [--plan] [--json]`, the package-relative installer bin's -`uninstall `, and the emitted `install.mjs --uninstall` are the +[--force] [--plan] [--json]` and the emitted `install.mjs --uninstall` are the receipt-owned reverse of `install` (#101; the maintainer's 2026-09-01 G4 deferral of mutation was reversed on 2026-09-03 with the request to fix every open issue). Every mutation is opt-in and bounded by the receipt: @@ -1838,8 +1840,9 @@ producer `{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` bytes may differ from the module graph), and `coverage.unobserved`. `agent-bundle validate --artifact` re-checks a listed record against the manifest file table without reading JavaScript (`AB6039`). The package build -returns the same record in process (`PackageBuildResult.evidence`, paths -`dist/bin/…`) and writes nothing to disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in +writes a separate `agent-bundle.package-compile-evidence.json` record for +package-only bins and libraries; generated plugin bytes remain covered by the +artifact record. `coverage.unobserved` lists the load forms Rslib leaves verbatim in the bundle, so the compiler neither bundles nor records them: `import()`, `require()`, `require.resolve(…)`, `createRequire(…)(…)`, `import.meta.resolve(…)`. No externals recorded @@ -1868,7 +1871,7 @@ therefore does not prove the absence of such a load. | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | -| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. A record with any `AB6039` finding, or with `coverage.rewritable` set, proves nothing to the emitted-module walk, which then parses every module in full. | Rebuild the artifact so its compile evidence record describes the emitted files. | +| `AB6039` | error | The artifact or package-only compile evidence sidecar is unreadable, invalid, judged under another policy, does not cover a compiled file, describes different bytes, names a path the corresponding file table does not list as compiled, misclassifies an external, or references a missing compiled sibling. An MCP App view also fails when its record names any external. A record with any `AB6039` finding, or with `coverage.rewritable` set, proves nothing to the emitted-module walk, which then parses every module in full. | Rebuild the artifact or generated npm root so its compile evidence describes the emitted files; do not edit generated evidence or outputs. | ## Workbench artifact inspection (`AB6200`–`AB6202`) @@ -1891,7 +1894,7 @@ The `AB7000`–`AB7004` codes are shared by two families of emitters with distinct meanings. `agent-bundle install` and `agent-bundle uninstall` (`install/install.ts`, `install/uninstall.ts`, `install/cursor-marketplace.ts`) throw them as `DiagnosticError`s with `target` set to the host; the -`install-entry` CLI wraps any non-diagnostic failure as `AB7004`. The +public install command wraps any non-diagnostic failure as `AB7004`. The development project service (`dev/project-service.ts`) and `inspectProject` emit them as **error** diagnostics on a failed preparation with `sourcePath` set to the config file and a fixed `recovery` ending in "then inspect again". @@ -1904,7 +1907,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 6fbdbb871..44d58c83e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1404,42 +1404,24 @@ kind whose row is not `supported`. The full matrix is in agent-bundle inspect --bundler [--target ] [--json] ``` -Dumps the **lowered Rspack configuration** of every output the build -compiles — artifact scripts, MCP entries, hook wrappers, the routed CLI bin, -each MCP App view, and the `dist/` package build's `bin` and `lib` entries — -one entry per compiler. The framework profile, the consumer `tools` hatch, -and the invariant layer are composed exactly as the build composes them, then -handed to the build's own engine — Rslib for executables, Rsbuild for MCP App -views — and stopped where the build would start compiling. What prints is -what the compiler receives: resolved `resolve.alias` entries (the -`agent-bundle/*` runtime modules and the project-rooted -`.agent-bundle-virtual/` generated modules beside the consumer's own -aliases), the `externals` list, the framework plugins -(`[object VirtualModulesPlugin]`, `[object ArtifactDependencyAuditPlugin]`), -`output.path`, module rules, and every default the engine fills in. The -lowering runs in production mode whatever `NODE_ENV` says and restores it -afterwards, and it runs the build's invariant assertions: a `tools` value the -build would refuse (a reserved alias, an `externals` entry naming a framework -runtime module) makes the inspection `invalid` with an `AB7001` diagnostic -carrying the refusal, instead of a config that never compiles. Entries the -framework wraps also carry the generated wrapper module source -(`generatedEntry`). - -The JSON rendering keeps the config's shape without dropping values JSON -cannot carry: functions render as `[function ]` (Rslib lowers each -entry's file name to `[function jsFilename]`), plugin instances as -`[object ]`, regular expressions as `[regexp //]`. - -Nothing is redacted (this is a local debugging surface; the lowered configs -carry absolute paths of the project and of agent-bundle's installed -toolchain), but two build-time values are replaced with stable tokens so the -output is deterministic for one project: the composite artifact root (chosen -per build) appears as `` — as `output.path` and inside any path -beneath it — and the synthesized declaration tsconfig (a temporary file -generated per package build) appears as ``. The -package build's `output.path` is its published destination, -`/dist`, although each real build stages outputs before -publishing them atomically. +Dumps the synthesized bundler configuration for every output the build +composes — artifact scripts, MCP entries, hook wrappers, the composite root's +MCP Apps Rsbuild config, and the `dist/` package build — exactly as the build +lowers it: in production mode whatever `NODE_ENV` says, the framework profile +with the consumer `tools` hatch merged over it and the invariant hook appended +last (functions render as `[function ]`). Entries the framework wraps also carry the generated +wrapper module source (`generatedEntry`). The composition comes from the same +functions the build uses, so the dump cannot drift from what compiles. + +Nothing is redacted (this is a local debugging surface), but two build-time +values are replaced with stable tokens so output is deterministic for one +project: the composite artifact root (chosen per build) appears as +``, and the synthesized declaration tsconfig (a temporary +file generated per package build) appears as ``. The +package build's output root appears as its published destination, `dist`, +although each real build stages outputs before publishing them atomically. +Resolved post-bundler internals stay Rslib's domain; this surfaces +agent-bundle's own composition, which is where the `tools` hatch lands. ## Dev-watch of the package build diff --git a/packages/agent-bundle/src/build/pack-dependencies.ts b/packages/agent-bundle/src/build/pack-dependencies.ts index 4e6c7c1a2..5296d0f61 100644 --- a/packages/agent-bundle/src/build/pack-dependencies.ts +++ b/packages/agent-bundle/src/build/pack-dependencies.ts @@ -38,15 +38,6 @@ const workspaceProtocol = /^(?:workspace|catalog):/u; /** Exact-prefix, as the packers test it: `" workspace:*"` is rewritten by none of them and published verbatim. */ export const isWorkspaceProtocol = (specifier: string): boolean => workspaceProtocol.test(specifier); -/** - * Whether the package manager running this pack (its `npm_config_user_agent`, - * e.g. `pnpm/10.0.0 npm/? node/v24.0.0 linux x64`) rewrites workspace - * protocols. Unknown or absent — `agent-bundle prepack` run outside any - * lifecycle — means no, so the gate stays strict. - */ -export const rewritesWorkspaceProtocols = (packerUserAgent: string | undefined): boolean => - /^(?:pnpm|yarn|bun)\//u.test(packerUserAgent ?? ''); - /** * How a consumer's npm reads one dependency entry, name and specifier * together: @@ -576,10 +567,11 @@ export interface InstallScriptDependencies { /** What the consumer-side install scripts (`installScriptText`) say about the installed dependencies `declared`. */ export const installScriptDependencies = async (options: { readonly declared: readonly string[]; + readonly dependencyRoot?: string; readonly packageDocument: Readonly>; readonly projectRoot: string; }): Promise => { - const executables = await executableCommands(options.declared, resolve(options.projectRoot)); + const executables = await executableCommands(options.declared, resolve(options.dependencyRoot ?? options.projectRoot)); const text = installScriptText(isRecord(options.packageDocument.scripts) ? options.packageDocument.scripts : {}); const needed = new Set(installScriptNeeds(text, simpleCommands(text), executables, options.packageDocument)); return { names: new Set([...installScriptMentions(text, executables), ...needed]), needed }; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 476f1d724..2d28d9528 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -146,6 +146,7 @@ const dependencyDiagnostics = async (options: { /** Package → the packed bundles the compiler inlined it into, each sorted. */ readonly bundledInto: ReadonlyMap; readonly declaredRuntimeDependencies: ReadonlySet; + readonly dependencyRoot: string; readonly packageDocument: Readonly>; readonly packedPaths: readonly string[]; readonly packerRewritesWorkspaceProtocols: boolean; @@ -160,6 +161,7 @@ const dependencyDiagnostics = async (options: { }); const installScripts = await installScriptDependencies({ declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), + dependencyRoot: options.dependencyRoot, packageDocument: options.packageDocument, projectRoot: options.projectRoot, }); @@ -344,6 +346,7 @@ export const packInventoryDiagnostics = async (options: { diagnostics.push(...await dependencyDiagnostics({ bundledInto, declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)), + dependencyRoot: resolve(options.projectRoot), packageDocument, packedPaths: [...packed], packerRewritesWorkspaceProtocols: options.packerRewritesWorkspaceProtocols, diff --git a/packages/agent-bundle/tests/pack-dependencies.test.ts b/packages/agent-bundle/tests/pack-dependencies.test.ts index b2cbf0efe..6f685fde6 100644 --- a/packages/agent-bundle/tests/pack-dependencies.test.ts +++ b/packages/agent-bundle/tests/pack-dependencies.test.ts @@ -5,7 +5,6 @@ import { declarationPackageReferences, isWorkspaceProtocol, packageNameOf, - rewritesWorkspaceProtocols, shellWords, } from '../src/build/pack-dependencies.ts'; import { declaredDependencies, isBarePackageName } from '../src/core/package-dependencies.ts'; @@ -118,18 +117,13 @@ it.each([ }); -it('tells workspace protocols apart and knows which packers rewrite them', () => { +it('tells workspace protocols apart', () => { expect(isWorkspaceProtocol('workspace:*')).toBe(true); expect(isWorkspaceProtocol('catalog:default')).toBe(true); // No packer rewrites a value that merely contains the protocol; npm then reads it as an invalid dist-tag. expect(isWorkspaceProtocol(' catalog:default')).toBe(false); expect(isWorkspaceProtocol('npm:effect@^4')).toBe(false); expect(isWorkspaceProtocol('github:owner/repo')).toBe(false); - expect(rewritesWorkspaceProtocols('pnpm/10.18.0 npm/? node/v24.0.0 linux x64')).toBe(true); - expect(rewritesWorkspaceProtocols('yarn/4.9.1 npm/? node/v24.0.0 linux x64')).toBe(true); - expect(rewritesWorkspaceProtocols('bun/1.2.0 npm/? node/v24.0.0 linux x64')).toBe(true); - expect(rewritesWorkspaceProtocols('npm/11.4.2 node/v24.0.0 linux x64 workspaces/false')).toBe(false); - expect(rewritesWorkspaceProtocols(undefined)).toBe(false); }); it.each([ diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index ada7cb91a..bc53752df 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -1,6 +1,8 @@ -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { execFile as executeFile } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; @@ -16,7 +18,9 @@ import { packOutputFromJson, type PackOutput, } from '../src/build/pack-inventory.ts'; +import type { PackageBuildResult } from '../src/build/package-build.ts'; +const execFile = promisify(executeFile); const workspaceNodeModules = join(process.cwd(), 'node_modules'); /** A gzipped ustar archive in npm's layout: one `package/package.json` entry with the given manifest text. */ @@ -234,7 +238,7 @@ it('reports package, model, host, and provenance version disagreement as AB7013' }, )); -it('reports installed dependencies no packed JavaScript imports as AB7014, per field', () => withPackageDocument( +it('reports installed dependencies a consumer never needs as AB7014, per field', () => withPackageDocument( (document) => { document.dependencies = { zod: '4.5.4', effect: '4.0.0' }; document.peerDependencies = { react: '19.2.8', 'optional-host': '^1.0.0' }; @@ -244,19 +248,68 @@ it('reports installed dependencies no packed JavaScript imports as AB7014, per f async () => { const reported = withCode(await diagnostics(), 'AB7014'); // One diagnostic per field; devDependencies never reach a consumer and optional peers are never installed, so - // nothing has to use optional-host. + // nothing has to use optional-host. The generated install bin inlines `effect`, so the compiler's evidence names + // that bundle; `zod` reached no bundle and is only listed. + expect(result.build.packageBuild!.evidence.assets.find((asset) => asset.path === 'index.js')?.packages) + .toEqual([]); expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ - expect.stringMatching(/^package\.json dependencies .*"effect", "zod"/u), - expect.stringMatching(/^package\.json peerDependencies .*"react"/u), + 'package.json dependencies names packages a consumer never needs installed: no packed declaration file references them, ' + + 'no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ' + + '"effect", "zod". Nothing packed reaches them at runtime; every consumer installs them for nothing.', + expect.stringMatching(/^package\.json peerDependencies .*"react"\. If they only constrain the host version/u), ]); // A required peer nothing imports may be a deliberate host-compatibility contract: a warning, not a refusal. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'warning']); - expect(reported[0]?.recovery).toContain('devDependencies'); + expect(reported[0]?.recovery).toBe('Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). ' + + 'Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, ' + + 'or a prebuilt payload declares in runtimeDependencies (definePrebuilt).'); expect(reported[1]?.message).toContain('compatibility contract'); expect(reported[1]?.recovery).toContain('peerDependenciesMeta'); }, )); +/** The shared fixture's package build, with compiler package evidence replaced per package-root path. */ +const packageBuildBundling = (bundled: Readonly>): PackageBuildResult => { + const packageBuild = result.build.packageBuild!; + return { + ...packageBuild, + evidence: { + ...packageBuild.evidence, + assets: packageBuild.evidence.assets.map((asset) => ({ ...asset, packages: bundled[asset.path] ?? [] })), + }, + }; +}; + +it('reports a dependency only compiled dist bundles inlined as AB7014, naming the bundles', () => withPackageDocument( + (document) => { + document.dependencies = { 'left-pad': '^1.3.0', 'never-loaded': '^1.0.0', 'tiny-pkg': '^1.0.0' }; + document.optionalDependencies = { 'optional-extra': '^1.0.0' }; + document.peerDependencies = { react: '19.2.8' }; + }, + async () => { + const paths = result.build.packageBuild!.files.map((file) => file.path); + expect(paths).toContain('index.js'); + const reported = withCode(await packInventoryDiagnostics({ + model: result.build.model, + packageBuild: packageBuildBundling({ + 'index.js': ['left-pad', 'react', 'tiny-pkg'], + }), + packOutput: result.pack, + packerRewritesWorkspaceProtocols: false, + projectRoot, + }), 'AB7014'); + // Bundled is not used: the names are still reported, and the tail says which bundles inlined them, names in + // field order and each name's bundles sorted. A peer the build inlined gets the same sentence; a field the + // compiler never touched gets the plain tail. + expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ + expect.stringMatching(/^package\.json dependencies .*: "left-pad", "never-loaded", "tiny-pkg"\. The build inlined "left-pad" into index\.js, and "tiny-pkg" into index\.js; every consumer installs them for nothing\.$/u), + expect.stringMatching(/^package\.json optionalDependencies .*: "optional-extra"\. Nothing packed reaches them at runtime; every consumer installs them for nothing\.$/u), + expect.stringMatching(/^package\.json peerDependencies .*: "react"\. The build inlined "react" into index\.js; every consumer installs them for nothing\.$/u), + ]); + expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); + }, +)); + it('accepts a dependency declared by a prebuilt payload runtimeDependencies list', () => withPackageDocument( (document) => { document.dependencies = { sharp: '^0.33.0', 'never-loaded': '^1.0.0' }; @@ -308,97 +361,6 @@ it('reports an optional peer only for a protocol npm cannot parse, which fails t }, )); -it('accepts a package loaded through a createRequire() binding, literal or computed', () => withPackageDocument( - (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'aliased.mjs'); - await writeFile(consumer, [ - // The factory renamed on import is still a factory. - 'import { createRequire as makeRequire } from "node:module";', - 'const load = makeRequire(import.meta.url);', - 'export const driver = load("driver-package");', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'aliased.mjs' }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - expect(reported?.message).not.toContain('"driver-package"'); - // Namespace-qualified factory, computed argument. - await writeFile(consumer, 'import * as Module from "node:module";\nconst load = Module.createRequire(import.meta.url);\nexport const any = (name) => load(name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Factory chained off a CommonJS load, literal argument. - await writeFile(consumer, 'const load = require("node:module").createRequire(__filename);\nmodule.exports = load("driver-package");\n'); - const [chained] = withCode(await diagnostics(pack), 'AB7014'); - expect(chained?.message).toContain('"never-loaded"'); - expect(chained?.message).not.toContain('"driver-package"'); - // Loader called inline with a literal. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(import.meta.url)("driver-package");\n'); - const [inline] = withCode(await diagnostics(pack), 'AB7014'); - expect(inline?.message).toContain('"never-loaded"'); - expect(inline?.message).not.toContain('"driver-package"'); - // Factory argument with nested calls, literal target. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(new URL("./entry.js", import.meta.url))("driver-package");\n'); - const [nested] = withCode(await diagnostics(pack), 'AB7014'); - expect(nested?.message).toContain('"never-loaded"'); - expect(nested?.message).not.toContain('"driver-package"'); - // The same factory argument, computed target: nothing can be called unused. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const any = (name) => createRequire(new URL("./entry.js", import.meta.url))(name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Comments between the loader and its parentheses, and around the literal, are trivia — and not a computed argument. - await writeFile(consumer, 'module.exports = require /* driver */ ( // which\n /* a */ "driver-package" /* b */ );\n'); - const [commented] = withCode(await diagnostics(pack), 'AB7014'); - expect(commented?.message).toContain('"never-loaded"'); - expect(commented?.message).not.toContain('"driver-package"'); - // Comment trivia before a computed argument still leaves the load computed. - await writeFile(consumer, 'module.exports = (name) => require /* any */ (/* of */ name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['a namespace import', 'import * as Module from "node:module";\nexport const driver = Module.createRequire(import.meta.url)("driver-package");'], - ['the default import', 'import module from "node:module";\nexport const driver = module.createRequire(import.meta.url)("driver-package");'], - ['require("node:module")', 'module.exports = require("node:module").createRequire(__filename)("driver-package");'], - ["require('module')", "module.exports = require('module').createRequire(__filename)('driver-package');"], - ['require("node:module") and .resolve', 'module.exports = require("node:module").createRequire(__filename).resolve("driver-package");'], - ['a two-level namespace, bound first', 'import * as ns from "node:module";\nconst load = ns.default.createRequire(import.meta.url);\nexport const driver = load("driver-package");'], -])('accepts a package loaded by a createRequire() call qualified through %s, direct or bound', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - expect(reported?.message).not.toContain('"driver-package"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['a namespace import', 'import * as Module from "node:module";\nexport const load = (name) => Module.createRequire(import.meta.url)(name);'], - ['require("node:module")', 'module.exports = (name) => require("node:module").createRequire(__filename)(name);'], -])('withholds AB7014 for a computed direct createRequire()() call qualified through %s', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifiers as AB7015', () => withPackageDocument( (document) => { document.dependencies = { @@ -430,71 +392,49 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier document.bundleDependencies = ['embedded', 'not-embedded']; document.optionalDependencies = { scp: 'git@github.com:owner/repo.git', - // npm skips these ten after the failed fetch too — and then postinstall fails: on the missing commands, and on - // the missing modules packed files it runs require. `newline-tool` is the first command of postinstall's second - // line, `setup-tool` the second of the first. The files are reached as `node scripts/install` (Node resolves - // `scripts/install.js`), as `npm test` running a script whose quoted path contains a space, and as - // `node scripts/hooks.cjs&&…` with no whitespace around the shell operator; the last loads its dependencies - // through a wildcard `imports` entry mapped to the package's own file, and through a directory whose packed - // manifest names its `main`. An inline `node -e` program that requires a package needs it too — its quotes - // escaped for the shell — as do the modules `node` preloads — a bare `-r` package and a packed `--import=` - // file, behind the valued `--conditions` option — before running `.`, the root `main`, and the one a - // `NODE_OPTIONS=--require=…` assignment on the `node` command preloads. + // npm skips these five after the failed fetch too — and then postinstall fails on the missing package: + // `newline-tool` is the first command of postinstall's second line, `setup-tool` the second of the first; + // `node node_modules/optional-driver/install.js` runs a file of the third directly, reached through `npm test` + // with no whitespace around the shell operator; `node` preloads the last two, a bare `-r` package behind the + // valued `--conditions` option and the one a `NODE_OPTIONS=--require=…` assignment on the `node` command names. 'setup-tool': 'git+https://github.com/owner/setup-tool.git', 'newline-tool': 'github:owner/newline-tool', 'optional-driver': 'github:owner/optional-driver', - 'optional-tester': 'github:owner/optional-tester', - 'optional-hook': 'github:owner/optional-hook', - 'optional-main': 'github:owner/optional-main', - 'optional-inline': 'github:owner/optional-inline', 'optional-preload': 'github:owner/optional-preload', - 'optional-imported': 'github:owner/optional-imported', - 'optional-root': 'github:owner/optional-root', 'optional-env-preload': 'github:owner/optional-env-preload', // Merely mentioned by the script — an `echo` argument; the operand of `rm -r`, whose `-r` is not Node's - // preload option; and the value of a `--require` after the program, which Node hands to the program as an - // argument — so npm's skipping them breaks nothing: a warning. + // preload option; the value of a `--require` after the program, which Node hands to the program as an + // argument; and the name of a packed file `node` runs, which the gate no longer opens — so npm's skipping + // them breaks nothing: a warning. 'optional-mentioned': 'github:owner/optional-mentioned', 'optional-removed': 'github:owner/optional-removed', 'optional-argument': 'github:owner/optional-argument', + 'optional-script': 'github:owner/optional-script', // npm parses these only to fail, so optional or not, the consumer's install dies. 'typo-optional': 'foo:bar', 'tag-optional': 'not a valid spec', 'url-optional': 'http:%zz', 'bad name': '^1.0.0', }; - document.imports = { '#hooks/*': './scripts/*-setup.cjs' }; - document.main = './scripts/root-setup.cjs'; document.scripts = { ...(document.scripts as Record | undefined), postinstall: 'echo start\nnewline-tool --init && setup-tool --init' - + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/install --require optional-argument && npm test', - // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes. - test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node scripts/hooks.cjs&&echo optional-mentioned' - + ' && node -e "require(\\"optional-inline\\")"' + + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/optional-script.cjs --require optional-argument && npm test', + // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes; + // a relative `--import=` preload names no package. + test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node node_modules/optional-driver/install.js&&echo optional-mentioned' + ' && rm -r optional-removed && node --conditions react-server -r optional-preload/register --import="./scripts/preload.mjs" .', }; }, async () => { - const packageRoot = join(projectRoot, 'dist'); - await mkdir(join(packageRoot, 'scripts', 'lib'), { recursive: true }); - await mkdir(join(packageRoot, 'vendor', 'vendored'), { recursive: true }); - await mkdir(join(packageRoot, 'vendor', 'bad-manifest'), { recursive: true }); + await mkdir(join(projectRoot, 'vendor', 'vendored'), { recursive: true }); + await mkdir(join(projectRoot, 'vendor', 'bad-manifest'), { recursive: true }); await Promise.all([ - writeFile(join(packageRoot, 'scripts', 'install.js'), 'import "./driver-setup.cjs";\n'), - writeFile(join(packageRoot, 'scripts', 'driver-setup.cjs'), 'module.exports = require("optional-driver");\n'), - writeFile(join(packageRoot, 'scripts', 'my install.cjs'), 'require("optional-tester");\n'), - writeFile(join(packageRoot, 'scripts', 'hooks.cjs'), 'require("#hooks/hook");\nrequire("./lib");\n'), - writeFile(join(packageRoot, 'scripts', 'hook-setup.cjs'), 'require("optional-hook");\n'), - writeFile(join(packageRoot, 'scripts', 'lib', 'package.json'), '{ "main": "setup.cjs" }\n'), - writeFile(join(packageRoot, 'scripts', 'lib', 'setup.cjs'), 'require("optional-main");\n'), - writeFile(join(packageRoot, 'scripts', 'preload.mjs'), 'import "optional-imported";\n'), - writeFile(join(packageRoot, 'scripts', 'root-setup.cjs'), 'require("optional-root");\n'), - writeFile(join(packageRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), - writeFile(join(packageRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), - writeFile(join(packageRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), - writeFile(join(packageRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), - writeFile(join(packageRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), + writeFile(join(projectRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), + writeFile(join(projectRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), + writeFile(join(projectRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), + writeFile(join(projectRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), + writeFile(join(projectRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), ]); const pack = { ...result.pack, files: [...result.pack.files, { path: 'node_modules/embedded/package.json' }, @@ -503,53 +443,30 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier { path: 'vendor/tarred.tgz' }, { path: 'vendor/bad-tarred-manifest.tgz' }, { path: 'vendor/not-archive.tgz' }, - { path: 'scripts/install.js' }, - { path: 'scripts/driver-setup.cjs' }, - { path: 'scripts/my install.cjs' }, - { path: 'scripts/hooks.cjs' }, - { path: 'scripts/hook-setup.cjs' }, - { path: 'scripts/lib/package.json' }, - { path: 'scripts/lib/setup.cjs' }, - { path: 'scripts/preload.mjs' }, - { path: 'scripts/root-setup.cjs' }, ] }; const reported = withCode(await diagnostics(pack), 'AB7015'); expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ expect.stringMatching(/^package\.json dependencies .*consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-hook" -> "github:owner\/optional-hook", "optional-imported" -> "github:owner\/optional-imported", "optional-inline" -> "github:owner\/optional-inline", "optional-main" -> "github:owner\/optional-main", "optional-preload" -> "github:owner\/optional-preload", "optional-root" -> "github:owner\/optional-root", "optional-tester" -> "github:owner\/optional-tester", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), + expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-preload" -> "github:owner\/optional-preload", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), + expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "optional-script" -> "github:owner\/optional-script", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), ]); // npm survives an optional dependency it parsed but cannot fetch, so that entry warns rather than blocks the // release; a specifier it cannot parse fails the manifest read and stays fatal, as does a skipped package an - // install script then runs or loads. + // install script then runs. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); - for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed']) { + for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed', 'optional-script']) { expect(reported[1]?.message).not.toContain(JSON.stringify(name)); } - for (const name of [ - 'setup-tool', 'newline-tool', 'optional-driver', 'optional-tester', 'optional-hook', 'optional-main', 'optional-inline', - 'optional-preload', 'optional-imported', 'optional-root', 'optional-env-preload', - ]) { + for (const name of ['setup-tool', 'newline-tool', 'optional-driver', 'optional-preload', 'optional-env-preload']) { expect(reported[2]?.message).not.toContain(JSON.stringify(name)); } - // To npm, `.` is the working directory (`--prefix .`), not a program: only `node .` runs the root `main`. - await withPackageDocument( - (document) => { - document.scripts = { ...(document.scripts as Record), postinstall: 'npm --prefix . run setup', setup: 'echo setup' }; - }, - async () => { - const survivable = withCode(await diagnostics(pack), 'AB7015').find((diagnostic) => diagnostic.severity === 'warning'); - expect(survivable?.message).toContain('"optional-root"'); - }, - ); for (const name of ['@agent-bundle/runtime', 'bashjsast', 'local', 'sibling', 'not-embedded', 'not-vendored', 'not-archive', 'bad-manifest', 'bad-tarred-manifest']) { expect(reported[0]?.message).toContain(`${JSON.stringify(name)} -> `); } for (const name of ['alias', 'tilde', 'versioned', 'embedded', 'vendored', 'tarred']) { expect(reported[0]?.message).not.toContain(JSON.stringify(name)); } - await rm(join(packageRoot, 'scripts'), { force: true, recursive: true }); - await rm(join(packageRoot, 'vendor'), { force: true, recursive: true }); + await rm(join(projectRoot, 'vendor'), { force: true, recursive: true }); expect(reported[0]?.recovery).toContain('registry'); const underPnpm = withCode(await diagnostics(pack, true), 'AB7015'); @@ -557,122 +474,10 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier }, )); -it('withholds AB7014 when packed JavaScript has a computed import() that could load any declared package', () => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'computed.mjs'); - await writeFile(consumer, 'export const load = (name) => import(name);\n'); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'computed.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Without that file the same declaration is reported. - expect(withCode(await diagnostics(), 'AB7014')).toHaveLength(1); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('withholds AB7014 when packed JavaScript the lexer rejects may hide an import()', () => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'unlexable.mjs'); - // An unbalanced call: the lexer throws before reporting any import, so nothing proves the package unused. - await writeFile(consumer, 'export const load = () => import("chosen-at-runtime"\n'); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'unlexable.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['require()', 'module.exports = (name) => require(name);'], - ['require.resolve()', 'module.exports = (name) => require.resolve(name);'], - ['import.meta.resolve()', 'export const where = (name) => import.meta.resolve(name);'], - ['a direct createRequire()()', 'import { createRequire } from "node:module";\nexport const load = (name) => createRequire(import.meta.url)(name);'], - ['require() of a literal-prefixed expression', 'module.exports = (variant) => require("chosen-at-runtime/" + variant);'], - ['require.resolve() of a template literal', 'module.exports = (variant) => require.resolve(`chosen-at-runtime/${variant}`);'], -])('withholds AB7014 for a computed CommonJS %s just as for a computed import()', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('module.exports') ? 'computed.cjs' : 'computed.mjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['const load = require;', 'const load = require;\nmodule.exports = load("chosen-at-runtime");'], - ['fn(require)', 'module.exports = (fn) => fn(require);'], - ['module.exports = require', 'module.exports = require'], - ['[require]', 'module.exports = [require];'], - ['a ? require : b', 'module.exports = typeof require === "function" ? require : null;'], - ['a createRequire() binding passed on', 'import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nexport const use = (fn) => fn(load);'], -])('withholds AB7014 when a loader is passed on as a value (%s), since it may load anything under another name', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'alias.mjs' : 'alias.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['require("…")', 'module.exports = require("node:path");'], - ['require.resolve("…")', 'module.exports = require.resolve("node:path");'], - ['typeof require', 'module.exports = typeof require;'], - ['the string "require"', 'module.exports = "require";'], - ['prose in comments', '/**\n * Use when a getter may fail, require\n * services, or run asynchronously.\n */\n// factory(module, require)\nmodule.exports = 1;'], - ['a bundler runtime named like require', 'const load = __webpack_require__;\nmodule.exports = load;'], -])('still reports AB7014 when require is only called, resolved through, type-tested, or named in a string or comment (%s)', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'not-alias.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'not-alias.cjs' }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('still reports AB7014 when the only resolve() calls are path or Promise resolution, literal or not', () => withPackageDocument( - (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'resolvers.mjs'); - await writeFile(consumer, [ - 'import path, { resolve } from "node:path";', - 'export const f = (a, b) => [resolve(a, b), Promise.resolve(a), path.resolve("never-loaded"), Promise.resolve("never-loaded")];', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'resolvers.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')[0]?.message).toContain('"never-loaded"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('accepts a dependency reached through a package imports map or run by a consumer install script', () => withPackageDocument( +it('accepts a dependency a consumer install script names or runs, through delegated scripts and their hooks', () => withPackageDocument( (document) => { document.dependencies = { + // Reached only through the manifest's imports map, which no packed declaration file resolves: unused. 'driver-package': '^1.0.0', 'named-in-script': '^1.0.0', typescript: '^5.0.0', @@ -712,21 +517,13 @@ it('accepts a dependency reached through a package imports map or run by a consu }; }, async () => { - const consumer = join(projectRoot, 'dist', 'mapped.mjs'); - await writeFile(consumer, 'export { default } from "#driver";\n'); // The fixture's node_modules is the workspace's; the manifest is removed again below. const wrapper = join(workspaceNodeModules, 'prepack-test-wrapper'); await mkdir(wrapper, { recursive: true }); await writeFile(join(wrapper, 'package.json'), JSON.stringify({ name: '@scope/real', version: '1.0.0', bin: 'cli.js' })); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'mapped.mjs' }] }; - // `#driver` reaches driver-package; the script names named-in-script directly, typescript through its `tsc` bin, - // and the alias through `real`, the bin npm derives from the installed manifest's name. `prepare` proves nothing. - const [withImport] = withCode(await diagnostics(pack), 'AB7014'); - expect(withImport?.message).toContain('"prepare-only"'); - expect(withImport?.message).toContain('"dormant-only"'); - expect(withImport?.message).not.toContain('"driver-package"'); - // Without the `#` import the map alone proves nothing. + // The script names named-in-script directly, typescript through its `tsc` bin, and the alias through `real`, + // the bin npm derives from the installed manifest's name. `prepare` proves nothing, nor does the map alone. const [reported] = withCode(await diagnostics(), 'AB7014'); expect(reported?.message).toContain('"driver-package"'); expect(reported?.message).toContain('"prepare-only"'); @@ -749,31 +546,10 @@ it('accepts a dependency reached through a package imports map or run by a consu ); } finally { await rm(wrapper, { force: true, recursive: true }); - await rm(consumer, { force: true }); } }, )); -it.each([ - ['a literal import()', 'node -e "import(\'optional-driver\')"', 'error'], - ['an awaited import() in an ES module program', 'node --input-type=module -e "await import(\'optional-driver\')"', 'error'], - ['a computed import(), which may load any declared package', 'node -e "import(process.argv[1])"', 'error'], - ['a literal require()', 'node -e "require(\'optional-driver\')"', 'error'], - ['source the lexer rejects, which may hide an import()', 'node -e "import(\'optional-driver\'"', 'error'], - ['import.meta, which loads nothing', 'node --input-type=module -p "typeof import.meta"', 'warning'], - ['the package name in a string', 'node -p "\'optional-driver\'"', 'warning'], -])('an inline node program with %s (%s) leaves a skipped optional dependency at severity %s', (_form, postinstall, severity) => withPackageDocument( - (document) => { - document.optionalDependencies = { 'optional-driver': 'github:owner/optional-driver' }; - document.scripts = { ...(document.scripts as Record | undefined), postinstall }; - }, - async () => { - const [reported] = withCode(await diagnostics(), 'AB7015'); - expect(reported?.message).toContain('"optional-driver"'); - expect(reported?.severity).toBe(severity); - }, -)); - it('reads a dependency whose installed manifest is not JSON as an unknown executable instead of failing the gate', () => withPackageDocument( (document) => { document.dependencies = { 'broken-dep': '^1.0.0', 'never-loaded': '^1.0.0' }; @@ -821,76 +597,54 @@ it('reads an installed manifest as npm does, so the last of duplicate name keys }, )); -it('prepack rejects an unused unresolvable optional dependency and retains its fetch warning', () => withPackageDocument( +it('prepack succeeds and surfaces the warning when the only finding is an unresolvable optional dependency', () => withPackageDocument( (document) => { document.optionalDependencies = { 'optional-native': 'github:owner/optional-native' }; + // The build rewrites dist, so the packed declaration that references the optional package lives in its own + // packed directory; an install script running it instead would make the failed fetch fatal. + document.files = [...(document.files as readonly string[]), 'extras']; }, async () => { - await expect(prepack({ root: projectRoot })).rejects.toMatchObject({ - diagnostics: [ - expect.objectContaining({ code: 'AB7014', severity: 'error' }), - expect.objectContaining({ code: 'AB7015', severity: 'warning' }), - ], - }); + const extras = join(projectRoot, 'extras'); + await mkdir(extras, { recursive: true }); + await writeFile(join(extras, 'optional.d.ts'), 'export type { Native } from "optional-native";\n'); + try { + const packed = await prepack({ root: projectRoot }); + expect(packed.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity])).toEqual([['AB7015', 'warning']]); + } finally { + await rm(extras, { force: true, recursive: true }); + } }, )); it('accepts a dependency that only packed declaration files reference, including @types for a type directive', () => withPackageDocument( - (document) => { document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0' }; }, + (document) => { + document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0', 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; + document.imports = { '#driver': { node: 'driver-package/node', default: 'driver-package' } }; + }, async () => { const declaration = join(projectRoot, 'dist', 'consumer.d.ts'); + const modern = join(projectRoot, 'dist', 'driver.d.mts'); await writeFile(declaration, [ '/// ', "import type { ZodType } from 'zod';", 'export declare const schema: ZodType;', 'export declare const buffer: Buffer;', + // A comment or string is not a reference, and a `declare module` in a module file augments a package. + '// import { Function } from "effect" -- never counts.', + 'export declare const text: "import x from \\"effect\\"";', '', ].join('\n')); + // A `#` specifier reaches the package the imports map names. + await writeFile(modern, 'export type { Driver } from "#driver";\n'); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.d.ts' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.d.ts' }, { path: 'dist/driver.d.mts' }] }; + const [reported] = withCode(await diagnostics(pack), 'AB7014'); + expect(reported?.message).toContain('"never-loaded"'); + for (const name of ['zod', '@types/node', 'driver-package']) expect(reported?.message).not.toContain(JSON.stringify(name)); } finally { await rm(declaration, { force: true }); - } - }, -)); - -it('accepts a dependency that packed JavaScript imports, requires, or only resolves', () => withPackageDocument( - (document) => { - document.dependencies = { - 'left-pad': '^1.3.0', - '@scope/required': '^2.0.0', - 'asset-pkg': '^1.0.0', - 'tool-pkg': '^1.0.0', - // Named only through escaped literals, which Node decodes before resolving. - 'hex-pkg': '^1.0.0', - 'unicode-pkg': '^1.0.0', - // Run as an executable, never loaded: by the `tsc` bin its installed manifest declares. - typescript: '^5.0.0', - }; - }, - async () => { - const consumer = join(projectRoot, 'dist', 'consumer.mjs'); - await writeFile(consumer, [ - 'import { execSync, spawnSync } from "node:child_process";', - 'const ran = [spawnSync("tsc", ["--version"]), execSync("tsc --noEmit")];', - 'import leftPad from "left-pad/lib/index.js";', - 'const { createRequire } = await import("node:module");', - 'const require = createRequire(import.meta.url);', - 'const required = require("@scope/required/subpath");', - 'const asset = require.resolve("asset-pkg/package.json");', - 'const tool = import.meta.resolve("tool-pkg/bin/tool");', - String.raw`const hex = require("\x68ex-pkg");`, - String.raw`const unicode = require('unicode-pkg\u002fsubpath');`, - '// import { Function } from "effect" -- a comment never counts.', - 'export { leftPad, required, asset, tool, hex, unicode, ran };', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); + await rm(modern, { force: true }); } }, )); @@ -922,28 +676,38 @@ const createSiblingProject = async ( return root; }; -it('accepts a dependency that only a prebuilt payload module imports: prepack passes, AB6005 does not walk prebuilt payloads', async () => { +it('accepts a dependency a prebuilt payload declares in runtimeDependencies: prepack passes, the payload stays opaque', async () => { const root = await createSiblingProject('prebuilt-project', { bin: { 'prebuilt-fixture': './dist/bin/prebuilt-fixture.js' }, - dependencies: { express: '^5.0.0' }, + dependencies: { 'body-parser': '^2.0.0', cors: '^2.8.5', express: '^5.0.0' }, files: ['dist', 'host-packs', 'README.md'], name: 'prebuilt-fixture', type: 'module', version: '1.2.3', }, [ + "import { definePrebuilt } from 'agent-bundle';", + '', 'export default {', ' bin: false,', " lib: './src/index.ts',", " mcp: { servers: { timeline: { entry: { prebuilt: './built/runtime/mcp/server.js' }, transport: 'stdio' } } },", " output: { distPath: 'host-packs' },", - " payload: { runtime: './built/runtime' },", + " payload: { runtime: definePrebuilt({ runtimeDependencies: ['body-parser', 'cors', 'express'], source: './built/runtime' }) },", " plugin: { name: 'prebuilt-fixture' },", " targets: ['cursor'],", '};', ], { - // A bare import in a module the framework copies rather than compiles: AB6005 never walks it, and the - // import is the usage evidence that keeps `express` out of AB7014. - 'built/runtime/mcp/server.js': 'import express from "express";\nexport default express;\n', + // A bare import, a `require()`, and a `require.resolve()` in a module the framework copies rather than compiles: + // nothing opens the file — AB6005 never walks it, and AB7014 reads no packed JavaScript — so the declaration + // above is what keeps `express`, `body-parser`, and `cors` out of AB7014. + 'built/runtime/mcp/server.js': [ + 'import express from "express";', + 'const body = require("body-parser");', + 'const where = require.resolve("cors");', + 'export default express;', + 'export { body, where };', + '', + ].join('\n'), 'src/index.ts': 'export const value = 1;\n', }); const packed = await prepack({ root }); @@ -989,3 +753,34 @@ it('fails prepack with compile-time AB6005, never AB7014, when only a compiled d }); expect(withCode(reported, 'AB7014')).toHaveLength(0); }, 180_000); + +it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { + const tarballs = join(cleanupRoot, 'tarballs'); + const consumer = join(cleanupRoot, 'consumer'); + const home = join(cleanupRoot, 'home'); + await Promise.all([ + mkdir(tarballs), + mkdir(consumer), + mkdir(join(home, '.cursor'), { recursive: true }), + ]); + const { stdout } = await execFile('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], { + cwd: projectRoot, + }); + const packed = packOutputFromJson(stdout); + await writeFile(join(consumer, 'package.json'), '{"private":true}\n'); + await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(tarballs, packed.filename)], { + cwd: consumer, + }); + const sourceCopy = join(cleanupRoot, 'source-copy'); + await cp(projectRoot, sourceCopy, { recursive: true, filter: (source) => source !== join(projectRoot, 'node_modules') }); + await rm(projectRoot, { force: true, recursive: true }); + + const installedBin = join(consumer, 'node_modules', '.bin', 'installer-fixture'); + const installed = await execFile(installedBin, ['install', 'cursor', '--json'], { + cwd: consumer, + env: { ...process.env, HOME: home }, + }); + expect(JSON.parse(installed.stdout)).toMatchObject({ host: 'cursor', state: 'installed' }); + await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); + expect(await readFile(join(sourceCopy, 'src', 'index.ts'), 'utf8')).toBe('export const value = 1;\n'); +}); diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 582339a6b..a4cf6b336 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -28,9 +28,9 @@ the deploy tree reports `AB6025`. A listed compile evidence record that does not manifest file table reports `AB6039`. Self-containment is proven from the compilation's externals (`AB6005` at compile time). The -compiler service lowers every host-pack surface and package-only entry (an authored bin or the -`lib` entry). The npm root reuses the manifest-declared routed CLI and its rendered route Flight -worker from the validated artifact. The framework-owned +compiler service lowers every host-pack surface and package-build entry (`dist/bin/*.js`, a +rendered route's Flight worker `.mjs`, and the `lib` entry), plus every MCP App view. The +framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` to record every module Rspack kept external, and the service reads that evidence before trusting an asset. Only Node built-ins (`node:fs`, `fs`), `pnpapi`, and an emitted sibling of the same artifact may remain external; any @@ -38,18 +38,26 @@ other request fails whatever spelling Rspack emitted (`import`, `require`, or it shim). An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. -The emitted-module walk remains behind that compiler evidence as defense in depth. It walks each -emitted JavaScript module as an ES module and rejects residual unsupported imports, non-literal -dynamic imports, and targets missing from or outside the emitted tree; a `dist` finding names its -file as `dist/`. How thoroughly syntax is checked follows who produced the bytes. A module -the framework compiled (manifest kind `bundle`) is the bundler's own output, so only the ESM lexer -runs over it, rejecting unterminated strings, templates, comments, and regexps and unbalanced -braces. A module the framework did not compile — a copied consumer script or standalone installer -— is parsed in full, as is every bundle of a build whose -[`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the emitted assets. -Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only, and declaration files -(`.d.ts`) are not walked. The route graph guards the same -self-containment before the bundler runs: a route module, layout, or provider — or a module one of +The emitted-module walk remains behind that compiler evidence for exactly what the compiler cannot +see. It reads every emitted `.js`/`.mjs` module as an ES module and, in any of them, compiled ones +included, fails an expression `import()` (`AB6005 has a non-literal dynamic import`). A module the +compile evidence record proves — a manifest `bundle` file the record covers with the same bytes, +from a build without a [`tools` hatch](../../reference/configuration.mdx#tools) — is lexed, not +parsed, and each literal import it still carries is held to the record: a Node built-in or one of +the file's recorded externals passes; any other request is one the build was told to ignore +(`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with no module, external, or warning) +and fails `AB6005 loads "", which the compiler neither bundled nor recorded as an +external`. A matching digest proves the bytes are the compiler's, not that every import in them was +resolved. Every other module is parsed in full and its +imports resolved against the manifest — a generated `install.mjs`, a copied consumer script, every +module of an artifact without a record, and every module of a build whose hatch could have rewritten +the emitted assets (the record's `coverage.rewritable`). Those findings are the residual `AB6005` +forms: unsupported or invalid specifiers, targets missing from or outside the emitted tree, invalid +syntax; a `dist` finding names its file as `dist/`. Prebuilt payloads (`kind: 'prebuilt'`) +stay opaque and hash-locked only, and declaration files (`.d.ts`) are not walked. + +The route graph guards the same self-containment before the bundler runs: a route module, layout, +or provider — or a module one of them reaches through relative imports — that value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`) is reported as `AB4837` @@ -237,36 +245,30 @@ repository is self-referential and causes churn on every table edit. npx agent-bundle prepack --root . --output artifact --json ``` -`prepack` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the -exact package and artifact inventory, manifest hashes, package bin targets, release-version -agreement, and the installed-dependency fields of `package.json`. The build inlines every -dependency into `dist/bin` and the host packs, so a published plugin should install nothing: +`prepack` runs the release build, creates the canonical npm root at `dist/`, and runs +`npm pack --dry-run --json --ignore-scripts` from that root. The generated package copies the +validated composite artifact unchanged, points routed `package.json` bins at the +manifest-declared `bin/.mjs`, and adds only separately authored bins and libraries. The gate +checks that exact inventory, manifest hashes, package bin targets, release-version agreement, +package-only compile evidence, and the installed-dependency fields of `package.json`. The build +inlines every dependency into authored package entries and the host packs, so a published plugin should install nothing: declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of the stack under `devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot keep a bare package external: the compiler reports `AB6005` before `prepack` reaches the inventory. So -`dependencies` is only for what the packed files demonstrably need by other means — a prebuilt -payload module's import, a `require`, `createRequire`, or `import.meta.resolve` call in a packed -file that the compiler did not resolve as a module dependency, a packed declaration's reference, -a consumer install script, or a `bin` command a packed file runs — since only JavaScript the -framework did not compile can still import one. `--output` is an artifact path relative to `--root` that overrides the configured -`output.distPath`, defaulting to `artifact`. The generated npm root omits pack and publish -lifecycle hooks to prevent source-project recursion while retaining consumer install scripts. -For `preinstall`, `install`, and `postinstall`, a single `node ` command is rebased from an -authored `dist/` bin or library path to the generated root and must name a staged output. -`AB4768` rejects unsupported Node command forms and source files the generated root does not -contain; arbitrary project source is never copied. Commands supplied by declared runtime -dependencies remain unchanged. -After the gate succeeds, publish exactly the root it validated with -`npm publish ./dist --ignore-scripts`; no npm lifecycle performs a host install. - -The npm root keeps the artifact's `agent-bundle.compile-evidence.json` and generated plugin bytes -unchanged. Package-only authored bins and libraries have their own persisted -`agent-bundle.package-compile-evidence.json`, with paths relative to the npm root. `prepack` -checks that record against the package-only `bundle` inventory with `AB6039`. +bundling a package does not prove that consumers need it installed. `--output` is an artifact path +relative to `--root` that overrides the configured +`output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` +prevents recursion, and no npm lifecycle ever performs a host install. + +Consumer `preinstall`, `install`, and `postinstall` scripts are preserved only when they do not +invoke Node directly, or when they are one `node ` command. Known authored +paths below the source `dist/` root are rebased to the generated root and checked against staged +files. Unsupported Node commands and absent source assets report `AB4768`; the package builder +never copies the source tree to make a lifecycle path work. That inlining is the compiler's contract, not a bundler default a toolchain upgrade may flip. -Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and -package-only JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, +Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and the +package build's JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, and `splitChunks: false`, and the framework adds no `externals` of its own; Rslib's `node` target leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) external. MCP App views inline every script and style into one HTML file, and the same audit plugin sits in their Rsbuild @@ -277,57 +279,38 @@ external — a `react` mapped to a global through the hatch, say — fails the v `externals` entries that name packages. RegExp, function-form, relative, and mutator externals are judged from the compilation's own evidence: `AB6005` fails a generated executable in a host pack or `dist` when Rspack kept anything except a built-in, `pnpapi`, or an emitted sibling external. -The emitted-module walk remains behind this compile-time check. An external kept through the -[`tools` hatch](../../reference/configuration.mdx#tools) is therefore not a way around the -contract and never reaches the prepack inventory. A -`dependencies` entry is therefore only for what the packed files demonstrably need from outside a -compiled bundle — a package a prebuilt `.js`/`.mjs`/`.cjs` payload module imports (prebuilt files -are opaque to `AB6005`, and `AB7014` scans only those extensions, so an extensionless prebuilt -module counts for nothing), one a packed file loads through a `require`, `createRequire`, or -`import.meta.resolve` call that the compiler does not resolve as a module dependency, one a -consumer-side install script runs, one whose `bin` a packed file executes, or one a -packed declaration file references (`.d.ts` outputs are not walked by `AB6005`), or one a -prebuilt payload names in `runtimeDependencies` — and `AB7014` -reports a declared dependency with none of that evidence, while `AB7015` reports one a consumer's -npm cannot install. +An external kept through the [`tools` hatch](../../reference/configuration.mdx#tools) is therefore +not a way around the contract and never reaches the prepack inventory. + +`AB7014` accepts exactly three kinds of consumer-runtime evidence: + +1. `runtimeDependencies` on a prebuilt payload declared with `definePrebuilt`; +2. a packed declaration file reference, as TypeScript's `preProcessFile` reads it: imports, + exports, import-equals `require`, `import("…")` types, module augmentations in an + external-module file, and `/// ` (including its `@types` twin); an ambient + `declare module` in a file with no imports counts for nothing, while a `#subpath` follows the + package `imports` map; +3. a consumer-side `preinstall`, `install`, or `postinstall` script, including delegated scripts, + that names the dependency or runs one of its `bin` commands. + +The framework's own runtime modules load no package at run time, so there is no framework +process-dependency record to read as a fourth source. + +A package merely inlined into a compiled bundle is not used. When that is the only evidence, +`AB7014` names the `dist` bundles that inlined it, for example: +`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` Move +build-only packages to `devDependencies`; compiled bundles inline their imports (`AB6005`). Keep a +runtime dependency only for what a packed declaration file references, a consumer install script +names or runs, or a prebuilt payload declares in `runtimeDependencies` (`definePrebuilt`). | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest or file, install surface, or README. Pack the generated `dist/` npm root without excluding its files. | -| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild, and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed npm root (including `src/`) or names a file npm omitted. Point a routed CLI at its manifest-declared `bin/.mjs`; point an authored bin at its generated `bin/*.js`. | +| `AB7010` | The dry-run npm inventory omits an expected npm-root file. Pack the generated root without excluding its files. | +| `AB7011` | A copied artifact file in the npm root no longer matches its manifest SHA-256. Rebuild, and do not modify the generated root. | +| `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so `import` evidence comes from prebuilt payload modules and other scripts the framework did not compile, while `require`, `createRequire`, and `import.meta.resolve` calls count from any packed file), no packed declaration references them, no `imports` mapping or consumer install script reaches them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | -| `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | - -For the canonical `dist/` npm root, `prepack` validates the exact input to -`npm publish ./dist`; it therefore always reports `workspace:` and `catalog:` -runtime specifiers, regardless of the package manager that launched the command. - -A prebuilt payload's `runtimeDependencies` declaration counts as use, since the compiler never opens a -payload file. The remaining dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would -publish is lexed for `import` specifiers and scanned for literal `require("…")`, `require.resolve("…")`, and -`import.meta.resolve("…")` calls (a `createRequire(…)` binding counts as `require`, even with the factory -renamed on import, as do direct `Module.createRequire(…)("…")` and `require("node:module").createRequire(…)("…")` calls; `path.resolve("…")` does not count), and every -packed `.d.ts` is scanned for the modules its types reference (a `/// ` directive -counts for the package and its `@types/*` twin); specifiers are reduced to package names, string escapes -decoded first, with Node built-ins ignored. A dependency packed code runs as an executable — a string literal -that is one of the `bin` commands its installed manifest declares, such as `spawnSync("tsc", ["--version"])` — -counts as used, as does one a -`#subpath` import may reach through the `imports` map, and a -dependency a consumer-side `preinstall`/`install`/`postinstall` script — not `prepare`, which npm never runs -for a published tarball — or any script it delegates to with `npm run` (the first positional after `run`; options -before or after `run`, valued or not, are skipped, and later words such as `npm run setup -- dormant` are that -script's arguments) or a direct `npm test`/`start`/`stop`/`restart` names (`npm restart` without a `restart` script running `stop` then `start`) (or whose `bin` command it runs; a -string-form `bin` is named after the installed manifest, read as npm reads it — the last of duplicate keys -wins — and the unscoped name stands in when the dependency is not installed locally or its manifest is not -JSON). A -computed `import(expression)` or `require(expression)` (likewise `require.resolve`, `import.meta.resolve`, -a direct `createRequire(…)(…)`, or a `createRequire` binding) in packed code could load any declared -package, so it withholds `AB7014` altogether; so does packed source the ESM lexer rejects, whose `import()` -calls it cannot report, and so does `require` passed on as a value (`const load = require`, -`fn(require)`) rather than called, since packages may then be loaded under a name the scan never sees. A mention inside a comment can only keep a dependency, never -report one; `devDependencies` are never inspected, and an `npm:` alias counts as a registry specifier when its target does. +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed. Use requires one of the three evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. | +| `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns because npm can continue without it, but escalates to an error when an install script needs the package: its `bin` command appears in command position, a `node_modules//` file is run, or a bare package is preloaded with `-r`/`--require`/`--import`/`--loader` (including through `NODE_OPTIONS`). A peer also present in `dependencies` or `optionalDependencies` is judged by that concrete entry. The canonical root is validated for `npm publish ./dist`, so `workspace:` and `catalog:` are always rejected; packed `bundleDependencies` and installable in-package `file:` sources are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | A release build also refuses a project with **no** release version at all (`AB4013`), so a published artifact never carries the `0.0.0-dev.` development fallback. A diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 9fcdc6fb8..4f6519bfb 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -23,22 +23,29 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。已列入清单的编译证据记录若 与清单文件表不一致,会报告 `AB6039`。 -自包含性首先由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面 -以及包专用入口(手写 bin 或 `lib` 入口)。npm 根目录复用已校验产物中由清单声明的路由式 CLI 及其渲染式 -路由 Flight worker。框架自有的 +自包含性由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面、 +每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口),以及每个 MCP App +视图。框架自有的 `ArtifactDependencyAuditPlugin` 会接入 `thisCompilation`,记录 Rspack 保持外部化的每个模块,服务则在 信任任何产物之前读取这份证据。只有 Node 内建模块(`node:fs`、`fs`)、`pnpapi` 以及同一产物已输出的同级 文件可以保持外部化;其他请求无论被 Rspack 输出成 `import`、`require` 还是其 `createRequire` shim,都会 失败。表达式请求(`import(expr)`、`require(expr)`)不在编译器视野之内:Rslib 的配置不解析它,而是原样保留, 因此它既不会被打包,也不算外部模块。 -输出模块遍历仍作为纵深防线留在编译证据之后。它把每个输出的 JavaScript 模块当作 ES 模块遍历,拒绝残留的 -不受支持导入、非字面量动态 import,以及输出树中缺失或逃逸到树外的目标;`dist` 中的发现以 -`dist/` 点名其文件。模块语法检查的深度取决于它的字节由谁产出。框架编译的模块(清单 kind 为 -`bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,拒绝未终止的字符串、模板、注释与正则以及 -不配对的花括号。框架没有编译的模块——被复制的消费者脚本或独立安装器——会被完整解析;若一次构建的 -[`tools` 逃生口](../../reference/configuration.mdx#tools)可能改写了输出资源,该构建的每个 bundle 也会 -被完整解析。预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定;声明文件(`.d.ts`)不会被遍历。 +已输出模块的遍历仍留在这份编译器证据之后,只负责编译器看不见的部分。它把每个已输出的 `.js`/`.mjs` +模块按 ES 模块读取,并在其中任何一个——包括编译产物——里出现表达式 `import()` 时失败 +(`AB6005 has a non-literal dynamic import`)。被编译证据记录证明的模块——记录以相同字节覆盖的清单 +`bundle` 文件,且构建未使用 [`tools` 逃生舱](../../reference/configuration.mdx#tools)——只做词法 +分析而不完整解析,但它仍带有的每个字面量导入都要对照记录核对:Node 内建模块或该文件记录在案的 +外部依赖通过;其他任何请求都是构建被要求忽略的导入(`rspackIgnore`/`webpackIgnore`——Rspack +原样保留它,没有模块、外部依赖或警告),并以 `AB6005 loads "", which the compiler neither +bundled nor recorded as an external` 失败。摘要匹配只证明字节是编译器输出的,不证明其中每个导入都 +已被解析。其他所有模块都会被完整解析,导入也会对照清单解析——生成的 `install.mjs`、被复制的消费者脚本、 +没有记录的产物中的每个模块,以及逃生舱可能改写了输出资源的构建(记录中的 `coverage.rewritable`) +里的每个模块。这些发现就是 `AB6005` 的残余形态:不受支持或无效的说明符、缺失于或逃逸出输出树的目标、 +无效语法;`dist` 的发现以 `dist/` 命名文件。预构建负载(`kind: 'prebuilt'`)保持不透明、仅做哈希 +锁定,声明文件(`.d.ts`)不会被遍历。 + 路由图会在打包器运行之前守住同一份自包含性: 路由模块、布局或 provider——或它们之一通过相对导入触达的模块——若值导入了携带编译器的框架入口 (`agent-bundle`、`agent-bundle/api`、`agent-bundle/config`、`agent-bundle/eval`、`agent-bundle/rstest`、 @@ -206,28 +213,24 @@ schema 校验它们输出的 JSON 文档,并把宿主的 schema 生成器视 npx agent-bundle prepack --root . --output artifact --json ``` -`prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、清单 -哈希、包 bin 目标、发布版本一致性以及 `package.json` 中的安装期依赖字段把关。构建会把每个依赖内联进 -`dist/bin` 与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 +`prepack` 运行发布构建,在 `dist/` 生成规范 npm 根目录,再从该根目录运行 +`npm pack --dry-run --json --ignore-scripts`。生成的包会原样复制已校验的组合产物,让路由式 +`package.json` bin 指向清单声明的 `bin/.mjs`,并且只额外加入单独编写的 bin 与库。 +门禁会检查这份精确清单、清单哈希、包 bin 目标、发布版本一致性、包专属编译证据以及 +`package.json` 中的安装期依赖字段。构建会把每个依赖内联进手写包入口与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及清单之前报告 `AB6005`。因此 -`dependencies` 只留给打包后的文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后 -文件中未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用、打包后声明文件 -的引用、消费者侧安装脚本,或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然 -可能导入它。`--output` 是相对 -`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。生成的 npm 根目录会移除 -pack 与 publish 生命周期钩子以防止源项目递归,同时保留消费者安装脚本。 -对于 `preinstall`、`install` 与 `postinstall`,单条 `node ` 命令会从作者声明的 `dist/` bin -或库路径重定位到生成根目录,且必须指向已暂存的输出。`AB4768` 会拒绝不受支持的 Node 命令形式以及 -生成根目录中不存在的源码文件;任意项目源码绝不会被复制。由已声明运行时依赖提供的命令保持不变。 -门禁成功后,用 `npm publish ./dist --ignore-scripts` 发布它校验过的准确根目录;任何 npm 生命周期都绝不会执行宿主安装。 - -npm 根目录原样保留产物的 `agent-bundle.compile-evidence.json` 与生成插件字节。包专用的作者 bin -与库另有持久化的 `agent-bundle.package-compile-evidence.json`,其中路径相对于 npm 根目录。 -`prepack` 会以 `AB6039` 对照包专用 `bundle` 清单检查该记录。 +仅仅把包内联进 bundle 并不能证明消费者需要安装它。`--output` 是相对 +`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` +脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 + +消费者的 `preinstall`、`install` 与 `postinstall` 脚本仅在不直接调用 Node,或恰好是一个 +`node ` 命令时保留。源 `dist/` 下已知的手写输出路径会改写到生成根目录,并对照 +已暂存文件校验。不支持的 Node 命令或缺失的源码资源会报告 `AB4768`;包构建器绝不会为了满足生命周期路径 +而复制整棵源码树。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 -CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 +CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`,以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都 内联进同一个 HTML 文件,同一个审计插件也位于它们的 Rsbuild 编译器中:浏览器文档不允许任何 external, @@ -236,34 +239,36 @@ CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以 的 `tools.rsbuild.output.autoExternal`,以及静态可见、 点名某个包的字符串或对象 `externals` 条目。RegExp、函数形式、相对路径与 mutator 添加的 externals 则由编译过程 自身的证据判断:若 Rspack 在宿主包或 `dist` 的生成可执行文件中保持了内建模块、`pnpapi` 或已输出同级文件 -之外的任何 external,`AB6005` 就会失败。输出模块遍历仍留在这项编译期检查之后。通过 +之外的任何 external,`AB6005` 就会失败。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化并不能绕过契约,也永远到不了 prepack -的清单检查。因此,`dependencies` 条目只留给打包后的文件 -有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建 -文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),打包后的 -文件通过未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用加载的包, -消费者侧安装脚本运行的包,打包后的文件执行其 `bin` 的包,打包后的声明文件引用的包 -(`.d.ts` 输出不会被 `AB6005` 遍历),或预构建 payload 在 `runtimeDependencies` 中声明的包——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告 -消费者的 npm 无法安装的依赖。 +的清单检查。 + +`AB7014` 只接受三类消费者运行时证据: + +1. 通过 `definePrebuilt` 声明的预构建 payload 上的 `runtimeDependencies`; +2. TypeScript 的 `preProcessFile` 从打包后声明文件读出的引用:import、export、import-equals + `require`、`import("…")` 类型、外部模块文件中的模块扩充,以及 + `/// `(连同其 `@types` 对应包);没有 import 的文件中的环境式 + `declare module` 不算证据,而 `#子路径` 会经由包的 `imports` 映射解析; +3. 消费者侧 `preinstall`、`install` 或 `postinstall` 脚本(包括其委托的脚本)点名该依赖,或运行其 + `bin` 命令。 + +框架自身的运行时模块在运行时不加载任何包,因此不存在可作为第四类证据读取的框架进程依赖记录。 + +仅仅内联进已编译 bundle 的包不算已使用。当这是唯一证据时,`AB7014` 会点名内联它的 `dist` bundle, +例如:`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` +请把仅构建期使用的包移到 `devDependencies`;编译后的 bundle 会内联其 import(`AB6005`)。仅当打包后的 +声明文件引用、消费者安装脚本点名或运行,或预构建 payload 在 `runtimeDependencies` +(`definePrebuilt`)中声明某个包时,才把它保留为运行时依赖。 | 代码 | 含义 | | --- | --- | -| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请打包生成的 `dist/` npm 根目录,且不要排除其中的文件。 | -| `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | -| `AB7012` | 某个 `package.json` bin 指向了打包后的 npm 根目录之外(包括 `src/`),或指名了一个被 npm 忽略的文件。路由式 CLI 应指向清单声明的 `bin/.mjs`;手写 bin 应指向生成的 `bin/*.js`。 | +| `AB7010` | dry-run 的 npm 清单遗漏了某个预期 npm 根目录文件。请打包生成根目录,且不要排除其中的文件。 | +| `AB7011` | npm 根目录中复制的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成根目录。 | +| `AB7012` | 某个 `package.json` bin 指向 `src/`,或指名了一个被 npm 忽略的文件。路由式 CLI 应指向清单声明的 `bin/.mjs`,手写 bin 应指向生成的 `bin/*.js` 文件。 | | `AB7013` | `package.json`、规范化后的插件元数据、某份宿主清单或产物 provenance 报告了不同的发布版本。请让每处发布标识一致。 | -| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它,也没有任何预构建 payload 在 `runtimeDependencies` 中声明它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | -| `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | - -对于规范的 `dist/` npm 根目录,`prepack` 校验的是 -`npm publish ./dist` 的准确输入;因此无论哪个包管理器启动该命令,运行时依赖中的 -`workspace:` 与 `catalog:` 说明符都会被报告。 - -预构建 payload 的 `runtimeDependencies` 声明视为已使用,因为编译器从不打开 payload 文件。其余依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` -说明符,并扫描字面量 `require("…")`、`require.resolve("…")` 与 `import.meta.resolve("…")` 调用(`createRequire(…)` 的绑定视同 `require`,即使导入时重命名了该工厂,直接调用的 `Module.createRequire(…)("…")` 与 `require("node:module").createRequire(…)("…")` 亦然;`path.resolve("…")` 不计入);每个打包后的 `.d.ts` 会被扫描其类型所引用的模块(`/// ` 指令同时计入该包及其 -`@types/*` 对应包)。说明符先解码字符串转义再归约为包名,忽略 Node 内建模块。打包代码作为可执行文件运行的依赖——字符串字面量正是其已安装清单所声明的某个 `bin` 命令,如 `spawnSync("tsc", ["--version"])`——视为已使用;`#子路径` 导入计入 `imports` 映射所指向的每个包;消费者侧 `preinstall`/`install`/`postinstall` 脚本(不含 `prepare`——npm 从不为已发布的 tarball 运行它)或其通过 `npm run`(取 `run` 之后的第一个位置参数;`run` 前后的选项,带值与否均被跳过,其后的词如 `npm run setup -- dormant` 中的 `dormant` 是该脚本的参数)或直接的 `npm test`/`start`/`stop`/`restart`(没有 `restart` 脚本时,`npm restart` 依次运行 `stop` 与 `start`)委托的任何脚本点名(或运行其 `bin` 命令;字符串形式的 `bin` 以已安装清单的名字命名——清单按 npm 的方式读取,重复键以最后一个为准——依赖未在本地安装或其清单不是 JSON 时以去掉作用域的包名代替)的依赖 -同样视为已使用。打包代码中的计算型 `import(表达式)` 或 `require(表达式)`(同样包括 `require.resolve`、`import.meta.resolve`、直接的 `createRequire(…)(…)` 或 `createRequire` 绑定)可能加载任何已声明的包,因此会整体撤回 `AB7014`;被 ESM 词法分析器拒绝的打包源码亦然,因为其中的 `import()` 调用无法被报告;把 `require` 当作值传递而非调用(`const load = require`、`fn(require)`)亦然,因为此后包可能以扫描看不到的名字被加载。注释中的提及只会保留某个依赖,绝不会报告它;`devDependencies` 永不检查, -`npm:` 别名在其目标是注册表说明符时才视为注册表说明符。 +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段点名了消费者无需安装的包。必须有上列三类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 | +| `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。抓取类的 `optionalDependencies` 项只发出警告,因为 npm 可以在没有它时继续;但消费者安装脚本需要该包时会升级为错误:其 `bin` 命令位于命令位置、运行了 `node_modules//` 文件,或通过 `-r`/`--require`/`--import`/`--loader`(包括 `NODE_OPTIONS`)预加载了裸包。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断。规范根目录按 `npm publish ./dist` 校验,因此 `workspace:` 与 `catalog:` 一律拒绝;已打包的 `bundleDependencies` 与包内可安装的 `file:` 来源不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | 发布构建同样会拒绝**完全没有**发布版本的项目(`AB4013`),因此已发布的产物绝不会携带 `0.0.0-dev.` 这个开发期回退值。声明的 `plugin.version` 与 `package.json` 不一致时会 From 42e482e75d531aabfa18bc7ea6191bafa67210d9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 22:16:38 +0000 Subject: [PATCH 11/19] fix(install): resolve package-root symlinks --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/build/manifest-file.ts | 10 ++++++++-- .../agent-bundle/tests/manifest-relocatable.test.ts | 7 +++++++ website/docs/en/guide/distribution/installation.mdx | 8 ++++---- website/docs/zh/guide/distribution/installation.mdx | 4 ++-- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b276ab597..7bb84e734 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -428,7 +428,7 @@ Validation happens at three moments, all fail-closed: | `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | -| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | +| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), or preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. The canonical root is validated for `npm publish ./dist`, so `workspace:` and `catalog:` are always reported: npm publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`. | `AB7014` counts exactly three sources of evidence that a consumer needs a declared package: diff --git a/packages/agent-bundle/src/build/manifest-file.ts b/packages/agent-bundle/src/build/manifest-file.ts index 94581b99b..3e9837b68 100644 --- a/packages/agent-bundle/src/build/manifest-file.ts +++ b/packages/agent-bundle/src/build/manifest-file.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, realpath } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { errorMessage, isErrno } from '../core/errors.ts'; @@ -29,7 +29,13 @@ export const readArtifactManifest = async (from: string): Promise --from node_modules/`. For Cursor, the npm root also -carries the artifact's standalone `install.mjs`; run it from the installed package root. The -packed host proof reconstructs the manifest-owned artifact from the installed package and exercises +Install any supported host explicitly from that root with +`npx agent-bundle install --from node_modules/`. This manifest-aware path excludes +npm-only metadata and authored package entries from host ownership comparisons. The packed host +proof reconstructs the manifest-owned artifact from the installed package and exercises `agent-bundle install --from` after deleting the source checkout. ## Development installs are a different thing diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 3cb44c449..c9cee37df 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -68,8 +68,8 @@ npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI, `node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 绝不会改动宿主的插件状态。 -请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装 Claude 或 -Codex 集成。对于 Cursor,npm 根目录还包含产物的独立 `install.mjs`;请从已安装包的根目录运行它。 +请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装任一受支持宿主。 +这条清单感知路径会把 npm 专属元数据与手写包入口排除在宿主归属比较之外。 打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 `agent-bundle install --from`。 From 6effcf8f7b9168edbaa5dfa5f8ae0939077af5fe Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 22:32:25 +0000 Subject: [PATCH 12/19] docs(distribution): clarify canonical publish boundaries --- docs/entry-conventions.md | 6 +++--- website/docs/en/guide/distribution/index.mdx | 2 ++ website/docs/en/guide/distribution/installation.mdx | 4 +++- website/docs/en/guide/distribution/validation.mdx | 5 +++-- website/docs/zh/guide/distribution/index.mdx | 1 + website/docs/zh/guide/distribution/installation.mdx | 4 +++- website/docs/zh/guide/distribution/validation.mdx | 4 ++-- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 44d58c83e..cc38edb42 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -32,9 +32,9 @@ package-only entries: gates the exact package/artifact inventory, manifest hashes, package bin targets, and release-version agreement. With no `--output`, prepack uses configured `output.distPath` when present and otherwise writes the plugin - root to `artifact/`, leaving the package build in `dist/`. Use it as an npm `prepack` - script; `--ignore-scripts` prevents recursion and npm install never runs the - host installer. + root to `artifact/`, leaving the package build in `dist/`. Run it directly + (the templates expose `pack:check`), then publish the generated root with + `npm publish ./dist --ignore-scripts`. - The package build runs for `agent-bundle build` (CLI, or `build({ packageOutputs: true })` through the API) and inside the `agent-bundle dev` rebuild loop (see “Dev-watch of the package build” diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 05510ee04..ac41160ca 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -84,6 +84,8 @@ A project that also ships as an npm package has a second output, `dist/`, but it plugin build. `dist/` is the npm package root: it contains the complete composite artifact, `package.json`, the standard package documentation files, and any package-only library or authored-bin entries described in [Package entries](../authoring/package-entries.mdx). +`validate --artifact` and other artifact-only commands still take the bare composite artifact; +the manifest-aware `install --from` path accepts the npm root. For a generated routed CLI, the installed package's `bin` points directly at `bin/.mjs`, copied unchanged from the validated artifact. The npm CLI therefore has the diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 9959601a3..6429a1653 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -77,7 +77,9 @@ ship without the installer it promises. ## The npm-installed CLI -The npm package root contains the composite plugin root itself. For a generated routed CLI, +The npm package root contains every manifest-owned composite-plugin byte plus npm-only metadata +and authored package entries. Commands such as `validate --artifact` still take the bare composite +artifact; `install --from` is manifest-aware and accepts either root. For a generated routed CLI, `package.json` points its `bin` at the same `bin/.mjs` executable the artifact exposes, without a second compilation or wrapper. Its complete command surface, including `web`, therefore works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index a4cf6b336..2e17f6400 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -257,8 +257,9 @@ declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of a bare package external: the compiler reports `AB6005` before `prepack` reaches the inventory. So bundling a package does not prove that consumers need it installed. `--output` is an artifact path relative to `--root` that overrides the configured -`output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` -prevents recursion, and no npm lifecycle ever performs a host install. +`output.distPath`, defaulting to `artifact`. Run the gate directly (generated projects expose +`pack:check`), then publish the generated root with `npm publish ./dist --ignore-scripts`. +No npm lifecycle ever performs a host install. Consumer `preinstall`, `install`, and `postinstall` scripts are preserved only when they do not invoke Node directly, or when they are one `node ` command. Known authored diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 975d533a1..2972b67bc 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -71,6 +71,7 @@ Claude 与 Codex 投影始终包含本地市场清单,正是这一点让它们 同时作为 npm 包发布的项目还有第二份输出 `dist/`,但它不是第二次插件构建。`dist/` 就是 npm 包根目录: 它包含完整的组合产物、`package.json`、标准包文档,以及[包入口](../authoring/package-entries.mdx)所述的 包专用库入口或手写 bin。 +`validate --artifact` 等仅面向产物的命令仍应接收纯组合产物;清单感知的 `install --from` 路径则接受 npm 根目录。 对于生成的路由式 CLI,已安装包的 `bin` 直接指向 `bin/.mjs`;该文件从已校验产物原样复制。因此 npm CLI 与产物 CLI 拥有完全相同的命令(包括 `web`)和完全相同的字节。tarball 中没有嵌套的 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index c9cee37df..ac85b6bfb 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -63,7 +63,9 @@ node ./install.mjs ## npm 安装的 CLI -npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI,`package.json` 的 `bin` 指向产物公开的同一个 +npm 包根目录包含组合插件清单拥有的全部字节,并额外带有 npm 专属元数据与手写包入口。 +`validate --artifact` 等命令仍接收纯组合产物;清单感知的 `install --from` 接受任一根目录。 +对于生成的路由式 CLI,`package.json` 的 `bin` 指向产物公开的同一个 `bin/.mjs` 可执行文件,不会再次编译,也没有包装器。因此它的完整命令面(包括 `web`)从 `node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 绝不会改动宿主的插件状态。 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 4f6519bfb..4adebe52f 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -221,8 +221,8 @@ npx agent-bundle prepack --root . --output artifact --json `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及清单之前报告 `AB6005`。因此 仅仅把包内联进 bundle 并不能证明消费者需要安装它。`--output` 是相对 -`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` -脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 +`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。请直接运行门禁(生成项目提供 +`pack:check`),再用 `npm publish ./dist --ignore-scripts` 发布生成根目录。任何 npm 生命周期都绝不会执行宿主安装。 消费者的 `preinstall`、`install` 与 `postinstall` 脚本仅在不直接调用 Node,或恰好是一个 `node ` 命令时保留。源 `dist/` 下已知的手写输出路径会改写到生成根目录,并对照 From 78907be4d00d7feb60d41fe4069f813392f6ca21 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 22:35:36 +0000 Subject: [PATCH 13/19] test(manifest): canonicalize symlink expectations --- packages/agent-bundle/tests/manifest-relocatable.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/tests/manifest-relocatable.test.ts b/packages/agent-bundle/tests/manifest-relocatable.test.ts index 4c172f5a8..429b94a13 100644 --- a/packages/agent-bundle/tests/manifest-relocatable.test.ts +++ b/packages/agent-bundle/tests/manifest-relocatable.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; @@ -230,6 +230,7 @@ it('emits a relocatable manifest that survives moving the composite root', async const moved = join(destParent, 'nested', 'moved-artifact'); await mkdir(dirname(moved), { recursive: true }); await rename(artifactRoot, moved); + const canonicalMoved = await realpath(moved); expect(await validateArtifact({ artifactRoot: moved })).toEqual([]); const read = await readArtifactManifest(moved); @@ -242,13 +243,13 @@ it('emits a relocatable manifest that survives moving the composite root', async await mkdir(dirname(linked), { recursive: true }); await symlink(moved, linked, 'dir'); const linkedRead = await readArtifactManifest(linked); - expect(linkedRead).toMatchObject({ root: moved, status: 'ok' }); + expect(linkedRead).toMatchObject({ root: canonicalMoved, status: 'ok' }); const registry = createDefaultRegistry(); for (const host of identityHosts) { const identity = await readBundleIdentity(moved, host); - expect(identity.bundleRoot).toBe(moved); - expect((await readBundleIdentity(linked, host)).bundleRoot).toBe(moved); + expect(identity.bundleRoot).toBe(canonicalMoved); + expect((await readBundleIdentity(linked, host)).bundleRoot).toBe(canonicalMoved); for (const document of Object.values(identity.documents)) { expect(isSafeRelativePosix(document)).toBe(true); const resolved = resolve(moved, document); From b37a5b40ed989c9461a9830907fe99bbf34244f3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 23:11:52 +0000 Subject: [PATCH 14/19] test(pack): isolate doctor host commands --- .../tests/support/host-install.ts | 58 ++++++++++++------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index d79d5873e..1247e42e3 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -38,7 +38,7 @@ import { DEV_INSTALL_MARKER, DevHostInstallManager } from '../../src/dev/host-in import { ProjectEventHub } from '../../src/dev/events.ts'; import type { ArtifactEpoch } from '../../src/dev/types.ts'; import { startDevServer } from '../../src/dev/workbench-server.ts'; -import { runDoctor } from '../../src/install/doctor.ts'; +import { runDoctor, type DoctorCommandRunner } from '../../src/install/doctor.ts'; import { installBundle, type InstallHost } from '../../src/install/install.ts'; import { readInstallReceipt } from '../../src/install/receipt.ts'; import { @@ -420,6 +420,20 @@ const run = async ( } }; +const doctorCommandRunner = (environment: Readonly): DoctorCommandRunner => + async (request) => { + const result = await run(request.executable, request.args, { + cwd: request.cwd, + environment: isolatedEnvironment(environment, request.environment ?? {}), + }); + return Object.freeze({ + exitCode: result.exitCode, + signal: null, + stderr: result.stderr, + stdout: result.stdout, + }); + }; + const runNodeCli = ( fixture: BuiltHostInstallFixture, args: readonly string[], @@ -963,6 +977,17 @@ export const runClaudeHostInstallProof = async ( assertProof(/\bSkills\s+\(1\)/iu.test(details.stdout), 'Claude component inventory did not report Skills (1).'); assertProof(/\bHooks\s+\(1\)/iu.test(details.stdout), 'Claude component inventory did not report Hooks (1).'); assertProof(/\bMCP servers\s+\(1\)/iu.test(details.stdout), 'Claude component inventory did not report MCP servers (1).'); + const doctor = await runDoctor({ + commandRunner: doctorCommandRunner(environment), + environment, + from: fixture.artifactRoot, + home, + hosts: ['claude'], + }); + assertProof( + doctor.hosts.find((entry) => entry.host === 'claude')?.bundle?.comparison?.status === 'current', + `Doctor did not report the installed Claude npm root as current: ${JSON.stringify(doctor.diagnostics)}`, + ); const skillPath = join(expectedInstallPath, 'skills', 'probe', 'SKILL.md'); await access(skillPath).catch(() => fail('Claude cache did not contain skills/probe/SKILL.md.')); @@ -976,16 +1001,6 @@ export const runClaudeHostInstallProof = async ( }, installedRoot: expectedInstallPath, }); - const doctor = await runDoctor({ - environment, - from: fixture.artifactRoot, - home, - hosts: ['claude'], - }); - assertProof( - doctor.hosts.find((entry) => entry.host === 'claude')?.bundle?.comparison?.status === 'current', - `Doctor did not report the installed Claude npm root as current: ${JSON.stringify(doctor.diagnostics)}`, - ); return Object.freeze({ host: 'claude', install: Object.freeze({ sameVersionRebuild, state: 'installed', version }), @@ -1102,17 +1117,8 @@ export const runCodexHostInstallProof = async ( `Codex installed plugin manifest interface did not advertise ${capability}.`, ); } - const sameVersionRebuild = await proveSameVersionRebuild({ - bundle: fixture.bundles.codex, - host: 'codex', - install: async () => { - const result = await runInstallCommand(fixture, 'codex', fixture.bundles.codex, { ...options, environment }); - assertProof(result.exitCode === 0, `Codex same-version reinstall failed: ${commandDetail(result)}`); - return parseJson(result.stdout, 'Codex reinstall'); - }, - installedRoot: cachePath, - }); const doctor = await runDoctor({ + commandRunner: doctorCommandRunner(environment), environment, from: fixture.artifactRoot, home, @@ -1122,6 +1128,16 @@ export const runCodexHostInstallProof = async ( doctor.hosts.find((entry) => entry.host === 'codex')?.bundle?.comparison?.status === 'current', `Doctor did not report the installed Codex npm root as current: ${JSON.stringify(doctor.diagnostics)}`, ); + const sameVersionRebuild = await proveSameVersionRebuild({ + bundle: fixture.bundles.codex, + host: 'codex', + install: async () => { + const result = await runInstallCommand(fixture, 'codex', fixture.bundles.codex, { ...options, environment }); + assertProof(result.exitCode === 0, `Codex same-version reinstall failed: ${commandDetail(result)}`); + return parseJson(result.stdout, 'Codex reinstall'); + }, + installedRoot: cachePath, + }); return Object.freeze({ host: 'codex', From 02370d4b0f4c4662be0c9c66ae32432307fabf13 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:30:45 +0000 Subject: [PATCH 15/19] feat: complete manifest-driven npm distribution --- docs/diagnostics.md | 2 +- .../schemas/agent-bundle.manifest.schema.json | 4 +- .../agent-bundle/src/build/package-build.ts | 2 +- packages/agent-bundle/src/install/surface.ts | 44 +- .../agent-bundle/src/web-host/manifest.ts | 2 +- .../tests/application-explorer.test.ts | 2 +- .../tests/artifact-inspection-service.test.ts | 2 +- .../agent-bundle/tests/build-compose.test.ts | 2 +- packages/agent-bundle/tests/build.test.ts | 2 +- packages/agent-bundle/tests/cli.test.ts | 2 +- .../tests/fixtures/manifest-keys.v3.json | 493 ++++++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 2 +- .../tests/install-surface.test.ts | 50 ++ .../tests/manifest-schema.test.ts | 10 +- packages/agent-bundle/tests/manifest.test.ts | 2 +- .../tests/mcp-probe-service.test.ts | 2 +- .../agent-bundle/tests/package-build.test.ts | 3 + .../tests/packed-host-install-proof.test.ts | 74 ++- .../tests/support/install-fixture.ts | 2 +- .../agent-bundle/tests/web-command.test.ts | 2 +- .../tests/web-host-routes-unit.test.ts | 2 +- .../agent-bundle/tests/web-manifest.test.ts | 6 +- .../docs/en/guide/concepts/architecture.mdx | 9 +- .../en/guide/distribution/installation.mdx | 7 +- .../docs/en/guide/distribution/validation.mdx | 4 +- .../docs/en/reference/artifact-manifest.mdx | 17 +- .../docs/en/reference/targets-artifacts.mdx | 6 +- .../docs/zh/guide/concepts/architecture.mdx | 9 +- .../zh/guide/distribution/installation.mdx | 4 + .../docs/zh/guide/distribution/validation.mdx | 3 +- .../docs/zh/reference/artifact-manifest.mdx | 17 +- .../docs/zh/reference/targets-artifacts.mdx | 6 +- 32 files changed, 725 insertions(+), 69 deletions(-) create mode 100644 packages/agent-bundle/tests/fixtures/manifest-keys.v3.json diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7bb84e734..c44cc5429 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1904,7 +1904,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | +| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 3` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json index 2570809f9..3d5108489 100644 --- a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json", "title": "agent-bundle artifact manifest", - "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 2). The public contract and the operational `compiler` record are versioned independently (`compiler.recordVersion`). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `compiler.provenance` paths equal `files` paths, `compiler.adapters` hosts equal `projections` hosts, `compiler.project.revision` and `compiler.project.configDigest` match `compiler.project.sourceInputs`, each provenance `sourceInput` names a `compiler.project.sourceInputs` path, `compiler.validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `compiler.project.packageName` and `compiler.project.packageVersion`.", + "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 3). The public contract and the operational `compiler` record are versioned independently (`compiler.recordVersion`). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `compiler.provenance` paths equal `files` paths, `compiler.adapters` hosts equal `projections` hosts, `compiler.project.revision` and `compiler.project.configDigest` match `compiler.project.sourceInputs`, each provenance `sourceInput` names a `compiler.project.sourceInputs` path, `compiler.validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `compiler.project.packageName` and `compiler.project.packageVersion`.", "type": "object", "additionalProperties": false, "properties": { @@ -27,7 +27,7 @@ "uniqueItems": true }, "manifestVersion": { - "const": 2 + "const": 3 }, "projections": { "type": "array", diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index d4a3558f8..a59170131 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -353,7 +353,7 @@ const copyStandardPackageFiles = async ( stageRoot: string, ): Promise> => { const sources = new Map(); - for (const name of ['README.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']) { + for (const name of ['README.md', 'AGENTS.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']) { const source = join(projectRoot, name); if (!existsSync(source)) continue; const destination = join(stageRoot, name); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 678ee8ccd..0d60c040a 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -368,7 +368,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' ? undefined', ' : isAbsolute(inherited) ? resolve(inherited) : resolve(canonical, inherited);', ' let declared;', - " for (const manifest of ['.cursor-plugin/mcp.json', 'mcp.json']) {", + " for (const manifest of declaredDocument('mcp') === undefined ? ['.cursor-plugin/mcp.json', 'mcp.json'] : [declaredDocument('mcp')]) {", ' let document;', " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", @@ -763,6 +763,19 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const isPreservedRoot = (name) => preservedEntries.includes(String(name).toLowerCase());', `const markerFiles = ${JSON.stringify(installSurfaceMarkerFiles)};`, "const source = resolve(fileURLToPath(new URL('.', import.meta.url)));", + 'const artifactManifest = await (async () => {', + ' try {', + " const value = JSON.parse(await readFile(join(source, 'agent-bundle.manifest.json'), 'utf8'));", + " if (value === null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.projections)) {", + " throw new Error('agent-bundle.manifest.json has no projections array.');", + ' }', + ' return value;', + " } catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; }", + '})();', + "const installProjection = artifactManifest?.projections.find((projection) => projection?.builtInHost === 'cursor') ??", + " artifactManifest?.projections.find((projection) => projection?.builtInHost === 'portable');", + "const declaredDocument = (name) => typeof installProjection?.documents?.[name] === 'string' ? installProjection.documents[name] : undefined;", + "const cursorPluginDocument = declaredDocument('plugin') ?? '.cursor-plugin/plugin.json';", "const cursorRoot = join(homedir(), '.cursor');", "const installRoot = join(cursorRoot, 'plugins', 'local');", 'const destination = join(installRoot, pluginName);', @@ -1013,7 +1026,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '};', '', 'const readManifest = async (root) => {', - " for (const manifest of ['.cursor-plugin/plugin.json', 'plugin.json']) {", + " for (const manifest of declaredDocument('plugin') === undefined ? ['.cursor-plugin/plugin.json', 'plugin.json'] : [declaredDocument('plugin')]) {", ' try {', " const value = JSON.parse(await readFile(join(root, manifest), 'utf8'));", " if (value !== null && typeof value === 'object' && typeof value.name === 'string') {", @@ -1037,6 +1050,14 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// forms itself in the Cursor copy of mcp.json and records the expansion in the receipt (provenance: derived).', "const agentPluginsSchemaPrefix = 'https://agent-plugins.org/schemas/';", 'const isAgentPluginsPack = async (root) => {', + ' if (artifactManifest !== undefined) {', + " const plugin = declaredDocument('plugin');", + " if (plugin === undefined) throw new Error('agent-bundle.manifest.json has no Cursor-compatible plugin document.');", + ' try {', + " const manifest = JSON.parse(await readFile(join(root, plugin), 'utf8'));", + " return manifest !== null && typeof manifest === 'object' && typeof manifest.$schema === 'string' && manifest.$schema.startsWith(agentPluginsSchemaPrefix);", + " } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return false; throw error; }", + ' }', " if (await exists(join(root, '.cursor-plugin', 'plugin.json'))) return false;", ' try {', " const manifest = JSON.parse(await readFile(join(root, 'plugin.json'), 'utf8'));", @@ -1078,14 +1099,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const expansion = await (async () => {', ' if (!agentPluginsPack) return undefined;', ' let text;', - " try { text = await readFile(join(source, 'mcp.json'), 'utf8'); }", + " const mcpDocument = declaredDocument('mcp') ?? 'mcp.json';", + " try { text = await readFile(join(source, mcpDocument), 'utf8'); }", " catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return undefined; throw error; }", ' const expanded = expandAgentPluginsMcp(text);', - " return expanded === undefined ? undefined : { documents: { 'mcp.json': text }, expanded, pluginData, pluginRoot: destination };", + ' return expanded === undefined ? undefined : { documents: { [mcpDocument]: text }, expanded, mcpDocument, pluginData, pluginRoot: destination };', '})();', '// The Cursor copy differs from the bundle in exactly that one file; the artifact inventory hashes the expanded', '// form so receipts, no-op reruns and replacement compare the bundle with what the copy must hold.', - "const cursorTransform = expansion === undefined ? undefined : (relativePath, bytes) => relativePath === 'mcp.json' ? Buffer.from(expansion.expanded, 'utf8') : bytes;", + 'const cursorTransform = expansion === undefined ? undefined : (relativePath, bytes) => relativePath === expansion.mcpDocument ? Buffer.from(expansion.expanded, "utf8") : bytes;', '', '// directories: the ones the installer created (all of them on a fresh install); only those are ever pruned.', '// hostDirectories: the ones created under ~/.cursor on the way to the plugin root; installedAt carries over from a', @@ -1127,7 +1149,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const stateLocations = async () => {', ' const canonical = await realpath(destination);', ' let servers = [];', - " for (const manifest of ['.cursor-plugin/mcp.json', 'mcp.json']) {", + " for (const manifest of declaredDocument('mcp') === undefined ? ['.cursor-plugin/mcp.json', 'mcp.json'] : [declaredDocument('mcp')]) {", ' let document;', " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", @@ -1255,7 +1277,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return relativePath === '' || content.has(toPosix(relativePath));", ' };', ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', - " if (expansion !== undefined) await writeFile(join(root, 'mcp.json'), expansion.expanded, 'utf8');", + " if (expansion !== undefined) await writeFile(join(root, expansion.mcpDocument), expansion.expanded, 'utf8');", ' const staged = await inventory(root);', " await writeFile(join(root, receiptFile), receiptFor(staged, receiptOptions), 'utf8');", ' return { inventory: staged, parent, root };', @@ -1331,13 +1353,13 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' }));', '};', '', - "if (mode === 'marketplace' && !(await exists(join(source, '.cursor-plugin', 'plugin.json')))) {", + "if (mode === 'marketplace' && (cursorPluginDocument !== '.cursor-plugin/plugin.json' || !(await exists(join(source, cursorPluginDocument))))) {", " throw new Error('--mode marketplace requires a Cursor Plugin (.cursor-plugin/plugin.json); Cursor marketplaces resolve plugins//.cursor-plugin/plugin.json. This bundle is an Agent Plugins (root plugin.json) pack: use the default local mode.');", '}', '// Owner and description come from the emitted .cursor-plugin/plugin.json, exactly as `agent-bundle install', '// cursor --mode marketplace` derives them, so both entry points stage byte-identical marketplace manifests.', 'const marketplaceManifestFor = async () => {', - " const manifest = JSON.parse(await readFile(join(source, '.cursor-plugin', 'plugin.json'), 'utf8'));", + " const manifest = JSON.parse(await readFile(join(source, cursorPluginDocument), 'utf8'));", " const owner = typeof manifest?.author?.name === 'string' ? manifest.author.name : pluginName;", " const description = typeof manifest?.description === 'string' ? { description: manifest.description } : {};", ' // Entry fields are limited to the pinned Cursor marketplace schema (name/source/description).', @@ -1384,7 +1406,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await mkdir(marketplaceRoot, { recursive: true });', ' if (await exists(marketplaceRepo)) {', ' let stagedVersion;', - " try { stagedVersion = JSON.parse(await readFile(join(marketplacePlugin, '.cursor-plugin/plugin.json'), 'utf8')).version; }", + ' try { stagedVersion = JSON.parse(await readFile(join(marketplacePlugin, cursorPluginDocument), "utf8")).version; }', " catch (error) { if (error?.code !== 'ENOENT') throw error; }", ' if (stagedVersion !== undefined && stagedVersion !== pluginVersion) {', ' throw new Error(`Refusing version collision at ${marketplaceRepo}: found ${stagedVersion}, requested ${pluginVersion}.`);', @@ -1455,7 +1477,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'if (expansion !== undefined) await mkdir(pluginData, { recursive: true });', 'const reportExpansion = () => {', ' if (expansion === undefined) return;', - ' console.log(`Expanded Agent Plugins placeholders for Cursor in mcp.json: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData} (Cursor does not expand them; recorded in ${receiptFile})`);', + ' console.log(`Expanded Agent Plugins placeholders for Cursor in ${expansion.mcpDocument}: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData} (Cursor does not expand them; recorded in ${receiptFile})`);', '};', '', 'if (!(await exists(destination))) {', diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts index c63a40067..db7edc96f 100644 --- a/packages/agent-bundle/src/web-host/manifest.ts +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -14,7 +14,7 @@ import { pathTokens } from '../core/types.ts'; * declared here so the lean web reader bundled into generated bins and the * full parser in `build/manifest.ts` refuse the same set of documents. */ -export const artifactManifestVersion = 2; +export const artifactManifestVersion = 3; export const artifactManifestName = 'agent-bundle.manifest.json'; diff --git a/packages/agent-bundle/tests/application-explorer.test.ts b/packages/agent-bundle/tests/application-explorer.test.ts index 0d99d9716..24951022a 100644 --- a/packages/agent-bundle/tests/application-explorer.test.ts +++ b/packages/agent-bundle/tests/application-explorer.test.ts @@ -123,7 +123,7 @@ const manifest = (): ArtifactManifest => ({ }, }, files: [], - manifestVersion: 2, + manifestVersion: 3, projections: [ { documents: { mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index b4a73e766..137372555 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -286,7 +286,7 @@ const manifestFor = ( scripts, }, files: manifestFiles, - manifestVersion: 2, + manifestVersion: 3, projections: [projection], routes: { digest: 'e'.repeat(64), diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index ca930e306..3fc3f45b0 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -245,7 +245,7 @@ describe('composite plugin root (#555)', () => { it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { const { output, result } = await buildFixture(undefined); - expect(result.build.manifest.manifestVersion).toBe(2); + expect(result.build.manifest.manifestVersion).toBe(3); expect(result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); expect(result.build.manifest.projections[0]!.documents.plugin).toBe('plugin.json'); expect(result.build.manifest.projections[0]!.documents.mcp).toBe('mcp.json'); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 996ba8dfd..ce1b6a92f 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -390,7 +390,7 @@ it('low-level build writes and returns the exact canonical manifest for a config source: { status: 'passed' }, }, }, - manifestVersion: 2, + manifestVersion: 3, projections: [expect.objectContaining({ host: 'portable' })], runtime: { node: '22.12.0' }, }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index e9c5d5d45..e65aba422 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -619,7 +619,7 @@ it('includes a built-manifest summary on inspect --json after a build, and omits expect(JSON.parse(after.stdout).output.manifest).toMatchObject({ application: { id: 'plugin:cli-fixture', name: 'cli-fixture', version: '1.0.0' }, executables: { bins: [], hooks: 0, mcpServers: [], scripts: [] }, - manifestVersion: 2, + manifestVersion: 3, projections: [{ host: 'codex' }, { host: 'portable' }], }); expect(JSON.parse(after.stdout).output.manifest.path).toMatch(/agent-bundle\.manifest\.json$/u); diff --git a/packages/agent-bundle/tests/fixtures/manifest-keys.v3.json b/packages/agent-bundle/tests/fixtures/manifest-keys.v3.json new file mode 100644 index 000000000..03b621b4d --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/manifest-keys.v3.json @@ -0,0 +1,493 @@ +{ + "enums": { + "distribution.channels": [ + [ + "local", + "npm" + ], + [ + "local" + ] + ], + "distribution.channels[]": [ + "local", + "npm" + ], + "executables.hooks[].kind": [ + "config", + "event-route" + ], + "executables.mcpServers[].apps[].prebuilt": [ + true + ], + "executables.mcpServers[].kind": [ + "command", + "compiled", + "prebuilt", + "remote" + ], + "executables.mcpServers[].launch.args[].kind": [ + "artifact", + "literal" + ], + "executables.scripts[].mode": [ + "bundle", + "copy" + ], + "files[].kind": [ + "bundle", + "copy", + "generated", + "prebuilt" + ], + "manifestVersion": [ + 3 + ], + "projections[].builtInHost": [ + "claude", + "codex", + "cursor", + "portable" + ], + "routes.cli.commands[].exitCode": [ + "result", + "zero" + ], + "routes.cli.commands[].options[].kind": [ + "boolean", + "enum", + "number", + "string" + ], + "routes.cli.mode": [ + "conflict", + "conventional", + "generated" + ], + "routes.cli.routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.cli.routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.cli.routes[].inputSchema.additionalProperties": [ + false + ], + "routes.cli.routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.type": [ + "object" + ], + "routes.cli.routes[].kind": [ + "cli", + "tool" + ], + "routes.cli.routes[].provenance.kind": [ + "conventional" + ], + "routes.contracts[].input.additionalProperties": [ + false + ], + "routes.contracts[].input.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.contracts[].input.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.contracts[].input.type": [ + "object" + ], + "routes.events[].execution.fallback": [ + "none", + "standalone" + ], + "routes.events[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.events[].inputSchema.additionalProperties": [ + false + ], + "routes.events[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.type": [ + "object" + ], + "routes.events[].kind": [ + "event-route" + ], + "routes.events[].provenance.kind": [ + "conventional" + ], + "routes.layouts[].scope": [ + "root", + "server" + ], + "routes.scripts[].execution.fallback": [ + "none", + "standalone" + ], + "routes.scripts[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.scripts[].inputSchema.additionalProperties": [ + false + ], + "routes.scripts[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.type": [ + "object" + ], + "routes.scripts[].kind": [ + "script" + ], + "routes.scripts[].provenance.kind": [ + "conventional" + ], + "routes.servers[].mode": [ + "command", + "conflict", + "custom", + "generated", + "remote" + ], + "routes.servers[].routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.servers[].routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.servers[].routes[].inputSchema.additionalProperties": [ + false + ], + "routes.servers[].routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.type": [ + "object" + ], + "routes.servers[].routes[].kind": [ + "app", + "prompt", + "resource", + "tool" + ], + "routes.servers[].routes[].provenance.kind": [ + "conventional" + ], + "web.apps[].allow[]": [ + "call-tool", + "download-file", + "open-external-link", + "request-display-mode" + ], + "web.open": [ + "browser", + "never" + ] + }, + "keys": [ + "application", + "application.description", + "application.id", + "application.name", + "application.version", + "compiler", + "distribution", + "distribution.channels", + "distribution.install", + "distribution.install.instructions", + "distribution.install.script", + "distribution.payloads", + "distribution.payloads[].hosts", + "distribution.payloads[].name", + "distribution.payloads[].runtimeDependencies", + "executables", + "executables.bins", + "executables.bins[].hosts", + "executables.bins[].name", + "executables.bins[].path", + "executables.bins[].worker", + "executables.hooks", + "executables.hooks[].event", + "executables.hooks[].host", + "executables.hooks[].id", + "executables.hooks[].kind", + "executables.hooks[].name", + "executables.hooks[].path", + "executables.hooks[].routeId", + "executables.hooks[].timeout", + "executables.mcpServers", + "executables.mcpServers[].apps", + "executables.mcpServers[].apps[].id", + "executables.mcpServers[].apps[].name", + "executables.mcpServers[].apps[].path", + "executables.mcpServers[].apps[].prebuilt", + "executables.mcpServers[].apps[].resourceUri", + "executables.mcpServers[].hosts", + "executables.mcpServers[].id", + "executables.mcpServers[].kind", + "executables.mcpServers[].launch", + "executables.mcpServers[].launch.args", + "executables.mcpServers[].launch.args[].kind", + "executables.mcpServers[].launch.args[].path", + "executables.mcpServers[].launch.args[].value", + "executables.mcpServers[].launch.entry", + "executables.mcpServers[].launch.env", + "executables.mcpServers[].launch.worker", + "executables.mcpServers[].name", + "executables.mcpServers[].transport", + "executables.scripts", + "executables.scripts[].hosts", + "executables.scripts[].id", + "executables.scripts[].mode", + "executables.scripts[].name", + "executables.scripts[].path", + "executables.scripts[].rendered", + "executables.scripts[].rendered.routeId", + "executables.scripts[].worker", + "files", + "files[].bytes", + "files[].kind", + "files[].mode", + "files[].path", + "files[].sha256", + "manifestVersion", + "projections", + "projections[].builtInHost", + "projections[].documents", + "projections[].documents.hooks", + "projections[].documents.marketplace", + "projections[].documents.mcp", + "projections[].documents.plugin", + "projections[].host", + "projections[].marketplace", + "projections[].marketplace.name", + "routes", + "routes.cli", + "routes.cli.commands", + "routes.cli.commands[].aliases", + "routes.cli.commands[].description", + "routes.cli.commands[].exitCode", + "routes.cli.commands[].mcp", + "routes.cli.commands[].mcp.confirm", + "routes.cli.commands[].mcp.server", + "routes.cli.commands[].mcp.tool", + "routes.cli.commands[].options", + "routes.cli.commands[].options[].aliases", + "routes.cli.commands[].options[].choices", + "routes.cli.commands[].options[].description", + "routes.cli.commands[].options[].key", + "routes.cli.commands[].options[].kind", + "routes.cli.commands[].options[].option", + "routes.cli.commands[].options[].positional", + "routes.cli.commands[].options[].repeated", + "routes.cli.commands[].options[].required", + "routes.cli.commands[].path", + "routes.cli.commands[].projection", + "routes.cli.commands[].projection.defaults", + "routes.cli.commands[].projection.mapInput", + "routes.cli.commands[].projection.module", + "routes.cli.commands[].projection.relaxed", + "routes.cli.commands[].routeId", + "routes.cli.mode", + "routes.cli.routes", + "routes.cli.routes[].contract", + "routes.cli.routes[].description", + "routes.cli.routes[].event", + "routes.cli.routes[].execution", + "routes.cli.routes[].execution.fallback", + "routes.cli.routes[].execution.preflight", + "routes.cli.routes[].execution.providers", + "routes.cli.routes[].execution.runtime", + "routes.cli.routes[].id", + "routes.cli.routes[].inputSchema", + "routes.cli.routes[].inputSchema.additionalProperties", + "routes.cli.routes[].inputSchema.properties", + "routes.cli.routes[].inputSchema.properties.*.default", + "routes.cli.routes[].inputSchema.properties.*.description", + "routes.cli.routes[].inputSchema.properties.*.enum", + "routes.cli.routes[].inputSchema.properties.*.items", + "routes.cli.routes[].inputSchema.properties.*.items.enum", + "routes.cli.routes[].inputSchema.properties.*.items.type", + "routes.cli.routes[].inputSchema.properties.*.type", + "routes.cli.routes[].inputSchema.required", + "routes.cli.routes[].inputSchema.type", + "routes.cli.routes[].kind", + "routes.cli.routes[].provenance", + "routes.cli.routes[].provenance.kind", + "routes.cli.routes[].serverId", + "routes.cli.routes[].source", + "routes.contracts", + "routes.contracts[].id", + "routes.contracts[].input", + "routes.contracts[].input.additionalProperties", + "routes.contracts[].input.properties", + "routes.contracts[].input.properties.*.default", + "routes.contracts[].input.properties.*.description", + "routes.contracts[].input.properties.*.enum", + "routes.contracts[].input.properties.*.items", + "routes.contracts[].input.properties.*.items.enum", + "routes.contracts[].input.properties.*.items.type", + "routes.contracts[].input.properties.*.type", + "routes.contracts[].input.required", + "routes.contracts[].input.type", + "routes.contracts[].origin", + "routes.contracts[].origin.binding", + "routes.contracts[].origin.module", + "routes.contracts[].routes", + "routes.digest", + "routes.events", + "routes.events[].contract", + "routes.events[].description", + "routes.events[].event", + "routes.events[].execution", + "routes.events[].execution.fallback", + "routes.events[].execution.preflight", + "routes.events[].execution.providers", + "routes.events[].execution.runtime", + "routes.events[].id", + "routes.events[].inputSchema", + "routes.events[].inputSchema.additionalProperties", + "routes.events[].inputSchema.properties", + "routes.events[].inputSchema.properties.*.default", + "routes.events[].inputSchema.properties.*.description", + "routes.events[].inputSchema.properties.*.enum", + "routes.events[].inputSchema.properties.*.items", + "routes.events[].inputSchema.properties.*.items.enum", + "routes.events[].inputSchema.properties.*.items.type", + "routes.events[].inputSchema.properties.*.type", + "routes.events[].inputSchema.required", + "routes.events[].inputSchema.type", + "routes.events[].kind", + "routes.events[].provenance", + "routes.events[].provenance.kind", + "routes.events[].serverId", + "routes.events[].source", + "routes.layouts", + "routes.layouts[].id", + "routes.layouts[].scope", + "routes.layouts[].serverId", + "routes.layouts[].source", + "routes.providers", + "routes.providers[].id", + "routes.providers[].name", + "routes.providers[].source", + "routes.scripts", + "routes.scripts[].contract", + "routes.scripts[].description", + "routes.scripts[].event", + "routes.scripts[].execution", + "routes.scripts[].execution.fallback", + "routes.scripts[].execution.preflight", + "routes.scripts[].execution.providers", + "routes.scripts[].execution.runtime", + "routes.scripts[].id", + "routes.scripts[].inputSchema", + "routes.scripts[].inputSchema.additionalProperties", + "routes.scripts[].inputSchema.properties", + "routes.scripts[].inputSchema.properties.*.default", + "routes.scripts[].inputSchema.properties.*.description", + "routes.scripts[].inputSchema.properties.*.enum", + "routes.scripts[].inputSchema.properties.*.items", + "routes.scripts[].inputSchema.properties.*.items.enum", + "routes.scripts[].inputSchema.properties.*.items.type", + "routes.scripts[].inputSchema.properties.*.type", + "routes.scripts[].inputSchema.required", + "routes.scripts[].inputSchema.type", + "routes.scripts[].kind", + "routes.scripts[].provenance", + "routes.scripts[].provenance.kind", + "routes.scripts[].serverId", + "routes.scripts[].source", + "routes.servers", + "routes.servers[].id", + "routes.servers[].mode", + "routes.servers[].name", + "routes.servers[].routes", + "routes.servers[].routes[].contract", + "routes.servers[].routes[].description", + "routes.servers[].routes[].event", + "routes.servers[].routes[].execution", + "routes.servers[].routes[].execution.fallback", + "routes.servers[].routes[].execution.preflight", + "routes.servers[].routes[].execution.providers", + "routes.servers[].routes[].execution.runtime", + "routes.servers[].routes[].id", + "routes.servers[].routes[].inputSchema", + "routes.servers[].routes[].inputSchema.additionalProperties", + "routes.servers[].routes[].inputSchema.properties", + "routes.servers[].routes[].inputSchema.properties.*.default", + "routes.servers[].routes[].inputSchema.properties.*.description", + "routes.servers[].routes[].inputSchema.properties.*.enum", + "routes.servers[].routes[].inputSchema.properties.*.items", + "routes.servers[].routes[].inputSchema.properties.*.items.enum", + "routes.servers[].routes[].inputSchema.properties.*.items.type", + "routes.servers[].routes[].inputSchema.properties.*.type", + "routes.servers[].routes[].inputSchema.required", + "routes.servers[].routes[].inputSchema.type", + "routes.servers[].routes[].kind", + "routes.servers[].routes[].provenance", + "routes.servers[].routes[].provenance.kind", + "routes.servers[].routes[].serverId", + "routes.servers[].routes[].source", + "runtime", + "runtime.node", + "web", + "web.apps", + "web.apps[].allow", + "web.apps[].app", + "web.apps[].input", + "web.apps[].name", + "web.apps[].resourceUri", + "web.apps[].server", + "web.apps[].tool", + "web.open" + ], + "manifestVersion": 3 +} diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 064835c0f..7f3a459a0 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -116,7 +116,7 @@ const fixtureHookManifest = ( distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks, mcpServers: [], scripts: [] }, files, - manifestVersion: 2, + manifestVersion: 3, projections, routes: { digest: emptyCompiledRouteGraph.digest, diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 6ae781c77..fdcc3d984 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -394,6 +394,56 @@ it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy o } }, 60_000); +it('reads plugin identity and MCP launch paths from the artifact manifest before conventional fallbacks', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-install-mjs-manifest-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + const pluginData = join(home, '.cursor', 'agent-bundle', 'plugin-data', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + const declaredMcp = `${JSON.stringify(agentPluginsMcp, null, 2)}\n`; + try { + const writes = writesFor('portable'); + await Promise.all([ + mkdir(join(bundle, 'declared'), { recursive: true }), + mkdir(join(home, '.cursor'), { recursive: true }), + ]); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, 'agent-bundle.manifest.json'), JSON.stringify({ + projections: [{ + builtInHost: 'portable', + documents: { mcp: 'declared/mcp.json', plugin: 'declared/plugin.json' }, + }], + })), + writeFile(join(bundle, 'declared', 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'install-fixture', + version: '1.2.3', + })), + writeFile(join(bundle, 'declared', 'mcp.json'), declaredMcp), + writeFile(join(bundle, 'plugin.json'), JSON.stringify({ name: 'conventional-decoy', version: '9.9.9' })), + writeFile(join(bundle, 'mcp.json'), '{"mcpServers":{}}\n'), + ]); + + const installed = await run(installer, [], home); + expect(installed).toMatchObject({ code: 0, stderr: '' }); + expect(installed.stdout).toContain('Installed install-fixture@1.2.3'); + expect(installed.stdout).toContain('Expanded Agent Plugins placeholders for Cursor'); + expect((await readInstallReceipt(destination))?.cursorExpansion).toMatchObject({ + documents: { 'declared/mcp.json': declaredMcp }, + pluginData, + pluginRoot: destination, + }); + expect(JSON.parse(await readFile(join(destination, 'declared', 'mcp.json'), 'utf8'))) + .toMatchObject({ mcpServers: { probe: { cwd: destination } } }); + expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe('{"mcpServers":{}}\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('documents the same-version reinstall recipe per host, including Claude\'s version-gated update', () => { const claude = writesFor('claude').get('INSTALL.md') ?? ''; expect(claude).toContain('Reinstall after a same-version rebuild'); diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index 5974434e3..50cb74ecc 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -172,7 +172,7 @@ const validManifest = (): ArtifactManifest => ({ file('runtime/scripts/lint.mjs', 'bundle'), file('runtime/scripts/lint.worker.mjs', 'bundle'), ], - manifestVersion: 2, + manifestVersion: 3, projections: [ { builtInHost: 'claude', @@ -305,7 +305,7 @@ const minimalManifest = (): ArtifactManifest => ({ distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, files: [], - manifestVersion: 2, + manifestVersion: 3, projections: [], routes: { digest: hash('1'), events: [], layouts: [], providers: [], scripts: [], servers: [] }, runtime: { node: '22.12.0' }, @@ -515,7 +515,7 @@ const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => * sweep covers key deletion, unknown keys, and retyping). Both reject. */ const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ - { apply: (manifest) => { (manifest as Record_).manifestVersion = 1; }, rule: 'manifestVersion is 2' }, + { apply: (manifest) => { (manifest as Record_).manifestVersion = 2; }, rule: 'manifestVersion is 3' }, { apply: (manifest) => { (manifest.compiler.producer as Record_).name = 'other'; }, rule: 'compiler.producer.name is agent-bundle' }, { apply: (manifest) => { (manifest.compiler as Record_).recordVersion = 2; }, rule: 'compiler.recordVersion is 1' }, { apply: (manifest) => { manifest.runtime.node = '22.12'; }, rule: 'runtime.node is major.minor.patch' }, @@ -731,7 +731,7 @@ it('leaves byte-level rules to the parser: a parsed value carries no formatting expect(validateArtifactManifestSchema(JSON.parse(pretty))).toEqual([]); }); -it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 2 that matches the shipped file', async () => { +it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 3 that matches the shipped file', async () => { expect(artifactManifestSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); expect(artifactManifestSchema.$id).toBe('https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json'); expect(artifactManifestSchema.type).toBe('object'); @@ -739,7 +739,7 @@ it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 2 tha expect(artifactManifestSchema.required).toEqual(Object.keys(minimalManifest()).sort()); const properties = asObject(artifactManifestSchema.properties); expect(Object.keys(properties)).toEqual([...Object.keys(minimalManifest()), 'web'].sort()); - expect(asObject(properties.manifestVersion).const).toBe(2); + expect(asObject(properties.manifestVersion).const).toBe(3); expect(Object.isFrozen(artifactManifestSchema)).toBe(true); expect(Object.isFrozen(properties)).toBe(true); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index ec0ce3adb..0bbca2e1b 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -430,7 +430,7 @@ it('rejects a files[] row at or under a root entry the artifact does not own, in it('rejects any manifestVersion other than the closed current version', () => { const manifest = clone() as unknown as Record; - manifest.manifestVersion = 3; + manifest.manifestVersion = 2; expect(() => parseArtifactManifest(canonicalBytes(manifest))) .toThrow(`manifestVersion must be ${artifactManifestVersion}.`); }); diff --git a/packages/agent-bundle/tests/mcp-probe-service.test.ts b/packages/agent-bundle/tests/mcp-probe-service.test.ts index 469fe5fc4..f792741ba 100644 --- a/packages/agent-bundle/tests/mcp-probe-service.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-service.test.ts @@ -165,7 +165,7 @@ const createBundle = async ( scripts: [], }, files, - manifestVersion: 2, + manifestVersion: 3, projections: [{ builtInHost: 'claude', documents: { diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 003902020..cd211bbf5 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -129,6 +129,7 @@ describe('framework-owned package build', () => { it('builds bin, lib, and dts outputs from conventions and stays deterministic', async () => { const root = await fixtureRoot({ ...conventionFixture(), + 'AGENTS.md': '# Authored repository instructions\n', 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"imports":{"#fixture":"./dist/index.js"},"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n', }); await installTypescriptToolchain(root); @@ -151,6 +152,7 @@ describe('framework-owned package build', () => { type: 'module', }); const paths = packageBuild!.files.map((file) => file.path); + expect(paths).toContain('AGENTS.md'); expect(paths).toContain('bin/package-build-fixture.js'); expect(paths).toContain('index.js'); expect(paths).toContain('index.d.ts'); @@ -165,6 +167,7 @@ describe('framework-owned package build', () => { .toContain('evidence-package'); expect(JSON.parse(await readFile(join(root, 'dist', packageCompileEvidenceFileName), 'utf8'))) .toEqual(packageBuild!.evidence); + expect(await readFile(join(root, 'dist', 'AGENTS.md'), 'utf8')).toBe('# Authored repository instructions\n'); for (const file of packageBuild!.files) { expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 504e67786..14ee7c1c0 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -1,4 +1,5 @@ import { execFile as executeFile, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -63,8 +64,10 @@ beforeAll(async () => { } fixturePackageVersion = packageDocument.version; delete packageDocument.private; + packageDocument.files = ['README.md']; await Promise.all([ writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), + writeFile(join(projectRoot, '.npmignore'), '.agents/\n.claude-plugin/\n.codex-plugin/\n'), writeFile(join(projectRoot, 'README.md'), '# Host install proof fixture\n'), writeFile(join(projectRoot, 'src', 'index.ts'), 'export const fixture = true;\n'), ]); @@ -99,7 +102,76 @@ beforeAll(async () => { const installedArtifactRoot = join(consumer, 'packed-artifact'); const manifestName = 'agent-bundle.manifest.json'; const manifestText = await readFile(join(installedPackageRoot, manifestName), 'utf8'); - const manifest = JSON.parse(manifestText) as { readonly files: readonly { readonly path: string }[] }; + const manifest = JSON.parse(manifestText) as { + readonly distribution: { + readonly install?: { readonly instructions?: string; readonly script?: string }; + }; + readonly executables: { + readonly bins: readonly { readonly path: string; readonly worker?: string }[]; + readonly hooks: readonly { readonly path: string }[]; + readonly mcpServers: readonly { + readonly apps: readonly { readonly path: string }[]; + readonly launch?: { readonly entry: string; readonly worker?: string }; + }[]; + readonly scripts: readonly { readonly path: string; readonly worker?: string }[]; + }; + readonly files: readonly { + readonly bytes: number; + readonly mode?: number; + readonly path: string; + readonly sha256: string; + }[]; + readonly projections: readonly { + readonly documents: Readonly>; + }[]; + }; + const packedPaths = new Set(packOutput.files.map((file) => file.path)); + expect(packedPaths.has(manifestName), proofLabel).toBe(true); + for (const hiddenRoot of ['.agents/plugins/', '.claude-plugin/', '.codex-plugin/']) { + expect( + [...packedPaths].some((path) => path.startsWith(hiddenRoot)), + `${proofLabel}: selected hidden host root ${hiddenRoot}`, + ).toBe(true); + } + for (const file of manifest.files) { + expect(packedPaths.has(file.path), `${proofLabel}: packed ${file.path}`).toBe(true); + const [artifactBytes, installedBytes, installedMetadata] = await Promise.all([ + readFile(join(sourceFixture.artifactRoot, file.path)), + readFile(join(installedPackageRoot, file.path)), + stat(join(installedPackageRoot, file.path)), + ]); + expect(installedBytes, `${proofLabel}: installed ${file.path}`).toEqual(artifactBytes); + expect(installedBytes.byteLength, `${proofLabel}: bytes ${file.path}`).toBe(file.bytes); + expect(createHash('sha256').update(installedBytes).digest('hex'), `${proofLabel}: digest ${file.path}`) + .toBe(file.sha256); + if (file.mode !== undefined) { + expect(installedMetadata.mode & 0o777, `${proofLabel}: mode ${file.path}`).toBe(file.mode); + } + } + const manifestPaths = new Set(manifest.files.map((file) => file.path)); + for (const projection of manifest.projections) { + for (const path of Object.values(projection.documents)) { + expect(manifestPaths.has(path), `${proofLabel}: projection document ${path}`).toBe(true); + } + } + const executablePaths = [ + ...manifest.executables.bins.flatMap((entry) => [entry.path, entry.worker]), + ...manifest.executables.hooks.map((entry) => entry.path), + ...manifest.executables.mcpServers.flatMap((entry) => [ + entry.launch?.entry, + entry.launch?.worker, + ...entry.apps.map((app) => app.path), + ]), + ...manifest.executables.scripts.flatMap((entry) => [entry.path, entry.worker]), + manifest.distribution.install?.instructions, + manifest.distribution.install?.script, + ].filter((path): path is string => path !== undefined); + for (const path of executablePaths) { + expect(manifestPaths.has(path), `${proofLabel}: executable ${path}`).toBe(true); + } + for (const forbidden of ['plugin.json', 'mcp.json']) { + expect(packedPaths.has(forbidden), `${proofLabel}: forbidden discovery ${forbidden}`).toBe(false); + } await mkdir(installedArtifactRoot); for (const file of [...manifest.files, { path: manifestName }]) { const destination = join(installedArtifactRoot, file.path); diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index 7c29909c6..162560258 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -150,7 +150,7 @@ export const writeInstallFixtureManifest = async ( distribution: { channels: ['local'], payloads: [] }, executables: { bins: [], hooks: [], mcpServers, scripts: [] }, files, - manifestVersion: 2, + manifestVersion: 3, projections: projectionRows, ...(web === undefined ? {} : { web }), routes: { diff --git a/packages/agent-bundle/tests/web-command.test.ts b/packages/agent-bundle/tests/web-command.test.ts index 3d15bdece..f54373b3e 100644 --- a/packages/agent-bundle/tests/web-command.test.ts +++ b/packages/agent-bundle/tests/web-command.test.ts @@ -195,7 +195,7 @@ const invoke = async ( const pluginRoot = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-command-'))); roots.push(pluginRoot); const manifestPath = join(pluginRoot, 'agent-bundle.manifest.json'); - if (options.manifest !== 'absent') await writeFile(manifestPath, '{"manifestVersion":2}\n'); + if (options.manifest !== 'absent') await writeFile(manifestPath, '{"manifestVersion":3}\n'); const stdout: string[] = []; const stderr: string[] = []; const commandOptions: WebCommandOptions = { diff --git a/packages/agent-bundle/tests/web-host-routes-unit.test.ts b/packages/agent-bundle/tests/web-host-routes-unit.test.ts index 94ab4c84f..1928e3f18 100644 --- a/packages/agent-bundle/tests/web-host-routes-unit.test.ts +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -72,7 +72,7 @@ const writeFixture = async (root: string, options: FixtureOptions): Promise ({ host })), web: { apps: [{ diff --git a/packages/agent-bundle/tests/web-manifest.test.ts b/packages/agent-bundle/tests/web-manifest.test.ts index 5adee3aa5..1bc6dbcda 100644 --- a/packages/agent-bundle/tests/web-manifest.test.ts +++ b/packages/agent-bundle/tests/web-manifest.test.ts @@ -101,7 +101,7 @@ const document = (overrides: Readonly> = {}): Readonly withDoc })); it('refuses every manifestVersion but the one it was built for, before reading any section', () => withDocument(async (path, write) => { - for (const manifestVersion of [undefined, 1, 3, '2']) { + for (const manifestVersion of [undefined, 1, 2, 4, '3']) { await write(document({ manifestVersion })); - await expect(readWebManifestDocument(path)).rejects.toThrow(/manifestVersion must be 2\./u); + await expect(readWebManifestDocument(path)).rejects.toThrow(/manifestVersion must be 3\./u); } })); diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 56969a822..9fce81549 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -424,7 +424,7 @@ keeps only the outline. ### Sections the writer emits The document has two parts with two version numbers. The **public -contract** (`manifestVersion: 2`) is what consumers read. The +contract** (`manifestVersion: 3`) is what consumers read. The **compiler record** (`compiler`, `recordVersion: 1`) is the operational account of the run — producer, project digests, provenance, adapter revisions, pinned schemas, validation — and changes without bumping @@ -432,7 +432,7 @@ revisions, pinned schemas, validation — and changes without bumping | Section | What it records | | --- | --- | -| `manifestVersion` | `2` | +| `manifestVersion` | `3` | | `application` | Host-independent identity: `id`, `name`, `version`, optional `description` | | `files[]` | Every emitted file except the manifest (`path`, `bytes`, `sha256`, `kind`, optional `mode`) | | `runtime` | `{ node }` from `NormalizedPlugin.runtime` | @@ -585,8 +585,9 @@ issue: | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | Generated typed App client + shared bridge | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | Production `web` surface on that same bridge | -Step 3 landed an authoritative `agent-bundle.manifest.json` -(`manifestVersion` 2). The field contract is +Step 3 landed the authoritative `agent-bundle.manifest.json` as version 2; +the distribution install pointers advance the current contract to +`manifestVersion` 3. The field contract is [Artifact manifest](../../reference/artifact-manifest.mdx). #592 stays open until the remaining acceptance criteria on that issue land. diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 6429a1653..0871026ce 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -55,7 +55,9 @@ node ./install.mjs ``` Its staged copy is idempotent for identical content and refuses version or content collisions. It -never invokes `sudo` and never changes `PATH`. +never invokes `sudo` and never changes `PATH`. When `agent-bundle.manifest.json` is present, the +installer reads the selected projection's plugin and MCP pointers from it; conventional +`.cursor-plugin/*` and root Agent Plugins paths are fallback discovery for manifest-less packs only. For a root Cursor loads as an Agent Plugins 1.0.0 pack — `portable` selected without `cursor`, so the root `plugin.json` is the only manifest Cursor finds — the installer also expands the @@ -85,6 +87,9 @@ without a second compilation or wrapper. Its complete command surface, including works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a host installation** — installing a package never mutates a host's plugin state. +Repository `AGENTS.md` instructions are copied as authored package metadata. They are never +replaced by generated projection instructions; a path collision fails the package build. + Install any supported host explicitly from that root with `npx agent-bundle install --from node_modules/`. This manifest-aware path excludes npm-only metadata and authored package entries from host ownership comparisons. The packed host diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 2e17f6400..1abb94c7c 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -250,7 +250,9 @@ npx agent-bundle prepack --root . --output artifact --json validated composite artifact unchanged, points routed `package.json` bins at the manifest-declared `bin/.mjs`, and adds only separately authored bins and libraries. The gate checks that exact inventory, manifest hashes, package bin targets, release-version agreement, -package-only compile evidence, and the installed-dependency fields of `package.json`. The build +package-only compile evidence, and the installed-dependency fields of `package.json`. Authored +`package.json.files` and `.npmignore` rules never narrow the generated root: every selected hidden +host directory and every manifest file is packed, or prepack fails before publication. The build inlines every dependency into authored package entries and the host packs, so a published plugin should install nothing: declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of the stack under `devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot keep diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 5a88cea91..4f353f715 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -1,5 +1,5 @@ --- -description: 'The agent-bundle.manifest.json contract (manifestVersion 2): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' +description: 'The agent-bundle.manifest.json contract (manifestVersion 3): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' --- # Artifact manifest @@ -19,11 +19,11 @@ directory layout to find out. | Property | Rule | | --- | --- | | Written once | Only `agent-bundle build` writes it, after the staged root is complete. Nothing else creates it; the one edit after the build is the re-index of a derived variant described under `files[]`, which re-measures non-compiled rows and never touches a compiled one. | -| `manifestVersion` | `2`. A reader refuses any other value. | +| `manifestVersion` | `3`. A reader refuses any other value. | | Canonical bytes | The file is the canonical JSON serialization of the document — object keys sorted, no insignificant whitespace, one trailing newline. Every reader re-serializes what it parsed and rejects the file unless the bytes are identical. A hand edit, a reformat, or a duplicate key fails the read. | | Closed keys | Every object admits exactly its documented keys. An unknown key at any level is a parse error. | | Sorted arrays | Every array has an explicit sort key (named per section below) and no duplicates. | -| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `2` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry is not the `files[]` row its server kind starts (`bundle` for `compiled`, `prebuilt` for `prebuilt`), whose worker is not a `bundle` row, or whose `artifact` argument is not inside the root (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | +| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `3` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry is not the `files[]` row its server kind starts (`bundle` for `compiled`, `prebuilt` for `prebuilt`), whose worker is not a `bundle` row, or whose `artifact` argument is not inside the root (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | | Paths | Every path is POSIX, relative to the root, and safe: no leading `/`, no backslash, no `.` or `..` segment, and never a build-machine absolute path. Moving the entire composite root to a different absolute directory keeps every reader working: every path stays root-relative and resolves inside the moved tree. Every path a section points at — a host document, an executable, an App view, an install file — is also a `files[]` row. | ## Who writes it @@ -65,7 +65,7 @@ and are versioned separately. | Field | Contents | | --- | --- | -| `manifestVersion` | `2`. Versions the public contract consumers read. | +| `manifestVersion` | `3`. Versions the public contract consumers read. | | `runtime` | `{ node }` — the consumer-facing generated-executable runtime floor the root was built for, as a canonical `major.minor.patch` string that satisfies the framework's floor. | ### `application` @@ -338,7 +338,7 @@ event route, and a routed CLI. Digests are elided; keys appear in canonical (sor { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", "sha256": "…" } ], - "manifestVersion": 2, + "manifestVersion": 3, "projections": [ { "builtInHost": "claude", "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", @@ -391,7 +391,7 @@ The same entry point exports the reader: | `readArtifactManifest(root)` | Opens `/agent-bundle.manifest.json` and returns `{ status: 'ok', manifest, path, root }`, `{ status: 'missing', … }`, or `{ status: 'invalid', detail, … }`. This is the one way the framework's own consumers open a built root. | | `parseArtifactManifest(bytes)` | Parses and validates canonical bytes into a frozen `ArtifactManifest`; throws on any violation, including a duplicate key or non-canonical bytes. | | `artifactManifestName` | `'agent-bundle.manifest.json'`. | -| `artifactManifestVersion` | `2`. | +| `artifactManifestVersion` | `3`. | | `artifactCompilerRecordVersion` | `1`. | The types (`ArtifactManifest`, `ArtifactManifestCompiler`, `ArtifactManifestCompilerAdapter`, @@ -400,7 +400,7 @@ are in the generated [Type API](./api.mdx). ## Reserved keys -These keys are part of the design but **not emitted**, and a v2 reader rejects them like any +These keys are part of the design but **not emitted**, and a v3 reader rejects them like any other unknown key. They are listed so that a consumer does not invent its own spelling: | Reserved key | Intended contents | @@ -420,7 +420,8 @@ lean web reader rejects the version mismatch itself, before it reads a slice). `runtime`, `projections[]`, `routes`, `executables`, `distribution`). It bumps on **any** change an old closed reader would reject — adding, renaming, or removing a public-contract key (optional or not), or changing an enumerated value set a reader closes. Within one - version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 2 + version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 3 + adds `distribution.install.instructions` and `distribution.install.script`; version 2 renamed `targets[]` to `projections[]`, moved the hook rows that `agent-bundle.hooks.json` used to carry into `executables.hooks[]` and deleted that file, and added `application`, `routes`, `executables`, `distribution`, and `projections[].documents`. diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 4106b9a8a..9e3406c76 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -51,7 +51,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once ├── INSTALL.md # when any built-in host is selected ├── install.mjs # when cursor or portable is selected -├── agent-bundle.manifest.json # the artifact index (manifestVersion 2) +├── agent-bundle.manifest.json # the artifact index (manifestVersion 3) └── agent-bundle.compile-evidence.json # compiler record per compiled file ``` @@ -142,7 +142,7 @@ inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per root (`manifestVersion: 2`). It is the root's **index** — the one +One manifest is emitted per root (`manifestVersion: 3`). It is the root's **index** — the one document every consumer reads to learn what the root contains — and the input to every later integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, `serve-app`, `mcp`, `hooks`, and the packed and installed-host proof levels. The full field reference is the @@ -150,7 +150,7 @@ integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, ` | Section | Contents | | --- | --- | -| `manifestVersion`, `runtime` | `2`, and `{ node }`, the consumer-facing generated-executable floor. | +| `manifestVersion`, `runtime` | `3`, and `{ node }`, the consumer-facing generated-executable floor. | | `application` | The identity, once and host-independent: `id`, `name`, `version`, optional `description`. What `install`, `doctor`, and `uninstall` act on. | | `files[]` | Every emitted file: `path`, `bytes`, `sha256`, `kind` (`bundle`, `copy`, `generated`, `prebuilt`), optional `mode`. | | `projections[]` | One row per selected host, sorted by `host`: `host`, optional `builtInHost`, the `documents` pointers to the host plugin, marketplace, MCP, and hooks documents the projection emitted, and its `marketplace` name. | diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index f97c1ab2f..4299cd71a 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -350,7 +350,7 @@ prompt 路由则返回经校验的协议结果(来自 `document.value`)。CL `build/manifest-routes.ts` 的 `artifactRoutesFor`。`writeManifest` 再用 `assembleArtifactManifest` 将其序列化:规范的 `stableJson` 加一个末尾换行。 `parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、缺失键、未排序 -的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `2`。公开读取方是 +的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `3`。公开读取方是 `build/manifest-file.ts` 的 `readArtifactManifest`:`ok`、`missing`(仅 ENOENT)或 `invalid`。 @@ -372,7 +372,7 @@ ENOENT)或 `invalid`。 ### 写入器发出的分节 -文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 2`) +文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 3`) 是消费者读取的部分。**编译器记录**(`compiler`,`recordVersion: 1`)是 这次运行的操作性账目——生产者、项目摘要、来源、适配器修订、固定的 schema、验证结果——它的变化不会推动 `manifestVersion`;消费者读取方 @@ -380,7 +380,7 @@ schema、验证结果——它的变化不会推动 `manifestVersion`;消费 | 分节 | 记录什么 | | --- | --- | -| `manifestVersion` | `2` | +| `manifestVersion` | `3` | | `application` | 与宿主无关的身份:`id`、`name`、`version`,可选的 `description` | | `files[]` | 除清单自身外的每个发出文件(`path`、`bytes`、`sha256`、`kind`,可选的 `mode`) | | `runtime` | 来自 `NormalizedPlugin.runtime` 的 `{ node }` | @@ -513,7 +513,8 @@ schema、验证结果——它的变化不会推动 `manifestVersion`;消费 | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | 生成的类型化 App 客户端 + 共享桥接 | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | 基于同一桥接的生产 `web` 表面 | -第 3 步落地了一份权威的 `agent-bundle.manifest.json`(`manifestVersion` 2)。 +第 3 步以版本 2 落地了权威的 `agent-bundle.manifest.json`;分发安装指针把当前契约推进到 +`manifestVersion` 3。 字段契约见 [产物清单](../../reference/artifact-manifest.mdx)。#592 会一直保持打开,直到该 issue 上剩余的验收标准落地。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index ac85b6bfb..c88b9f416 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -47,6 +47,8 @@ node ./install.mjs ``` 它的分阶段复制对内容相同的情况是幂等的,并会拒绝版本或内容冲突。它绝不调用 `sudo`,也绝不修改 `PATH`。 +存在 `agent-bundle.manifest.json` 时,安装器从所选投影读取插件与 MCP 指针;只有无清单的包才回退探测 +`.cursor-plugin/*` 或根目录 Agent Plugins 路径。 对于 Cursor 会按 Agent Plugins 1.0.0 包加载的根目录——选中了 `portable` 而未选 `cursor`,因此根目录的 `plugin.json` 是 Cursor 能找到的唯一清单——安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, @@ -70,6 +72,8 @@ npm 包根目录包含组合插件清单拥有的全部字节,并额外带有 `node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 绝不会改动宿主的插件状态。 +仓库中的 `AGENTS.md` 作为作者编写的包元数据原样复制。生成的投影说明绝不会覆盖它;路径冲突会让包构建失败。 + 请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装任一受支持宿主。 这条清单感知路径会把 npm 专属元数据与手写包入口排除在宿主归属比较之外。 打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 4adebe52f..fa2814124 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -217,7 +217,8 @@ npx agent-bundle prepack --root . --output artifact --json `npm pack --dry-run --json --ignore-scripts`。生成的包会原样复制已校验的组合产物,让路由式 `package.json` bin 指向清单声明的 `bin/.mjs`,并且只额外加入单独编写的 bin 与库。 门禁会检查这份精确清单、清单哈希、包 bin 目标、发布版本一致性、包专属编译证据以及 -`package.json` 中的安装期依赖字段。构建会把每个依赖内联进手写包入口与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 +`package.json` 中的安装期依赖字段。作者写入的 `package.json.files` 与 `.npmignore` 规则不会缩窄 +生成根目录:每个所选隐藏宿主目录与每个清单文件都必须进入包,否则 prepack 会在发布前失败。构建会把每个依赖内联进手写包入口与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及清单之前报告 `AB6005`。因此 仅仅把包内联进 bundle 并不能证明消费者需要安装它。`--output` 是相对 diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 7b79e0de3..530cc5a40 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -1,5 +1,5 @@ --- -description: 'agent-bundle.manifest.json 契约(manifestVersion 2):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' +description: 'agent-bundle.manifest.json 契约(manifestVersion 3):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' --- # 产物清单 @@ -16,11 +16,11 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 属性 | 规则 | | --- | --- | | 只写一次 | 只有 `agent-bundle build` 在暂存根目录完成之后写出它。没有任何别的东西会创建它;构建之后唯一的编辑是 `files[]` 一节所述对派生变体的重新索引,它只重新测量非编译行,绝不触碰编译行。 | -| `manifestVersion` | `2`。读取方拒绝任何其他值。 | +| `manifestVersion` | `3`。读取方拒绝任何其他值。 | | 规范字节 | 文件就是该文档的规范 JSON 序列化——对象键已排序、没有无意义的空白、末尾恰有一个换行。每个读取方都会把解析结果重新序列化,字节不完全一致就拒绝该文件。手工编辑、重新格式化或重复键都会让读取失败。 | | 封闭键 | 每个对象只接受文档化的那些键。任何层级上的未知键都是解析错误。 | | 有序数组 | 每个数组都有显式的排序键(各分节下方注明),且没有重复项。 | -| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`2` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry 不是其服务器 kind 所启动的那种 `files[]` 行(`compiled` 对应 `bundle`,`prebuilt` 对应 `prebuilt`)、worker 不是 `bundle` 行、或 `artifact` 参数不在根目录之内的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | +| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`3` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry 不是其服务器 kind 所启动的那种 `files[]` 行(`compiled` 对应 `bundle`,`prebuilt` 对应 `prebuilt`)、worker 不是 `bundle` 行、或 `artifact` 参数不在根目录之内的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | | 路径 | 每个路径都是 POSIX 形式、相对根目录且安全:没有前导 `/`、没有反斜杠、没有 `.` 或 `..` 段,也绝不会是构建机上的绝对路径。把整个组合根目录搬到另一个绝对路径后,每个读取方仍然可用:所有路径都保持相对根目录,并在搬迁后的树内解析。任何分节指向的路径——宿主文档、可执行文件、App 视图、安装文件——同时也是一行 `files[]`。 | ## 由谁写入 @@ -58,7 +58,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 内容 | | --- | --- | -| `manifestVersion` | `2`。为消费者读取的公开契约版本化。 | +| `manifestVersion` | `3`。为消费者读取的公开契约版本化。 | | `runtime` | `{ node }`——该根目录据以构建的面向消费者的生成式可执行文件运行时下限,为满足框架下限的规范 `major.minor.patch` 字符串。 | ### `application` @@ -320,7 +320,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", "sha256": "…" } ], - "manifestVersion": 2, + "manifestVersion": 3, "projections": [ { "builtInHost": "claude", "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", @@ -372,7 +372,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | `readArtifactManifest(root)` | 打开 `/agent-bundle.manifest.json`,返回 `{ status: 'ok', manifest, path, root }`、`{ status: 'missing', … }` 或 `{ status: 'invalid', detail, … }`。这是框架自身的消费者打开已构建根目录的唯一方式。 | | `parseArtifactManifest(bytes)` | 把规范字节解析并校验为冻结的 `ArtifactManifest`;任何违规——包括重复键或非规范字节——都会抛出。 | | `artifactManifestName` | `'agent-bundle.manifest.json'`。 | -| `artifactManifestVersion` | `2`。 | +| `artifactManifestVersion` | `3`。 | | `artifactCompilerRecordVersion` | `1`。 | 类型(`ArtifactManifest`、`ArtifactManifestCompiler`、`ArtifactManifestCompilerAdapter`、 @@ -381,7 +381,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 ## 保留键 -这些键属于设计的一部分,但**不会输出**,v2 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 +这些键属于设计的一部分,但**不会输出**,v3 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 不要自创拼写: | 保留键 | 预期内容 | @@ -399,7 +399,8 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 - `manifestVersion` 为消费者读取的**公开契约**版本化(`application`、`files[]`、`runtime`、 `projections[]`、`routes`、`executables`、`distribution`)。旧的封闭读取方会拒绝的**任何**变更 都会使其递增——新增、重命名或删除一个公开契约键(无论是否可选),或更改读取方封闭的枚举值集合。 - 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 2 把 `targets[]` 重命名为 + 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 3 新增 + `distribution.install.instructions` 与 `distribution.install.script`;版本 2 把 `targets[]` 重命名为 `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 `projections[].documents`。 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index d5fadd5d4..372aae066 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -46,7 +46,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 -├── agent-bundle.manifest.json # 产物索引(manifestVersion 2) +├── agent-bundle.manifest.json # 产物索引(manifestVersion 3) └── agent-bundle.compile-evidence.json # 每个已编译文件的编译器记录 ``` @@ -127,14 +127,14 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 ## agent-bundle.manifest.json -每个根目录输出一份清单(`manifestVersion: 2`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 +每个根目录输出一份清单(`manifestVersion: 3`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 根目录包含什么——也是之后每一项完整性检查的输入:`validate --artifact`、`prepack`、`install`、 `doctor --from`、`serve-app`、`mcp`、`hooks`,以及 packed 与 installed-host 证明级别。完整的字段参考见 [产物清单](./artifact-manifest.mdx)页;概要如下: | 分节 | 内容 | | --- | --- | -| `manifestVersion`、`runtime` | `2`,以及 `{ node }`,即面向消费者的生成式可执行文件下限。 | +| `manifestVersion`、`runtime` | `3`,以及 `{ node }`,即面向消费者的生成式可执行文件下限。 | | `application` | 身份,只记录一次且与宿主无关:`id`、`name`、`version`,可选的 `description`。这是 `install`、`doctor` 与 `uninstall` 作用的对象。 | | `files[]` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`(`bundle`、`copy`、`generated`、`prebuilt`)、可选的 `mode`。 | | `projections[]` | 每个所选宿主一行,按 `host` 排序:`host`、可选的 `builtInHost`、指向该投影输出的宿主插件、市场、MCP 与钩子文档的 `documents` 指针,以及它的 `marketplace` 名。 | From f72cbaad30c13a1a253b4cd2972623a54c14c7e2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:31:31 +0000 Subject: [PATCH 16/19] docs: bind npm root changeset to PR --- .changeset/canonical-npm-root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 165467717..a421f5b59 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, persist package-only compile evidence, rebase supported consumer lifecycle paths and report `AB4768` for absent source assets, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, persist package-only compile evidence, rebase supported consumer lifecycle paths and report `AB4768` for absent source assets, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#656). From bfa41d9eb02959b097c064b0185c1c5b72d2c344 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:56:45 +0000 Subject: [PATCH 17/19] fix: close npm distribution review gaps --- .changeset/canonical-npm-root.md | 2 +- packages/agent-bundle/src/install/surface.ts | 10 ++- .../tests/install-surface.test.ts | 10 ++- .../agent-bundle/tests/package-build.test.ts | 80 +++++++++++++++++++ .../tests/packed-host-install-proof.test.ts | 8 +- packages/agent-bundle/tests/prepack.test.ts | 15 ++-- .../docs/en/reference/artifact-manifest.mdx | 5 +- .../docs/zh/reference/artifact-manifest.mdx | 4 +- 8 files changed, 116 insertions(+), 18 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index a421f5b59..296bc805b 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -3,4 +3,4 @@ "create-agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, persist package-only compile evidence, rebase supported consumer lifecycle paths and report `AB4768` for absent source assets, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#656). +Package the validated composite root as the npm root, point generated CLI bins at its manifest-declared executable, advance `agent-bundle.manifest.json` to version 3, preserve supported lifecycle assets and authored `AGENTS.md`, persist package-only compile evidence, reject unpublishable dependency protocols with `AB7015`, and report missing generated executables or lifecycle assets with `AB4767` and `AB4768`; rebuild and replace version 2 installs before managing them with this release (#656). diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 0d60c040a..1b48bb9bd 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -766,8 +766,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { 'const artifactManifest = await (async () => {', ' try {', " const value = JSON.parse(await readFile(join(source, 'agent-bundle.manifest.json'), 'utf8'));", - " if (value === null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.projections)) {", - " throw new Error('agent-bundle.manifest.json has no projections array.');", + " if (value === null || typeof value !== 'object' || Array.isArray(value) || !Array.isArray(value.files) || !Array.isArray(value.projections)) {", + " throw new Error('agent-bundle.manifest.json has no files or projections array.');", ' }', ' return value;', " } catch (error) { if (error?.code === 'ENOENT') return undefined; throw error; }", @@ -878,6 +878,12 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const bytes = await readFile(absolute);', ' hashEntry(hash, relative, metadata, transform === undefined ? bytes : transform(posixPath, bytes));', ' };', + ' if (artifactManifest !== undefined) {', + " const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);", + " for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);", + ' for (const file of sortNames(selected)) await visit(file);', + " return { files, hash: hash.digest('hex') };", + ' }', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', ' if (entry === receiptFile) {', ' // The receipt is deletion authority: skipped from the hash, must be a regular file.', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index fdcc3d984..3810ac319 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -412,6 +412,12 @@ it('reads plugin identity and MCP launch paths from the artifact manifest before writeFile(installer, writes.get('install.mjs') ?? ''), writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), writeFile(join(bundle, 'agent-bundle.manifest.json'), JSON.stringify({ + files: [ + { path: 'declared/mcp.json' }, + { path: 'declared/plugin.json' }, + { path: 'INSTALL.md' }, + { path: 'install.mjs' }, + ], projections: [{ builtInHost: 'portable', documents: { mcp: 'declared/mcp.json', plugin: 'declared/plugin.json' }, @@ -425,6 +431,7 @@ it('reads plugin identity and MCP launch paths from the artifact manifest before writeFile(join(bundle, 'declared', 'mcp.json'), declaredMcp), writeFile(join(bundle, 'plugin.json'), JSON.stringify({ name: 'conventional-decoy', version: '9.9.9' })), writeFile(join(bundle, 'mcp.json'), '{"mcpServers":{}}\n'), + writeFile(join(bundle, 'package.json'), '{"name":"npm-only"}\n'), ]); const installed = await run(installer, [], home); @@ -438,7 +445,8 @@ it('reads plugin identity and MCP launch paths from the artifact manifest before }); expect(JSON.parse(await readFile(join(destination, 'declared', 'mcp.json'), 'utf8'))) .toMatchObject({ mcpServers: { probe: { cwd: destination } } }); - expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe('{"mcpServers":{}}\n'); + await expect(readFile(join(destination, 'mcp.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(destination, 'package.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index cd211bbf5..6da897c99 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -527,6 +527,86 @@ describe('framework-owned package build', () => { expect(binSource).toContain('./shipped.cjs'); }, 120_000); + it('fails the package build with AB6005 on a literal import the compiler was told to ignore in a dist bundle', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + " lib: { entry: './src/index.ts', dts: false },", + " mcp: { servers: { echoer: {} } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'src/cli.ts': [ + "export const pad = () => import('left-pad' /* rspackIgnore: true */);", + "export const missing = () => import(/* webpackIgnore: true */ './missing.js');", + "export const outside = () => import(/* rspackIgnore: true */ '../../outside.js');", + '', + 'export const main = async (): Promise => {', + ' process.stdout.write(`${typeof pad}${typeof missing}${typeof outside}\\n`);', + ' return 0;', + '};', + '', + ].join('\n'), + 'src/index.ts': "export const sibling = () => import(/* rspackIgnore: true */ './bin/package-build-fixture.js');\n", + }); + + const failure = await packageBuildFailure(root); + expect(failure).toBeInstanceOf(DiagnosticError); + const ignored = (asset: string, request: string): Diagnostic => ({ + code: 'AB6005', + generatedPath: asset, + message: `Generated JavaScript import from ${JSON.stringify(asset)} loads ${JSON.stringify(request)}, which the compiler ` + + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', + recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', + severity: 'error', + }); + expect([...withCode((failure as DiagnosticError).diagnostics, 'AB6005')].sort(byMessage)).toEqual([ + ignored('dist/bin/package-build-fixture.js', '../../outside.js'), + ignored('dist/bin/package-build-fixture.js', './missing.js'), + ignored('dist/bin/package-build-fixture.js', 'left-pad'), + ignored('dist/index.js', './bin/package-build-fixture.js'), + ].sort(byMessage)); + await unpublishedPackageOutput(root); + }, 120_000); + + it('fails the package build with AB6005 on an expression import the compiler left verbatim in a dist bundle', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + ' lib: false,', + " mcp: { servers: { echoer: {} } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'src/cli.ts': [ + 'export const load = (name: string) => import(name);', + '', + 'export const main = async (argv: readonly string[]): Promise => {', + " process.stdout.write(`${Object.keys(await load(argv[0] ?? 'node:os')).length}\\n`);", + ' return 0;', + '};', + '', + ].join('\n'), + }); + + const failure = await packageBuildFailure(root); + expect(failure).toBeInstanceOf(DiagnosticError); + expect(withCode((failure as DiagnosticError).diagnostics, 'AB6005')).toEqual([{ + code: 'AB6005', + generatedPath: 'dist/bin/package-build-fixture.js', + message: 'Generated JavaScript import from "dist/bin/package-build-fixture.js" has a non-literal dynamic import.', + recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', + severity: 'error', + }]); + await unpublishedPackageOutput(root); + }, 120_000); + it('accepts a sibling authored module that the package build bundles into the executable', async () => { const root = await fixtureRoot({ ...conventionFixture(), diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index 14ee7c1c0..bbcf06102 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -75,7 +75,7 @@ beforeAll(async () => { const config = await readFile(configPath, 'utf8'); await writeFile(configPath, config.replace( 'export default {\n', - "export default {\n bin: false,\n lib: { dts: false, entry: './src/index.ts' },\n", + "export default {\n lib: { dts: false, entry: './src/index.ts' },\n", )); }, }); @@ -85,6 +85,12 @@ beforeAll(async () => { const consumer = join(cleanupRoot, 'consumer'); await Promise.all([mkdir(tarballs), mkdir(consumer)]); expect(await readdir(consumer), proofLabel).toEqual([]); + const generatedPackage = JSON.parse(await readFile(join(projectRoot, 'dist', 'package.json'), 'utf8')) as { + readonly files?: unknown; + }; + expect(generatedPackage.files, `${proofLabel}: authored files policy`).toBeUndefined(); + await expect(access(join(projectRoot, 'dist', '.npmignore')), `${proofLabel}: authored ignore policy`) + .rejects.toMatchObject({ code: 'ENOENT' }); const packed = await execFile( 'npm', diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index bc53752df..e31332d7b 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -754,7 +754,7 @@ it('fails prepack with compile-time AB6005, never AB7014, when only a compiled d expect(withCode(reported, 'AB7014')).toHaveLength(0); }, 180_000); -it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { +it('installs a real generated tarball and runs its manifest-driven Cursor installer from node_modules', async () => { const tarballs = join(cleanupRoot, 'tarballs'); const consumer = join(cleanupRoot, 'consumer'); const home = join(cleanupRoot, 'home'); @@ -764,23 +764,20 @@ it('installs a real packed tarball and runs its Cursor installer from node_modul mkdir(join(home, '.cursor'), { recursive: true }), ]); const { stdout } = await execFile('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], { - cwd: projectRoot, + cwd: join(projectRoot, 'dist'), }); const packed = packOutputFromJson(stdout); await writeFile(join(consumer, 'package.json'), '{"private":true}\n'); await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(tarballs, packed.filename)], { cwd: consumer, }); - const sourceCopy = join(cleanupRoot, 'source-copy'); - await cp(projectRoot, sourceCopy, { recursive: true, filter: (source) => source !== join(projectRoot, 'node_modules') }); await rm(projectRoot, { force: true, recursive: true }); - const installedBin = join(consumer, 'node_modules', '.bin', 'installer-fixture'); - const installed = await execFile(installedBin, ['install', 'cursor', '--json'], { + const installer = join(consumer, 'node_modules', 'installer-fixture', 'install.mjs'); + const installed = await execFile(process.execPath, [installer], { cwd: consumer, env: { ...process.env, HOME: home }, }); - expect(JSON.parse(installed.stdout)).toMatchObject({ host: 'cursor', state: 'installed' }); + expect(installed.stdout).toContain('Installed installer-fixture@1.2.3'); await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); - expect(await readFile(join(sourceCopy, 'src', 'index.ts'), 'utf8')).toBe('export const value = 1;\n'); }); diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 4f353f715..f90c53341 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -420,8 +420,9 @@ lean web reader rejects the version mismatch itself, before it reads a slice). `runtime`, `projections[]`, `routes`, `executables`, `distribution`). It bumps on **any** change an old closed reader would reject — adding, renaming, or removing a public-contract key (optional or not), or changing an enumerated value set a reader closes. Within one - version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 3 - adds `distribution.install.instructions` and `distribution.install.script`; version 2 + version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 3 marks + the canonical npm-root guarantee that packages preserve the manifest-owned bytes and executable; + version 2 already reserved the same `distribution.install` pointer shape. Version 2 renamed `targets[]` to `projections[]`, moved the hook rows that `agent-bundle.hooks.json` used to carry into `executables.hooks[]` and deleted that file, and added `application`, `routes`, `executables`, `distribution`, and `projections[].documents`. diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 530cc5a40..f892bde24 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -399,8 +399,8 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 - `manifestVersion` 为消费者读取的**公开契约**版本化(`application`、`files[]`、`runtime`、 `projections[]`、`routes`、`executables`、`distribution`)。旧的封闭读取方会拒绝的**任何**变更 都会使其递增——新增、重命名或删除一个公开契约键(无论是否可选),或更改读取方封闭的枚举值集合。 - 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 3 新增 - `distribution.install.instructions` 与 `distribution.install.script`;版本 2 把 `targets[]` 重命名为 + 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 3 标记 npm 根目录保留清单所拥有的 + 字节与可执行文件这一规范保证;版本 2 已预留同样的 `distribution.install` 指针形状。版本 2 把 `targets[]` 重命名为 `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 `projections[].documents`。 From 43da98fee90ade6059ee2cf55384c411b4beb0a6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 01:05:09 +0000 Subject: [PATCH 18/19] test: restore manifest-declared executable modes --- packages/agent-bundle/tests/packed-readonly-state-root.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 1232e9f9c..6f065fbef 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -212,6 +212,9 @@ it('serves a state-writing tool from a read-only installed artifact without writ expect(await readFile(join(installedRoot, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); await chmodTree(installedRoot, { directory: 0o755, file: 0o644 }); + for (const file of manifest.files) { + if (file.mode !== undefined) await chmod(join(installedRoot, file.path), file.mode); + } readOnly = false; const kept = await execFile(process.execPath, [installer, '--uninstall', '--keep-data'], { cwd: artifact, env }); expect(kept.stdout).toContain(`Data (keep): kept`); From d57ead821a374003ff03b7a1a5cc18590e35d944 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 01:33:44 +0000 Subject: [PATCH 19/19] fix: align standalone manifest inventory --- packages/agent-bundle/src/install/surface.ts | 11 +++++++++- .../tests/support/host-install.ts | 22 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 1b48bb9bd..ab02f025d 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -834,6 +834,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const toPosix = (path) => path.replaceAll('\\\\', '/');", 'const short = (hash) => hash.slice(0, 12);', 'const sortNames = (names) => [...names].sort((left, right) => left.localeCompare(right));', + 'const compareTreePaths = (left, right) => {', + " const leftSegments = left.split('/');", + " const rightSegments = right.split('/');", + ' for (let index = 0; index < Math.min(leftSegments.length, rightSegments.length); index += 1) {', + ' const compared = leftSegments[index].localeCompare(rightSegments[index]);', + ' if (compared !== 0) return compared;', + ' }', + ' return leftSegments.length - rightSegments.length;', + '};', '// Every ancestor directory of the given POSIX-relative files, deduplicated and sorted.', 'const directoriesOf = (files) => {', ' const directories = new Set();', @@ -881,7 +890,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' if (artifactManifest !== undefined) {', " const selected = new Set(['agent-bundle.manifest.json', ...artifactManifest.files.map((file) => file.path)]);", " for (const file of ['.env', '.env.local']) if (await exists(join(root, file))) selected.add(file);", - ' for (const file of sortNames(selected)) await visit(file);', + ' for (const file of [...selected].sort(compareTreePaths)) await visit(file);', " return { files, hash: hash.digest('hex') };", ' }', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 6c3d931a4..6a3b8b4fd 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -19,6 +19,7 @@ import { cursorPluginValidator, } from '../../src/adapters/cursor.ts'; import { createAdapterValidator } from '../../src/adapters/types.ts'; +import { parseArtifactManifest } from '../../src/build/manifest.ts'; import { reindexArtifactManifest } from '../../src/build/manifest-reindex.ts'; import { isInsideOrEqual } from '../../src/core/paths.ts'; import { validatePortablePluginFiles } from '../../src/host-contracts/portable-plugin-validation.ts'; @@ -40,7 +41,7 @@ import type { ArtifactEpoch } from '../../src/dev/types.ts'; import { startDevServer } from '../../src/dev/workbench-server.ts'; import { runDoctor, type DoctorCommandRunner } from '../../src/install/doctor.ts'; import { installBundle, type InstallHost } from '../../src/install/install.ts'; -import { readInstallReceipt } from '../../src/install/receipt.ts'; +import { manifestInventory, readInstallReceipt } from '../../src/install/receipt.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -1731,14 +1732,28 @@ export const runPortableHostInstallProof = async ( portableFinding?.state === 'installed' && portableFinding.launch?.state === 'expanded', `Doctor inventory did not report the portable install as expanded: ${JSON.stringify(portableFinding)}`, ); + const coreInventory = await manifestInventory( + fixture.portableBundle, + parseArtifactManifest(await readFile(join(fixture.portableBundle, 'agent-bundle.manifest.json'), 'utf8')), + ); + const emittedReceipt = await readInstallReceipt(destination); + assertProof( + JSON.stringify(emittedReceipt?.files) === JSON.stringify(coreInventory.files), + `The emitted installer file order disagrees with the core manifest inventory: ${JSON.stringify(emittedReceipt?.files)} vs ${JSON.stringify(coreInventory.files)}.`, + ); await install('Already installed'); // Same-version rebuild through the emitted install.mjs: owned files replaced in place, then a no-op. const marker = join(fixture.portableBundle, sameVersionRebuildMarker); await writeFile(marker, '# same-version rebuild\n'); + let indexed = false; let sameVersionRebuild: SameVersionRebuildProof; try { + await reindexArtifactManifest(fixture.portableBundle, { + added: [{ kind: 'generated', path: sameVersionRebuildMarker }], + }); + indexed = true; await install('Replaced'); await access(join(destination, sameVersionRebuildMarker)).catch(() => fail('Portable emitted installer did not refresh the installed copy for the same-version rebuild.')); @@ -1746,6 +1761,11 @@ export const runPortableHostInstallProof = async ( sameVersionRebuild = 'replaced'; } finally { await rm(marker, { force: true }); + if (indexed) { + await reindexArtifactManifest(fixture.portableBundle, { + removed: [sameVersionRebuildMarker], + }); + } } return Object.freeze({