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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
135 changes: 135 additions & 0 deletions apps/cli/scripts/registry-parity.ts
Original file line number Diff line number Diff line change
@@ -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<string>) => {
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<string> =>
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 });
}
3 changes: 3 additions & 0 deletions apps/cli/src/docs/CliReference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/docs/CliReferenceMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
83 changes: 83 additions & 0 deletions apps/cli/src/service/CatalogProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string> = [];
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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
toRecipePreviewInput,
} from "./form";

let nextCatalogSessionId = 0;

const newCatalogSessionId = () => ++nextCatalogSessionId;

const reconcileTargetsWithCatalog = (
targets: ReadonlyArray<TargetInstance>,
catalog: typeof RecipeBuilderCatalog.Type,
Expand Down Expand Up @@ -70,7 +74,7 @@ export function useRecipeBuilderWorker(
const [catalogRequestResult, requestCatalog] = useAtom(catalogAtom);
const [previewRequestResult, requestPreview] = useAtom(previewAtom);
const [compatibilityNotice, setCompatibilityNotice] = useState<string>();
const [sessionId, setSessionId] = useState(1);
const [sessionId, setSessionId] = useState(newCatalogSessionId);
const [catalogSnapshot, setCatalogSnapshot] = useState<
| {
readonly request: CatalogAtomRequest;
Expand Down Expand Up @@ -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(() => {
Expand Down
21 changes: 21 additions & 0 deletions apps/docs/app/content/catalog-registry.mdx
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/docs/app/content/reference/cli/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/nav.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
33 changes: 33 additions & 0 deletions apps/docs/test/fixtures/registry-parity.ts
Original file line number Diff line number Diff line change
@@ -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<string, RecipePreviewInput>;

export type RegistryParityCase = keyof typeof registryParityCases;
Loading
Loading