diff --git a/AGENTS.md b/AGENTS.md index 93eb4541..8ee148cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,13 @@ When working with domain language for this application, use these sources first: Prefer these canonical terms in code reviews, issues, docs, commit messages, and implementation discussions. +## Catalog registry work + +- Production CLI commands and Recipe Builder sessions load the official JSON catalog through `CatalogLoader`. Keep one loaded `CatalogService` through selection, Blueprint, Plan, Apply, and Finalize. +- Repository authoring uses the local bundled definitions. Do not import that adapter into production CLI or worker entrypoints. +- Use controlled HTTP fixtures for routine tests. Qualify deployed registry headers, validators, CORS, and genuine 404 responses separately before releasing dependent clients. +- Keep community-source configuration in issue #276; the official source is an internal default, not a new project-file field. + ## Complexity Analysis Use the complexity report to identify refactoring targets before making changes. diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 1b399eed..9c98cde3 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -14,7 +14,9 @@ - Keep the initial CLI scaffold single-file until complexity appears. - Use `effect/unstable/cli` with `Command.make(...)` and `Command.run(...)`. -- Provide Bun runtime services with `BunServices.layer`. +- Provide the configured Node or Bun runtime services through `PlatformLayer`. +- Production commands use `CatalogProvider.official`; local authoring uses `CatalogProvider.authoring`. Keep registry warnings on stderr so JSON stdout remains parseable. +- E2E tests use `e2e/entrypoint.ts` for a controlled catalog response. Do not make routine tests depend on the deployed registry. ## Domain Terminology References diff --git a/apps/cli/scripts/registry-parity.ts b/apps/cli/scripts/registry-parity.ts new file mode 100644 index 00000000..c28a74df --- /dev/null +++ b/apps/cli/scripts/registry-parity.ts @@ -0,0 +1,135 @@ +// This test fixture runs the real CLI in a disposable repository. +// @effect-diagnostics nodeBuiltinImport:off + +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { NodeServices } from "@effect/platform-node"; +import { CatalogService } from "@repo/catalog"; +import { exportOfficialCatalog } from "@repo/catalog/authoring"; +import { + BlueprintService, + CatalogCache, + CatalogLoader, + RecipeService, +} from "@repo/scaffold"; +import { RecipePreviewInput } from "@repo/scaffold/recipe-preview"; +import { Effect, Layer, Schema } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +const request = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + input: RecipePreviewInput, + command: Schema.String, + addTarget: Schema.optional(Schema.String), + }), + ), +)(await Bun.stdin.text()); + +const sourceUrl = "https://fixture.example.test/registry/v1/catalog.json"; +const document = await Effect.runPromise(exportOfficialCatalog()); +const client = HttpClient.make((httpRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + httpRequest, + new Response(document, { + headers: { "content-type": "application/json" }, + }), + ), + ), +); +const loaderLayer = CatalogLoader.layer.pipe( + Layer.provideMerge(CatalogCache.memory), + Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), + Layer.provideMerge(NodeServices.layer), +); + +const blueprint = await Effect.runPromise( + Effect.gen(function* () { + const { catalog } = yield* (yield* CatalogLoader).load({ + sourceUrl, + allowFinalizeScripts: true, + }); + return yield* Effect.gen(function* () { + const selection = yield* (yield* RecipeService).resolve( + request.input.recipe, + { + config: request.input.config, + providerStrategy: { _tag: "fail-on-ambiguous" }, + }, + ); + return yield* (yield* BlueprintService).resolve( + selection, + request.input.config, + ); + }).pipe( + Effect.provide( + Layer.merge(RecipeService.layer, BlueprintService.layer).pipe( + Layer.provideMerge(Layer.succeed(CatalogService, catalog)), + ), + ), + ); + }).pipe(Effect.provide(loaderLayer)), +); + +const temporaryRoot = mkdtempSync(join(tmpdir(), "registry-cli-parity-")); +try { + const stubDirectory = join(temporaryRoot, "stubs"); + mkdirSync(stubDirectory); + // Exercise CLI file application without running generated-project installs. + for (const binary of ["bun", "npm", "npx", "node", "deno", "git"]) { + const stub = join(stubDirectory, binary); + writeFileSync(stub, "#!/bin/sh\nexit 0\n"); + chmodSync(stub, 0o755); + } + + const words = request.command.split(" "); + const createAt = words.indexOf("create"); + if (createAt < 0 || words.some((word) => word.includes("'"))) + throw new Error( + "The controlled recipe must provide an unquoted create command.", + ); + const env = { + ...Bun.env, + PATH: `${stubDirectory}:${Bun.env["PATH"] ?? ""}`, + }; + const run = (args: ReadonlyArray) => { + const result = Bun.spawnSync( + [process.execPath, "run", "e2e/entrypoint.ts", ...args], + { cwd: join(import.meta.dir, ".."), env, timeout: 60_000 }, + ); + if (result.exitCode !== 0) + throw new Error( + `CLI ${args[0]} failed: ${result.stderr.toString()}\n${result.stdout.toString()}`, + ); + }; + run([...words.slice(createAt), "--yes", "--root", temporaryRoot]); + const projectRoot = join(temporaryRoot, request.input.config.name); + if (request.addTarget !== undefined) + run(["add", "--yes", "--root", projectRoot, "--target", request.addTarget]); + + const filesIn = (directory: string): ReadonlyArray => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? filesIn(path) : [path]; + }); + const files = filesIn(projectRoot) + .map((path) => ({ + path: relative(projectRoot, path), + status: "created" as const, + contents: readFileSync(path, "utf8"), + })) + .sort((left, right) => left.path.localeCompare(right.path)); + process.stdout.write(JSON.stringify({ blueprint, files })); +} finally { + rmSync(temporaryRoot, { recursive: true, force: true }); +} diff --git a/apps/cli/src/docs/CliReference.test.ts b/apps/cli/src/docs/CliReference.test.ts index df2f8c7f..efba5ec5 100644 --- a/apps/cli/src/docs/CliReference.test.ts +++ b/apps/cli/src/docs/CliReference.test.ts @@ -80,6 +80,9 @@ describe("CLI reference", () => { expect(pages.find((page) => page.slug === "index")?.content).toContain( "## Global options", ); + expect(pages.find((page) => page.slug === "index")?.content).toContain( + "/catalog-registry", + ); expect(pages.find((page) => page.slug === "catalog")?.content).toContain( "## stack-effect catalog workspace validate", ); diff --git a/apps/cli/src/docs/CliReferenceMarkdown.ts b/apps/cli/src/docs/CliReferenceMarkdown.ts index 699de939..1e5f90e2 100644 --- a/apps/cli/src/docs/CliReferenceMarkdown.ts +++ b/apps/cli/src/docs/CliReferenceMarkdown.ts @@ -201,6 +201,7 @@ const renderIndexPage = ( generatedNotice, "# CLI reference", `Commands, arguments, options, and examples for ${inlineCode(`${reference.name} v${reference.version}`)}. These pages are generated from the current Effect CLI command tree.`, + "Catalog-dependent commands load current definitions from the official registry. If the registry is temporarily unavailable, a validated user cache may be used with a warning on stderr. See [Catalog updates and offline use](/catalog-registry).", "## Commands", markdownTable( ["Command", "Description"], diff --git a/apps/cli/src/service/CatalogProvider.test.ts b/apps/cli/src/service/CatalogProvider.test.ts index e2bae964..7e7824b0 100644 --- a/apps/cli/src/service/CatalogProvider.test.ts +++ b/apps/cli/src/service/CatalogProvider.test.ts @@ -1,5 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { assert, it } from "@effect/vitest"; +import { exportOfficialCatalog } from "@repo/catalog/authoring"; import { CatalogDocument } from "@repo/domain/Catalog"; import { CatalogCache, CatalogLoader } from "@repo/scaffold"; import { @@ -79,6 +80,88 @@ it.effect("loads once for a graph command through controlled HTTP", () => { }).pipe(Effect.provide(layerFor(client))); }); +it.effect("uses changed file content on the next generation command", () => + Effect.gen(function* () { + const initial = yield* exportOfficialCatalog(); + const decoded = yield* Schema.decodeEffect( + Schema.fromJsonString(CatalogDocument), + )(initial); + const revised = yield* Schema.encodeEffect( + Schema.fromJsonString(CatalogDocument), + )({ + ...decoded, + targets: decoded.targets.map((target) => ({ + ...target, + contributions: + target.kind === "client-react" + ? target.contributions.map((contribution) => + contribution._tag === "file" && + contribution.path === "{{targetPath}}/src/main.tsx" + ? { + ...contribution, + contents: `${contribution.contents}\n// Registry revision marker.\n`, + } + : contribution, + ) + : target.contributions, + })), + }); + assert.notStrictEqual(revised, initial); + let requests = 0; + const stdout: Array = []; + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-content-update-", + }); + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(requests === 1 ? initial : revised, { + headers: { "content-type": "application/json" }, + }), + ); + }), + ); + const capturedConsole: Console.Console = Object.assign( + Object.create(globalThis.console), + { + log: (value: string) => { + stdout.push(value); + }, + }, + ); + yield* Effect.gen(function* () { + const args = [ + "create", + "demo", + "--target", + "client-react/web:client-react-http-api", + "--yes", + "--no-git", + "--dry-run", + "--show-files", + "--root", + directory, + ]; + yield* runCommand(args); + yield* runCommand(args); + }).pipe( + Effect.provide( + Layer.merge( + layerFor(client), + Layer.succeed(Console.Console, capturedConsole), + ), + ), + ); + assert.strictEqual(requests, 2); + assert.notDeepEqual(stdout[0], stdout[1]); + assert.notInclude(stdout[0] ?? "", "Registry revision marker"); + assert.include(stdout[1] ?? "", "Registry revision marker"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("stops init before writing when the registry is unavailable", () => { let requests = 0; const client = HttpClient.make((request) => diff --git a/apps/docs/app/components/recipe-builder/use-recipe-builder-worker.ts b/apps/docs/app/components/recipe-builder/use-recipe-builder-worker.ts index 19ccda4b..e0341705 100644 --- a/apps/docs/app/components/recipe-builder/use-recipe-builder-worker.ts +++ b/apps/docs/app/components/recipe-builder/use-recipe-builder-worker.ts @@ -25,6 +25,10 @@ import { toRecipePreviewInput, } from "./form"; +let nextCatalogSessionId = 0; + +const newCatalogSessionId = () => ++nextCatalogSessionId; + const reconcileTargetsWithCatalog = ( targets: ReadonlyArray, catalog: typeof RecipeBuilderCatalog.Type, @@ -70,7 +74,7 @@ export function useRecipeBuilderWorker( const [catalogRequestResult, requestCatalog] = useAtom(catalogAtom); const [previewRequestResult, requestPreview] = useAtom(previewAtom); const [compatibilityNotice, setCompatibilityNotice] = useState(); - const [sessionId, setSessionId] = useState(1); + const [sessionId, setSessionId] = useState(newCatalogSessionId); const [catalogSnapshot, setCatalogSnapshot] = useState< | { readonly request: CatalogAtomRequest; @@ -108,7 +112,7 @@ export function useRecipeBuilderWorker( setCatalogSnapshot(undefined); requestPreview(Atom.Interrupt); requestCatalog(Atom.Interrupt); - setSessionId((current) => current + 1); + setSessionId(newCatalogSessionId()); }, [enabled, requestCatalog, requestPreview]); useEffect(() => { diff --git a/apps/docs/app/content/catalog-registry.mdx b/apps/docs/app/content/catalog-registry.mdx new file mode 100644 index 00000000..fe77bba0 --- /dev/null +++ b/apps/docs/app/content/catalog-registry.mdx @@ -0,0 +1,21 @@ +# Catalog updates and offline use + +Stack Effect loads the current catalog before a command or Recipe Builder session generates a project. The catalog defines available targets, modules, dependencies, and file contributions. It is served as JSON from the [official registry](/registry/v1/catalog.json). + +## What changes between runs + +Each new catalog-dependent CLI command and each new Builder session checks for current definitions. A running command or session keeps the catalog it already loaded, so its choices, Blueprint, Plan, and generated files agree with each other. A later run can use an updated compatible catalog and produce different files from the same choices. + +`stack-effect --help` and `stack-effect --version` work without a registry request. Repository authoring commands use local catalog definitions. + +## When the registry is unavailable + +If a network failure or temporary server error prevents a current load, Stack Effect can use a previously validated, compatible catalog from the user cache. The CLI prints the source and last validation time to stderr. Recipe Builder keeps a visible cached-catalog notice. Machine-readable CLI stdout remains usable. + +With no usable cache, the operation stops before generating files. An invalid document, unsupported catalog capability, or permanent HTTP error also stops the operation, even if an older cache exists. Retry in Recipe Builder starts a new session and checks the registry again. + +## Project configuration + +New `stack.effect.json` files include a [`$schema` editor link](/schemas/v1/stack.effect.schema.json). Existing files without it remain valid. The link helps editors describe the file; the installed CLI validates configuration with its own schema. Project files contain no downloaded catalog, catalog version pin, or lockfile. + +Shared Recipe Builder links contain choices rather than catalog data. Reopening a link checks current definitions, may use a disclosed cached catalog during an outage, and reports choices that can no longer be resolved. diff --git a/apps/docs/app/content/reference/cli/index.mdx b/apps/docs/app/content/reference/cli/index.mdx index 489364c3..012bcd56 100644 --- a/apps/docs/app/content/reference/cli/index.mdx +++ b/apps/docs/app/content/reference/cli/index.mdx @@ -4,6 +4,8 @@ Commands, arguments, options, and examples for `stack-effect v0.15.0`. These pages are generated from the current Effect CLI command tree. +Catalog-dependent commands load current definitions from the official registry. If the registry is temporarily unavailable, a validated user cache may be used with a warning on stderr. See [Catalog updates and offline use](/catalog-registry). + ## Commands | Command | Description | diff --git a/apps/docs/app/nav.config.ts b/apps/docs/app/nav.config.ts index 91e95741..a3b2cbb2 100644 --- a/apps/docs/app/nav.config.ts +++ b/apps/docs/app/nav.config.ts @@ -27,6 +27,7 @@ export const navigation: NavSection[] = [ items: [ { label: "Getting Started", href: "/getting-started" }, { label: "How it works", href: "/how-it-works" }, + { label: "Catalog updates", href: "/catalog-registry" }, { label: "Use with coding agents", href: "/use-with-coding-agents", diff --git a/apps/docs/app/routes.ts b/apps/docs/app/routes.ts index f9222eb8..35f1006e 100644 --- a/apps/docs/app/routes.ts +++ b/apps/docs/app/routes.ts @@ -10,6 +10,7 @@ export default [ route("builder", "routes/builder.tsx"), route("getting-started", "content/getting-started.mdx"), route("how-it-works", "content/how-it-works.mdx"), + route("catalog-registry", "content/catalog-registry.mdx"), route("use-with-coding-agents", "routes/use-with-coding-agents.tsx"), ...prefix("reference/cli", [ index("content/reference/cli/index.mdx"), diff --git a/apps/docs/test/fixtures/registry-parity.ts b/apps/docs/test/fixtures/registry-parity.ts new file mode 100644 index 00000000..282bea38 --- /dev/null +++ b/apps/docs/test/fixtures/registry-parity.ts @@ -0,0 +1,33 @@ +import type { RecipePreviewInput } from "@repo/scaffold/recipe-preview"; +import { toRecipePreviewInput } from "../../app/components/recipe-builder/form"; +import { fullStackRecipeFixture } from "../components/recipe-builder/recipe-fixtures"; + +const fullStack = { ...fullStackRecipeFixture, gitEnabled: false }; + +export const registryParityCases = { + bun: toRecipePreviewInput(fullStack), + node: toRecipePreviewInput({ + ...fullStack, + config: { + ...fullStack.config, + runtime: { _tag: "node", packageManager: "npm" }, + }, + }), + deno: toRecipePreviewInput({ + ...fullStack, + config: { + name: "deno-preview", + runtime: { _tag: "deno" }, + typescript: "6", + monorepo: undefined, + lint: undefined, + format: undefined, + test: "vitest", + }, + database: "sqlite", + targets: [], + supportSelections: [], + }), +} satisfies Record; + +export type RegistryParityCase = keyof typeof registryParityCases; diff --git a/apps/docs/test/workers/recipe-builder.browser.test.ts b/apps/docs/test/workers/recipe-builder.browser.test.ts index 11e15e2b..825ec289 100644 --- a/apps/docs/test/workers/recipe-builder.browser.test.ts +++ b/apps/docs/test/workers/recipe-builder.browser.test.ts @@ -1,8 +1,17 @@ import { assert, it } from "@effect/vitest"; +import { Blueprint } from "@repo/domain/Blueprint"; import { TargetIdentity, TargetKey, TargetKind } from "@repo/domain/Catalog"; import { STACK_CONFIG_SCHEMA_URL } from "@repo/domain/Scaffold"; -import { Cause, Effect, Exit, Option } from "effect"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { + ApplyPreviewFileSchema, + RecipePreviewInput, +} from "@repo/scaffold/recipe-preview"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, +} from "effect/unstable/http"; import { AtomRegistry } from "effect/unstable/reactivity"; import { catalogAtom, @@ -11,6 +20,12 @@ import { } from "../../app/atom/recipe-builder-atom"; import { toRecipePreviewInput } from "../../app/components/recipe-builder/form"; import { fullStackRecipeFixture } from "../components/recipe-builder/recipe-fixtures"; +import { registryParityCases } from "../fixtures/registry-parity"; + +const CliParityResult = Schema.Struct({ + blueprint: Blueprint, + files: Schema.Array(ApplyPreviewFileSchema), +}); const runAtom = ( atom: import("effect/unstable/reactivity").Atom.AtomResultFn, @@ -29,7 +44,7 @@ const runAtom = ( const catalogSource = () => new URL("/registry/v1/catalog.json", window.location.origin).toString(); -const setRegistryMode = (mode: "current" | "outage" | "invalid") => +const setRegistryMode = (mode: "current" | "outage" | "invalid" | "revised") => Effect.gen(function* () { const client = yield* HttpClient.HttpClient; yield* client.get( @@ -37,6 +52,33 @@ const setRegistryMode = (mode: "current" | "outage" | "invalid") => ); }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie); +const cliParity = ( + input: RecipePreviewInput, + command: string, + addTarget?: string, +) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const body = yield* Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + input: RecipePreviewInput, + command: Schema.String, + addTarget: Schema.optional(Schema.String), + }), + ), + )({ input, command, ...(addTarget === undefined ? {} : { addTarget }) }); + const response = yield* client.execute( + HttpClientRequest.post( + `${window.location.origin}/registry-test/cli-parity`, + ).pipe(HttpClientRequest.bodyText(body, "application/json")), + ); + assert.strictEqual(response.status, 200); + return yield* Schema.decodeEffect(Schema.fromJsonString(CliParityResult))( + yield* response.text, + ); + }).pipe(Effect.provide(FetchHttpClient.layer)); + const makeSession = Effect.gen(function* () { const registry = AtomRegistry.make(); const stopCatalog = registry.mount(catalogAtom); @@ -68,6 +110,56 @@ const makeSession = Effect.gen(function* () { }; }); +for (const [name, input] of Object.entries(registryParityCases)) { + it.live(`matches CLI Blueprint and files for ${name}`, () => + Effect.gen(function* () { + yield* setRegistryMode("current"); + const session = yield* makeSession; + yield* session.catalog(); + const browser = yield* session.preview({ + sessionId: 1, + targetIdentityKey: name, + input, + }); + const cli = yield* cliParity(input, browser.preview.command); + assert.deepEqual(browser.preview.blueprint, cli.blueprint); + assert.deepEqual(browser.preview.files, cli.files); + }).pipe(Effect.scoped), + ); +} + +it.live("matches browser files after CLI create and incremental add", () => + Effect.gen(function* () { + yield* setRegistryMode("current"); + const session = yield* makeSession; + yield* session.catalog(10); + const base = yield* session.preview({ + sessionId: 10, + targetIdentityKey: "base", + input: registryParityCases.bun, + }); + const input = toRecipePreviewInput({ + ...fullStackRecipeFixture, + gitEnabled: false, + targets: [ + ...fullStackRecipeFixture.targets, + { id: "utility", kind: "package", name: "util", modules: [] }, + ], + }); + const browser = yield* session.preview({ + sessionId: 10, + targetIdentityKey: "with-util", + input, + }); + const cli = yield* cliParity(input, base.preview.command, "package/util"); + assert.deepEqual(browser.preview.blueprint, cli.blueprint); + const addedFiles = (files: typeof cli.files) => + files.filter((file) => file.path.startsWith("packages/util/")); + assert.isAbove(addedFiles(cli.files).length, 0); + assert.deepEqual(addedFiles(browser.preview.files), addedFiles(cli.files)); + }).pipe(Effect.scoped), +); + it.live("uses a persisted catalog after reload during an outage", () => Effect.gen(function* () { yield* setRegistryMode("current"); @@ -130,6 +222,44 @@ it.live("rejects previews from a replaced catalog session", () => }).pipe(Effect.scoped), ); +it.live( + "keeps a session stable while a later session sees compatible changes", + () => + Effect.gen(function* () { + yield* setRegistryMode("current"); + yield* Effect.addFinalizer(() => setRegistryMode("current")); + const session = yield* makeSession; + const first = yield* session.catalog(50); + const input = registryParityCases.bun; + const previewFor = (sessionId: number) => + session.preview({ sessionId, targetIdentityKey: "revision", input }); + const firstPreview = yield* previewFor(50); + yield* setRegistryMode("revised"); + const stillFirst = yield* session.catalog(50); + const stillFirstPreview = yield* previewFor(50); + const later = yield* session.catalog(51); + const laterPreview = yield* previewFor(51); + const mainFile = (files: typeof firstPreview.preview.files) => + files.find((file) => file.path === "apps/client-react-web/src/main.tsx") + ?.contents ?? ""; + assert.deepEqual(stillFirst.catalog.targets, first.catalog.targets); + assert.notDeepEqual(later.catalog.targets, first.catalog.targets); + assert.include(later.catalog.targets[0]?.title ?? "", "revised"); + assert.strictEqual( + mainFile(stillFirstPreview.preview.files), + mainFile(firstPreview.preview.files), + ); + assert.notInclude( + mainFile(firstPreview.preview.files), + "Registry revision marker", + ); + assert.include( + mainFile(laterPreview.preview.files), + "Registry revision marker", + ); + }).pipe(Effect.scoped), +); + it.live( "should return a generated repository when a recipe crosses the browser Worker boundary", () => diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts index c113cc1a..4bebe833 100644 --- a/apps/docs/vitest.config.ts +++ b/apps/docs/vitest.config.ts @@ -17,8 +17,62 @@ export default defineConfig({ ], { cwd: process.cwd() }, ).toString(); - let mode: "current" | "outage" | "invalid" = "current"; + const revisedCatalog = JSON.parse(document) as { + targets: Array<{ + kind: string; + title: string; + contributions: Array<{ + _tag: string; + path?: string; + contents?: string; + }>; + }>; + }; + revisedCatalog.targets = revisedCatalog.targets.map((target) => ({ + ...target, + title: `${target.title} revised`, + contributions: + target.kind === "client-react" + ? target.contributions.map((contribution) => + contribution._tag === "file" && + contribution.path === "{{targetPath}}/src/main.tsx" + ? { + ...contribution, + contents: `${contribution.contents}\n// Registry revision marker.\n`, + } + : contribution, + ) + : target.contributions, + })); + const revisedDocument = JSON.stringify(revisedCatalog); + let mode: "current" | "outage" | "invalid" | "revised" = "current"; server.middlewares.use((request, response, next) => { + if ( + request.url === "/registry-test/cli-parity" && + request.method === "POST" + ) { + const chunks: Array = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + try { + const result = execFileSync( + "bun", + ["run", "../cli/scripts/registry-parity.ts"], + { + cwd: process.cwd(), + input: Buffer.concat(chunks), + maxBuffer: 32 * 1024 * 1024, + }, + ); + response.setHeader("content-type", "application/json"); + response.end(result); + } catch { + response.statusCode = 500; + response.end("CLI parity fixture failed"); + } + }); + return; + } if (request.url?.startsWith("/registry-test/mode")) { const requested = new URL( request.url, @@ -27,7 +81,8 @@ export default defineConfig({ if ( requested === "current" || requested === "outage" || - requested === "invalid" + requested === "invalid" || + requested === "revised" ) mode = requested; response.end(mode); @@ -40,7 +95,13 @@ export default defineConfig({ return; } response.setHeader("content-type", "application/json"); - response.end(mode === "invalid" ? '{"formatVersion":2}' : document); + response.end( + mode === "invalid" + ? '{"formatVersion":2}' + : mode === "revised" + ? revisedDocument + : document, + ); return; } next();