diff --git a/.changeset/packaged-host-installers.md b/.changeset/packaged-host-installers.md new file mode 100644 index 000000000..a8ba97f6d --- /dev/null +++ b/.changeset/packaged-host-installers.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Generate package-relative host installer bins for publishable plugin packages and add the `agent-bundle prepack` inventory, freshness, bin-target, and version-agreement gate. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e66a36050..b07a1746d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -26,11 +26,21 @@ gate a build, a validation, or a dev rebuild. | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | +| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7xxx` | Project preparation and development rebuilds. | | `AB7300`–`AB7316` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health, and durable-state inventory. | | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | +## npm prepack gate (`AB7010`–`AB7013`) + +| 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. | +| `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | + ## Declaration generation (`AB4716`) A `lib` entry with `dts` enabled compiles its source directory as its own diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 7d9ea9dfd..765656a33 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -21,6 +21,21 @@ node-consumable package build under `dist/` — the outputs `package.json` | `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | +- When package outputs and at least one Claude, Codex, or Cursor host pack are + built inside the project, 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`. Declare the matching + `package.json` `bin` value. Its grammar is + `install [--scope ] [--json]`; help lists only built hosts. + The baked URL resolves the shipped artifact directory from `import.meta.url`, + never the caller's working directory, and delegates to the same + `installBundle` implementation as `agent-bundle install`. +- `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. Use it as an npm `prepack` script; + `--ignore-scripts` prevents recursion and npm install never runs the host + installer. - The package build runs for `agent-bundle build` (CLI, or `build({ packageOutputs: true })` through the API) and inside the `agent-bundle dev` rebuild loop (see “Dev-watch of the package build” diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 5abb8b1e2..a279dda25 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -67,6 +67,7 @@ manifests at files inside those payloads without compiling them. Payload files c | Command | Purpose | | --- | --- | | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | +| `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | | `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, and `--json` supported). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. | @@ -128,6 +129,15 @@ Cursor installation is user-scoped. Claude also accepts `--scope project` and `--scope local`; Codex is user-scoped. A source-free artifact root is accepted by `--from` when it contains the selected host target directory. +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 ] [--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. + ## Developer workbench `agent-bundle dev` serves a loopback-only prebuilt workbench. It shows project diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 747e1667c..1e6019460 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ 'event-ipc': './src/events/ipc.ts', 'event-project': './src/events/project.ts', index: './src/index.ts', + 'install-entry': './src/install-entry.ts', 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', 'mcp-apps': './src/mcp-apps.ts', 'mcp-entry': './src/mcp-entry.ts', diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 2867b12a1..f55db9f19 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,5 +1,7 @@ +import { execFile as executeFile } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; import { AGENT_STATE_DEFAULT_BUDGETS, @@ -11,6 +13,11 @@ import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; +import { + packInventoryDiagnostics, + packOutputFromJson, + type PackOutput, +} from './build/pack-inventory.ts'; import type { CapabilityState } from './core/capabilities.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { emptyCompiledRouteGraph } from './routes/graph.ts'; @@ -333,6 +340,11 @@ export interface BuildProjectResult { readonly projectContext: ProjectContext; } +export interface PrepackResult { + readonly build: BuildProjectResult; + readonly pack: PackOutput; +} + export interface ArtifactOperationOptions extends ProjectOptions { readonly artifact?: string; } @@ -716,6 +728,7 @@ export const build = async (options: BuildOptions): Promise let packageBuild: PackageBuildResult | undefined; if (packageOutputRoot !== undefined) { packageBuild = await buildPackageOutputs({ + ...(isInsideOrEqual(prepared.root, output) ? { artifactRoot: output } : {}), model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), @@ -731,6 +744,33 @@ export const build = async (options: BuildOptions): Promise }); }; +const execFile = promisify(executeFile); + +export const prepack = async (options: BuildOptions): Promise => { + const result = await build({ ...options, packageOutputs: true }); + if (result.packageBuild === undefined) { + throw new DiagnosticError([{ + code: 'AB7010', + message: 'Prepack requires at least one framework-owned package output.', + recovery: 'Declare a package bin or lib entry before running agent-bundle prepack.', + severity: 'error', + }]); + } + const { stdout } = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: resolve(options.root), + }); + const pack = packOutputFromJson(stdout); + const diagnostics = await packInventoryDiagnostics({ + artifactRoot: result.build.outputRoot, + model: result.model, + packageBuild: result.packageBuild, + packOutput: pack, + projectRoot: options.root, + }); + if (diagnostics.length > 0) throw new DiagnosticError(diagnostics); + return deepFreeze({ build: result, pack }); +}; + /** Every eval refusal reaches a caller as one actionable diagnostic, never a raw service error. */ const evalDiagnostics: Readonly { throw new Error('Unable to locate the agent-bundle/cli-entry runtime module for generated CLI executables.'); }; +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({', + ` artifactRelativeUrl: ${JSON.stringify(options.artifactRelativeUrl)},`, + ` hosts: Object.freeze(${stableJson(options.hosts)}),`, + ` name: ${JSON.stringify(options.name)},`, + '}));', + '', +].join('\n'); + export interface GeneratedCliBinEntryOptions { readonly commands: readonly CompiledCliCommand[]; readonly plugin: { readonly description?: string; readonly name: string; readonly version: string }; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts new file mode 100644 index 000000000..12080625e --- /dev/null +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -0,0 +1,194 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +import type { NormalizedPlugin } from '../core/types.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { installSurfaceRequirements } from '../install/surface.ts'; +import { artifactManifestName } from './emit.ts'; +import { parseArtifactManifest } from './manifest.ts'; +import type { PackageBuildResult } from './package-build.ts'; + +export interface PackOutputFile { + readonly path: string; +} + +export interface PackOutput { + readonly filename: string; + readonly files: readonly PackOutputFile[]; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +export const packOutputFromJson = (stdout: string): PackOutput => { + const parsed: unknown = JSON.parse(stdout); + const entries = Array.isArray(parsed) + ? parsed + : isRecord(parsed) + ? Object.values(parsed) + : undefined; + if (entries === undefined) { + throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); + } + if (entries.length !== 1) { + throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); + } + const [entry] = entries; + if (!isRecord(entry) || typeof entry.filename !== 'string' || !Array.isArray(entry.files)) { + throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); + } + const files = entry.files.map((file) => { + if (!isRecord(file) || typeof file.path !== 'string') { + throw new TypeError('npm pack --json returned an invalid file entry.'); + } + return Object.freeze({ path: file.path }); + }); + return Object.freeze({ filename: entry.filename, files: Object.freeze(files) }); +}; + +const toPosixRelative = (root: string, path: string): string => + relative(resolve(root), resolve(path)).replaceAll('\\', '/'); + +const exists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +}; + +const jsonRecord = async (path: string): Promise>> => { + const value: unknown = JSON.parse(await readFile(path, 'utf8')); + if (!isRecord(value)) throw new TypeError(`Expected a JSON object at ${JSON.stringify(path)}.`); + return value; +}; + +const hostManifestPaths = (target: string): readonly string[] => { + switch (target) { + case 'claude': + return Object.freeze(['.claude-plugin/plugin.json']); + case 'codex': + return Object.freeze(['.codex-plugin/plugin.json']); + case 'cursor': + return Object.freeze(['.cursor-plugin/plugin.json']); + case 'plugin': + return Object.freeze([ + '.claude-plugin/plugin.json', + '.codex-plugin/plugin.json', + '.cursor-plugin/plugin.json', + ]); + case 'portable': + return Object.freeze(['plugin.json']); + default: + return Object.freeze([]); + } +}; + +const binEntries = (value: unknown): readonly [string, string][] => { + if (typeof value === 'string') return Object.freeze([['bin', value] as const]); + if (!isRecord(value)) return Object.freeze([]); + return Object.freeze(Object.entries(value) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .sort(([left], [right]) => left.localeCompare(right))); +}; + +const diagnostic = (code: string, message: string, recovery: string): Diagnostic => Object.freeze({ + code, + message, + recovery, + severity: 'error', +}); + +export const packInventoryDiagnostics = async (options: { + readonly artifactRoot: string; + readonly model: NormalizedPlugin; + readonly packageBuild: PackageBuildResult; + readonly packOutput: PackOutput; + 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 manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + const packageDocument = await jsonRecord(join(projectRoot, '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}`, + ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), + ...manifest.targets.flatMap((target) => + installSurfaceRequirements(target.name).map((path) => `${artifactPrefix}/${target.name}/${path}`)), + ]); + if (await exists(join(projectRoot, 'README.md'))) expected.add('README.md'); + + const diagnostics: Diagnostic[] = []; + const missing = [...expected].filter((path) => !packed.has(path)).sort((left, right) => left.localeCompare(right)); + if (missing.length > 0) { + diagnostics.push(diagnostic( + 'AB7010', + `npm pack omits expected files: ${missing.map((path) => JSON.stringify(path)).join(', ')}.`, + 'Add the exact paths (including dist and the artifact directory) to the package.json "files" allowlist.', + )); + } + + const stale: string[] = []; + for (const file of manifest.files) { + const bytes = await readFile(join(artifactRoot, file.path)); + if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) stale.push(`${artifactPrefix}/${file.path}`); + } + if (stale.length > 0) { + diagnostics.push(diagnostic( + 'AB7011', + `Artifact files no longer match their manifest hashes: ${stale.sort().map((path) => JSON.stringify(path)).join(', ')}.`, + 'Run agent-bundle prepack again without modifying generated artifacts.', + )); + } + + const invalidBins = binEntries(packageDocument.bin) + .filter(([, target]) => { + const normalized = target.replace(/^\.\//u, ''); + return !normalized.startsWith(`${packagePrefix}/`) || 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]) => + `${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".', + )); + } + + const versions: Array = [ + ['package.json', packageDocument.version], + ['normalized plugin', options.model.metadata.version], + ['artifact provenance', manifest.project.packageVersion], + ]; + for (const target of manifest.targets) { + for (const path of hostManifestPaths(target.name)) { + const absolute = join(artifactRoot, target.name, path); + if (await exists(absolute)) { + versions.push([`${target.name}/${path}`, (await jsonRecord(absolute)).version]); + } + } + } + const expectedVersion = options.model.metadata.version; + const disagreements = versions + .filter(([, version]) => version !== expectedVersion) + .map(([source, version]) => `${source}=${JSON.stringify(version)}`) + .sort((left, right) => left.localeCompare(right)); + if (disagreements.length > 0) { + diagnostics.push(diagnostic( + 'AB7013', + `Release versions disagree with normalized plugin version ${JSON.stringify(expectedVersion)}: ${disagreements.join(', ')}.`, + 'Set package.json, plugin metadata, generated host manifests, and artifact provenance to one semantic version.', + )); + } + + 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 46f2a772c..f150b9bdc 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -14,7 +14,10 @@ import { cliEntryRuntimeSpecifier, generatedCliBinEntrySource, generatedExecutableEntrySource, + generatedInstallBinEntrySource, generatedRenderedRouteWorkerSource, + installEntryRuntimePath, + installEntryRuntimeSpecifier, } from './entry-shell.ts'; import { projectMeta } from './meta.ts'; import type { BundledOutputEvidence } from './provenance.ts'; @@ -102,6 +105,10 @@ 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([]); @@ -175,6 +182,35 @@ export const planPackageEntries = async ( : { virtualSource: generatedExecutableEntrySource({ entrySource: bin.source, exportName }) }), }); } + const installHosts = Object.freeze((['claude', 'codex', 'cursor'] as const) + .filter((host) => model.targets.some((target) => target.name === host || target.name === 'plugin'))); + if ( + installHosts.length > 0 && + options.artifactRoot !== undefined && + options.packageOutputRoot !== undefined + ) { + const name = packageBuild.bins.some((bin) => bin.name === model.metadata.name) + ? `${model.metadata.name}-install` + : model.metadata.name; + const outputRelativePath = `bin/${name}.js`; + const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath)); + const relativeArtifact = relative(emittedBinDirectory, options.artifactRoot).replaceAll('\\', '/'); + 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({ @@ -229,6 +265,7 @@ const declarationSource = (sourceDir: string, declarationPath: string): string | }; export const buildPackageOutputs = async (options: { + readonly artifactRoot?: string; readonly model: NormalizedPlugin; readonly projectRoot: string; readonly tools?: AgentBundleToolsConfig; @@ -241,7 +278,10 @@ export const buildPackageOutputs = async (options: { const dtsTsconfig = packageBuild.lib?.dts === true && libSourceDir !== undefined ? await synthesizeDtsTsconfig({ projectRoot, sourceDir: libSourceDir }) : undefined; - const entries = await planPackageEntries(options.model, dtsTsconfig?.path); + 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; @@ -251,13 +291,18 @@ export const buildPackageOutputs = async (options: { await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); try { - const cliRuntimeShell = entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) - ? cliEntryRuntimePath() - : undefined; + const ignoredRuntimeRoots = Object.freeze([ + ...(entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) + ? [runtimeIgnoredRoot(cliEntryRuntimePath())] + : []), + ...(entries.some((entry) => entry.aliases?.[installEntryRuntimeSpecifier] !== undefined) + ? [runtimeIgnoredRoot(installEntryRuntimePath())] + : []), + ]); const evidence = await buildPackageEntries({ cwd: projectRoot, entries, - ...(cliRuntimeShell === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(cliRuntimeShell)] }), + ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), logLevel: 'error', meta: projectMeta(options.model.metadata), outputRoot: stageRoot, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f62753165..79e696a1d 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -13,6 +13,7 @@ import type { build, compareEvals, inspect, + prepack, runEvals, startDevServer, validate, @@ -53,6 +54,7 @@ interface CliSignalSource { export interface CliDependencies { readonly installBundle?: typeof installBundle; + readonly prepack?: typeof prepack; readonly runDoctor?: typeof runDoctor; /** Injectable only to make foreground shutdown behavior deterministic in tests. */ readonly signals?: CliSignalSource; @@ -252,6 +254,12 @@ const writeHumanBuild = (output: Output, result: Awaited>): void => { + output.write( + `Prepack validated ${result.pack.files.length} file(s) for ${result.build.model.metadata.name}\n`, + ); +}; + const writeHumanInstall = (output: Output, result: InstallResult): void => { const destination = result.destination ?? result.bundleRoot; output.write( @@ -476,6 +484,20 @@ export const runCli = async ( else writeHumanBuild(stdout, result); }); + const prepackCommand = configureSourceOptions( + program.command('prepack').description('Build and validate the npm pack inventory'), + ).option('--output ', 'Artifact output path relative to --root'); + prepackCommand.action(async (options: BuildCommandOptions) => { + const { prepack } = await import('./api.ts'); + const result = await (dependencies.prepack ?? prepack)({ + ...projectOptions(options), + output: options.output, + packageOutputs: true, + }); + if (options.json === true) writeMachine(stdout, result); + else writeHumanPrepack(stdout, result); + }); + const installCommand = program.command('install') .description('Install a built bundle into a supported host') .argument('', 'Destination host: claude, codex, or cursor', installHost) diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts new file mode 100644 index 000000000..50911c3c2 --- /dev/null +++ b/packages/agent-bundle/src/install-entry.ts @@ -0,0 +1,126 @@ +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 { + installBundle, + type InstallHost, + type InstallResult, + type InstallScope, +} from './install/install.ts'; + +export interface GeneratedInstallProcessOptions { + readonly artifactRelativeUrl: string; + readonly hosts: readonly InstallHost[]; + readonly name: string; +} + +const usage = (options: GeneratedInstallProcessOptions): string => [ + `Usage: ${options.name} install [--scope ] [--json]`, + '', + `Built hosts: ${options.hosts.join(', ')}`, + '', +].join('\n'); + +const diagnosticsFor = (error: unknown): readonly Diagnostic[] => + error instanceof DiagnosticError + ? error.diagnostics + : Object.freeze([Object.freeze({ + code: 'AB7004', + message: error instanceof Error ? error.message : String(error), + severity: 'error' as const, + })]); + +const writeHuman = (result: InstallResult): void => { + const destination = result.destination ?? result.bundleRoot; + process.stdout.write( + `${result.state === 'already-installed' ? 'Already installed' : 'Installed'} ` + + `${result.plugin}@${result.version} for ${result.host} at ${destination}\n`, + ); +}; + +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'; + +interface ParsedInstallArguments { + readonly host: InstallHost; + readonly json: boolean; + readonly scope: InstallScope; +} + +const parseArguments = ( + argv: readonly string[], + options: GeneratedInstallProcessOptions, +): ParsedInstallArguments => { + if (argv[0] !== 'install') { + throw new TypeError(`Expected "install "; built hosts: ${options.hosts.join(', ')}.`); + } + const candidate = argv[1]; + if (candidate === undefined || !isHost(candidate) || !options.hosts.includes(candidate)) { + throw new TypeError( + `Cannot install host ${JSON.stringify(candidate ?? '')}; built hosts: ${options.hosts.join(', ')}.`, + ); + } + let json = false; + let scope: InstallScope = 'user'; + for (let index = 2; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--json') { + json = true; + 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({ host: candidate, json, scope }); +}; + +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 = fileURLToPath(new URL(options.artifactRelativeUrl, import.meta.url)); + 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.', + ); + } + const result = await installBundle({ + from: artifactRoot, + host: parsed.host, + scope: parsed.scope, + }); + if (parsed.json) process.stdout.write(`${stableJson(result)}\n`); + else writeHuman(result); + 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/install.ts b/packages/agent-bundle/src/install/install.ts index 1053ba754..c0b954be2 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -141,9 +141,11 @@ const resolveBundleRoot = async (from: string, host: InstallHost): Promise { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const fixture = async (options: { + readonly bin?: false | string; + readonly target: 'cursor' | 'plugin' | 'portable'; +}): 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: { ${JSON.stringify(options.bin)}: './src/cli.ts' },`]), + " lib: './src/index.ts',", + " plugin: { name: 'installer-fixture' },", + ` targets: [${JSON.stringify(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 ] [--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: '' }); + expect(JSON.parse(installed.stdout)).toMatchObject({ + host: 'cursor', + plugin: 'installer-fixture', + state: 'installed', + version: '1.2.3', + }); + await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); +}, 120_000); + +it('uses the plugin name when free and skips portable-only artifacts', async () => { + const cursorRoot = await fixture({ 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'); + + const pluginRoot = await fixture({ bin: false, target: 'plugin' }); + 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 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/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts new file mode 100644 index 000000000..7a28d6aed --- /dev/null +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -0,0 +1,178 @@ +import { execFile as executeFile } from 'node:child_process'; +import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { prepack } from '../src/api.ts'; +import { runCli } from '../src/cli.ts'; +import { + packInventoryDiagnostics, + packOutputFromJson, + type PackOutput, +} from '../src/build/pack-inventory.ts'; + +const execFile = promisify(executeFile); +const workspaceNodeModules = join(process.cwd(), 'node_modules'); +let cleanupRoot: string; +let projectRoot: string; +let result: Awaited>; +let payloadPath: string; +let payloadBytes: string; + +beforeAll(async () => { + cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-prepack-')); + projectRoot = join(cleanupRoot, 'project'); + await mkdir(join(projectRoot, 'src'), { recursive: true }); + 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', + }, null, 2)}\n`), + writeFile(join(projectRoot, 'README.md'), '# Installer fixture\n'), + writeFile(join(projectRoot, 'agent-bundle.config.ts'), [ + 'export default {', + ' bin: false,', + " lib: './src/index.ts',", + " plugin: { name: 'installer-fixture' },", + " targets: ['cursor'],", + '};', + '', + ].join('\n')), + writeFile(join(projectRoot, 'src', 'index.ts'), 'export const value = 1;\n'), + ]); + result = await prepack({ output: 'host-packs', root: projectRoot }); + payloadPath = join(projectRoot, 'host-packs', 'cursor', 'INSTALL.md'); + payloadBytes = await readFile(payloadPath, 'utf8'); +}); + +afterAll(async () => { + await rm(cleanupRoot, { force: true, recursive: true }); +}); + +const diagnostics = (packOutput: PackOutput = result.pack): Promise => + packInventoryDiagnostics({ + artifactRoot: result.build.build.outputRoot, + model: result.build.model, + packageBuild: result.build.packageBuild!, + packOutput, + projectRoot, + }); + +it('parses npm 11 arrays and npm 12 package-keyed pack output', () => { + const entry = { filename: 'fixture.tgz', files: [{ path: 'dist/index.js' }] }; + expect(packOutputFromJson(JSON.stringify([entry]))).toEqual(entry); + expect(packOutputFromJson(JSON.stringify({ 'installer-fixture': entry }))).toEqual(entry); +}); + +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'); +}); + +it('exposes --root, --output, and --json through the prepack command', async () => { + const calls: unknown[] = []; + const stdout: string[] = []; + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + const code = await runCli( + ['prepack', '--root', projectRoot, '--output', 'host-packs', '--json'], + { stderr: { write: () => undefined }, stdout: { write: (chunk: string) => stdout.push(chunk) } }, + { + prepack: async (options) => { + calls.push(options); + return result; + }, + }, + ); + expect(code).toBe(0); + expect(calls).toEqual([expect.objectContaining({ + output: 'host-packs', + packageOutputs: true, + root: projectRoot, + })]); + expect(JSON.parse(stdout.join(''))).toMatchObject({ + build: { model: { metadata: { name: 'installer-fixture' } } }, + pack: { files: expect.any(Array) }, + }); +}); + +it('reports missing allowlisted artifacts as AB7010', async () => { + const pack = { + ...result.pack, + files: result.pack.files.filter((file) => file.path !== 'host-packs/cursor/INSTALL.md'), + }; + expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ code: 'AB7010' })); +}); + +it('reports stale artifact hashes as AB7011', async () => { + await writeFile(payloadPath, `${payloadBytes}stale\n`); + try { + expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB7011' })); + } finally { + await writeFile(payloadPath, payloadBytes); + } +}); + +it('reports source-relative or unpacked package bins as AB7012', async () => { + const packagePath = join(projectRoot, 'package.json'); + const original = await readFile(packagePath, 'utf8'); + const document = JSON.parse(original) as Record; + document.bin = { 'installer-fixture': './src/cli.ts' }; + await writeFile(packagePath, `${JSON.stringify(document, null, 2)}\n`); + try { + expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB7012' })); + } finally { + await writeFile(packagePath, original); + } +}); + +it('reports package, model, host, and provenance version disagreement as AB7013', async () => { + const packagePath = join(projectRoot, 'package.json'); + const original = await readFile(packagePath, 'utf8'); + const document = JSON.parse(original) as Record; + document.version = '9.0.0'; + await writeFile(packagePath, `${JSON.stringify(document, null, 2)}\n`); + try { + expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB7013' })); + } finally { + await writeFile(packagePath, original); + } +}); + +it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { + const tarballs = join(cleanupRoot, 'tarballs'); + const consumer = join(cleanupRoot, 'consumer'); + const home = join(cleanupRoot, 'home'); + await Promise.all([ + mkdir(tarballs), + mkdir(consumer), + mkdir(join(home, '.cursor'), { recursive: true }), + ]); + const { stdout } = await execFile('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], { + cwd: projectRoot, + }); + const packed = packOutputFromJson(stdout); + await writeFile(join(consumer, 'package.json'), '{"private":true}\n'); + await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(tarballs, packed.filename)], { + cwd: consumer, + }); + const sourceCopy = join(cleanupRoot, 'source-copy'); + await cp(projectRoot, sourceCopy, { recursive: true, filter: (source) => source !== join(projectRoot, 'node_modules') }); + await rm(projectRoot, { force: true, recursive: true }); + + const installedBin = join(consumer, 'node_modules', '.bin', 'installer-fixture'); + const installed = await execFile(installedBin, ['install', 'cursor', '--json'], { + cwd: consumer, + env: { ...process.env, HOME: home }, + }); + expect(JSON.parse(installed.stdout)).toMatchObject({ host: 'cursor', state: 'installed' }); + await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); + expect(await readFile(join(sourceCopy, 'src', 'index.ts'), 'utf8')).toBe('export const value = 1;\n'); +}); diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index ceecb7cab..3d8952f03 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -6,14 +6,16 @@ import { join } from 'node:path'; import { promisify } from 'node:util'; import { isolatedCommandEnvironment } from '../../../../rstest.worker-isolation.ts'; +import { + packOutputFromJson, + type PackOutput as SharedPackOutput, +} from '../../src/build/pack-inventory.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); -export interface SharedPackOutput { - readonly filename: string; - readonly files: readonly { readonly path: string }[]; -} +export { packOutputFromJson }; +export type { SharedPackOutput }; export interface SharedPack { /** First `npm pack --json` entry recorded when the tarball was produced. */ @@ -23,26 +25,6 @@ export interface SharedPack { export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle' | 'runtime'; -export const packOutputFromJson = (stdout: string): SharedPackOutput => { - const parsed: unknown = JSON.parse(stdout); - const entries = Array.isArray(parsed) - ? parsed - : parsed !== null && typeof parsed === 'object' - ? Object.values(parsed) - : undefined; - if (entries === undefined) { - throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); - } - if (entries.length !== 1) { - throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); - } - const [entry] = entries; - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { - throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); - } - return entry as SharedPackOutput; -}; - /** * NODE_PATH-free environment with per-command npm cache and tmp roots under * the worker's RSTEST_WORKER_ID directory (see rstest.worker-isolation.ts), diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 8b2648164..5f7dc24fa 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -37,6 +37,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-adapters.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/lifecycle-replay-dev-server.test.ts', 'packages/agent-bundle/tests/mcp-session-service.test.ts', @@ -44,6 +45,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/package-build.test.ts', 'packages/agent-bundle/tests/path-token-resolver.test.ts', 'packages/agent-bundle/tests/plugin-bundle.test.ts', + 'packages/agent-bundle/tests/prepack.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', 'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts', 'packages/agent-bundle/tests/script-playground-service.test.ts',