From 316077ef7ef3d322fc57b7e647e5887beda039cc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 01:19:26 +0000 Subject: [PATCH 1/3] feat(create-agent-bundle): scaffold through Effect FileSystem/Path with NodeServices.layer Phase 1 of the FileSystem/Path adoption: the scaffolder's reads, writes, mkdir, readdir, stat and local-tarball inspection run as Effect programs over FileSystem.FileSystem and Path.Path; runCli provides @effect/platform-node's NodeServices.layer once and crosses back to the bin's Promise contract through the package's new src/effect/boundary.ts, which unwraps PlatformError to its Node cause so messages and exit codes are unchanged. Unit tests move to effect-rstest with scoped temp directories plus FileSystem.layerNoop protocol tests; the packed e2e that drives the real binary is untouched. docs/effect-conventions.md flips the platform-node decision to adopted-for-ordinary-I/O with the explicit keep-raw carve-outs. --- .changeset/effect-filesystem-scaffolder.md | 5 + docs/effect-conventions.md | 119 ++- packages/create-agent-bundle/package.json | 5 +- .../src/effect/boundary.ts | 51 + .../create-agent-bundle/src/effect/lift.ts | 18 + packages/create-agent-bundle/src/framework.ts | 120 +-- packages/create-agent-bundle/src/index.ts | 118 ++- packages/create-agent-bundle/src/scaffold.ts | 78 +- .../tests/effect-boundary.test.ts | 58 ++ .../tests/framework.test.ts | 177 ++-- .../tests/scaffold-noop.test.ts | 205 +++++ .../tests/scaffold.test.ts | 868 +++++++++--------- pnpm-lock.yaml | 132 +++ 13 files changed, 1267 insertions(+), 687 deletions(-) create mode 100644 .changeset/effect-filesystem-scaffolder.md create mode 100644 packages/create-agent-bundle/src/effect/boundary.ts create mode 100644 packages/create-agent-bundle/src/effect/lift.ts create mode 100644 packages/create-agent-bundle/tests/effect-boundary.test.ts create mode 100644 packages/create-agent-bundle/tests/scaffold-noop.test.ts diff --git a/.changeset/effect-filesystem-scaffolder.md b/.changeset/effect-filesystem-scaffolder.md new file mode 100644 index 000000000..859a1af59 --- /dev/null +++ b/.changeset/effect-filesystem-scaffolder.md @@ -0,0 +1,5 @@ +--- +"create-agent-bundle": patch +--- + +Run the `create-agent-bundle` scaffolder's filesystem work (template copy, `package.json`/config/README rewrites, local `file:` tarball inspection, target-directory check) on Effect's `FileSystem` and `Path` services, provided once by `@effect/platform-node`'s `NodeServices.layer` at the `create-agent-bundle` bin entry. Scaffolded files, messages, and exit codes are unchanged (`UsageError` still exits 2 and filesystem failures still report the Node error text); the self-contained `dist/index.js` bundle grows from 74 kB to 457 kB and the published tarball from 33 kB to 110 kB. (#PR) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 93c08a400..fcf629fc1 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -24,6 +24,7 @@ Each Effect-consuming package has exactly one `src/effect/boundary.ts`: - [`packages/rsc-runtime/src/effect/boundary.ts`](../packages/rsc-runtime/src/effect/boundary.ts) — runtime + state kernel internals. - [`packages/agent-bundle/src/effect/boundary.ts`](../packages/agent-bundle/src/effect/boundary.ts) — the dev seam (Stage 3). Maps interruption to `AbortError` and rethrows the dev seam's typed contracts (`CodedError` subclasses, `DiagnosticError`) unchanged. +- [`packages/create-agent-bundle/src/effect/boundary.ts`](../packages/create-agent-bundle/src/effect/boundary.ts) — the scaffolder (FileSystem phase 1). Rethrows `UsageError` / `Error` unchanged; unwraps `PlatformError` to its Node cause. The boundary owns: @@ -212,24 +213,92 @@ and result state. Atoms live in `effect/unstable/reactivity`; React bindings com ## Effect platform services (@effect/platform-node) -Evaluated 2026-09-01 against `effect@4.0.0-rc.112` + -`@effect/platform-node@4.0.0-rc.112`; **decision = not adopted** this RC -cycle (missing `lstat`/`O_NOFOLLOW`/inode primitives for hardened fs -protocols; `runMain` cannot express the 130/143 signal-distinct exit -contract); revisit at GA. - -Effect platform services are optional inside Effect-native internals, not a -blanket replacement for `node:fs` or `node:path`. Use them when portable -ordinary I/O materially improves service substitution or scoped ownership, and -provide only the narrow `NodeFileSystem`/`NodePath` layers at an existing -Effect boundary. Keep raw Node APIs for compiler and generated-entry code, -synchronous SQLite setup, `lstat`/`O_NOFOLLOW`, inode/link identity, -directory-fsync and atomic-publication protocols, transferred resource -ownership, or bespoke process exit contracts. Map `PlatformError` to the -existing typed contract at one boundary. Use scoped temporary paths only in -tests already Effect-native; do not convert Promise-contract tests solely for -fixture cleanup. Treat `layerNoop` as a selective stub, not an in-memory -filesystem. +Re-evaluated 2026-09-03 against `effect@4.0.0-rc.112` + +`@effect/platform-node@4.0.0-rc.112`; **decision = adopted for ordinary +filesystem I/O and path operations** (the 2026-09-01 decline is superseded). +`FileSystem.FileSystem` and `Path.Path` from the `effect` package are the +sanctioned way for framework code to touch the filesystem; the Node +implementations come from `@effect/platform-node` (`NodeServices.layer`, or +the narrower `NodeFileSystem.layer` / `NodePath.layer`). The API gap that +motivated the decline is still real and is what the keep-raw list below +encodes: the pinned `FileSystem` has no `lstat`, `OpenFlag` accepts only +string flags (no `O_NOFOLLOW`), and there is no directory fsync. +`NodeRuntime.runMain` stays banned (the 130/143 signal-distinct exit +contract). + +### Adopt + +- Ordinary reads, writes, `mkdir`, `readDirectory`, `stat`, `exists`, + `remove`, `rename`, `copy` in code that already runs (or is being moved) + inside an Effect program: `yield* FileSystem.FileSystem`, then the method. + `readDirectory` returns names only — `stat(...).type === 'Directory'` + replaces `Dirent.isDirectory()`. +- `Path.Path` for `join` / `resolve` / `dirname` / `fromFileUrl` in the same + modules. `fromFileUrl` fails with `BadArgument`; `Effect.orDie` it when the + URL is built from `import.meta.url`. +- Temporary directories whose lifetime ends with the enclosing operation: + `makeTempDirectoryScoped` inside `Effect.scoped`, replacing `mkdtemp` + + `try`/`finally` `rm`. **Not** when ownership of the directory is + transferred to a longer-lived object (the MCP session plugin-data dir in + `dev/mcp-session/mcp-session-service.ts`): a scoped temp is removed when + the scope closes, which is too early there. +- File handles whose use is bounded by one program: scoped `open`. +- Layer wiring: one composition root per process. The first-party CLI and + the scaffolder provide `NodeServices.layer` immediately before their + boundary's `runPromise`; the dev server (phase 2) gets one + `makeScopedEffectRuntime(NodeServices.layer)` in `startDevServer`, disposed + from the session's `close`. Never provide a platform layer deep inside + library code. +- Errors: `PlatformError` flows through the Effect error channel and is + mapped once, at the boundary, onto the existing contract. Where a + user-facing AB#### diagnostic already exists for the failure, map to it + without changing the code or message. Where the contract is "print the + Node error" (the scaffolder), unwrap `PlatformError.cause` to the + `ErrnoException` so messages stay byte-identical. +- Tests: `FileSystem.layerNoop({ ...overrides })` for call/result/error + protocol tests — its defaults fail with `NotFound` or die, so override + every operation the code under test performs. Keep real temp directories + (`makeTempDirectoryScoped` under `it.effect` / `it.live`) for anything + about symlinks, permissions, atomic rename, SQLite, or packed executables. + Do not convert Promise-contract tests solely for fixture cleanup. + +### Keep raw (`node:fs` / `node:path`) — explicit carve-outs + +- `core/durable-fs.ts` and everything that publishes through it: epoch + store, playground stores, eval run-store, dev-lock, receipts. They need + `lstat`, `O_NOFOLLOW`, inode identity, directory fsync, `wx` exclusive + create, and same-filesystem atomic rename. +- `install/*` and `doctor`: `lstat` containment walks, symlink refusal, + same-fs staging, `wx` receipt creation, atomic rename. Ordinary stage + directories there move to `makeTempDirectoryScoped` only once the + installer body itself is Effect-native. +- `events/ipc.ts` inode locks (`open` with `wx` + `stat` identity + Linux + start time). +- Synchronous SQLite setup (`rsc-runtime/src/state/sqlite.ts`). +- `dev/watcher.ts`: chokidar stays. `FileSystem.watch` is a thin `fs.watch` + with create/update/remove only — no `ignored` callbacks, readiness, or the + other event kinds — and the watcher's `dev:ino` signatures need `stat` + semantics we do not want to change. +- Synchronous config/discovery on the compiler and cold-start path + (`config/validate.ts`, `config/conventional-entry.ts`, + `core/project-context.ts`), Rspack/rslib compiler I/O (`build/rslib.ts`), + and Rspack loader/plugin hot paths. +- Every **emitted** artifact: generated hook wrappers + (`adapters/hook-contract.ts`), `build/entry-shell.ts` shells, `bin/*.mjs` + templates, and the installer surface strings (`install/surface.ts`). They + must not depend on an Effect runtime at run time (see the cold-start + budget). + +### Boundary modules + +`packages/create-agent-bundle/src/effect/boundary.ts` is the scaffolder's +sole run edge (phase 1 pilot): `runPromise` rethrows `UsageError` and plain +`Error` unchanged and unwraps `PlatformError` to its Node cause. `runCli` +provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, +`node` target): `dist/index.js` 73.7 kB → 456.8 kB with `NodeServices.layer` +(264.7 kB with only `NodeFileSystem` + `NodePath`); packed tarball 33.0 kB → +110.2 kB; `--help` cold start ≈40 ms → ≈65 ms. `undici` is not pulled into +the bundle. ## Effect Schema wire contracts (Schema projections) @@ -307,7 +376,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections). | Module | Adopted in | Re-verify | | --- | --- | --- | | `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | -| `@effect/platform-node` (`NodeFileSystem` / `NodePath`) | **declined** (2026-09-01) | revisit at Effect GA; re-pin re-evaluates lstat/O_NOFOLLOW/inode primitives + `runMain` 130/143 exit contract | +| `@effect/platform-node` (`NodeServices.layer`; `FileSystem` / `Path` services live in `effect`) | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list | re-pin bumps `@effect/platform-node` in lockstep with `effect`; re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract | | `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default | ## Language service @@ -335,10 +404,14 @@ must not regress it: `pnpm bench:hook-cold-start -- --check`. ## Re-pin chore -1. Bump the exact `effect` version in `packages/rsc-runtime/package.json`. -2. Synchronize `@effect/atom-react` in `packages/workbench/package.json` to +1. Bump the exact `effect` version in `packages/rsc-runtime/package.json`, + `packages/agent-bundle/package.json`, `packages/workbench/package.json`, + and `packages/create-agent-bundle/package.json`. +2. Synchronize `@effect/atom-react` in `packages/workbench/package.json` and + `@effect/platform-node` in `packages/create-agent-bundle/package.json` to the same RC; re-run the Workbench disposal regression test and production - bundle measurement (rsbuild size table). + bundle measurement (rsbuild size table), and re-measure the scaffolder + bundle (`pnpm --filter create-agent-bundle build` prints the size table). 3. `git subtree pull --prefix=repos/effect https://github.com/Effect-TS/effect.git main --squash`. 4. Re-read `repos/effect/LLMS.md` and refresh `agent-patterns/effect-*.md`. 5. Re-verify every unstable-module row and the language-service diagnostics. @@ -359,5 +432,5 @@ soon as the trigger fires and retire the row. | --- | --- | --- | --- | | 2026-09-03 | `@rslib/core` **`0.23.2`** — root, `packages/agent-bundle`, `packages/rsc-runtime`, `packages/create-agent-bundle` devDependencies. Stays on `0.23.x` until rslib 1.0 leaves rc. | `npm view @rslib/core dist-tags`: `latest` `0.23.2`, `rc` `1.0.0-rc.2`, `beta` `1.0.0-beta.3`, `canary` `0.20.0-canary-202603101`. | `latest` becomes `1.x`. Bump all four pins in one chore; re-run `pnpm build`, `lint:package`, `check:release`, and the Rslib-driven compile tests. | | 2026-09-03 | `effect-rstest` **pkg.pr.new preview `e5f8d5f`** (`https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f`) — `packages/agent-bundle`, `packages/rsc-runtime` devDependencies. Needs a real release pin once published. | `npm view effect-rstest versions`: **E404 — not published to npm** (no versions, no dist-tags). | First npm publish of `effect-rstest`. Replace both preview URLs with the exact published version, refresh `pnpm-lock.yaml`, re-run `pnpm test:unit` (`it.effect` / `it.live` suites). | -| 2026-09-03 | `effect` **`4.0.0-rc.112`** (`packages/agent-bundle`, `packages/rsc-runtime`, `packages/workbench`), `@effect/atom-react` `4.0.0-rc.112` (`packages/workbench`), `@effect/language-service` `0.87.2` and `@effect/tsgo` `0.39.0` (root). Auto re-pin in lockstep + `repos/effect` subtree + Workbench atom phase 4 unblock (stream-backed derived atoms) once the post-rc.112 disposal fix ships. | `npm view effect dist-tags`: `rc` **`4.0.0-rc.112`** (unchanged), `beta` `4.0.0-beta.107`, `latest` `3.22.1`. `@effect/atom-react`: `rc` `4.0.0-rc.112`. `@effect/language-service`: `latest` `0.87.2`. `@effect/tsgo`: `latest` `0.39.1` (patch ahead of the `0.39.0` pin; rides the lockstep chore). | `effect@rc` advances past `4.0.0-rc.112`. Run the re-pin chore steps 1–6 above, bumping `effect`, `@effect/atom-react`, `@effect/language-service`, and `@effect/tsgo` together, then lift the stream-backed derived-atom ban in the Workbench if the disposal fix is in the new RC. | +| 2026-09-03 | `effect` **`4.0.0-rc.112`** (`packages/agent-bundle`, `packages/rsc-runtime`, `packages/workbench`, `packages/create-agent-bundle`), `@effect/atom-react` `4.0.0-rc.112` (`packages/workbench`), `@effect/platform-node` `4.0.0-rc.112` (`packages/create-agent-bundle`), `@effect/language-service` `0.87.2` and `@effect/tsgo` `0.39.0` (root). Auto re-pin in lockstep + `repos/effect` subtree + Workbench atom phase 4 unblock (stream-backed derived atoms) once the post-rc.112 disposal fix ships. | `npm view effect dist-tags`: `rc` **`4.0.0-rc.112`** (unchanged), `beta` `4.0.0-beta.107`, `latest` `3.22.1`. `@effect/atom-react`: `rc` `4.0.0-rc.112`. `@effect/language-service`: `latest` `0.87.2`. `@effect/tsgo`: `latest` `0.39.1` (patch ahead of the `0.39.0` pin; rides the lockstep chore). | `effect@rc` advances past `4.0.0-rc.112`. Run the re-pin chore steps 1–6 above, bumping `effect`, `@effect/atom-react`, `@effect/language-service`, and `@effect/tsgo` together, then lift the stream-backed derived-atom ban in the Workbench if the disposal fix is in the new RC. | | 2026-09-03 | Agent Plugins specification **`1.0.0`** — `packages/agent-bundle/src/adapters/schemas/portable/{plugin,mcp}.schema.json` + `PROVENANCE.json` (spec repo `agentplugins/agent-plugins-spec` @ `ff8ab5e392cc87bd88d87c060815a87490e51003`, 2026-08-19), portable `adapterRevision` `1.8.0`, pins in `tests/adapter-metadata.test.ts`. Spec watch for #426; not an npm pin, so re-verify with `curl`/`gh api`, not `npm view`. | Live `https://agent-plugins.org/schemas/1.0.0/{plugin,mcp}.schema.json` rehash to the pinned sha256 (1805 / 3408 bytes). Repo `main` HEAD unchanged at the pinned commit; **no tags, no GitHub releases**. `spec/1.1.0.md` is "Status: Working Draft" (started 2026-08-15, `a2afd7ec`); in-repo `schemas/1.1.0/*.schema.json` differ from 1.0.0 only in the `$id`/`const`/`description` version strings; `https://agent-plugins.org/schemas/1.1.0/*.schema.json` → 404. Observed latest published version: **1.0.0**. | `spec/1.1.0.md` (or later) flips to "Published" **and** `agent-plugins.org/schemas//` serves both schemas. Re-pin under `schemas/portable/` with a dated `PROVENANCE.json` (sha/bytes/date/commit), bump the portable `adapterRevision`, refresh the metadata pins, run `pnpm test:unit` (portable adapter + plugin-validation suites) and `pnpm test:host-install:build`, and add a capability row per additive field. | diff --git a/packages/create-agent-bundle/package.json b/packages/create-agent-bundle/package.json index 0638443c3..a67bcdd77 100644 --- a/packages/create-agent-bundle/package.json +++ b/packages/create-agent-bundle/package.json @@ -43,8 +43,11 @@ }, "devDependencies": { "@clack/prompts": "1.7.0", + "@effect/platform-node": "4.0.0-rc.112", "@rslib/core": "0.23.2", "@rstest/core": "0.11.10", - "@types/node": "26.4.0" + "@types/node": "26.4.0", + "effect": "4.0.0-rc.112", + "effect-rstest": "https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f" } } diff --git a/packages/create-agent-bundle/src/effect/boundary.ts b/packages/create-agent-bundle/src/effect/boundary.ts new file mode 100644 index 000000000..ec2884a0c --- /dev/null +++ b/packages/create-agent-bundle/src/effect/boundary.ts @@ -0,0 +1,51 @@ +import { Cause, Effect, Exit } from 'effect'; +import { PlatformError } from 'effect/PlatformError'; + +/** + * The sole Effect → Promise edge for `create-agent-bundle`. + * + * The scaffolder's filesystem work runs as Effect programs over the + * `FileSystem` / `Path` services; `runCli` provides the Node platform layer + * and crosses back to the bin's Promise contract here. Nothing in this module + * is exported from the package. See `docs/effect-conventions.md`. + * + * Error mapping keeps the CLI's observable contract: `UsageError` (exit 2) + * and every other `Error` (exit 1, message printed) rethrow unchanged, and a + * `PlatformError` unwraps to the Node error it wraps, so a failed read still + * reports `ENOENT: no such file or directory, open '...'` — the message the + * scaffolder printed before the filesystem moved onto Effect. + */ + +const abortError = (cause: unknown): DOMException => { + const error = new DOMException('The operation was aborted', 'AbortError'); + error.cause = cause; + return error; +}; + +/** + * `FileSystem` and `Path` fail with `PlatformError` whose `cause` is the + * original `NodeJS.ErrnoException`. The CLI's user-facing messages are + * built from that Node error, so the wrapper is peeled off here. + */ +export const toCliError = (value: unknown): Error => { + if (value instanceof PlatformError) { + return value.cause instanceof Error ? value.cause : value; + } + if (value instanceof Error) return value; + return new Error(String(value)); +}; + +/** The message a failed platform operation prints: the Node error's, when there is one. */ +export const describeError = (value: unknown): string => toCliError(value).message; + +export const mapCause = (cause: Cause.Cause): Error => { + if (Cause.hasInterruptsOnly(cause)) return abortError(cause); + return toCliError(Cause.squash(cause)); +}; + +/** Promise edge. Typed CLI failures rethrow as-is; platform failures unwrap to their Node cause. */ +export const runPromise = async (effect: Effect.Effect): Promise => { + const exit = await Effect.runPromiseExit(effect); + if (Exit.isSuccess(exit)) return exit.value; + throw mapCause(exit.cause); +}; diff --git a/packages/create-agent-bundle/src/effect/lift.ts b/packages/create-agent-bundle/src/effect/lift.ts new file mode 100644 index 000000000..39aa7b8db --- /dev/null +++ b/packages/create-agent-bundle/src/effect/lift.ts @@ -0,0 +1,18 @@ +import { Effect } from 'effect'; + +import { toCliError } from './boundary.ts'; + +/** + * Lifts for the scaffolder's remaining Promise/sync helpers (gunzip, + * `JSON.parse`, the package-manager child process, the Clack prompts). The + * thrown/rejected value stays identity-preserved when it already is an + * `Error` — the CLI's `UsageError` contract crosses `src/effect/boundary.ts` + * untouched — and anything else is normalized to one, so the fail channel is + * typed `Error`, never `unknown`. + */ + +export const liftPromise = (evaluate: () => PromiseLike): Effect.Effect => + Effect.tryPromise({ catch: toCliError, try: evaluate }); + +export const liftTry = (evaluate: () => A): Effect.Effect => + Effect.try({ catch: toCliError, try: evaluate }); diff --git a/packages/create-agent-bundle/src/framework.ts b/packages/create-agent-bundle/src/framework.ts index 0d1bd1633..47b5e00e4 100644 --- a/packages/create-agent-bundle/src/framework.ts +++ b/packages/create-agent-bundle/src/framework.ts @@ -1,8 +1,10 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; import { promisify } from 'node:util'; import { gunzip } from 'node:zlib'; +import { Effect, FileSystem, Path } from 'effect'; + +import { describeError } from './effect/boundary.ts'; +import { liftPromise, liftTry } from './effect/lift.ts'; import { UsageError } from './options.ts'; const previewPattern = /-preview-([0-9a-f]{7,40})$/u; @@ -86,46 +88,55 @@ const tarHeaderChecksumMatches = (header: Buffer): boolean => { * spec is written verbatim into that project's `package.json` and npm resolves * it from there — never from this CLI's working directory. */ -const localTarballPackageName = async (packageSpec: string, baseDirectory: string): Promise => { - const path = resolve(baseDirectory, packageSpec.slice('file:'.length)); - try { - const archive = await unzip(await readFile(path)); - let packageName: string | undefined; - for (let offset = 0; offset + tarBlockSize <= archive.length;) { - const header = archive.subarray(offset, offset + tarBlockSize); - if (isEndOfArchiveBlock(header)) break; - if (!tarHeaderChecksumMatches(header)) { - throw new Error(`Invalid tar header checksum at offset ${offset}: the archive is corrupt.`); - } - 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 + tarBlockSize; - 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') { - throw new Error('Packed package manifest has no string name.'); - } - packageName = manifest.name; - } - offset = contentsOffset + Math.ceil(size / tarBlockSize) * tarBlockSize; +const packedPackageName = Effect.fnUntraced(function* ( + tarballPath: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const compressed = yield* fs.readFile(tarballPath); + const archive = yield* liftPromise(() => unzip(compressed)); + let packageName: string | undefined; + for (let offset = 0; offset + tarBlockSize <= archive.length;) { + const header = archive.subarray(offset, offset + tarBlockSize); + if (isEndOfArchiveBlock(header)) break; + if (!tarHeaderChecksumMatches(header)) { + return yield* Effect.fail(new Error(`Invalid tar header checksum at offset ${offset}: the archive is corrupt.`)); } - if (packageName === undefined) { - throw new Error('Packed package manifest was not found.'); + 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) { + return yield* Effect.fail(new Error(`Invalid tar entry size "${sizeText}".`)); } - return packageName; - } catch (error) { - throw new UsageError( - `Cannot inspect local package tarball "${packageSpec}": ${error instanceof Error ? error.message : String(error)}`, - ); + const contentsOffset = offset + tarBlockSize; + 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.')); + } + packageName = manifest.name; + } + offset = contentsOffset + Math.ceil(size / tarBlockSize) * tarBlockSize; } -}; + if (packageName === undefined) { + return yield* Effect.fail(new Error('Packed package manifest was not found.')); + } + return packageName; +}); + +const localTarballPackageName = Effect.fnUntraced(function* ( + packageSpec: string, + baseDirectory: string, +): Effect.fn.Return { + const path = yield* Path.Path; + return yield* packedPackageName(path.resolve(baseDirectory, packageSpec.slice('file:'.length))).pipe( + Effect.catch((error) => Effect.fail( + new UsageError(`Cannot inspect local package tarball "${packageSpec}": ${describeError(error)}`), + )), + ); +}); /** * Verifies a local framework tarball for templates that pin no runtime @@ -137,39 +148,44 @@ const localTarballPackageName = async (packageSpec: string, baseDirectory: strin * `baseDirectory` is the scaffold target directory, so a relative `file:` spec * is probed exactly where the emitted `package.json` will point. */ -export const assertLocalFrameworkTarball = async (frameworkSpec: string, baseDirectory: string): Promise => { +export const assertLocalFrameworkTarball = Effect.fnUntraced(function* ( + frameworkSpec: string, + baseDirectory: string, +): Effect.fn.Return { if (!frameworkSpec.startsWith('file:')) return; - const frameworkName = await localTarballPackageName(frameworkSpec, baseDirectory); + const frameworkName = yield* localTarballPackageName(frameworkSpec, baseDirectory); if (frameworkName !== 'agent-bundle') { - throw new UsageError( + return yield* Effect.fail(new UsageError( `Local package tarball "${frameworkSpec}" is not the agent-bundle package: expected agent-bundle, ` + `received ${JSON.stringify(frameworkName)}.`, - ); + )); } -}; +}); /** * Derives and verifies a coherent local framework/runtime tarball pair, * resolving relative `file:` specs against the scaffold target directory. */ -export const validatedRuntimeSpecForFramework = async ( +export const validatedRuntimeSpecForFramework = Effect.fnUntraced(function* ( frameworkSpec: string, baseDirectory: string, -): Promise => { - const runtimeSpec = runtimeSpecForFramework(frameworkSpec); +): Effect.fn.Return { + const runtimeSpec = yield* liftTry(() => runtimeSpecForFramework(frameworkSpec)).pipe( + Effect.catch((error) => (error instanceof UsageError ? Effect.fail(error) : Effect.die(error))), + ); if (!frameworkSpec.startsWith('file:')) return runtimeSpec; - const [frameworkName, runtimeName] = await Promise.all([ + const [frameworkName, runtimeName] = yield* Effect.all([ localTarballPackageName(frameworkSpec, baseDirectory), localTarballPackageName(runtimeSpec, baseDirectory), - ]); + ], { concurrency: 'unbounded' }); if (frameworkName !== 'agent-bundle' || runtimeName !== '@agent-bundle/runtime') { - throw new UsageError( + 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)}.`, - ); + )); } return runtimeSpec; -}; +}); /** * Resolve the dependency spec the scaffolded project pins `agent-bundle` to. diff --git a/packages/create-agent-bundle/src/index.ts b/packages/create-agent-bundle/src/index.ts index f716bd8cc..8b741c102 100644 --- a/packages/create-agent-bundle/src/index.ts +++ b/packages/create-agent-bundle/src/index.ts @@ -1,10 +1,12 @@ import { spawn } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { cancel, intro, isCancel, log, multiselect, note, outro, select, text } from '@clack/prompts'; +import * as NodeServices from '@effect/platform-node/NodeServices'; +import { Effect, FileSystem, Path } from 'effect'; +import type { PlatformError } from 'effect/PlatformError'; +import { mapCause, runPromise } from './effect/boundary.ts'; +import { liftPromise, liftTry } from './effect/lift.ts'; import { resolveFrameworkSpec } from './framework.ts'; import { UsageError, @@ -50,21 +52,26 @@ const clackPrompter: Prompter = { * it packs the preview tarball, and that suffix is what pairs the scaffolded * project with the matching agent-bundle preview. */ -const ownVersion = async (): Promise => { - const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { - readonly version: string; - }; +const ownVersion = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestPath = yield* path.fromFileUrl(new URL('../package.json', import.meta.url)).pipe( + // The URL is built from import.meta.url, so a `BadArgument` here is a bug. + Effect.orDie, + ); + const manifest = JSON.parse(yield* fs.readFileString(manifestPath)) as { readonly version: string }; return manifest.version; -}; +}); -const runInstall = async (options: ResolvedOptions, targetDirectory: string): Promise => { - log.step(`Installing dependencies with ${options.packageManager}...`); - return new Promise((resolvePromise, rejectPromise) => { - const child = spawn(options.packageManager, ['install'], { cwd: targetDirectory, stdio: 'inherit' }); - child.on('error', rejectPromise); - child.on('close', (code) => { resolvePromise(code ?? 1); }); +const runInstall = (options: ResolvedOptions, targetDirectory: string): Effect.Effect => + liftPromise(() => { + log.step(`Installing dependencies with ${options.packageManager}...`); + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(options.packageManager, ['install'], { cwd: targetDirectory, stdio: 'inherit' }); + child.on('error', rejectPromise); + child.on('close', (code) => { resolvePromise(code ?? 1); }); + }); }); -}; const nextSteps = (options: ResolvedOptions): string => { const steps = [`cd ${options.targetDir}`]; @@ -73,37 +80,34 @@ const nextSteps = (options: ResolvedOptions): string => { return steps.map((step, index) => `${index + 1}. ${step}`).join('\n'); }; -export const runCli = async (argv: readonly string[]): Promise<0 | 1 | 2> => { - let flags: ParsedFlags; - try { - flags = parseFlags(argv); - } catch (error) { - if (error instanceof UsageError) { - process.stderr.write(`${error.message}\n\n${helpText}`); - return 2; - } - throw error; - } - if (flags.help) { - process.stdout.write(helpText); - return 0; - } - - const version = await ownVersion(); +/** + * The scaffold run after flag parsing. Filesystem work goes through the + * `FileSystem` / `Path` services; the failure → exit-code contract is the + * CLI's: `UsageError` cancels with exit 2, anything else with exit 1. + */ +const scaffoldProgram = Effect.fnUntraced(function* ( + flags: ParsedFlags, +): Effect.fn.Return<0 | 1 | 2, PlatformError, FileSystem.FileSystem | Path.Path> { + // Reading this package's own manifest fails before the intro, exactly as + // it did as a rejected Promise: no cancel banner, the error leaves runCli. + const version = yield* ownVersion; intro(`create-agent-bundle ${version}`); - try { + const run = Effect.gen(function* () { + const path = yield* Path.Path; const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; - const options = await resolveOptions(flags, { + const options = yield* liftPromise(() => resolveOptions(flags, { interactive, prompter: clackPrompter, userAgent: process.env['npm_config_user_agent'], - }); - const frameworkSpec = resolveFrameworkSpec(version, options.frameworkVersion); - const targetDirectory = resolve(process.cwd(), options.targetDir); - await assertScaffoldTarget(targetDirectory, options.targetDir); + })); + const frameworkSpec = yield* liftTry(() => resolveFrameworkSpec(version, options.frameworkVersion)); + const targetDirectory = path.resolve(process.cwd(), options.targetDir); + yield* assertScaffoldTarget(targetDirectory, options.targetDir); - const templateRoot = fileURLToPath(new URL(`../templates/${options.template}`, import.meta.url)); - const files = await scaffold({ + const templateRoot = yield* path.fromFileUrl(new URL(`../templates/${options.template}`, import.meta.url)).pipe( + Effect.orDie, + ); + const files = yield* scaffold({ frameworkSpec, packageName: options.packageName, pluginName: options.pluginName, @@ -115,23 +119,47 @@ export const runCli = async (argv: readonly string[]): Promise<0 | 1 | 2> => { log.info(`agent-bundle is pinned to ${frameworkSpec} — see docs/preview-packages.md in the repository for the preview channel.`); if (options.install) { - const exitCode = await runInstall(options, targetDirectory); + const exitCode = yield* runInstall(options, targetDirectory); if (exitCode !== 0) { log.warn(`${options.packageManager} install failed (exit code ${exitCode}). Run "${options.packageManager} install" in ${options.targetDir} manually.`); outro('Scaffolded, but dependencies are not installed.'); - return 1; + return 1 as const; } } note(nextSteps(options), 'Next steps'); outro('Project ready.'); - return 0; - } catch (error) { + return 0 as const; + }); + // `catchCause`, not `catch`: template-drift rewrites throw plain Errors, + // which surface as defects, and they must cancel with the same message. + return yield* run.pipe(Effect.catchCause((cause) => Effect.sync((): 1 | 2 => { + const error = mapCause(cause); if (error instanceof UsageError) { cancel(error.message); return 2; } - cancel(error instanceof Error ? error.message : String(error)); + cancel(error.message); return 1; + }))); +}); + +export const runCli = async (argv: readonly string[]): Promise<0 | 1 | 2> => { + let flags: ParsedFlags; + try { + flags = parseFlags(argv); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`${error.message}\n\n${helpText}`); + return 2; + } + throw error; + } + if (flags.help) { + process.stdout.write(helpText); + return 0; } + // The one composition root: the Node platform services are provided here + // and nowhere else in the package. + return runPromise(Effect.provide(scaffoldProgram(flags), NodeServices.layer)); }; diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index 3ee13cc7a..b1c15501b 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -1,6 +1,7 @@ -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { Effect, FileSystem, Path } from 'effect'; +import type { PlatformError } from 'effect/PlatformError'; +import { liftTry } from './effect/lift.ts'; import { defaultTargets, UsageError, type TargetName } from './options.ts'; import { assertLocalFrameworkTarball, validatedRuntimeSpecForFramework } from './framework.ts'; @@ -40,19 +41,24 @@ export interface ScaffoldRequest { readonly templateRoot: string; } +/** `ENOENT` on the platform error channel. */ +const isNotFound = (error: PlatformError): boolean => error.reason._tag === 'NotFound'; + /** The target directory must be absent, empty, or hold nothing but `.git`. */ -export const assertScaffoldTarget = async (targetDirectory: string, displayName: string): Promise => { - let entries: readonly string[]; - try { - entries = await readdir(targetDirectory); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw error; - } +export const assertScaffoldTarget = Effect.fnUntraced(function* ( + targetDirectory: string, + displayName: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(targetDirectory).pipe( + Effect.catch((error) => (isNotFound(error) ? Effect.succeed([]) : Effect.fail(error))), + ); if (entries.some((entry) => entry !== '.git')) { - throw new UsageError(`Target directory "${displayName}" is not empty. Choose a new directory or empty it first.`); + return yield* Effect.fail( + new UsageError(`Target directory "${displayName}" is not empty. Choose a new directory or empty it first.`), + ); } -}; +}); interface TemplateManifest { bin?: Record; @@ -176,40 +182,50 @@ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): * list, and the README's install instructions. Returns the emitted * project-relative paths, sorted. */ -export const scaffold = async (request: ScaffoldRequest): Promise => { - const templateManifest = JSON.parse( - await readFile(join(request.templateRoot, 'package_json'), 'utf8'), - ) as TemplateManifest; +export const scaffold = Effect.fnUntraced(function* ( + request: ScaffoldRequest, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const manifestSource = yield* fs.readFileString(path.join(request.templateRoot, 'package_json')); + const templateManifest = yield* liftTry(() => JSON.parse(manifestSource) as TemplateManifest); const usesWorkspaceRuntime = [templateManifest.dependencies, templateManifest.devDependencies] .some((section) => section?.['@agent-bundle/runtime'] === 'workspace:*'); let runtimeSpec: string | undefined; if (usesWorkspaceRuntime) { - runtimeSpec = await validatedRuntimeSpecForFramework(request.frameworkSpec, request.targetDirectory); + runtimeSpec = yield* validatedRuntimeSpecForFramework(request.frameworkSpec, request.targetDirectory); } else { - await assertLocalFrameworkTarball(request.frameworkSpec, request.targetDirectory); + yield* assertLocalFrameworkTarball(request.frameworkSpec, request.targetDirectory); } const emitted: string[] = []; - const copyDirectory = async (from: string, to: string, relative: string): Promise => { - await mkdir(to, { recursive: true }); - for (const entry of await readdir(from, { withFileTypes: true })) { - const name = renamedEntries[entry.name] ?? entry.name; - const source = join(from, entry.name); - const destination = join(to, name); + const copyDirectory: ( + from: string, + to: string, + relative: string, + ) => Effect.Effect = Effect.fnUntraced(function* (from, to, relative) { + yield* fs.makeDirectory(to, { recursive: true }); + for (const entryName of yield* fs.readDirectory(from)) { + const name = renamedEntries[entryName] ?? entryName; + const source = path.join(from, entryName); + const destination = path.join(to, name); const relativePath = relative === '' ? name : `${relative}/${name}`; - if (entry.isDirectory()) { - await copyDirectory(source, destination, relativePath); + const info = yield* fs.stat(source); + if (info.type === 'Directory') { + yield* copyDirectory(source, destination, relativePath); continue; } - let contents = (await readFile(source, 'utf8')).replaceAll(placeholderName, request.pluginName); + let contents = (yield* fs.readFileString(source)).replaceAll(placeholderName, request.pluginName); + // Template drift is a checked-in-template bug: the rewrites throw and + // the defect crosses the boundary as the same Error it always was. if (relativePath === 'package.json') contents = rewriteManifest(contents, request, runtimeSpec); if (relativePath === 'agent-bundle.config.ts') contents = rewriteConfigTargets(contents, request.targets); if (relativePath === 'README.md') contents = rewriteReadmeInstall(contents, request.targets); - await writeFile(destination, contents); + yield* fs.writeFileString(destination, contents); emitted.push(relativePath); } - }; - await copyDirectory(request.templateRoot, request.targetDirectory, ''); + }); + yield* copyDirectory(request.templateRoot, request.targetDirectory, ''); // Code-unit order, not localeCompare: the emitted inventory must be stable // across machines and locales. return emitted.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); -}; +}); diff --git a/packages/create-agent-bundle/tests/effect-boundary.test.ts b/packages/create-agent-bundle/tests/effect-boundary.test.ts new file mode 100644 index 000000000..a05a667e6 --- /dev/null +++ b/packages/create-agent-bundle/tests/effect-boundary.test.ts @@ -0,0 +1,58 @@ +import { Effect, PlatformError } from 'effect'; +import { describe, expect, it } from '@rstest/core'; + +import { describeError, runPromise, toCliError } from '../src/effect/boundary.ts'; +import { UsageError } from '../src/options.ts'; + +/** + * The Promise edge keeps the CLI's observable failure contract: typed + * `UsageError` and plain `Error` values rethrow as the same instances, and a + * `PlatformError` unwraps to the Node error it wraps so messages match what + * the scaffolder printed before the filesystem moved onto Effect. + */ + +const enoent = (): NodeJS.ErrnoException => { + const error: NodeJS.ErrnoException = new Error("ENOENT: no such file or directory, open '/tmp/missing.tgz'"); + error.code = 'ENOENT'; + error.syscall = 'open'; + error.path = '/tmp/missing.tgz'; + return error; +}; + +const wrapped = (cause: unknown): PlatformError.PlatformError => + PlatformError.systemError({ + _tag: 'NotFound', + cause, + method: 'readFile', + module: 'FileSystem', + pathOrDescriptor: '/tmp/missing.tgz', + }); + +describe('create-agent-bundle effect boundary', () => { + it('rethrows a usage error as the same instance', async () => { + const error = new UsageError('bad flag'); + await expect(runPromise(Effect.fail(error))).rejects.toBe(error); + }); + + it('unwraps a platform error to the Node error it carries', async () => { + const cause = enoent(); + await expect(runPromise(Effect.fail(wrapped(cause)))).rejects.toBe(cause); + expect(describeError(wrapped(cause))).toBe("ENOENT: no such file or directory, open '/tmp/missing.tgz'"); + }); + + it('keeps a platform error without an Error cause as itself', () => { + const error = wrapped(undefined); + expect(toCliError(error)).toBe(error); + expect(describeError(error)).toBe('NotFound: FileSystem.readFile (/tmp/missing.tgz)'); + }); + + it('rethrows defects as their Error and wraps non-Error values', async () => { + const drift = new Error('Template drift: agent-bundle.config.ts no longer contains `targets: [...]`.'); + await expect(runPromise(Effect.die(drift))).rejects.toBe(drift); + await expect(runPromise(Effect.fail('plain string'))).rejects.toThrow('plain string'); + }); + + it('maps interruption to an AbortError', async () => { + await expect(runPromise(Effect.interrupt)).rejects.toMatchObject({ name: 'AbortError' }); + }); +}); diff --git a/packages/create-agent-bundle/tests/framework.test.ts b/packages/create-agent-bundle/tests/framework.test.ts index ff2541926..ad2477029 100644 --- a/packages/create-agent-bundle/tests/framework.test.ts +++ b/packages/create-agent-bundle/tests/framework.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, expect, it } from '@rstest/core'; +import * as NodeServices from '@effect/platform-node/NodeServices'; +import { Effect, FileSystem, Path } from 'effect'; +import { describe, expect, it, layer } from 'effect-rstest'; import { assertLocalFrameworkTarball, @@ -18,15 +18,20 @@ import { tamperedTrailingHeaderPackageTarball, } from './support/package-tarball.ts'; -const withTarballDirectory = async ( - run: (directory: string) => Promise, -): Promise => { - const directory = await mkdtemp(join(tmpdir(), 'create-agent-bundle-tarball-')); - try { - await run(directory); - } finally { - await rm(directory, { force: true, recursive: true }); +/** A scoped temp directory holding the named tarballs; removed when the test scope closes. */ +const tarballDirectory = Effect.fnUntraced(function* (tarballs: Readonly>) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-tarball-' }); + for (const [name, contents] of Object.entries(tarballs)) { + yield* fs.writeFile(path.join(directory, name), contents); } + return directory; +}); + +const expectUsageError = (error: unknown, message?: string): void => { + expect(error).toBeInstanceOf(UsageError); + if (message !== undefined) expect((error as Error).message).toContain(message); }; describe('previewPackageSpec', () => { @@ -97,85 +102,87 @@ describe('runtimeSpecForFramework', () => { }); }); -describe('assertLocalFrameworkTarball', () => { - it('leaves registry and preview specs to npm', async () => { - await expect(assertLocalFrameworkTarball('0.1.0', tmpdir())).resolves.toBeUndefined(); - await expect(assertLocalFrameworkTarball('next', tmpdir())).resolves.toBeUndefined(); - await expect(assertLocalFrameworkTarball( +layer(NodeServices.layer, { excludeTestServices: true })('assertLocalFrameworkTarball', (it) => { + it.effect('leaves registry and preview specs to npm', () => Effect.gen(function* () { + expect(yield* assertLocalFrameworkTarball('0.1.0', tmpdir())).toBeUndefined(); + expect(yield* assertLocalFrameworkTarball('next', tmpdir())).toBeUndefined(); + expect(yield* assertLocalFrameworkTarball( 'https://pkg.pr.new/ScriptedAlchemy/agent-bundle/agent-bundle@da5df1d', tmpdir(), - )).resolves.toBeUndefined(); - }); - - it('rejects a local tarball that cannot be read', async () => { - await expect(assertLocalFrameworkTarball('file:/tmp/absent-agent-bundle.tgz', tmpdir())) - .rejects.toThrow(UsageError); - }); - - it('accepts a well-formed tarball whose tar header checksum is correct', async () => { - await withTarballDirectory(async (directory) => { - const tarball = join(directory, 'agent-bundle-0.0.0.tgz'); - await writeFile(tarball, packageTarball('agent-bundle')); - await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)).resolves.toBeUndefined(); + )).toBeUndefined(); + })); + + it.effect('rejects a local tarball that cannot be read, naming the Node error', () => Effect.gen(function* () { + const error = yield* Effect.flip(assertLocalFrameworkTarball('file:/tmp/absent-agent-bundle.tgz', tmpdir())); + // The platform wrapper is peeled off: the message is the ENOENT Node error's. + expectUsageError(error, 'Cannot inspect local package tarball "file:/tmp/absent-agent-bundle.tgz": ENOENT'); + })); + + it.effect('accepts a well-formed tarball whose tar header checksum is correct', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ 'agent-bundle-0.0.0.tgz': packageTarball('agent-bundle') }); + const tarball = path.join(directory, 'agent-bundle-0.0.0.tgz'); + expect(yield* assertLocalFrameworkTarball(`file:${tarball}`, directory)).toBeUndefined(); + })); + + it.effect('rejects an inflatable tarball whose tar header checksum does not match', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ 'agent-bundle-0.0.0.tgz': tamperedPackageTarball('agent-bundle') }); + const tarball = path.join(directory, 'agent-bundle-0.0.0.tgz'); + expectUsageError(yield* Effect.flip(assertLocalFrameworkTarball(`file:${tarball}`, directory)), 'Invalid tar header checksum'); + })); + + it.effect('rejects a tarball whose manifest is valid but a later tar header is corrupt', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ + 'agent-bundle-0.0.0.tgz': tamperedTrailingHeaderPackageTarball('agent-bundle'), }); - }); - - it('rejects an inflatable tarball whose tar header checksum does not match', async () => { - await withTarballDirectory(async (directory) => { - const tarball = join(directory, 'agent-bundle-0.0.0.tgz'); - await writeFile(tarball, tamperedPackageTarball('agent-bundle')); - await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)).rejects.toThrow(UsageError); - await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)) - .rejects.toThrow('Invalid tar header checksum'); - }); - }); - - it('rejects a tarball whose manifest is valid but a later tar header is corrupt', async () => { - await withTarballDirectory(async (directory) => { - const tarball = join(directory, 'agent-bundle-0.0.0.tgz'); - await writeFile(tarball, tamperedTrailingHeaderPackageTarball('agent-bundle')); - await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)).rejects.toThrow(UsageError); - await expect(assertLocalFrameworkTarball(`file:${tarball}`, directory)) - .rejects.toThrow('Invalid tar header checksum'); - }); - }); - - it('resolves a relative file: spec against the base directory, not the process working directory', async () => { - await withTarballDirectory(async (directory) => { - await writeFile(join(directory, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')); - const spec = 'file:../agent-bundle-0.0.0.tgz'; - await expect(assertLocalFrameworkTarball(spec, join(directory, 'project'))).resolves.toBeUndefined(); - await expect(assertLocalFrameworkTarball(spec, process.cwd())).rejects.toThrow(UsageError); - }); - }); + const tarball = path.join(directory, 'agent-bundle-0.0.0.tgz'); + expectUsageError(yield* Effect.flip(assertLocalFrameworkTarball(`file:${tarball}`, directory)), 'Invalid tar header checksum'); + })); + + it.effect('resolves a relative file: spec against the base directory, not the process working directory', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ 'agent-bundle-0.0.0.tgz': packageTarball('agent-bundle') }); + const spec = 'file:../agent-bundle-0.0.0.tgz'; + expect(yield* assertLocalFrameworkTarball(spec, path.join(directory, 'project'))).toBeUndefined(); + expectUsageError(yield* Effect.flip(assertLocalFrameworkTarball(spec, process.cwd()))); + })); }); -describe('validatedRuntimeSpecForFramework', () => { - it('leaves registry and preview specs to npm', async () => { - await expect(validatedRuntimeSpecForFramework('0.1.0', tmpdir())).resolves.toBe('0.1.0'); - }); - - it('resolves a relative file: pair against the base directory', async () => { - await withTarballDirectory(async (directory) => { - await Promise.all([ - writeFile(join(directory, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')), - writeFile(join(directory, 'agent-bundle-runtime-0.0.0.tgz'), packageTarball('@agent-bundle/runtime')), - ]); - const spec = 'file:../agent-bundle-0.0.0.tgz'; - await expect(validatedRuntimeSpecForFramework(spec, join(directory, 'project'))) - .resolves.toBe('file:../agent-bundle-runtime-0.0.0.tgz'); - await expect(validatedRuntimeSpecForFramework(spec, process.cwd())).rejects.toThrow(UsageError); +layer(NodeServices.layer, { excludeTestServices: true })('validatedRuntimeSpecForFramework', (it) => { + it.effect('leaves registry and preview specs to npm', () => Effect.gen(function* () { + expect(yield* validatedRuntimeSpecForFramework('0.1.0', tmpdir())).toBe('0.1.0'); + })); + + it.effect('fails closed on the typed usage error when no runtime spec can be derived', () => Effect.gen(function* () { + expectUsageError( + yield* Effect.flip(validatedRuntimeSpecForFramework('file:/tmp/framework.tgz', tmpdir())), + 'npm registry version, range, or tag', + ); + })); + + it.effect('resolves a relative file: pair against the base directory', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ + 'agent-bundle-0.0.0.tgz': packageTarball('agent-bundle'), + 'agent-bundle-runtime-0.0.0.tgz': packageTarball('@agent-bundle/runtime'), }); - }); - - it('rejects a pair whose runtime tarball has a tampered tar header', async () => { - await withTarballDirectory(async (directory) => { - await Promise.all([ - writeFile(join(directory, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')), - writeFile(join(directory, 'agent-bundle-runtime-0.0.0.tgz'), tamperedPackageTarball('@agent-bundle/runtime')), - ]); - await expect(validatedRuntimeSpecForFramework(`file:${join(directory, 'agent-bundle-0.0.0.tgz')}`, directory)) - .rejects.toThrow('Invalid tar header checksum'); + const spec = 'file:../agent-bundle-0.0.0.tgz'; + expect(yield* validatedRuntimeSpecForFramework(spec, path.join(directory, 'project'))) + .toBe('file:../agent-bundle-runtime-0.0.0.tgz'); + expectUsageError(yield* Effect.flip(validatedRuntimeSpecForFramework(spec, process.cwd()))); + })); + + it.effect('rejects a pair whose runtime tarball has a tampered tar header', () => Effect.gen(function* () { + const path = yield* Path.Path; + const directory = yield* tarballDirectory({ + 'agent-bundle-0.0.0.tgz': packageTarball('agent-bundle'), + 'agent-bundle-runtime-0.0.0.tgz': tamperedPackageTarball('@agent-bundle/runtime'), }); - }); + expectUsageError( + yield* Effect.flip(validatedRuntimeSpecForFramework(`file:${path.join(directory, 'agent-bundle-0.0.0.tgz')}`, directory)), + 'Invalid tar header checksum', + ); + })); }); diff --git a/packages/create-agent-bundle/tests/scaffold-noop.test.ts b/packages/create-agent-bundle/tests/scaffold-noop.test.ts new file mode 100644 index 000000000..4a81534c6 --- /dev/null +++ b/packages/create-agent-bundle/tests/scaffold-noop.test.ts @@ -0,0 +1,205 @@ +import { Effect, FileSystem, Layer, Option, Path, PlatformError } from 'effect'; +import { describe, expect, it } from 'effect-rstest'; + +import { assertLocalFrameworkTarball } from '../src/framework.ts'; +import { UsageError } from '../src/options.ts'; +import { assertScaffoldTarget, scaffold } from '../src/scaffold.ts'; + +/** + * Deterministic unit tests over `FileSystem.layerNoop`: every operation the + * scaffolder performs is overridden explicitly (the noop defaults fail with + * NotFound or die), so these tests pin the exact read/mkdir/write protocol + * without touching the disk. OS semantics — real temp directories, tarball + * inflation — stay in scaffold.test.ts and framework.test.ts. + */ + +const fileInfo = (type: FileSystem.File.Type): FileSystem.File.Info => ({ + atime: Option.none(), + birthtime: Option.none(), + blksize: Option.none(), + blocks: Option.none(), + dev: 0, + gid: Option.none(), + ino: Option.none(), + mode: 0o644, + mtime: Option.none(), + nlink: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0), + type, + uid: Option.none(), +}); + +const notFound = (method: string, path: string): PlatformError.PlatformError => + PlatformError.systemError({ _tag: 'NotFound', method, module: 'FileSystem', pathOrDescriptor: path }); + +interface RecordedFileSystem { + readonly directories: { readonly path: string; readonly recursive: boolean | undefined }[]; + readonly layer: Layer.Layer; + readonly written: Map; +} + +/** An in-memory template tree keyed by absolute POSIX path; directories are implied by their files. */ +const templateFileSystem = (files: Readonly>): RecordedFileSystem => { + const directories: RecordedFileSystem['directories'] = []; + const written = new Map(); + const children = (directory: string): string[] | undefined => { + const prefix = `${directory}/`; + const names = new Set(); + for (const file of Object.keys(files)) { + if (!file.startsWith(prefix)) continue; + names.add(file.slice(prefix.length).split('/')[0]!); + } + return names.size === 0 ? undefined : [...names]; + }; + const fileSystem = FileSystem.layerNoop({ + makeDirectory: (path, options) => Effect.sync(() => { + directories.push({ path, recursive: options?.recursive }); + }), + readDirectory: (path) => { + const entries = children(path); + return entries === undefined ? Effect.fail(notFound('readDirectory', path)) : Effect.succeed(entries); + }, + readFileString: (path) => { + const contents = files[path]; + return contents === undefined ? Effect.fail(notFound('readFileString', path)) : Effect.succeed(contents); + }, + stat: (path) => { + if (path in files) return Effect.succeed(fileInfo('File')); + if (children(path) !== undefined) return Effect.succeed(fileInfo('Directory')); + return Effect.fail(notFound('stat', path)); + }, + writeFileString: (path, data) => Effect.sync(() => { + written.set(path, data); + }), + }); + return { directories, layer: Layer.merge(fileSystem, Path.layer), written }; +}; + +const templateRoot = '/templates/minimal'; +const minimalTemplate = { + [`${templateRoot}/README.md`]: '# my-agent-plugin\n', + [`${templateRoot}/agent-bundle.config.ts`]: "export default { name: 'my-agent-plugin', targets: ['portable', 'codex', 'claude'] };\n", + [`${templateRoot}/gitignore`]: 'node_modules\n', + [`${templateRoot}/package_json`]: `${JSON.stringify({ + devDependencies: { 'agent-bundle': 'workspace:*' }, + name: 'my-agent-plugin', + }, null, 2)}\n`, + [`${templateRoot}/src/skills/getting-started/SKILL.md`]: '---\nname: my-agent-plugin\n---\n', +}; + +describe('scaffold over FileSystem.layerNoop', () => { + it.effect('copies the template through readDirectory/stat/readFileString/makeDirectory/writeFileString', () => { + const recorded = templateFileSystem(minimalTemplate); + return Effect.gen(function* () { + const files = yield* scaffold({ + frameworkSpec: '0.4.0', + packageName: '@scope/status-plugin', + pluginName: 'status-plugin', + targetDirectory: '/project', + targets: ['portable', 'cursor'], + templateRoot, + }); + // Sorted in code-unit order; the rename table restored the real names. + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'src/skills/getting-started/SKILL.md', + ]); + // Every directory on the way down is created recursively, parent first. + expect(recorded.directories).toEqual([ + { path: '/project', recursive: true }, + { path: '/project/src', recursive: true }, + { path: '/project/src/skills', recursive: true }, + { path: '/project/src/skills/getting-started', recursive: true }, + ]); + expect([...recorded.written.keys()].sort()).toEqual([ + '/project/.gitignore', + '/project/README.md', + '/project/agent-bundle.config.ts', + '/project/package.json', + '/project/src/skills/getting-started/SKILL.md', + ]); + expect(recorded.written.get('/project/.gitignore')).toBe('node_modules\n'); + expect(recorded.written.get('/project/README.md')).toBe('# status-plugin\n'); + expect(recorded.written.get('/project/src/skills/getting-started/SKILL.md')).toBe('---\nname: status-plugin\n---\n'); + expect(recorded.written.get('/project/agent-bundle.config.ts')) + .toBe("export default { name: 'status-plugin', targets: ['portable', 'cursor'] };\n"); + expect(JSON.parse(recorded.written.get('/project/package.json') ?? '')).toEqual({ + devDependencies: { 'agent-bundle': '0.4.0' }, + name: '@scope/status-plugin', + }); + }).pipe(Effect.provide(recorded.layer)); + }); + + it.effect('validates the framework spec before creating or writing anything', () => { + const recorded = templateFileSystem({ + ...minimalTemplate, + [`${templateRoot}/package_json`]: `${JSON.stringify({ + dependencies: { '@agent-bundle/runtime': 'workspace:*' }, + name: 'my-agent-plugin', + })}\n`, + }); + return Effect.gen(function* () { + const error = yield* Effect.flip(scaffold({ + frameworkSpec: 'file:../agent-bundle-0.4.0.tgz', + packageName: 'status-plugin', + pluginName: 'status-plugin', + targetDirectory: '/project', + targets: ['portable'], + templateRoot, + })); + expect(error).toBeInstanceOf(UsageError); + expect((error as Error).message).toContain('Cannot inspect local package tarball "file:../agent-bundle-0.4.0.tgz"'); + expect(recorded.directories).toEqual([]); + expect(recorded.written.size).toBe(0); + }).pipe(Effect.provide(recorded.layer)); + }); + + it.effect('reports the platform failure when the tarball cannot be read', () => Effect.gen(function* () { + const error = yield* Effect.flip(assertLocalFrameworkTarball('file:/tmp/agent-bundle.tgz', '/project')); + expect(error).toBeInstanceOf(UsageError); + // No Node cause behind the noop error, so the PlatformError's own message is reported. + expect((error as Error).message).toBe( + 'Cannot inspect local package tarball "file:/tmp/agent-bundle.tgz": ' + + 'NotFound: FileSystem.readFile (/tmp/agent-bundle.tgz): No such file or directory', + ); + }).pipe(Effect.provide(Layer.merge(FileSystem.layerNoop({}), Path.layer)))); +}); + +describe('assertScaffoldTarget over FileSystem.layerNoop', () => { + const targetLayer = (entries: readonly string[] | PlatformError.PlatformError): Layer.Layer => + FileSystem.layerNoop({ + readDirectory: () => (Array.isArray(entries) ? Effect.succeed([...entries]) : Effect.fail(entries as PlatformError.PlatformError)), + }); + + it.effect('treats a missing directory as available', () => Effect.gen(function* () { + expect(yield* assertScaffoldTarget('/absent', 'absent')).toBeUndefined(); + }).pipe(Effect.provide(targetLayer(notFound('readDirectory', '/absent'))))); + + it.effect('accepts an empty directory and a lone .git', () => Effect.gen(function* () { + expect(yield* assertScaffoldTarget('/empty', 'empty').pipe(Effect.provide(targetLayer([])))).toBeUndefined(); + expect(yield* assertScaffoldTarget('/git-only', 'git-only').pipe(Effect.provide(targetLayer(['.git'])))).toBeUndefined(); + })); + + it.effect('rejects a directory with real contents as a usage error', () => Effect.gen(function* () { + const error = yield* Effect.flip(assertScaffoldTarget('/occupied', 'my-plugin')); + expect(error).toBeInstanceOf(UsageError); + expect((error as Error).message).toBe('Target directory "my-plugin" is not empty. Choose a new directory or empty it first.'); + }).pipe(Effect.provide(targetLayer(['.git', 'existing.txt'])))); + + it.effect('lets every other platform failure through untouched', () => { + const denied = PlatformError.systemError({ + _tag: 'PermissionDenied', + method: 'readDirectory', + module: 'FileSystem', + pathOrDescriptor: '/locked', + }); + return Effect.gen(function* () { + const error = yield* Effect.flip(assertScaffoldTarget('/locked', 'locked')); + expect(error).toBe(denied); + }).pipe(Effect.provide(targetLayer(denied))); + }); +}); diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 34838b442..2c5c0f417 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -1,17 +1,28 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it } from '@rstest/core'; +import * as NodeServices from '@effect/platform-node/NodeServices'; +import { Cause, Effect, type Exit, FileSystem, Path } from 'effect'; +import { expect, layer } from 'effect-rstest'; import { runtimeSpecForFramework } from '../src/framework.ts'; import { UsageError, type TargetName } from '../src/options.ts'; import { assertScaffoldTarget, placeholderName, scaffold } from '../src/scaffold.ts'; import { packageTarball, tamperedPackageTarball } from './support/package-tarball.ts'; -const templatesRoot = join(process.cwd(), 'packages', 'create-agent-bundle', 'templates'); - -const scaffoldTemplate = async ( +const workspaceRoot = process.cwd(); +const templateRoot = (path: Path.Path, template: string): string => + path.join(workspaceRoot, 'packages', 'create-agent-bundle', 'templates', template); + +interface ScaffoldedTemplate { + readonly files: readonly string[]; + readonly frameworkSpec: string; + readonly root: string; +} + +/** + * Scaffold one template into a scoped temp directory: the directory, and + * everything the scaffold wrote under it, is removed when the test's scope + * closes — no `finally` bookkeeping per test. + */ +const scaffoldTemplate = Effect.fnUntraced(function* ( template: string, overrides: Partial<{ packageName: string; @@ -19,489 +30,446 @@ const scaffoldTemplate = async ( targets: readonly TargetName[]; withRuntimeTarball: boolean; }> = {}, -): Promise<{ readonly files: readonly string[]; readonly frameworkSpec: string; readonly root: string }> => { - const root = await mkdtemp(join(tmpdir(), `create-agent-bundle-${template}-`)); - const frameworkTarball = join(root, 'agent-bundle-0.0.0.tgz'); - const runtimeTarball = join(root, 'agent-bundle-runtime-0.0.0.tgz'); - await writeFile(frameworkTarball, packageTarball('agent-bundle')); +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: `create-agent-bundle-${template}-` }); + const frameworkTarball = path.join(root, 'agent-bundle-0.0.0.tgz'); + const runtimeTarball = path.join(root, 'agent-bundle-runtime-0.0.0.tgz'); + yield* fs.writeFile(frameworkTarball, packageTarball('agent-bundle')); if (overrides.withRuntimeTarball !== false) { - await writeFile(runtimeTarball, packageTarball('@agent-bundle/runtime')); + yield* fs.writeFile(runtimeTarball, packageTarball('@agent-bundle/runtime')); } const frameworkSpec = `file:${frameworkTarball}`; - const files = await scaffold({ + const files = yield* scaffold({ frameworkSpec, packageName: overrides.packageName ?? 'status-plugin', pluginName: overrides.pluginName ?? 'status-plugin', - targetDirectory: join(root, 'project'), + targetDirectory: path.join(root, 'project'), targets: overrides.targets ?? ['portable', 'codex', 'claude'], - templateRoot: join(templatesRoot, template), - }); - await Promise.all([rm(frameworkTarball), rm(runtimeTarball, { force: true })]); - return { files, frameworkSpec, root: join(root, 'project') }; -}; - -describe('scaffold', () => { - it('emits the documented minimal inventory', async () => { - const { files, root } = await scaffoldTemplate('minimal'); - try { - expect(files).toEqual([ - '.gitignore', - 'README.md', - 'agent-bundle.config.ts', - 'package.json', - 'src/skills/getting-started/SKILL.md', - 'tests/skill.test.ts', - 'tsconfig.json', - ]); - } finally { - await rm(root, { force: true, recursive: true }); - } + templateRoot: templateRoot(path, template), }); + yield* fs.remove(frameworkTarball); + yield* fs.remove(runtimeTarball, { force: true }); + const scaffolded: ScaffoldedTemplate = { files, frameworkSpec, root: path.join(root, 'project') }; + return scaffolded; +}); - it('emits the documented mcp-server inventory', async () => { - const { files, root } = await scaffoldTemplate('mcp-server'); - try { - expect(files).toEqual([ - '.gitignore', - 'README.md', - 'agent-bundle.config.ts', - 'package.json', - 'rstest.projection.config.ts', - 'rstest.route-unit.config.ts', - 'src/mcp/status/tools/report-status.tsx', - 'src/scripts/check-status.ts', - 'src/status.ts', - 'tests/projection/mcp-in-memory.test.ts', - 'tests/route-unit/report-status.test.ts', - 'tests/status.test.ts', - 'tsconfig.json', - ]); - } finally { - await rm(root, { force: true, recursive: true }); - } +const readJson = (file: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return JSON.parse(yield* fs.readFileString(file)) as T; }); - it('emits the documented cli-tool inventory', async () => { - const { files, root } = await scaffoldTemplate('cli-tool'); - try { - expect(files).toEqual([ - '.gitignore', - 'README.md', - 'agent-bundle.config.ts', - 'package.json', - 'rstest.projection.config.ts', - 'src/cli/greet.ts', - 'src/index.ts', - 'src/scripts/hello.ts', - 'tests/greet.test.ts', - 'tests/projection/cli-dispatch.test.ts', - 'tests/projection/script-dispatch.test.ts', - 'tsconfig.json', - ]); - } finally { - await rm(root, { force: true, recursive: true }); - } +const readText = (file: string): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file); }); - it('scaffolds publishable package fields for templates with package builds', async () => { - const [cliTool, mcpServer] = await Promise.all([ +layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesystem)', (it) => { + it.effect('emits the documented minimal inventory', () => Effect.gen(function* () { + const { files } = yield* scaffoldTemplate('minimal'); + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'src/skills/getting-started/SKILL.md', + 'tests/skill.test.ts', + 'tsconfig.json', + ]); + })); + + it.effect('emits the documented mcp-server inventory', () => Effect.gen(function* () { + const { files } = yield* scaffoldTemplate('mcp-server'); + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'rstest.projection.config.ts', + 'rstest.route-unit.config.ts', + 'src/mcp/status/tools/report-status.tsx', + 'src/scripts/check-status.ts', + 'src/status.ts', + 'tests/projection/mcp-in-memory.test.ts', + 'tests/route-unit/report-status.test.ts', + 'tests/status.test.ts', + 'tsconfig.json', + ]); + })); + + it.effect('emits the documented cli-tool inventory', () => Effect.gen(function* () { + const { files } = yield* scaffoldTemplate('cli-tool'); + expect(files).toEqual([ + '.gitignore', + 'README.md', + 'agent-bundle.config.ts', + 'package.json', + 'rstest.projection.config.ts', + 'src/cli/greet.ts', + 'src/index.ts', + 'src/scripts/hello.ts', + 'tests/greet.test.ts', + 'tests/projection/cli-dispatch.test.ts', + 'tests/projection/script-dispatch.test.ts', + 'tsconfig.json', + ]); + })); + + it.effect('scaffolds publishable package fields for templates with package builds', () => Effect.gen(function* () { + const path = yield* Path.Path; + const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin' }), - ]); - try { - const cliManifest = JSON.parse(await readFile(join(cliTool.root, 'package.json'), 'utf8')) as { - readonly bin: Record; - readonly files: readonly string[]; - readonly scripts: Record; - }; - expect(cliManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - 'greeter-install': './dist/bin/greeter-install.js', - }); - expect(cliManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); - - const mcpManifest = JSON.parse(await readFile(join(mcpServer.root, 'package.json'), 'utf8')) as { - readonly bin: Record; - readonly exports: Record; - readonly files: readonly string[]; - readonly private: boolean; - readonly scripts: Record; - }; - expect(mcpManifest.private).toBe(true); - expect(mcpManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(mcpManifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); - expect(mcpManifest.exports).toEqual({ - '.': { types: './dist/status.d.ts', import: './dist/status.js' }, - }); - expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); - await expect(readFile(join(mcpServer.root, 'agent-bundle.config.ts'), 'utf8')) - .resolves.toContain("lib: './src/status.ts'"); - } finally { - await Promise.all([ - rm(cliTool.root, { force: true, recursive: true }), - rm(mcpServer.root, { force: true, recursive: true }), - ]); - } - }); + ], { concurrency: 'unbounded' }); + const cliManifest = yield* readJson<{ + readonly bin: Record; + readonly files: readonly string[]; + readonly scripts: Record; + }>(path.join(cliTool.root, 'package.json')); + expect(cliManifest.files).toEqual(['dist', 'artifact', 'README.md']); + expect(cliManifest.bin).toEqual({ + greeter: './dist/bin/greeter.js', + 'greeter-install': './dist/bin/greeter-install.js', + }); + expect(cliManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + + const mcpManifest = yield* readJson<{ + readonly bin: Record; + readonly exports: Record; + readonly files: readonly string[]; + readonly private: boolean; + readonly scripts: Record; + }>(path.join(mcpServer.root, 'package.json')); + expect(mcpManifest.private).toBe(true); + expect(mcpManifest.files).toEqual(['dist', 'artifact', 'README.md']); + expect(mcpManifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); + expect(mcpManifest.exports).toEqual({ + '.': { types: './dist/status.d.ts', import: './dist/status.js' }, + }); + expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + expect(yield* readText(path.join(mcpServer.root, 'agent-bundle.config.ts'))).toContain("lib: './src/status.ts'"); + })); - it('drops generated installer bins when no installable host target is selected', async () => { - const [cliTool, mcpServer] = await Promise.all([ + it.effect('drops generated installer bins when no installable host target is selected', () => Effect.gen(function* () { + const path = yield* Path.Path; + const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['portable'] }), - ]); - try { - const cliManifest = JSON.parse(await readFile(join(cliTool.root, 'package.json'), 'utf8')) as { - readonly bin?: Record; - }; - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - }); - - const mcpManifest = JSON.parse(await readFile(join(mcpServer.root, 'package.json'), 'utf8')) as { - readonly bin?: Record; - }; - expect(mcpManifest.bin).toBeUndefined(); - } finally { - await Promise.all([ - rm(cliTool.root, { force: true, recursive: true }), - rm(mcpServer.root, { force: true, recursive: true }), - ]); - } - }); + ], { concurrency: 'unbounded' }); + const cliManifest = yield* readJson<{ readonly bin?: Record }>(path.join(cliTool.root, 'package.json')); + expect(cliManifest.bin).toEqual({ + greeter: './dist/bin/greeter.js', + }); + + const mcpManifest = yield* readJson<{ readonly bin?: Record }>(path.join(mcpServer.root, 'package.json')); + expect(mcpManifest.bin).toBeUndefined(); + })); - it('renders README install instructions for the selected targets', async () => { - const [defaults, cursorOnly, pluginOnly, portableOnly, minimal] = await Promise.all([ + it.effect('renders README install instructions for the selected targets', () => Effect.gen(function* () { + const path = yield* Path.Path; + const [defaults, cursorOnly, pluginOnly, portableOnly, minimal] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['cursor'] }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['plugin'] }), scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('minimal', { pluginName: 'skills-only', targets: ['portable'] }), - ]); - try { - // Default targets (portable, codex, claude): one line per installable host, - // in the package build's host order; the template's hard-coded `claude` - // example never survives as the only instruction (#317 review). - const defaultReadme = await readFile(join(defaults.root, 'README.md'), 'utf8'); - expect(defaultReadme).toContain([ - '# after publishing/installing the package', - 'npx greeter-install install claude', - 'npx greeter-install install codex', - '', - ].join('\n')); - expect(defaultReadme).not.toContain('install cursor'); - expect(defaultReadme).toContain('The installer accepts the\nselected host targets only: `claude`, `codex`.'); - - // A cursor-only scaffold's installer rejects `claude`, so the README must - // not suggest it. - const cursorReadme = await readFile(join(cursorOnly.root, 'README.md'), 'utf8'); - expect(cursorReadme).toContain('npx status-plugin install cursor\n'); - expect(cursorReadme).not.toContain('install claude'); - expect(cursorReadme).not.toContain('install codex'); - - // The composite plugin target installs into every host. - const pluginReadme = await readFile(join(pluginOnly.root, 'README.md'), 'utf8'); - expect(pluginReadme).toContain([ - 'npx status-plugin install claude', - 'npx status-plugin install codex', - 'npx status-plugin install cursor', - ].join('\n')); - - // Portable-only scaffolds ship no installer bin at all. - const portableReadme = await readFile(join(portableOnly.root, 'README.md'), 'utf8'); - expect(portableReadme).not.toMatch(/^npx \S+ install /mu); - expect(portableReadme).toContain("no installable host target ('portable')"); - expect(portableReadme).toContain('add `claude`, `codex`, or `cursor` to `targets`'); - // Re-enabling installers needs the dropped package.json bin entry back too, - // and the README names exactly the mapping the template shipped. - const templateManifest = JSON.parse( - await readFile(join(templatesRoot, 'cli-tool', 'package_json'), 'utf8'), - ) as { readonly bin: Record }; - const installerBin = `${placeholderName}-install`; - expect(templateManifest.bin[installerBin]).toBeDefined(); - const droppedEntry = `"greeter-install": "${templateManifest.bin[installerBin]?.replaceAll(placeholderName, 'greeter')}"`; - expect(portableReadme).toContain(`# ${droppedEntry} to get one`); - expect(portableReadme).toContain(`restore \`${droppedEntry}\` under \`bin\` in`); - expect(portableReadme).toContain('never edits the manifest'); - - // The skills-only template has no install section and passes through. - const minimalReadme = await readFile(join(minimal.root, 'README.md'), 'utf8'); - expect(minimalReadme).toBe( - (await readFile(join(templatesRoot, 'minimal', 'README.md'), 'utf8')).replaceAll(placeholderName, 'skills-only'), - ); - } finally { - await Promise.all([defaults, cursorOnly, pluginOnly, portableOnly, minimal].map( - ({ root }) => rm(root, { force: true, recursive: true }), - )); - } - }); - - it('leaves the skills-only template without package-build packaging fields', async () => { - const { root } = await scaffoldTemplate('minimal'); - try { - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - readonly bin?: unknown; - readonly files?: unknown; - readonly scripts: Record; - }; - expect(manifest.bin).toBeUndefined(); - expect(manifest.files).toBeUndefined(); - expect(manifest.scripts.prepack).toBeUndefined(); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it('scaffolds the minimal template without a runtime tarball', async () => { - const { root } = await scaffoldTemplate('minimal', { withRuntimeTarball: false }); - try { - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - readonly devDependencies: Record; - }; - expect(manifest.devDependencies['agent-bundle']).toMatch(/^file:/u); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); + ], { concurrency: 'unbounded' }); + // Default targets (portable, codex, claude): one line per installable host, + // in the package build's host order; the template's hard-coded `claude` + // example never survives as the only instruction (#317 review). + const defaultReadme = yield* readText(path.join(defaults.root, 'README.md')); + expect(defaultReadme).toContain([ + '# after publishing/installing the package', + 'npx greeter-install install claude', + 'npx greeter-install install codex', + '', + ].join('\n')); + expect(defaultReadme).not.toContain('install cursor'); + expect(defaultReadme).toContain('The installer accepts the\nselected host targets only: `claude`, `codex`.'); + + // A cursor-only scaffold's installer rejects `claude`, so the README must + // not suggest it. + const cursorReadme = yield* readText(path.join(cursorOnly.root, 'README.md')); + expect(cursorReadme).toContain('npx status-plugin install cursor\n'); + expect(cursorReadme).not.toContain('install claude'); + expect(cursorReadme).not.toContain('install codex'); + + // The composite plugin target installs into every host. + const pluginReadme = yield* readText(path.join(pluginOnly.root, 'README.md')); + expect(pluginReadme).toContain([ + 'npx status-plugin install claude', + 'npx status-plugin install codex', + 'npx status-plugin install cursor', + ].join('\n')); + + // Portable-only scaffolds ship no installer bin at all. + const portableReadme = yield* readText(path.join(portableOnly.root, 'README.md')); + expect(portableReadme).not.toMatch(/^npx \S+ install /mu); + expect(portableReadme).toContain("no installable host target ('portable')"); + expect(portableReadme).toContain('add `claude`, `codex`, or `cursor` to `targets`'); + // Re-enabling installers needs the dropped package.json bin entry back too, + // and the README names exactly the mapping the template shipped. + const templateManifest = yield* readJson<{ readonly bin: Record }>( + path.join(templateRoot(path, 'cli-tool'), 'package_json'), + ); + const installerBin = `${placeholderName}-install`; + expect(templateManifest.bin[installerBin]).toBeDefined(); + const droppedEntry = `"greeter-install": "${templateManifest.bin[installerBin]?.replaceAll(placeholderName, 'greeter')}"`; + expect(portableReadme).toContain(`# ${droppedEntry} to get one`); + expect(portableReadme).toContain(`restore \`${droppedEntry}\` under \`bin\` in`); + expect(portableReadme).toContain('never edits the manifest'); + + // The skills-only template has no install section and passes through. + const minimalReadme = yield* readText(path.join(minimal.root, 'README.md')); + expect(minimalReadme).toBe( + (yield* readText(path.join(templateRoot(path, 'minimal'), 'README.md'))).replaceAll(placeholderName, 'skills-only'), + ); + })); + + it.effect('leaves the skills-only template without package-build packaging fields', () => Effect.gen(function* () { + const path = yield* Path.Path; + const { root } = yield* scaffoldTemplate('minimal'); + const manifest = yield* readJson<{ + readonly bin?: unknown; + readonly files?: unknown; + readonly scripts: Record; + }>(path.join(root, 'package.json')); + expect(manifest.bin).toBeUndefined(); + expect(manifest.files).toBeUndefined(); + expect(manifest.scripts.prepack).toBeUndefined(); + })); + + it.effect('scaffolds the minimal template without a runtime tarball', () => Effect.gen(function* () { + const path = yield* Path.Path; + const { root } = yield* scaffoldTemplate('minimal', { withRuntimeTarball: false }); + const manifest = yield* readJson<{ + readonly devDependencies: Record; + }>(path.join(root, 'package.json')); + expect(manifest.devDependencies['agent-bundle']).toMatch(/^file:/u); + })); // Routed commands execute inside the typed Agent request context, so the // cli-tool template now pairs the runtime package like mcp-server does. - it('pins the paired runtime for the routed cli-tool template', async () => { - const { frameworkSpec, root } = await scaffoldTemplate('cli-tool', { pluginName: 'greeter' }); - try { - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - readonly dependencies: Record; - }; - expect(manifest.dependencies['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); - expect(manifest.dependencies['zod']).toBeDefined(); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it('replaces every placeholder and pins the framework spec', async () => { - const { files, frameworkSpec, root } = await scaffoldTemplate('cli-tool', { + it.effect('pins the paired runtime for the routed cli-tool template', () => Effect.gen(function* () { + const path = yield* Path.Path; + const { frameworkSpec, root } = yield* scaffoldTemplate('cli-tool', { pluginName: 'greeter' }); + const manifest = yield* readJson<{ + readonly dependencies: Record; + }>(path.join(root, 'package.json')); + expect(manifest.dependencies['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); + expect(manifest.dependencies['zod']).toBeDefined(); + })); + + it.effect('replaces every placeholder and pins the framework spec', () => Effect.gen(function* () { + const path = yield* Path.Path; + const { files, frameworkSpec, root } = yield* scaffoldTemplate('cli-tool', { packageName: '@scope/status-plugin', pluginName: 'status-plugin', }); - try { - for (const file of files) { - const contents = await readFile(join(root, file), 'utf8'); - expect(contents).not.toContain(placeholderName); - expect(contents).not.toContain('workspace:*'); - } - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - readonly bin: Record; - readonly dependencies?: Record; - readonly devDependencies: Record; - readonly name: string; - }; - expect(manifest.name).toBe('@scope/status-plugin'); - expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); - expect(files).toContain('src/cli/greet.ts'); - expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); - expect(manifest.bin).toEqual({ - 'status-plugin': './dist/bin/status-plugin.js', - 'status-plugin-install': './dist/bin/status-plugin-install.js', - }); - const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); - expect(config).toContain("name: 'status-plugin'"); - // Routed CLI: no `scripts` or `bin` entry names the executable; the - // command graph compiles from src/cli/** by convention. - expect(config).not.toMatch(/\bscripts:/u); - expect(config).not.toMatch(/\bbin:/u); - // The generated help names the scaffolded plugin, and the template's own - // proof asserts that exact text, so the rename must reach the test. - expect(await readFile(join(root, 'tests/projection/cli-dispatch.test.ts'), 'utf8')) - .toContain("Run 'status-plugin greet --help' for usage."); - } finally { - await rm(root, { force: true, recursive: true }); + for (const file of files) { + const contents = yield* readText(path.join(root, file)); + expect(contents).not.toContain(placeholderName); + expect(contents).not.toContain('workspace:*'); } - }); + const manifest = yield* readJson<{ + readonly bin: Record; + readonly dependencies?: Record; + readonly devDependencies: Record; + readonly name: string; + }>(path.join(root, 'package.json')); + expect(manifest.name).toBe('@scope/status-plugin'); + expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); + expect(files).toContain('src/cli/greet.ts'); + expect(manifest.dependencies?.['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); + expect(manifest.bin).toEqual({ + 'status-plugin': './dist/bin/status-plugin.js', + 'status-plugin-install': './dist/bin/status-plugin-install.js', + }); + const config = yield* readText(path.join(root, 'agent-bundle.config.ts')); + expect(config).toContain("name: 'status-plugin'"); + // Routed CLI: no `scripts` or `bin` entry names the executable; the + // command graph compiles from src/cli/** by convention. + expect(config).not.toMatch(/\bscripts:/u); + expect(config).not.toMatch(/\bbin:/u); + // The generated help names the scaffolded plugin, and the template's own + // proof asserts that exact text, so the rename must reach the test. + expect(yield* readText(path.join(root, 'tests/projection/cli-dispatch.test.ts'))) + .toContain("Run 'status-plugin greet --help' for usage."); + })); for (const template of ['minimal', 'mcp-server', 'cli-tool'] as const) { - it(`declares the ${template} release version only in package.json`, async () => { - const { root } = await scaffoldTemplate(template); - try { - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { - readonly version?: string; - }; - expect(manifest.version).toBe('0.1.0'); - const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); - expect(config).not.toMatch(/\bversion:/u); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); + it.effect(`declares the ${template} release version only in package.json`, () => Effect.gen(function* () { + const path = yield* Path.Path; + const { root } = yield* scaffoldTemplate(template); + const manifest = yield* readJson<{ readonly version?: string }>(path.join(root, 'package.json')); + expect(manifest.version).toBe('0.1.0'); + const config = yield* readText(path.join(root, 'agent-bundle.config.ts')); + expect(config).not.toMatch(/\bversion:/u); + })); } - it('writes the selected targets into the config', async () => { - const { root } = await scaffoldTemplate('minimal', { targets: ['portable', 'cursor'] }); - try { - const config = await readFile(join(root, 'agent-bundle.config.ts'), 'utf8'); - expect(config).toContain("targets: ['portable', 'cursor'],"); - expect(config).not.toContain("'codex'"); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it('validates local runtime tarballs before writing scaffold files', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-validation-')); - const frameworkTarball = join(root, 'agent-bundle-0.0.0.tgz'); - const targetDirectory = join(root, 'project'); - try { - await writeFile(frameworkTarball, packageTarball('agent-bundle')); - await expect(scaffold({ - frameworkSpec: `file:${frameworkTarball}`, - packageName: 'status-plugin', - pluginName: 'status-plugin', - targetDirectory, - targets: ['portable', 'codex', 'claude'], - templateRoot: join(templatesRoot, 'mcp-server'), - })).rejects.toThrow(UsageError); - await expect(readdir(targetDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - const scaffoldFrameworkOnly = async ( - tarball: Buffer | undefined, - check: ( - scaffolded: Promise, - targetDirectory: string, - frameworkSpec: string, - ) => Promise, - ): Promise => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-framework-only-')); - const frameworkTarball = join(root, 'agent-bundle-0.0.0.tgz'); - const targetDirectory = join(root, 'project'); + it.effect('writes the selected targets into the config', () => Effect.gen(function* () { + const path = yield* Path.Path; + const { root } = yield* scaffoldTemplate('minimal', { targets: ['portable', 'cursor'] }); + const config = yield* readText(path.join(root, 'agent-bundle.config.ts')); + expect(config).toContain("targets: ['portable', 'cursor'],"); + expect(config).not.toContain("'codex'"); + })); + + it.effect('validates local runtime tarballs before writing scaffold files', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-validation-' }); + const frameworkTarball = path.join(root, 'agent-bundle-0.0.0.tgz'); + const targetDirectory = path.join(root, 'project'); + yield* fs.writeFile(frameworkTarball, packageTarball('agent-bundle')); + const error = yield* Effect.flip(scaffold({ + frameworkSpec: `file:${frameworkTarball}`, + packageName: 'status-plugin', + pluginName: 'status-plugin', + targetDirectory, + targets: ['portable', 'codex', 'claude'], + templateRoot: templateRoot(path, 'mcp-server'), + })); + expect(error).toBeInstanceOf(UsageError); + expect(yield* fs.exists(targetDirectory)).toBe(false); + })); + + const scaffoldFrameworkOnly = Effect.fnUntraced(function* (tarball: Buffer | undefined) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-framework-only-' }); + const frameworkTarball = path.join(root, 'agent-bundle-0.0.0.tgz'); + const targetDirectory = path.join(root, 'project'); const frameworkSpec = `file:${frameworkTarball}`; - try { - if (tarball !== undefined) await writeFile(frameworkTarball, tarball); - await check(scaffold({ - frameworkSpec, - packageName: 'status-plugin', - pluginName: 'status-plugin', - targetDirectory, - targets: ['portable', 'codex', 'claude'], - templateRoot: join(templatesRoot, 'minimal'), - }), targetDirectory, frameworkSpec); - } finally { - await rm(root, { force: true, recursive: true }); - } - }; - - it('rejects a missing local framework tarball for a template with no runtime dependency', async () => { - await scaffoldFrameworkOnly(undefined, async (scaffolded, targetDirectory) => { - await expect(scaffolded).rejects.toThrow(UsageError); - await expect(readdir(targetDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); - }); - }); - - it('rejects a corrupt local framework tarball for a template with no runtime dependency', async () => { - await scaffoldFrameworkOnly(Buffer.from('not a gzip archive'), async (scaffolded, targetDirectory) => { - await expect(scaffolded).rejects.toThrow(UsageError); - await expect(readdir(targetDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); - }); + if (tarball !== undefined) yield* fs.writeFile(frameworkTarball, tarball); + const outcome: Exit.Exit = yield* Effect.exit(scaffold({ + frameworkSpec, + packageName: 'status-plugin', + pluginName: 'status-plugin', + targetDirectory, + targets: ['portable', 'codex', 'claude'], + templateRoot: templateRoot(path, 'minimal'), + })); + return { frameworkSpec, outcome, targetDirectory }; }); - it('rejects a misnamed local framework tarball for a template with no runtime dependency', async () => { - await scaffoldFrameworkOnly(packageTarball('@scope/not-agent-bundle'), async (scaffolded, targetDirectory) => { - await expect(scaffolded).rejects.toThrow(UsageError); - await expect(scaffolded).rejects.toThrow('expected agent-bundle'); - await expect(readdir(targetDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); - }); - }); + const expectUsageFailure = ( + result: { readonly outcome: Exit.Exit }, + message?: string, + ): void => { + expect(result.outcome._tag).toBe('Failure'); + if (result.outcome._tag !== 'Failure') return; + const error = Cause.squash(result.outcome.cause); + expect(error).toBeInstanceOf(UsageError); + if (message !== undefined) expect((error as Error).message).toContain(message); + }; - it('scaffolds a template with no runtime dependency from a valid local framework tarball', async () => { - await scaffoldFrameworkOnly(packageTarball('agent-bundle'), async (scaffolded, targetDirectory, frameworkSpec) => { - await expect(scaffolded).resolves.toContain('package.json'); - const manifest = JSON.parse(await readFile(join(targetDirectory, 'package.json'), 'utf8')) as { - readonly devDependencies: Record; - }; - expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); + it.effect('rejects a missing local framework tarball for a template with no runtime dependency', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const result = yield* scaffoldFrameworkOnly(undefined); + expectUsageFailure(result); + expect(yield* fs.exists(result.targetDirectory)).toBe(false); + })); + + it.effect('rejects a corrupt local framework tarball for a template with no runtime dependency', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const result = yield* scaffoldFrameworkOnly(Buffer.from('not a gzip archive')); + expectUsageFailure(result); + expect(yield* fs.exists(result.targetDirectory)).toBe(false); + })); + + it.effect('rejects a misnamed local framework tarball for a template with no runtime dependency', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const result = yield* scaffoldFrameworkOnly(packageTarball('@scope/not-agent-bundle')); + expectUsageFailure(result, 'expected agent-bundle'); + expect(yield* fs.exists(result.targetDirectory)).toBe(false); + })); + + it.effect('scaffolds a template with no runtime dependency from a valid local framework tarball', () => Effect.gen(function* () { + const path = yield* Path.Path; + const result = yield* scaffoldFrameworkOnly(packageTarball('agent-bundle')); + expect(result.outcome._tag).toBe('Success'); + if (result.outcome._tag !== 'Success') return; + expect(result.outcome.value).toContain('package.json'); + const manifest = yield* readJson<{ + readonly devDependencies: Record; + }>(path.join(result.targetDirectory, 'package.json')); + expect(manifest.devDependencies['agent-bundle']).toBe(result.frameworkSpec); + })); + + it.effect('rejects a local framework tarball with a tampered tar header', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const result = yield* scaffoldFrameworkOnly(tamperedPackageTarball('agent-bundle')); + expectUsageFailure(result, 'Invalid tar header checksum'); + expect(yield* fs.exists(result.targetDirectory)).toBe(false); + })); + + it.effect('resolves a relative framework tarball spec against the target directory', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-relative-' }); + yield* fs.writeFile(path.join(root, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')); + const targetDirectory = path.join(root, 'project'); + const frameworkSpec = 'file:../agent-bundle-0.0.0.tgz'; + const files = yield* scaffold({ + frameworkSpec, + packageName: 'status-plugin', + pluginName: 'status-plugin', + targetDirectory, + targets: ['portable'], + templateRoot: templateRoot(path, 'minimal'), }); - }); - - it('rejects a local framework tarball with a tampered tar header', async () => { - await scaffoldFrameworkOnly(tamperedPackageTarball('agent-bundle'), async (scaffolded, targetDirectory) => { - await expect(scaffolded).rejects.toThrow(UsageError); - await expect(scaffolded).rejects.toThrow('Invalid tar header checksum'); - await expect(readdir(targetDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(files).toContain('package.json'); + const manifest = yield* readJson<{ + readonly devDependencies: Record; + }>(path.join(targetDirectory, 'package.json')); + expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); + })); + + it.effect('resolves a relative framework/runtime tarball pair against the target directory', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-relative-pair-' }); + yield* fs.writeFile(path.join(root, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')); + yield* fs.writeFile(path.join(root, 'agent-bundle-runtime-0.0.0.tgz'), packageTarball('@agent-bundle/runtime')); + const targetDirectory = path.join(root, 'project'); + const files = yield* scaffold({ + frameworkSpec: 'file:../agent-bundle-0.0.0.tgz', + packageName: 'status-plugin', + pluginName: 'status-plugin', + targetDirectory, + targets: ['portable'], + templateRoot: templateRoot(path, 'mcp-server'), }); - }); - - it('resolves a relative framework tarball spec against the target directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-relative-')); - try { - await writeFile(join(root, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')); - const targetDirectory = join(root, 'project'); - const frameworkSpec = 'file:../agent-bundle-0.0.0.tgz'; - await expect(scaffold({ - frameworkSpec, - packageName: 'status-plugin', - pluginName: 'status-plugin', - targetDirectory, - targets: ['portable'], - templateRoot: join(templatesRoot, 'minimal'), - })).resolves.toContain('package.json'); - const manifest = JSON.parse(await readFile(join(targetDirectory, 'package.json'), 'utf8')) as { - readonly devDependencies: Record; - }; - expect(manifest.devDependencies['agent-bundle']).toBe(frameworkSpec); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it('resolves a relative framework/runtime tarball pair against the target directory', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-relative-pair-')); - try { - await Promise.all([ - writeFile(join(root, 'agent-bundle-0.0.0.tgz'), packageTarball('agent-bundle')), - writeFile(join(root, 'agent-bundle-runtime-0.0.0.tgz'), packageTarball('@agent-bundle/runtime')), - ]); - const targetDirectory = join(root, 'project'); - await expect(scaffold({ - frameworkSpec: 'file:../agent-bundle-0.0.0.tgz', - packageName: 'status-plugin', - pluginName: 'status-plugin', - targetDirectory, - targets: ['portable'], - templateRoot: join(templatesRoot, 'mcp-server'), - })).resolves.toContain('package.json'); - const manifest = JSON.parse(await readFile(join(targetDirectory, 'package.json'), 'utf8')) as { - readonly dependencies: Record; - }; - expect(manifest.dependencies['@agent-bundle/runtime']).toBe('file:../agent-bundle-runtime-0.0.0.tgz'); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); + expect(files).toContain('package.json'); + const manifest = yield* readJson<{ + readonly dependencies: Record; + }>(path.join(targetDirectory, 'package.json')); + expect(manifest.dependencies['@agent-bundle/runtime']).toBe('file:../agent-bundle-runtime-0.0.0.tgz'); + })); }); -describe('assertScaffoldTarget', () => { - it('accepts a missing directory, an empty directory, and a lone .git', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-target-')); - try { - await expect(assertScaffoldTarget(join(root, 'absent'), 'absent')).resolves.toBeUndefined(); - await expect(assertScaffoldTarget(root, 'empty')).resolves.toBeUndefined(); - await mkdir(join(root, '.git')); - await expect(assertScaffoldTarget(root, 'git-only')).resolves.toBeUndefined(); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); - - it('rejects a directory with real contents', async () => { - const root = await mkdtemp(join(tmpdir(), 'create-agent-bundle-target-')); - try { - await writeFile(join(root, 'existing.txt'), 'occupied'); - await expect(assertScaffoldTarget(root, 'occupied')).rejects.toThrow(UsageError); - } finally { - await rm(root, { force: true, recursive: true }); - } - }); +layer(NodeServices.layer, { excludeTestServices: true })('assertScaffoldTarget (real filesystem)', (it) => { + it.effect('accepts a missing directory, an empty directory, and a lone .git', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-target-' }); + yield* assertScaffoldTarget(path.join(root, 'absent'), 'absent'); + yield* assertScaffoldTarget(root, 'empty'); + yield* fs.makeDirectory(path.join(root, '.git')); + yield* assertScaffoldTarget(root, 'git-only'); + })); + + it.effect('rejects a directory with real contents', () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'create-agent-bundle-target-' }); + yield* fs.writeFileString(path.join(root, 'existing.txt'), 'occupied'); + const error = yield* Effect.flip(assertScaffoldTarget(root, 'occupied')); + expect(error).toBeInstanceOf(UsageError); + })); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bdfec8ad..8ee6e0e7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,9 @@ importers: '@clack/prompts': specifier: 1.7.0 version: 1.7.0 + '@effect/platform-node': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) '@rslib/core': specifier: 0.23.2 version: 0.23.2(typescript@7.0.2) @@ -352,6 +355,12 @@ importers: '@types/node': specifier: 26.4.0 version: 26.4.0 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 + effect-rstest: + specifier: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f + version: https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f(@rstest/core@0.11.10)(effect@4.0.0-rc.112) packages/rsc-runtime: dependencies: @@ -665,6 +674,19 @@ packages: resolution: {integrity: sha512-CfiSoaVQO8pZgaMZGw5VvMvRGybsJ2LaSDoLYaqiVGvo/YYPYVR2GzUKY0h1NtIweq/+SQ5XLrvtzPIttWQ0GQ==} hasBin: true + '@effect/platform-node-shared@4.0.0-rc.112': + resolution: {integrity: sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.112 + + '@effect/platform-node@4.0.0-rc.112': + resolution: {integrity: sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.112 + redis: '>=5.0.0 <7.0.0' + '@effect/tsgo-darwin-arm64@0.39.0': resolution: {integrity: sha512-ahkZmztsGVZm1mUeAr5ABQn1GzVd1p40gpt5SbDdaSbG0ApDQHEPNtPYvuhN7dVpNtpgQX/AVdkxE92o/wHeIg==} cpu: [arm64] @@ -938,6 +960,42 @@ packages: resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} engines: {node: '>=18'} + '@redis/bloom@6.2.1': + resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/client@6.2.1': + resolution: {integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.2.1': + resolution: {integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/search@6.2.1': + resolution: {integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/time-series@6.2.1': + resolution: {integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + '@rsbuild/core@2.1.13': resolution: {integrity: sha512-Z+6MzmjOio4+bFZQ24k+7ge/oNCOdXIunAssrswTNE8AIf6mcyXpJZevRXRiOEMZasRPA8VNyh+9JngQLg729Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1749,6 +1807,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -2555,6 +2617,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -2819,6 +2886,10 @@ packages: recma-stringify@1.0.0: resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redis@6.2.1: + resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} + engines: {node: '>= 20.0.0'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3139,6 +3210,10 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} + undici@8.10.1: + resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} + engines: {node: '>=22.19.0'} + unhead@2.1.17: resolution: {integrity: sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ==} @@ -3458,6 +3533,26 @@ snapshots: '@effect/language-service@0.87.2': {} + '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-rc.112 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + effect: 4.0.0-rc.112 + mime: 4.1.0 + redis: 6.2.1 + undici: 8.10.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@effect/tsgo-darwin-arm64@0.39.0': optional: true @@ -3782,6 +3877,26 @@ snapshots: dependencies: tinyexec: 1.3.0 + '@redis/bloom@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/client@6.2.1': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/search@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/time-series@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + '@rsbuild/core@2.1.13': dependencies: '@rspack/core': 2.1.10(@swc/helpers@0.5.23) @@ -4537,6 +4652,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -5654,6 +5771,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@4.1.0: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -5931,6 +6050,17 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + redis@6.2.1: + dependencies: + '@redis/bloom': 6.2.1(@redis/client@6.2.1) + '@redis/client': 6.2.1 + '@redis/json': 6.2.1(@redis/client@6.2.1) + '@redis/search': 6.2.1(@redis/client@6.2.1) + '@redis/time-series': 6.2.1(@redis/client@6.2.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -6307,6 +6437,8 @@ snapshots: undici@7.29.0: {} + undici@8.10.1: {} + unhead@2.1.17: dependencies: hookable: 6.1.1 From 8f8b35202a8dd7c37eebb363424879e8dbc57af5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 01:23:14 +0000 Subject: [PATCH 2/3] chore(changeset): reference #501 --- .changeset/effect-filesystem-scaffolder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/effect-filesystem-scaffolder.md b/.changeset/effect-filesystem-scaffolder.md index 859a1af59..eff873c11 100644 --- a/.changeset/effect-filesystem-scaffolder.md +++ b/.changeset/effect-filesystem-scaffolder.md @@ -2,4 +2,4 @@ "create-agent-bundle": patch --- -Run the `create-agent-bundle` scaffolder's filesystem work (template copy, `package.json`/config/README rewrites, local `file:` tarball inspection, target-directory check) on Effect's `FileSystem` and `Path` services, provided once by `@effect/platform-node`'s `NodeServices.layer` at the `create-agent-bundle` bin entry. Scaffolded files, messages, and exit codes are unchanged (`UsageError` still exits 2 and filesystem failures still report the Node error text); the self-contained `dist/index.js` bundle grows from 74 kB to 457 kB and the published tarball from 33 kB to 110 kB. (#PR) +Run the `create-agent-bundle` scaffolder's filesystem work (template copy, `package.json`/config/README rewrites, local `file:` tarball inspection, target-directory check) on Effect's `FileSystem` and `Path` services, provided once by `@effect/platform-node`'s `NodeServices.layer` at the `create-agent-bundle` bin entry. Scaffolded files, messages, and exit codes are unchanged (`UsageError` still exits 2 and filesystem failures still report the Node error text); the self-contained `dist/index.js` bundle grows from 74 kB to 457 kB and the published tarball from 33 kB to 110 kB. (#501) From 68ac2e10b6c2412a2ddb7caa7fe4c90d197da9ef Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:23:44 +0000 Subject: [PATCH 3/3] docs(effect): track the create-agent-bundle effect-rstest preview pin; user-facing changeset summary --- .changeset/effect-filesystem-scaffolder.md | 2 +- docs/effect-conventions.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/effect-filesystem-scaffolder.md b/.changeset/effect-filesystem-scaffolder.md index eff873c11..f019220ed 100644 --- a/.changeset/effect-filesystem-scaffolder.md +++ b/.changeset/effect-filesystem-scaffolder.md @@ -2,4 +2,4 @@ "create-agent-bundle": patch --- -Run the `create-agent-bundle` scaffolder's filesystem work (template copy, `package.json`/config/README rewrites, local `file:` tarball inspection, target-directory check) on Effect's `FileSystem` and `Path` services, provided once by `@effect/platform-node`'s `NodeServices.layer` at the `create-agent-bundle` bin entry. Scaffolded files, messages, and exit codes are unchanged (`UsageError` still exits 2 and filesystem failures still report the Node error text); the self-contained `dist/index.js` bundle grows from 74 kB to 457 kB and the published tarball from 33 kB to 110 kB. (#501) +Scaffold through Effect's `FileSystem` and `Path` services (`@effect/platform-node`): every filesystem failure during `create-agent-bundle` now surfaces once, at the CLI boundary, with the same Node error text and exit codes as before; the published tarball grows from 33 kB to 110 kB. (#501) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index fcf629fc1..bad3b1a35 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -431,6 +431,6 @@ soon as the trigger fires and retire the row. | Recorded | Pin (where) | Observed registry state | Trigger / action | | --- | --- | --- | --- | | 2026-09-03 | `@rslib/core` **`0.23.2`** — root, `packages/agent-bundle`, `packages/rsc-runtime`, `packages/create-agent-bundle` devDependencies. Stays on `0.23.x` until rslib 1.0 leaves rc. | `npm view @rslib/core dist-tags`: `latest` `0.23.2`, `rc` `1.0.0-rc.2`, `beta` `1.0.0-beta.3`, `canary` `0.20.0-canary-202603101`. | `latest` becomes `1.x`. Bump all four pins in one chore; re-run `pnpm build`, `lint:package`, `check:release`, and the Rslib-driven compile tests. | -| 2026-09-03 | `effect-rstest` **pkg.pr.new preview `e5f8d5f`** (`https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f`) — `packages/agent-bundle`, `packages/rsc-runtime` devDependencies. Needs a real release pin once published. | `npm view effect-rstest versions`: **E404 — not published to npm** (no versions, no dist-tags). | First npm publish of `effect-rstest`. Replace both preview URLs with the exact published version, refresh `pnpm-lock.yaml`, re-run `pnpm test:unit` (`it.effect` / `it.live` suites). | +| 2026-09-03 | `effect-rstest` **pkg.pr.new preview `e5f8d5f`** (`https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f`) — `packages/agent-bundle`, `packages/rsc-runtime`, `packages/create-agent-bundle` devDependencies (three pins). Needs a real release pin once published. | `npm view effect-rstest versions`: **E404 — not published to npm** (no versions, no dist-tags). | First npm publish of `effect-rstest`. Replace all three preview URLs with the exact published version, refresh `pnpm-lock.yaml`, re-run `pnpm test:unit` (`it.effect` / `it.live` suites). | | 2026-09-03 | `effect` **`4.0.0-rc.112`** (`packages/agent-bundle`, `packages/rsc-runtime`, `packages/workbench`, `packages/create-agent-bundle`), `@effect/atom-react` `4.0.0-rc.112` (`packages/workbench`), `@effect/platform-node` `4.0.0-rc.112` (`packages/create-agent-bundle`), `@effect/language-service` `0.87.2` and `@effect/tsgo` `0.39.0` (root). Auto re-pin in lockstep + `repos/effect` subtree + Workbench atom phase 4 unblock (stream-backed derived atoms) once the post-rc.112 disposal fix ships. | `npm view effect dist-tags`: `rc` **`4.0.0-rc.112`** (unchanged), `beta` `4.0.0-beta.107`, `latest` `3.22.1`. `@effect/atom-react`: `rc` `4.0.0-rc.112`. `@effect/language-service`: `latest` `0.87.2`. `@effect/tsgo`: `latest` `0.39.1` (patch ahead of the `0.39.0` pin; rides the lockstep chore). | `effect@rc` advances past `4.0.0-rc.112`. Run the re-pin chore steps 1–6 above, bumping `effect`, `@effect/atom-react`, `@effect/language-service`, and `@effect/tsgo` together, then lift the stream-backed derived-atom ban in the Workbench if the disposal fix is in the new RC. | | 2026-09-03 | Agent Plugins specification **`1.0.0`** — `packages/agent-bundle/src/adapters/schemas/portable/{plugin,mcp}.schema.json` + `PROVENANCE.json` (spec repo `agentplugins/agent-plugins-spec` @ `ff8ab5e392cc87bd88d87c060815a87490e51003`, 2026-08-19), portable `adapterRevision` `1.8.0`, pins in `tests/adapter-metadata.test.ts`. Spec watch for #426; not an npm pin, so re-verify with `curl`/`gh api`, not `npm view`. | Live `https://agent-plugins.org/schemas/1.0.0/{plugin,mcp}.schema.json` rehash to the pinned sha256 (1805 / 3408 bytes). Repo `main` HEAD unchanged at the pinned commit; **no tags, no GitHub releases**. `spec/1.1.0.md` is "Status: Working Draft" (started 2026-08-15, `a2afd7ec`); in-repo `schemas/1.1.0/*.schema.json` differ from 1.0.0 only in the `$id`/`const`/`description` version strings; `https://agent-plugins.org/schemas/1.1.0/*.schema.json` → 404. Observed latest published version: **1.0.0**. | `spec/1.1.0.md` (or later) flips to "Published" **and** `agent-plugins.org/schemas//` serves both schemas. Re-pin under `schemas/portable/` with a dated `PROVENANCE.json` (sha/bytes/date/commit), bump the portable `adapterRevision`, refresh the metadata pins, run `pnpm test:unit` (portable adapter + plugin-validation suites) and `pnpm test:host-install:build`, and add a capability row per additive field. |