Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion examples/audiobook-curator/src/cli-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
41 changes: 41 additions & 0 deletions examples/audiobook-curator/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
1 change: 0 additions & 1 deletion packages/agent-bundle/src/routes/typegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
10 changes: 9 additions & 1 deletion packages/agent-bundle/tests/route-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<typeof route0.inputSchema, typeof route0.resultSchema>;');
expect(first).not.toContain('src/scripts/rebuild-index');
expect(first).not.toContain('"script:rebuild-index"');
expect(first).toContain('export type RouteId = keyof AgentBundleRoutes;');
});

Expand Down
64 changes: 61 additions & 3 deletions packages/create-agent-bundle/src/framework.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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-<version>.tgz.',
);
Comment on lines +26 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve advertised version and URL framework specs

For the mcp-server template, any explicit --framework-version other than the exact pkg.pr.new URL or narrowly named file: tarball now reaches this throw, including 0.1.0 and ordinary npm-compatible URLs. Those inputs remain explicitly advertised by both helpText and the package README, and they worked before this commit by reusing the selected spec for the runtime dependency. Either retain support by deriving/configuring the runtime spec or narrow the public option documentation and validation before scaffolding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #199 (merged as 954a44b). Registry versions, ranges, tags, and npm-compatible URLs are accepted again: any spec that isn't a pkg.pr.new preview URL or file: tarball is mirrored onto @agent-bundle/runtime, restoring the advertised pre-regression pairing behavior. The UsageError message now documents all accepted forms.

};

const localTarballPackageName = async (packageSpec: string): Promise<string> => {
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<string> => {
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;
};

/**
Expand Down
16 changes: 10 additions & 6 deletions packages/create-agent-bundle/src/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,16 +55,20 @@ interface TemplateManifest {
name?: string;
}

const rewriteManifest = (contents: string, request: ScaffoldRequest): string => {
const rewriteManifest = async (contents: string, request: ScaffoldRequest): Promise<string> => {
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`;
Expand Down Expand Up @@ -97,7 +101,7 @@ export const scaffold = async (request: ScaffoldRequest): Promise<readonly strin
continue;
}
let contents = (await readFile(source, 'utf8')).replaceAll(placeholderName, request.pluginName);
if (relativePath === 'package.json') contents = rewriteManifest(contents, request);
if (relativePath === 'package.json') contents = await rewriteManifest(contents, request);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate tarballs before writing scaffold files

When an MCP template uses a missing or mismatched runtime tarball, this newly awaited validation throws only while copyDirectory is processing package_json. If earlier entries have already been emitted—as happens with the checked-in templates—the CLI returns an error but leaves a partially populated target directory, and the next corrected invocation is rejected by assertScaffoldTarget as non-empty. Validate the package pair before copying begins, or remove files created by the failed scaffold.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #199 (merged as 954a44b). scaffold() now resolves and validates the framework/runtime tarball pair before any file is written; a bad tarball fails the run without creating the target directory (regression test asserts ENOENT on the target after failure).

if (relativePath === 'agent-bundle.config.ts') contents = rewriteConfigTargets(contents, request.targets);
await writeFile(destination, contents);
emitted.push(relativePath);
Expand Down
7 changes: 7 additions & 0 deletions packages/create-agent-bundle/tests/framework.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,12 @@ describe('runtimeSpecForFramework', () => {
.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);
});
});
12 changes: 11 additions & 1 deletion packages/create-agent-bundle/tests/scaffold-packed.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 24 additions & 6 deletions packages/create-agent-bundle/tests/scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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',
});
Expand All @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions packages/create-agent-bundle/tests/support/scaffold-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ export const scaffoldProject = async (
return join(runnerRoot, projectName);
};

export const scaffoldProjectWithMismatchedRuntime = async (projectName: string): Promise<void> => {
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<void> => {
await execFile('npm', ['run', script], { cwd: projectRoot, env: installedEnvironment() });
};
Expand Down
Loading