Skip to content
Merged
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 .changeset/spotty-olives-travel.md
Original file line number Diff line number Diff line change
@@ -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`.
40 changes: 40 additions & 0 deletions apps/cli/e2e/entrypoint.ts
Original file line number Diff line number Diff line change
@@ -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,
});
2 changes: 1 addition & 1 deletion apps/cli/e2e/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CLI>()("e2e/CLI", {
make: Effect.gen(function* () {
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);

Expand Down
57 changes: 55 additions & 2 deletions apps/cli/src/command.ts
Original file line number Diff line number Diff line change
@@ -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<string>) =>
Effect.gen(function* () {
const configure = yield* ConfigureService;
yield* configure.readConfig(Option.getOrElse(root, () => process.cwd()));
});

const inspectProjectConfig = (
name: Option.Option<string>,
root: Option.Option<string>,
) =>
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,
]),
);
2 changes: 2 additions & 0 deletions apps/cli/src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
runtimeFlag,
typescriptFlag,
} from "../flags";
import { CommandServicesLayer } from "../services";

const defaultWorkspaceRoot = "workspace/catalog-built";

Expand Down Expand Up @@ -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.",
Expand Down
9 changes: 6 additions & 3 deletions apps/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions apps/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 34 additions & 21 deletions apps/cli/src/commands/plan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PlanRequest } from "@repo/domain/Plan";
import { StackConfig } from "@repo/domain/Scaffold";
import {
BlueprintService,
FinalizeService,
Expand All @@ -10,6 +11,7 @@ import {
import {
Array as Arr,
Console,
Context,
Effect,
FileSystem,
Match,
Expand Down Expand Up @@ -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<string>) =>
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 },
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/docs/CliReference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
17 changes: 13 additions & 4 deletions apps/cli/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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")),
Expand All @@ -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),
);
Loading
Loading