diff --git a/.changeset/deno-runtime.md b/.changeset/deno-runtime.md new file mode 100644 index 00000000..7ddf8538 --- /dev/null +++ b/.changeset/deno-runtime.md @@ -0,0 +1,5 @@ +--- +"stack-effect": minor +--- + +Scaffold Deno projects with Deno-managed dependencies, tasks, and standalone executable builds across supported targets and modules. Turbo workspace generation remains unavailable with Deno. diff --git a/apps/cli/e2e/deno.test.ts b/apps/cli/e2e/deno.test.ts new file mode 100644 index 00000000..9b5cf711 --- /dev/null +++ b/apps/cli/e2e/deno.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, layer } from "@effect/vitest"; +import { Effect } from "effect"; +import { CLI } from "./harness"; + +describe("Deno generated project", () => { + layer(CLI.layer)("create", (it) => { + it.effect("should plan a Deno SQLite package", () => + Effect.gen(function* () { + const cli = yield* CLI; + + yield* cli.run( + "create", + "deno-sqlite", + "--yes", + "--no-git", + "--dry-run", + "--runtime", + "deno", + "--target", + "package/db:package-db-sqlite", + "--root", + cli.workdir, + ); + + yield* cli.expectExitCode(0); + yield* cli.expectFileNotExists("deno-sqlite/stack.effect.json"); + yield* cli.expectFileNotExists("deno-sqlite/package.json"); + }), + ); + + it.effect( + "should install Git hooks after initializing the repository", + () => + Effect.gen(function* () { + const cli = yield* CLI; + + yield* cli.run( + "create", + "deno-hooks", + "--yes", + "--trust", + "--runtime", + "deno", + "--target", + "workspace/deno-hooks:workspace-devenv-husky", + "--root", + cli.workdir, + ); + yield* cli.expectExitCode(0); + + yield* cli.withinProject("deno-hooks", function* (project) { + const hooksPath = yield* project.exec( + "git", + "config", + "--get", + "core.hooksPath", + ); + expect(hooksPath.exitCode).toBe(0); + expect(hooksPath.stdout.trim()).toBe(".husky/_"); + }); + }), + { timeout: 120_000 }, + ); + + it.effect( + "should install, check, test, build, run CLI, and serve HTTP", + () => + Effect.gen(function* () { + const cli = yield* CLI; + + yield* cli.run( + "create", + "deno-app", + "--yes", + "--trust", + "--runtime", + "deno", + "--target", + "server/api:server-http-api,server-http-api-todos", + "--target", + "package/db:package-db-sqlite", + "--target", + "server-mcp/mcp:mcp-tools", + "--target", + "cli/app:cli-command-hello", + "--root", + cli.workdir, + ); + yield* cli.expectExitCode(0); + + yield* cli.withinProject("deno-app", function* (project) { + yield* project.expectFileExists(".git/HEAD"); + yield* project.expectFileContaining("deno.json", '"dev:all":'); + yield* project.expectFileContaining( + "apps/server-api/src/index.ts", + 'from "@effect/platform-deno"', + ); + yield* project.expectFileContaining( + "apps/server-mcp-mcp/src/index.ts", + 'from "@effect/platform-deno"', + ); + yield* project.expectFileContaining( + "apps/cli-app/src/index.ts", + 'from "@effect/platform-deno"', + ); + yield* project.expectCommandSucceeds( + "Type-check", + "deno", + "task", + "type-check:all", + ); + yield* project.expectCommandSucceeds( + "Tests", + "deno", + "task", + "test:all", + ); + yield* project.expectCommandSucceeds( + "Build", + "deno", + "task", + "build:all", + ); + + const hello = yield* project.exec( + "apps/cli-app/dist/cli-app", + "hello", + "Codex", + ); + expect(hello.exitCode).toBe(0); + expect(hello.stdout).toContain("Hello, Codex!"); + + yield* project.writeFile( + "smoke-server.sh", + `#!/bin/sh +set -eu +PORT=$((20000 + $$ % 20000)) +HOST=127.0.0.1 PORT="$PORT" DATABASE_FILE=./data/app.sqlite apps/server-api/dist/server-api >server.log 2>&1 & +server_pid=$! +trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT +for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do + if curl -fsS "http://127.0.0.1:$PORT/" >server-response.txt 2>/dev/null; then + grep -q '"Hello Effect!"' server-response.txt + curl -fsS "http://127.0.0.1:$PORT/todos" -H 'Content-Type: application/json' --data '{"title":"Deno task"}' >todo-created.json + grep -q '"title":"Deno task"' todo-created.json + curl -fsS "http://127.0.0.1:$PORT/todos" >todo-list.json + grep -q '"title":"Deno task"' todo-list.json + exit 0 + fi + sleep 0.25 +done +cat server.log +exit 1 +`, + ); + yield* project.expectCommandSucceeds( + "HTTP and shutdown", + "sh", + "smoke-server.sh", + ); + + yield* project.writeFile( + "smoke-mcp.sh", + `#!/bin/sh +set -eu +PORT=$((40000 + $$ % 20000)) +MCP_HOST=127.0.0.1 MCP_PORT="$PORT" apps/server-mcp-mcp/dist/server-mcp-mcp >mcp.log 2>&1 & +server_pid=$! +trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT +for attempt in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do + if curl -fsS "http://127.0.0.1:$PORT/mcp" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' >mcp-response.txt 2>/dev/null; then + grep -q '"protocolVersion":"2025-06-18"' mcp-response.txt + exit 0 + fi + sleep 0.25 +done +cat mcp.log +exit 1 +`, + ); + yield* project.expectCommandSucceeds( + "MCP initialization and shutdown", + "sh", + "smoke-mcp.sh", + ); + }); + }), + { timeout: 300_000 }, + ); + }); +}); diff --git a/apps/cli/src/commands/catalog.ts b/apps/cli/src/commands/catalog.ts index 8fe786c8..5169f02f 100644 --- a/apps/cli/src/commands/catalog.ts +++ b/apps/cli/src/commands/catalog.ts @@ -14,6 +14,7 @@ import { ApplyService, BlueprintService, ContributionResolver, + defaultsForRuntime, FinalizeService, PlanService, parseRecipeTargetSpecs, @@ -44,6 +45,7 @@ import { monorepoFlag, recipeTargetFlag, rootFlag, + runtimeFlag, typescriptFlag, } from "../flags"; @@ -506,20 +508,21 @@ const linkWorkspacePackages = Effect.fn("catalog.workspace.linkPackages")( yield* Effect.forEach( packageNames, (packageName) => - fs - .symlink( - path.join(packagesRoot, packageName), - path.join(scopeRoot, packageName), - ) - .pipe( - Effect.mapError( - (error) => - new WorkspaceCommandFailed({ - command: `link @repo/${packageName}`, - cause: error, - }), - ), - ), + Effect.gen(function* () { + const linkPath = path.join(scopeRoot, packageName); + if (yield* fs.exists(linkPath)) return; + yield* fs + .symlink(path.join(packagesRoot, packageName), linkPath) + .pipe( + Effect.mapError( + (error) => + new WorkspaceCommandFailed({ + command: `link @repo/${packageName}`, + cause: error, + }), + ), + ); + }), { concurrency: 1 }, ); }, @@ -572,6 +575,7 @@ const reset = Command.make( format: formatFlag, root: rootFlag, target: recipeTargetFlag, + runtime: runtimeFlag, typescript: typescriptFlag, monorepo: monorepoFlag, }, @@ -580,6 +584,11 @@ const reset = Command.make( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const defaults = yield* StackConfigDefaults; + const runtime = Option.getOrElse( + flags.runtime, + () => defaults.runtimeName, + ); + const runtimeDefaults = defaultsForRuntime(defaults, runtime); const repoRoot = path.resolve( Option.getOrElse(flags.root, () => defaultWorkspaceRoot), ); @@ -595,20 +604,26 @@ const reset = Command.make( const config = new StackConfig({ name: "catalog-built" as typeof Schema.NonEmptyString.Type, - runtime: defaults.runtime, + runtime: + runtime === "node" + ? { _tag: "node", packageManager: "pnpm" } + : { _tag: runtime }, typescript: Option.getOrElse( flags.typescript, - () => defaults.typescriptVersion, + () => runtimeDefaults.typescript, + ), + lint: runtimeDefaults.lint, + format: Option.getOrElse(flags.format, () => runtimeDefaults.format), + test: runtimeDefaults.test, + monorepo: Option.getOrElse( + flags.monorepo, + () => runtimeDefaults.monorepo, ), - lint: defaults.lint, - format: Option.getOrElse(flags.format, () => defaults.format), - test: defaults.test, - monorepo: Option.getOrElse(flags.monorepo, () => defaults.monorepo), }); const selection = yield* buildWorkspaceSelection(config, flags.target); const blueprintService = yield* BlueprintService; - const blueprint = yield* blueprintService.resolve(selection); + const blueprint = yield* blueprintService.resolve(selection, config); const planService = yield* PlanService; const plan = yield* planService.build({ blueprint, repoRoot, config }); const applyService = yield* ApplyService; @@ -681,6 +696,18 @@ const validate = Command.make("validate", { root: rootFlag }, (flags) => const repoRoot = path.resolve( Option.getOrElse(flags.root, () => defaultWorkspaceRoot), ); + const fs = yield* FileSystem.FileSystem; + const denoWorkspace = yield* fs + .exists(path.join(repoRoot, "deno.json")) + .pipe(Effect.orElseSucceed(() => false)); + if (denoWorkspace) { + yield* Effect.forEach( + ["type-check:all", "test:all", "build:all"], + (task) => runValidationCommand(repoRoot, "deno", ["task", task]), + { concurrency: 1 }, + ); + return; + } yield* Effect.forEach( [ ["run", "format:check"], diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 1f6b211d..d94ffc8c 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -1,7 +1,16 @@ import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; import type { RecipeSpec, RecipeTargetSpec } from "@repo/domain/Recipe"; -import { StackConfig } from "@repo/domain/Scaffold"; -import { RecipeService, StackConfigDefaults } from "@repo/scaffold"; +import { + makeRuntime, + runtimeForPackageManager, + StackConfig, +} from "@repo/domain/Scaffold"; +import { + BlueprintService, + defaultsForRuntime, + RecipeService, + StackConfigDefaults, +} from "@repo/scaffold"; import { Array as Arr, Console, Effect, Option, Schema } from "effect"; import { Command } from "effect/unstable/cli"; import { @@ -31,8 +40,8 @@ const validateRuntimeOptions = Effect.fn("create.validateRuntimeOptions")( runtime, packageManager, }: { - readonly runtime: Option.Option<"bun" | "node">; - readonly packageManager: Option.Option<"bun" | "pnpm" | "npm">; + readonly runtime: Option.Option<"bun" | "deno" | "node">; + readonly packageManager: Option.Option<"bun" | "deno" | "pnpm" | "npm">; }) { if ( Option.isSome(runtime) && @@ -45,14 +54,25 @@ const validateRuntimeOptions = Effect.fn("create.validateRuntimeOptions")( ); } + if ( + Option.isSome(runtime) && + runtime.value === "deno" && + Option.isSome(packageManager) && + packageManager.value !== "deno" + ) { + return yield* Effect.fail( + `Invalid create options: --runtime deno conflicts with --package-manager ${packageManager.value}.`, + ); + } + if ( Option.isSome(runtime) && runtime.value === "node" && Option.isSome(packageManager) && - packageManager.value === "bun" + (packageManager.value === "bun" || packageManager.value === "deno") ) { return yield* Effect.fail( - "Invalid create options: --runtime node conflicts with --package-manager bun.", + `Invalid create options: --runtime node conflicts with --package-manager ${packageManager.value}.`, ); } }, @@ -70,8 +90,8 @@ const buildConfig = ({ defaults, }: { readonly projectName: string; - readonly runtime: Option.Option<"bun" | "node">; - readonly packageManager: Option.Option<"bun" | "pnpm" | "npm">; + readonly runtime: Option.Option<"bun" | "deno" | "node">; + readonly packageManager: Option.Option<"bun" | "deno" | "pnpm" | "npm">; readonly typescript: Option.Option<"6" | "7">; readonly monorepo: Option.Option; readonly lint: Option.Option; @@ -83,27 +103,20 @@ const buildConfig = ({ packageManager, () => defaults.packageManagerName, ); - const runtimeName = Option.getOrElse( - runtime, - () => (packageManagerName === "bun" ? "bun" : "node") as "bun" | "node", + const runtimeName = Option.getOrElse(runtime, () => + runtimeForPackageManager(packageManagerName), ); - const runtimeConfig = - runtimeName === "bun" - ? ({ _tag: "bun" } as const) - : ({ - _tag: "node", - packageManager: - packageManagerName === "bun" ? "pnpm" : packageManagerName, - } as const); + const runtimeConfig = makeRuntime(runtimeName, packageManagerName); + const runtimeDefaults = defaultsForRuntime(defaults, runtimeName); return new StackConfig({ name: projectName as typeof Schema.NonEmptyString.Type, runtime: runtimeConfig, - typescript: Option.getOrElse(typescript, () => defaults.typescriptVersion), - monorepo: Option.getOrElse(monorepo, () => defaults.monorepo), - lint: Option.getOrElse(lint, () => defaults.lint), - format: Option.getOrElse(format, () => defaults.format), - test: Option.getOrElse(test, () => defaults.test), + typescript: Option.getOrElse(typescript, () => runtimeDefaults.typescript), + monorepo: Option.getOrElse(monorepo, () => runtimeDefaults.monorepo), + lint: Option.getOrElse(lint, () => runtimeDefaults.lint), + format: Option.getOrElse(format, () => runtimeDefaults.format), + test: Option.getOrElse(test, () => runtimeDefaults.test), }); }; @@ -204,6 +217,8 @@ export const create = Command.make( config, providerStrategy: { _tag: "fail-on-ambiguous" }, }); + const blueprints = yield* BlueprintService; + yield* blueprints.resolve(selection, config); const createCommand = recipes.renderCreateCommand({ config, selection }); const existing = yield* configure diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index c0d44ef2..caeb78a8 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -5,7 +5,10 @@ import { TargetIdentity, TargetKind, } from "@repo/domain/Catalog"; +import { makeRuntime } from "@repo/domain/Scaffold"; import { + BlueprintService, + defaultsForRuntime, RecipeService, StackConfigDefaults, toWorkspaceToolValue, @@ -173,14 +176,16 @@ export const init = Command.make( message: "What runtime will you use?", choices: [ { title: "bun", value: "bun" as const }, + { title: "deno", value: "deno" as const }, { title: "node", value: "node" as const }, ], }); + const runtimeDefaults = defaultsForRuntime(defaults, runtimeChoice); const typescript = Option.isSome(flags.typescript) ? flags.typescript.value : flags.yes - ? defaults.typescriptVersion + ? runtimeDefaults.typescript : yield* Select({ message: "What TypeScript version will you use?", choices: [ @@ -189,45 +194,41 @@ export const init = Command.make( ], }); - let runtime: typeof StackConfig.fields.runtime.Type; - if (runtimeChoice === "bun") { - runtime = { _tag: "bun" }; - } else { - const pm = flags.yes - ? ("pnpm" as const) - : yield* Select({ + const nodePackageManager = + runtimeChoice === "node" && !flags.yes + ? yield* Select({ message: "What package manager will you use?", choices: [ { title: "pnpm", value: "pnpm" as const }, { title: "npm", value: "npm" as const }, ], - }); - runtime = { _tag: "node", packageManager: pm }; - } + }) + : ("pnpm" as const); + const runtime = makeRuntime(runtimeChoice, nodePackageManager); const monorepo = yield* chooseOptionalTool( flags.yes, "What monorepo tool will you use?", monorepoChoices, - defaults.monorepo ?? "", + runtimeDefaults.monorepo ?? "", ); const lint = yield* chooseOptionalTool( flags.yes, "What will you use for linting?", lintChoices, - defaults.lint ?? "", + runtimeDefaults.lint ?? "", ); const format_ = yield* chooseOptionalTool( flags.yes, "What will you use for formatting?", formatChoices, - defaults.format ?? "", + runtimeDefaults.format ?? "", ); const test = yield* chooseOptionalTool( flags.yes, "What test framework will you use?", testChoices, - defaults.test ?? "", + runtimeDefaults.test ?? "", ); const git = flags.noGit @@ -337,11 +338,6 @@ export const init = Command.make( } } - if (!flags.dryRun) { - yield* configure.writeConfig(repoRoot, config); - yield* Console.log(`\nWritten ${CONFIG_FILENAME}`); - } - const pipeline = yield* ScaffoldPipeline; const recipe = yield* RecipeService; const explicitWorkspaceModules = [ @@ -369,6 +365,14 @@ export const init = Command.make( }, ); + const blueprints = yield* BlueprintService; + yield* blueprints.resolve(selection, config); + + if (!flags.dryRun) { + yield* configure.writeConfig(repoRoot, config); + yield* Console.log(`\nWritten ${CONFIG_FILENAME}`); + } + yield* pipeline.run({ selection, repoRoot, diff --git a/apps/cli/src/commands/plan.ts b/apps/cli/src/commands/plan.ts index 0d8c77a9..596e892b 100644 --- a/apps/cli/src/commands/plan.ts +++ b/apps/cli/src/commands/plan.ts @@ -88,7 +88,10 @@ export const plan = Command.make( } const blueprintService = yield* BlueprintService; - const blueprint = yield* blueprintService.resolve(input.selection); + const blueprint = yield* blueprintService.resolve( + input.selection, + config, + ); const recipes = yield* RecipeService; const createCommand = recipes.renderCreateCommand({ config, diff --git a/apps/cli/src/flags.ts b/apps/cli/src/flags.ts index 916dcd97..3765f69e 100644 --- a/apps/cli/src/flags.ts +++ b/apps/cli/src/flags.ts @@ -67,10 +67,11 @@ export const trustFlag = Flag.Boolean("trust").pipe( ), ); -export const runtimeFlag = Flag.Literals("runtime", ["bun", "node"]).pipe( - Flag.optional, - Flag.withDescription("Runtime to use"), -); +export const runtimeFlag = Flag.Literals("runtime", [ + "bun", + "deno", + "node", +]).pipe(Flag.optional, Flag.withDescription("Runtime to use")); export const typescriptFlag = Flag.Literals("typescript", ["6", "7"]).pipe( Flag.optional, @@ -79,12 +80,13 @@ export const typescriptFlag = Flag.Literals("typescript", ["6", "7"]).pipe( export const packageManagerFlag = Flag.Literals("package-manager", [ "bun", + "deno", "pnpm", "npm", ]).pipe( Flag.optional, Flag.withDescription( - "Override the default package manager. bun implies --runtime bun; pnpm/npm imply --runtime node.", + "Override the default package manager. bun and deno use their own package managers; pnpm/npm imply --runtime node.", ), ); diff --git a/apps/cli/src/service/RecipeService.test.ts b/apps/cli/src/service/RecipeService.test.ts index 59ba473f..5c652ab2 100644 --- a/apps/cli/src/service/RecipeService.test.ts +++ b/apps/cli/src/service/RecipeService.test.ts @@ -999,6 +999,32 @@ describe("RecipeService", () => { ); }).pipe(Effect.provide(TestLayer)), ); + + it.effect("should keep explicit Deno tooling in a copied command", () => + Effect.gen(function* () { + const service = yield* RecipeService; + const config = new StackConfig({ + ...testConfig, + runtime: { _tag: "deno" }, + typescript: "7", + monorepo: "vite-plus", + lint: "oxlint", + format: "oxfmt", + }); + const selection = yield* service.resolve( + { targets: [] }, + { + config, + providerStrategy: { _tag: "fail-on-ambiguous" }, + }, + ); + + assert.strictEqual( + service.renderCreateCommand({ config, selection }), + "deno run -A npm:stack-effect@latest create recipe-app --runtime deno --package-manager deno --typescript 7 --monorepo vite-plus --lint oxlint --format oxfmt --no-git", + ); + }).pipe(Effect.provide(TestLayer)), + ); }); describe("errors", () => { diff --git a/apps/cli/src/service/ScaffoldPipeline.ts b/apps/cli/src/service/ScaffoldPipeline.ts index ee02c189..26dfa74d 100644 --- a/apps/cli/src/service/ScaffoldPipeline.ts +++ b/apps/cli/src/service/ScaffoldPipeline.ts @@ -130,7 +130,7 @@ export class ScaffoldPipeline extends Context.Service()( const applyService = yield* ApplyService; const applyPreviewService = yield* ApplyPreviewService; - const blueprint = yield* blueprintService.resolve(selection); + const blueprint = yield* blueprintService.resolve(selection, config); const formattedBlueprint = yield* formatter.formatBlueprint(blueprint); @@ -373,7 +373,11 @@ export class ScaffoldPipeline extends Context.Service()( } allSteps.push(...nextSteps); if (!isWorkspaceInit) { - allSteps.push(`${config.packageManagerName} run dev`); + allSteps.push( + config.runtimeName === "deno" + ? "deno task dev:all" + : `${config.packageManagerName} run dev`, + ); } const preview = NextStepsPreview({ diff --git a/apps/docs/app/components/recipe-builder/form.ts b/apps/docs/app/components/recipe-builder/form.ts index e48cec65..70741dc1 100644 --- a/apps/docs/app/components/recipe-builder/form.ts +++ b/apps/docs/app/components/recipe-builder/form.ts @@ -7,6 +7,7 @@ import { Array as Arr, Context, Schema } from "effect"; const RuntimeSchema = Schema.TaggedUnion({ bun: {}, + deno: {}, node: { packageManager: Schema.Literals(["pnpm", "npm"]), }, diff --git a/apps/docs/app/components/recipe-builder/recipe-builder-url.ts b/apps/docs/app/components/recipe-builder/recipe-builder-url.ts index 27b7f9b7..365bd02a 100644 --- a/apps/docs/app/components/recipe-builder/recipe-builder-url.ts +++ b/apps/docs/app/components/recipe-builder/recipe-builder-url.ts @@ -1,4 +1,6 @@ +import { makeRuntime, runtimeForPackageManager } from "@repo/domain/Scaffold"; import { encodeRecipeTargetSpecs, RecipeTargetString } from "@repo/scaffold"; +import { defaultsForRuntime } from "@repo/scaffold/browser"; import { Array as Arr, Option, Schema } from "effect"; import { initialRecipeBuilderValues, @@ -13,8 +15,10 @@ const defaults = initialRecipeBuilderValues.config; const RecipeUrlSchema = Schema.Struct({ name: Schema.optional(Schema.String), target: Schema.Array(RecipeTargetString), - runtime: Schema.optional(Schema.Literals(["bun", "node"])), - packageManager: Schema.optional(Schema.Literals(["bun", "pnpm", "npm"])), + runtime: Schema.optional(Schema.Literals(["bun", "deno", "node"])), + packageManager: Schema.optional( + Schema.Literals(["bun", "deno", "pnpm", "npm"]), + ), typescript: Schema.optional(Schema.Literals(["6", "7"])), monorepo: Schema.optional(Schema.String), lint: Schema.optional(Schema.String), @@ -26,7 +30,11 @@ const RecipeUrlSchema = Schema.Struct({ (runtime === "bun" && packageManager !== undefined && packageManager !== "bun") || - (runtime === "node" && packageManager === "bun") + (runtime === "deno" && + packageManager !== undefined && + packageManager !== "deno") || + (runtime === "node" && + (packageManager === "bun" || packageManager === "deno")) ? "Runtime and package manager conflict." : undefined, ), @@ -84,21 +92,17 @@ const toInitialValues = ( recipe: typeof RecipeUrlSchema.Type, ): RecipeBuilderFormValues | undefined => { const packageManager = recipe.packageManager ?? "bun"; - const runtime = recipe.runtime ?? (packageManager === "bun" ? "bun" : "node"); + const runtime = recipe.runtime ?? runtimeForPackageManager(packageManager); + if (runtime === "deno" && recipe.monorepo === "turbo") return undefined; + const runtimeDefaults = defaultsForRuntime(defaults, runtime); const config = { name: recipe.name ?? defaults.name, - runtime: - runtime === "bun" - ? ({ _tag: "bun" } as const) - : ({ - _tag: "node" as const, - packageManager: packageManager === "npm" ? "npm" : "pnpm", - } as const), - typescript: recipe.typescript ?? defaults.typescript, - monorepo: recipe.monorepo ?? defaults.monorepo, - lint: recipe.lint ?? defaults.lint, - format: recipe.format ?? defaults.format, - test: recipe.test ?? defaults.test, + runtime: makeRuntime(runtime, packageManager), + typescript: recipe.typescript ?? runtimeDefaults.typescript, + monorepo: recipe.monorepo ?? runtimeDefaults.monorepo, + lint: recipe.lint ?? runtimeDefaults.lint, + format: recipe.format ?? runtimeDefaults.format, + test: recipe.test ?? runtimeDefaults.test, }; const targets = mergeTargets(recipe.target); const workspaceTargets = targets.filter( @@ -218,6 +222,10 @@ export const decodeRecipeBuilderUrl = ( export const encodeRecipeBuilderUrl = ( values: RecipeBuilderFormValues, ): URLSearchParams => { + const runtimeDefaults = defaultsForRuntime( + defaults, + values.config.runtime._tag, + ); const params = new URLSearchParams(); const previewInput = toRecipePreviewInput(values); const targets = previewInput.recipe.targets.flatMap((target) => @@ -237,17 +245,22 @@ export const encodeRecipeBuilderUrl = ( encodeRecipeTargetSpecs(targets) .sort() .forEach((target) => params.append("target", target)); - if (values.config.runtime._tag === "node") { - params.set("runtime", "node"); - params.set("package-manager", values.config.runtime.packageManager); + if (values.config.runtime._tag !== "bun") { + params.set("runtime", values.config.runtime._tag); + params.set( + "package-manager", + values.config.runtime._tag === "deno" + ? "deno" + : values.config.runtime.packageManager, + ); } - if (values.config.typescript !== defaults.typescript) { + if (values.config.typescript !== runtimeDefaults.typescript) { params.set("typescript", values.config.typescript ?? "6"); } (["monorepo", "lint", "format", "test"] as const).forEach((field) => { const value = values.config[field]; - if (value !== undefined && value !== defaults[field]) - params.set(field, value); + const defaultValue = runtimeDefaults[field]; + if (value !== undefined && value !== defaultValue) params.set(field, value); }); if (!values.gitEnabled) params.set("no-git", ""); return params; diff --git a/apps/docs/app/components/recipe-builder/recipe-builder.tsx b/apps/docs/app/components/recipe-builder/recipe-builder.tsx index 6cfc449a..dd48c065 100644 --- a/apps/docs/app/components/recipe-builder/recipe-builder.tsx +++ b/apps/docs/app/components/recipe-builder/recipe-builder.tsx @@ -63,7 +63,9 @@ function RecipeBuilderContent() { ), runtime: config.runtime._tag, package_manager: - config.runtime._tag === "node" ? config.runtime.packageManager : "bun", + config.runtime._tag === "node" + ? config.runtime.packageManager + : config.runtime._tag, file_count: preview?.files.length ?? 0, }; }; diff --git a/apps/docs/app/components/recipe-builder/stack-configurator.tsx b/apps/docs/app/components/recipe-builder/stack-configurator.tsx index 15974f17..6067bb2d 100644 --- a/apps/docs/app/components/recipe-builder/stack-configurator.tsx +++ b/apps/docs/app/components/recipe-builder/stack-configurator.tsx @@ -1,5 +1,7 @@ "use client"; +import { makeRuntime } from "@repo/domain/Scaffold"; +import { defaultsForRuntime } from "@repo/scaffold/browser"; import { useSelector } from "@tanstack/react-form"; import { String as Str } from "effect"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -26,6 +28,7 @@ import { import { Spinner } from "~/components/ui/spinner"; import { Toggle } from "~/components/ui/toggle"; import { ToggleGroup, ToggleGroupItem } from "~/components/ui/toggle-group"; +import { initialRecipeBuilderValues } from "./form"; import { useRecipeBuilderCatalog, useRecipeBuilderFormContext, @@ -54,7 +57,9 @@ export function StackConfigurator() { const choices = catalog?.configuration; const runtime = config.runtime._tag; const packageManager = - config.runtime._tag === "node" ? config.runtime.packageManager : "bun"; + config.runtime._tag === "node" + ? config.runtime.packageManager + : config.runtime._tag; const configure = (updates: Partial) => form.setFieldValue("config", (current) => ({ ...current, ...updates })); const updateTool = (field: ToolField, value: string) => @@ -123,18 +128,19 @@ export function StackConfigurator() { aria-labelledby="stack-runtime-label" value={[runtime]} variant="outline" - className="grid w-full grid-cols-2" + className="grid w-full grid-cols-3" onValueChange={(values) => { const value = values[0] ?? runtime; + const nextRuntime = + value === "bun" || value === "deno" ? value : "node"; configure({ - runtime: - value === "bun" - ? { _tag: "bun" } - : { - _tag: "node", - packageManager: - packageManager === "bun" ? "pnpm" : packageManager, - }, + runtime: makeRuntime(nextRuntime, packageManager), + ...(nextRuntime === "deno" + ? defaultsForRuntime( + initialRecipeBuilderValues.config, + "deno", + ) + : {}), }); }} > @@ -144,6 +150,9 @@ export function StackConfigurator() { Node + + Deno + @@ -152,14 +161,14 @@ export function StackConfigurator() { label="Package manager" value={packageManager} options={ - runtime === "bun" - ? [{ value: "bun", label: "Bun" }] + runtime !== "node" + ? [{ value: runtime, label: runtime === "deno" ? "Deno" : "Bun" }] : [ { value: "pnpm", label: "pnpm" }, { value: "npm", label: "npm" }, ] } - disabled={runtime === "bun"} + disabled={runtime !== "node"} onChange={(value) => form.setFieldValue("config", (current) => current.runtime._tag === "node" && @@ -200,7 +209,10 @@ export function StackConfigurator() { options={ choices?.[field].map((choice) => ({ value: choice.value, - label: choice.title, + label: choice.supportedRuntimes.includes(runtime) + ? choice.title + : `${choice.title} (unavailable with ${runtime})`, + disabled: !choice.supportedRuntimes.includes(runtime), })) ?? [] } disabled={choices === undefined} @@ -261,6 +273,7 @@ type ConfigurationSelectProps = { readonly options: ReadonlyArray<{ readonly value: string; readonly label: string; + readonly disabled?: boolean; }>; readonly disabled?: boolean; readonly onChange: (value: string) => void; @@ -274,10 +287,11 @@ function ConfigurationSelect({ disabled = false, onChange, }: ConfigurationSelectProps) { + const selectedOption = options.find((option) => option.value === value); const unavailable = !disabled && value.length > 0 && - !options.some((option) => option.value === value); + (selectedOption === undefined || selectedOption.disabled === true); return ( {label} @@ -299,7 +313,11 @@ function ConfigurationSelect({ {options.map((option) => ( - + {option.label} ))} diff --git a/apps/docs/app/content/reference/cli/init.mdx b/apps/docs/app/content/reference/cli/init.mdx index 31bef7cc..5b8ed181 100644 --- a/apps/docs/app/content/reference/cli/init.mdx +++ b/apps/docs/app/content/reference/cli/init.mdx @@ -24,7 +24,7 @@ stack-effect init [flags] [] | `--yes, -y` | Skip confirmation prompts (uses defaults where available) | | `--dry-run` | Preview changes without writing to disk | | `--show-files` | Include generated file contents in a dry-run preview | -| `--runtime choice` | Runtime to use (choices: bun, node) | +| `--runtime choice` | Runtime to use (choices: bun, deno, node) | | `--typescript choice` | TypeScript major version to configure (choices: 6, 7) | | `--no-git` | Skip git repository initialization | | `--trust` | Skip finalize script approval prompt and run all scripts | @@ -52,3 +52,9 @@ stack-effect init my-app --yes --runtime bun --no-git ## Guides Follow [Getting started](/getting-started) to scaffold your first project, or read [How Stack Effect changes your repository](/how-it-works) to understand previews, conflicts, approvals, and writes. + +## Deno projects + +Choose Deno with `--runtime deno`. The generated project uses Deno for dependency installation and workspace tasks. Run `deno task dev:all`, `deno task type-check:all`, `deno task test:all`, or `deno task build:all`. Server, MCP, and CLI builds use `deno compile` to produce standalone executables. + +The Deno catalog covers React and Foldkit clients, HTTP and MCP servers, CLI applications, shared packages, AI and chat, presence, and SQLite and PostgreSQL modules. TypeScript 6 and native Deno workspace tasks remain the defaults; you can select TypeScript 7, formatting and linting tools, Nx, or Vite+ explicitly. SQLite requires `DATABASE_FILE` when launching a compiled server from a different working directory. Turborepo currently rejects Deno as the project package manager. diff --git a/apps/docs/app/workers/recipe-builder/domain.ts b/apps/docs/app/workers/recipe-builder/domain.ts index 1792171c..c2fa9e2f 100644 --- a/apps/docs/app/workers/recipe-builder/domain.ts +++ b/apps/docs/app/workers/recipe-builder/domain.ts @@ -1,5 +1,6 @@ import { ModuleDefinition, + SupportedRuntime, TargetDefinition, TargetIdentity, } from "@repo/domain/Catalog"; @@ -14,6 +15,7 @@ const CatalogChoice = Schema.Struct({ title: ModuleDefinition.fields.title, description: ModuleDefinition.fields.description, value: Schema.String, + supportedRuntimes: Schema.Array(SupportedRuntime), }); export const CatalogModule = Schema.Struct({ 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 a81e7d03..710c9f04 100644 --- a/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts +++ b/apps/docs/app/workers/recipe-builder/recipe-builder.worker.ts @@ -22,6 +22,8 @@ const RecipeBuilderRpcHandlersLive = RecipeBuilderRpc.toLayer( .map((module) => ({ ...module, value: toWorkspaceToolValue(module.id), + supportedRuntimes: + module.supportedRuntimes ?? (["bun", "node"] as const), })); return RecipeBuilderRpc.of({ diff --git a/apps/docs/test/components/recipe-builder/recipe-builder-url.unit.test.ts b/apps/docs/test/components/recipe-builder/recipe-builder-url.unit.test.ts index 493d386a..601dac01 100644 --- a/apps/docs/test/components/recipe-builder/recipe-builder-url.unit.test.ts +++ b/apps/docs/test/components/recipe-builder/recipe-builder-url.unit.test.ts @@ -21,10 +21,89 @@ describe("recipe builder URL", () => { expect(decoded.initialValues.supportSelections).toEqual([]); }); + it("round trips Deno with its own package manager", () => { + const values = { + ...fullStackRecipeFixture, + config: { + ...fullStackRecipeFixture.config, + runtime: { _tag: "deno" as const }, + monorepo: undefined, + lint: undefined, + format: undefined, + }, + }; + const encoded = encodeRecipeBuilderUrl(values); + const decoded = decodeRecipeBuilderUrl(encoded); + + expect(encoded.get("runtime")).toBe("deno"); + expect(encoded.get("package-manager")).toBe("deno"); + expect(decoded.issue).toBeUndefined(); + expect(decoded.initialValues.config.runtime).toEqual({ _tag: "deno" }); + expect(decoded.initialValues.config.typescript).toBe("6"); + expect(decoded.initialValues.config.monorepo).toBeUndefined(); + expect(decoded.initialValues.config.lint).toBeUndefined(); + expect(decoded.initialValues.config.format).toBeUndefined(); + }); + + it("keeps explicit Deno tooling choices in a share URL", () => { + const values = { + ...fullStackRecipeFixture, + config: { + ...fullStackRecipeFixture.config, + runtime: { _tag: "deno" as const }, + typescript: "7" as const, + monorepo: "vite-plus" as const, + lint: "oxlint" as const, + format: "oxfmt" as const, + }, + }; + const encoded = encodeRecipeBuilderUrl(values); + const decoded = decodeRecipeBuilderUrl(encoded); + + expect(encoded.get("typescript")).toBe("7"); + expect(encoded.get("monorepo")).toBe("vite-plus"); + expect(encoded.get("lint")).toBe("oxlint"); + expect(encoded.get("format")).toBe("oxfmt"); + expect(decoded.issue).toBeUndefined(); + expect(decoded.initialValues.config.typescript).toBe("7"); + expect(decoded.initialValues.config.monorepo).toBe("vite-plus"); + expect(decoded.initialValues.config.lint).toBe("oxlint"); + expect(decoded.initialValues.config.format).toBe("oxfmt"); + }); + + it("infers Deno from a shared URL with only its package manager", () => { + const decoded = decodeRecipeBuilderUrl( + new URLSearchParams("package-manager=deno"), + ); + + expect(decoded.issue).toBeUndefined(); + expect(decoded.initialValues.config.runtime).toEqual({ _tag: "deno" }); + }); + + it("restores explicit Deno tooling choices", () => { + const decoded = decodeRecipeBuilderUrl( + new URLSearchParams( + "runtime=deno&package-manager=deno&typescript=7&monorepo=vite-plus&lint=oxlint&format=oxfmt", + ), + ); + + expect(decoded.issue).toBeUndefined(); + expect(decoded.initialValues.config).toMatchObject({ + runtime: { _tag: "deno" }, + typescript: "7", + monorepo: "vite-plus", + lint: "oxlint", + format: "oxfmt", + }); + }); + it("rejects malformed, unknown, and conflicting shared links without a partial restore", () => { [ "?target=server/api:", "?runtime=bun&package-manager=pnpm", + "?runtime=deno&package-manager=npm", + "?runtime=node&package-manager=deno", + "?runtime=deno&package-manager=deno&monorepo=turbo", "?runtime=node&runtime=bun&package-manager=pnpm", "?target=server/api:server-http-api,server-http-api", "?name=demo&utm_source=newsletter", diff --git a/apps/docs/test/components/recipe-builder/recipe-builder.browser.test.tsx b/apps/docs/test/components/recipe-builder/recipe-builder.browser.test.tsx index 121ee9bb..528a51dc 100644 --- a/apps/docs/test/components/recipe-builder/recipe-builder.browser.test.tsx +++ b/apps/docs/test/components/recipe-builder/recipe-builder.browser.test.tsx @@ -252,6 +252,54 @@ test("should keep Bun selected after rapidly changing the Node package manager", .not.toContain("package-manager="); }); +test("should expose Deno configuration choices", async () => { + await renderRecipeBuilder(); + + await page.getByRole("button", { name: "Deno" }).click(); + + await expect.element(page.getByLabelText("Package manager")).toBeDisabled(); + await expect + .element(page.getByLabelText("Package manager")) + .toHaveTextContent("Deno"); + await expect.element(page.getByLabelText("TypeScript")).toBeEnabled(); + await expect + .element(page.getByLabelText("TypeScript")) + .toHaveTextContent("TypeScript 6"); + await expect.element(page.getByLabelText("Monorepo")).toBeEnabled(); + await page.getByLabelText("Monorepo").click(); + await expect + .element( + page.getByRole("option", { + name: "Turborepo (unavailable with deno)", + }), + ) + .toBeDisabled(); + await expect.element(page.getByLabelText("Lint")).toBeEnabled(); + await expect.element(page.getByLabelText("Format")).toBeEnabled(); + await expect + .poll(() => page.getByLabelText("Recipe URL search").element().textContent) + .toContain("runtime=deno"); +}); + +test("should clear Turbo when switching a recipe to Deno", async () => { + await renderRecipeBuilder(); + + await page.getByLabelText("Monorepo").click(); + await page.getByRole("option", { name: "Turborepo" }).click(); + await expect + .element(page.getByLabelText("Monorepo")) + .toHaveTextContent("Turborepo"); + + await page.getByRole("button", { name: "Deno" }).click(); + + await expect + .element(page.getByLabelText("Monorepo")) + .toHaveTextContent("None"); + await expect + .poll(() => page.getByLabelText("Recipe URL search").element().textContent) + .not.toContain("monorepo=turbo"); +}); + test("should disable and clear Husky when Git is turned off", async () => { await renderRecipeBuilder(); diff --git a/apps/docs/test/components/recipe-builder/recipe-fixtures.ts b/apps/docs/test/components/recipe-builder/recipe-fixtures.ts index c75df635..802041e7 100644 --- a/apps/docs/test/components/recipe-builder/recipe-fixtures.ts +++ b/apps/docs/test/components/recipe-builder/recipe-fixtures.ts @@ -201,6 +201,7 @@ export const recipeCatalogFixture = Schema.decodeUnknownSync( title: "Turborepo", description: "Monorepo build orchestration with caching", value: "turbo", + supportedRuntimes: ["bun", "node"], }, ], lint: [ @@ -209,6 +210,7 @@ export const recipeCatalogFixture = Schema.decodeUnknownSync( title: "Biome", description: "Fast linter with recommended defaults", value: "biome", + supportedRuntimes: ["bun", "node", "deno"], }, ], format: [ @@ -217,6 +219,7 @@ export const recipeCatalogFixture = Schema.decodeUnknownSync( title: "Biome", description: "Fast formatter with recommended defaults", value: "biome", + supportedRuntimes: ["bun", "node", "deno"], }, ], test: [ @@ -225,6 +228,7 @@ export const recipeCatalogFixture = Schema.decodeUnknownSync( title: "Vitest", description: "Unit and integration testing framework", value: "vitest", + supportedRuntimes: ["bun", "node", "deno"], }, ], devenv: [ @@ -233,6 +237,7 @@ export const recipeCatalogFixture = Schema.decodeUnknownSync( title: "Husky + lint-staged", description: "Run staged-file format and lint tasks before each commit", value: "workspace-devenv-husky", + supportedRuntimes: ["bun", "node", "deno"], }, ], }, diff --git a/apps/docs/test/workers/recipe-builder.browser.test.ts b/apps/docs/test/workers/recipe-builder.browser.test.ts index a62d5000..96e9f5fc 100644 --- a/apps/docs/test/workers/recipe-builder.browser.test.ts +++ b/apps/docs/test/workers/recipe-builder.browser.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; import { TargetIdentity, TargetKey, TargetKind } from "@repo/domain/Catalog"; -import { Effect } from "effect"; +import { Cause, Effect, Exit, Option } from "effect"; import { AtomRegistry } from "effect/unstable/reactivity"; import { catalogAtom, previewAtom } from "../../app/atom/recipe-builder-atom"; import { toRecipePreviewInput } from "../../app/components/recipe-builder/form"; @@ -77,5 +77,50 @@ it.live( ), ); assert.isNotEmpty(result.catalog.configuration.monorepo); + const turbo = result.catalog.configuration.monorepo.find( + (choice) => choice.value === "turbo", + ); + assert.isDefined(turbo); + assert.deepEqual(turbo.supportedRuntimes, ["bun", "node"]); + }), +); + +it.live( + "should report an unsupported Deno module without stopping the preview worker", + () => + Effect.gen(function* () { + const invalid = yield* Effect.exit( + runAtom(previewAtom, { + targetIdentityKey: "deno-turbo", + input: toRecipePreviewInput({ + ...fullStackRecipeFixture, + config: { + ...fullStackRecipeFixture.config, + runtime: { _tag: "deno" }, + typescript: "6", + monorepo: "turbo", + lint: undefined, + format: undefined, + }, + }), + }), + ); + + assert.isTrue(Exit.isFailure(invalid)); + if (Exit.isFailure(invalid)) { + const failure = Cause.findErrorOption(invalid.cause).pipe( + Option.getOrUndefined, + ); + assert.match( + failure?.message ?? "", + /does not support module workspace-monorepo-turbo/u, + ); + } + + const valid = yield* runAtom(previewAtom, { + targetIdentityKey: "valid-after-deno-turbo", + input: toRecipePreviewInput(fullStackRecipeFixture), + }); + assert.include(valid.preview.command, "full-stack-app"); }), ); diff --git a/packages/catalog/src/registry/content/ai.ts b/packages/catalog/src/registry/content/ai.ts index f4f8589f..68e336ca 100644 --- a/packages/catalog/src/registry/content/ai.ts +++ b/packages/catalog/src/registry/content/ai.ts @@ -1072,11 +1072,7 @@ const stringifyJson = (value: unknown) => const stringifyValue = (value: unknown) => typeof value === "string" ? Effect.succeed(value) : stringifyJson(value); -const optionalNonEmpty = (value: string | void) => { - if (value === undefined) { - return {}; - } - +const optionalNonEmpty = (value: string) => { const input = String.trim(value); return String.isEmpty(input) ? {} : { input }; }; @@ -1101,7 +1097,7 @@ const toolStartInput = (part: ToolStart) => { } if (part.params === undefined) { - return Effect.void; + return Effect.succeed(""); } return stringifyValue(part.params); diff --git a/packages/catalog/src/registry/content/chat.ts b/packages/catalog/src/registry/content/chat.ts index 5bffbb95..611a26b9 100644 --- a/packages/catalog/src/registry/content/chat.ts +++ b/packages/catalog/src/registry/content/chat.ts @@ -190,7 +190,7 @@ import { ChatNotFoundError, GenerationInProgressError, } from "@repo/domain/ChatRpc"; -{{#if runtime=bun}}import { layer as PlatformCryptoLayer } from "@effect/platform-bun/BunCrypto";{{/if}}{{#if runtime=node}}import { layer as PlatformCryptoLayer } from "@effect/platform-node/NodeCrypto";{{/if}} +{{#if runtime=bun}}import { layer as PlatformCryptoLayer } from "@effect/platform-bun/BunCrypto";{{/if}}{{#if runtime=node}}import { layer as PlatformCryptoLayer } from "@effect/platform-node/NodeCrypto";{{/if}}{{#if runtime=deno}}import { layer as PlatformCryptoLayer } from "@effect/platform-deno/DenoCrypto";{{/if}} import { Context, Crypto, Effect, HashMap, Layer, Option, Ref } from "effect"; type ChatSession = { diff --git a/packages/catalog/src/registry/content/cli.ts b/packages/catalog/src/registry/content/cli.ts index 03687d0a..7dc58d96 100644 --- a/packages/catalog/src/registry/content/cli.ts +++ b/packages/catalog/src/registry/content/cli.ts @@ -4,16 +4,16 @@ export const cliPackageJsonContents = `{ "version": "0.0.0", "type": "module", "bin": { - "{{packageName}}": "dist/index.js" + "{{packageName}}": "{{#if runtime=deno}}dist/{{packageName}}{{/if}}{{#if runtime=bun}}dist/index.js{{/if}}{{#if runtime=node}}dist/index.js{{/if}}" }, "scripts": {}, "dependencies": { - "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}": "4.0.0-rc.108", + "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}{{#if runtime=deno}}@effect/platform-deno{{/if}}": "4.0.0-rc.108", "effect": "4.0.0-rc.108" }, "devDependencies": { "@repo/config-typescript": "{{workspaceDependency}}", - "{{#if runtime=bun}}@types/bun{{/if}}{{#if runtime=node}}@types/node{{/if}}": "{{#if runtime=bun}}^1.2.17{{/if}}{{#if runtime=node}}^24.0.0{{/if}}", + {{#if runtime=bun}}"@types/bun": "^1.2.17",{{/if}}{{#if runtime=node}}"@types/node": "^24.0.0",{{/if}} "vitest": "^4.1.4" } } @@ -25,7 +25,7 @@ export const cliTsconfigContents = `{ "rootDir": "../..", "outDir": "dist", "noEmit": true, - "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}"] + "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}{{#if runtime=deno}}deno", "node{{/if}}"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] @@ -41,7 +41,7 @@ export const cliTsconfigContents = `{ * * Additional subcommands are added by modules. */ -export const cliIndexContents = `{{#if runtime=bun}}import { BunRuntime, BunServices } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeRuntime, NodeServices } from "@effect/platform-node";{{/if}} +export const cliIndexContents = `{{#if runtime=bun}}import { BunRuntime, BunServices } from "@effect/platform-bun";{{/if}}{{#if runtime=deno}}import { DenoRuntime, DenoServices } from "@effect/platform-deno";{{/if}}{{#if runtime=node}}import { NodeRuntime, NodeServices } from "@effect/platform-node";{{/if}} import { Effect, Layer } from "effect"; import { Command } from "effect/unstable/cli"; @@ -51,13 +51,13 @@ const root = Command.make("{{packageName}}"); const AllCommands = Command.withSubcommands([]); // NOTE: Modules append additional runtime layers through Layer.mergeAll. -const RuntimeLayers = Layer.mergeAll({{#if runtime=bun}}BunServices{{/if}}{{#if runtime=node}}NodeServices{{/if}}.layer); +const RuntimeLayers = Layer.mergeAll({{#if runtime=bun}}BunServices{{/if}}{{#if runtime=node}}NodeServices{{/if}}{{#if runtime=deno}}DenoServices{{/if}}.layer); root.pipe( AllCommands, Command.run({ version: "0.0.0" }), Effect.provide(RuntimeLayers), - {{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}.runMain, + {{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}{{#if runtime=deno}}DenoRuntime{{/if}}.runMain, ); `; diff --git a/packages/catalog/src/registry/content/db.ts b/packages/catalog/src/registry/content/db.ts index fb069c68..a4b48308 100644 --- a/packages/catalog/src/registry/content/db.ts +++ b/packages/catalog/src/registry/content/db.ts @@ -3,8 +3,33 @@ export * from "./HealthCheck"; export * from "./Migrations"; `; -export const dbDatabaseContents = `{{#if runtime=bun}}import { BunFileSystem, BunPath } from "@effect/platform-bun"; +export const dbDenoSqliteCompatContents = `import { DatabaseSync } from "node:sqlite"; + +// Deno exposes loadExtension but not Node's enableLoadExtension toggle. +// Keep extensions disabled except while Effect SQL explicitly requests one. +if (!Object.getOwnPropertyDescriptor(DatabaseSync.prototype, "enableLoadExtension")) { + const enabled = new WeakSet(); + const loadExtension = DatabaseSync.prototype.loadExtension; + Object.defineProperty(DatabaseSync.prototype, "enableLoadExtension", { + configurable: true, + value(this: DatabaseSync, allow: boolean) { + if (allow) enabled.add(this); + else enabled.delete(this); + }, + }); + Object.defineProperty(DatabaseSync.prototype, "loadExtension", { + configurable: true, + value(this: DatabaseSync, path: string) { + if (!enabled.has(this)) throw new Error("SQLite extension loading is disabled"); + return loadExtension.call(this, path); + }, + }); +} +`; + +export const dbDatabaseContents = `{{#if runtime=deno}}import "./DenoSqliteCompat";{{/if}}{{#if runtime=bun}}import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { SqliteClient } from "@effect/sql-sqlite-bun";{{/if}}{{#if runtime=node}}import { NodeFileSystem, NodePath } from "@effect/platform-node"; +import { SqliteClient } from "@effect/sql-sqlite-node";{{/if}}{{#if runtime=deno}}import { DenoFileSystem, DenoPath } from "@effect/platform-deno"; import { SqliteClient } from "@effect/sql-sqlite-node";{{/if}} import { Config, Effect, FileSystem, Layer, Path, String } from "effect"; @@ -36,10 +61,10 @@ export const SqliteLive = Layer.unwrap( transformResultNames: String.snakeToCamel, }); }), -).pipe(Layer.provide({{#if runtime=bun}}[BunFileSystem.layer, BunPath.layer]{{/if}}{{#if runtime=node}}[NodeFileSystem.layer, NodePath.layer]{{/if}})); +).pipe(Layer.provide({{#if runtime=bun}}[BunFileSystem.layer, BunPath.layer]{{/if}}{{#if runtime=node}}[NodeFileSystem.layer, NodePath.layer]{{/if}}{{#if runtime=deno}}[DenoFileSystem.layer, DenoPath.layer]{{/if}})); `; -export const dbPostgresDatabaseContents = `{{#if runtime=bun}}import { BunServices as PlatformServices } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeServices as PlatformServices } from "@effect/platform-node";{{/if}} +export const dbPostgresDatabaseContents = `{{#if runtime=bun}}import { BunServices as PlatformServices } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeServices as PlatformServices } from "@effect/platform-node";{{/if}}{{#if runtime=deno}}import { DenoServices as PlatformServices } from "@effect/platform-deno";{{/if}} import { PgClient } from "@effect/sql-pg"; import { Config, Layer, Redacted, String } from "effect"; @@ -71,6 +96,7 @@ export const PostgresLive = PgClient.layerConfig({ export const dbMigrationsContents = `{{#if runtime=bun}}import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { SqliteMigrator } from "@effect/sql-sqlite-bun";{{/if}}{{#if runtime=node}}import { NodeFileSystem, NodePath } from "@effect/platform-node"; +import { SqliteMigrator } from "@effect/sql-sqlite-node";{{/if}}{{#if runtime=deno}}import { DenoFileSystem, DenoPath } from "@effect/platform-deno"; import { SqliteMigrator } from "@effect/sql-sqlite-node";{{/if}} import { Effect, Layer, Path } from "effect"; import { SqliteLive } from "./Database"; @@ -89,7 +115,7 @@ export const MigrationsLive = Layer.unwrap( loader: SqliteMigrator.fromFileSystem(directory), }), ), -).pipe(Layer.provide({{#if runtime=bun}}[BunFileSystem.layer, BunPath.layer]{{/if}}{{#if runtime=node}}[NodeFileSystem.layer, NodePath.layer]{{/if}})); +).pipe(Layer.provide({{#if runtime=bun}}[BunFileSystem.layer, BunPath.layer]{{/if}}{{#if runtime=node}}[NodeFileSystem.layer, NodePath.layer]{{/if}}{{#if runtime=deno}}[DenoFileSystem.layer, DenoPath.layer]{{/if}})); export const MigratedLive = MigrationsLive.pipe( Layer.provide(SqliteLive), @@ -99,7 +125,7 @@ export const MigratedLive = MigrationsLive.pipe( export const DatabaseLive = Layer.mergeAll(SqliteLive, MigratedLive); `; -export const dbPostgresMigrationsContents = `{{#if runtime=bun}}import { BunServices as PlatformServices } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeServices as PlatformServices } from "@effect/platform-node";{{/if}} +export const dbPostgresMigrationsContents = `{{#if runtime=bun}}import { BunServices as PlatformServices } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeServices as PlatformServices } from "@effect/platform-node";{{/if}}{{#if runtime=deno}}import { DenoServices as PlatformServices } from "@effect/platform-deno";{{/if}} import { PgMigrator } from "@effect/sql-pg"; import { Effect, Layer, Path } from "effect"; import { PostgresLive } from "./Database"; @@ -207,7 +233,7 @@ volumes: postgres-data: `; -export const dbMigrateScriptContents = `{{#if runtime=bun}}import { BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeRuntime } from "@effect/platform-node";{{/if}} +export const dbMigrateScriptContents = `{{#if runtime=bun}}import { BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeRuntime } from "@effect/platform-node";{{/if}}{{#if runtime=deno}}import { DenoRuntime } from "@effect/platform-deno";{{/if}} import { Console, Effect } from "effect"; import { MigratedLive } from "../src"; @@ -215,10 +241,10 @@ const program = Console.log("Database migrations completed").pipe( Effect.provide(MigratedLive), ); -{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}.runMain(program); +{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}{{#if runtime=deno}}DenoRuntime{{/if}}.runMain(program); `; -export const dbHealthScriptContents = `{{#if runtime=bun}}import { BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeRuntime } from "@effect/platform-node";{{/if}} +export const dbHealthScriptContents = `{{#if runtime=bun}}import { BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeRuntime } from "@effect/platform-node";{{/if}}{{#if runtime=deno}}import { DenoRuntime } from "@effect/platform-deno";{{/if}} import { Console, Effect } from "effect"; import { checkDatabaseHealth, DatabaseLive } from "../src"; @@ -227,5 +253,5 @@ const program = Effect.gen(function* () { yield* Console.log(healthy ? "Database is healthy" : "Database is unhealthy"); }).pipe(Effect.provide(DatabaseLive)); -{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}.runMain(program); +{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}{{#if runtime=deno}}DenoRuntime{{/if}}.runMain(program); `; diff --git a/packages/catalog/src/registry/content/init.ts b/packages/catalog/src/registry/content/init.ts index ddcb1757..f11b8348 100644 --- a/packages/catalog/src/registry/content/init.ts +++ b/packages/catalog/src/registry/content/init.ts @@ -46,12 +46,14 @@ export const rootPackageJsonContents = `{ "overrides": { "@effect/platform-node-shared": "4.0.0-rc.108" }, - "packageManager": "{{packageManagerSpec}}", + {{#if runtime=bun}}"packageManager": "{{packageManagerSpec}}",{{/if}}{{#if runtime=node}}"packageManager": "{{packageManagerSpec}}",{{/if}} "scripts": {}, - "devDependencies": {}, - "engines": { + "devDependencies": { {{#if runtime=deno}}"@types/deno": "^2.7.0", "@types/node": "^26.0.0"{{/if}} }, + {{#if runtime=bun}}"engines": { "node": ">=24" - }, + },{{/if}}{{#if runtime=node}}"engines": { + "node": ">=24" + },{{/if}} "workspaces": [ "apps/*", "packages/*" @@ -59,6 +61,19 @@ export const rootPackageJsonContents = `{ } `; +export const denoJsonContents = `{ + "nodeModulesDir": "auto", + "unstable": ["sloppy-imports"], + "workspace": ["apps/*", "packages/*"], + "tasks": { + "dev:all": "{{#if monorepo}}deno task dev{{/if}}{{#if noMonorepo}}deno task --recursive --if-present dev{{/if}}", + "build:all": "{{#if monorepo}}deno task build{{/if}}{{#if noMonorepo}}deno task --recursive --if-present build{{/if}}", + "test:all": "deno task --recursive --if-present test", + "type-check:all": "{{#if monorepo}}deno task type-check{{/if}}{{#if noMonorepo}}deno task --recursive --if-present type-check{{/if}}" + } +} +`; + export const rootTsconfigContents = `{ "$schema": "{{#if typescript=6}}./node_modules/@effect/language-service/schema.json{{/if}}{{#if typescript=7}}./node_modules/@effect/tsgo/schema.json{{/if}}", "extends": "./packages/config-typescript/base.json", @@ -101,7 +116,9 @@ export const configTypescriptBaseContents = `{ "moduleResolution": "bundler", "module": "ESNext", "target": "ES2022", - "lib": ["ES2023"], + "lib": ["ES2023"{{#if runtime=deno}}, "DOM"{{/if}}], + {{#if runtime=deno}}"types": ["deno", "node"], + {{/if}} "strict": true, "exactOptionalPropertyTypes": true, "noFallthroughCasesInSwitch": true, @@ -249,7 +266,7 @@ export const nxJsonContents = `{ "{workspaceRoot}/pnpm-lock.yml", "{workspaceRoot}/pnpm-workspace.yaml", "{workspaceRoot}/scripts/hash-env.mjs", - { "runtime": "node ./scripts/hash-env.mjs" } + { "runtime": "{{#if runtime=deno}}deno run --allow-read ./scripts/hash-env.mjs{{/if}}{{#if runtime=bun}}node ./scripts/hash-env.mjs{{/if}}{{#if runtime=node}}node ./scripts/hash-env.mjs{{/if}}" } ] }, "targetDefaults": { @@ -280,7 +297,7 @@ export const nxJsonContents = `{ } `; -export const nxHashEnvContents = `/* oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- This Nx build helper runs directly in Node and is intentionally outside the Effect runtime. */ +export const nxHashEnvContents = `/* oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- This Nx build helper uses runtime file APIs and is intentionally outside the Effect runtime. */ import { createHash } from "node:crypto"; import { readdir, readFile } from "node:fs/promises"; import { join, relative } from "node:path"; @@ -578,6 +595,7 @@ export const flakeNixContents = `{ "x86_64-linux" "aarch64-linux" {{#if runtime=node}}"x86_64-darwin" + {{/if}}{{#if runtime=deno}}"x86_64-darwin" {{/if}}"aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; @@ -592,13 +610,16 @@ export const flakeNixContents = `{ default = pkgs.mkShell { packages = with pkgs; [ {{#if runtime=bun}}bun - {{/if}}nodejs_24 + {{/if}}{{#if runtime=deno}}deno + {{/if}}{{#if runtime=bun}}nodejs_24 + {{/if}}{{#if runtime=node}}nodejs_24 + {{/if}} git ]; shellHook = '' {{#if runtime=bun}}echo "Bun $(bun --version)" - {{/if}}echo "Node $(node --version)" + echo "Node $(node --version)"{{/if}}{{#if runtime=node}}echo "Node $(node --version)"{{/if}}{{#if runtime=deno}}echo "Deno $(deno --version | head -n 1)"{{/if}} ''; }; } @@ -617,7 +638,7 @@ export const devcontainerJsonContents = `{ "image": "mcr.microsoft.com/devcontainers/typescript-node", "features": { - "ghcr.io/shyim/devcontainers-features/bun:0": {} + {{#if runtime=bun}}"ghcr.io/shyim/devcontainers-features/bun:0": {}{{/if}}{{#if runtime=deno}}"ghcr.io/devcontainers-community/features/deno": {"version": "2.9.4"}{{/if}} }, "postCreateCommand": "{{packageManager}} install", @@ -637,6 +658,7 @@ export const devcontainerJsonContents = `{ {{/if}}{{#if format=dprint}}"dprint.dprint", {{/if}}{{#if format=oxfmt}}"oxc.oxc-vscode", {{/if}}{{#if runtime=bun}}"oven.bun-vscode", + {{/if}}{{#if runtime=deno}}"denoland.vscode-deno", {{/if}}"effectful-tech.effect-vscode", "YoavBls.pretty-ts-errors" ] @@ -647,7 +669,7 @@ export const devcontainerJsonContents = `{ // -- husky ----------------------------------------------------------------- -export const huskyPreCommitContents = `{{packageManager}} run lint-staged +export const huskyPreCommitContents = `{{#if runtime=deno}}deno task lint-staged{{/if}}{{#if runtime=bun}}bun run lint-staged{{/if}}{{#if runtime=node}}{{packageManager}} run lint-staged{{/if}} `; export const lintStagedConfigContents = `{ @@ -658,3 +680,12 @@ export const lintStagedConfigContents = `{ ] } `; + +export const denoLintStagedConfigContents = `{ + "*.{js,jsx,cjs,mjs,ts,tsx,cts,mts}": [ + "deno task --if-present format --"{{#if lint=biome}}, + "deno task lint --"{{/if}}{{#if lint=oxlint}}, + "deno task lint:fix --"{{/if}} + ] +} +`; diff --git a/packages/catalog/src/registry/content/mcp.ts b/packages/catalog/src/registry/content/mcp.ts index 3aa02a8a..ca2c27db 100644 --- a/packages/catalog/src/registry/content/mcp.ts +++ b/packages/catalog/src/registry/content/mcp.ts @@ -5,12 +5,12 @@ export const mcpServerPackageJsonContents = `{ "type": "module", "scripts": {}, "dependencies": { - "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}": "4.0.0-rc.108", + "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}{{#if runtime=deno}}@effect/platform-deno{{/if}}": "4.0.0-rc.108", "effect": "4.0.0-rc.108" }, "devDependencies": { "@repo/config-typescript": "{{workspaceDependency}}", - "{{#if runtime=bun}}@types/bun{{/if}}{{#if runtime=node}}@types/node{{/if}}": "{{#if runtime=bun}}^1.2.17{{/if}}{{#if runtime=node}}^24.0.0{{/if}}", + {{#if runtime=bun}}"@types/bun": "^1.2.17",{{/if}}{{#if runtime=node}}"@types/node": "^24.0.0",{{/if}} "vitest": "^4.1.4" } } @@ -22,14 +22,14 @@ export const mcpServerTsconfigContents = `{ "rootDir": "../..", "outDir": "dist", "noEmit": true, - "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}"] + "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}{{#if runtime=deno}}deno", "node{{/if}}"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } `; -export const mcpServerIndexContents = `{{#if runtime=bun}}import { BunHttpServer, BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +export const mcpServerIndexContents = `{{#if runtime=bun}}import { BunHttpServer, BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=deno}}import { DenoHttpServer, DenoRuntime } from "@effect/platform-deno";{{/if}}{{#if runtime=node}}import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; import { createServer } from "node:http";{{/if}} import { Config, Effect, Layer } from "effect"; import { McpProtocol, McpServer } from "effect/unstable/ai"; @@ -66,7 +66,7 @@ const McpHttpLive = Effect.gen(function* () { Layer.provideMerge(McpCapabilities), HttpRouter.serve, HttpServer.withLogAddress, - Layer.provide({{#if runtime=bun}}BunHttpServer.layerConfig(McpServerConfig){{/if}}{{#if runtime=node}}NodeHttpServer.layerConfig(createServer, McpServerConfig){{/if}}), + Layer.provide({{#if runtime=bun}}BunHttpServer.layerConfig(McpServerConfig){{/if}}{{#if runtime=node}}NodeHttpServer.layerConfig(createServer, McpServerConfig){{/if}}{{#if runtime=deno}}DenoHttpServer.layerConfig(McpServerConfig){{/if}}), ); }).pipe( Layer.unwrap, @@ -74,7 +74,7 @@ const McpHttpLive = Effect.gen(function* () { Effect.satisfiesServicesType(), ); -{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}.runMain(McpHttpLive); +{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}{{#if runtime=deno}}DenoRuntime{{/if}}.runMain(McpHttpLive); `; export const mcpHelloPromptContents = `import { Effect, Layer, Schema } from "effect"; diff --git a/packages/catalog/src/registry/content/server.ts b/packages/catalog/src/registry/content/server.ts index ac656f30..db066273 100644 --- a/packages/catalog/src/registry/content/server.ts +++ b/packages/catalog/src/registry/content/server.ts @@ -5,12 +5,12 @@ export const serverPackageJsonContents = `{ "type": "module", "scripts": {}, "dependencies": { - "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}": "4.0.0-rc.108", + "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}{{#if runtime=deno}}@effect/platform-deno{{/if}}": "4.0.0-rc.108", "effect": "4.0.0-rc.108" }, "devDependencies": { "@repo/config-typescript": "{{workspaceDependency}}", - "{{#if runtime=bun}}@types/bun{{/if}}{{#if runtime=node}}@types/node{{/if}}": "{{#if runtime=bun}}^1.2.17{{/if}}{{#if runtime=node}}^24.0.0{{/if}}", + {{#if runtime=bun}}"@types/bun": "^1.2.17",{{/if}}{{#if runtime=node}}"@types/node": "^24.0.0",{{/if}} "vitest": "^4.1.4" } } @@ -22,7 +22,7 @@ export const serverTsconfigContents = `{ "rootDir": "../..", "outDir": "dist", "noEmit": true, - "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}"] + "types": ["{{#if runtime=bun}}bun{{/if}}{{#if runtime=node}}node{{/if}}{{#if runtime=deno}}deno", "node{{/if}}"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] @@ -38,7 +38,7 @@ export const serverTsconfigContents = `{ * * Additional capabilities (RPC, WebSocket) are added by modules. */ -export const serverIndexContents = `{{#if runtime=bun}}import { BunHttpServer, BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +export const serverIndexContents = `{{#if runtime=bun}}import { BunHttpServer, BunRuntime } from "@effect/platform-bun";{{/if}}{{#if runtime=deno}}import { DenoHttpServer, DenoRuntime } from "@effect/platform-deno";{{/if}}{{#if runtime=node}}import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.layerConfig requires the Node server factory. import { createServer } from "node:http";{{/if}} import { Api } from "@repo/domain/Api"; @@ -67,7 +67,7 @@ const RouterDependencies = Layer.mergeAll(Layer.empty); const AllRouters = Layer.mergeAll(ApiRouter); // NOTE: Modules append additional server layers through Layer.mergeAll. -const ServerLayers = Layer.mergeAll({{#if runtime=bun}}BunHttpServer.layerConfig(ServerConfig){{/if}}{{#if runtime=node}}NodeHttpServer.layerConfig(createServer, ServerConfig){{/if}}); +const ServerLayers = Layer.mergeAll({{#if runtime=bun}}BunHttpServer.layerConfig(ServerConfig){{/if}}{{#if runtime=node}}NodeHttpServer.layerConfig(createServer, ServerConfig){{/if}}{{#if runtime=deno}}DenoHttpServer.layerConfig(ServerConfig){{/if}}); const HttpLive = Effect.gen(function* () { const config = yield* ServerConfig; @@ -95,7 +95,7 @@ const HttpLive = Effect.gen(function* () { ); }).pipe(Layer.unwrap, Layer.launch); -{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}.runMain(HttpLive); +{{#if runtime=bun}}BunRuntime{{/if}}{{#if runtime=node}}NodeRuntime{{/if}}{{#if runtime=deno}}DenoRuntime{{/if}}.runMain(HttpLive); `; export const serverDevToolsContents = `import { Config, Effect, Layer } from "effect"; diff --git a/packages/catalog/src/registry/content/todo.ts b/packages/catalog/src/registry/content/todo.ts index 33d32e77..a16ae231 100644 --- a/packages/catalog/src/registry/content/todo.ts +++ b/packages/catalog/src/registry/content/todo.ts @@ -145,7 +145,7 @@ export const todoRepositoryContents = `import { TodoPersistenceError, type UpdateTodoInput, } from "@repo/domain/Todo"; -{{#if runtime=bun}}import { layer as PlatformCryptoLayer } from "@effect/platform-bun/BunCrypto";{{/if}}{{#if runtime=node}}import { layer as PlatformCryptoLayer } from "@effect/platform-node/NodeCrypto";{{/if}} +{{#if runtime=bun}}import { layer as PlatformCryptoLayer } from "@effect/platform-bun/BunCrypto";{{/if}}{{#if runtime=node}}import { layer as PlatformCryptoLayer } from "@effect/platform-node/NodeCrypto";{{/if}}{{#if runtime=deno}}import { layer as PlatformCryptoLayer } from "@effect/platform-deno/DenoCrypto";{{/if}} import { Context, Crypto, Effect, Layer, Schema } from "effect"; import { SqlClient } from "effect/unstable/sql/SqlClient"; diff --git a/packages/catalog/src/registry/content/websocket.ts b/packages/catalog/src/registry/content/websocket.ts index 53323d84..5bc78aeb 100644 --- a/packages/catalog/src/registry/content/websocket.ts +++ b/packages/catalog/src/registry/content/websocket.ts @@ -66,7 +66,7 @@ export class WebSocketRpc extends RpcGroup.make( `; // Server Presence RPC handler -export const serverPresenceContents = `import { BunCrypto } from "@effect/platform-bun"; +export const serverPresenceContents = `{{#if runtime=bun}}import { BunCrypto as PlatformCrypto } from "@effect/platform-bun";{{/if}}{{#if runtime=node}}import { NodeCrypto as PlatformCrypto } from "@effect/platform-node";{{/if}}{{#if runtime=deno}}import { DenoCrypto as PlatformCrypto } from "@effect/platform-deno";{{/if}} import { type ClientInfo, type WebSocketEvent, @@ -166,7 +166,7 @@ const PresenceRpcHandlers = WebSocketRpc.toLayer( ).pipe( Layer.provide(PresenceService.layer), Layer.provide(ClientGenerator.layer), - Layer.provide(BunCrypto.layer), + Layer.provide(PlatformCrypto.layer), ); export const PresenceRpcLive = RpcServer.layerHttp({ diff --git a/packages/catalog/src/registry/moduleRegistry.test.ts b/packages/catalog/src/registry/moduleRegistry.test.ts index 9c48827c..f665d012 100644 --- a/packages/catalog/src/registry/moduleRegistry.test.ts +++ b/packages/catalog/src/registry/moduleRegistry.test.ts @@ -1,10 +1,24 @@ import { ModuleCapability } from "@repo/domain/Catalog"; import { describe, expect, it } from "vitest"; import { moduleRegistry } from "./moduleRegistry"; +import { targetRegistry } from "./targetRegistry"; describe("moduleRegistry", () => { const knownIds = new Set(moduleRegistry.map((m) => m.id)); + it("should expose every compatible catalog target and module for Deno", () => { + expect( + targetRegistry + .filter((target) => !target.supportedRuntimes?.includes("deno")) + .map((target) => target.kind), + ).toEqual([]); + expect( + moduleRegistry + .filter((module) => !module.supportedRuntimes?.includes("deno")) + .map((module) => module.id), + ).toEqual(["workspace-monorepo-turbo"]); + }); + it("should have unique module ids", () => { const ids = moduleRegistry.map((m) => m.id); const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i); diff --git a/packages/catalog/src/registry/modules/cli.ts b/packages/catalog/src/registry/modules/cli.ts index 61eafee6..2eaa532e 100644 --- a/packages/catalog/src/registry/modules/cli.ts +++ b/packages/catalog/src/registry/modules/cli.ts @@ -16,6 +16,7 @@ import { export const cliModules: ReadonlyArray = [ { id: ModuleId.make("cli-command-hello"), + supportedRuntimes: ["bun", "node", "deno"], title: "Hello Command", description: "A simple hello-world subcommand for the CLI", supportedOn: [{ _tag: "kind", kind: TargetKind.make("cli") }], @@ -41,6 +42,7 @@ export const cliModules: ReadonlyArray = [ }, { id: ModuleId.make("cli-chat-driver"), + supportedRuntimes: ["bun", "node", "deno"], title: "Chat CLI Driver", description: "Shared direct-AI chat plumbing for CLI chat commands", visibility: "internal", @@ -90,6 +92,7 @@ export const cliModules: ReadonlyArray = [ }, { id: ModuleId.make("cli-command-chat-ask"), + supportedRuntimes: ["bun", "node", "deno"], title: "Ask Command", description: "One-shot AI ask command for CLI applications", supportedOn: [{ _tag: "kind", kind: TargetKind.make("cli") }], @@ -124,6 +127,7 @@ export const cliModules: ReadonlyArray = [ }, { id: ModuleId.make("cli-command-chat-terminal"), + supportedRuntimes: ["bun", "node", "deno"], title: "Terminal Chat Command", description: "Interactive terminal AI chat command for CLI applications", supportedOn: [{ _tag: "kind", kind: TargetKind.make("cli") }], @@ -170,6 +174,7 @@ export const cliModules: ReadonlyArray = [ }, { id: ModuleId.make("cli-devtools"), + supportedRuntimes: ["bun", "node", "deno"], title: "Effect DevTools CLI", description: "Optional Effect DevTools tracer layer for CLI apps", supportedOn: [{ _tag: "kind", kind: TargetKind.make("cli") }], diff --git a/packages/catalog/src/registry/modules/client-foldkit.ts b/packages/catalog/src/registry/modules/client-foldkit.ts index 7d654578..dfd9e18c 100644 --- a/packages/catalog/src/registry/modules/client-foldkit.ts +++ b/packages/catalog/src/registry/modules/client-foldkit.ts @@ -40,6 +40,7 @@ export const clientFoldkitModules: ReadonlyArray = [ { id: ModuleId.make("client-foldkit-devtools"), + supportedRuntimes: ["bun", "node", "deno"], title: "Foldkit DevTools", description: "Optional Foldkit runtime devtools for message inspection", supportedOn: [{ _tag: "kind", kind: foldkitKind }], @@ -66,6 +67,7 @@ export const clientFoldkitModules: ReadonlyArray = }, { id: ModuleId.make("client-foldkit-http-api"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP API Client (Foldkit)", description: "REST API client with Command pattern for Foldkit", supportedOn: [{ _tag: "kind", kind: foldkitKind }], @@ -156,6 +158,7 @@ export const clientFoldkitModules: ReadonlyArray = }, { id: ModuleId.make("client-foldkit-http-rpc"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP RPC Client (Foldkit)", description: "RPC streaming client with Subscription pattern for Foldkit", supportedOn: [{ _tag: "kind", kind: foldkitKind }], @@ -265,6 +268,7 @@ export const clientFoldkitModules: ReadonlyArray = }, { id: ModuleId.make("client-foldkit-ws-presence"), + supportedRuntimes: ["bun", "node", "deno"], title: "WebSocket Presence (Foldkit)", description: "Real-time presence UI with WebSocket RPC for Foldkit", supportedOn: [{ _tag: "kind", kind: foldkitKind }], @@ -374,6 +378,7 @@ export const clientFoldkitModules: ReadonlyArray = }, { id: ModuleId.make("client-foldkit-chat"), + supportedRuntimes: ["bun", "node", "deno"], title: "Chat Client (Foldkit)", description: "AI chat UI with streaming and tool calls for Foldkit", supportedOn: [{ _tag: "kind", kind: foldkitKind }], diff --git a/packages/catalog/src/registry/modules/client.ts b/packages/catalog/src/registry/modules/client.ts index 22db01f6..66660f72 100644 --- a/packages/catalog/src/registry/modules/client.ts +++ b/packages/catalog/src/registry/modules/client.ts @@ -44,6 +44,7 @@ const domainTarget = new TargetIdentity({ export const clientModules: ReadonlyArray = [ { id: ModuleId.make("client-react-web-worker"), + supportedRuntimes: ["bun", "node", "deno"], title: "Browser Web Worker", description: "Browser Worker RPC with Effect Schema, Stream, Schedule, and Atom RPC", @@ -91,6 +92,7 @@ export const clientModules: ReadonlyArray = [ }, { id: ModuleId.make("client-react-http-api"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP API Client", description: "REST API client with Effect Atom and typed HttpApiClient", supportedOn: [{ _tag: "kind", kind: clientReactKind }], @@ -139,6 +141,7 @@ export const clientModules: ReadonlyArray = [ }, { id: ModuleId.make("client-react-http-api-todos"), + supportedRuntimes: ["bun", "node", "deno"], title: "Todo HTTP Client", description: "Persistent Todo CRUD card backed by the typed HTTP API", supportedOn: [{ _tag: "kind", kind: clientReactKind }], @@ -190,6 +193,7 @@ export const clientModules: ReadonlyArray = [ }, { id: ModuleId.make("client-react-http-rpc"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP RPC Client", description: "RPC streaming client with tick atom and UI", supportedOn: [{ _tag: "kind", kind: clientReactKind }], @@ -243,6 +247,7 @@ export const clientModules: ReadonlyArray = [ }, { id: ModuleId.make("client-react-chat"), + supportedRuntimes: ["bun", "node", "deno"], title: "Chat Client", description: "AI chat UI with streaming, tool calls, and state machine", supportedOn: [{ _tag: "kind", kind: clientReactKind }], @@ -311,12 +316,13 @@ export const clientModules: ReadonlyArray = [ { label: "Install shadcn chat UI components", command: - "bunx shadcn@latest add message-scroller message bubble attachment marker --yes --overwrite", + "{{#if runtime=deno}}deno run -A npm:shadcn@latest add message-scroller message bubble attachment marker --yes --overwrite{{/if}}{{#if runtime=bun}}bunx shadcn@latest add message-scroller message bubble attachment marker --yes --overwrite{{/if}}{{#if runtime=node}}bunx shadcn@latest add message-scroller message bubble attachment marker --yes --overwrite{{/if}}", }, ], }, { id: ModuleId.make("client-react-ws-presence"), + supportedRuntimes: ["bun", "node", "deno"], title: "WebSocket Presence Client", description: "Real-time presence UI with WebSocket RPC", supportedOn: [{ _tag: "kind", kind: clientReactKind }], @@ -372,6 +378,7 @@ export const clientModules: ReadonlyArray = [ }, { id: ModuleId.make("client-react-devtools"), + supportedRuntimes: ["bun", "node", "deno"], title: "Effect DevTools React Client", description: "Optional Effect DevTools tracer layer for React atom runtime", supportedOn: [{ _tag: "kind", kind: clientReactKind }], diff --git a/packages/catalog/src/registry/modules/config.ts b/packages/catalog/src/registry/modules/config.ts index c6ff0813..3bd0395b 100644 --- a/packages/catalog/src/registry/modules/config.ts +++ b/packages/catalog/src/registry/modules/config.ts @@ -8,6 +8,7 @@ import { configTypescriptViteContents } from "../content/client"; export const configModules: ReadonlyArray = [ { id: ModuleId.make("config-typescript-vite"), + supportedRuntimes: ["bun", "node", "deno"], title: "Config TypeScript Vite", description: "Vite TypeScript preset for client applications", visibility: "internal", diff --git a/packages/catalog/src/registry/modules/domain.ts b/packages/catalog/src/registry/modules/domain.ts index 44dbe80b..6b577bb8 100644 --- a/packages/catalog/src/registry/modules/domain.ts +++ b/packages/catalog/src/registry/modules/domain.ts @@ -27,6 +27,7 @@ const domainTarget = new TargetIdentity({ export const domainModules: ReadonlyArray = [ { id: ModuleId.make("domain-api-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain API", description: "Shared domain schemas and RPC definitions", visibility: "internal", @@ -59,6 +60,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-rpc-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain RPC", description: "Shared RPC definitions for streaming over HTTP", visibility: "internal", @@ -91,6 +93,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-todo-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain Todo", description: "Shared Todo schemas and errors", visibility: "internal", @@ -125,6 +128,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-todo-http-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain Todo HTTP", description: "Todo HTTP API contract", visibility: "internal", @@ -163,6 +167,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-todo-rpc-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain Todo RPC", description: "Todo RPC contract merged into the server RPC group", visibility: "internal", @@ -214,6 +219,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-chat-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain Chat", description: "Chat stream protocol, message schemas, and client state machine", @@ -264,6 +270,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-chat-managed-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain Managed Chat", description: "Managed chat send, watch, and interrupt RPC definitions", visibility: "internal", @@ -302,6 +309,7 @@ export const domainModules: ReadonlyArray = [ }, { id: ModuleId.make("domain-ws-contracts"), + supportedRuntimes: ["bun", "node", "deno"], title: "Domain WebSocket", description: "WebSocket RPC definitions for real-time presence", visibility: "internal", diff --git a/packages/catalog/src/registry/modules/init.ts b/packages/catalog/src/registry/modules/init.ts index d0d38d32..b557d136 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, + denoLintStagedConfigContents, devcontainerJsonContents, dprintJsonContents, envrcContents, @@ -26,6 +27,7 @@ import { const gitInitModule: typeof ModuleDefinition.Type = { id: ModuleId.make("workspace-devenv-git"), + supportedRuntimes: ["bun", "node", "deno"], title: "Git", description: "Initialize a git repository with an initial commit", visibility: "internal", @@ -45,7 +47,7 @@ const gitInitModule: typeof ModuleDefinition.Type = { { label: "Initialize git repository and create initial commit", command: - 'test "$(git rev-parse --show-toplevel 2>/dev/null)" = "$PWD" || (git init --initial-branch=main && git add -A && git commit -m "initial commit" && {{packageManager}} run --if-present postprepare)', + 'test "$(git rev-parse --show-toplevel 2>/dev/null)" = "$PWD" || (git init --initial-branch=main && git add -A && git commit -m "initial commit")', phase: "post-finalize", }, ], @@ -53,6 +55,7 @@ const gitInitModule: typeof ModuleDefinition.Type = { const nixFlakeModule: typeof ModuleDefinition.Type = { id: ModuleId.make("workspace-devenv-nix-flake"), + supportedRuntimes: ["bun", "node", "deno"], title: "Nix Flake", description: "Declarative development environment with Nix", visibility: "internal", @@ -88,6 +91,7 @@ const nixFlakeModule: typeof ModuleDefinition.Type = { const devcontainerModule: typeof ModuleDefinition.Type = { id: ModuleId.make("workspace-devenv-devcontainer"), + supportedRuntimes: ["bun", "node", "deno"], title: "Dev Container", description: "VS Code/GitHub Codespaces development container", visibility: "internal", @@ -117,6 +121,7 @@ const devcontainerModule: typeof ModuleDefinition.Type = { const huskyModule: typeof ModuleDefinition.Type = { id: ModuleId.make("workspace-devenv-husky"), + supportedRuntimes: ["bun", "node", "deno"], title: "Husky + lint-staged", description: "Run staged-file format and lint tasks before each commit", visibility: "internal", @@ -140,9 +145,14 @@ const huskyModule: typeof ModuleDefinition.Type = { }, { _tag: "file", - path: "{{targetPath}}/.lintstagedrc.json", + path: "{{#if runtime=bun}}{{targetPath}}/.lintstagedrc.json{{/if}}{{#if runtime=node}}{{targetPath}}/.lintstagedrc.json{{/if}}", contents: lintStagedConfigContents, }, + { + _tag: "file", + path: "{{#if runtime=deno}}{{targetPath}}/.lintstagedrc.json{{/if}}", + contents: denoLintStagedConfigContents, + }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", @@ -172,11 +182,20 @@ const huskyModule: typeof ModuleDefinition.Type = { value: "lint-staged", }, ], + scripts: [ + { + label: "Install Git hooks", + command: + "{{#if runtime=deno}}deno task postprepare{{/if}}{{#if runtime=bun}}bun run postprepare{{/if}}{{#if runtime=node}}{{packageManager}} run postprepare{{/if}}", + phase: "post-finalize", + }, + ], }; export const initModules: ReadonlyArray = [ { id: ModuleId.make("workspace-typescript-6"), + supportedRuntimes: ["bun", "node", "deno"], title: "TypeScript 6", description: "TypeScript 6 with the Effect language-service plugin", visibility: "internal", @@ -217,6 +236,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-typescript-7"), + supportedRuntimes: ["bun", "node", "deno"], title: "TypeScript 7", description: "TypeScript 7 with the native Effect TypeScript-Go server", visibility: "internal", @@ -324,6 +344,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-monorepo-nx"), + supportedRuntimes: ["bun", "node", "deno"], title: "Nx", description: "Package-based monorepo task orchestration and caching with Nx", @@ -394,6 +415,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-monorepo-vite-plus"), + supportedRuntimes: ["bun", "node", "deno"], title: "Vite+", description: "Monorepo task orchestration and caching with Vite+", visibility: "internal", @@ -461,6 +483,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-biome"), + supportedRuntimes: ["bun", "node", "deno"], title: "Biome", description: "Shared Biome dependency and configuration", visibility: "internal", @@ -496,6 +519,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-biome-lint"), + supportedRuntimes: ["bun", "node", "deno"], title: "Biome", description: "Fast linter with recommended defaults", visibility: "internal", @@ -523,6 +547,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-biome-format"), + supportedRuntimes: ["bun", "node", "deno"], title: "Biome", description: "Fast formatter with recommended defaults", visibility: "internal", @@ -561,6 +586,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-oxfmt"), + supportedRuntimes: ["bun", "node", "deno"], title: "Oxfmt", description: "High-performance formatter for the JavaScript ecosystem", visibility: "internal", @@ -620,6 +646,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-dprint"), + supportedRuntimes: ["bun", "node", "deno"], title: "dprint", description: "Fast pluggable formatter used by the Effect team", visibility: "internal", @@ -674,6 +701,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-quality-oxlint"), + supportedRuntimes: ["bun", "node", "deno"], title: "oxlint", description: "Fast Rust-based linter used by the Effect team", visibility: "internal", @@ -728,6 +756,7 @@ export const initModules: ReadonlyArray = [ }, { id: ModuleId.make("workspace-test-vitest"), + supportedRuntimes: ["bun", "node", "deno"], title: "Vitest", description: "Unit and integration testing framework", visibility: "internal", @@ -759,7 +788,7 @@ export const initModules: ReadonlyArray = [ _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", - name: "test", + name: "{{#if runtime=bun}}test{{/if}}{{#if runtime=node}}test{{/if}}", value: "{{#if monorepo=turbo}}turbo run test{{/if}}{{#if monorepo=vite-plus}}vp run -r test{{/if}}{{#if monorepo=nx}}nx run-many -t test{{/if}}", }, diff --git a/packages/catalog/src/registry/modules/mcp.ts b/packages/catalog/src/registry/modules/mcp.ts index 0a719181..73341881 100644 --- a/packages/catalog/src/registry/modules/mcp.ts +++ b/packages/catalog/src/registry/modules/mcp.ts @@ -18,6 +18,7 @@ const aiTarget = new TargetIdentity({ export const mcpModules: ReadonlyArray = [ { id: ModuleId.make("mcp-tools"), + supportedRuntimes: ["bun", "node", "deno"], title: "MCP Tools", description: "Expose Effect AI toolkits through the MCP server", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -36,6 +37,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-toolkit-datetime"), + supportedRuntimes: ["bun", "node", "deno"], title: "Date and Time Toolkit", description: "Expose the shared date and time toolkit through MCP", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -70,6 +72,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-toolkit-math"), + supportedRuntimes: ["bun", "node", "deno"], title: "Math Toolkit", description: "Expose the shared deterministic math toolkit through MCP", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -104,6 +107,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-prompts"), + supportedRuntimes: ["bun", "node", "deno"], title: "MCP Prompts", description: "Add reusable prompt templates to the MCP server", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -118,6 +122,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-prompt-hello"), + supportedRuntimes: ["bun", "node", "deno"], title: "Hello Prompt", description: "Example parameterized MCP greeting prompt", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -143,6 +148,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-resources"), + supportedRuntimes: ["bun", "node", "deno"], title: "MCP Resources", description: "Publish static and templated content through MCP", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], @@ -157,6 +163,7 @@ export const mcpModules: ReadonlyArray = [ }, { id: ModuleId.make("mcp-resource-primer"), + supportedRuntimes: ["bun", "node", "deno"], title: "Primer Resource", description: "Example static text resource describing the generated server", supportedOn: [{ _tag: "kind", kind: mcpServerKind }], diff --git a/packages/catalog/src/registry/modules/packages.ts b/packages/catalog/src/registry/modules/packages.ts index dd483e82..f645e902 100644 --- a/packages/catalog/src/registry/modules/packages.ts +++ b/packages/catalog/src/registry/modules/packages.ts @@ -20,6 +20,7 @@ import { } from "../content/ai"; import { dbDatabaseContents, + dbDenoSqliteCompatContents, dbHealthCheckContents, dbHealthScriptContents, dbIndexContents, @@ -42,6 +43,7 @@ import { todoMigrationContents, todoRepositoryContents } from "../content/todo"; export const packageModules: ReadonlyArray = [ { id: ModuleId.make("package-ai-core"), + supportedRuntimes: ["bun", "node", "deno"], title: "AI Package", description: "Anthropic language model configuration and workflow utilities", @@ -112,6 +114,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-think"), + supportedRuntimes: ["bun", "node", "deno"], title: "Think Toolkit", description: "Minimal AI toolkit with a think tool for step-by-step reasoning", @@ -141,6 +144,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-datetime"), + supportedRuntimes: ["bun", "node", "deno"], title: "DateTime Toolkit", description: "Timezone-aware date and time tool for time-sensitive agent behavior", @@ -177,6 +181,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-math"), + supportedRuntimes: ["bun", "node", "deno"], title: "Math Toolkit", description: "Deterministic arithmetic evaluator for safe math computation", visibility: "internal", @@ -212,6 +217,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-chat-toolkit-datetime"), + supportedRuntimes: ["bun", "node", "deno"], title: "DateTime Toolkit", description: "Attach the shared date and time toolkit to the chat service", supportedOn: [ @@ -268,6 +274,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-chat-toolkit-math"), + supportedRuntimes: ["bun", "node", "deno"], title: "Math Toolkit", description: "Attach the shared deterministic math toolkit to the chat service", @@ -325,6 +332,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-memory"), + supportedRuntimes: ["bun", "node", "deno"], title: "Memory Toolkit", description: "Key-value scratchpad for persisting facts across tool invocations", @@ -375,6 +383,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-plan"), + supportedRuntimes: ["bun", "node", "deno"], title: "Plan Toolkit", description: "Structured task tracking that forces plan-before-act discipline", @@ -425,6 +434,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-toolkit-webfetch"), + supportedRuntimes: ["bun", "node", "deno"], title: "WebFetch Toolkit", description: "URL content retrieval with HTML stripping for retrieval-augmented workflows", @@ -475,6 +485,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-ai-chat-service"), + supportedRuntimes: ["bun", "node", "deno"], title: "Chat Service", description: "AI chat service with agentic loop for streaming tool-augmented conversations", @@ -567,6 +578,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-db-sqlite"), + supportedRuntimes: ["bun", "node", "deno"], title: "SQLite Database", description: "Reusable Effect SQL SQLite package with migrations", provides: [ModuleCapability.make("db-sql")], @@ -586,6 +598,11 @@ export const packageModules: ReadonlyArray = [ path: "{{targetPath}}/src/index.ts", contents: dbIndexContents, }, + { + _tag: "file", + path: "{{#if runtime=deno}}{{targetPath}}/src/DenoSqliteCompat.ts{{/if}}", + contents: dbDenoSqliteCompatContents, + }, { _tag: "file", path: "{{targetPath}}/src/Database.ts", @@ -620,14 +637,14 @@ export const packageModules: ReadonlyArray = [ _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "dependencies", - name: "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}", + name: "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}{{#if runtime=deno}}@effect/platform-deno{{/if}}", value: "4.0.0-rc.108", }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "dependencies", - name: "{{#if runtime=bun}}@effect/sql-sqlite-bun{{/if}}{{#if runtime=node}}@effect/sql-sqlite-node{{/if}}", + name: "{{#if runtime=bun}}@effect/sql-sqlite-bun{{/if}}{{#if runtime=node}}@effect/sql-sqlite-node{{/if}}{{#if runtime=deno}}@effect/sql-sqlite-node{{/if}}", value: "4.0.0-rc.108", }, { @@ -637,6 +654,13 @@ export const packageModules: ReadonlyArray = [ name: "{{#if runtime=node}}tsx{{/if}}", value: "^4.21.0", }, + { + _tag: "pkg-json-entry", + path: "{{targetPath}}/package.json", + field: "devDependencies", + name: "{{#if runtime=deno}}@types/node{{/if}}", + value: "^26.1.2", + }, { _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", @@ -650,7 +674,7 @@ export const packageModules: ReadonlyArray = [ field: "scripts", name: "db:migrate", value: - "{{#if runtime=bun}}bun run scripts/migrate.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/migrate.ts{{/if}}", + "{{#if runtime=bun}}bun run scripts/migrate.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/migrate.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-read --allow-write --allow-ffi scripts/migrate.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -658,15 +682,16 @@ export const packageModules: ReadonlyArray = [ field: "scripts", name: "db:health", value: - "{{#if runtime=bun}}bun run scripts/health.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/health.ts{{/if}}", + "{{#if runtime=bun}}bun run scripts/health.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/health.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-read --allow-write --allow-ffi scripts/health.ts{{/if}}", }, ], nextSteps: [ - "SQLite Database: Set `DATABASE_FILE` for `{{targetPath}}` if you want a database path other than the default `../../data/app.sqlite`.", + "SQLite Database: Set `DATABASE_FILE` for `{{targetPath}}` if you want a database path other than the default `../../data/app.sqlite`.{{#if runtime=deno}} Set `DATABASE_FILE=./data/app.sqlite` when launching a compiled executable from the project root.{{/if}}", ], }, { id: ModuleId.make("package-db-postgres"), + supportedRuntimes: ["bun", "node", "deno"], title: "Postgres Database", description: "Reusable Effect SQL Postgres package with migrations", provides: [ModuleCapability.make("db-sql")], @@ -730,7 +755,7 @@ export const packageModules: ReadonlyArray = [ _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "dependencies", - name: "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}", + name: "{{#if runtime=bun}}@effect/platform-bun{{/if}}{{#if runtime=node}}@effect/platform-node{{/if}}{{#if runtime=deno}}@effect/platform-deno{{/if}}", value: "4.0.0-rc.108", }, { @@ -760,7 +785,7 @@ export const packageModules: ReadonlyArray = [ field: "scripts", name: "db:migrate", value: - "{{#if runtime=bun}}bun run scripts/migrate.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/migrate.ts{{/if}}", + "{{#if runtime=bun}}bun run scripts/migrate.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/migrate.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-read --allow-write --allow-net scripts/migrate.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -768,7 +793,7 @@ export const packageModules: ReadonlyArray = [ field: "scripts", name: "db:health", value: - "{{#if runtime=bun}}bun run scripts/health.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/health.ts{{/if}}", + "{{#if runtime=bun}}bun run scripts/health.ts{{/if}}{{#if runtime=node}}node --import tsx scripts/health.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-read --allow-write --allow-net scripts/health.ts{{/if}}", }, ], nextSteps: [ @@ -777,6 +802,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-db-todo-repository"), + supportedRuntimes: ["bun", "node", "deno"], title: "Todo Repository", description: "Persistent Todo CRUD repository over the selected SQL database", @@ -855,6 +881,7 @@ export const packageModules: ReadonlyArray = [ }, { id: ModuleId.make("package-presence-service"), + supportedRuntimes: ["bun", "node", "deno"], title: "Presence Package", description: "Real-time presence tracking service with PubSub and client generation", diff --git a/packages/catalog/src/registry/modules/server.ts b/packages/catalog/src/registry/modules/server.ts index e2456c0e..1a3bef2f 100644 --- a/packages/catalog/src/registry/modules/server.ts +++ b/packages/catalog/src/registry/modules/server.ts @@ -43,6 +43,7 @@ const serverApiTarget = new TargetIdentity({ export const serverModules: ReadonlyArray = [ { id: ModuleId.make("server-http-api"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP API Server", description: "REST API endpoints with Effect HTTP", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -75,6 +76,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-http-rpc"), + supportedRuntimes: ["bun", "node", "deno"], title: "HTTP RPC Server", description: "RPC streaming server with tick handler", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -113,6 +115,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-http-api-todos"), + supportedRuntimes: ["bun", "node", "deno"], title: "Todo HTTP API", description: "Persistent Todo CRUD endpoints over Effect HTTP API", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -173,6 +176,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-http-rpc-todos"), + supportedRuntimes: ["bun", "node", "deno"], title: "Todo HTTP RPC", description: "Persistent Todo CRUD operations on the shared HTTP RPC route", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -233,6 +237,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-chat-rpc"), + supportedRuntimes: ["bun", "node", "deno"], title: "Chat Server", description: "AI chat RPC handler with tool support", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -307,6 +312,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-chat-runtime-managed"), + supportedRuntimes: ["bun", "node", "deno"], title: "Managed Chat Runtime", description: "In-memory managed chat send, watch, and interrupt runtime", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -351,6 +357,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-ws-presence"), + supportedRuntimes: ["bun", "node", "deno"], title: "WebSocket Presence Server", description: "Real-time presence tracking over WebSocket RPC", supportedOn: [{ _tag: "kind", kind: serverKind }], @@ -401,6 +408,7 @@ export const serverModules: ReadonlyArray = [ }, { id: ModuleId.make("server-devtools"), + supportedRuntimes: ["bun", "node", "deno"], title: "Effect DevTools Server", description: "Optional Effect DevTools tracer layer for server apps", supportedOn: [{ _tag: "kind", kind: serverKind }], diff --git a/packages/catalog/src/registry/targetRegistry.ts b/packages/catalog/src/registry/targetRegistry.ts index b3bc4cdf..58ea7e53 100644 --- a/packages/catalog/src/registry/targetRegistry.ts +++ b/packages/catalog/src/registry/targetRegistry.ts @@ -39,6 +39,7 @@ import { import { configTypescriptBaseContents, configTypescriptPackageJsonContents, + denoJsonContents, gitignoreContents, pnpmWorkspaceContents, rootPackageJsonContents, @@ -62,6 +63,7 @@ import { export const targetRegistry: ReadonlyArray = [ { kind: TargetKind.make("workspace"), + supportedRuntimes: ["bun", "node", "deno"], title: "Project Initialization", description: "Set up a new project with recommended structure and configuration", @@ -78,6 +80,11 @@ export const targetRegistry: ReadonlyArray = [ path: "{{targetPath}}/package.json", contents: rootPackageJsonContents, }, + { + _tag: "file", + path: "{{#if runtime=deno}}{{targetPath}}/deno.json{{/if}}", + contents: denoJsonContents, + }, { _tag: "file", path: "{{#if packageManager=pnpm}}{{targetPath}}/pnpm-workspace.yaml{{/if}}", @@ -103,6 +110,7 @@ export const targetRegistry: ReadonlyArray = [ { kind: TargetKind.make("client-react"), + supportedRuntimes: ["bun", "node", "deno"], title: "Client React Application", description: "A frontend application built with React", defaultName: "web", @@ -221,13 +229,14 @@ export const targetRegistry: ReadonlyArray = [ { label: "Install shadcn client components", command: - "bunx shadcn@latest add button card input switch --yes --overwrite", + "{{#if runtime=deno}}deno run -A npm:shadcn@latest add button card input switch --yes --overwrite{{/if}}{{#if runtime=bun}}bunx shadcn@latest add button card input switch --yes --overwrite{{/if}}{{#if runtime=node}}bunx shadcn@latest add button card input switch --yes --overwrite{{/if}}", }, ], }, { kind: TargetKind.make("client-foldkit"), + supportedRuntimes: ["bun", "node", "deno"], title: "Client Foldkit Application", description: "A frontend application built with Foldkit (Elm Architecture)", defaultName: "web", @@ -336,6 +345,7 @@ export const targetRegistry: ReadonlyArray = [ { kind: TargetKind.make("server"), + supportedRuntimes: ["bun", "node", "deno"], title: "Server Application", description: "A backend application, such as an API server", defaultName: "api", @@ -377,7 +387,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "build", value: - "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}", + "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}{{#if runtime=deno}}deno compile --allow-env --allow-net --allow-read --allow-write --allow-ffi --output dist/{{packageName}} src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -392,7 +402,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "dev", value: - "{{#if runtime=bun}}bun run src/index.ts{{/if}}{{#if runtime=node}}tsx src/index.ts{{/if}}", + "{{#if runtime=bun}}bun run src/index.ts{{/if}}{{#if runtime=node}}tsx src/index.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-net --allow-read --allow-write src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -400,7 +410,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "dev:watch", value: - "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}", + "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}{{#if runtime=deno}}deno run --watch --allow-env --allow-net --allow-read --allow-write src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -428,6 +438,7 @@ export const targetRegistry: ReadonlyArray = [ { kind: TargetKind.make("server-mcp"), + supportedRuntimes: ["bun", "node", "deno"], title: "MCP Server Application", description: "A Model Context Protocol server with composable tools, prompts, and resources", @@ -470,7 +481,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "build", value: - "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}", + "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}{{#if runtime=deno}}deno compile --allow-env --allow-net --allow-read --allow-write --output dist/{{packageName}} src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -485,7 +496,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "dev", value: - "{{#if runtime=bun}}bun run src/index.ts{{/if}}{{#if runtime=node}}tsx src/index.ts{{/if}}", + "{{#if runtime=bun}}bun run src/index.ts{{/if}}{{#if runtime=node}}tsx src/index.ts{{/if}}{{#if runtime=deno}}deno run --allow-env --allow-net --allow-read --allow-write src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -493,7 +504,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "dev:watch", value: - "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}", + "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}{{#if runtime=deno}}deno run --watch --allow-env --allow-net --allow-read --allow-write src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -520,17 +531,18 @@ export const targetRegistry: ReadonlyArray = [ _tag: "pkg-json-entry", path: "{{targetPath}}/package.json", field: "scripts", - name: "inspector", + name: "{{#if runtime=bun}}inspector{{/if}}{{#if runtime=node}}inspector{{/if}}", value: "bun run dev & sleep 2 && npx @mcpjam/inspector@latest; kill %1", }, ], nextSteps: [ - "MCP Server: run `bun dev --filter={{packageName}}` and connect an MCP client to http://localhost:9009/mcp, or run `bun --filter={{packageName}} run inspector` to open MCPJam Inspector.", + "MCP Server: run `{{#if runtime=deno}}deno task --filter {{packageName}} dev{{/if}}{{#if runtime=bun}}bun dev --filter={{packageName}}{{/if}}{{#if runtime=node}}bun dev --filter={{packageName}}{{/if}}` and connect an MCP client to http://localhost:9009/mcp{{#if runtime=bun}}, or run `bun --filter={{packageName}} run inspector` to open MCPJam Inspector{{/if}}{{#if runtime=node}}, or run `bun --filter={{packageName}} run inspector` to open MCPJam Inspector{{/if}}.", ], }, { kind: TargetKind.make("cli"), + supportedRuntimes: ["bun", "node", "deno"], title: "CLI Application", description: "A command-line interface application", defaultName: "app", @@ -572,7 +584,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "build", value: - "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}", + "{{#if runtime=bun}}bun build src/index.ts --outdir=dist --target=bun --minify{{/if}}{{#if runtime=node}}esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js{{/if}}{{#if runtime=deno}}deno compile --allow-env --allow-net --allow-read --allow-write --output dist/{{packageName}} src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -587,7 +599,7 @@ export const targetRegistry: ReadonlyArray = [ field: "scripts", name: "dev", value: - "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}", + "{{#if runtime=bun}}bun --watch run src/index.ts{{/if}}{{#if runtime=node}}tsx watch src/index.ts{{/if}}{{#if runtime=deno}}deno run --watch --allow-env --allow-net --allow-read --allow-write src/index.ts{{/if}}", }, { _tag: "pkg-json-entry", @@ -615,6 +627,7 @@ export const targetRegistry: ReadonlyArray = [ { kind: TargetKind.make("package"), + supportedRuntimes: ["bun", "node", "deno"], title: "Shared Package", description: "A shared library package for code reuse across targets", visibility: "internal", diff --git a/packages/domain/src/Catalog.ts b/packages/domain/src/Catalog.ts index 05bf66d7..74adf63a 100644 --- a/packages/domain/src/Catalog.ts +++ b/packages/domain/src/Catalog.ts @@ -288,6 +288,8 @@ export const ModuleChild = Schema.Struct({ requirement: Schema.Literals(["required", "optional"]), }); +export const SupportedRuntime = Schema.Literals(["bun", "node", "deno"]); + export const ModuleDefinition = Schema.Struct({ id: ModuleId, title: Schema.String, @@ -305,6 +307,7 @@ export const ModuleDefinition = Schema.Struct({ Schema.withConstructorDefault(Effect.succeed([])), ), supportedOn: Schema.Array(SupportedOn), + supportedRuntimes: Schema.optional(Schema.Array(SupportedRuntime)), dependencies: Schema.Array(ModuleDependency), implies: Schema.Array(ModuleImplication).pipe( Schema.optionalKey, @@ -354,6 +357,7 @@ export const TargetDefinition = Schema.Struct({ Schema.optionalKey, Schema.withConstructorDefault(Effect.succeed([])), ), + supportedRuntimes: Schema.optional(Schema.Array(SupportedRuntime)), contributions: Schema.Array(Contribution), scripts: Schema.Array(ScriptDefinition).pipe( Schema.optionalKey, diff --git a/packages/domain/src/Scaffold.test.ts b/packages/domain/src/Scaffold.test.ts index 9170aca3..f467e6f9 100644 --- a/packages/domain/src/Scaffold.test.ts +++ b/packages/domain/src/Scaffold.test.ts @@ -201,6 +201,20 @@ describe("StackConfig TypeScript version", () => { }); }); +describe("StackConfig Deno runtime", () => { + it("decodes Deno with Deno-owned package and workspace commands", () => { + const config = Schema.decodeUnknownSync(StackConfig)({ + name: "deno-project", + runtime: { _tag: "deno" }, + typescript: "6", + }); + + expect(config.runtimeName).toBe("deno"); + expect(config.packageManagerName).toBe("deno"); + expect(config.workspaceDependency).toBe("workspace:*"); + }); +}); + describe("ContributionTokenContext.resolve", () => { const makeContext = ( configOverrides: Partial = {}, @@ -339,6 +353,19 @@ describe("ContributionTokenContext.resolve", () => { }); describe("equality conditionals", () => { + it("selects the native workspace task when no monorepo tool is configured", () => { + expect( + makeContext({}).resolve( + "{{#if monorepo}}external{{/if}}{{#if noMonorepo}}native{{/if}}", + ), + ).toBe("native"); + expect( + makeContext({ monorepo: "nx" }).resolve( + "{{#if monorepo}}external{{/if}}{{#if noMonorepo}}native{{/if}}", + ), + ).toBe("external"); + }); + it("should resolve an equality conditional when its value contains a hyphen", () => { const ctx = makeContext({ monorepo: "vite-plus" }); expect( diff --git a/packages/domain/src/Scaffold.ts b/packages/domain/src/Scaffold.ts index 87cabc90..40525be3 100644 --- a/packages/domain/src/Scaffold.ts +++ b/packages/domain/src/Scaffold.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Match, Schema } from "effect"; import { Contribution, ModuleId, TargetIdentity, TargetKey } from "./Catalog"; export const TargetContribution = Schema.Struct({ @@ -17,13 +17,43 @@ export const NormalizedContributions = Schema.Struct({ modules: Schema.Array(ModuleContribution), }); -const Runtime = Schema.TaggedUnion({ +export const Runtime = Schema.TaggedUnion({ bun: {}, + deno: {}, node: { packageManager: Schema.Literals(["pnpm", "npm"]), }, }); +export type RuntimeName = (typeof Runtime.Type)["_tag"]; +export type PackageManagerName = "bun" | "deno" | "pnpm" | "npm"; + +const packageManagerRuntimes: Record = { + bun: "bun", + deno: "deno", + pnpm: "node", + npm: "node", +}; + +export const runtimeForPackageManager = ( + packageManager: PackageManagerName, +): RuntimeName => packageManagerRuntimes[packageManager]; + +export const makeRuntime = ( + runtime: RuntimeName, + packageManager: PackageManagerName, +): typeof Runtime.Type => + Match.value(runtime).pipe( + Match.when("bun", () => ({ _tag: "bun" as const })), + Match.when("deno", () => ({ _tag: "deno" as const })), + Match.when("node", () => ({ + _tag: "node" as const, + packageManager: + packageManager === "npm" ? ("npm" as const) : ("pnpm" as const), + })), + Match.exhaustive, + ); + export const TypeScriptVersion = Schema.Literals(["6", "7"]); export class StackConfig extends Schema.Class("StackConfig")({ @@ -39,13 +69,14 @@ export class StackConfig extends Schema.Class("StackConfig")({ return this.typescript ?? "6"; } - get runtimeName(): "bun" | "node" { + get runtimeName(): "bun" | "deno" | "node" { return this.runtime._tag; } - get packageManagerName(): "bun" | "npm" | "pnpm" { + get packageManagerName(): "bun" | "deno" | "npm" | "pnpm" { return Runtime.match(this.runtime, { bun: () => "bun" as const, + deno: () => "deno" as const, node: (r) => r.packageManager, }); } @@ -54,6 +85,8 @@ export class StackConfig extends Schema.Class("StackConfig")({ switch (this.packageManagerName) { case "bun": return "bun@1.4.2"; + case "deno": + return "deno@2.9.4"; case "npm": return "npm@10.9.0"; case "pnpm": @@ -78,8 +111,8 @@ export class ContributionTokenContext extends Schema.Class) => { return Cause.squash(exit.cause); }; +const denoConfig = new StackConfig({ + name: Schema.NonEmptyString.make("deno-project"), + runtime: { _tag: "deno" }, + typescript: "6", +}); + describe("BlueprintService", () => { layer(BlueprintService.layer)("resolve", (it) => { describe("when validating selections", () => { @@ -584,6 +591,88 @@ describe("BlueprintService", () => { }); }), ); + + it.effect( + "should resolve a supported Deno server and its dependencies", + () => + Effect.gen(function* () { + const blueprintService = yield* BlueprintService; + const blueprint = yield* blueprintService.resolve( + { + targets: [ + { + identity: serverApiIdentity, + modules: [], + }, + ], + }, + denoConfig, + ); + + expect( + getNode( + blueprint, + toAttachedModuleNodeId( + domainIdentity.toKey(), + ModuleId.make("domain-api-contracts"), + ), + ), + ).toMatchObject({ moduleId: "domain-api-contracts" }); + }), + ); + + it.effect("should resolve a Deno client and its Vite dependency", () => + Effect.gen(function* () { + const blueprintService = yield* BlueprintService; + const client = new TargetIdentity({ + kind: TargetKind.make("client-react"), + name: "web", + }); + const blueprint = yield* blueprintService.resolve( + { targets: [{ identity: client, modules: [] }] }, + denoConfig, + ); + + expect( + getNode( + blueprint, + toAttachedModuleNodeId( + client.toKey(), + ModuleId.make("config-typescript-vite"), + ), + ), + ).toMatchObject({ moduleId: "config-typescript-vite" }); + }), + ); + + it.effect("should reject Turbo for Deno before planning", () => + Effect.gen(function* () { + const blueprintService = yield* BlueprintService; + const failure = yield* Effect.flip( + blueprintService.resolve( + { + targets: [ + { + identity: new TargetIdentity({ + kind: TargetKind.make("workspace"), + name: "deno-project", + }), + modules: [ + { id: ModuleId.make("workspace-monorepo-turbo") }, + ], + }, + ], + }, + denoConfig, + ), + ); + + expect(failure).toMatchObject({ + message: + "Runtime deno does not support module workspace-monorepo-turbo.", + }); + }), + ); }); }); }); diff --git a/packages/scaffold/src/service/blueprint/BlueprintService.ts b/packages/scaffold/src/service/blueprint/BlueprintService.ts index 81e506b2..3237101b 100644 --- a/packages/scaffold/src/service/blueprint/BlueprintService.ts +++ b/packages/scaffold/src/service/blueprint/BlueprintService.ts @@ -12,6 +12,7 @@ import { type ModuleId, type TargetIdentity, } from "@repo/domain/Catalog"; +import type { StackConfig } from "@repo/domain/Scaffold"; import type { Selection } from "@repo/domain/Selection"; import { Array as Arr, @@ -46,6 +47,7 @@ export class BlueprintService extends Context.Service()( const resolve = Effect.fn("BlueprintService.resolve")(function* ( selection: typeof Selection.Type, + config?: typeof StackConfig.Type, ) { yield* validateSelection(selection, catalog); @@ -68,6 +70,28 @@ export class BlueprintService extends Context.Service()( ), ); + if (config !== undefined) { + yield* Effect.forEach(blueprint.nodes, (node) => + Effect.gen(function* () { + const definition = + node._tag === "target" + ? yield* catalog.getTarget(node.identity.kind) + : yield* catalog.getModule(node.moduleId); + const supportedRuntimes = definition.supportedRuntimes ?? [ + "bun", + "node", + ]; + if (!supportedRuntimes.includes(config.runtimeName)) { + return yield* Effect.fail( + new BlueprintFailure({ + message: `Runtime ${config.runtimeName} does not support ${node._tag === "target" ? `target ${node.identity.kind}` : `module ${node.moduleId}`}.`, + }), + ); + } + }), + ); + } + return blueprint.toSorted(); }); diff --git a/packages/scaffold/src/service/finalize/FinalizeService.test.ts b/packages/scaffold/src/service/finalize/FinalizeService.test.ts index 821e644f..1c52494d 100644 --- a/packages/scaffold/src/service/finalize/FinalizeService.test.ts +++ b/packages/scaffold/src/service/finalize/FinalizeService.test.ts @@ -236,6 +236,22 @@ describe("FinalizeService", () => { }).pipe(Effect.provide(makeFinalizeLayer([]))), ); + it.effect("runs Deno's prepare task after installing dependencies", () => + Effect.gen(function* () { + const svc = yield* FinalizeService; + const config = new StackConfig({ + name: "test" as typeof import("effect").Schema.NonEmptyString.Type, + runtime: { _tag: "deno" }, + }); + const scripts = yield* svc.preview(emptyBlueprint, makeConfig(config)); + + expect(scripts.map((script) => script.command)).toEqual([ + "deno install", + "deno task --if-present prepare", + ]); + }).pipe(Effect.provide(makeFinalizeLayer([]))), + ); + it.effect( "collects finalize scripts from target definitions before config-derived scripts", () => diff --git a/packages/scaffold/src/service/finalize/FinalizeService.ts b/packages/scaffold/src/service/finalize/FinalizeService.ts index c1442751..69b0ad8c 100644 --- a/packages/scaffold/src/service/finalize/FinalizeService.ts +++ b/packages/scaffold/src/service/finalize/FinalizeService.ts @@ -229,14 +229,21 @@ const buildConfigDerivedScripts = ( config: FinalizeConfig, ): ResolvedScript[] => { const { packageManagerName: pm, lint, format } = config.config; + const runTask = (task: string) => + pm === "deno" ? `deno task ${task}` : `${pm} run ${task}`; return Arr.filterMap( [ { when: true, label: "Install dependencies", command: `${pm} install` }, - { when: !!lint, label: `Run ${lint} lint`, command: `${pm} run lint` }, + { + when: pm === "deno", + label: "Prepare dependencies", + command: "deno task --if-present prepare", + }, + { when: !!lint, label: `Run ${lint} lint`, command: runTask("lint") }, { when: !!format, label: `Run ${format} format`, - command: `${pm} run format`, + command: runTask("format"), }, ], (entry): Result.Result => diff --git a/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts b/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts index 6a9d66c7..10357075 100644 --- a/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts +++ b/packages/scaffold/src/service/recipe/RecipePreviewService.test.ts @@ -1,4 +1,5 @@ import { assert, it } from "@effect/vitest"; +import { ModuleId, TargetIdentity, TargetKind } from "@repo/domain/Catalog"; import { StackConfig } from "@repo/domain/Scaffold"; import { Effect, Schema } from "effect"; import { RecipePreviewService } from "./RecipePreviewService"; @@ -33,6 +34,34 @@ const previewQualityConfig = ( }); }); +it.effect("should preview Deno SQLite files", () => + Effect.gen(function* () { + const previews = yield* RecipePreviewService; + const preview = yield* previews.preview({ + config: new StackConfig({ + name: Schema.NonEmptyString.make("deno-preview"), + runtime: { _tag: "deno" }, + typescript: "6", + }), + recipe: { + targets: [ + { + target: new TargetIdentity({ + kind: TargetKind.make("package"), + name: "db", + }), + modules: [ModuleId.make("package-db-sqlite")], + }, + ], + }, + }); + + assert.isTrue( + preview.files.some((file) => file.path === "packages/db/src/Database.ts"), + ); + }).pipe(Effect.provide(RecipePreviewService.layer)), +); + it.effect( "should preserve Biome import organization when dprint formatting is selected", () => diff --git a/packages/scaffold/src/service/recipe/RecipePreviewService.ts b/packages/scaffold/src/service/recipe/RecipePreviewService.ts index da23949a..68842921 100644 --- a/packages/scaffold/src/service/recipe/RecipePreviewService.ts +++ b/packages/scaffold/src/service/recipe/RecipePreviewService.ts @@ -53,7 +53,7 @@ export class RecipePreviewService extends Context.Service< config, providerStrategy: { _tag: "fail-on-ambiguous" }, }); - const blueprint = yield* blueprints.resolve(selection); + const blueprint = yield* blueprints.resolve(selection, config); const fileSystem = yield* MemoryFileSystem.makeCrypto; const path = yield* Path.Path.pipe(Effect.provide(Path.layer)); diff --git a/packages/scaffold/src/service/recipe/RecipeService.ts b/packages/scaffold/src/service/recipe/RecipeService.ts index 5f036ceb..72f23a33 100644 --- a/packages/scaffold/src/service/recipe/RecipeService.ts +++ b/packages/scaffold/src/service/recipe/RecipeService.ts @@ -10,7 +10,7 @@ import { type RecipeResolveOptions, } from "./RecipeErrors"; import { encodeRecipeTargetSpecs } from "./RecipeTargets"; -import { StackConfigDefaults } from "./StackConfigDefaults"; +import { defaultsForRuntime, StackConfigDefaults } from "./StackConfigDefaults"; import { toTypeScriptModuleId, toWorkspaceModuleId } from "./WorkspaceModules"; export { @@ -46,8 +46,8 @@ const quoteShellArg = (value: string) => const configPackageManager = ( runtime: (typeof StackConfig.Type)["runtime"], -): "bun" | "pnpm" | "npm" => - runtime._tag === "bun" ? "bun" : runtime.packageManager; +): "bun" | "deno" | "pnpm" | "npm" => + runtime._tag === "node" ? runtime.packageManager : runtime._tag; const configWorkspaceModules = ( config: typeof StackConfig.Type, @@ -230,8 +230,14 @@ export class RecipeService extends Context.Service< config, selection, }) => { - const commandRunner = config.runtime._tag === "bun" ? "bunx" : "npx"; + const commandRunner = + config.runtime._tag === "bun" + ? "bunx" + : config.runtime._tag === "deno" + ? "deno run -A npm:" + : "npx"; const packageManager = configPackageManager(config.runtime); + const runtimeDefaults = defaultsForRuntime(defaults, config.runtime._tag); const configModuleIds = new Set(configWorkspaceModules(config)); const targetFlags = pipe( selection.targets, @@ -259,30 +265,38 @@ export class RecipeService extends Context.Service< ); return [ - commandRunner, - "stack-effect@latest", + commandRunner === "deno run -A npm:" + ? "deno run -A npm:stack-effect@latest" + : commandRunner, + ...(commandRunner === "deno run -A npm:" + ? [] + : ["stack-effect@latest"]), "create", quoteShellArg(config.name), ...targetFlags, ...(config.runtime._tag === defaults.runtime._tag ? [] - : ["--runtime", "node"]), + : ["--runtime", config.runtime._tag]), ...(packageManager === defaults.packageManagerName ? [] : ["--package-manager", packageManager]), ...renderChangedFlag( "--typescript", config.typescriptVersion, - defaults.typescriptVersion, + runtimeDefaults.typescript, ), ...renderChangedFlag( "--monorepo", config.monorepo, - defaults.monorepo ?? "", + runtimeDefaults.monorepo ?? "", + ), + ...renderChangedFlag("--lint", config.lint, runtimeDefaults.lint ?? ""), + ...renderChangedFlag( + "--format", + config.format, + runtimeDefaults.format ?? "", ), - ...renderChangedFlag("--lint", config.lint, defaults.lint ?? ""), - ...renderChangedFlag("--format", config.format, defaults.format ?? ""), - ...renderChangedFlag("--test", config.test, defaults.test ?? ""), + ...renderChangedFlag("--test", config.test, runtimeDefaults.test ?? ""), ...(selectionIncludesWorkspaceModule(selection, "workspace-devenv-git") ? [] : ["--no-git"]), diff --git a/packages/scaffold/src/service/recipe/StackConfigDefaults.test.ts b/packages/scaffold/src/service/recipe/StackConfigDefaults.test.ts new file mode 100644 index 00000000..61140556 --- /dev/null +++ b/packages/scaffold/src/service/recipe/StackConfigDefaults.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { defaultsForRuntime } from "./StackConfigDefaults"; + +const defaults = { + name: "example", + runtime: { _tag: "bun" as const }, + typescript: "7" as const, + monorepo: "vite-plus", + lint: "oxlint", + format: "oxfmt", + test: "vitest", +}; + +describe("runtime defaults", () => { + it("keeps the configured defaults for Bun and Node", () => { + for (const runtime of ["bun", "node"] as const) { + expect(defaultsForRuntime(defaults, runtime)).toEqual({ + typescript: "7", + monorepo: "vite-plus", + lint: "oxlint", + format: "oxfmt", + test: "vitest", + }); + } + }); + + it("uses Deno defaults without copying unrelated configuration fields", () => { + expect(defaultsForRuntime(defaults, "deno")).toEqual({ + typescript: "6", + monorepo: undefined, + lint: undefined, + format: undefined, + test: "vitest", + }); + }); +}); diff --git a/packages/scaffold/src/service/recipe/StackConfigDefaults.ts b/packages/scaffold/src/service/recipe/StackConfigDefaults.ts index 1e9cd683..008a6e54 100644 --- a/packages/scaffold/src/service/recipe/StackConfigDefaults.ts +++ b/packages/scaffold/src/service/recipe/StackConfigDefaults.ts @@ -1,4 +1,4 @@ -import { StackConfig } from "@repo/domain/Scaffold"; +import { type RuntimeName, StackConfig } from "@repo/domain/Scaffold"; import { Context, Schema } from "effect"; export const StackConfigDefaults = Context.Reference( @@ -16,3 +16,35 @@ export const StackConfigDefaults = Context.Reference( }), }, ); + +type RuntimeDefaults = { + readonly typescript?: "6" | "7" | undefined; + readonly monorepo?: string | undefined; + readonly lint?: string | undefined; + readonly format?: string | undefined; + readonly test?: string | undefined; +}; + +const runtimeOverrides: Record> = { + bun: {}, + deno: { + typescript: "6", + monorepo: undefined, + lint: undefined, + format: undefined, + }, + node: {}, +}; + +export const defaultsForRuntime = ( + defaults: RuntimeDefaults, + runtime: RuntimeName, +): RuntimeDefaults & { readonly typescript: "6" | "7" } => ({ + monorepo: defaults.monorepo, + lint: defaults.lint, + format: defaults.format, + test: defaults.test, + ...runtimeOverrides[runtime], + typescript: + runtimeOverrides[runtime].typescript ?? defaults.typescript ?? "6", +});