Skip to content

Commit bfa74ba

Browse files
committed
scaffold: add lib and worker templates; harness: round-robin templates, fewer dev/test soft-fails
Scaffold: - Add lib template (library + vitest), worker template (Effect Queue) - TEMPLATES now basic, service, cli, http-server, lib, worker Lifecycle harness: - Template selection: round-robin by scenario index (even distribution) - Skip bun run dev for http-server and when code is broken - Run bun run test with expectFailure when code is broken - Add soft-fail patterns for vitest/test failures - Extract isCodeCurrentlyBroken to code-broken.ts with tests - Output checks, list parsing, scaffold-validate, report-summary modules and tests ep-cli: - PatternNotFoundError for ep show when pattern not found; exit non-zero - Login/auth docs and EP_AUTH_URL support API server and docs updates. Made-with: Cursor
1 parent f2a8a96 commit bfa74ba

32 files changed

Lines changed: 1456 additions & 86 deletions

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Development workflows, testing, and CI/CD.
4343
- **[PUBLISHING_PIPELINE.md](./development/PUBLISHING_PIPELINE.md)** -
4444
Content publishing workflow
4545
- **[TESTING.md](./development/TESTING.md)** - Testing guide
46+
- **[SCAFFOLD_USER_GUIDE.md](./development/SCAFFOLD_USER_GUIDE.md)** - Scaffold a new Effect test project (templates, tools, next steps)
4647

4748
### [Jobs To Be Done](./jobs-to-be-done/)
4849

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Scaffold script user guide
2+
3+
The scaffold script creates a new TypeScript/Effect project with a chosen template and optionally installs Effect Patterns rules for AI tools (Cursor, VS Code, Windsurf, Agents). Run it from the **Effect-Patterns repository root**.
4+
5+
**Quick start:**
6+
7+
```bash
8+
bun run scaffold my-app --template service
9+
```
10+
11+
---
12+
13+
## Prerequisites
14+
15+
- **Bun** — used to run the script and to install dependencies in the new project
16+
- **Git** — the script runs `git init` and an initial commit
17+
- **Effect Patterns repo** — run the command from the repo root (where `package.json` and `scripts/` live)
18+
19+
Rule installation (`ep install add --tool <tool>`) may call the Effect Patterns API. If the API is unavailable or not configured, the script continues and reports which tools failed; you can retry later from the new project directory.
20+
21+
---
22+
23+
## How to run
24+
25+
**Command:** `bun run scaffold` (defined in the root `package.json`).
26+
27+
### Interactive mode
28+
29+
No arguments: the script prompts for project name, template, and tools.
30+
31+
```bash
32+
bun run scaffold
33+
```
34+
35+
### Non-interactive mode
36+
37+
Pass the project name; optionally pass `--template` and one or more `--tool` options.
38+
39+
```bash
40+
bun run scaffold my-app
41+
```
42+
43+
- Uses the **basic** template and installs rules for **all** tools.
44+
45+
```bash
46+
bun run scaffold my-app --template service
47+
```
48+
49+
- Uses the **service** template and installs rules for all tools.
50+
51+
```bash
52+
bun run scaffold my-app --template cli --tool cursor --tool agents
53+
```
54+
55+
- Uses the **cli** template and installs rules only for Cursor and Agents.
56+
57+
---
58+
59+
## Options
60+
61+
| Option | Description |
62+
|--------|-------------|
63+
| `--template <name>` | Project template. One of: `basic`, `service`, `cli`, `http-server`. |
64+
| `--tool <name>` | Tool to install Effect Patterns rules for; repeatable. Values: `agents`, `cursor`, `vscode`, `windsurf`. |
65+
66+
If you provide a project name but omit `--tool`, the script installs rules for all four tools.
67+
68+
---
69+
70+
## Templates
71+
72+
| Template | Description |
73+
|----------|-------------|
74+
| **basic** | Minimal Effect app: `Console.log` and `Effect.runPromise`. |
75+
| **service** | Effect.Service example (Greeter) plus a Vitest test. |
76+
| **cli** | @effect/cli app with a `hello` subcommand. |
77+
| **http-server** | @effect/platform HTTP server with a `/health` route. |
78+
79+
Each template adds the right dependencies and starter files under `src/`.
80+
81+
---
82+
83+
## Output location and contents
84+
85+
**Directory:** Projects are created under `$HOME/Projects/TestRepos/<project-name>` (e.g. `~/Projects/TestRepos/my-app`). There is no option to change this path.
86+
87+
**Steps the script performs:**
88+
89+
1. Create the project directory and `src/`
90+
2. Write `package.json` (template-specific dependencies), `tsconfig.json`, `.gitignore`
91+
3. Write template files into `src/`
92+
4. Run `bun install`
93+
5. Run `git init` and create an initial commit
94+
6. For each selected tool, run `ep install add --tool <tool>` (using the repo’s ep-cli)
95+
96+
If `ep install add` fails for a tool (e.g. API unavailable), the script prints a warning and continues. The final summary includes a retry command for failed tools.
97+
98+
---
99+
100+
## Environment variables
101+
102+
| Variable | Purpose |
103+
|----------|---------|
104+
| **HOME** | Used to build the output path `$HOME/Projects/TestRepos/<name>`. If unset, the script falls back to `/Users/paul`. |
105+
| **EFFECT_PATTERNS_API_URL** | If set, passed to the `ep install add` subprocess (e.g. for a local or staging API). Rule installation may fail if the API is unreachable or not configured. |
106+
107+
---
108+
109+
## Troubleshooting
110+
111+
**"Directory already exists"** — The script will not overwrite an existing directory. Use a different project name or remove the existing directory.
112+
113+
**"Unknown template" / "Unknown tool"** — Use only the supported values:
114+
- Templates: `basic`, `service`, `cli`, `http-server`
115+
- Tools: `agents`, `cursor`, `vscode`, `windsurf`
116+
117+
**ep install failed** — The script prints a warning and a retry command, e.g. `cd <projectDir> && bun run <ep-cli-entry> install add --tool <tool>`. You may need to configure an API key or `EFFECT_PATTERNS_API_URL`; see the [ep-cli README](../../packages/ep-cli/README.md) and project [MCP_CONFIG.md](../../MCP_CONFIG.md) for API and key setup.
118+
119+
---
120+
121+
## Next steps
122+
123+
After scaffolding:
124+
125+
```bash
126+
cd ~/Projects/TestRepos/<project-name>
127+
bun run dev
128+
```
129+
130+
For the **service** template, run tests with:
131+
132+
```bash
133+
bun run test
134+
```

packages/api-server/app/api/patterns/[id]/route.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,16 @@ import { getPatternByIdDb } from "@effect-patterns/toolkit";
1212
import { Effect } from "effect";
1313
import { type NextRequest, NextResponse } from "next/server";
1414
import { randomUUID } from "crypto";
15-
import {
16-
validateApiKey,
17-
} from "../../../../src/auth/apiKey";
1815
import { PatternNotFoundError } from "../../../../src/errors";
1916
import { errorHandler, errorToResponse } from "../../../../src/server/errorHandler";
2017
import { runWithRuntime } from "../../../../src/server/init";
2118
import { getPatternByIdFallback } from "../../../../src/server/pattern-fallback";
2219
import { TracingService } from "../../../../src/tracing/otlpLayer";
2320

2421
// Handler implementation with automatic span creation via Effect.fn
25-
const handleGetPattern = (request: NextRequest, patternId: string) => Effect.gen(function* () {
22+
const handleGetPattern = (_request: NextRequest, patternId: string) => Effect.gen(function* () {
2623
const tracing = yield* TracingService;
2724

28-
// Validate API key
29-
yield* validateApiKey(request);
30-
3125
// Annotate span with pattern ID
3226
yield* Effect.annotateCurrentSpan({
3327
patternId,

packages/api-server/app/api/patterns/route.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@ import { searchPatternsDb, type Pattern } from "@effect-patterns/toolkit";
1212
import { Effect } from "effect";
1313
import { type NextRequest, NextResponse } from "next/server";
1414
import { randomUUID } from "crypto";
15-
import {
16-
validateApiKey,
17-
} from "../../../src/auth/apiKey";
1815
import { ValidationError } from "../../../src/errors";
1916
import { errorHandler, errorToResponse } from "../../../src/server/errorHandler";
2017
import { runWithRuntime } from "../../../src/server/init";
@@ -45,9 +42,6 @@ const handleSearchPatterns = Effect.fn("search-patterns")(function* (
4542
) {
4643
const tracing = yield* TracingService;
4744

48-
// Validate API key
49-
yield* validateApiKey(request);
50-
5145
// Extract query parameters
5246
const { searchParams } = new URL(request.url);
5347
const query = searchParams.get("q") || undefined;

packages/ep-cli/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ If stdin is empty, command fails with:
127127
| `PATTERN_API_KEY` | API key sent as `x-api-key` to pattern API | unset |
128128
| `EP_API_KEY_FILE` | File containing API key text | unset |
129129
| `EP_CONFIG_FILE` | Path to JSON config (expects `{"apiKey":"..."}`) | `${XDG_CONFIG_HOME:-~/.config}/ep-cli/config.json` |
130+
| `EP_AUTH_URL` | Base URL for `ep login` browser auth (use if default returns 404) | `https://effecttalk.dev/cli/auth` |
130131
| `EFFECT_PATTERNS_API_URL` | Base URL for pattern API | `https://effect-patterns-mcp-server-buddybuilder.vercel.app` |
131132
| `EP_API_TIMEOUT_MS` | Request timeout in ms | `10000` |
132133
| `EP_INSTALLED_STATE_FILE` | Installed-rules state file path override | `${XDG_STATE_HOME:-~/.local/state}/ep-cli/installed-rules.json` |
@@ -577,6 +578,21 @@ Other install targets:
577578

578579
## Troubleshooting
579580

581+
### `ep login` opens a page that returns 404
582+
583+
Symptom:
584+
585+
- Browser opens `https://effecttalk.dev/cli/auth` but the page shows "404 Not Found".
586+
587+
The EffectTalk auth page may not be deployed yet. Use manual API key setup instead:
588+
589+
1. Obtain an API key from your Effect Patterns / API provider.
590+
2. Set `PATTERN_API_KEY` in your environment, or
591+
3. Put the key in a file and set `EP_API_KEY_FILE` to its path, or
592+
4. Write `{"apiKey":"your-key"}` to `~/.config/ep-cli/config.json` (or set `EP_CONFIG_FILE` to another path).
593+
594+
Then run `ep list` or `ep search …` as usual.
595+
580596
### Unauthorized (401)
581597

582598
Symptom:

packages/ep-cli/src/__tests__/cli-contract.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,12 @@ describe("ep-cli stream and machine-mode contracts", () => {
148148
expect(result.status).toBe(1);
149149
expect(result.stderr).toContain("No API key was provided on stdin");
150150
});
151+
152+
it("exits non-zero and prints not found when pattern id does not exist", () => {
153+
const result = runCli(["show", "nonexistent-pattern-id-xyz"]);
154+
155+
expect(result.status).not.toBe(0);
156+
const combined = `${result.stdout}\n${result.stderr}`;
157+
expect(combined).toMatch(/not found/i);
158+
});
151159
});

packages/ep-cli/src/__tests__/login-command.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
import { afterEach, beforeEach, describe, expect, it } from "vitest";
66
import { Effect } from "effect";
7-
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
7+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
8+
import http from "node:http";
89
import path from "node:path";
910
import { tmpdir } from "node:os";
1011
import { resolveConfigPath, writeConfig } from "../services/config/writer.js";
12+
import { closeServer, tryListen, waitForCallback } from "../commands/login-command.js";
1113

1214
describe("resolveConfigPath", () => {
1315
const originalEnv = { ...process.env };
@@ -85,6 +87,26 @@ describe("writeConfig", () => {
8587
expect(existsSync(nestedPath)).toBe(true);
8688
});
8789

90+
it("sets config file permissions to 0o600", async () => {
91+
const configPath = await Effect.runPromise(
92+
writeConfig({ apiKey: "key", email: "e@x.com" })
93+
);
94+
95+
expect(statSync(configPath).mode & 0o777).toBe(0o600);
96+
});
97+
98+
it("creates parent directory with restricted permissions (0o700)", async () => {
99+
const nestedPath = path.join(testDir, "restricted-dir", "config.json");
100+
process.env.EP_CONFIG_FILE = nestedPath;
101+
102+
await Effect.runPromise(
103+
writeConfig({ apiKey: "key", email: "e@x.com" })
104+
);
105+
106+
const dirMode = statSync(path.dirname(nestedPath)).mode & 0o777;
107+
expect(dirMode).toBe(0o700);
108+
});
109+
88110
it("overwrites existing config", async () => {
89111
await Effect.runPromise(
90112
writeConfig({ apiKey: "old-key", email: "old@x.com" })
@@ -99,3 +121,126 @@ describe("writeConfig", () => {
99121
expect(content.email).toBe("new@x.com");
100122
});
101123
});
124+
125+
describe("tryListen", () => {
126+
let server: http.Server;
127+
128+
beforeEach(() => {
129+
server = http.createServer();
130+
});
131+
132+
afterEach(async () => {
133+
await Effect.runPromise(closeServer(server)).catch(() => {});
134+
});
135+
136+
it("succeeds on a free port", async () => {
137+
const port = await Effect.runPromise(tryListen(server, 0));
138+
expect(port).toBe(0);
139+
});
140+
141+
it("fails when port is already in use", async () => {
142+
const blocker = http.createServer();
143+
// Listen on OS-assigned port first
144+
await new Promise<void>((resolve) => {
145+
blocker.listen(0, "127.0.0.1", resolve);
146+
});
147+
const usedPort = (blocker.address() as { port: number }).port;
148+
149+
const result = await Effect.runPromise(
150+
tryListen(server, usedPort).pipe(Effect.either)
151+
);
152+
153+
expect(result._tag).toBe("Left");
154+
155+
await new Promise<void>((resolve) => {
156+
blocker.close(() => resolve());
157+
});
158+
});
159+
});
160+
161+
describe("waitForCallback", () => {
162+
let server: http.Server;
163+
let port: number;
164+
const state = "test-state-abc";
165+
166+
beforeEach(async () => {
167+
server = http.createServer();
168+
await new Promise<void>((resolve) => {
169+
server.listen(0, "127.0.0.1", resolve);
170+
});
171+
port = (server.address() as { port: number }).port;
172+
});
173+
174+
afterEach(async () => {
175+
await Effect.runPromise(closeServer(server)).catch(() => {});
176+
});
177+
178+
it("returns apiKey and email on valid callback", async () => {
179+
const resultPromise = Effect.runPromise(waitForCallback(server, port, state));
180+
181+
// Send a valid callback
182+
await fetch(
183+
`http://127.0.0.1:${port}/callback?state=${state}&apiKey=key-123&email=test@example.com`
184+
);
185+
186+
const result = await resultPromise;
187+
expect(result.apiKey).toBe("key-123");
188+
expect(result.email).toBe("test@example.com");
189+
});
190+
191+
it("returns 404 for non-callback paths", async () => {
192+
// Start waiting (don't await — it blocks until callback)
193+
const resultPromise = Effect.runPromise(waitForCallback(server, port, state));
194+
195+
const response = await fetch(`http://127.0.0.1:${port}/other`);
196+
expect(response.status).toBe(404);
197+
198+
// Now send valid callback to unblock
199+
await fetch(
200+
`http://127.0.0.1:${port}/callback?state=${state}&apiKey=key-123&email=e@x.com`
201+
);
202+
await resultPromise;
203+
});
204+
205+
it("returns 403 for invalid state", async () => {
206+
const resultPromise = Effect.runPromise(waitForCallback(server, port, state));
207+
208+
const response = await fetch(
209+
`http://127.0.0.1:${port}/callback?state=wrong-state&apiKey=key-123`
210+
);
211+
expect(response.status).toBe(403);
212+
213+
// Clean up by sending valid callback
214+
await fetch(
215+
`http://127.0.0.1:${port}/callback?state=${state}&apiKey=key-123&email=e@x.com`
216+
);
217+
await resultPromise;
218+
});
219+
220+
it("fails when apiKey is missing", async () => {
221+
const resultPromise = Effect.runPromise(
222+
waitForCallback(server, port, state).pipe(Effect.either)
223+
);
224+
225+
await fetch(`http://127.0.0.1:${port}/callback?state=${state}`);
226+
227+
const either = await resultPromise;
228+
expect(either._tag).toBe("Left");
229+
});
230+
231+
it("returns 409 on duplicate callback after resolution", async () => {
232+
const resultPromise = Effect.runPromise(waitForCallback(server, port, state));
233+
234+
// First valid callback
235+
await fetch(
236+
`http://127.0.0.1:${port}/callback?state=${state}&apiKey=key-123&email=e@x.com`
237+
);
238+
await resultPromise;
239+
240+
// Second callback should get 409
241+
const response = await fetch(
242+
`http://127.0.0.1:${port}/callback?state=${state}&apiKey=key-456&email=e2@x.com`
243+
);
244+
expect(response.status).toBe(409);
245+
});
246+
});

0 commit comments

Comments
 (0)