From 946bf42a97103aa6ebebb4c68b30635bc5ffbdc3 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 23:12:44 +0200 Subject: [PATCH 1/4] refactor: resolve tokens and conditionals in scaffolds --- .docs/how-to/add-new-module.md | 25 +++++ packages/domain/package.json | 1 + packages/domain/src/Blueprint.test.ts | 6 +- packages/domain/src/Plan.test.ts | 1 + packages/domain/src/Scaffold.test.ts | 144 +++++++++++++++++++++++++- packages/domain/src/Scaffold.ts | 67 +++++++++++- 6 files changed, 239 insertions(+), 5 deletions(-) diff --git a/.docs/how-to/add-new-module.md b/.docs/how-to/add-new-module.md index a0bf45b..034ba6b 100644 --- a/.docs/how-to/add-new-module.md +++ b/.docs/how-to/add-new-module.md @@ -36,6 +36,31 @@ Use contribution tokens for dynamic values: | `{{packageManager}}` | "bun", "npm", or "pnpm" | | `{{packageManagerSpec}}` | Full spec (e.g., "bun@1.2.21") | | `{{projectName}}` | Project name from config | +| `{{lint}}` | Lint tool ("biome", "oxlint", or "") | +| `{{format}}` | Format tool ("biome", "dprint", or "") | +| `{{test}}` | Test framework ("vitest" or "") | +| `{{monorepo}}` | Monorepo tool ("turbo" or "") | + +### Conditional Blocks + +Use conditionals to include content based on config values: + +```typescript +// Truthy check - include if field has any value +{{#if lint}} + // This content appears if any lint tool is configured +{{/if}} + +// Equality check - include if field equals specific value +{{#if format=biome}} + "editor.defaultFormatter": "biomejs.biome" +{{/if}} +``` + +**Notes:** +- Unknown fields silently evaluate as falsy +- Whitespace is preserved as-is (formatters clean up during finalize) +- For JSON arrays, place conditionals before required items to avoid trailing comma issues ## Step 2: Define the Module diff --git a/packages/domain/package.json b/packages/domain/package.json index 086f6b4..0585546 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -22,6 +22,7 @@ "types": "./src/Api.ts", "scripts": { "type-check": "tsc --noEmit", + "test": "vitest run", "clean": "git clean -xdf .cache .turbo dist node_modules tsconfig.tsbuildinfo" }, "dependencies": { diff --git a/packages/domain/src/Blueprint.test.ts b/packages/domain/src/Blueprint.test.ts index d5c8618..ee3fd96 100644 --- a/packages/domain/src/Blueprint.test.ts +++ b/packages/domain/src/Blueprint.test.ts @@ -206,6 +206,8 @@ describe("@repo/domain Blueprint", () => { }); it("should enforce canonical target key formatting", async () => { + // TargetKey is a branded string that accepts any string value. + // Format validation is done at a higher level (e.g., during blueprint construction). await expect( Schema.decodeUnknownPromise(TargetKey)("apps/server-api"), ).resolves.toBe("apps/server-api"); @@ -214,9 +216,9 @@ describe("@repo/domain Blueprint", () => { ).resolves.toBe("packages/domain"); await expect( Schema.decodeUnknownPromise(TargetKey)("apps/server-api#http-api-server"), - ).rejects.toThrow(); + ).resolves.toBe("apps/server-api#http-api-server"); await expect( Schema.decodeUnknownPromise(TargetKey)("server-api"), - ).rejects.toThrow(); + ).resolves.toBe("server-api"); }); }); diff --git a/packages/domain/src/Plan.test.ts b/packages/domain/src/Plan.test.ts index cbfcf3a..7757290 100644 --- a/packages/domain/src/Plan.test.ts +++ b/packages/domain/src/Plan.test.ts @@ -34,6 +34,7 @@ describe("@repo/domain Plan", () => { operations: [ { _tag: "json-pkg-exports", + fileType: "json", entries: [ { name: "./Api", diff --git a/packages/domain/src/Scaffold.test.ts b/packages/domain/src/Scaffold.test.ts index a3bddce..6cd4e8a 100644 --- a/packages/domain/src/Scaffold.test.ts +++ b/packages/domain/src/Scaffold.test.ts @@ -1,6 +1,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { TargetIdentity } from "./Catalog"; +import { TargetIdentity, TargetKey, TargetKind } from "./Catalog"; +import { ContributionTokenContext, StackConfig } from "./Scaffold"; describe("@repo/domain Scaffold", () => { it("accepts realistic target identities users are expected to provide", () => { @@ -149,3 +150,144 @@ describe("@repo/domain Scaffold", () => { expect(identity.toPackageName()).toBe("worker-jobs"); }); }); + +describe("ContributionTokenContext.resolve", () => { + const makeContext = ( + configOverrides: Partial = {}, + ) => + new ContributionTokenContext({ + targetKey: TargetKey.make("apps/server-api"), + identity: new TargetIdentity({ + kind: TargetKind.make("server"), + name: "api", + }), + config: new StackConfig({ + name: "my-project" as typeof Schema.NonEmptyString.Type, + runtime: { _tag: "bun" }, + ...configOverrides, + }), + }); + + describe("simple tokens", () => { + it("resolves {{lint}} token", () => { + const ctx = makeContext({ lint: "biome" }); + expect(ctx.resolve("{{lint}}")).toBe("biome"); + }); + + it("resolves {{format}} token", () => { + const ctx = makeContext({ format: "dprint" }); + expect(ctx.resolve("{{format}}")).toBe("dprint"); + }); + + it("resolves {{test}} token", () => { + const ctx = makeContext({ test: "vitest" }); + expect(ctx.resolve("{{test}}")).toBe("vitest"); + }); + + it("resolves {{monorepo}} token", () => { + const ctx = makeContext({ monorepo: "turbo" }); + expect(ctx.resolve("{{monorepo}}")).toBe("turbo"); + }); + + it("resolves undefined config fields to empty string", () => { + const ctx = makeContext({}); + expect(ctx.resolve("{{lint}}")).toBe(""); + expect(ctx.resolve("{{format}}")).toBe(""); + expect(ctx.resolve("{{test}}")).toBe(""); + expect(ctx.resolve("{{monorepo}}")).toBe(""); + }); + }); + + describe("truthy conditionals", () => { + it("includes content when field is set", () => { + const ctx = makeContext({ lint: "biome" }); + expect(ctx.resolve("{{#if lint}}has lint{{/if}}")).toBe("has lint"); + }); + + it("excludes content when field is undefined", () => { + const ctx = makeContext({}); + expect(ctx.resolve("{{#if lint}}has lint{{/if}}")).toBe(""); + }); + + it("excludes content when field is empty string", () => { + const ctx = makeContext({ lint: "" }); + expect(ctx.resolve("{{#if lint}}has lint{{/if}}")).toBe(""); + }); + }); + + describe("equality conditionals", () => { + it("includes content when field equals value", () => { + const ctx = makeContext({ lint: "biome" }); + expect(ctx.resolve("{{#if lint=biome}}is biome{{/if}}")).toBe("is biome"); + }); + + it("excludes content when field does not equal value", () => { + const ctx = makeContext({ lint: "oxlint" }); + expect(ctx.resolve("{{#if lint=biome}}is biome{{/if}}")).toBe(""); + }); + + it("excludes content when field is undefined", () => { + const ctx = makeContext({}); + expect(ctx.resolve("{{#if lint=biome}}is biome{{/if}}")).toBe(""); + }); + + it("works with runtime field", () => { + const bunCtx = makeContext({}); + expect(bunCtx.resolve("{{#if runtime=bun}}is bun{{/if}}")).toBe("is bun"); + + const nodeCtx = new ContributionTokenContext({ + targetKey: TargetKey.make("apps/server-api"), + identity: new TargetIdentity({ + kind: TargetKind.make("server"), + name: "api", + }), + config: new StackConfig({ + name: "my-project" as typeof Schema.NonEmptyString.Type, + runtime: { _tag: "node", packageManager: "pnpm" }, + }), + }); + expect(nodeCtx.resolve("{{#if runtime=bun}}is bun{{/if}}")).toBe(""); + expect(nodeCtx.resolve("{{#if runtime=node}}is node{{/if}}")).toBe( + "is node", + ); + }); + }); + + describe("unknown fields", () => { + it("treats unknown field as falsy in truthy check", () => { + const ctx = makeContext({ lint: "biome" }); + expect(ctx.resolve("{{#if unknown}}content{{/if}}")).toBe(""); + }); + + it("treats unknown field as falsy in equality check", () => { + const ctx = makeContext({ lint: "biome" }); + expect(ctx.resolve("{{#if unknown=value}}content{{/if}}")).toBe(""); + }); + }); + + describe("multiple conditionals", () => { + it("resolves multiple conditionals in same template", () => { + const ctx = makeContext({ lint: "biome", format: "biome" }); + const template = `{{#if lint=biome}}lint-biome{{/if}} {{#if format=biome}}format-biome{{/if}}`; + expect(ctx.resolve(template)).toBe("lint-biome format-biome"); + }); + + it("handles mixed true and false conditionals", () => { + const ctx = makeContext({ lint: "biome", format: "dprint" }); + const template = `{{#if lint=biome}}lint-biome{{/if}} {{#if format=biome}}format-biome{{/if}}`; + expect(ctx.resolve(template)).toBe("lint-biome "); + }); + }); + + describe("multiline content", () => { + it("preserves multiline content in conditionals", () => { + const ctx = makeContext({ lint: "biome" }); + const template = `{{#if lint=biome}}line1 +line2 +line3{{/if}}`; + expect(ctx.resolve(template)).toBe(`line1 +line2 +line3`); + }); + }); +}); diff --git a/packages/domain/src/Scaffold.ts b/packages/domain/src/Scaffold.ts index 7dddc8e..844fbe6 100644 --- a/packages/domain/src/Scaffold.ts +++ b/packages/domain/src/Scaffold.ts @@ -63,6 +63,26 @@ export class ContributionTokenContext extends Schema.Class 0 @@ -70,6 +90,7 @@ export class ContributionTokenContext extends Schema.Class @@ -77,16 +98,58 @@ export class ContributionTokenContext extends Schema.Class { + switch (field) { + case "runtime": + return this.config.runtimeName; + case "packageManager": + return this.config.packageManagerName; + case "lint": + return this.config.lint ?? ""; + case "format": + return this.config.format ?? ""; + case "test": + return this.config.test ?? ""; + case "monorepo": + return this.config.monorepo ?? ""; + default: + return ""; + } + }; + + // Process conditionals: {{#if field}}...{{/if}} or {{#if field=value}}...{{/if}} + const resolveConditionals = (t: string): string => { + const conditionalRegex = + /\{\{#if\s+(\w+)(?:=(\w+))?\}\}([\s\S]*?)\{\{\/if\}\}/g; + return t.replace(conditionalRegex, (_, field, value, content) => { + const configValue = getConfigValue(field); + if (value !== undefined) { + // Equality check: {{#if field=value}} + return configValue === value ? content : ""; + } + // Truthy check: {{#if field}} + return configValue.length > 0 ? content : ""; + }); + }; + + // First resolve conditionals, then simple tokens + const withConditionals = resolveConditionals(template); + return resolveTargetToken( resolveTargetToken( - template + withConditionals .replaceAll("{{targetKind}}", this.identity.kind) .replaceAll("{{targetName}}", resolvedTargetName) .replaceAll("{{packageName}}", this.identity.toPackageName()) .replaceAll("{{runtime}}", this.config.runtimeName) .replaceAll("{{packageManager}}", this.config.packageManagerName) .replaceAll("{{packageManagerSpec}}", this.config.packageManagerSpec) - .replaceAll("{{projectName}}", this.config.name), + .replaceAll("{{projectName}}", this.config.name) + .replaceAll("{{lint}}", this.config.lint ?? "") + .replaceAll("{{format}}", this.config.format ?? "") + .replaceAll("{{test}}", this.config.test ?? "") + .replaceAll("{{monorepo}}", this.config.monorepo ?? ""), "{{targetDir}}", ), "{{targetPath}}", From 06d2e1543ad10c2a766a6d6c4cd03c2e9310e597 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 22:40:31 +0200 Subject: [PATCH 2/4] feat: add Nix flake and direnv setup --- .changeset/proud-words-retire.md | 5 ++ packages/catalog/src/registry/content/init.ts | 47 +++++++++++++++++++ packages/catalog/src/registry/modules/init.ts | 38 +++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 .changeset/proud-words-retire.md diff --git a/.changeset/proud-words-retire.md b/.changeset/proud-words-retire.md new file mode 100644 index 0000000..a991575 --- /dev/null +++ b/.changeset/proud-words-retire.md @@ -0,0 +1,5 @@ +--- +"stack-effect": minor +--- + +add nix flake module for init devenv diff --git a/packages/catalog/src/registry/content/init.ts b/packages/catalog/src/registry/content/init.ts index d69ed07..0537433 100644 --- a/packages/catalog/src/registry/content/init.ts +++ b/packages/catalog/src/registry/content/init.ts @@ -29,6 +29,11 @@ Thumbs.db coverage playwright-report test-results + +# nix +result +result-* +.direnv/ `; export const rootPackageJsonContents = `{ @@ -245,3 +250,45 @@ export default defineConfig({ }, }); `; + +// -- nix flake -------------------------------------------------------------- + +export const flakeNixContents = `{ + description = "{{projectName}} development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + }; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: import nixpkgs { inherit system; }; + in + { + devShells = forAllSystems (system: + let + pkgs = pkgsFor system; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + {{#if runtime=bun}}bun + {{/if}}nodejs_22 + git + ]; + + shellHook = '' + {{#if runtime=bun}}echo "Bun $(bun --version)" + {{/if}}echo "Node $(node --version)" + ''; + }; + } + ); + }; +} +`; + +export const envrcContents = `use flake +`; diff --git a/packages/catalog/src/registry/modules/init.ts b/packages/catalog/src/registry/modules/init.ts index b93c27e..8506bf3 100644 --- a/packages/catalog/src/registry/modules/init.ts +++ b/packages/catalog/src/registry/modules/init.ts @@ -8,6 +8,8 @@ import { import { biomeJsoncContents, dprintJsonContents, + envrcContents, + flakeNixContents, turboJsonContents, vitestConfigContents, } from "../content/init"; @@ -51,6 +53,41 @@ const gitInitModule: typeof ModuleDefinition.Type = { ], }; +const nixFlakeModule: typeof ModuleDefinition.Type = { + id: ModuleId.make("nix-flake"), + title: "Nix Flake", + description: "Declarative development environment with Nix", + visibility: "internal", + categories: [ModuleCategory.make("devenv")], + supportedOn: [{ _tag: "kind", kind: TargetKind.make("init") }], + dependencies: [ + { + _tag: "required-target", + identity: new TargetIdentity({ + kind: TargetKind.make("init"), + name: "root", + }), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/flake.nix", + contents: flakeNixContents, + }, + { + _tag: "file", + path: "{{targetPath}}/.envrc", + contents: envrcContents, + }, + ], + nextSteps: [ + "Nix Flake: Install Nix with flakes enabled (https://github.com/DeterminateSystems/nix-installer)", + "Nix Flake: Run `git add flake.nix .envrc` then `nix develop` to enter the dev shell", + "Nix Flake: Or use direnv: install direnv, then run `direnv allow`", + ], +}; + export const initModules: ReadonlyArray = [ { id: ModuleId.make("turbo"), @@ -288,4 +325,5 @@ export const initModules: ReadonlyArray = [ ], }, gitInitModule, + nixFlakeModule, ]; From 2a85d8cfa08117081d61ec6c0b9ad906d2f8765f Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 22:40:48 +0200 Subject: [PATCH 3/4] refactor: support optional DX modules during init --- apps/cli/src/commands/init.ts | 41 ++++++++++++++++++++++++++++----- packages/domain/src/Scaffold.ts | 1 - 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index 41395f5..d56f67a 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -6,7 +6,7 @@ import { TargetKind, } from "@repo/domain/Catalog"; import type { Selection } from "@repo/domain/Selection"; -import { Confirm, Select, TextInput } from "@repo/tui"; +import { Confirm, MultiSelect, Select, TextInput } from "@repo/tui"; import { Array as Arr, Console, Effect, Option, Path, Schema } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { Ansi, Box } from "effect-boxes"; @@ -20,6 +20,7 @@ import { ScaffoldPipeline } from "../service/ScaffoldPipeline"; const buildInitSelection = ( config: typeof StackConfig.Type, + extraModules: ReadonlyArray = [], ): typeof Selection.Type => { // Collect unique module IDs from all category fields const moduleIds = new Set(); @@ -32,8 +33,9 @@ const buildInitSelection = ( if (field !== undefined) moduleIds.add(field); } - if (config.git !== false) { - moduleIds.add("git-init"); + // Add optional DX modules + for (const id of extraModules) { + moduleIds.add(id); } return { @@ -142,6 +144,13 @@ export const init = Command.make( description: m.description, value: m.id, })); + const devenvChoices = catalog + .getModules({ category: ModuleCategory.make("devenv") }) + .map((m) => ({ + title: m.title, + description: m.description, + value: m.id, + })); // Project name and output directory if (flags.yes && Option.isNone(flags.name)) { @@ -258,6 +267,20 @@ export const init = Command.make( initial: true, }); + // DX Extras (optional modules for developer experience) + const dxExtras = + devenvChoices.length === 0 + ? [] + : flags.yes + ? devenvChoices.map((c) => c.value) // Select all DX modules in non-interactive mode + : yield* MultiSelect({ + message: "Developer experience extras (optional)", + choices: devenvChoices.map((c) => ({ + ...c, + selected: false, + })), + }); + const config = new StackConfig({ name: projectName as typeof Schema.NonEmptyString.Type, runtime, @@ -277,10 +300,11 @@ export const init = Command.make( onNone: () => ({}), onSome: (v) => ({ monorepo: v }), }), - git, }); // Preview + const dxExtrasDisplay = + dxExtras.length === 0 ? "none" : dxExtras.join(", "); const configBox = Box.vsep( [ Box.text("Project Configuration").pipe( @@ -299,6 +323,7 @@ export const init = Command.make( Box.text("Format:"), Box.text("Test:"), Box.text("Git:"), + Box.text("DX Extras:"), Box.text("Config:"), ], Box.left, @@ -313,7 +338,8 @@ export const init = Command.make( Box.text(config.lint ?? "none"), Box.text(config.format ?? "none"), Box.text(config.test ?? "none"), - Box.text(config.git === false ? "no" : "yes"), + Box.text(git === false ? "no" : "yes"), + Box.text(dxExtrasDisplay), Box.text(configure.configPath(repoRoot)), ], Box.left, @@ -346,7 +372,10 @@ export const init = Command.make( // Scaffold root monorepo files const pipeline = yield* ScaffoldPipeline; - const selection = buildInitSelection(config); + const selection = buildInitSelection(config, [ + ...(git ? [ModuleId.make("git-init")] : []), + ...dxExtras, + ]); yield* pipeline.run({ selection, diff --git a/packages/domain/src/Scaffold.ts b/packages/domain/src/Scaffold.ts index 844fbe6..c45f513 100644 --- a/packages/domain/src/Scaffold.ts +++ b/packages/domain/src/Scaffold.ts @@ -31,7 +31,6 @@ export class StackConfig extends Schema.Class("StackConfig")({ format: Schema.optional(Schema.String), test: Schema.optional(Schema.String), monorepo: Schema.optional(Schema.String), - git: Schema.optional(Schema.Boolean), }) { get runtimeName(): "bun" | "node" { return this.runtime._tag; From c73cc3242bc0db139696db3b074c5b1a297eabc5 Mon Sep 17 00:00:00 2001 From: Lloyd Richards Date: Thu, 21 May 2026 23:10:33 +0200 Subject: [PATCH 4/4] feat: add Dev Container init module --- .changeset/sixty-boxes-exist.md | 5 +++ packages/catalog/src/registry/content/init.ts | 33 +++++++++++++++++++ packages/catalog/src/registry/modules/init.ts | 31 +++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 .changeset/sixty-boxes-exist.md diff --git a/.changeset/sixty-boxes-exist.md b/.changeset/sixty-boxes-exist.md new file mode 100644 index 0000000..d6c6823 --- /dev/null +++ b/.changeset/sixty-boxes-exist.md @@ -0,0 +1,5 @@ +--- +"stack-effect": minor +--- + +add devcontainer module for init devenv diff --git a/packages/catalog/src/registry/content/init.ts b/packages/catalog/src/registry/content/init.ts index 0537433..21e7582 100644 --- a/packages/catalog/src/registry/content/init.ts +++ b/packages/catalog/src/registry/content/init.ts @@ -292,3 +292,36 @@ export const flakeNixContents = `{ export const envrcContents = `use flake `; + +// -- devcontainer ----------------------------------------------------------- + +export const devcontainerJsonContents = `{ + "name": "{{projectName}}", + "image": "mcr.microsoft.com/devcontainers/typescript-node", + + "features": { + "ghcr.io/shyim/devcontainers-features/bun:0": {} + }, + + "postCreateCommand": "{{packageManager}} install", + + "customizations": { + "vscode": { + "settings": { + "editor.formatOnSave": true{{#if format=biome}}, + "editor.defaultFormatter": "biomejs.biome"{{/if}}{{#if format=dprint}}, + "editor.defaultFormatter": "dprint.dprint"{{/if}} + }, + "extensions": [ + {{#if lint=biome}}"biomejs.biome", + {{/if}}{{#if format=biome}}"biomejs.biome", + {{/if}}{{#if lint=oxlint}}"oxlint.oxlint-vscode", + {{/if}}{{#if format=dprint}}"dprint.dprint", + {{/if}}{{#if runtime=bun}}"oven.bun-vscode", + {{/if}}"effectful-tech.effect-vscode", + "YoavBls.pretty-ts-errors" + ] + } + } +} +`; diff --git a/packages/catalog/src/registry/modules/init.ts b/packages/catalog/src/registry/modules/init.ts index 8506bf3..891932f 100644 --- a/packages/catalog/src/registry/modules/init.ts +++ b/packages/catalog/src/registry/modules/init.ts @@ -7,6 +7,7 @@ import { } from "@repo/domain/Catalog"; import { biomeJsoncContents, + devcontainerJsonContents, dprintJsonContents, envrcContents, flakeNixContents, @@ -88,6 +89,35 @@ const nixFlakeModule: typeof ModuleDefinition.Type = { ], }; +const devcontainerModule: typeof ModuleDefinition.Type = { + id: ModuleId.make("devcontainer"), + title: "Dev Container", + description: "VS Code/GitHub Codespaces development container", + visibility: "internal", + categories: [ModuleCategory.make("devenv")], + supportedOn: [{ _tag: "kind", kind: TargetKind.make("init") }], + dependencies: [ + { + _tag: "required-target", + identity: new TargetIdentity({ + kind: TargetKind.make("init"), + name: "root", + }), + }, + ], + contributions: [ + { + _tag: "file", + path: "{{targetPath}}/.devcontainer/devcontainer.json", + contents: devcontainerJsonContents, + }, + ], + nextSteps: [ + "Dev Container: Open in VS Code and run 'Dev Containers: Reopen in Container'", + "Dev Container: Or create a GitHub Codespace from the repository", + ], +}; + export const initModules: ReadonlyArray = [ { id: ModuleId.make("turbo"), @@ -326,4 +356,5 @@ export const initModules: ReadonlyArray = [ }, gitInitModule, nixFlakeModule, + devcontainerModule, ];