From a6dc65b47db76b3fe7bc6566aa302315930c1210 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Sun, 27 Sep 2026 21:45:17 +0200 Subject: [PATCH] feat(cli): load official catalog registry per command --- .changeset/spotty-olives-travel.md | 7 + apps/cli/e2e/entrypoint.ts | 40 +++ apps/cli/e2e/harness.ts | 2 +- apps/cli/src/authoring.ts | 6 +- apps/cli/src/command.ts | 57 +++- apps/cli/src/commands/catalog.ts | 2 + apps/cli/src/commands/create.ts | 9 +- apps/cli/src/commands/init.ts | 10 +- apps/cli/src/commands/plan.ts | 55 ++-- apps/cli/src/docs/CliReference.test.ts | 2 + apps/cli/src/runtime.ts | 17 +- apps/cli/src/service/CatalogProvider.test.ts | 263 ++++++++++++++++++ apps/cli/src/service/CatalogProvider.ts | 50 ++++ apps/cli/src/service/ConfigureService.test.ts | 45 +++ apps/cli/src/service/ConfigureService.ts | 66 ++++- apps/cli/src/service/FileCatalogCache.test.ts | 38 +++ apps/cli/src/service/FileCatalogCache.ts | 139 +++++++++ apps/cli/src/services.ts | 14 +- 18 files changed, 769 insertions(+), 53 deletions(-) create mode 100644 .changeset/spotty-olives-travel.md create mode 100644 apps/cli/e2e/entrypoint.ts create mode 100644 apps/cli/src/service/CatalogProvider.test.ts create mode 100644 apps/cli/src/service/CatalogProvider.ts create mode 100644 apps/cli/src/service/ConfigureService.test.ts create mode 100644 apps/cli/src/service/FileCatalogCache.test.ts create mode 100644 apps/cli/src/service/FileCatalogCache.ts diff --git a/.changeset/spotty-olives-travel.md b/.changeset/spotty-olives-travel.md new file mode 100644 index 00000000..ea9b9355 --- /dev/null +++ b/.changeset/spotty-olives-travel.md @@ -0,0 +1,7 @@ +--- +"stack-effect": minor +--- + +CLI commands now load the official catalog before generating or updating a project. During an outage, they can use a previously validated catalog from the user cache and print a warning to stderr. + +For example, `stack-effect add --target package/db:package-db-sqlite` checks current catalog definitions before planning changes. New projects include the hosted JSON Schema link in `stack.effect.json`. diff --git a/apps/cli/e2e/entrypoint.ts b/apps/cli/e2e/entrypoint.ts new file mode 100644 index 00000000..662c1c9b --- /dev/null +++ b/apps/cli/e2e/entrypoint.ts @@ -0,0 +1,40 @@ +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { exportOfficialCatalog } from "@repo/catalog/authoring"; +import { CatalogCache, CatalogLoader } from "@repo/scaffold"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { cliProgram } from "../src/cliProgram"; +import { CatalogProvider } from "../src/service/CatalogProvider"; +import { ConfigureService } from "../src/service/ConfigureService"; + +const FixtureHttpClient = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const json = yield* exportOfficialCatalog(); + return HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(json, { + headers: { "content-type": "application/json" }, + }), + ), + ), + ); + }), +); + +const FixtureLayer = CatalogProvider.official.pipe( + Layer.provideMerge( + CatalogLoader.layer.pipe( + Layer.provideMerge(CatalogCache.memory), + Layer.provideMerge(FixtureHttpClient), + ), + ), + Layer.provideMerge(ConfigureService.layer), + Layer.provideMerge(NodeServices.layer), +); + +NodeRuntime.runMain(cliProgram.pipe(Effect.provide(FixtureLayer)), { + disableErrorReporting: true, +}); diff --git a/apps/cli/e2e/harness.ts b/apps/cli/e2e/harness.ts index 58e97df2..a088fa41 100644 --- a/apps/cli/e2e/harness.ts +++ b/apps/cli/e2e/harness.ts @@ -289,7 +289,7 @@ const makeProjectContext = ( }; }; -const cliEntrypoint = new URL("../src/index.ts", import.meta.url).pathname; +const cliEntrypoint = new URL("./entrypoint.ts", import.meta.url).pathname; export class CLI extends Context.Service()("e2e/CLI", { make: Effect.gen(function* () { diff --git a/apps/cli/src/authoring.ts b/apps/cli/src/authoring.ts index d804fe9d..9e4202d7 100644 --- a/apps/cli/src/authoring.ts +++ b/apps/cli/src/authoring.ts @@ -3,10 +3,12 @@ import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Effect, Layer } from "effect"; import { cliProgram } from "./cliProgram"; import { PlatformLayer } from "./runtime"; -import { StackEffectServicesLayer } from "./services"; +import { CatalogProvider } from "./service/CatalogProvider"; +import { ConfigureService } from "./service/ConfigureService"; -const AuthoringLayer = StackEffectServicesLayer.pipe( +const AuthoringLayer = CatalogProvider.authoring.pipe( Layer.provideMerge(BundledCatalogLayer), + Layer.provideMerge(ConfigureService.layer), Layer.provideMerge(PlatformLayer), ); diff --git a/apps/cli/src/command.ts b/apps/cli/src/command.ts index 9a5a234b..0dd86b83 100644 --- a/apps/cli/src/command.ts +++ b/apps/cli/src/command.ts @@ -1,15 +1,68 @@ +import { Effect, Option } from "effect"; import { Command } from "effect/unstable/cli"; import { add } from "./commands/add"; import { catalog } from "./commands/catalog"; import { create } from "./commands/create"; import { graph } from "./commands/graph"; import { init } from "./commands/init"; -import { plan } from "./commands/plan"; +import { parsePlanInput, ParsedPlanInput, plan } from "./commands/plan"; import { schema } from "./commands/schema"; +import { resolveNameAndRoot } from "./lib/project"; +import { ConfigureService } from "./service/ConfigureService"; +import { CommandServicesLayer } from "./services"; + +const requireExistingConfig = (root: Option.Option) => + Effect.gen(function* () { + const configure = yield* ConfigureService; + yield* configure.readConfig(Option.getOrElse(root, () => process.cwd())); + }); + +const inspectProjectConfig = ( + name: Option.Option, + root: Option.Option, +) => + Option.isNone(name) + ? Effect.void + : Effect.gen(function* () { + const configure = yield* ConfigureService; + const { repoRoot } = yield* resolveNameAndRoot(name.value, root); + yield* configure.readConfig(repoRoot).pipe( + Effect.asVoid, + Effect.catchTag("MissingConfigError", () => Effect.void), + ); + }); export const stackEffectCommand = Command.make("stack-effect").pipe( Command.withDescription( "Interactive CLI for scaffolding and extending Effect-powered TypeScript projects. Compose targets (server, client, cli, package) with incrementally-addable modules.", ), - Command.withSubcommands([init, create, add, graph, plan, schema, catalog]), + Command.withSubcommands([ + init.pipe( + Command.provide(CommandServicesLayer), + Command.provideEffectDiscard((flags) => + inspectProjectConfig(flags.name, flags.root), + ), + ), + create.pipe( + Command.provide(CommandServicesLayer), + Command.provideEffectDiscard((flags) => + inspectProjectConfig(flags.name, flags.root), + ), + ), + add.pipe( + Command.provide(CommandServicesLayer), + Command.provideEffectDiscard((flags) => + requireExistingConfig(flags.root), + ), + ), + graph.pipe(Command.provide(CommandServicesLayer)), + plan.pipe( + Command.provide(CommandServicesLayer), + Command.provideEffect(ParsedPlanInput, (flags) => + parsePlanInput(flags.root), + ), + ), + schema.pipe(Command.provide(CommandServicesLayer)), + catalog, + ]), ); diff --git a/apps/cli/src/commands/catalog.ts b/apps/cli/src/commands/catalog.ts index cff1f060..988e5d9b 100644 --- a/apps/cli/src/commands/catalog.ts +++ b/apps/cli/src/commands/catalog.ts @@ -47,6 +47,7 @@ import { runtimeFlag, typescriptFlag, } from "../flags"; +import { CommandServicesLayer } from "../services"; const defaultWorkspaceRoot = "workspace/catalog-built"; @@ -666,6 +667,7 @@ const reset = Command.make( } }), ).pipe( + Command.provide(CommandServicesLayer), Command.withShortDescription("Rebuild the generated catalog workspace"), Command.withDescription( "Reset an editable generated catalog workspace and commit a git baseline.", diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index d94ffc8c..a4c38144 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -3,6 +3,7 @@ import type { RecipeSpec, RecipeTargetSpec } from "@repo/domain/Recipe"; import { makeRuntime, runtimeForPackageManager, + STACK_CONFIG_SCHEMA_URL, StackConfig, } from "@repo/domain/Scaffold"; import { @@ -110,6 +111,7 @@ const buildConfig = ({ const runtimeDefaults = defaultsForRuntime(defaults, runtimeName); return new StackConfig({ + $schema: STACK_CONFIG_SCHEMA_URL, name: projectName as typeof Schema.NonEmptyString.Type, runtime: runtimeConfig, typescript: Option.getOrElse(typescript, () => runtimeDefaults.typescript), @@ -221,9 +223,10 @@ export const create = Command.make( yield* blueprints.resolve(selection, config); const createCommand = recipes.renderCreateCommand({ config, selection }); - const existing = yield* configure - .readConfig(repoRoot) - .pipe(Effect.option); + const existing = yield* configure.readConfig(repoRoot).pipe( + Effect.asSome, + Effect.catchTag("MissingConfigError", () => Effect.succeedNone), + ); if (Option.isSome(existing)) { return yield* Effect.fail( diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index 0b1916e0..419b7910 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -5,7 +5,7 @@ import { TargetIdentity, TargetKind, } from "@repo/domain/Catalog"; -import { makeRuntime } from "@repo/domain/Scaffold"; +import { makeRuntime, STACK_CONFIG_SCHEMA_URL } from "@repo/domain/Scaffold"; import { BlueprintService, defaultsForRuntime, @@ -141,9 +141,10 @@ export const init = Command.make( flags.root, ); - const existing = yield* configure - .readConfig(repoRoot) - .pipe(Effect.option); + const existing = yield* configure.readConfig(repoRoot).pipe( + Effect.asSome, + Effect.catchTag("MissingConfigError", () => Effect.succeedNone), + ); if (Option.isSome(existing)) { yield* Console.log( @@ -250,6 +251,7 @@ export const init = Command.make( }); const config = new StackConfig({ + $schema: STACK_CONFIG_SCHEMA_URL, name: projectName as typeof Schema.NonEmptyString.Type, runtime, typescript, diff --git a/apps/cli/src/commands/plan.ts b/apps/cli/src/commands/plan.ts index e49420a4..52c70a65 100644 --- a/apps/cli/src/commands/plan.ts +++ b/apps/cli/src/commands/plan.ts @@ -1,4 +1,5 @@ import { PlanRequest } from "@repo/domain/Plan"; +import { StackConfig } from "@repo/domain/Scaffold"; import { BlueprintService, FinalizeService, @@ -10,6 +11,7 @@ import { import { Array as Arr, Console, + Context, Effect, FileSystem, Match, @@ -56,6 +58,37 @@ const outputFlag = Flag.String("output").pipe( Flag.withDescription("Write output to a file instead of stdout"), ); +export class ParsedPlanInput extends Context.Service< + ParsedPlanInput, + { + readonly input: typeof PlanRequest.Type; + readonly config: typeof StackConfig.Type; + } +>()("ParsedPlanInput") {} + +export const parsePlanInput = (root: Option.Option) => + Effect.gen(function* () { + const repoRoot = Option.getOrElse(root, () => process.cwd()); + const stdio = yield* Stdio; + const stdin = yield* stdio.stdin.pipe(Stream.decodeText(), Stream.mkString); + const input = yield* Schema.decodeEffect( + Schema.fromJsonString(PlanRequest), + )(stdin); + const configure = yield* ConfigureService; + const config = + input.config ?? + (yield* configure + .readConfig(repoRoot) + .pipe( + Effect.catchTag("MissingConfigError", () => + Effect.fail( + "No config found. Provide 'config' in stdin or ensure stack.effect.json exists at --root.", + ), + ), + )); + return { input, config }; + }); + export const plan = Command.make( "plan", { root: rootFlag, format: formatFlag, output: outputFlag }, @@ -65,27 +98,7 @@ export const plan = Command.make( const repoRoot = Option.getOrElse(flags.root, () => process.cwd()); const format = Option.getOrElse(flags.format, () => "llm" as const); - const stdio = yield* Stdio; - const stdin = yield* stdio.stdin.pipe( - Stream.decodeText(), - Stream.mkString, - ); - - const input = yield* Schema.decodeEffect( - Schema.fromJsonString(PlanRequest), - )(stdin); - - const configureService = yield* ConfigureService; - const fileConfig = yield* configureService - .readConfig(repoRoot) - .pipe(Effect.catch(() => Effect.void)); - - const config = input.config ?? fileConfig; - if (!config) { - return yield* Effect.fail( - "No config found. Provide 'config' in stdin or ensure stack.effect.json exists at --root.", - ); - } + const { input, config } = yield* ParsedPlanInput; const blueprintService = yield* BlueprintService; const blueprint = yield* blueprintService.resolve( diff --git a/apps/cli/src/docs/CliReference.test.ts b/apps/cli/src/docs/CliReference.test.ts index 408b9362..df2f8c7f 100644 --- a/apps/cli/src/docs/CliReference.test.ts +++ b/apps/cli/src/docs/CliReference.test.ts @@ -4,11 +4,13 @@ import { BundledCatalogLayer } from "@repo/catalog/authoring"; import { Effect, Layer } from "effect"; import pkg from "../../package.json"; import { stackEffectCommand } from "../command"; +import { CatalogProvider } from "../service/CatalogProvider"; import { StackEffectServicesLayer } from "../services"; import { collectCliReference, validateCliReference } from "./CliReference"; import { renderCliReferencePages } from "./CliReferenceMarkdown"; const TestLayer = StackEffectServicesLayer.pipe( + Layer.provideMerge(CatalogProvider.authoring), Layer.provideMerge(BundledCatalogLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/cli/src/runtime.ts b/apps/cli/src/runtime.ts index 7a4ebcbe..67a68e6f 100644 --- a/apps/cli/src/runtime.ts +++ b/apps/cli/src/runtime.ts @@ -1,8 +1,11 @@ import { BunServices } from "@effect/platform-bun"; import { NodeServices } from "@effect/platform-node"; -import { BundledCatalogLayer } from "@repo/catalog/authoring"; +import { CatalogLoader } from "@repo/scaffold"; import { Config, Effect, Layer } from "effect"; -import { StackEffectServicesLayer } from "./services"; +import { FetchHttpClient } from "effect/unstable/http"; +import { CatalogProvider } from "./service/CatalogProvider"; +import { ConfigureService } from "./service/ConfigureService"; +import { fileCatalogCacheLayer } from "./service/FileCatalogCache"; const CliConfig = Config.all({ TARGET: Config.Literals(["bun", "node"]).pipe(Config.withDefault("node")), @@ -15,7 +18,13 @@ export const PlatformLayer = Layer.unwrap( }), ); -export const StackEffectLayer = StackEffectServicesLayer.pipe( - Layer.provideMerge(BundledCatalogLayer), +export const StackEffectLayer = CatalogProvider.official.pipe( + Layer.provideMerge( + CatalogLoader.layer.pipe( + Layer.provideMerge(fileCatalogCacheLayer()), + Layer.provideMerge(FetchHttpClient.layer), + ), + ), + Layer.provideMerge(ConfigureService.layer), Layer.provideMerge(PlatformLayer), ); diff --git a/apps/cli/src/service/CatalogProvider.test.ts b/apps/cli/src/service/CatalogProvider.test.ts new file mode 100644 index 00000000..e2bae964 --- /dev/null +++ b/apps/cli/src/service/CatalogProvider.test.ts @@ -0,0 +1,263 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { CatalogDocument } from "@repo/domain/Catalog"; +import { CatalogCache, CatalogLoader } from "@repo/scaffold"; +import { + Console, + Effect, + FileSystem, + Layer, + Path, + Schema, + Stream, +} from "effect"; +import * as Stdio from "effect/Stdio"; +import { Command } from "effect/unstable/cli"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { stackEffectCommand } from "../command"; +import { CatalogProvider } from "./CatalogProvider"; +import { ConfigureService } from "./ConfigureService"; + +const document = Schema.encodeSync(Schema.fromJsonString(CatalogDocument))({ + formatVersion: 1, + catalogId: Schema.NonEmptyString.make("fixture"), + requiredCapabilities: [], + targets: [], + modules: [], +}); + +const runCommand = Command.runWith(stackEffectCommand, { version: "test" }); + +const layerFor = (client: HttpClient.HttpClient) => + CatalogProvider.official.pipe( + Layer.provideMerge( + CatalogLoader.layer.pipe( + Layer.provideMerge(CatalogCache.memory), + Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), + ), + ), + Layer.provideMerge(ConfigureService.layer), + Layer.provideMerge(NodeServices.layer), + ); + +it.effect("keeps help and version independent of the registry", () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(document, { + headers: { "content-type": "application/json" }, + }), + ); + }), + ); + return Effect.gen(function* () { + yield* runCommand(["--help"]); + yield* runCommand(["--version"]); + assert.strictEqual(requests, 0); + }).pipe(Effect.provide(layerFor(client))); +}); + +it.effect("loads once for a graph command through controlled HTTP", () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(document, { + headers: { "content-type": "application/json" }, + }), + ); + }), + ); + return Effect.gen(function* () { + yield* runCommand(["graph", "--format", "mermaid"]); + assert.strictEqual(requests, 1); + }).pipe(Effect.provide(layerFor(client))); +}); + +it.effect("stops init before writing when the registry is unavailable", () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 503 }), + ); + }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-init-failure-", + }); + const projectRoot = path.join(directory, "demo"); + yield* Effect.flip( + runCommand(["init", "demo", "--yes", "--root", directory]), + ); + assert.strictEqual(requests, 1); + assert.isFalse(yield* fs.exists(projectRoot)); + }).pipe(Effect.scoped, Effect.provide(layerFor(client))); +}); + +it.effect( + "keeps JSON stdout parseable when a later command uses stale data", + () => { + let requests = 0; + const stdout: Array = []; + const stderr: Array = []; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + requests === 1 + ? new Response(document, { + headers: { "content-type": "application/json" }, + }) + : new Response(null, { status: 503 }), + ); + }), + ); + const capturedConsole: Console.Console = Object.assign( + Object.create(globalThis.console), + { + log: (value: string) => { + stdout.push(value); + }, + error: (value: string) => { + stderr.push(value); + }, + }, + ); + return Effect.gen(function* () { + yield* runCommand(["schema"]); + yield* runCommand(["schema"]); + assert.strictEqual(requests, 2); + assert.strictEqual(stdout.length, 2); + assert.isObject( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Json))( + stdout[1] ?? "", + ), + ); + assert.include(stderr.join("\n"), "Using cached catalog from"); + }).pipe( + Effect.provide( + Layer.merge( + layerFor(client), + Layer.succeed(Console.Console, capturedConsole), + ), + ), + ); + }, +); + +it.effect( + "reports a malformed existing config before requesting the registry", + () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 503 }), + ); + }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-config-", + }); + yield* fs.writeFileString(path.join(directory, "stack.effect.json"), "{"); + const error = yield* Effect.flip( + runCommand([ + "add", + "--yes", + "--root", + directory, + "--target", + "package/demo:package-db-sqlite", + "--dry-run", + ]), + ); + assert.isTrue( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "MalformedConfigError", + ); + assert.strictEqual(requests, 0); + }).pipe(Effect.scoped, Effect.provide(layerFor(client))); + }, +); + +it.effect("parses planning stdin before requesting the registry", () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 503 }), + ); + }), + ); + return Effect.gen(function* () { + yield* Effect.flip(runCommand(["plan"])); + assert.strictEqual(requests, 0); + }).pipe( + Effect.provide( + Layer.merge( + layerFor(client), + Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode("{")) }), + ), + ), + ); +}); + +it.effect( + "reports missing planning configuration before requesting the registry", + () => { + let requests = 0; + const client = HttpClient.make((request) => + Effect.sync(() => { + requests++; + return HttpClientResponse.fromWeb( + request, + new Response(null, { status: 503 }), + ); + }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-missing-config-", + }); + const error = yield* Effect.flip( + runCommand(["plan", "--root", directory]), + ); + assert.include(String(error), "No config found"); + assert.strictEqual(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + layerFor(client), + Stdio.layerTest({ + stdin: Stream.make( + new TextEncoder().encode('{"selection":{"targets":[]}}'), + ), + }), + ), + ), + ); + }, +); diff --git a/apps/cli/src/service/CatalogProvider.ts b/apps/cli/src/service/CatalogProvider.ts new file mode 100644 index 00000000..aaf54ce9 --- /dev/null +++ b/apps/cli/src/service/CatalogProvider.ts @@ -0,0 +1,50 @@ +import { CatalogService } from "@repo/catalog"; +import { CatalogLoader, type CatalogLoadFailure } from "@repo/scaffold"; +import { Console, Context, DateTime, Effect, Layer } from "effect"; + +export const OFFICIAL_CATALOG_URL = + "https://stack-effect.lloydrichards.dev/registry/v1/catalog.json"; + +export class CatalogProvider extends Context.Service< + CatalogProvider, + { + readonly load: Effect.Effect< + typeof CatalogService.Service, + CatalogLoadFailure + >; + } +>()("CatalogProvider") { + static readonly official = Layer.effect( + this, + Effect.gen(function* () { + const loader = yield* CatalogLoader; + return { + load: loader + .load({ + sourceUrl: OFFICIAL_CATALOG_URL, + allowFinalizeScripts: true, + }) + .pipe( + Effect.tap(({ warning }) => + warning === undefined + ? Effect.void + : Console.error( + warning.kind === "stale" + ? `Using cached catalog from ${warning.sourceUrl}; last validated at ${DateTime.formatIso(DateTime.makeUnsafe(warning.lastValidatedAt))}.` + : `Catalog from ${warning.sourceUrl} is current, but it could not be cached (validated at ${DateTime.formatIso(DateTime.makeUnsafe(warning.lastValidatedAt))}).`, + ), + ), + Effect.map(({ catalog }) => catalog), + ), + }; + }), + ); + + static readonly authoring = Layer.effect( + this, + Effect.gen(function* () { + const catalog = yield* CatalogService; + return { load: Effect.succeed(catalog) }; + }), + ); +} diff --git a/apps/cli/src/service/ConfigureService.test.ts b/apps/cli/src/service/ConfigureService.test.ts new file mode 100644 index 00000000..0a36b364 --- /dev/null +++ b/apps/cli/src/service/ConfigureService.test.ts @@ -0,0 +1,45 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { STACK_CONFIG_SCHEMA_URL, StackConfig } from "@repo/domain/Scaffold"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { CONFIG_FILENAME, ConfigureService } from "./ConfigureService"; + +const testLayer = ConfigureService.layer.pipe( + Layer.provideMerge(NodeServices.layer), +); + +it.effect( + "round-trips optional editor metadata without adding it to old configs", + () => + Effect.gen(function* () { + const configure = yield* ConfigureService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-config-roundtrip-", + }); + const location = path.join(directory, CONFIG_FILENAME); + yield* fs.writeFileString( + location, + '{"name":"old","runtime":{"_tag":"bun"}}', + ); + const old = yield* configure.readConfig(directory); + assert.isUndefined(old.$schema); + yield* configure.writeConfig(directory, old); + const oldJson = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), + )(yield* fs.readFileString(location)); + assert.isUndefined(oldJson["$schema"]); + + const annotated = new StackConfig({ + name: old.name, + runtime: old.runtime, + $schema: STACK_CONFIG_SCHEMA_URL, + }); + yield* configure.writeConfig(directory, annotated); + assert.strictEqual( + (yield* configure.readConfig(directory)).$schema, + STACK_CONFIG_SCHEMA_URL, + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), +); diff --git a/apps/cli/src/service/ConfigureService.ts b/apps/cli/src/service/ConfigureService.ts index b707c2b7..41b86385 100644 --- a/apps/cli/src/service/ConfigureService.ts +++ b/apps/cli/src/service/ConfigureService.ts @@ -1,10 +1,38 @@ import { StackConfig } from "@repo/domain/Scaffold"; -import { Context, Effect, FileSystem, Layer, Schema } from "effect"; +import { Context, Data, Effect, FileSystem, Layer, Schema } from "effect"; export { StackConfig }; export const CONFIG_FILENAME = "stack.effect.json" as const; +export class MissingConfigError extends Data.TaggedError("MissingConfigError")<{ + readonly path: string; +}> { + override get message(): string { + return `No ${CONFIG_FILENAME} found at ${this.path}. Run 'stack-effect init' first.`; + } +} + +export class MalformedConfigError extends Data.TaggedError( + "MalformedConfigError", +)<{ + readonly path: string; + readonly detail: string; +}> { + override get message(): string { + return `Invalid ${CONFIG_FILENAME} at ${this.path}: ${this.detail}`; + } +} + +export class ConfigFileError extends Data.TaggedError("ConfigFileError")<{ + readonly path: string; + readonly detail: string; +}> { + override get message(): string { + return `Could not read ${CONFIG_FILENAME} at ${this.path}: ${this.detail}`; + } +} + export class ConfigureService extends Context.Service()( "ConfigureService", { @@ -15,32 +43,40 @@ export class ConfigureService extends Context.Service()( const readConfig = (repoRoot: string) => Effect.gen(function* () { - const raw = yield* fs.readFileString(configPath(repoRoot)); + const location = configPath(repoRoot); + const raw = yield* fs.readFileString(location).pipe( + Effect.mapError((error) => + error.reason._tag === "NotFound" + ? new MissingConfigError({ path: location }) + : new ConfigFileError({ + path: location, + detail: error.message, + }), + ), + ); return yield* Schema.decodeEffect(Schema.fromJsonString(StackConfig))( raw, + ).pipe( + Effect.mapError( + (error) => + new MalformedConfigError({ + path: location, + detail: error.message, + }), + ), ); }); const writeConfig = (repoRoot: string, config: typeof StackConfig.Type) => Effect.gen(function* () { const json = yield* Schema.encodeEffect( - Schema.fromJsonString(StackConfig), + Schema.fromJsonString(StackConfig, { space: 2 }), )(config); yield* fs.makeDirectory(repoRoot, { recursive: true }); - yield* fs.writeFileString(configPath(repoRoot), json); + yield* fs.writeFileString(configPath(repoRoot), `${json}\n`); }); - const requireConfig = (repoRoot: string) => - readConfig(repoRoot).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* Effect.logError( - `No ${CONFIG_FILENAME} found. Run 'stack-effect init' first.`, - ); - return yield* Effect.die("Config not found"); - }), - ), - ); + const requireConfig = readConfig; return { configPath, readConfig, writeConfig, requireConfig } as const; }), diff --git a/apps/cli/src/service/FileCatalogCache.test.ts b/apps/cli/src/service/FileCatalogCache.test.ts new file mode 100644 index 00000000..d89f3c45 --- /dev/null +++ b/apps/cli/src/service/FileCatalogCache.test.ts @@ -0,0 +1,38 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { CatalogCache } from "@repo/scaffold"; +import { Effect, FileSystem } from "effect"; +import { fileCatalogCacheLayer } from "./FileCatalogCache"; + +it.effect("atomically replaces a complete cache entry outside a project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-effect-catalog-cache-", + }); + yield* Effect.gen(function* () { + const cache = yield* CatalogCache; + const sourceUrl = "https://catalog.example.test/catalog.json"; + const first = { + sourceUrl, + bytes: new TextEncoder().encode("first"), + digest: "first-digest", + etag: '"first"', + validatedAt: 1_000, + }; + yield* cache.write(first); + assert.deepEqual(yield* cache.read(sourceUrl), first); + + const second = { + ...first, + bytes: new TextEncoder().encode("second"), + digest: "second-digest", + etag: '"second"', + validatedAt: 2_000, + }; + yield* cache.write(second); + assert.deepEqual(yield* cache.read(sourceUrl), second); + assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); + }).pipe(Effect.provide(fileCatalogCacheLayer(directory))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/cli/src/service/FileCatalogCache.ts b/apps/cli/src/service/FileCatalogCache.ts new file mode 100644 index 00000000..22268d97 --- /dev/null +++ b/apps/cli/src/service/FileCatalogCache.ts @@ -0,0 +1,139 @@ +import { homedir } from "node:os"; +import { + CatalogCache, + CatalogCacheFailure, + type CatalogCacheEntry, + type CatalogCacheShape, +} from "@repo/scaffold"; +import { + Config, + Crypto, + Effect, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; + +const StoredEntry = Schema.Struct({ + sourceUrl: Schema.String, + bytes: Schema.String, + digest: Schema.String, + etag: Schema.optional(Schema.String), + lastModified: Schema.optional(Schema.String), + validatedAt: Schema.Finite, +}); +const StoredJson = Schema.fromJsonString(StoredEntry); + +const toHex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const defaultCacheRoot = Effect.fn("FileCatalogCache.defaultRoot")(function* ( + path: typeof Path.Path.Service, +) { + const userHome = homedir(); + const localAppData = yield* Config.option(Config.String("LOCALAPPDATA")); + const xdgCacheHome = yield* Config.option(Config.String("XDG_CACHE_HOME")); + const base = + process.platform === "win32" + ? Option.getOrElse(localAppData, () => + path.join(userHome, "AppData", "Local"), + ) + : Option.getOrElse(xdgCacheHome, () => + process.platform === "darwin" + ? path.join(userHome, "Library", "Caches") + : path.join(userHome, ".cache"), + ); + return path.join(base, "stack-effect", "registry"); +}); + +export const fileCatalogCacheLayer = (directory?: string) => + Layer.effect( + CatalogCache, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = directory ?? (yield* defaultCacheRoot(path)); + + const entryPath = Effect.fn("FileCatalogCache.entryPath")(function* ( + sourceUrl: string, + ) { + const hash = yield* crypto + .digest("SHA-256", new TextEncoder().encode(sourceUrl)) + .pipe( + Effect.mapError( + () => + new CatalogCacheFailure({ + message: "Could not locate catalog cache entry.", + }), + ), + ); + return path.join(root, `${toHex(hash)}.json`); + }); + + const fail = (message: string) => new CatalogCacheFailure({ message }); + + const read = Effect.fn("FileCatalogCache.read")(function* ( + sourceUrl: string, + ) { + const location = yield* entryPath(sourceUrl); + const json = yield* fs.readFileString(location).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.as(Effect.void, undefined), + ), + Effect.mapError(() => + fail(`Could not read catalog cache at ${location}.`), + ), + ); + if (json === undefined) return undefined; + const stored = yield* Schema.decodeEffect(StoredJson)(json).pipe( + Effect.mapError(() => fail(`Invalid catalog cache at ${location}.`)), + ); + return { + sourceUrl: stored.sourceUrl, + bytes: Uint8Array.from(Buffer.from(stored.bytes, "base64")), + digest: stored.digest, + validatedAt: stored.validatedAt, + ...(stored.etag === undefined ? {} : { etag: stored.etag }), + ...(stored.lastModified === undefined + ? {} + : { lastModified: stored.lastModified }), + } satisfies CatalogCacheEntry; + }); + + const write = Effect.fn("FileCatalogCache.write")(function* ( + entry: CatalogCacheEntry, + ) { + const location = yield* entryPath(entry.sourceUrl); + const nonce = yield* crypto + .randomBytes(8) + .pipe( + Effect.mapError(() => + fail("Could not create a catalog cache temporary file."), + ), + ); + const temporary = `${location}.${toHex(nonce)}.tmp`; + const json = yield* Schema.encodeEffect(StoredJson)({ + ...entry, + bytes: Buffer.from(entry.bytes).toString("base64"), + }).pipe( + Effect.mapError(() => fail("Could not encode catalog cache entry.")), + ); + yield* Effect.gen(function* () { + yield* fs.makeDirectory(root, { recursive: true }); + yield* fs.writeFileString(temporary, json); + yield* fs.rename(temporary, location); + }).pipe( + Effect.mapError(() => + fail(`Could not write catalog cache at ${location}.`), + ), + Effect.onExit(() => fs.remove(temporary).pipe(Effect.ignore)), + ); + }); + + return { read, write } satisfies CatalogCacheShape; + }), + ); diff --git a/apps/cli/src/services.ts b/apps/cli/src/services.ts index 998b6672..1ced0b32 100644 --- a/apps/cli/src/services.ts +++ b/apps/cli/src/services.ts @@ -1,3 +1,4 @@ +import { CatalogService } from "@repo/catalog"; import { ApplyPreviewService, ApplyService, @@ -8,7 +9,8 @@ import { RecipeService, ScaffoldFormatter, } from "@repo/scaffold"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; +import { CatalogProvider } from "./service/CatalogProvider"; import { ConfigureService } from "./service/ConfigureService"; import { ScaffoldPipeline } from "./service/ScaffoldPipeline"; @@ -24,3 +26,13 @@ export const StackEffectServicesLayer = Layer.mergeAll( RecipeService.layer, ScaffoldPipeline.layer, ); + +/** Construct services only after the parsed command asks for a catalog. */ +export const CommandServicesLayer = Layer.unwrap( + Effect.gen(function* () { + const catalog = yield* (yield* CatalogProvider).load; + return StackEffectServicesLayer.pipe( + Layer.provideMerge(Layer.succeed(CatalogService, catalog)), + ); + }), +);