@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.
- package/CHANGELOG.md +20 -0
- package/README.md +15 -11
- package/agents/AGENTS.md +11 -9
- package/agents/CLAUDE.md +11 -9
- package/package.json +2 -2
- package/skills/idea2prototype/SKILL.md +2 -0
- package/skills/idea2prototype/scripts/idea2prototype.mjs +174 -28
- package/skills/idea2prototype/templates/api/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/api/package.json +1 -1
- package/skills/idea2prototype/templates/api/server.mjs +37 -6
- package/skills/idea2prototype/templates/cli/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/cli/package.json +1 -1
- package/skills/idea2prototype/templates/features/api-crud/route.tmpl.mjs +40 -10
- package/skills/idea2prototype/templates/features/auth-local/route.tmpl.mjs +44 -10
- package/skills/idea2prototype/templates/features/rate-limit/route.tmpl.mjs +4 -2
- package/skills/idea2prototype/templates/features/rate-limit/test.tmpl.mjs +22 -2
- package/skills/idea2prototype/templates/features/worker-jobs/jobs.tmpl.mjs +23 -3
- package/skills/idea2prototype/templates/library/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/library/package.json +1 -1
- package/skills/idea2prototype/templates/web/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/web/package.json +1 -1
- package/skills/idea2prototype/templates/web/server.mjs +36 -5
- package/skills/idea2prototype/templates/worker/.idea2prototype.json +1 -1
- package/skills/idea2prototype/templates/worker/package.json +1 -1
- 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:**
|
|
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 —
|
|
61
|
+
**Any agent or plain shell — npx straight off npmjs, no clone needed:**
|
|
60
62
|
|
|
61
63
|
```bash
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
71
|
+
**From a clean clone** (offline / air-gapped, or pinning the engine alongside the project):
|
|
71
72
|
|
|
72
73
|
```bash
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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? `
|
|
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? `
|
|
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.
|
|
4
|
-
"description": "Turn ideas into tested prototypes
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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('
|
|
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,
|
|
405
|
-
if (
|
|
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
|
|
510
|
-
|
|
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))
|
|
589
|
-
|
|
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`);
|
|
@@ -15,7 +15,7 @@ async function loadApiRoutes() {
|
|
|
15
15
|
try {
|
|
16
16
|
files = await readdir(ROUTES_DIR);
|
|
17
17
|
} catch {
|
|
18
|
-
return routes;
|
|
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
|
-
|
|
49
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
9
|
+
let ready = null; // memoized cold load: concurrent first callers share ONE array
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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(
|
|
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
|
|
14
|
+
let usersReady = null; // memoized cold load: concurrent first callers share ONE array
|
|
15
15
|
let secretCache = null;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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(
|
|
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:
|
|
7
|
-
//
|
|
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
|
-
|
|
24
|
+
raw = await readFile(STORE_PATH, 'utf8');
|
|
24
25
|
} catch (err) {
|
|
25
|
-
if (err?.code !== 'ENOENT') {
|
|
26
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
60
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
19
|
+
raw = await readFile(STORE_PATH, 'utf8');
|
|
19
20
|
} catch (err) {
|
|
20
|
-
if (err?.code !== 'ENOENT') {
|
|
21
|
-
|
|
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
|
}
|