Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deno-runtime.md
Original file line number Diff line number Diff line change
@@ -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.
191 changes: 191 additions & 0 deletions apps/cli/e2e/deno.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
});
});
69 changes: 48 additions & 21 deletions apps/cli/src/commands/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ApplyService,
BlueprintService,
ContributionResolver,
defaultsForRuntime,
FinalizeService,
PlanService,
parseRecipeTargetSpecs,
Expand Down Expand Up @@ -44,6 +45,7 @@ import {
monorepoFlag,
recipeTargetFlag,
rootFlag,
runtimeFlag,
typescriptFlag,
} from "../flags";

Expand Down Expand Up @@ -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 },
);
},
Expand Down Expand Up @@ -572,6 +575,7 @@ const reset = Command.make(
format: formatFlag,
root: rootFlag,
target: recipeTargetFlag,
runtime: runtimeFlag,
typescript: typescriptFlag,
monorepo: monorepoFlag,
},
Expand All @@ -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),
);
Expand All @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading