diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md new file mode 100644 index 000000000..296bc805b --- /dev/null +++ b/.changeset/canonical-npm-root.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"create-agent-bundle": minor +--- + +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/docs/diagnostics.md b/docs/diagnostics.md index af2983892..95bd71ac3 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). | @@ -425,12 +425,12 @@ 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. | +| `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: @@ -835,20 +835,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`) @@ -1514,8 +1517,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: @@ -1841,8 +1843,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 @@ -1871,7 +1874,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`) @@ -1894,7 +1897,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,10 +1907,10 @@ 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; 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/effect-conventions.md b/docs/effect-conventions.md index 5f144f12b..b9a2238c9 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 fa3dd5e45..ff2cdb5c2 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -13,33 +13,28 @@ 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 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” @@ -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 @@ -1318,41 +1313,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 @@ -1428,42 +1407,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 @@ -1537,8 +1498,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: @@ -1644,12 +1606,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/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 6940e004f..218ed032d 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/README.md b/packages/agent-bundle/README.md index 52f46a8ee..253c48406 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 @@ -1051,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/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 ffdeedac2..5913a905f 100644 --- a/packages/agent-bundle/fixtures/web-surface/package.json +++ b/packages/agent-bundle/fixtures/web-surface/package.json @@ -2,6 +2,16 @@ "name": "web-surface-fixture", "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/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index f37312d77..2d0d2ba8e 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -77,7 +77,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/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/api.ts b/packages/agent-bundle/src/api.ts index 39467d815..5ff3e667e 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, @@ -1299,7 +1298,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 }), @@ -1373,15 +1372,14 @@ 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({ - artifactRoot: result.build.outputRoot, 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/build.ts b/packages/agent-bundle/src/build/build.ts index ac65b49e8..1f69e63eb 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 { @@ -810,6 +815,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/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/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index 7c6d57a5b..b0ae6a780 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/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 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 2830ba470..2d28d9528 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -1,13 +1,18 @@ 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'; @@ -82,9 +87,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 { @@ -144,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; @@ -158,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, }); @@ -233,7 +237,6 @@ const dependencyDiagnostics = async (options: { }; export const packInventoryDiagnostics = async (options: { - readonly artifactRoot: string; readonly model: NormalizedPlugin; readonly packageBuild: PackageBuildResult; readonly packOutput: PackOutput; @@ -241,21 +244,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 +260,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 +321,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]); } @@ -332,10 +346,11 @@ 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, - 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 87feb1ffa..b10013b54 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -1,16 +1,26 @@ 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 +29,22 @@ 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 { 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', 'AGENTS.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,74 +373,123 @@ 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.model.sourceMap === true ? { sourceMap: true } : {}), - ...(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: publishedPrefix, + entries, + ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), + logLevel: 'error', + meta: projectMeta(options.model.metadata), + outputRoot: compileRoot, + ...(options.model.sourceMap === true ? { sourceMap: true } : {}), + ...(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) { @@ -380,35 +501,33 @@ export const buildPackageOutputs = async (options: { throw new Error(`Package build did not emit expected declarations ${JSON.stringify(`${lib.name}.d.ts`)}.`); } + // 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 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. const selfContainment = await validateJavaScriptModules({ artifactRoot: stageRoot, - files: staged, - provenModules: rewritable ? new Map() : accountedRequestsOf(evidence, publishedPrefix), - reportedRoot: publishedPrefix, + files: staged.filter((file) => !artifactByPath.has(file.path)), + provenModules: rewritable ? new Map() : accountedRequestsOf(evidence), + 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 b76bf8efb..000000000 --- a/packages/agent-bundle/src/install-entry.ts +++ /dev/null @@ -1,231 +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 { errorMessage } from './core/errors.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: errorMessage(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/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index d5a0cf836..de4af1f73 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -54,7 +54,6 @@ import { listStoredInstallReceipts, readInstallReceipt, readInstallReceiptFile, - treeInventory, type InstalledTreeComparison, type InstalledTreeOwnership, type InstallReceipt, @@ -75,7 +74,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 +1797,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 029633062..8c9530034 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)`; @@ -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/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/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/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/surface.ts b/packages/agent-bundle/src/install/surface.ts index 678ee8ccd..ab02f025d 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.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; }", + '})();', + "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);', @@ -821,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();', @@ -865,6 +887,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 [...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))) {', ' if (entry === receiptFile) {', ' // The receipt is deletion authority: skipped from the hash, must be a regular file.', @@ -1013,7 +1041,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 +1065,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 +1114,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 +1164,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 +1292,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 +1368,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 +1421,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 +1492,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/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 953ef63ee..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 { 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 treeInventory(entry.installPath)).hash; + installedHash = (await installedBundleInventory(entry.installPath, host)).hash; } catch (error) { if (!isErrno(error, 'ENOENT')) { if (!force) { 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-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/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 cc0dc5770..435144ccb 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -347,7 +347,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-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/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/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/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index 815a56568..e1717b634 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -114,13 +114,17 @@ 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'); 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 +145,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 +163,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 +171,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(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( @@ -183,7 +186,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' }, @@ -207,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()); @@ -214,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); - expect(await versionOf(client)).toBe('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/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 9bc24effa..8b420c907 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -379,17 +379,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/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/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/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..3810ac319 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -394,6 +394,64 @@ 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({ + 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' }, + }], + })), + 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'), + writeFile(join(bundle, 'package.json'), '{"name":"npm-only"}\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 } } }); + 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 }); + } +}, 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/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/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/manifest-relocatable.test.ts b/packages/agent-bundle/tests/manifest-relocatable.test.ts index b8862fd2c..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); @@ -238,10 +239,17 @@ it('emits a relocatable manifest that survives moving the composite root', async expect(read.manifest).toEqual(manifest); expect(await readFile(read.path, 'utf8')).toBe(manifestBytes); + const linked = join(destParent, 'node_modules', 'relocated-plugin'); + await mkdir(dirname(linked), { recursive: true }); + await symlink(moved, linked, 'dir'); + const linkedRead = await readArtifactManifest(linked); + 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(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); 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/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/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 9c8bb152e..6da897c99 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`);", @@ -143,7 +127,11 @@ 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(), + '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); const result = await build({ output: 'artifact', packageOutputs: true, root }); @@ -154,20 +142,32 @@ 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' }, + imports: { '#fixture': './index.js' }, + name: 'package-build-fixture', + private: true, + scripts: { test: 'rstest' }, + 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'); + 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); + 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))); } @@ -187,6 +187,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': [ @@ -197,7 +218,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`);", @@ -220,6 +247,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'); @@ -498,8 +528,6 @@ describe('framework-owned package build', () => { }, 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': [ @@ -522,10 +550,7 @@ describe('framework-owned package build', () => { '};', '', ].join('\n'), - 'src/index.ts': [ - "export const sibling = () => import(/* rspackIgnore: true */ './bin/package-build-fixture.js');", - '', - ].join('\n'), + 'src/index.ts': "export const sibling = () => import(/* rspackIgnore: true */ './bin/package-build-fixture.js');\n", }); const failure = await packageBuildFailure(root); 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-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index de6bc9ed6..bbcf06102 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,6 @@ import { execFile as executeFile, spawnSync } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +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'; import { promisify } from 'node:util'; @@ -15,7 +16,6 @@ import { runCursorHostInstallProof, runHostUninstallProof, type BuiltHostInstallFixture, - type HostInstallCommand, } from './support/host-install.ts'; import { installedEnvironment, @@ -49,7 +49,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,10 +64,10 @@ beforeAll(async () => { } fixturePackageVersion = packageDocument.version; delete packageDocument.private; - packageDocument.bin = { [pluginName]: `./dist/bin/${pluginName}.js` }; - packageDocument.files = ['artifact', 'dist', 'README.md']; + 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'), ]); @@ -76,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", )); }, }); @@ -86,11 +85,17 @@ 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', ['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 +105,86 @@ 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 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); + 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 +192,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 +205,6 @@ beforeAll(async () => { cli: sourceFixture.cli, root: cleanupRoot, }); - packedInstallCommand = Object.freeze({ cwd: consumer, executable: installedBin }); }, 300_000); afterAll(async () => { @@ -139,11 +219,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 +232,6 @@ claudePluginIt( async () => { const report = await runClaudeHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -189,7 +263,6 @@ codexPluginIt( async () => { const report = await runCodexHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -226,7 +299,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 +349,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 +369,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 +387,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/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 2aa7943a0..6f065fbef 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); @@ -211,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`); diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index c69d27560..468c34723 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); @@ -30,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; @@ -45,7 +55,11 @@ const webReadyKeys: readonly (keyof WebReadyDocument)[] = ['app', 'port', 'resou let consumer = ''; let project = ''; let artifact = ''; +let packageRoot = ''; +let artifactBin = ''; let bin = ''; +let installedPackageRoot = ''; +let packedPaths: readonly string[] = []; const spawned = new Set(); const observedProcessIds = new Set(); @@ -80,6 +94,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 +111,65 @@ 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), + ]); + const { stdout: packJson } = await execFile('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + 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', + ...lifecycleInstallArguments, + ...(npmMajor >= 12 ? [] : [tarball]), + ], { cwd: installedConsumer, env: installedEnvironment() }); + installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); + const packageDocument = JSON.parse( + await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), + ) 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. @@ -159,6 +232,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); @@ -200,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/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 0983a234f..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'; @@ -8,6 +8,7 @@ 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'; @@ -53,8 +54,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 +72,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 +85,6 @@ const diagnostics = ( packerRewritesWorkspaceProtocols = false, ): Promise => packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, model: result.build.model, packageBuild: result.build.packageBuild!, packOutput, @@ -126,8 +124,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 +156,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 +178,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 +187,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); } }; @@ -235,12 +250,12 @@ it('reports installed dependencies a consumer never needs as AB7014, per field', // 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'); + expect(result.build.packageBuild!.evidence.assets.find((asset) => asset.path === 'index.js')?.packages) + .toEqual([]); 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.', + + '"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. @@ -253,7 +268,7 @@ it('reports installed dependencies a consumer never needs as AB7014, per field', }, )); -/** The shared fixture's package build, its evidence record's `packages` set per recorded asset path (`dist/…`). */ +/** 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 { @@ -273,13 +288,11 @@ it('reports a dependency only compiled dist bundles inlined as AB7014, naming th }, async () => { const paths = result.build.packageBuild!.files.map((file) => file.path); - expect(paths).toEqual(expect.arrayContaining(['bin/installer-fixture.js', 'index.js'])); + expect(paths).toContain('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'], + 'index.js': ['left-pad', 'react', 'tiny-pkg'], }), packOutput: result.pack, packerRewritesWorkspaceProtocols: false, @@ -289,9 +302,9 @@ it('reports a dependency only compiled dist bundles inlined as AB7014, naming th // 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 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 dist\/index\.js; 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']); }, @@ -312,7 +325,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, @@ -703,7 +715,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 () => { @@ -742,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'); @@ -752,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/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 575496013..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'; @@ -38,9 +39,9 @@ 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 { manifestInventory, readInstallReceipt } from '../../src/install/receipt.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -184,12 +185,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 +195,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. */ @@ -427,6 +421,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[], @@ -437,7 +445,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 +453,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, @@ -536,7 +529,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.`, @@ -985,6 +978,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.')); @@ -1130,6 +1134,17 @@ export const runCodexHostInstallProof = async ( `Codex installed plugin manifest interface did not advertise ${capability}.`, ); } + const doctor = await runDoctor({ + commandRunner: doctorCommandRunner(environment), + 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)}`, + ); const sameVersionRebuild = await proveSameVersionRebuild({ bundle: fixture.bundles.codex, host: 'codex', @@ -1717,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.')); @@ -1732,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({ 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/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/rstest.integration-tests.ts b/rstest.integration-tests.ts index c6724cacd..eb78663e4 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/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/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/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 40ebb5a96..667cf04aa 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -423,7 +423,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 @@ -431,7 +431,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` | @@ -584,8 +584,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/index.mdx b/website/docs/en/guide/distribution/index.mdx index 753db93ca..ac41160ca 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,18 @@ 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). +`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 +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..0871026ce 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 @@ -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 @@ -75,21 +77,24 @@ 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: +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 +host installation** — installing a package never mutates a host's plugin state. -```sh - install [--scope ] [--json] -``` +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. -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. +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 @@ -100,8 +105,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..1abb94c7c 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -245,17 +245,29 @@ 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`. 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 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 +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 the @@ -296,12 +308,12 @@ names or runs, or a prebuilt payload declares in `runtimeDependencies` (`defineP | 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. | -| `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 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 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`. | +| `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/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index 5a88cea91..f90c53341 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,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 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/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/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 1aba9b0db..7c98dab9b 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/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 ``` 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/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 8f376e6ce..7b042757b 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -349,7 +349,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`。 @@ -371,7 +371,7 @@ ENOENT)或 `invalid`。 ### 写入器发出的分节 -文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 2`) +文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 3`) 是消费者读取的部分。**编译器记录**(`compiler`,`recordVersion: 1`)是 这次运行的操作性账目——生产者、项目摘要、来源、适配器修订、固定的 schema、验证结果——它的变化不会推动 `manifestVersion`;消费者读取方 @@ -379,7 +379,7 @@ schema、验证结果——它的变化不会推动 `manifestVersion`;消费 | 分节 | 记录什么 | | --- | --- | -| `manifestVersion` | `2` | +| `manifestVersion` | `3` | | `application` | 与宿主无关的身份:`id`、`name`、`version`,可选的 `description` | | `files[]` | 除清单自身外的每个发出文件(`path`、`bytes`、`sha256`、`kind`,可选的 `mode`) | | `runtime` | 来自 `NormalizedPlugin.runtime` 的 `{ node }` | @@ -512,7 +512,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/index.mdx b/website/docs/zh/guide/distribution/index.mdx index 1cee726b3..2972b67bc 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,14 @@ 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。 +`validate --artifact` 等仅面向产物的命令仍应接收纯组合产物;清单感知的 `install --from` 路径则接受 npm 根目录。 + +对于生成的路由式 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..c88b9f416 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。' --- # 宿主安装 @@ -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 副本中展开该标准的占位符, @@ -61,19 +63,21 @@ node ./install.mjs 产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 -## 相对包的安装器 bin +## npm 安装的 CLI -当包输出与选中了内置宿主的根目录一起发布时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 -插件名,否则使用 `-install`(两者都被占用时追加数字后缀)。请在 `package.json` 中把这个 -名字映射到生成的 `dist/bin/*.js` 文件;消费者随后运行: +npm 包根目录包含组合插件清单拥有的全部字节,并额外带有 npm 专属元数据与手写包入口。 +`validate --artifact` 等命令仍接收纯组合产物;清单感知的 `install --from` 接受任一根目录。 +对于生成的路由式 CLI,`package.json` 的 `bin` 指向产物公开的同一个 +`bin/.mjs` 可执行文件,不会再次编译,也没有包装器。因此它的完整命令面(包括 `web`)从 +`node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 +绝不会改动宿主的插件状态。 -```sh - install [--scope ] [--json] -``` +仓库中的 `AGENTS.md` 作为作者编写的包元数据原样复制。生成的投影说明绝不会覆盖它;路径冲突会让包构建失败。 -帮助信息只列出实际构建出来的宿主。该可执行文件通过 `import.meta.url` 在已安装包旁边定位产物目录,绝不 -依赖调用方的工作目录,因此无论从哪里调用,它在 `node_modules` 中都能工作。**任何 npm 生命周期都不会执行 -安装**——安装一个包绝不会改动宿主的插件状态。 +请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装任一受支持宿主。 +这条清单感知路径会把 npm 专属元数据与手写包入口排除在宿主归属比较之外。 +打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 +`agent-bundle install --from`。 ## 开发期安装是另一回事 @@ -83,7 +87,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..fa2814124 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -213,14 +213,22 @@ 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` 中的安装期依赖字段。作者写入的 `package.json.files` 与 `.npmignore` 规则不会缩窄 +生成根目录:每个所选隐藏宿主目录与每个清单文件都必须进入包,否则 prepack 会在发布前失败。构建会把每个依赖内联进手写包入口与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `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/` 下已知的手写输出路径会改写到生成根目录,并对照 +已暂存文件校验。不支持的 Node 命令或缺失的源码资源会报告 `AB4768`;包构建器绝不会为了满足生命周期路径 +而复制整棵源码树。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 @@ -256,12 +264,12 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 | 代码 | 含义 | | --- | --- | -| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请把 `dist` 与产物目录加入包的 `files` 允许列表。 | -| `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | -| `AB7012` | 某个 `package.json` bin 指向了打包后的 `dist` 输出之外(包括 `src/`),或指名了一个被 npm 忽略的文件。请把它指向生成的 `dist/bin` 文件。 | +| `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` 字段点名了消费者无需安装的包。必须有上列三类证据之一才算使用;已编译 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` 下。 | +| `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` 不一致时会 diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index 7b79e0de3..f892bde24 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 标记 npm 根目录保留清单所拥有的 + 字节与可执行文件这一规范保证;版本 2 已预留同样的 `distribution.install` 指针形状。版本 2 把 `targets[]` 重命名为 `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 `projections[].documents`。 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 文档决定每个服务器的运行时位置(相对覆盖值从该服务器的执行目录解析); diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index e9f5001e6..34b81fe9b 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` 名。 |