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
16 changes: 9 additions & 7 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,17 @@ because they change no publishable package.
package needs to move when the other does. Preview tarballs pin the peer to
the same commit (`docs/preview-packages.md`), which is a preview concern,
not a version-coupling one. Revisit if the peer range ever becomes exact.
- `create-agent-bundle` versions independently; its templates pin
`agent-bundle` explicitly rather than through a workspace range.
- `create-agent-bundle` versions independently, but its optional
`workspace:*` peers on `agent-bundle` and `@agent-bundle/runtime` are the
release pairing record. `pnpm pack` rewrites them to the two exact published
versions; the installed scaffolder reads that record instead of assuming
the packages share a version.
- `updateInternalDependencies: "patch"` with
`bumpVersionsWithWorkspaceProtocolOnly: true`: only `workspace:` ranges
between publishable packages trigger dependent patch bumps. The one such
range is `@agent-bundle/runtime`'s `rsc-markdown-stream: workspace:^`, so
a `rsc-markdown-stream` release also patch-bumps the runtime and
re-publishes it with the new caret (`pnpm publish` rewrites `workspace:^`
to `^<version>` in the shipped manifest).
between publishable packages trigger dependent patch bumps. The runtime's
`rsc-markdown-stream: workspace:^` edge republishes it with the renderer's
new caret. The scaffolder's two optional `workspace:*` peers patch-bump and
republish it whenever either member of its exact release pair moves.
- `access` stays `"restricted"` at the repository level until the release
owner decides the npm package names and access policy
(`docs/preview-packages.md`). `@agent-bundle/runtime` and
Expand Down
5 changes: 5 additions & 0 deletions .changeset/create-runtime-release-pairing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"create-agent-bundle": patch
---

Make `npm create agent-bundle` select the compiler/runtime pair recorded by `create-agent-bundle`, preserve same-SHA previews, and reject incompatible `--framework-version` pairings before writing files. (#739)
12 changes: 6 additions & 6 deletions docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ pnpm 11 additionally needs the `blockExoticSubdeps` setting described below.

Previews carry the version string `0.0.0-preview-<sha>`, and the publish
(`--peerDeps`) rewrites every peer range that points at a sibling workspace
package to that exact preview version inside the preview tarballs. Today that
is the optional `@agent-bundle/runtime` peer declared by `agent-bundle`
(`@agent-bundle/runtime` itself no longer declares an `agent-bundle` peer;
its peers are `react`, `react-dom`, and the optional `@rspack/core`). A regular
`dependencies` entry that names a sibling workspace package is rewritten to
that sibling's same-sha tarball URL: `@agent-bundle/runtime`'s
package to that exact preview version inside the preview tarballs. Those peers
are the optional `@agent-bundle/runtime` peer declared by `agent-bundle` and
the optional compiler/runtime release-pair record declared by
`create-agent-bundle` (`@agent-bundle/runtime` itself has no `agent-bundle`
peer). A regular `dependencies` entry that names a sibling workspace package
is rewritten to that sibling's same-sha tarball URL: `@agent-bundle/runtime`'s
`rsc-markdown-stream` dependency resolves to the renderer preview of the same
commit. Installing both packages from the same sha therefore works with stock npm — no
`--legacy-peer-deps` needed. Mixing two different shas fails with `ERESOLVE`
Expand Down
28 changes: 19 additions & 9 deletions packages/create-agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ scripted and asks nothing — the remaining values fall back to their defaults.
| `--targets <list>` | Comma-separated host targets: `portable`, `claude`, `codex`, `cursor`. Default: `portable,codex,claude`. |
| `--package-manager <name>` | `npm`, `pnpm`, `yarn`, or `bun`. Default: detected from the invoking client. |
| `--no-install` | Skip installing dependencies after scaffolding. |
| `--framework-version <spec>` | Pin the project's `agent-bundle` dependency to this spec (a version, a tarball path, or a URL). |
| `--framework-version <spec>` | Pin the project's `agent-bundle` dependency to this spec (a version, a tarball path, or a URL). Runtime templates require the compiler version recorded by this scaffolder release. |
| `-h, --help` | Show usage. |

## Templates
Expand Down Expand Up @@ -59,14 +59,24 @@ anything; its README documents the wiring to add with the first route.

## The framework dependency

Scaffolded projects pin `agent-bundle` to an exact
[pkg.pr.new](https://pkg.pr.new) preview tarball. Without
`--framework-version`, the pin is derived from this scaffolder's own preview
version: pkg.pr.new publishes every workspace package of one commit under the
same `-preview-<sha>` suffix, so the scaffolder and the framework it pins
always come from the same commit. A non-preview build of the scaffolder has
no derivable default (the `agent-bundle` name on npm currently belongs to an
unrelated project) and requires `--framework-version` explicitly.
Preview scaffolders pin `agent-bundle` and `@agent-bundle/runtime` to exact
[pkg.pr.new](https://pkg.pr.new) tarballs from one commit SHA. A runtime
template overridden from a preview scaffolder accepts another exact
pkg.pr.new URL or the unversioned local names `agent-bundle.tgz` and
`agent-bundle-runtime.tgz`; versioned registry or local overrides require the
matching npm scaffolder release.

An npm release records its compatible compiler and runtime versions as
optional peers in the packed `create-agent-bundle` manifest. The scaffolder
pins those two recorded versions independently — it never derives the runtime
version from the compiler version — and rejects a runtime-bearing scaffold
whose `--framework-version` does not match the recorded compiler. A local
compiler tarball selects the sibling runtime tarball with the recorded runtime
version and validates both package names and versions before writing the
project.
Until this project owns the npm package names, use the preview command above;
a locally packed release record does not make the unrelated registry package
safe to install.

## License

Expand Down
12 changes: 12 additions & 0 deletions packages/create-agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,17 @@
"@types/node": "26.4.1",
"effect": "4.0.0-rc.112",
"effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f"
},
"peerDependencies": {
"@agent-bundle/runtime": "workspace:*",
"agent-bundle": "workspace:*"
},
"peerDependenciesMeta": {
"@agent-bundle/runtime": {
"optional": true
},
"agent-bundle": {
"optional": true
}
}
}
133 changes: 93 additions & 40 deletions packages/create-agent-bundle/src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,50 @@ const unzip = promisify(gunzip);

export type PreviewPackageName = 'agent-bundle' | '@agent-bundle/runtime' | 'create-agent-bundle';

export interface FrameworkRuntimePairing {
readonly framework: string;
readonly runtime: string;
}

export const runtimePairingFromManifest = (manifest: {
readonly peerDependencies?: Readonly<Record<string, unknown>>;
readonly version: string;
}): FrameworkRuntimePairing | undefined => {
if (previewPattern.test(manifest.version)) return undefined;
const framework = manifest.peerDependencies?.['agent-bundle'];
const runtime = manifest.peerDependencies?.['@agent-bundle/runtime'];
if (typeof framework !== 'string' || typeof runtime !== 'string') return undefined;
if (framework.startsWith('workspace:') || runtime.startsWith('workspace:')) return undefined;
return { framework, runtime };
};

export const previewPackageSpec = (packageName: PreviewPackageName, sha: string): string =>
`https://pkg.pr.new/ScriptedAlchemy/agent-bundle/${packageName}@${sha}`;

export const previewFrameworkSpec = (sha: string): string => previewPackageSpec('agent-bundle', sha);

const missingPairingError = (frameworkSpec: string): UsageError => new UsageError(
`Cannot select @agent-bundle/runtime for agent-bundle spec "${frameworkSpec}": `
+ 'this create-agent-bundle package has no release pairing metadata. Use a same-SHA pkg.pr.new URL, '
+ 'the unversioned local pair agent-bundle.tgz and agent-bundle-runtime.tgz, or a create-agent-bundle '
+ 'release paired with that registry compiler.',
);

/**
* Derives a paired runtime package from an exact preview/local framework
* build, or reuses an npm version, range, or tag that resolves independently
* under each package name.
* Exact previews pair by commit. Released scaffolders carry the compiler and
* runtime versions their packed manifest selected from the workspace.
*/
export const runtimeSpecForFramework = (frameworkSpec: string): string => {
export const runtimeSpecForFramework = (
frameworkSpec: string,
pairing?: FrameworkRuntimePairing,
): 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]}`;
const localTarball = /^(file:(?:.*[/\\])?)agent-bundle(-[^/\\]+)?\.tgz$/u.exec(frameworkSpec);
if (localTarball !== null) {
return `${localTarball[1]}agent-bundle-runtime${localTarball[2] ?? ''}.tgz`;
if (localTarball[2] === undefined) return `${localTarball[1]}agent-bundle-runtime.tgz`;
if (pairing === undefined) throw missingPairingError(frameworkSpec);
return `${localTarball[1]}agent-bundle-runtime-${pairing.runtime}.tgz`;
}
if (
frameworkSpec !== ''
Expand All @@ -37,12 +65,20 @@ export const runtimeSpecForFramework = (frameworkSpec: string): string => {
&& !frameworkSpec.endsWith('.tgz')
&& !frameworkSpec.endsWith('.tar.gz')
) {
return frameworkSpec;
if (pairing === undefined) throw missingPairingError(frameworkSpec);
if (frameworkSpec !== pairing.framework) {
throw new UsageError(
`This create-agent-bundle release is paired with agent-bundle ${pairing.framework} `
+ `and @agent-bundle/runtime ${pairing.runtime}; agent-bundle spec "${frameworkSpec}" may resolve `
+ 'an incompatible compiler. Omit --framework-version or install the matching create-agent-bundle release.',
);
}
return pairing.runtime;
}
throw new UsageError(
`Cannot derive a paired @agent-bundle/runtime package from agent-bundle spec "${frameworkSpec}". `
+ 'This package spec cannot be reused for @agent-bundle/runtime. Use an npm registry version, range, or tag; '
+ 'an exact pkg.pr.new preview URL; or a file: tarball '
+ 'This package spec cannot be reused for @agent-bundle/runtime. Use the npm registry version paired '
+ 'with this create-agent-bundle release, an exact pkg.pr.new preview URL, or a file: tarball '
+ 'named agent-bundle.tgz or agent-bundle-<version>.tgz.',
);
};
Expand Down Expand Up @@ -83,18 +119,18 @@ const tarHeaderChecksumMatches = (header: Buffer): boolean => {
return stored === unsigned || stored === signed;
};

/**
* `baseDirectory` is the scaffolded project root, because a relative `file:`
* spec is written verbatim into that project's `package.json` and npm resolves
* it from there — never from this CLI's working directory.
*/
const packedPackageName = Effect.fnUntraced(function* (
interface PackedPackageManifest {
readonly name: string;
readonly version: string;
}

const packedPackageManifest = Effect.fnUntraced(function* (
tarballPath: string,
): Effect.fn.Return<string, Error, FileSystem.FileSystem> {
): Effect.fn.Return<PackedPackageManifest, Error, FileSystem.FileSystem> {
const fs = yield* FileSystem.FileSystem;
const compressed = yield* fs.readFile(tarballPath);
const archive = yield* liftPromise(() => unzip(compressed));
let packageName: string | undefined;
let packageManifest: PackedPackageManifest | undefined;
for (let offset = 0; offset + tarBlockSize <= archive.length;) {
const header = archive.subarray(offset, offset + tarBlockSize);
if (isEndOfArchiveBlock(header)) break;
Expand All @@ -112,26 +148,31 @@ const packedPackageName = Effect.fnUntraced(function* (
if (name === 'package/package.json') {
const manifest = yield* liftTry(() => JSON.parse(
archive.subarray(contentsOffset, contentsOffset + size).toString('utf8'),
) as { readonly name?: unknown });
if (typeof manifest.name !== 'string') {
return yield* Effect.fail(new Error('Packed package manifest has no string name.'));
) as { readonly name?: unknown; readonly version?: unknown });
if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string') {
return yield* Effect.fail(new Error('Packed package manifest has no string name and version.'));
}
packageName = manifest.name;
packageManifest = { name: manifest.name, version: manifest.version };
}
offset = contentsOffset + Math.ceil(size / tarBlockSize) * tarBlockSize;
}
if (packageName === undefined) {
if (packageManifest === undefined) {
return yield* Effect.fail(new Error('Packed package manifest was not found.'));
}
return packageName;
return packageManifest;
});

const localTarballPackageName = Effect.fnUntraced(function* (
/**
* `baseDirectory` is the scaffolded project root, because a relative `file:`
* spec is written verbatim into that project's `package.json` and npm resolves
* it from there — never from this CLI's working directory.
*/
const localTarballPackageManifest = Effect.fnUntraced(function* (
packageSpec: string,
baseDirectory: string,
): Effect.fn.Return<string, UsageError, FileSystem.FileSystem | Path.Path> {
): Effect.fn.Return<PackedPackageManifest, UsageError, FileSystem.FileSystem | Path.Path> {
const path = yield* Path.Path;
return yield* packedPackageName(path.resolve(baseDirectory, packageSpec.slice('file:'.length))).pipe(
return yield* packedPackageManifest(path.resolve(baseDirectory, packageSpec.slice('file:'.length))).pipe(
Effect.catch((error) => Effect.fail(
new UsageError(`Cannot inspect local package tarball "${packageSpec}": ${describeError(error)}`),
)),
Expand All @@ -153,11 +194,11 @@ export const assertLocalFrameworkTarball = Effect.fnUntraced(function* (
baseDirectory: string,
): Effect.fn.Return<void, UsageError, FileSystem.FileSystem | Path.Path> {
if (!frameworkSpec.startsWith('file:')) return;
const frameworkName = yield* localTarballPackageName(frameworkSpec, baseDirectory);
if (frameworkName !== 'agent-bundle') {
const framework = yield* localTarballPackageManifest(frameworkSpec, baseDirectory);
if (framework.name !== 'agent-bundle') {
return yield* Effect.fail(new UsageError(
`Local package tarball "${frameworkSpec}" is not the agent-bundle package: expected agent-bundle, `
+ `received ${JSON.stringify(frameworkName)}.`,
+ `received ${JSON.stringify(framework.name)}.`,
));
}
});
Expand All @@ -169,19 +210,27 @@ export const assertLocalFrameworkTarball = Effect.fnUntraced(function* (
export const validatedRuntimeSpecForFramework = Effect.fnUntraced(function* (
frameworkSpec: string,
baseDirectory: string,
pairing?: FrameworkRuntimePairing,
): Effect.fn.Return<string, UsageError, FileSystem.FileSystem | Path.Path> {
const runtimeSpec = yield* liftTry(() => runtimeSpecForFramework(frameworkSpec)).pipe(
const runtimeSpec = yield* liftTry(() => runtimeSpecForFramework(frameworkSpec, pairing)).pipe(
Effect.catch((error) => (error instanceof UsageError ? Effect.fail(error) : Effect.die(error))),
);
if (!frameworkSpec.startsWith('file:')) return runtimeSpec;
const [frameworkName, runtimeName] = yield* Effect.all([
localTarballPackageName(frameworkSpec, baseDirectory),
localTarballPackageName(runtimeSpec, baseDirectory),
const [framework, runtime] = yield* Effect.all([
localTarballPackageManifest(frameworkSpec, baseDirectory),
localTarballPackageManifest(runtimeSpec, baseDirectory),
], { concurrency: 'unbounded' });
if (frameworkName !== 'agent-bundle' || runtimeName !== '@agent-bundle/runtime') {
if (framework.name !== 'agent-bundle' || runtime.name !== '@agent-bundle/runtime') {
return yield* Effect.fail(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)}.`,
+ `@agent-bundle/runtime, received ${JSON.stringify(framework.name)} and ${JSON.stringify(runtime.name)}.`,
));
}
if (pairing !== undefined && (framework.version !== pairing.framework || runtime.version !== pairing.runtime)) {
return yield* Effect.fail(new UsageError(
`Local package tarballs do not match this create-agent-bundle release: expected agent-bundle `
+ `${pairing.framework} and @agent-bundle/runtime ${pairing.runtime}, received agent-bundle `
+ `${framework.version} and @agent-bundle/runtime ${runtime.version}.`,
));
}
return runtimeSpec;
Expand All @@ -195,17 +244,21 @@ export const validatedRuntimeSpecForFramework = Effect.fnUntraced(function* (
* preview version: pkg.pr.new publishes every workspace package of one
* commit under the same `<version>-preview-<sha>` string, so the paired
* `agent-bundle` preview of the very build that shipped this scaffolder is
* always the right default. There is no derivable default outside a preview
* build — the `agent-bundle` name on npm belongs to an unrelated project, so
* falling back to a semver range would install the wrong package.
* always the right default. A registry release instead uses the compiler
* version recorded in its packed pairing metadata.
*/
export const resolveFrameworkSpec = (ownVersion: string, flag: string | undefined): string => {
export const resolveFrameworkSpec = (
ownVersion: string,
flag: string | undefined,
pairing?: FrameworkRuntimePairing,
): string => {
if (flag !== undefined && flag.trim() !== '') return flag.trim();
const preview = previewPattern.exec(ownVersion);
if (preview !== null) return previewFrameworkSpec(preview[1]!);
if (pairing !== undefined) return pairing.framework;
throw new UsageError(
`This build of create-agent-bundle (${ownVersion}) is not a pkg.pr.new preview, so it cannot derive `
+ 'a default agent-bundle version. Pass --framework-version <spec> — for example '
`This build of create-agent-bundle (${ownVersion}) is not a pkg.pr.new preview and has no release `
+ 'pairing metadata, so it cannot derive a default agent-bundle version. Pass --framework-version <spec> — for example '
+ '--framework-version https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@<sha>.',
);
};
Loading
Loading