diff --git a/.okf/architecture/catalog-distribution.md b/.okf/architecture/catalog-distribution.md index 93f7ffd8..6a5abbcb 100644 --- a/.okf/architecture/catalog-distribution.md +++ b/.okf/architecture/catalog-distribution.md @@ -25,7 +25,7 @@ generated: { by: codex, at: "2026-09-27T21:00:00+02:00" } # Catalog distribution and freshness -Status: Accepted direction, not yet implemented. Current code still bundles catalog definitions. GitHub issues own implementation scope, sequencing, and acceptance evidence; this document owns the reusable decisions and their consequences. +Status: Accepted direction. Local work for #249 now composes and validates explicitly supplied definitions, then injects one catalog through scaffold services. The CLI and Recipe Builder still use a temporary bundled adapter. HTTP loading, caching, publication, and deployed behavior remain unimplemented. GitHub issues own implementation scope, sequencing, and acceptance evidence; this document owns the reusable decisions and their consequences. ## Separate content delivery from engine releases diff --git a/AGENTS.md b/AGENTS.md index c23c01c7..93eb4541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ | `bun run type-check` | Run TypeScript checks across workspaces | | `bun run test` | Run workspace tests through Turbo + Vitest | | `bun run test --filter=` | Run tests for a specific workspace | +| `bun run catalog:reset-workspace` | Build the local authoring catalog workspace | +| `bun run catalog:diff-workspace` | Compare generated authoring files | +| `bun run catalog:validate-workspace` | Validate generated authoring files | ## Tech Stack @@ -65,6 +68,8 @@ Selection ──> Blueprint ┬─> Plan ──> Apply ──> ApplyResult The CLI (`apps/cli`) orchestrates this flow using shared packages in `packages/*`. Users run `stack-effect init` to create a project and `stack-effect add` to incrementally add targets (client, server, cli, package) with modules (features). +The root `catalog:*` scripts use `apps/cli/src/authoring.ts` and local definitions. Published `stack-effect catalog workspace` command names remain available through the CLI entrypoint. + ## Core Priorities 1. Deterministic behavior: identical inputs should produce identical blueprint and plan outputs. diff --git a/apps/cli/e2e/matrix.test.ts b/apps/cli/e2e/matrix.test.ts index 021f6a7c..27a30142 100644 --- a/apps/cli/e2e/matrix.test.ts +++ b/apps/cli/e2e/matrix.test.ts @@ -1,5 +1,6 @@ import { describe, layer } from "@effect/vitest"; import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { ModuleId, TargetKind } from "@repo/domain/Catalog"; import { Effect } from "effect"; import { CLI } from "./harness"; @@ -71,13 +72,13 @@ const defaultTargetNames = new Map([ const catalogModules = Effect.runSync( Effect.map(CatalogService, (catalog) => catalog.getModules()).pipe( - Effect.provide(CatalogService.layer), + Effect.provide(BundledCatalogLayer), ), ); const getCapabilityProviders = Effect.runSync( Effect.map(CatalogService, (catalog) => catalog.getCapabilityProviders.bind(catalog), - ).pipe(Effect.provide(CatalogService.layer)), + ).pipe(Effect.provide(BundledCatalogLayer)), ); const catalogModulesById = new Map( catalogModules.map((module) => [module.id, module]), @@ -194,7 +195,7 @@ const buildMatrix = Effect.gen(function* () { }); const matrix = Effect.runSync( - buildMatrix.pipe(Effect.provide(CatalogService.layer)), + buildMatrix.pipe(Effect.provide(BundledCatalogLayer)), ); const individualModuleEntries = matrix.filter( @@ -305,7 +306,7 @@ const buildChildrenMatrix = Effect.gen(function* () { }); const childrenMatrix = Effect.runSync( - buildChildrenMatrix.pipe(Effect.provide(CatalogService.layer)), + buildChildrenMatrix.pipe(Effect.provide(BundledCatalogLayer)), ); const buildCapabilityMatrix = Effect.gen(function* () { @@ -339,7 +340,7 @@ const buildCapabilityMatrix = Effect.gen(function* () { }); const capabilityMatrix = Effect.runSync( - buildCapabilityMatrix.pipe(Effect.provide(CatalogService.layer)), + buildCapabilityMatrix.pipe(Effect.provide(BundledCatalogLayer)), ); const buildFullStackMatrix = Effect.gen(function* () { @@ -385,7 +386,7 @@ const buildFullStackMatrix = Effect.gen(function* () { }); const fullStackMatrix = Effect.runSync( - buildFullStackMatrix.pipe(Effect.provide(CatalogService.layer)), + buildFullStackMatrix.pipe(Effect.provide(BundledCatalogLayer)), ); const buildMaximalClientMatrix = Effect.gen(function* () { @@ -412,7 +413,7 @@ const buildMaximalClientMatrix = Effect.gen(function* () { }); const maximalClientMatrix = Effect.runSync( - buildMaximalClientMatrix.pipe(Effect.provide(CatalogService.layer)), + buildMaximalClientMatrix.pipe(Effect.provide(BundledCatalogLayer)), ); const expectInstallPasses = (project: { diff --git a/apps/cli/src/authoring.ts b/apps/cli/src/authoring.ts new file mode 100644 index 00000000..d804fe9d --- /dev/null +++ b/apps/cli/src/authoring.ts @@ -0,0 +1,15 @@ +import { NodeRuntime } from "@effect/platform-node"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; +import { Effect, Layer } from "effect"; +import { cliProgram } from "./cliProgram"; +import { PlatformLayer } from "./runtime"; +import { StackEffectServicesLayer } from "./services"; + +const AuthoringLayer = StackEffectServicesLayer.pipe( + Layer.provideMerge(BundledCatalogLayer), + Layer.provideMerge(PlatformLayer), +); + +NodeRuntime.runMain(cliProgram.pipe(Effect.provide(AuthoringLayer)), { + disableErrorReporting: true, +}); diff --git a/apps/cli/src/cliProgram.ts b/apps/cli/src/cliProgram.ts new file mode 100644 index 00000000..21894551 --- /dev/null +++ b/apps/cli/src/cliProgram.ts @@ -0,0 +1,43 @@ +import { Cause, Console, Effect } from "effect"; +import { Ansi, Box } from "effect-boxes"; +import { Command } from "effect/unstable/cli"; +import pkg from "../package.json"; +import { stackEffectCommand } from "./command"; + +export const cliProgram = stackEffectCommand.pipe( + Command.run({ version: pkg.version }), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + const message = Box.vsep( + [ + Box.text("Interrupted.").pipe( + Box.annotate(Ansi.combine(Ansi.bold, Ansi.yellow)), + ), + Box.text("Goodbye! Come back when you're ready to stack."), + ], + 1, + Box.center1, + ).pipe( + Box.pad(0, 1), + Box.border("rounded", { annotation: Ansi.yellow }), + Box.moveDown(1), + ); + return Console.log(`\n${Box.renderPrettySync(message)}`); + } + + const message = Cause.prettyErrors(cause) + .map((error) => error.message) + .filter((message) => message.length > 0) + .join("\n"); + + return Console.error( + message.length > 0 ? message : Cause.pretty(cause), + ).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 1; + }), + ), + ); + }), +); diff --git a/apps/cli/src/commands/add.test.ts b/apps/cli/src/commands/add.test.ts index fd04999b..dc3cbdbb 100644 --- a/apps/cli/src/commands/add.test.ts +++ b/apps/cli/src/commands/add.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { ModuleId, TargetKind } from "@repo/domain/Catalog"; import { Effect } from "effect"; import { type CollectedTarget, resolveCapabilities } from "./add"; @@ -44,6 +44,6 @@ describe("add capability resolution", () => { modules: ["package-db-sqlite"], confirmed: false, }); - }).pipe(Effect.provide(CatalogService.layer)), + }).pipe(Effect.provide(BundledCatalogLayer)), ); }); diff --git a/apps/cli/src/docs/CliReference.test.ts b/apps/cli/src/docs/CliReference.test.ts index de7533bb..408b9362 100644 --- a/apps/cli/src/docs/CliReference.test.ts +++ b/apps/cli/src/docs/CliReference.test.ts @@ -1,5 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Effect, Layer } from "effect"; import pkg from "../../package.json"; import { stackEffectCommand } from "../command"; @@ -8,6 +9,7 @@ import { collectCliReference, validateCliReference } from "./CliReference"; import { renderCliReferencePages } from "./CliReferenceMarkdown"; const TestLayer = StackEffectServicesLayer.pipe( + Layer.provideMerge(BundledCatalogLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 56d5a3f1..c4d7fcac 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,48 +1,8 @@ import { NodeRuntime } from "@effect/platform-node"; -import { Cause, Console, Effect } from "effect"; -import { Ansi, Box } from "effect-boxes"; -import { Command } from "effect/unstable/cli"; -import pkg from "../package.json"; -import { stackEffectCommand } from "./command"; +import { Effect } from "effect"; +import { cliProgram } from "./cliProgram"; import { StackEffectLayer } from "./runtime"; -const program = stackEffectCommand.pipe( - Command.run({ version: pkg.version }), - Effect.provide(StackEffectLayer), - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - const message = Box.vsep( - [ - Box.text("Interrupted.").pipe( - Box.annotate(Ansi.combine(Ansi.bold, Ansi.yellow)), - ), - Box.text("Goodbye! Come back when you're ready to stack."), - ], - 1, - Box.center1, - ).pipe( - Box.pad(0, 1), - Box.border("rounded", { annotation: Ansi.yellow }), - Box.moveDown(1), - ); - return Console.log(`\n${Box.renderPrettySync(message)}`); - } - - const message = Cause.prettyErrors(cause) - .map((error) => error.message) - .filter((message) => message.length > 0) - .join("\n"); - - return Console.error( - message.length > 0 ? message : Cause.pretty(cause), - ).pipe( - Effect.andThen( - Effect.sync(() => { - process.exitCode = 1; - }), - ), - ); - }), -); - -NodeRuntime.runMain(program, { disableErrorReporting: true }); +NodeRuntime.runMain(cliProgram.pipe(Effect.provide(StackEffectLayer)), { + disableErrorReporting: true, +}); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts index db0f7fa5..7a4ebcbe 100644 --- a/apps/cli/src/runtime.ts +++ b/apps/cli/src/runtime.ts @@ -1,5 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { NodeServices } from "@effect/platform-node"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Config, Effect, Layer } from "effect"; import { StackEffectServicesLayer } from "./services"; @@ -7,7 +8,7 @@ const CliConfig = Config.all({ TARGET: Config.Literals(["bun", "node"]).pipe(Config.withDefault("node")), }); -const PlatformLayer = Layer.unwrap( +export const PlatformLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* CliConfig; return config.TARGET === "bun" ? BunServices.layer : NodeServices.layer; @@ -15,5 +16,6 @@ const PlatformLayer = Layer.unwrap( ); export const StackEffectLayer = StackEffectServicesLayer.pipe( + Layer.provideMerge(BundledCatalogLayer), Layer.provideMerge(PlatformLayer), ); diff --git a/apps/cli/src/service/RecipeService.test.ts b/apps/cli/src/service/RecipeService.test.ts index 682e321d..7bf8df87 100644 --- a/apps/cli/src/service/RecipeService.test.ts +++ b/apps/cli/src/service/RecipeService.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; -import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { ModuleCapability, ModuleId, @@ -34,7 +34,7 @@ const testConfig = new StackConfig(testConfigFields); const makeTestLayer = (defaults: StackConfig) => RecipeService.layer.pipe( - Layer.provide(CatalogService.layer), + Layer.provide(BundledCatalogLayer), Layer.provide(Layer.succeed(StackConfigDefaults, defaults)), ); diff --git a/apps/cli/src/services.ts b/apps/cli/src/services.ts index 8d19f585..998b6672 100644 --- a/apps/cli/src/services.ts +++ b/apps/cli/src/services.ts @@ -1,4 +1,3 @@ -import { CatalogService } from "@repo/catalog"; import { ApplyPreviewService, ApplyService, @@ -24,4 +23,4 @@ export const StackEffectServicesLayer = Layer.mergeAll( ConfigureService.layer, RecipeService.layer, ScaffoldPipeline.layer, -).pipe(Layer.provideMerge(CatalogService.layer)); +); diff --git a/apps/docs/app/content/reference/cli/index.mdx b/apps/docs/app/content/reference/cli/index.mdx index 3bf38483..489364c3 100644 --- a/apps/docs/app/content/reference/cli/index.mdx +++ b/apps/docs/app/content/reference/cli/index.mdx @@ -2,7 +2,7 @@ # CLI reference -Commands, arguments, options, and examples for `stack-effect v0.14.0`. These pages are generated from the current Effect CLI command tree. +Commands, arguments, options, and examples for `stack-effect v0.15.0`. These pages are generated from the current Effect CLI command tree. ## Commands diff --git a/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts b/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts index 710c9f04..56657891 100644 --- a/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts +++ b/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts @@ -2,6 +2,7 @@ import * as BrowserWorkerRunner from "@effect/platform-browser/BrowserWorkerRunner"; import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { ModuleCategory } from "@repo/domain/Catalog"; import { RecipePreviewService, @@ -59,8 +60,8 @@ const RecipeBuilderRpcHandlersLive = RecipeBuilderRpc.toLayer( }); }), ).pipe( - Layer.provide(CatalogService.layer), Layer.provide(RecipePreviewService.layer), + Layer.provide(BundledCatalogLayer), ); const WorkerLive = RpcServer.layer(RecipeBuilderRpc, { diff --git a/package.json b/package.json index 32a636e9..583114cf 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ }, "scripts": { "build": "turbo run build", - "catalog:diff-workspace": "bun run start -- catalog workspace diff --root workspace/catalog-built", - "catalog:reset-workspace": "bun run start -- catalog workspace reset --root workspace/catalog-built", - "catalog:validate-workspace": "bun run start -- catalog workspace validate --root workspace/catalog-built", + "catalog:diff-workspace": "bun apps/cli/src/authoring.ts catalog workspace diff --root workspace/catalog-built", + "catalog:reset-workspace": "bun apps/cli/src/authoring.ts catalog workspace reset --root workspace/catalog-built", + "catalog:validate-workspace": "bun apps/cli/src/authoring.ts catalog workspace validate --root workspace/catalog-built", "clean": "turbo run clean && git clean -xdf node_modules .cache .turbo dist tsconfig.tsbuildinfo playwright-report test-results", "dev": "turbo run dev", "docs:check": "bun run docs:generate && git diff --exit-code -- apps/docs/app/content/reference/cli", diff --git a/packages/catalog/package.json b/packages/catalog/package.json index 129c3d3f..6826c707 100644 --- a/packages/catalog/package.json +++ b/packages/catalog/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./authoring": "./src/authoring.ts" }, "main": "./src/index.ts", "types": "./src/index.ts", diff --git a/packages/catalog/src/CatalogService.test.ts b/packages/catalog/src/CatalogService.test.ts index 78af500c..1d2d35c4 100644 --- a/packages/catalog/src/CatalogService.test.ts +++ b/packages/catalog/src/CatalogService.test.ts @@ -1,9 +1,10 @@ import { assert, layer } from "@effect/vitest"; import { ModuleId } from "@repo/domain/Catalog"; import { Effect } from "effect"; +import { BundledCatalogLayer } from "./authoring"; import { CatalogService } from "./CatalogService"; -layer(CatalogService.layer)("CatalogService", (it) => { +layer(BundledCatalogLayer)("CatalogService", (it) => { it.effect( "should expose module incompatibilities when building the public catalog tree", () => diff --git a/packages/catalog/src/CatalogService.ts b/packages/catalog/src/CatalogService.ts index 6323aeb4..27c43a0c 100644 --- a/packages/catalog/src/CatalogService.ts +++ b/packages/catalog/src/CatalogService.ts @@ -1,5 +1,6 @@ import type { CatalogGraph, + CatalogFragment, CatalogTree, ModuleCapability, ModuleCategory, @@ -20,8 +21,7 @@ import { Match, Result, } from "effect"; -import { moduleRegistry } from "./registry/moduleRegistry"; -import { targetRegistry } from "./registry/targetRegistry"; +import { composeCatalog } from "./composeCatalog"; export type BuilderCatalogTarget = { readonly kind: typeof TargetKind.Type; @@ -101,10 +101,16 @@ const requiredCapabilityDependency = Match.type< Match.orElse(() => Result.fail("skip" as const)), ); +const CatalogDefinitions = Context.Service( + "@repo/catalog/CatalogDefinitions", +); + export class CatalogService extends Context.Service()( "CatalogService", { make: Effect.gen(function* () { + const { targets: targetRegistry, modules: moduleRegistry } = + yield* CatalogDefinitions; const targetIndex = new Map(targetRegistry.map((t) => [t.kind, t])); const moduleIndex = new Map(moduleRegistry.map((m) => [m.id, m])); @@ -456,5 +462,13 @@ export class CatalogService extends Context.Service()( }), }, ) { - static readonly layer = Layer.effect(CatalogService)(CatalogService.make); + static readonly fromFragments = ( + fragments: ReadonlyArray, + options?: { readonly trustedFragmentIndex?: number }, + ) => + Layer.effect(CatalogService)(CatalogService.make).pipe( + Layer.provide( + Layer.effect(CatalogDefinitions)(composeCatalog(fragments, options)), + ), + ); } diff --git a/packages/catalog/src/authoring.ts b/packages/catalog/src/authoring.ts new file mode 100644 index 00000000..386cdc90 --- /dev/null +++ b/packages/catalog/src/authoring.ts @@ -0,0 +1,14 @@ +import { CatalogService } from "./CatalogService"; +import { moduleRegistry } from "./registry/moduleRegistry"; +import { targetRegistry } from "./registry/targetRegistry"; + +/** Local definitions for repository tooling and the temporary bundled clients. */ +export const bundledCatalog = { + targets: targetRegistry, + modules: moduleRegistry, +}; + +export const BundledCatalogLayer = CatalogService.fromFragments( + [bundledCatalog], + { trustedFragmentIndex: 0 }, +); diff --git a/packages/catalog/src/composeCatalog.test.ts b/packages/catalog/src/composeCatalog.test.ts new file mode 100644 index 00000000..08019c90 --- /dev/null +++ b/packages/catalog/src/composeCatalog.test.ts @@ -0,0 +1,172 @@ +import { assert, it } from "@effect/vitest"; +import { + ModuleCapability, + ModuleId, + TargetIdentity, + TargetKind, + type ModuleDefinition, +} from "@repo/domain/Catalog"; +import { Effect } from "effect"; +import { bundledCatalog } from "./authoring"; +import { composeCatalog } from "./composeCatalog"; + +const extraModule: typeof ModuleDefinition.Type = { + id: ModuleId.make("workspace-extra-example"), + title: "Extra example", + description: "A contributed file", + supportedOn: [{ _tag: "kind", kind: TargetKind.make("workspace") }], + dependencies: [], + contributions: [{ _tag: "file", path: "extra.txt", contents: "extra\n" }], +}; + +it.effect("composes an independent module against an official target", () => + Effect.gen(function* () { + const catalog = yield* composeCatalog( + [bundledCatalog, { targets: [], modules: [extraModule] }], + { trustedFragmentIndex: 0 }, + ); + assert.strictEqual(catalog.modules.at(-1)?.id, extraModule.id); + assert.strictEqual(catalog.targets.length, bundledCatalog.targets.length); + }), +); + +it.effect( + "rejects duplicate identifiers before lookup indexes can overwrite them", + () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + composeCatalog( + [ + bundledCatalog, + { targets: [], modules: [bundledCatalog.modules[0]] }, + ], + { + trustedFragmentIndex: 0, + }, + ), + ); + assert.match(failure.message, /Duplicate module ID/); + }), +); + +it.effect( + "validates references after composition and rejects missing targets", + () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + composeCatalog([{ targets: [], modules: [extraModule] }]), + ); + assert.match(failure.message, /missing target workspace/); + }), +); + +it.effect("rejects duplicate target kinds", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + composeCatalog( + [bundledCatalog, { targets: [bundledCatalog.targets[0]], modules: [] }], + { trustedFragmentIndex: 0 }, + ), + ); + assert.match(failure.message, /Duplicate target kind workspace/); + }), +); + +it.effect("rejects broken graph references before constructing a service", () => + Effect.gen(function* () { + const target = new TargetIdentity({ + kind: TargetKind.make("workspace"), + name: "root", + }); + const invalid: typeof ModuleDefinition.Type = { + ...extraModule, + dependencies: [ + { + _tag: "required-module", + target, + moduleId: ModuleId.make("missing-module"), + }, + { + _tag: "required-capability", + target, + capability: ModuleCapability.make("missing-capability"), + }, + ], + implies: [ + { + targetKind: TargetKind.make("workspace"), + moduleId: ModuleId.make("missing-implied"), + }, + ], + children: [ + { moduleId: ModuleId.make("missing-child"), requirement: "required" }, + ], + conflictsWith: [ModuleId.make("missing-conflict")], + }; + const failure = yield* Effect.flip( + composeCatalog([bundledCatalog, { targets: [], modules: [invalid] }], { + trustedFragmentIndex: 0, + }), + ); + for (const missing of [ + "missing-module", + "missing-capability", + "missing-implied", + "missing-child", + "missing-conflict", + ]) { + assert.include(failure.message, missing); + } + }), +); + +it.effect( + "rejects references to modules on the wrong target and one-sided conflicts", + () => + Effect.gen(function* () { + const invalid: typeof ModuleDefinition.Type = { + ...extraModule, + dependencies: [ + { + _tag: "required-module", + target: new TargetIdentity({ + kind: TargetKind.make("workspace"), + name: "root", + }), + moduleId: ModuleId.make("domain-api-contracts"), + }, + ], + conflictsWith: [ModuleId.make("workspace-quality-oxlint")], + }; + const failure = yield* Effect.flip( + composeCatalog([bundledCatalog, { targets: [], modules: [invalid] }], { + trustedFragmentIndex: 0, + }), + ); + assert.include(failure.message, "unsupported target"); + assert.include(failure.message, "asymmetric conflict"); + }), +); + +it.effect("does not allow an untrusted fragment to add Finalize scripts", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + composeCatalog( + [ + bundledCatalog, + { + targets: [], + modules: [ + { + ...extraModule, + scripts: [{ label: "run", command: "echo run" }], + }, + ], + }, + ], + { trustedFragmentIndex: 0 }, + ), + ); + assert.match(failure.message, /Finalize scripts/); + }), +); diff --git a/packages/catalog/src/composeCatalog.ts b/packages/catalog/src/composeCatalog.ts new file mode 100644 index 00000000..838bebbc --- /dev/null +++ b/packages/catalog/src/composeCatalog.ts @@ -0,0 +1,154 @@ +import { CatalogFragment, CatalogValidationError } from "@repo/domain/Catalog"; +import { Array as Arr, Effect, Schema } from "effect"; + +/** Validate fragments separately, then resolve references against their union. */ +export const composeCatalog = Effect.fn("Catalog.compose")(function* ( + fragments: ReadonlyArray, + options: { readonly trustedFragmentIndex?: number } = {}, +) { + const decoded = yield* Effect.forEach(fragments, (fragment, index) => + Schema.decodeUnknownEffect(CatalogFragment)(fragment).pipe( + Effect.mapError( + (error) => + new CatalogValidationError({ + issues: [`Fragment ${index}: ${error.message}`], + }), + ), + ), + ); + const targets = Arr.flatMap(decoded, (fragment) => fragment.targets); + const modules = Arr.flatMap(decoded, (fragment) => fragment.modules); + const targetByKind = new Map( + targets.map((target) => [target.kind, target]), + ); + const moduleById = new Map( + modules.map((module) => [module.id, module]), + ); + const issues: Array = []; + + const duplicates = (ids: ReadonlyArray) => + ids.filter((id, index) => ids.indexOf(id) !== index); + issues.push( + ...duplicates(targets.map((target) => target.kind)).map( + (kind) => `Duplicate target kind ${kind}`, + ), + ); + issues.push( + ...duplicates(modules.map((module) => module.id)).map( + (id) => `Duplicate module ID ${id}`, + ), + ); + + const supports = (module: (typeof modules)[number], kind: string) => + module.supportedOn.some( + (rule) => rule._tag === "kind" && rule.kind === kind, + ); + const requireTarget = (kind: string, owner: string) => { + if (!targetByKind.has(kind)) + issues.push(`${owner} references missing target ${kind}`); + }; + const requireModule = (id: string, owner: string) => { + if (!moduleById.has(id)) + issues.push(`${owner} references missing module ${id}`); + }; + + for (const target of targets) { + for (const id of target.requiredModules ?? []) { + requireModule(id, `Target ${target.kind}`); + const required = moduleById.get(id); + if (required && !supports(required, target.kind)) { + issues.push( + `Target ${target.kind} requires module ${id} on another target`, + ); + } + } + } + for (const module of modules) { + for (const rule of module.supportedOn) { + requireTarget( + rule._tag === "kind" ? rule.kind : rule.identity.kind, + `Module ${module.id}`, + ); + } + for (const dependency of module.dependencies) { + const target = + dependency._tag === "required-target" + ? dependency.identity + : dependency.target; + requireTarget(target.kind, `Module ${module.id}`); + if (dependency._tag === "required-module") { + requireModule(dependency.moduleId, `Module ${module.id}`); + const required = moduleById.get(dependency.moduleId); + if ( + required && + !required.supportedOn.some((rule) => target.matches(rule)) + ) + issues.push( + `Module ${module.id} requires ${dependency.moduleId} on an unsupported target`, + ); + } + if ( + dependency._tag === "required-capability" && + !modules.some( + (candidate) => + candidate.provides?.includes(dependency.capability) && + candidate.supportedOn.some((rule) => target.matches(rule)), + ) + ) + issues.push( + `Module ${module.id} requires unavailable capability ${dependency.capability}`, + ); + } + for (const implication of module.implies ?? []) { + requireTarget(implication.targetKind, `Module ${module.id}`); + requireModule(implication.moduleId, `Module ${module.id}`); + const implied = moduleById.get(implication.moduleId); + if (implied && !supports(implied, implication.targetKind)) + issues.push( + `Module ${module.id} implies ${implication.moduleId} on an unsupported target`, + ); + } + for (const child of module.children ?? []) { + requireModule(child.moduleId, `Module ${module.id}`); + const definition = moduleById.get(child.moduleId); + if ( + definition && + !module.supportedOn.some((rule) => + definition.supportedOn.some((other) => { + if (rule._tag === "kind" && other._tag === "kind") + return rule.kind === other.kind; + if (rule._tag === "identity" && other._tag === "identity") + return rule.identity.toKey() === other.identity.toKey(); + return ( + (rule._tag === "kind" ? rule.kind : rule.identity.kind) === + (other._tag === "kind" ? other.kind : other.identity.kind) + ); + }), + ) + ) + issues.push( + `Module ${module.id} has child ${child.moduleId} on another target`, + ); + } + for (const conflict of module.conflictsWith ?? []) { + requireModule(conflict, `Module ${module.id}`); + if ( + moduleById.has(conflict) && + !moduleById.get(conflict)?.conflictsWith?.includes(module.id) + ) + issues.push( + `Module ${module.id} has asymmetric conflict with ${conflict}`, + ); + } + } + decoded.forEach((fragment, index) => { + if (index === options.trustedFragmentIndex) return; + for (const definition of [...fragment.targets, ...fragment.modules]) { + if (definition.scripts?.length) + issues.push(`Fragment ${index} contains Finalize scripts`); + } + }); + + if (issues.length) return yield* new CatalogValidationError({ issues }); + return { targets, modules } as const; +}); diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index d94f2362..fed6bf12 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -5,3 +5,4 @@ export { type BuilderCatalogTargetModules, CatalogService, } from "./CatalogService"; +export { composeCatalog } from "./composeCatalog"; diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index d9180bf7..50ad7d34 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -369,6 +369,21 @@ export const TargetDefinition = Schema.Struct({ ), }); +export const CatalogFragment = Schema.Struct({ + targets: Schema.Array(TargetDefinition), + modules: Schema.Array(ModuleDefinition), +}); + +export type CatalogFragment = typeof CatalogFragment.Type; + +export class CatalogValidationError extends Data.TaggedError( + "CatalogValidationError", +)<{ readonly issues: ReadonlyArray }> { + override get message(): string { + return `Invalid catalog: ${this.issues.join("; ")}`; + } +} + export const CatalogNode = Schema.TaggedUnion({ target: { definition: TargetDefinition, diff --git a/packages/scaffold/scratchpad.ts b/packages/scaffold/scratchpad.ts index 22d408bf..b37dbe86 100644 --- a/packages/scaffold/scratchpad.ts +++ b/packages/scaffold/scratchpad.ts @@ -1,5 +1,5 @@ import { BunRuntime, BunServices } from "@effect/platform-bun"; -import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Apply as ApplyIntent } from "@repo/domain/Apply"; import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; import { StackConfig } from "@repo/domain/Scaffold"; @@ -159,8 +159,10 @@ BunRuntime.runMain( BlueprintService.layer, PlanService.layer, ScaffoldFormatter.layer, - CatalogService.layer, - ).pipe(Layer.provideMerge(BunServices.layer)), + ).pipe( + Layer.provideMerge(BundledCatalogLayer), + Layer.provideMerge(BunServices.layer), + ), ), ), ); diff --git a/packages/scaffold/src/service/apply/ApplyPreviewService.test.ts b/packages/scaffold/src/service/apply/ApplyPreviewService.test.ts index cb82e22c..203c7617 100644 --- a/packages/scaffold/src/service/apply/ApplyPreviewService.test.ts +++ b/packages/scaffold/src/service/apply/ApplyPreviewService.test.ts @@ -6,6 +6,7 @@ import nodePath from "node:path"; import { MemoryFileSystem } from "@effect-vfs/memory"; import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto"; import { describe, expect, it } from "@effect/vitest"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Apply, type ApplyDecision } from "@repo/domain/Apply"; import { Blueprint } from "@repo/domain/Blueprint"; import { @@ -80,16 +81,18 @@ const makeApply = ( const TestLayer = Layer.provideMerge( ApplyPreviewService.layer, - Layer.merge( + Layer.mergeAll( Layer.provideMerge(MemoryFileSystem.layer, BrowserCrypto.layer), Path.layer, + BundledCatalogLayer, ), ); const WorkspaceTestLayer = Layer.provideMerge( ApplyWorkspaceService.layer, - Layer.merge( + Layer.mergeAll( Layer.provideMerge(MemoryFileSystem.layer, BrowserCrypto.layer), Path.layer, + BundledCatalogLayer, ), ); describe("ApplyPreviewService", () => { @@ -279,6 +282,7 @@ describe("ApplyPreviewService", () => { ); const previewLayer = ApplyPreviewService.layer.pipe( Layer.provide(hostLayer), + Layer.provide(BundledCatalogLayer), ); const windowsRepoRoot = "C:\\repo"; const packageJsonPath = nodePath.win32.join( @@ -437,7 +441,9 @@ describe("ApplyWorkspaceService", () => { }).pipe( Effect.provide( Layer.provideMerge( - ApplyWorkspaceService.layer, + ApplyWorkspaceService.layer.pipe( + Layer.provide(BundledCatalogLayer), + ), Layer.merge( Layer.provideMerge(MemoryFileSystem.layer, BrowserCrypto.layer), Path.layer, @@ -488,7 +494,7 @@ describe("ApplyWorkspaceService", () => { }).pipe( Effect.provide( Layer.provideMerge( - ApplyWorkspaceService.layer, + ApplyWorkspaceService.layer.pipe(Layer.provide(BundledCatalogLayer)), Layer.merge( Layer.provideMerge(MemoryFileSystem.layer, BrowserCrypto.layer), Path.layer, diff --git a/packages/scaffold/src/service/apply/ApplyWorkspaceService.ts b/packages/scaffold/src/service/apply/ApplyWorkspaceService.ts index 4cc3f03d..06addb05 100644 --- a/packages/scaffold/src/service/apply/ApplyWorkspaceService.ts +++ b/packages/scaffold/src/service/apply/ApplyWorkspaceService.ts @@ -1,5 +1,6 @@ import { MemoryFileSystem } from "@effect-vfs/memory"; import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto"; +import { CatalogService } from "@repo/catalog"; import { Apply, ApplyFailure, @@ -58,6 +59,7 @@ export class ApplyWorkspaceService extends Context.Service< const hostPath = yield* Path.Path; const virtualPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); const repositoryState = yield* RepositoryStateService; + const catalog = yield* CatalogService; const create: ApplyWorkspaceServiceShape["create"] = Effect.fn( "ApplyWorkspaceService.create", @@ -260,6 +262,7 @@ export class ApplyWorkspaceService extends Context.Service< plan(input).pipe( Effect.provide( Layer.fresh(PlanService.layer).pipe( + Layer.provide(Layer.succeed(CatalogService, catalog)), Layer.provide(fileSystemLayer), ), ), diff --git a/packages/scaffold/src/service/blueprint/BlueprintService.test.ts b/packages/scaffold/src/service/blueprint/BlueprintService.test.ts index da498aa1..80b6c2e9 100644 --- a/packages/scaffold/src/service/blueprint/BlueprintService.test.ts +++ b/packages/scaffold/src/service/blueprint/BlueprintService.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { describe, expect, layer } from "@effect/vitest"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { type Blueprint, BlueprintFailure, @@ -8,7 +9,7 @@ import { } from "@repo/domain/Blueprint"; import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; import { StackConfig } from "@repo/domain/Scaffold"; -import { Cause, Effect, Exit, Schema } from "effect"; +import { Cause, Effect, Exit, Layer, Schema } from "effect"; import { BlueprintService } from "./BlueprintService"; const domainIdentity = new TargetIdentity({ @@ -42,8 +43,12 @@ const denoConfig = new StackConfig({ typescript: "6", }); +const TestLayer = BlueprintService.layer.pipe( + Layer.provide(BundledCatalogLayer), +); + describe("BlueprintService", () => { - layer(BlueprintService.layer)("resolve", (it) => { + layer(TestLayer)("resolve", (it) => { describe("when validating selections", () => { it.effect("should fail when the same target is selected twice", () => Effect.gen(function* () { diff --git a/packages/scaffold/src/service/blueprint/BlueprintService.ts b/packages/scaffold/src/service/blueprint/BlueprintService.ts index 659ef02e..851fff06 100644 --- a/packages/scaffold/src/service/blueprint/BlueprintService.ts +++ b/packages/scaffold/src/service/blueprint/BlueprintService.ts @@ -97,9 +97,7 @@ export class BlueprintService extends Context.Service()( }), }, ) { - static readonly layer = Layer.effect(BlueprintService)( - BlueprintService.make, - ).pipe(Layer.provide(CatalogService.layer)); + static readonly layer = Layer.effect(BlueprintService)(BlueprintService.make); } const validateSelection = Effect.fn("BlueprintService.validateSelection")( diff --git a/packages/scaffold/src/service/finalize/FinalizeService.test.ts b/packages/scaffold/src/service/finalize/FinalizeService.test.ts index 7186b6d5..0debe136 100644 --- a/packages/scaffold/src/service/finalize/FinalizeService.test.ts +++ b/packages/scaffold/src/service/finalize/FinalizeService.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { CatalogService } from "@repo/catalog"; +import { bundledCatalog } from "@repo/catalog/authoring"; import { Blueprint, toAttachedModuleNodeId } from "@repo/domain/Blueprint"; import { type ModuleDefinition, @@ -159,6 +160,42 @@ const makeFinalizeLayer = ( Layer.provide(makeSpawnerLayer(executed, opts.failures)), ); +it.effect("collects next steps from an injected declarative module", () => { + const moduleId = ModuleId.make("server-extra-example"); + const catalogLayer = CatalogService.fromFragments( + [ + bundledCatalog, + { + targets: [], + modules: [ + { + id: moduleId, + title: "Extra example", + description: "Contributed guidance", + supportedOn: [{ _tag: "kind", kind: TargetKind.make("server") }], + dependencies: [], + contributions: [], + nextSteps: ["Read {{targetPath}}/extra.txt"], + }, + ], + }, + ], + { trustedFragmentIndex: 0 }, + ); + const serviceLayer = FinalizeService.layer.pipe( + Layer.provide(catalogLayer), + Layer.provide(makeSpawnerLayer([])), + ); + return Effect.gen(function* () { + const finalize = yield* FinalizeService; + const steps = yield* finalize.collectNextSteps( + targetWithModule(serverIdentity, moduleId), + makeConfig(), + ); + expect(steps).toContain("Read apps/server-api/extra.txt"); + }).pipe(Effect.provide(serviceLayer)); +}); + const runToReport = ( svc: typeof FinalizeService.Service, blueprint: typeof Blueprint.Type, diff --git a/packages/scaffold/src/service/finalize/FinalizeService.ts b/packages/scaffold/src/service/finalize/FinalizeService.ts index 69b0ad8c..f97db417 100644 --- a/packages/scaffold/src/service/finalize/FinalizeService.ts +++ b/packages/scaffold/src/service/finalize/FinalizeService.ts @@ -189,9 +189,7 @@ export class FinalizeService extends Context.Service()( }), }, ) { - static readonly layer = Layer.effect(FinalizeService)( - FinalizeService.make, - ).pipe(Layer.provide(CatalogService.layer)); + static readonly layer = Layer.effect(FinalizeService)(FinalizeService.make); } const createTokenContext = ( diff --git a/packages/scaffold/src/service/plan/ContributionResolver.ts b/packages/scaffold/src/service/plan/ContributionResolver.ts index fb3e1722..edf7197e 100644 --- a/packages/scaffold/src/service/plan/ContributionResolver.ts +++ b/packages/scaffold/src/service/plan/ContributionResolver.ts @@ -89,7 +89,7 @@ export class ContributionResolver extends Context.Service( ) { static readonly layer = Layer.effect(ContributionResolver)( ContributionResolver.make, - ).pipe(Layer.provide(CatalogService.layer)); + ); } const resolveContributionTokens = ( diff --git a/packages/scaffold/src/service/plan/PlanService.test.ts b/packages/scaffold/src/service/plan/PlanService.test.ts index f5e98326..d94f1efe 100644 --- a/packages/scaffold/src/service/plan/PlanService.test.ts +++ b/packages/scaffold/src/service/plan/PlanService.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { describe, expect, it } from "@effect/vitest"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Blueprint, toAttachedModuleNodeId } from "@repo/domain/Blueprint"; import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; import { @@ -221,6 +222,7 @@ const makePlanServiceLayer = ( Layer.provide(makeRepoSnapshotServiceLayer(load)), Layer.provide(makeRepositoryStateServiceLayer(load)), Layer.provide(assessorLayer), + Layer.provide(BundledCatalogLayer), ); const buildPlan = ({ diff --git a/packages/scaffold/src/service/plan/PlanService.ts b/packages/scaffold/src/service/plan/PlanService.ts index 887d86f3..5b471ddf 100644 --- a/packages/scaffold/src/service/plan/PlanService.ts +++ b/packages/scaffold/src/service/plan/PlanService.ts @@ -1,4 +1,3 @@ -import { CatalogService } from "@repo/catalog"; import type { Blueprint } from "@repo/domain/Blueprint"; import type { CatalogNotFound } from "@repo/domain/Catalog"; import { Plan, PlanFailure, type RepoSnapshot } from "@repo/domain/Plan"; @@ -179,6 +178,5 @@ export class PlanService extends Context.Service< Layer.provide(RepoSnapshotService.layer), Layer.provide(RepositoryStateService.layer), Layer.provide(PlanAssessor.layer), - Layer.provide(CatalogService.layer), ); } diff --git a/packages/scaffold/src/service/plan/RepositoryStateIntegration.test.ts b/packages/scaffold/src/service/plan/RepositoryStateIntegration.test.ts index a81fce90..e16a641f 100644 --- a/packages/scaffold/src/service/plan/RepositoryStateIntegration.test.ts +++ b/packages/scaffold/src/service/plan/RepositoryStateIntegration.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { MemoryFileSystem } from "@effect-vfs/memory"; import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto"; import { describe, expect, it } from "@effect/vitest"; +import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Apply, StalePlanFailure } from "@repo/domain/Apply"; import { Blueprint, toAttachedModuleNodeId } from "@repo/domain/Blueprint"; import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; @@ -53,9 +54,10 @@ const TestLayer = Layer.provideMerge( ApplyWorkspaceService.layer, RepositoryStateService.layer, ), - Layer.merge( + Layer.mergeAll( Layer.provideMerge(MemoryFileSystem.layer, BrowserCrypto.layer), Path.layer, + BundledCatalogLayer, ), ); @@ -482,7 +484,9 @@ describe("Plan and Apply repository state", () => { ), }; const layer = Layer.provideMerge( - Layer.merge(PlanService.layer, ApplyService.layer), + Layer.merge(PlanService.layer, ApplyService.layer).pipe( + Layer.provide(BundledCatalogLayer), + ), Layer.merge( Layer.succeed(FileSystem.FileSystem, wrapped), Path.layer, diff --git a/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts b/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts index 10357075..10f795e8 100644 --- a/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts +++ b/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts @@ -1,7 +1,14 @@ import { assert, it } from "@effect/vitest"; -import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; +import { CatalogService } from "@repo/catalog"; +import { BundledCatalogLayer, bundledCatalog } from "@repo/catalog/authoring"; +import { + ModuleId, + TargetIdentity, + TargetKind, + type ModuleDefinition, +} from "@repo/domain/Catalog"; import { StackConfig } from "@repo/domain/Scaffold"; -import { Effect, Schema } from "effect"; +import { Effect, Graph, Layer, Schema } from "effect"; import { RecipePreviewService } from "./RecipePreviewService"; const PackageJsonFromJsonString = Schema.fromJsonString( @@ -15,6 +22,85 @@ const UnknownFromJsonString = Schema.decodeUnknownSync( ); const decodePackageJson = Schema.decodeUnknownSync(PackageJsonFromJsonString); +it.effect( + "plans and previews a module supplied outside the built-in registry", + () => { + const extra: typeof ModuleDefinition.Type = { + id: ModuleId.make("package-extra-example"), + title: "Extra example", + description: "A contributed file", + supportedOn: [{ _tag: "kind", kind: TargetKind.make("package") }], + dependencies: [], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/extra.txt", + contents: "from fragment\n", + }, + ], + }; + const catalogLayer = CatalogService.fromFragments( + [bundledCatalog, { targets: [], modules: [extra] }], + { trustedFragmentIndex: 0 }, + ); + return Effect.gen(function* () { + const catalog = yield* CatalogService; + const previews = yield* RecipePreviewService; + const projection = yield* catalog.toBuilderCatalog([ + new TargetIdentity({ kind: TargetKind.make("package"), name: "extra" }), + ]); + assert.isTrue( + projection.targetModules[0]?.modules.some( + (module) => module.id === extra.id, + ), + ); + assert.isTrue( + catalog.toCatalogTree.targets.some((target) => + target.modules.some((module) => module.id === extra.id), + ), + ); + assert.isTrue( + [...Graph.nodes(catalog.toGraph)].some( + ([, node]) => + node._tag === "module" && node.definition.id === extra.id, + ), + ); + const preview = yield* previews.preview({ + config: new StackConfig({ + name: Schema.NonEmptyString.make("extra-project"), + runtime: { _tag: "bun" }, + }), + recipe: { + targets: [ + { + target: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "extra", + }), + modules: [extra.id], + }, + ], + }, + }); + assert.isTrue( + preview.blueprint.nodes.some( + (node) => + node._tag === "attached-module" && node.moduleId === extra.id, + ), + ); + assert.strictEqual( + preview.files.find((file) => file.path === "packages/extra/extra.txt") + ?.contents, + "from fragment\n", + ); + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provideMerge(catalogLayer)), + ), + ); + }, +); + const previewQualityConfig = ( lint: "biome" | "oxlint", format: "dprint" | "oxfmt", @@ -59,7 +145,11 @@ it.effect("should preview Deno SQLite files", () => assert.isTrue( preview.files.some((file) => file.path === "packages/db/src/Database.ts"), ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -102,7 +192,11 @@ it.effect( fileContents(".vscode/settings.json"), "source.organizeImports.biome", ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -120,7 +214,11 @@ it.effect( assert.strictEqual(packageJson.scripts["format:check"], "oxfmt --check"); assert.strictEqual(packageJson.devDependencies["oxfmt"], "^0.65.0"); assert.isUndefined(fileContents("dprint.json")); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -155,7 +253,11 @@ it.effect( ], }, ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -174,7 +276,11 @@ it.effect( fileContents(".vscode/extensions.json"), '"recommendations": ["oxc.oxc-vscode"]', ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -195,7 +301,11 @@ it.effect( fileContents(".vscode/settings.json"), "source.organizeImports.biome", ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect( @@ -211,7 +321,11 @@ it.effect( fileContents(".vscode/settings.json"), "source.organizeImports.biome", ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); it.effect("should generate standalone Oxlint when monorepo is omitted", () => @@ -241,5 +355,9 @@ it.effect("should generate standalone Oxlint when monorepo is omitted", () => fileContents(".oxlintrc.json"), "oxlint-presets/effect-native.json", ); - }).pipe(Effect.provide(RecipePreviewService.layer)), + }).pipe( + Effect.provide( + RecipePreviewService.layer.pipe(Layer.provide(BundledCatalogLayer)), + ), + ), ); diff --git a/packages/scaffold/src/service/recipe/RecipePreviewService.ts b/packages/scaffold/src/service/recipe/RecipePreviewService.ts index 6c67b339..55bf7935 100644 --- a/packages/scaffold/src/service/recipe/RecipePreviewService.ts +++ b/packages/scaffold/src/service/recipe/RecipePreviewService.ts @@ -1,6 +1,5 @@ import { MemoryFileSystem } from "@effect-vfs/memory"; import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto"; -import { CatalogService } from "@repo/catalog"; import { Apply, ApplyFailure, type StalePlanFailure } from "@repo/domain/Apply"; import type { BlueprintFailure } from "@repo/domain/Blueprint"; import type { CatalogNotFound } from "@repo/domain/Catalog"; @@ -93,7 +92,6 @@ export class RecipePreviewService extends Context.Service< static readonly layer = Layer.effect(this, this.make).pipe( Layer.provide(RecipeService.layer), Layer.provide(BlueprintService.layer), - Layer.provide(CatalogService.layer), Layer.provide( ApplyWorkspaceService.layer.pipe( Layer.provide(