diff --git a/examples/audiobook-curator/src/cli-command.ts b/examples/audiobook-curator/src/cli-command.ts index 3a2812e0f..26939ee46 100644 --- a/examples/audiobook-curator/src/cli-command.ts +++ b/examples/audiobook-curator/src/cli-command.ts @@ -67,8 +67,11 @@ export const runCliCommands = async ( return 0; } const signal = options.signal ?? new AbortController().signal; + signal.throwIfAborted(); const input = command.inputSchema.parse(command.cli.parse(argv.slice(1))); - const result = command.resultSchema.parse(await command.handler(input, { signal })); + const handled = await command.handler(input, { signal }); + signal.throwIfAborted(); + const result = command.resultSchema.parse(handled); write(`${JSON.stringify(result)}\n`); return command.cli.exitCode?.(result) ?? 0; }; diff --git a/examples/audiobook-curator/tests/cli.test.ts b/examples/audiobook-curator/tests/cli.test.ts index c21c2ca84..da946cebe 100644 --- a/examples/audiobook-curator/tests/cli.test.ts +++ b/examples/audiobook-curator/tests/cli.test.ts @@ -39,4 +39,45 @@ describe('audiobook-curator CLI', () => { await expect(runCli(['audit', '/library', '--overwrite'], { operations: operations(), write: () => undefined })) .rejects.toThrow('Unknown option'); }); + + it('does not invoke a command when cancellation was already requested', async () => { + const controller = new AbortController(); + controller.abort(); + let invoked = false; + const output: string[] = []; + + await expect(runCli(['inspect', '/library'], { + operations: { + ...operations(), + inspect: async (input) => { + invoked = true; + return { files: [], operation: 'inspect', root: input.root, totalBytes: 0 }; + }, + }, + signal: controller.signal, + write: (value) => output.push(value), + })).rejects.toThrow('aborted'); + + expect(invoked).toBe(false); + expect(output).toEqual([]); + }); + + it('does not emit a result when cancellation is requested during a command', async () => { + const controller = new AbortController(); + const output: string[] = []; + + await expect(runCli(['inspect', '/library'], { + operations: { + ...operations(), + inspect: async (input) => { + controller.abort(); + return { files: [], operation: 'inspect', root: input.root, totalBytes: 0 }; + }, + }, + signal: controller.signal, + write: (value) => output.push(value), + })).rejects.toThrow('aborted'); + + expect(output).toEqual([]); + }); }); diff --git a/packages/agent-bundle/src/routes/typegen.ts b/packages/agent-bundle/src/routes/typegen.ts index 6504c0b3a..99d16eabf 100644 --- a/packages/agent-bundle/src/routes/typegen.ts +++ b/packages/agent-bundle/src/routes/typegen.ts @@ -10,7 +10,6 @@ const executableRoutes = (graph: CompiledRouteGraph): readonly CompiledAgentRout ...graph.servers.flatMap((server) => server.routes.filter((route) => route.kind !== 'app')), ...(graph.cli?.routes ?? []), ...graph.events, - ...graph.scripts, ].sort((left, right) => left.id.localeCompare(right.id))); const declarationImport = (route: CompiledAgentRoute, index: number): string => { diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts index 46b214844..cb1f773ee 100644 --- a/packages/agent-bundle/tests/route-graph.test.ts +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -469,7 +469,13 @@ it('generates deterministic route-specific types from the compiled graph', () => digest: 'typegen-digest', events: [], providers: [], - scripts: [], + scripts: [{ + config: emptyRouteConfig, + id: 'script:rebuild-index', + kind: 'script', + provenance: { kind: 'conventional', relativePath: 'src/scripts/rebuild-index.ts' }, + source: '/workspace/project/src/scripts/rebuild-index.ts', + }], servers: [{ id: 'mcp:curator', mode: 'generated', @@ -490,6 +496,8 @@ it('generates deterministic route-specific types from the compiled graph', () => expect(second).toBe(first); expect(first).toContain('import type * as route0 from "../src/mcp/curator/tools/inspect.js";'); expect(first).toContain('"tool:curator/inspect": RouteContract;'); + expect(first).not.toContain('src/scripts/rebuild-index'); + expect(first).not.toContain('"script:rebuild-index"'); expect(first).toContain('export type RouteId = keyof AgentBundleRoutes;'); }); diff --git a/packages/create-agent-bundle/src/framework.ts b/packages/create-agent-bundle/src/framework.ts index d7ac8a471..e61c9af5b 100644 --- a/packages/create-agent-bundle/src/framework.ts +++ b/packages/create-agent-bundle/src/framework.ts @@ -1,6 +1,12 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { gunzip } from 'node:zlib'; + import { UsageError } from './options.ts'; const previewPattern = /-preview-([0-9a-f]{7,40})$/u; +const unzip = promisify(gunzip); export type PreviewPackageName = 'agent-bundle' | '@agent-bundle/runtime' | 'create-agent-bundle'; @@ -13,10 +19,62 @@ export const previewFrameworkSpec = (sha: string): string => previewPackageSpec( export const runtimeSpecForFramework = (frameworkSpec: string): string => { const preview = /^(https:\/\/pkg\.pr\.new\/ScriptedAlchemy\/agent-bundle\/)agent-bundle@([0-9a-f]{7,40})$/u.exec(frameworkSpec); if (preview !== null) return `${preview[1]}@agent-bundle/runtime@${preview[2]}`; - if (frameworkSpec.startsWith('file:')) { - return frameworkSpec.replace(/agent-bundle-([^/]+\.tgz)$/u, 'agent-bundle-runtime-$1'); + const localTarball = /^(file:(?:.*[/\\])?)agent-bundle(-[^/\\]+)?\.tgz$/u.exec(frameworkSpec); + if (localTarball !== null) { + return `${localTarball[1]}agent-bundle-runtime${localTarball[2] ?? ''}.tgz`; + } + throw new UsageError( + `Cannot derive a paired @agent-bundle/runtime package from agent-bundle spec "${frameworkSpec}". ` + + 'Use a pkg.pr.new preview URL or a file: tarball named agent-bundle.tgz or agent-bundle-.tgz.', + ); +}; + +const localTarballPackageName = async (packageSpec: string): Promise => { + const path = resolve(packageSpec.slice('file:'.length)); + try { + const archive = await unzip(await readFile(path)); + for (let offset = 0; offset + 512 <= archive.length;) { + const header = archive.subarray(offset, offset + 512); + const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/u, ''); + if (name === '') break; + const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/u, '').trim(); + const size = Number.parseInt(sizeText, 8); + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error(`Invalid tar entry size "${sizeText}".`); + } + const contentsOffset = offset + 512; + if (name === 'package/package.json') { + const manifest = JSON.parse(archive.subarray(contentsOffset, contentsOffset + size).toString('utf8')) as { + readonly name?: unknown; + }; + if (typeof manifest.name === 'string') return manifest.name; + throw new Error('Packed package manifest has no string name.'); + } + offset = contentsOffset + Math.ceil(size / 512) * 512; + } + throw new Error('Packed package manifest was not found.'); + } catch (error) { + throw new UsageError( + `Cannot inspect local package tarball "${packageSpec}": ${error instanceof Error ? error.message : String(error)}`, + ); + } +}; + +/** Derives and verifies a coherent local framework/runtime tarball pair. */ +export const validatedRuntimeSpecForFramework = async (frameworkSpec: string): Promise => { + const runtimeSpec = runtimeSpecForFramework(frameworkSpec); + if (!frameworkSpec.startsWith('file:')) return runtimeSpec; + const [frameworkName, runtimeName] = await Promise.all([ + localTarballPackageName(frameworkSpec), + localTarballPackageName(runtimeSpec), + ]); + if (frameworkName !== 'agent-bundle' || runtimeName !== '@agent-bundle/runtime') { + throw new UsageError( + `Local package tarballs are not a valid agent-bundle/runtime pair: expected agent-bundle and ` + + `@agent-bundle/runtime, received ${JSON.stringify(frameworkName)} and ${JSON.stringify(runtimeName)}.`, + ); } - return frameworkSpec; + return runtimeSpec; }; /** diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index dfdedffa7..d0c1b46b9 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { UsageError, type TargetName } from './options.ts'; -import { runtimeSpecForFramework } from './framework.ts'; +import { validatedRuntimeSpecForFramework } from './framework.ts'; /** * The literal project name every template is written under. Templates stay @@ -55,16 +55,20 @@ interface TemplateManifest { name?: string; } -const rewriteManifest = (contents: string, request: ScaffoldRequest): string => { +const rewriteManifest = async (contents: string, request: ScaffoldRequest): Promise => { const manifest = JSON.parse(contents) as TemplateManifest; manifest.name = request.packageName; + let runtimeSpec: string | undefined; for (const section of [manifest.dependencies, manifest.devDependencies]) { if (section === undefined) continue; for (const [dependency, range] of Object.entries(section)) { if (range !== 'workspace:*') continue; - section[dependency] = dependency === '@agent-bundle/runtime' - ? runtimeSpecForFramework(request.frameworkSpec) - : request.frameworkSpec; + if (dependency === '@agent-bundle/runtime') { + runtimeSpec ??= await validatedRuntimeSpecForFramework(request.frameworkSpec); + section[dependency] = runtimeSpec; + } else { + section[dependency] = request.frameworkSpec; + } } } return `${JSON.stringify(manifest, null, 2)}\n`; @@ -97,7 +101,7 @@ export const scaffold = async (request: ScaffoldRequest): Promise { .toBe('https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@da5df1d'); expect(runtimeSpecForFramework('file:/tmp/agent-bundle-0.1.0.tgz')) .toBe('file:/tmp/agent-bundle-runtime-0.1.0.tgz'); + expect(runtimeSpecForFramework('file:/tmp/agent-bundle.tgz')) + .toBe('file:/tmp/agent-bundle-runtime.tgz'); + }); + + it('fails closed when a paired runtime spec cannot be derived', () => { + expect(() => runtimeSpecForFramework('file:/tmp/framework.tgz')).toThrow(UsageError); + expect(() => runtimeSpecForFramework('0.1.0')).toThrow(UsageError); }); }); diff --git a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts index 936e1f631..cac285a2c 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts @@ -3,10 +3,20 @@ import { join } from 'node:path'; import { afterAll, expect, it } from '@rstest/core'; -import { cleanupScaffoldFixture, expectCleanValidate, npmRun, scaffoldProject } from './support/scaffold-fixture.ts'; +import { + cleanupScaffoldFixture, + expectCleanValidate, + npmRun, + scaffoldProject, + scaffoldProjectWithMismatchedRuntime, +} from './support/scaffold-fixture.ts'; afterAll(cleanupScaffoldFixture); +it('rejects a local framework tarball paired with the wrong runtime package', async () => { + await expect(scaffoldProjectWithMismatchedRuntime('mismatched-runtime-project')).rejects.toMatchObject({ code: 2 }); +}, 600_000); + /** * Per-PR scaffolder smoke: one template through the full consumer journey — * installed scaffolder bin, template scaffold, scaffolder-driven npm install, diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index f7d9363fc..b170a3369 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -1,9 +1,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { gzipSync } from 'node:zlib'; import { describe, expect, it } from '@rstest/core'; +import { runtimeSpecForFramework } from '../src/framework.ts'; import { UsageError, type TargetName } from '../src/options.ts'; import { assertScaffoldTarget, placeholderName, scaffold } from '../src/scaffold.ts'; @@ -12,17 +14,33 @@ const templatesRoot = join(process.cwd(), 'packages', 'create-agent-bundle', 'te const scaffoldTemplate = async ( template: string, overrides: Partial<{ packageName: string; pluginName: string; targets: readonly TargetName[] }> = {}, -): Promise<{ readonly files: readonly string[]; readonly root: string }> => { +): Promise<{ readonly files: readonly string[]; readonly frameworkSpec: string; readonly root: string }> => { const root = await mkdtemp(join(tmpdir(), `create-agent-bundle-${template}-`)); + const packageTarball = (name: string): Buffer => { + const manifest = Buffer.from(JSON.stringify({ name })); + const archive = Buffer.alloc(512 + Math.ceil(manifest.length / 512) * 512 + 1024); + archive.write('package/package.json', 0, 'utf8'); + archive.write(`${manifest.length.toString(8).padStart(11, '0')}\0`, 124, 'ascii'); + manifest.copy(archive, 512); + return gzipSync(archive); + }; + const frameworkTarball = join(root, 'agent-bundle-0.0.0.tgz'); + const runtimeTarball = join(root, 'agent-bundle-runtime-0.0.0.tgz'); + await Promise.all([ + writeFile(frameworkTarball, packageTarball('agent-bundle')), + writeFile(runtimeTarball, packageTarball('@agent-bundle/runtime')), + ]); + const frameworkSpec = `file:${frameworkTarball}`; const files = await scaffold({ - frameworkSpec: 'file:/tmp/agent-bundle-0.0.0.tgz', + frameworkSpec, packageName: overrides.packageName ?? 'status-plugin', pluginName: overrides.pluginName ?? 'status-plugin', targetDirectory: join(root, 'project'), targets: overrides.targets ?? ['portable', 'codex', 'claude'], templateRoot: join(templatesRoot, template), }); - return { files, root: join(root, 'project') }; + await Promise.all([rm(frameworkTarball), rm(runtimeTarball)]); + return { files, frameworkSpec, root: join(root, 'project') }; }; describe('scaffold', () => { @@ -81,7 +99,7 @@ describe('scaffold', () => { }); it('replaces every placeholder and pins the framework spec', async () => { - const { files, root } = await scaffoldTemplate('cli-tool', { + const { files, frameworkSpec, root } = await scaffoldTemplate('cli-tool', { packageName: '@scope/status-plugin', pluginName: 'status-plugin', }); @@ -98,9 +116,9 @@ describe('scaffold', () => { readonly name: string; }; expect(manifest.name).toBe('@scope/status-plugin'); - expect(manifest.devDependencies['agent-bundle']).toBe('file:/tmp/agent-bundle-0.0.0.tgz'); + expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); if (files.includes('src/mcp/status/tools/report-status.tsx')) { - expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe('file:/tmp/agent-bundle-runtime-0.0.0.tgz'); + expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); } expect(manifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); diff --git a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts index ca04c5cf3..d78cd92bb 100644 --- a/packages/create-agent-bundle/tests/support/scaffold-fixture.ts +++ b/packages/create-agent-bundle/tests/support/scaffold-fixture.ts @@ -83,6 +83,27 @@ export const scaffoldProject = async ( return join(runnerRoot, projectName); }; +export const scaffoldProjectWithMismatchedRuntime = async (projectName: string): Promise => { + const { frameworkTarball, root, runnerRoot, scaffolderBin } = await fixture(); + const mismatchedDirectory = join(root, 'mismatched-pair'); + const mismatchedFramework = join(mismatchedDirectory, 'agent-bundle-mismatched.tgz'); + const mismatchedRuntime = join(mismatchedDirectory, 'agent-bundle-runtime-mismatched.tgz'); + await mkdir(mismatchedDirectory, { recursive: true }); + await Promise.all([ + copyFile(frameworkTarball, mismatchedFramework), + copyFile(frameworkTarball, mismatchedRuntime), + ]); + + await execFile(scaffolderBin, [ + projectName, + '--template', 'mcp-server', + '--targets', 'portable', + '--package-manager', 'npm', + '--framework-version', `file:${mismatchedFramework}`, + '--no-install', + ], { cwd: runnerRoot, env: installedEnvironment() }); +}; + export const npmRun = async (projectRoot: string, script: string): Promise => { await execFile('npm', ['run', script], { cwd: projectRoot, env: installedEnvironment() }); };