@ahmed-3m/idea2prototype 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +15 -11
  3. package/agents/AGENTS.md +11 -9
  4. package/agents/CLAUDE.md +11 -9
  5. package/package.json +2 -2
  6. package/skills/idea2prototype/SKILL.md +2 -0
  7. package/skills/idea2prototype/scripts/idea2prototype.mjs +174 -28
  8. package/skills/idea2prototype/templates/api/.idea2prototype.json +1 -1
  9. package/skills/idea2prototype/templates/api/package.json +1 -1
  10. package/skills/idea2prototype/templates/api/server.mjs +37 -6
  11. package/skills/idea2prototype/templates/cli/.idea2prototype.json +1 -1
  12. package/skills/idea2prototype/templates/cli/package.json +1 -1
  13. package/skills/idea2prototype/templates/features/api-crud/route.tmpl.mjs +40 -10
  14. package/skills/idea2prototype/templates/features/auth-local/route.tmpl.mjs +44 -10
  15. package/skills/idea2prototype/templates/features/rate-limit/route.tmpl.mjs +4 -2
  16. package/skills/idea2prototype/templates/features/rate-limit/test.tmpl.mjs +22 -2
  17. package/skills/idea2prototype/templates/features/worker-jobs/jobs.tmpl.mjs +23 -3
  18. package/skills/idea2prototype/templates/library/.idea2prototype.json +1 -1
  19. package/skills/idea2prototype/templates/library/package.json +1 -1
  20. package/skills/idea2prototype/templates/web/.idea2prototype.json +1 -1
  21. package/skills/idea2prototype/templates/web/package.json +1 -1
  22. package/skills/idea2prototype/templates/web/server.mjs +36 -5
  23. package/skills/idea2prototype/templates/worker/.idea2prototype.json +1 -1
  24. package/skills/idea2prototype/templates/worker/package.json +1 -1
  25. package/skills/idea2prototype/templates/worker/worker.mjs +23 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0
4
+
5
+ First stable release. The scaffold contract — route objects, check/verify verdicts, `add`-module shapes — is frozen as-is; 1.x commits to additive changes only.
6
+
7
+ - Stress: every finding of the 2026-10-20 stress test (#49–#60) triaged and resolved. Data stores that fail to parse are quarantined (`data/<name>.json.corrupt-<ts>`, original bytes preserved) instead of silently wiped; single-writer and stateless-token semantics documented in the README; `ci.yml` runs with minimal permissions; README status line pinned by CI.
8
+ - Fix: `verify` (judge mode) discovers tests by node:test's own conventions (`*.test.{js,mjs,cjs}`, `test-*.js`, `*-test.js`/`*_test.js`, `test.js`, and everything under a `test/` dir, at any depth) — no more false PASS when failing tests live outside `tests/*.test.mjs` — and never reads or executes anything under `uploads/`, `data/`, `dist/`, `coverage/`, or `node_modules/`. The verdict header names its mode, boot-skip names the real cause, and an empty directory fails.
9
+ - Fix: `new` validates everything before writing a single file — reserved Windows device names (CON, NUL, COM1–9, …) and a 200-char cap apply to the final directory name AND every path component that will be created; quotes/backslashes/control characters in `--name`/`--idea` die instead of poisoning the JSON manifest mid-scaffold.
10
+ - Fix: the CLI never silently ignores input it doesn't understand — unknown flags, empty or flag-like flag values, and extra positional arguments die with the allowed list on every command; nested `new sub/proj` (missing parents created) works while an unusable parent dies with a readable message; a hung test run reports a `tests timed out` verdict on every supported Node (22's file-level runner timeout and 24's spawn-budget timeout both covered).
11
+ - Fix: `rateLimit({...})` instances own their buckets — independently configured wrappers no longer drain one shared budget (#54).
12
+ - Fix: the server templates answer oversize bodies with 413 before destroying the socket (up to 2× the limit drained so the 413 can be delivered; beyond that destroyed to bound the cost), turn contract-violating handler returns into clean 500s, clamp non-standard statuses, and send `null` for `[status]`-only returns. Both web and api templates are covered by live e2e tests.
13
+ - Fix: the package identity is scoped everywhere in-repo — `package.json` is `@ahmed-3m/idea2prototype` (what the release pipeline actually publishes); `publish.yml` no longer rewrites the name at publish time; tarball/global installs and the engine's registry-pin detection agree (pinned by a consistency test).
14
+ - CI: SHA-pinned actions upgraded to current majors (checkout v7.0.1, setup-node v7.1.0) plus weekly Dependabot bump-PRs so the pins stop rotting; nothing auto-merges.
15
+ - Benchmark: the 10-spec paired benchmark set is complete (this release archives specs 5–6 and 8–10: quiz, pomodoro, echo chat, habit tracker, word-counter CLI; spec 7 landed in 0.9.0). Across n=10: hand-written LOC −10%…−64% on 7 of 8 build-type specs (spec 6 at parity) and generated tests 2–3× the control's (parity on spec 6), full-depth verify vs. the control's recurring boot-skip; wall-clock split honestly, with the from-scratch arm faster on several small builds. All rubrics pre-registered; all judges mechanical.
16
+ - Tests: engine + consistency suite 46 → 69.
17
+
18
+ ## 0.9.1
19
+
20
+ - Fix: scaffolds made through a published install (`npx`, `npm i -g`) now pin registry coordinates in the check script (`npx -y @ahmed-3m/idea2prototype@<scaffold-version> check`) instead of an absolute path into the volatile npx cache — `npm run check` survives `npm cache clean` and runs on a second machine. Checkouts (repo clone, plugin cache) still pin the stable local engine path; the exact-version guarantee is unchanged either way. Template placeholder renamed `__IDEA2PROTOTYPE_BIN__` → `__IDEA2PROTOTYPE_CMD__` to match the broader contract.
21
+ - CI: release publishes now fail loudly when `NPM_TOKEN` is missing instead of skipping with a warning — npmjs is the documented primary channel, and a mirror-only release with a green run would silently invert the README's install claims. Manual `workflow_dispatch` without the secret keeps the visible skip.
22
+
3
23
  ## 0.9.0
4
24
 
5
25
  - Feature: `add worker-jobs` — the worker template's persistent job queue as a feature module for web/api projects: `enqueue`/`list`/`registerHandler`, `data/jobs.json` persistence, 3 attempts with a terminal `failed` state, disposable demo routes `POST/GET /api/jobs`. `enqueue` auto-kicks a background drain; concurrent enqueues share one serialized drain, so double-processing is designed out, and the drain exits on an empty queue (no long-lived timer; server `close()` unaffected). Closes the previously documented "web app + background jobs" cross-template gap. Review-hardened: memoized cold load (concurrent first enqueues share one array), chained atomic store writes (write-to-tmp + rename; a failed write can't poison later ones or leave a half-written store), honest drain diagnostics, handler contract documented.
package/README.md CHANGED
@@ -15,7 +15,7 @@ Idea2Prototype check — PASS (3/3)
15
15
  boot PASS GET /api/health -> 200, GET / -> 200 in 45ms
16
16
  ```
17
17
 
18
- **Status:** v0.8.0 · actively maintained · 0.x — the check/verify contract and template shapes are stable; new capabilities arrive as `add` modules.
18
+ **Status:** v1.0.0 · actively maintained · 1.x — the check/verify contract and template shapes are stable; new capabilities arrive as `add` modules.
19
19
 
20
20
  That verdict is the whole idea: an agent building a prototype should **never generate boilerplate it didn't need to, and never read logs it didn't need to**. The syntax step covers server code (`node --check`) and every `.js` under `public/`, recursively (parsed as both ESM and classic script — only code broken in both modes fails).
21
21
 
@@ -54,27 +54,31 @@ Idea2Prototype moves all three to deterministic code: `new` scaffolds a running,
54
54
 
55
55
  Templates: `web` (static frontend + health + auto-loading routes), `api` (JSON API + route manifest), `cli` (arg-parsed tool), `worker` (persistent job queue with retries), `library` (importable module: `createStore` + `memoAsync`). All Node >= 18.17, built-in `node:test`, zero installs — ever.
56
56
 
57
+ **JSON stores are single-writer.** Every `data/*.json` store is an in-memory list persisted on write by ONE server process — run two servers on the same project dir and each will clobber the other's acknowledged writes on its next persist (last writer wins the whole file). A store that fails to parse on first read is never silently wiped: it is quarantined as `data/<name>.json.corrupt-<timestamp>` (the original bytes are preserved for recovery) and the process starts empty with a loud stderr note. `auth-local` sessions are stateless HMAC tokens — logout clears the browser cookie only; a copied token stays valid until it expires.
58
+
57
59
  ## Quickstart
58
60
 
59
- **Any agent or plain shell — works from a clean clone:**
61
+ **Any agent or plain shell — npx straight off npmjs, no clone needed:**
60
62
 
61
63
  ```bash
62
- git clone https://github.com/ahmed-3m/idea2prototype
63
- node idea2prototype/skills/idea2prototype/scripts/idea2prototype.mjs new my-app --template web --name "My App" --idea "recipe box"
64
+ npx @ahmed-3m/idea2prototype new my-app --template web --name "My App" --idea "recipe box"
64
65
  cd my-app && npm start # http://127.0.0.1:3000
65
66
  npm run check # the verdict above
66
- node ../idea2prototype/skills/idea2prototype/scripts/idea2prototype.mjs add api-crud --resource recipes
67
+ npx @ahmed-3m/idea2prototype add api-crud --resource recipes
67
68
  npm run check # still PASS — now with CRUD tests
68
69
  ```
69
70
 
70
- **Using npx** the same session is shorter:
71
+ **From a clean clone** (offline / air-gapped, or pinning the engine alongside the project):
71
72
 
72
73
  ```bash
73
- npx @ahmed-3m/idea2prototype new my-app --template web --name "My App" --idea "recipe box"
74
- cd my-app && npm start && npx @ahmed-3m/idea2prototype add api-crud --resource recipes && npm run check
74
+ git clone https://github.com/ahmed-3m/idea2prototype
75
+ node idea2prototype/skills/idea2prototype/scripts/idea2prototype.mjs new my-app --template web --name "My App" --idea "recipe box"
76
+ cd my-app && npm start # http://127.0.0.1:3000
77
+ node ../idea2prototype/skills/idea2prototype/scripts/idea2prototype.mjs add api-crud --resource recipes
78
+ npm run check # still PASS — now with CRUD tests
75
79
  ```
76
80
 
77
- It is also mirrored on the GitHub Packages registry as `@ahmed-3m/idea2prototype` (published automatically per release) — note that registry asks consumers for a token in `.npmrc` even for public packages, so treat it as a mirror, not the primary channel.
81
+ Every release publishes `@ahmed-3m/idea2prototype` to npmjs.org — the primary channel — and mirrors it on the GitHub Packages registry under the same name; that registry asks consumers for a token in `.npmrc` even for public packages, so treat it as the mirror. (`npm i -g @ahmed-3m/idea2prototype` gets you the short `i2p` command the table above uses.)
78
82
 
79
83
  **ZCode plugin:** add this repo's marketplace, install `idea2prototype`, then pick the skill in the composer or use `/proto-new a recipe box with search`.
80
84
 
@@ -109,7 +113,7 @@ The generated CRUD test then needs a session instead of anonymous POSTs — regi
109
113
 
110
114
  ## Benchmarks
111
115
 
112
- `benchmarks/specs.md` is a pre-registered protocol: paired 2-arm design (same agent with vs. without the harness), blind functional judges, a symmetric verifier (`i2p verify`), and hypotheses stated up front. Results land in that file as they're measured — failures included. Three specs are archived: specs 1–2 (web app, API) favor the harness (23% fewer hand-written LOC, 2× tests, zero runner-compat failures — the control's from-scratch `npm test` broke on Node 24/Windows); spec 3 (micro-CLI) is an honest loss on speed and LOC — fixed overhead dominates tiny builds — with reliability and function at parity.
116
+ `benchmarks/specs.md` is a pre-registered protocol: paired 2-arm design (same agent with vs. without the harness), blind functional judges, a symmetric verifier (`i2p verify`), and hypotheses stated up front. Results land in that file as they're measured — failures included. Ten specs are archived. Specs 1–2 (web app, API) favor the harness (23% fewer hand-written LOC, 2× tests, zero runner-compat failures — the control's from-scratch `npm test` broke on Node 24/Windows); spec 3 (micro-CLI) is an honest loss on speed and LOC — fixed overhead dominates tiny builds — with reliability and function at parity; spec 4 (expense tracker) wrote ~43% fewer hand-written LOC with 3× tests; spec 7 (bookmarks API + tag filter) is the strongest result at ~64%. Across the set the pattern holds: hand-written LOC −10%…−64% on 7 of 8 build-type specs (spec 6 at parity), generated tests 2–3× the control's, full-depth verify against the control's recurring boot-skip — while the from-scratch arm stays faster on several small builds, recorded as such.
113
117
 
114
118
  ## Design rules
115
119
 
@@ -119,7 +123,7 @@ The generated CRUD test then needs a session instead of anonymous POSTs — regi
119
123
  4. **No destructive operations.** `new` refuses non-empty directories.
120
124
  5. **Portable core.** One `.mjs` engine; plugin/skill/npm wrappers are sugar.
121
125
 
122
- One deliberate tradeoff: a scaffolded project's `npm run check` invokes the exact engine that scaffolded it, so older prototypes keep the behavior they were built with (run a newer engine by hand and it reports the drift note). An `npx`-driven check is on the roadmap.
126
+ One deliberate tradeoff: a scaffolded project's `npm run check` invokes the exact engine version that scaffolded it — a clone-scaffolded project pins the sibling engine path, an npx-scaffolded project pins registry coordinates (`npx -y @ahmed-3m/idea2prototype@<version> check`), so a cleared npx cache or a second machine just re-fetches the same version — and older prototypes keep the behavior they were built with (run a newer engine by hand and it reports the drift note). An always-current check remains on the roadmap.
123
127
 
124
128
  Another boundary worth naming: templates are single-purpose and do not compose with each other — features compose within a template (auth × CRUD, rate-limit, cors, sse, worker-jobs). The one gap this used to leave — a web/api app that also needs background jobs — is closed by `add worker-jobs`, which brings the worker template's persistent queue in as a feature module; anything bigger than that still scaffolds as the primary template with the smaller part hand-built.
125
129
 
package/agents/AGENTS.md CHANGED
@@ -2,23 +2,25 @@
2
2
 
3
3
  Copy this file into any project where your coding agent should build with idea2prototype. Works with any agent that reads AGENTS.md conventions.
4
4
 
5
- You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
5
+ You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx @ahmed-3m/idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
6
6
 
7
7
  ## Commands
8
8
 
9
- - `npx idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
10
- - `npx idea2prototype add api-crud --resource <name>` — JSON-file CRUD resource + roundtrip tests
11
- - `npx idea2prototype add auth-local` — register/login/logout/me (scrypt + signed cookies) + tests; `requireAuth` protects other routes
12
- - `npx idea2prototype add upload` — multipart file uploads + fetch-back + tests
13
- - `npx idea2prototype add rate-limit` — per-key fixed-window limiter (429 + Retry-After headers); `rateLimit()` wraps any handler
14
- - `npx idea2prototype add cors` — CORS wrapper (preflight + access-control-* headers, origin allowlists); `cors()` wraps any handler
9
+ - `npx @ahmed-3m/idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
10
+ - `npx @ahmed-3m/idea2prototype add api-crud --resource <name>` — JSON-file CRUD resource + roundtrip tests
11
+ - `npx @ahmed-3m/idea2prototype add auth-local` — register/login/logout/me (scrypt + signed cookies) + tests; `requireAuth` protects other routes
12
+ - `npx @ahmed-3m/idea2prototype add upload` — multipart file uploads + fetch-back + tests
13
+ - `npx @ahmed-3m/idea2prototype add rate-limit` — per-key fixed-window limiter (429 + Retry-After headers); `rateLimit()` wraps any handler
14
+ - `npx @ahmed-3m/idea2prototype add cors` — CORS wrapper (preflight + access-control-* headers, origin allowlists); `cors()` wraps any handler
15
+ - `npx @ahmed-3m/idea2prototype add sse` — Server-Sent Events stream (`GET /api/events`) + heartbeats
16
+ - `npx @ahmed-3m/idea2prototype add worker-jobs` — persistent background job queue (`data/jobs.json`, 3 attempts) with demo routes
15
17
  - `npm run check` (inside a project) — syntax + tests + boot, ~10-line verdict
16
- - `npx idea2prototype map` — paths + LOC + exports; read this before opening files
18
+ - `npx @ahmed-3m/idea2prototype map` — paths + LOC + exports; read this before opening files
17
19
 
18
20
  ## Workflow
19
21
 
20
22
  1. Scaffold with `new`, then fill `PROTOTYPE.md` (Goal ≤ 3 bullets, 1–3 core flows, out-of-scope).
21
23
  2. Never hand-write files the tool owns (`server.mjs`, scaffold `tests/`, `package.json`). Add capabilities with `add`, not from scratch.
22
24
  3. After every meaningful change: `npm run check`. Read the verdict only — never run tests directly, never read raw logs.
23
- 4. Need context? `i2p map` first; open only the files you're changing.
25
+ 4. Need context? `npx @ahmed-3m/idea2prototype map` first; open only the files you're changing.
24
26
  5. Done = check PASS + spec flows work. Report the verdict lines.
package/agents/CLAUDE.md CHANGED
@@ -2,23 +2,25 @@
2
2
 
3
3
  Copy this file into any project where your coding agent should build with idea2prototype. Works with any agent that reads AGENTS.md/CLAUDE.md conventions.
4
4
 
5
- You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
5
+ You have `idea2prototype` (`i2p`) available — a zero-dependency prototype tool. Use it instead of hand-writing scaffolding. If the npm package isn't installed or available, run the engine directly instead of `npx @ahmed-3m/idea2prototype`: `node <path-to-idea2prototype-repo>/skills/idea2prototype/scripts/idea2prototype.mjs <command>`.
6
6
 
7
7
  ## Commands
8
8
 
9
- - `npx idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
10
- - `npx idea2prototype add api-crud --resource <name>` — JSON-file CRUD resource + roundtrip tests
11
- - `npx idea2prototype add auth-local` — register/login/logout/me (scrypt + signed cookies) + tests; `requireAuth` protects other routes
12
- - `npx idea2prototype add upload` — multipart file uploads + fetch-back + tests
13
- - `npx idea2prototype add rate-limit` — per-key fixed-window limiter (429 + Retry-After headers); `rateLimit()` wraps any handler
14
- - `npx idea2prototype add cors` — CORS wrapper (preflight + access-control-* headers, origin allowlists); `cors()` wraps any handler
9
+ - `npx @ahmed-3m/idea2prototype new <dir> --template web|api|cli|worker|library` — scaffold a running, tested project (~0 tokens vs 10–30k hand-written)
10
+ - `npx @ahmed-3m/idea2prototype add api-crud --resource <name>` — JSON-file CRUD resource + roundtrip tests
11
+ - `npx @ahmed-3m/idea2prototype add auth-local` — register/login/logout/me (scrypt + signed cookies) + tests; `requireAuth` protects other routes
12
+ - `npx @ahmed-3m/idea2prototype add upload` — multipart file uploads + fetch-back + tests
13
+ - `npx @ahmed-3m/idea2prototype add rate-limit` — per-key fixed-window limiter (429 + Retry-After headers); `rateLimit()` wraps any handler
14
+ - `npx @ahmed-3m/idea2prototype add cors` — CORS wrapper (preflight + access-control-* headers, origin allowlists); `cors()` wraps any handler
15
+ - `npx @ahmed-3m/idea2prototype add sse` — Server-Sent Events stream (`GET /api/events`) + heartbeats
16
+ - `npx @ahmed-3m/idea2prototype add worker-jobs` — persistent background job queue (`data/jobs.json`, 3 attempts) with demo routes
15
17
  - `npm run check` (inside a project) — syntax + tests + boot, ~10-line verdict
16
- - `npx idea2prototype map` — paths + LOC + exports; read this before opening files
18
+ - `npx @ahmed-3m/idea2prototype map` — paths + LOC + exports; read this before opening files
17
19
 
18
20
  ## Workflow
19
21
 
20
22
  1. Scaffold with `new`, then fill `PROTOTYPE.md` (Goal ≤ 3 bullets, 1–3 core flows, out-of-scope).
21
23
  2. Never hand-write files the tool owns (`server.mjs`, scaffold `tests/`, `package.json`). Add capabilities with `add`, not from scratch.
22
24
  3. After every meaningful change: `npm run check`. Read the verdict only — never run tests directly, never read raw logs.
23
- 4. Need context? `i2p map` first; open only the files you're changing.
25
+ 4. Need context? `npx @ahmed-3m/idea2prototype map` first; open only the files you're changing.
24
26
  5. Done = check PASS + spec flows work. Report the verdict lines.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ahmed-3m/idea2prototype",
3
- "version": "0.9.0",
4
- "description": "Turn ideas into tested prototypes — token-lean scaffolds + one-command verification for any coding agent. Zero dependencies.",
3
+ "version": "1.0.0",
4
+ "description": "Turn ideas into tested prototypes \u2014 token-lean scaffolds + one-command verification for any coding agent. Zero dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "ahmed-3m",
@@ -92,4 +92,6 @@ Then `check` — the anon-401/authed-201 contract and the rest of the CRUD suite
92
92
  - `check` exits non-zero on failure, prints at most 3 failure notes — fix those, re-run. A trailing `note` line about scaffold/engine version drift is informational, not a failure. `check --json` gives the same verdict machine-readable. `verify [dir]` runs the same verdict on any Node project (benchmark/judge mode).
93
93
  - If the user asks for dependencies (a framework, a database), ask once, then add via `npm install` and extend the tests accordingly.
94
94
  - Projects are plain Node — `npm start` (web/api), `node cli.mjs help` (cli), `import ... from './index.mjs'` (library); no other tooling assumed.
95
+ - JSON stores (`data/*.json`) are single-writer: one server process per project dir — a second server's persist clobbers the first's acknowledged writes. A store that fails to parse on first read is quarantined (`data/<name>.json.corrupt-<ts>`, original bytes preserved) and the process starts empty with a loud stderr note — never a silent wipe.
96
+ - `auth-local` session tokens are stateless HMAC: logout clears the browser cookie only, and a token copied before logout stays valid until it expires — production-ish apps should shorten the TTL or add a server-side revocation list (the route's docblock says the same).
95
97
  - Templates are single-purpose: no cross-template composition exists. If a task needs background jobs inside a web/api project, `add worker-jobs` brings the worker template's queue in as a feature — no need to scaffold both. Feature composition (`auth-local` × `api-crud`, `rate-limit`, `cors`, `sse` streaming, `worker-jobs`) is supported; template composition is not.
@@ -18,7 +18,8 @@ import { spawnSync } from 'node:child_process';
18
18
  import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
19
19
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
 
21
- const VERSION = '0.9.0';
21
+ const VERSION = '1.0.0';
22
+ const PKG_NAME = '@ahmed-3m/idea2prototype';
22
23
  const HERE = dirname(fileURLToPath(import.meta.url));
23
24
  const TEMPLATES_DIR = join(HERE, '..', 'templates');
24
25
  const FEATURES_DIR = join(TEMPLATES_DIR, 'features');
@@ -27,6 +28,20 @@ const TEXT_EXTENSIONS = new Set(['.md', '.json', '.html', '.js', '.mjs', '.css',
27
28
  const SAY = (...args) => console.log(...args);
28
29
  const FAIL = (...args) => console.error(...args);
29
30
 
31
+ // The scaffold's `npm run check` must keep invoking the engine that built it.
32
+ // From a published install (npx cache, npm i -g) the engine lives inside a
33
+ // volatile node_modules dir that a cache clean or a second machine erases —
34
+ // pin the registry coordinates instead, which re-fetch the exact version.
35
+ // From a source checkout (repo clone, plugin cache) the local path is stable.
36
+ // The JSON.stringify wrapper keeps the command embeddable in package.json.
37
+ function engineCheckCommand() {
38
+ const enginePath = fileURLToPath(import.meta.url);
39
+ const command = enginePath.includes(join('node_modules', PKG_NAME))
40
+ ? `npx -y ${PKG_NAME}@${VERSION} check`
41
+ : `node "${enginePath}" check`;
42
+ return JSON.stringify(command).slice(1, -1); // embed inside the template's JSON string value
43
+ }
44
+
30
45
  // ---------------------------------------------------------------------------
31
46
  // new
32
47
  // ---------------------------------------------------------------------------
@@ -34,6 +49,7 @@ async function cmdNew(rest) {
34
49
  const opts = parseFlags(rest, ['template', 'name', 'idea']);
35
50
  const dir = opts._[0];
36
51
  if (!dir) die('usage: idea2prototype new <dir> [--template web|api|cli|worker|library] [--name "..."] [--idea "..."]');
52
+ checkPositionalCount(opts, 1, 'new takes one directory name (new <dir>)');
37
53
 
38
54
  const template = opts.template ?? 'web';
39
55
  const templateDir = join(TEMPLATES_DIR, template);
@@ -45,10 +61,47 @@ async function cmdNew(rest) {
45
61
  if (!/^[a-z0-9][a-z0-9-_]*$/.test(slug)) {
46
62
  die(`invalid directory name "${slug}" — use letters, digits, dashes, underscores`);
47
63
  }
64
+ // Windows reserved device names (CON, NUL, COM1-9, ...) create directories
65
+ // PowerShell cannot even enter. Reject on every platform so a scaffold
66
+ // stays portable, not just usable where it was created.
67
+ const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
68
+ if (RESERVED.test(slug)) {
69
+ die(`"${slug}" is a reserved Windows device name — pick a different directory name`);
70
+ }
71
+ if (slug.length > 200) {
72
+ die(`directory name too long (${slug.length} chars, max 200) — nested scaffold files can exceed Windows MAX_PATH`);
73
+ }
74
+ // Same portability bar for every component `new` may CREATE: without this,
75
+ // `new con/proj` scaffolds inside a reserved-name dir via the back door.
76
+ for (const part of dir.split(/[\\/]+/).filter(Boolean).slice(0, -1)) {
77
+ const component = part.toLowerCase();
78
+ if (RESERVED.test(component)) {
79
+ die(`"${part}" is a reserved Windows device name — pick a different directory name`);
80
+ }
81
+ if (component.length > 200) {
82
+ die(`directory name too long (${component.length} chars, max 200) — nested scaffold files can exceed Windows MAX_PATH`);
83
+ }
84
+ }
48
85
  const name = (opts.name ?? basename(resolve(dir))).replace(/[<>]/g, '');
49
86
  const idea = opts.idea ?? '(describe the idea in one sentence)';
87
+ // --name/--idea land verbatim in the JSON manifest and generated files; a
88
+ // quote or backslash would poison the manifest AFTER all files are on disk.
89
+ // Reject before writing anything.
90
+ for (const [flag, value] of [['--name', name], ['--idea', idea]]) {
91
+ if (/["\\\u0000-\u001f]/.test(value)) {
92
+ die(`invalid ${flag} value — quotes, backslashes, and control characters are not allowed`);
93
+ }
94
+ }
50
95
 
51
96
  const target = resolve(process.cwd(), dir);
97
+ // cp(recursive) creates missing intermediate parents, so nested `new sub/proj`
98
+ // is a supported input — create them here, where an unusable path (a file in
99
+ // the prefix, no permissions) dies with a readable message instead of ENOENT.
100
+ try {
101
+ await mkdir(dirname(target), { recursive: true });
102
+ } catch (err) {
103
+ die(`cannot create "${dir}" — parent path does not exist or is not a directory${err?.code ? ` (${err.code})` : ''}`);
104
+ }
52
105
  if (existsSync(target) && readdirSyncSafe(target).length > 0) {
53
106
  die(`"${target}" exists and is not empty — idea2prototype never overwrites existing files`);
54
107
  }
@@ -58,7 +111,7 @@ async function cmdNew(rest) {
58
111
  '{{name}}': name,
59
112
  '{{slug}}': slug,
60
113
  '{{idea}}': idea,
61
- __IDEA2PROTOTYPE_BIN__: JSON.stringify(fileURLToPath(import.meta.url)).slice(1, -1),
114
+ __IDEA2PROTOTYPE_CMD__: engineCheckCommand(),
62
115
  });
63
116
 
64
117
  const markerPath = join(target, '.idea2prototype.json');
@@ -177,6 +230,7 @@ const FEATURES = {
177
230
  async function cmdAdd(rest) {
178
231
  const feature = rest[0]?.startsWith('--') ? undefined : rest[0];
179
232
  const opts = parseFlags(rest.slice(feature ? 1 : 0), ['resource']);
233
+ checkPositionalCount(opts, 0, 'add takes one feature name (add <feature>)');
180
234
  const available = Object.keys(FEATURES).join(', ');
181
235
  if (!feature) die(`usage: idea2prototype add <feature> — available: ${available}`);
182
236
  const def = FEATURES[feature];
@@ -221,32 +275,41 @@ async function cmdAdd(rest) {
221
275
  // check / verify — the compact verdict
222
276
  // ---------------------------------------------------------------------------
223
277
  async function cmdCheck(rest) {
224
- const opts = parseFlags(rest, []);
278
+ const opts = parseFlags(rest, [], ['json']);
279
+ checkPositionalCount(opts, 0, 'check takes no arguments — run it from inside the project');
225
280
  await loadMarker(); // must run inside a idea2prototype project
226
- return runChecks(process.cwd(), 'json' in opts);
281
+ return runChecks(process.cwd(), 'json' in opts, 'check');
227
282
  }
228
283
 
229
284
  // verify: run the same verdict on ANY Node project — used to judge projects
230
285
  // this tool never scaffolded (benchmark control arm, external code).
231
286
  async function cmdVerify(rest) {
232
- const dir = rest.find((a) => !a.startsWith('--'));
287
+ const opts = parseFlags(rest, [], ['json']);
288
+ checkPositionalCount(opts, 1, 'verify takes one optional directory (verify [dir])');
289
+ const dir = opts._[0];
233
290
  const target = resolve(process.cwd(), dir ?? '.');
234
291
  if (!existsSync(target) || !statSync(target).isDirectory()) {
235
292
  die(`no such directory: ${target}`);
236
293
  }
237
- return runChecks(target, rest.includes('--json'));
294
+ return runChecks(target, 'json' in opts, 'verify');
238
295
  }
239
296
 
240
- async function runChecks(projectDir, asJson) {
297
+ async function runChecks(projectDir, asJson, mode = 'check') {
241
298
  const marker = await readMarker(projectDir); // null for foreign projects (verify mode)
242
299
  const steps = [];
243
300
  const syntax = await stepSyntax(projectDir);
244
301
  steps.push(syntax);
245
302
  const tests = await stepTests(projectDir, syntax.status === 'pass');
246
303
  steps.push(tests);
247
- const boot = await stepBoot(projectDir, tests.status === 'pass', marker);
304
+ const boot = await stepBoot(projectDir, tests, marker);
248
305
  steps.push(boot);
249
306
 
307
+ // An empty directory is not a project: judge mode must not hand out a
308
+ // green verdict for a dir with nothing to check (no sources, no tests).
309
+ if (!marker && tests.status === 'skip' && steps[0].files === 0) {
310
+ steps.push({ name: 'project', status: 'fail', detail: 'no Node project found (no sources parsed, no test files)' });
311
+ }
312
+
250
313
  const failed = steps.filter((s) => s.status === 'fail');
251
314
  const summary = failed.length === 0 ? 'PASS' : 'FAIL';
252
315
  const passedCount = steps.filter((s) => s.status === 'pass').length;
@@ -263,7 +326,7 @@ async function runChecks(projectDir, asJson) {
263
326
  }, null, 2));
264
327
  } else {
265
328
  SAY(``);
266
- SAY(`Idea2Prototype check — ${summary} (${passedCount}/${steps.length} passed) [${projectDir}]`);
329
+ SAY(`Idea2Prototype ${mode} — ${summary} (${passedCount}/${steps.length} passed) [${projectDir}]`);
267
330
  for (const s of steps) {
268
331
  const icon = s.status === 'pass' ? 'PASS ' : s.status === 'fail' ? 'FAIL ' : 'SKIP ';
269
332
  SAY(` ${s.name.padEnd(7)} ${icon} ${s.detail}`);
@@ -314,8 +377,8 @@ async function stepSyntax(projectDir) {
314
377
  detail = `${serverFiles.length} files parse clean`;
315
378
  }
316
379
  return failures.length
317
- ? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3) }
318
- : { name: 'syntax', status: 'pass', detail };
380
+ ? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3), files: total }
381
+ : { name: 'syntax', status: 'pass', detail, files: total };
319
382
  }
320
383
 
321
384
  // Browser JS can't take the plain `node --check <file>` path: an .js file
@@ -334,22 +397,60 @@ function parsesClean(source, inputType) {
334
397
  return res.status === 0;
335
398
  }
336
399
 
400
+ // Test discovery for judge mode, mirroring `node --test`'s own default
401
+ // conventions: **/*.test.{js,mjs,cjs}, test.js, test-*.*, *-test.*/_*_test.*
402
+ // basenames at any depth, plus EVERY .js/.mjs/.cjs under a `test/` directory
403
+ // (any depth) — so projects this tool never scaffolded are judged by their
404
+ // real tests instead of silently passing with none found.
405
+ // Untrusted (uploads/) and generated (dist/, coverage/, data/, public/)
406
+ // trees are never entered: the syntax step already refuses to READ uploads,
407
+ // and discovery must not hand an uploaded *.test.js to `node --test` to
408
+ // EXECUTE. Dot-dirs and node_modules are skipped the same way.
409
+ const TEST_DIR_SKIP = new Set(['node_modules', 'public', 'data', 'uploads', 'dist', 'coverage']);
410
+ const TEST_BASENAME = /^(test(?:-.+)?|.+[._-]test)\.(js|mjs|cjs)$/;
411
+
412
+ async function discoverTestFiles(projectDir) {
413
+ const found = new Set();
414
+ const JS = /\.(js|mjs|cjs)$/;
415
+ async function walk(dir, depth, underTestDir) {
416
+ if (depth > 6) return;
417
+ let entries;
418
+ try {
419
+ entries = await readdir(dir, { withFileTypes: true });
420
+ } catch {
421
+ return;
422
+ }
423
+ for (const entry of entries) {
424
+ if (entry.name.startsWith('.') || TEST_DIR_SKIP.has(entry.name)) continue;
425
+ const full = join(dir, entry.name);
426
+ if (entry.isDirectory()) {
427
+ await walk(full, depth + 1, underTestDir || entry.name === 'test');
428
+ } else if (underTestDir ? JS.test(entry.name) : TEST_BASENAME.test(entry.name)) {
429
+ found.add(full);
430
+ }
431
+ }
432
+ }
433
+ await walk(projectDir, 0, false);
434
+ return [...found].sort();
435
+ }
436
+
337
437
  async function stepTests(projectDir, syntaxPassed) {
338
- const testDir = join(projectDir, 'tests');
339
438
  if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)' };
340
- let testFiles = [];
341
- try {
342
- testFiles = (await readdir(testDir)).filter((f) => f.endsWith('.test.mjs')).sort();
343
- } catch {
344
- return { name: 'tests', status: 'skip', detail: 'no tests/ directory' };
439
+ const testFiles = (await discoverTestFiles(projectDir)).map((f) => relative(projectDir, f));
440
+ if (!testFiles.length) {
441
+ return {
442
+ name: 'tests',
443
+ status: 'skip',
444
+ detail: 'no test files found',
445
+ notes: ['looked for node:test conventions (*.test.{js,mjs,cjs}, test-*.js, *-test.js, *_test.js, test.js, and every .js/.mjs/.cjs under a test/ directory), skipping node_modules, uploads, data, dist, coverage, public — if your tests live elsewhere, this verdict is syntax-only'],
446
+ };
345
447
  }
346
- if (!testFiles.length) return { name: 'tests', status: 'skip', detail: 'no *.test.mjs files' };
347
448
 
348
449
  // Pass explicit file paths: glob args to `node --test` are Node >= 21 only.
349
450
  // Strip NODE_TEST_CONTEXT so a check spawned from inside a test runner
350
451
  // doesn't put the child into child-reporter mode (no TAP summary).
351
452
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'NODE_TEST_CONTEXT'));
352
- const fileArgs = testFiles.map((f) => join('tests', f));
453
+ const fileArgs = testFiles.map((f) => f.split(sep).join('/'));
353
454
  // Serial execution trades wall-clock for determinism — scale the spawn
354
455
  // budget with the file count so slow-but-passing suites aren't killed
355
456
  // (90s floor, 30s per file, 5-minute ceiling).
@@ -381,6 +482,23 @@ async function stepTests(projectDir, syntaxPassed) {
381
482
  res = spawnSync(process.execPath, ['--test', ...fileArgs], spawnOpts);
382
483
  out = capture(res);
383
484
  }
485
+ // A hung test run surfaces two ways, by Node version. 22's runner enforces
486
+ // --test-timeout at the FILE level: a top-level infinite loop is killed by
487
+ // the runner itself (~15s, "testTimeoutFailure" + "# cancelled 1", clean
488
+ // exit). 24's runner never fires there, so the spawn budget is the only
489
+ // bound (ETIMEDOUT, no summary). Both must read as a timeout verdict —
490
+ // never "? failed of ?" or spawn plumbing.
491
+ const fileTimeoutMs = /test timed out after (\d+)ms/.exec(out)?.[1];
492
+ if (res.error?.code === 'ETIMEDOUT' || fileTimeoutMs !== undefined) {
493
+ return {
494
+ name: 'tests',
495
+ status: 'fail',
496
+ detail: res.error?.code === 'ETIMEDOUT'
497
+ ? `tests timed out after ${Math.round(spawnOpts.timeout / 1000)}s — no summary produced`
498
+ : `tests timed out — the runner's own timeout killed the blocked test (after ${fileTimeoutMs}ms)`,
499
+ notes: ['a test file blocked past every bound — infinite loop, hung worker, or slower than the per-test budget?'],
500
+ };
501
+ }
384
502
  const counts = {};
385
503
  for (const m of out.matchAll(/^# (tests|pass|fail|skipped|cancelled) (\d+)$/gm)) counts[m[1]] = Number(m[2]);
386
504
 
@@ -401,8 +519,14 @@ async function stepTests(projectDir, syntaxPassed) {
401
519
  };
402
520
  }
403
521
 
404
- async function stepBoot(projectDir, testsPassed, marker) {
405
- if (!testsPassed) return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
522
+ async function stepBoot(projectDir, testsStep, marker) {
523
+ if (testsStep.status === 'fail') return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
524
+ if (testsStep.status === 'skip') {
525
+ // Name the actual cause — a syntax failure is why nothing ran, not
526
+ // "no tests found" ("skipped (…)" details unwrap to their inner cause).
527
+ const cause = testsStep.detail.replace(/^skipped \((.*)\)$/, '$1');
528
+ return { name: 'boot', status: 'skip', detail: `skipped (${cause})` };
529
+ }
406
530
  // Library projects have no server to probe — boot = import the public API.
407
531
  if (marker?.template === 'library') return bootLibrary(projectDir);
408
532
  return bootServer(projectDir, marker);
@@ -495,7 +619,9 @@ async function bootServer(projectDir, marker) {
495
619
  // ---------------------------------------------------------------------------
496
620
  // list / help
497
621
  // ---------------------------------------------------------------------------
498
- async function cmdList() {
622
+ async function cmdList(rest) {
623
+ const opts = parseFlags(rest, []);
624
+ checkPositionalCount(opts, 0, 'list takes no arguments');
499
625
  const templates = (await readdir(TEMPLATES_DIR, { withFileTypes: true }))
500
626
  .filter((d) => d.isDirectory() && d.name !== 'features')
501
627
  .map((d) => d.name);
@@ -506,8 +632,10 @@ async function cmdList() {
506
632
  // map — compact repo map (paths + LOC + exports) so an agent can decide what
507
633
  // to read instead of loading whole files into context.
508
634
  async function cmdMap(rest) {
509
- const dir = rest.find((a) => !a.startsWith('--'));
510
- const asJson = rest.includes('--json');
635
+ const opts = parseFlags(rest, [], ['json']);
636
+ checkPositionalCount(opts, 1, 'map takes one optional directory (map [dir])');
637
+ const dir = opts._[0];
638
+ const asJson = 'json' in opts;
511
639
  const root = resolve(process.cwd(), dir ?? '.');
512
640
  if (!existsSync(root) || !statSync(root).isDirectory()) die(`no such directory: ${root}`);
513
641
 
@@ -579,14 +707,25 @@ const withTimeout = (promise, ms, label) => {
579
707
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
580
708
  };
581
709
 
582
- function parseFlags(args, valueFlags) {
710
+ function parseFlags(args, valueFlags, booleanFlags = []) {
583
711
  const out = { _: [] };
712
+ const allowed = [
713
+ ...valueFlags.map((f) => `--${f} <value>`),
714
+ ...booleanFlags.map((f) => `--${f}`),
715
+ ].join(', ') || 'none';
584
716
  for (let i = 0; i < args.length; i++) {
585
717
  const arg = args[i];
586
718
  if (arg.startsWith('--')) {
587
719
  const key = arg.slice(2);
588
- if (valueFlags.includes(key)) out[key] = args[++i];
589
- else out[key] = true;
720
+ if (valueFlags.includes(key)) {
721
+ const val = args[++i];
722
+ if (val === undefined || val === '' || val.startsWith('-')) die(`flag "${arg}" needs a value (allowed: ${allowed})`);
723
+ out[key] = val;
724
+ } else if (booleanFlags.includes(key)) {
725
+ out[key] = true;
726
+ } else {
727
+ die(`unknown flag "${arg}" (allowed: ${allowed})`);
728
+ }
590
729
  } else {
591
730
  out._.push(arg);
592
731
  }
@@ -594,6 +733,13 @@ function parseFlags(args, valueFlags) {
594
733
  return out;
595
734
  }
596
735
 
736
+ // Positionals beyond what a command consumes die instead of being silently
737
+ // ignored — `check ./proj` that quietly ignores ./proj is a silent wrong answer.
738
+ function checkPositionalCount(opts, max, what) {
739
+ const extra = opts._[max];
740
+ if (extra !== undefined) die(`unexpected argument "${extra}" — ${what}`);
741
+ }
742
+
597
743
  // Canonical marker reader: null when absent or unparseable.
598
744
  async function readMarker(projectDir) {
599
745
  try {
@@ -690,7 +836,7 @@ try {
690
836
  case 'check': await cmdCheck(rest); break;
691
837
  case 'verify': await cmdVerify(rest); break;
692
838
  case 'map': await cmdMap(rest); break;
693
- case 'list': await cmdList(); break;
839
+ case 'list': await cmdList(rest); break;
694
840
  case 'version': case '--version': case '-v': SAY(VERSION); break;
695
841
  case 'help': case undefined: case '--help': case '-h': help(); break;
696
842
  default: die(`unknown command "${command}" — run: idea2prototype help`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "template": "api",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -5,6 +5,6 @@
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "start": "node server.mjs",
8
- "check": "node \"__IDEA2PROTOTYPE_BIN__\" check"
8
+ "check": "__IDEA2PROTOTYPE_CMD__"
9
9
  }
10
10
  }
@@ -15,7 +15,7 @@ async function loadApiRoutes() {
15
15
  try {
16
16
  files = await readdir(ROUTES_DIR);
17
17
  } catch {
18
- return routes; // no routes yet — health + index only
18
+ return routes;
19
19
  }
20
20
  for (const file of files.filter((f) => f.endsWith('.mjs')).sort()) {
21
21
  const mod = await import(pathToFileURL(join(ROUTES_DIR, file)).href);
@@ -41,22 +41,42 @@ function matchRoute(route, url) {
41
41
  function readRawBody(req, limit = 10e6) {
42
42
  return new Promise((resolve, reject) => {
43
43
  let size = 0;
44
+ let tooLarge = false;
44
45
  const chunks = [];
45
46
  req.on('data', (chunk) => {
46
47
  size += chunk.length;
48
+ // Oversize policy: drain up to 2x limit so a clean 413 can still be
49
+ // delivered at end(); past 2x, destroy the socket to bound the cost of
50
+ // absorbing junk — those clients see a connection reset, deliberately.
47
51
  if (size > limit) {
48
- reject(new Error('body too large'));
49
- req.destroy();
52
+ tooLarge = true;
53
+ if (size > limit * 2) {
54
+ req.destroy();
55
+ const err = new Error('body too large');
56
+ err.code = 'BODY_TOO_LARGE';
57
+ reject(err);
58
+ }
50
59
  } else {
51
60
  chunks.push(chunk);
52
61
  }
53
62
  });
54
- req.on('end', () => resolve(Buffer.concat(chunks)));
63
+ req.on('end', () => {
64
+ if (tooLarge) {
65
+ const err = new Error('body too large');
66
+ err.code = 'BODY_TOO_LARGE';
67
+ reject(err);
68
+ } else {
69
+ resolve(Buffer.concat(chunks));
70
+ }
71
+ });
55
72
  req.on('error', reject);
56
73
  });
57
74
  }
58
75
 
59
76
  function sendJson(res, status, payload, headers = {}) {
77
+ // Handlers are user code — a bogus status must degrade to a clean 500, not
78
+ // throw inside writeHead and leak Node internals ("Invalid status code: b").
79
+ if (!Number.isInteger(status) || status < 100 || status > 599) status = 500;
60
80
  if (status === 204 || status === 304) {
61
81
  res.writeHead(status, headers);
62
82
  return res.end();
@@ -65,7 +85,9 @@ function sendJson(res, status, payload, headers = {}) {
65
85
  res.writeHead(status, { 'content-length': payload.length, ...headers });
66
86
  return res.end(payload);
67
87
  }
68
- const data = JSON.stringify(payload);
88
+ // [status] with no payload serializes as null — JSON.stringify(undefined)
89
+ // returns undefined and Buffer.byteLength would throw on it.
90
+ const data = JSON.stringify(payload ?? null);
69
91
  res.writeHead(status, {
70
92
  'content-type': 'application/json; charset=utf-8',
71
93
  'content-length': Buffer.byteLength(data),
@@ -94,7 +116,10 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
94
116
  try {
95
117
  raw = await readRawBody(req);
96
118
  } catch (err) {
97
- return sendJson(res, 400, { error: err.message });
119
+ if (err.code === 'BODY_TOO_LARGE') {
120
+ return sendJson(res, 413, { error: 'body too large' });
121
+ }
122
+ return sendJson(res, 400, { error: String(err?.message ?? err) });
98
123
  }
99
124
  if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) {
100
125
  ctx.rawBody = raw;
@@ -111,6 +136,12 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
111
136
  // ctx.res (e.g. Server-Sent Events) returns nothing — the stream
112
137
  // stays open past this request; don't try to send a JSON response.
113
138
  if (res.headersSent || res.writableEnded) return;
139
+ // Contract-violating returns (a bare string, undefined, ...) must be
140
+ // a clean 500 — destructuring them would leak parser plumbing
141
+ // ("result is not iterable") into the response.
142
+ if (!Array.isArray(result)) {
143
+ return sendJson(res, 500, { error: 'handler returned an invalid result (expected [status, payload, headers?])' });
144
+ }
114
145
  const [status, payload, headers] = result;
115
146
  return sendJson(res, status, payload, headers);
116
147
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "template": "cli",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -5,6 +5,6 @@
5
5
  "private": true,
6
6
  "bin": { "{{slug}}": "cli.mjs" },
7
7
  "scripts": {
8
- "check": "node \"__IDEA2PROTOTYPE_BIN__\" check"
8
+ "check": "__IDEA2PROTOTYPE_CMD__"
9
9
  }
10
10
  }
@@ -1,26 +1,56 @@
1
1
  // Idea2Prototype feature: api-crud — resource "{{resource}}"
2
2
  // Contract: handlers return [status, payload]; the server sends payload as JSON.
3
3
  // Persistence: JSON file at data/{{resource}}.json (created on first write).
4
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
7
7
 
8
8
  const STORE_PATH = join(process.cwd(), 'data', '{{resource}}.json');
9
- let cache = null;
9
+ let ready = null; // memoized cold load: concurrent first callers share ONE array
10
10
 
11
- async function load() {
12
- if (cache) return cache;
13
- try {
14
- cache = JSON.parse(await readFile(STORE_PATH, 'utf8'));
15
- } catch {
16
- cache = [];
11
+ function load() {
12
+ if (!ready) {
13
+ ready = (async () => {
14
+ let cache = null;
15
+ let raw = null;
16
+ try {
17
+ raw = await readFile(STORE_PATH, 'utf8');
18
+ } catch (err) {
19
+ if (err?.code !== 'ENOENT') console.error(`[{{resource}}] could not read data store (${err?.code ?? err}) — starting empty`);
20
+ }
21
+ if (raw !== null) {
22
+ try {
23
+ cache = JSON.parse(raw);
24
+ } catch {
25
+ cache = null;
26
+ }
27
+ }
28
+ if (raw === null || cache === null || !Array.isArray(cache)) {
29
+ // A store that exists but is corrupt (unparseable, or not a list) must
30
+ // never be silently wiped — the next persist() would overwrite data
31
+ // that may still be recoverable. Quarantine it, say so loudly, start
32
+ // empty. If even the quarantine rename fails, do NOT pretend it is
33
+ // safe: the original file WILL be overwritten by the next persist.
34
+ if (raw !== null) {
35
+ const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
36
+ try {
37
+ await rename(STORE_PATH, quarantine);
38
+ console.error(`[{{resource}}] data store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
39
+ } catch (err) {
40
+ console.error(`[{{resource}}] data store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
41
+ }
42
+ }
43
+ cache = [];
44
+ }
45
+ return cache;
46
+ })();
17
47
  }
18
- return cache;
48
+ return ready;
19
49
  }
20
50
 
21
51
  async function persist() {
22
52
  await mkdir(join(process.cwd(), 'data'), { recursive: true });
23
- await writeFile(STORE_PATH, JSON.stringify(cache, null, 2));
53
+ await writeFile(STORE_PATH, JSON.stringify(await load(), null, 2));
24
54
  }
25
55
 
26
56
  function requireObject(body) {
@@ -3,7 +3,7 @@
3
3
  // Compose into other routes: import { requireAuth } from './auth.mjs'
4
4
  // methods: { GET: requireAuth(async (ctx) => [200, { hello: ctx.user.username }]) }
5
5
  import { createHmac, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
6
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
 
9
9
  const USERS_FILE = join(process.cwd(), 'data', 'users.json');
@@ -11,22 +11,51 @@ const SECRET_FILE = join(process.cwd(), 'data', '.auth-secret');
11
11
  const COOKIE_NAME = 'i2p_session';
12
12
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
13
13
 
14
- let usersCache = null;
14
+ let usersReady = null; // memoized cold load: concurrent first callers share ONE array
15
15
  let secretCache = null;
16
16
 
17
- async function loadUsers() {
18
- if (usersCache) return usersCache;
19
- try {
20
- usersCache = JSON.parse(await readFile(USERS_FILE, 'utf8'));
21
- } catch {
22
- usersCache = [];
17
+ function loadUsers() {
18
+ if (!usersReady) {
19
+ usersReady = (async () => {
20
+ let usersCache = null;
21
+ let raw = null;
22
+ try {
23
+ raw = await readFile(USERS_FILE, 'utf8');
24
+ } catch (err) {
25
+ if (err?.code !== 'ENOENT') console.error(`[auth] could not read users store (${err?.code ?? err}) — starting empty`);
26
+ }
27
+ if (raw !== null) {
28
+ try {
29
+ usersCache = JSON.parse(raw);
30
+ } catch {
31
+ usersCache = null;
32
+ }
33
+ }
34
+ if (raw === null || usersCache === null || !Array.isArray(usersCache)) {
35
+ // A users store that exists but is corrupt must never be silently
36
+ // wiped — the next register would overwrite every account. Quarantine,
37
+ // say so, start empty (accounts can be re-registered; the old file is
38
+ // preserved). If the rename itself fails, say THAT loudly too.
39
+ if (raw !== null) {
40
+ const quarantine = `${USERS_FILE}.corrupt-${Date.now().toString(36)}`;
41
+ try {
42
+ await rename(USERS_FILE, quarantine);
43
+ console.error(`[auth] users store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
44
+ } catch (err) {
45
+ console.error(`[auth] users store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
46
+ }
47
+ }
48
+ usersCache = [];
49
+ }
50
+ return usersCache;
51
+ })();
23
52
  }
24
- return usersCache;
53
+ return usersReady;
25
54
  }
26
55
 
27
56
  async function persistUsers() {
28
57
  await mkdir(join(process.cwd(), 'data'), { recursive: true });
29
- await writeFile(USERS_FILE, JSON.stringify(usersCache, null, 2));
58
+ await writeFile(USERS_FILE, JSON.stringify(await loadUsers(), null, 2));
30
59
  }
31
60
 
32
61
  async function getSecret() {
@@ -147,6 +176,11 @@ export default [
147
176
  {
148
177
  path: '/api/auth/logout',
149
178
  methods: {
179
+ // Clears the BROWSER cookie only. Session tokens are stateless HMAC —
180
+ // there is no server-side session store, so a token copied before
181
+ // logout keeps authenticating until it expires (SESSION_TTL_MS).
182
+ // Anything user-facing on top of this demo should shorten the TTL or
183
+ // add a server-side revocation list.
150
184
  POST: async () => [200, { ok: true }, { 'set-cookie': clearedCookie() }],
151
185
  },
152
186
  },
@@ -3,9 +3,7 @@
3
3
  // Compose into any route handler: import { rateLimit } from './ratelimit.mjs'
4
4
  // methods: { GET: rateLimit({ max: 10, windowMs: 60_000 })(async (ctx) => [200, { ok: true }]) }
5
5
 
6
- const buckets = new Map(); // key -> { count, resetAt }
7
6
  const CLEANUP_INTERVAL_MS = 60_000;
8
- let lastCleanup = 0;
9
7
 
10
8
  /**
11
9
  * Wrap a route handler with a per-key fixed-window limiter.
@@ -15,8 +13,12 @@ let lastCleanup = 0;
15
13
  * e.g. keyFn: (ctx) => ctx.req.headers['x-forwarded-for'] ?? 'local'.
16
14
  * Over-limit requests get 429 + Retry-After; allowed ones pass
17
15
  * through with X-RateLimit-* headers merged into the response.
16
+ * Each rateLimit({...}) instance owns its OWN buckets: independently
17
+ * configured wrappers never drain each other's budget.
18
18
  */
19
19
  export function rateLimit({ max = 60, windowMs = 60_000, keyFn } = {}) {
20
+ const buckets = new Map(); // key -> { count, resetAt }
21
+ let lastCleanup = 0;
20
22
  return (handler) => async (ctx) => {
21
23
  const key = keyFn ? keyFn(ctx) : (ctx.req?.socket?.remoteAddress ?? 'local');
22
24
  const now = Date.now();
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import { start } from '../server.mjs';
4
4
  import { rateLimit } from '../routes/ratelimit.mjs';
5
5
 
6
- // NOTE: both tests share the module-level rate buckets; the second test uses
7
- // custom keys so it is independent of the first test's IP bucket.
6
+ // NOTE: each rateLimit({...}) instance owns its own buckets (the map lives
7
+ // inside the factory) — that independence is pinned by the third test.
8
8
 
9
9
  test('GET /api/ping allows max then returns 429 with limit headers', async (t) => {
10
10
  const app = await start(0);
@@ -46,3 +46,23 @@ test('rateLimit: custom keyFn isolates clients into separate buckets', async ()
46
46
  const retryAfter = Number(second[2]['Retry-After']);
47
47
  assert.ok(retryAfter >= 59 && retryAfter <= 60, `unexpected Retry-After ${retryAfter}`);
48
48
  });
49
+
50
+ test('rateLimit: independently configured wrappers never share budget', async () => {
51
+ // exhaust a tight limiter, then prove a loose limiter's FIRST request
52
+ // still sees a full budget (module-level buckets used to leak the count
53
+ // across wrappers — cross-route lockout)
54
+ const tight = rateLimit({ max: 1, windowMs: 60_000, keyFn: (ctx) => ctx.key })(
55
+ async () => [200, { which: 'tight' }],
56
+ );
57
+ const loose = rateLimit({ max: 50, windowMs: 60_000, keyFn: (ctx) => ctx.key })(
58
+ async () => [200, { which: 'loose' }],
59
+ );
60
+
61
+ await tight({ key: 'carol', req: {} }); // tight budget spent
62
+ assert.equal((await tight({ key: 'carol', req: {} }))[0], 429);
63
+
64
+ const [status, , headers] = await loose({ key: 'carol', req: {} }); // same client, other route
65
+ assert.equal(status, 200); // NOT locked out by the tight wrapper
66
+ assert.equal(headers['X-RateLimit-Limit'], '50');
67
+ assert.equal(headers['X-RateLimit-Remaining'], '49'); // full budget, not 48
68
+ });
@@ -19,11 +19,31 @@ let ready = null; // memoized cold load: concurrent first callers share ONE arra
19
19
  function load() {
20
20
  if (!ready) {
21
21
  ready = (async () => {
22
+ let raw = null;
22
23
  try {
23
- jobs = JSON.parse(await readFile(STORE_PATH, 'utf8'));
24
+ raw = await readFile(STORE_PATH, 'utf8');
24
25
  } catch (err) {
25
- if (err?.code !== 'ENOENT') {
26
- console.error(`[jobs] could not read ${STORE_PATH} (${err?.message ?? err}) — starting empty`);
26
+ if (err?.code !== 'ENOENT') console.error(`[jobs] could not read store (${err?.code ?? err}) — starting empty`);
27
+ }
28
+ if (raw !== null) {
29
+ try {
30
+ jobs = JSON.parse(raw);
31
+ } catch {
32
+ jobs = null;
33
+ }
34
+ }
35
+ if (raw === null || jobs === null || !Array.isArray(jobs)) {
36
+ // A store that exists but is corrupt must never be silently wiped —
37
+ // the next persist would overwrite recoverable job history. Quarantine
38
+ // it, say so loudly, start empty.
39
+ if (raw !== null) {
40
+ const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
41
+ try {
42
+ await rename(STORE_PATH, quarantine);
43
+ console.error(`[jobs] store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
44
+ } catch (err) {
45
+ console.error(`[jobs] store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
46
+ }
27
47
  }
28
48
  jobs = [];
29
49
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "template": "library",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -7,6 +7,6 @@
7
7
  "exports": { ".": "./index.mjs" },
8
8
  "files": ["index.mjs"],
9
9
  "scripts": {
10
- "check": "node \"__IDEA2PROTOTYPE_BIN__\" check"
10
+ "check": "__IDEA2PROTOTYPE_CMD__"
11
11
  }
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "template": "web",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -5,6 +5,6 @@
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "start": "node server.mjs",
8
- "check": "node \"__IDEA2PROTOTYPE_BIN__\" check"
8
+ "check": "__IDEA2PROTOTYPE_CMD__"
9
9
  }
10
10
  }
@@ -52,22 +52,42 @@ function matchRoute(route, url) {
52
52
  function readRawBody(req, limit = 10e6) {
53
53
  return new Promise((resolve, reject) => {
54
54
  let size = 0;
55
+ let tooLarge = false;
55
56
  const chunks = [];
56
57
  req.on('data', (chunk) => {
57
58
  size += chunk.length;
59
+ // Oversize policy: drain up to 2x limit so a clean 413 can still be
60
+ // delivered at end(); past 2x, destroy the socket to bound the cost of
61
+ // absorbing junk — those clients see a connection reset, deliberately.
58
62
  if (size > limit) {
59
- reject(new Error('body too large'));
60
- req.destroy();
63
+ tooLarge = true;
64
+ if (size > limit * 2) {
65
+ req.destroy();
66
+ const err = new Error('body too large');
67
+ err.code = 'BODY_TOO_LARGE';
68
+ reject(err);
69
+ }
61
70
  } else {
62
71
  chunks.push(chunk);
63
72
  }
64
73
  });
65
- req.on('end', () => resolve(Buffer.concat(chunks)));
74
+ req.on('end', () => {
75
+ if (tooLarge) {
76
+ const err = new Error('body too large');
77
+ err.code = 'BODY_TOO_LARGE';
78
+ reject(err);
79
+ } else {
80
+ resolve(Buffer.concat(chunks));
81
+ }
82
+ });
66
83
  req.on('error', reject);
67
84
  });
68
85
  }
69
86
 
70
87
  function sendJson(res, status, payload, headers = {}) {
88
+ // Handlers are user code — a bogus status must degrade to a clean 500, not
89
+ // throw inside writeHead and leak Node internals ("Invalid status code: b").
90
+ if (!Number.isInteger(status) || status < 100 || status > 599) status = 500;
71
91
  if (status === 204 || status === 304) {
72
92
  res.writeHead(status, headers);
73
93
  return res.end();
@@ -76,7 +96,9 @@ function sendJson(res, status, payload, headers = {}) {
76
96
  res.writeHead(status, { 'content-length': payload.length, ...headers });
77
97
  return res.end(payload);
78
98
  }
79
- const data = JSON.stringify(payload);
99
+ // [status] with no payload serializes as null — JSON.stringify(undefined)
100
+ // returns undefined and Buffer.byteLength would throw on it.
101
+ const data = JSON.stringify(payload ?? null);
80
102
  res.writeHead(status, {
81
103
  'content-type': 'application/json; charset=utf-8',
82
104
  'content-length': Buffer.byteLength(data),
@@ -105,7 +127,10 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
105
127
  try {
106
128
  raw = await readRawBody(req);
107
129
  } catch (err) {
108
- return sendJson(res, 400, { error: err.message });
130
+ if (err.code === 'BODY_TOO_LARGE') {
131
+ return sendJson(res, 413, { error: 'body too large' });
132
+ }
133
+ return sendJson(res, 400, { error: String(err?.message ?? err) });
109
134
  }
110
135
  if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) {
111
136
  ctx.rawBody = raw;
@@ -122,6 +147,12 @@ export async function start(port = Number(process.env.PORT ?? 3000)) {
122
147
  // ctx.res (e.g. Server-Sent Events) returns nothing — the stream
123
148
  // stays open past this request; don't try to send a JSON response.
124
149
  if (res.headersSent || res.writableEnded) return;
150
+ // Contract-violating returns (a bare string, undefined, ...) must be
151
+ // a clean 500 — destructuring them would leak parser plumbing
152
+ // ("result is not iterable") into the response.
153
+ if (!Array.isArray(result)) {
154
+ return sendJson(res, 500, { error: 'handler returned an invalid result (expected [status, payload, headers?])' });
155
+ }
125
156
  const [status, payload, headers] = result;
126
157
  return sendJson(res, status, payload, headers);
127
158
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "template": "worker",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -5,6 +5,6 @@
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "start": "node worker.mjs",
8
- "check": "node \"__IDEA2PROTOTYPE_BIN__\" check"
8
+ "check": "__IDEA2PROTOTYPE_CMD__"
9
9
  }
10
10
  }
@@ -14,11 +14,31 @@ let ready = null; // memoized cold load: concurrent first callers share ONE arra
14
14
  function load() {
15
15
  if (!ready) {
16
16
  ready = (async () => {
17
+ let raw = null;
17
18
  try {
18
- jobs = JSON.parse(await readFile(STORE_PATH, 'utf8'));
19
+ raw = await readFile(STORE_PATH, 'utf8');
19
20
  } catch (err) {
20
- if (err?.code !== 'ENOENT') {
21
- console.error(`[jobs] could not read ${STORE_PATH} (${err?.message ?? err}) — starting empty`);
21
+ if (err?.code !== 'ENOENT') console.error(`[jobs] could not read store (${err?.code ?? err}) — starting empty`);
22
+ }
23
+ if (raw !== null) {
24
+ try {
25
+ jobs = JSON.parse(raw);
26
+ } catch {
27
+ jobs = null;
28
+ }
29
+ }
30
+ if (raw === null || jobs === null || !Array.isArray(jobs)) {
31
+ // A store that exists but is corrupt must never be silently wiped —
32
+ // the next persist would overwrite recoverable job history. Quarantine
33
+ // it, say so loudly, start empty.
34
+ if (raw !== null) {
35
+ const quarantine = `${STORE_PATH}.corrupt-${Date.now().toString(36)}`;
36
+ try {
37
+ await rename(STORE_PATH, quarantine);
38
+ console.error(`[jobs] store is not a valid JSON list — quarantined as ${quarantine}; starting empty`);
39
+ } catch (err) {
40
+ console.error(`[jobs] store is corrupt AND could not be quarantined (${err?.code ?? err}) — the next write will overwrite it`);
41
+ }
22
42
  }
23
43
  jobs = [];
24
44
  }