diff --git a/.changeset/scaffold-package-installers.md b/.changeset/scaffold-package-installers.md new file mode 100644 index 000000000..081002a09 --- /dev/null +++ b/.changeset/scaffold-package-installers.md @@ -0,0 +1,6 @@ +--- +"create-agent-bundle": minor +--- + +Adopt framework-generated package installers and prepack inventory validation +in the publishable CLI and MCP scaffold templates. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbf7c1904..a55a06e42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,3 +49,4 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true diff --git a/docs/preview-packages.md b/docs/preview-packages.md index 9d92bb2b3..da52920cc 100644 --- a/docs/preview-packages.md +++ b/docs/preview-packages.md @@ -3,7 +3,11 @@ Nothing is published to npm yet, deliberately: the current package names are placeholders, and npm publishing is deferred until the final name is chosen (it will then use [npm package provenance](https://docs.npmjs.com/generating-provenance-statements); -the manifests and release workflow are already wired for it). Until then +the publish step exports `NPM_CONFIG_PROVENANCE=true` and runs the packed +release gates before `changeset publish`). Before enabling that path, the +release owner must resolve the repository-wide `"access": "restricted"` +policy for `agent-bundle`, which does not currently override it with +`publishConfig.access`. Until then pkg.pr.new is the release channel. Every CI package-preview run publishes real, installable tarballs of all three publishable workspace packages to [pkg.pr.new](https://pkg.pr.new) — a free continuous-release registry keyed by commit SHA and pull request. diff --git a/package.json b/package.json index 882a0e96a..eaf992893 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test:host-install:session:claude": "pnpm build && AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION=1 pnpm test:host-install:session", "changeset": "changeset", "version-packages": "changeset version", - "release": "pnpm build && changeset publish", + "release": "pnpm check:release && changeset publish", "preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/create-agent-bundle'", "pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json", "audit:release": "pnpm lint:package && attw --pack --profile esm-only packages/agent-bundle && node scripts/audit-packed-release.mjs", diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a279dda25..c8da14932 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -520,8 +520,8 @@ then Hooks and Scripts, then the interactive MCP App. Run the complete local delivery gate with `pnpm check && pnpm check:release`. `pnpm check:release` is release-only: its exact package-script components are -`pnpm pack:dry-run`, `pnpm audit:release`, and `pnpm test:packed`, and it does not replace -`pnpm check`. +`pnpm pack:dry-run`, `pnpm audit:release`, and `pnpm test:packed:release`, and it does not replace +`pnpm check`. `pnpm release` runs that release gate before `changeset publish`. Native Claude/Codex smokes stay intentionally opt-in and skipped in ordinary CI. npm publishing is deferred until the release owner picks the final package name/scope and license; pkg.pr.new previews are the interim channel, and the first npm release will use npm diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index db844a2fa..c8b32ef87 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -16,8 +16,14 @@ npm run check # validate + build + typecheck + test # after a build node dist/bin/my-agent-plugin.js World + +# after publishing/installing the package +npx my-agent-plugin-install install cursor ``` +Installing the npm package does not mutate any host; run the generated +`my-agent-plugin-install install ` command explicitly. + ## Layout - `agent-bundle.config.ts` — the one typed config; the CLI is also declared diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json index 3203b454c..ecfffcf92 100644 --- a/packages/create-agent-bundle/templates/cli-tool/package_json +++ b/packages/create-agent-bundle/templates/cli-tool/package_json @@ -8,10 +8,12 @@ }, "files": [ "dist", + "artifact", "README.md" ], "bin": { - "my-agent-plugin": "./dist/bin/my-agent-plugin.js" + "my-agent-plugin": "./dist/bin/my-agent-plugin.js", + "my-agent-plugin-install": "./dist/bin/my-agent-plugin-install.js" }, "exports": { ".": { @@ -23,6 +25,7 @@ "build": "agent-bundle build --json --output artifact", "check": "npm run validate && npm run build && npm run typecheck && npm run test", "dev": "agent-bundle dev", + "prepack": "agent-bundle prepack --json --output artifact", "test": "rstest tests", "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "agent-bundle validate --json" diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md index c5e7b29d8..392189ddf 100644 --- a/packages/create-agent-bundle/templates/mcp-server/README.md +++ b/packages/create-agent-bundle/templates/mcp-server/README.md @@ -15,14 +15,21 @@ npm run test # plain module tests npm run test:routes # route-unit pool 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 cursor ``` +Installing the npm package does not mutate any host; run the generated +`my-agent-plugin install ` command explicitly. + ## Layout - `agent-bundle.config.ts` — plugin identity, targets, and project policy. - `src/mcp/status/tools/report-status.tsx` — the complete MCP tool route. - `src/scripts/check-status.ts` — an artifact script with a generated process envelope. -- `src/status.ts` — shared domain logic covered by `tests/`. +- `src/status.ts` — shared domain logic covered by `tests/` and published as + the package library export. - `rstest.route-unit.config.ts`, `rstest.projection.config.ts` — the two framework-generated test pools (see below). diff --git a/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts index 0bafe8bc3..82340a92b 100644 --- a/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/mcp-server/agent-bundle.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'agent-bundle'; export default defineConfig({ + lib: './src/status.ts', plugin: { description: 'A stdio MCP server plugin scaffolded from the mcp-server template.', name: 'my-agent-plugin', diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json index 953e47234..9988ec984 100644 --- a/packages/create-agent-bundle/templates/mcp-server/package_json +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -7,10 +7,25 @@ "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", + "import": "./dist/status.js" + } + }, "scripts": { "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", "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 74cbc121d..3ccf68225 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 @@ -1,12 +1,13 @@ import { execFile as executeFile } from 'node:child_process'; -import { readFile, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { afterAll, expect, it } from '@rstest/core'; -import { installedEnvironment, npmInstallArguments } from '../../agent-bundle/tests/support/shared-pack.ts'; +import { installedEnvironment, npmInstallArguments, packOutputFromJson } from '../../agent-bundle/tests/support/shared-pack.ts'; import { cleanupScaffoldFixture, expectCleanValidate, npmRun, scaffoldProject } from './support/scaffold-fixture.ts'; const execFile = promisify(executeFile); @@ -32,6 +33,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'); // 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 @@ -86,6 +88,7 @@ it.concurrent('scaffolds the cli-tool template with a framework-built bin, lib, const checked = await npmRun(projectRoot, 'check'); expect(checked).toContain('tests/cli.test.ts'); await expectCleanValidate(projectRoot); + await npmRun(projectRoot, 'prepack'); // The src/cli.ts convention produced the executable package bin. const bin = join(projectRoot, 'dist', 'bin', 'greeter.js'); @@ -105,4 +108,21 @@ it.concurrent('scaffolds the cli-tool template with a framework-built bin, lib, await expect(execFile(process.execPath, [ join(projectRoot, 'artifact', 'portable', 'scripts', 'greeter.mjs'), 'World', ], { cwd: projectRoot, env: installedEnvironment() })).resolves.toMatchObject({ stdout: 'Hello, World!\n' }); + + const packDestination = await mkdtemp(join(tmpdir(), 'create-agent-bundle-cli-pack-')); + try { + const { stdout } = await execFile('npm', [ + 'pack', '--json', '--ignore-scripts', '--pack-destination', packDestination, + ], { cwd: projectRoot, 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/portable/plugin.json'); + expect(packedPaths).toContain('artifact/codex/.codex-plugin/plugin.json'); + expect(packedPaths).toContain('artifact/claude/.claude-plugin/plugin.json'); + expect(packedPaths).toContain('dist/bin/greeter-install.js'); + } finally { + await rm(packDestination, { force: true, recursive: true }); + } }, 600_000); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index ba1200cda..2c3b447ec 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -99,6 +99,64 @@ describe('scaffold', () => { } }); + it('scaffolds publishable package fields for templates with package builds', async () => { + const [cliTool, mcpServer] = await Promise.all([ + scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), + scaffoldTemplate('mcp-server', { pluginName: 'status-plugin' }), + ]); + try { + const cliManifest = JSON.parse(await readFile(join(cliTool.root, 'package.json'), 'utf8')) as { + readonly bin: Record; + readonly files: readonly string[]; + readonly scripts: Record; + }; + 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'); + + const mcpManifest = JSON.parse(await readFile(join(mcpServer.root, 'package.json'), 'utf8')) as { + readonly bin: Record; + readonly exports: Record; + readonly files: readonly string[]; + readonly private: boolean; + readonly scripts: Record; + }; + 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.exports).toEqual({ + '.': { types: './dist/status.d.ts', import: './dist/status.js' }, + }); + expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + await expect(readFile(join(mcpServer.root, 'agent-bundle.config.ts'), 'utf8')) + .resolves.toContain("lib: './src/status.ts'"); + } finally { + await Promise.all([ + rm(cliTool.root, { force: true, recursive: true }), + rm(mcpServer.root, { force: true, recursive: true }), + ]); + } + }); + + it('leaves the skills-only template without package-build packaging fields', async () => { + const { root } = await scaffoldTemplate('minimal'); + try { + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { + readonly bin?: unknown; + readonly files?: unknown; + readonly scripts: Record; + }; + expect(manifest.bin).toBeUndefined(); + expect(manifest.files).toBeUndefined(); + expect(manifest.scripts.prepack).toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + for (const template of ['minimal', 'cli-tool'] as const) { it(`scaffolds the ${template} template without a runtime tarball`, async () => { const { root } = await scaffoldTemplate(template, { withRuntimeTarball: false }); @@ -135,7 +193,10 @@ describe('scaffold', () => { if (files.includes('src/mcp/status/tools/report-status.tsx')) { expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); } - expect(manifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); + expect(manifest.bin).toEqual({ + 'status-plugin': './dist/bin/status-plugin.js', + 'status-plugin-install': './dist/bin/status-plugin-install.js', + }); const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); expect(config).toContain("name: 'status-plugin'"); expect(config).toContain("'status-plugin': './src/cli.ts'");