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
5 changes: 5 additions & 0 deletions .changeset/proud-words-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stack-effect": minor
---

add nix flake module for init devenv
5 changes: 5 additions & 0 deletions .changeset/sixty-boxes-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stack-effect": minor
---

add devcontainer module for init devenv
25 changes: 25 additions & 0 deletions .docs/how-to/add-new-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 35 additions & 6 deletions apps/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,6 +20,7 @@ import { ScaffoldPipeline } from "../service/ScaffoldPipeline";

const buildInitSelection = (
config: typeof StackConfig.Type,
extraModules: ReadonlyArray<string> = [],
): typeof Selection.Type => {
// Collect unique module IDs from all category fields
const moduleIds = new Set<string>();
Expand All @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions packages/catalog/src/registry/content/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ Thumbs.db
coverage
playwright-report
test-results

# nix
result
result-*
.direnv/
`;

export const rootPackageJsonContents = `{
Expand Down Expand Up @@ -245,3 +250,78 @@ 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
`;

// -- 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"
]
}
}
}
`;
69 changes: 69 additions & 0 deletions packages/catalog/src/registry/modules/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
} from "@repo/domain/Catalog";
import {
biomeJsoncContents,
devcontainerJsonContents,
dprintJsonContents,
envrcContents,
flakeNixContents,
turboJsonContents,
vitestConfigContents,
} from "../content/init";
Expand Down Expand Up @@ -51,6 +54,70 @@ 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`",
],
};

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<typeof ModuleDefinition.Type> = [
{
id: ModuleId.make("turbo"),
Expand Down Expand Up @@ -288,4 +355,6 @@ export const initModules: ReadonlyArray<typeof ModuleDefinition.Type> = [
],
},
gitInitModule,
nixFlakeModule,
devcontainerModule,
];
1 change: 1 addition & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 4 additions & 2 deletions packages/domain/src/Blueprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});
});
1 change: 1 addition & 0 deletions packages/domain/src/Plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("@repo/domain Plan", () => {
operations: [
{
_tag: "json-pkg-exports",
fileType: "json",
entries: [
{
name: "./Api",
Expand Down
Loading
Loading