@ahmed-3m/idea2prototype 0.9.1 → 1.0.1

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.
@@ -18,7 +18,7 @@ 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.1';
21
+ const VERSION = '1.0.1';
22
22
  const PKG_NAME = '@ahmed-3m/idea2prototype';
23
23
  const HERE = dirname(fileURLToPath(import.meta.url));
24
24
  const TEMPLATES_DIR = join(HERE, '..', 'templates');
@@ -49,6 +49,7 @@ async function cmdNew(rest) {
49
49
  const opts = parseFlags(rest, ['template', 'name', 'idea']);
50
50
  const dir = opts._[0];
51
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>)');
52
53
 
53
54
  const template = opts.template ?? 'web';
54
55
  const templateDir = join(TEMPLATES_DIR, template);
@@ -60,10 +61,47 @@ async function cmdNew(rest) {
60
61
  if (!/^[a-z0-9][a-z0-9-_]*$/.test(slug)) {
61
62
  die(`invalid directory name "${slug}" — use letters, digits, dashes, underscores`);
62
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
+ }
63
85
  const name = (opts.name ?? basename(resolve(dir))).replace(/[<>]/g, '');
64
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
+ }
65
95
 
66
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
+ }
67
105
  if (existsSync(target) && readdirSyncSafe(target).length > 0) {
68
106
  die(`"${target}" exists and is not empty — idea2prototype never overwrites existing files`);
69
107
  }
@@ -189,9 +227,29 @@ const FEATURES = {
189
227
  },
190
228
  };
191
229
 
230
+ // api-crud resource names that would collide with another feature's data
231
+ // store, route file, or URL path — in EITHER add order, so a file-exists
232
+ // check alone can't catch them. "users" is the dangerous one: a CRUD resource
233
+ // over data/users.json serves auth-local's salts + hashes to anonymous GETs
234
+ // and lets anonymous POSTs plant login-capable accounts.
235
+ const RESERVED_RESOURCES = new Map([
236
+ ['users', 'auth-local stores accounts in data/users.json'],
237
+ ['auth', 'auth-local owns routes/auth.mjs and /api/auth/*'],
238
+ ['jobs', 'worker-jobs owns data/jobs.json, routes/jobs.mjs and /api/jobs'],
239
+ ['uploads', 'upload owns routes/uploads.mjs and /api/uploads'],
240
+ ['events', 'sse owns /api/events'],
241
+ ['sse', 'sse owns routes/sse.mjs'],
242
+ ['ping', 'rate-limit owns /api/ping'],
243
+ ['ratelimit', 'rate-limit owns routes/ratelimit.mjs'],
244
+ ['cors', 'cors owns routes/cors.mjs'],
245
+ ['cors-ping', 'cors owns /api/cors-ping'],
246
+ ['health', 'the server answers /api/health itself'],
247
+ ]);
248
+
192
249
  async function cmdAdd(rest) {
193
250
  const feature = rest[0]?.startsWith('--') ? undefined : rest[0];
194
251
  const opts = parseFlags(rest.slice(feature ? 1 : 0), ['resource']);
252
+ checkPositionalCount(opts, 0, 'add takes one feature name (add <feature>)');
195
253
  const available = Object.keys(FEATURES).join(', ');
196
254
  if (!feature) die(`usage: idea2prototype add <feature> — available: ${available}`);
197
255
  const def = FEATURES[feature];
@@ -207,6 +265,9 @@ async function cmdAdd(rest) {
207
265
  if (!/^[a-z][a-z0-9-]*$/.test(resource)) {
208
266
  die(`invalid resource name "${resource}" — lowercase letters, digits, dashes`);
209
267
  }
268
+ if (RESERVED_RESOURCES.has(resource)) {
269
+ die(`resource name "${resource}" is reserved — ${RESERVED_RESOURCES.get(resource)}; pick another name`);
270
+ }
210
271
  opts.resource = resource;
211
272
  }
212
273
 
@@ -236,32 +297,41 @@ async function cmdAdd(rest) {
236
297
  // check / verify — the compact verdict
237
298
  // ---------------------------------------------------------------------------
238
299
  async function cmdCheck(rest) {
239
- const opts = parseFlags(rest, []);
300
+ const opts = parseFlags(rest, [], ['json']);
301
+ checkPositionalCount(opts, 0, 'check takes no arguments — run it from inside the project');
240
302
  await loadMarker(); // must run inside a idea2prototype project
241
- return runChecks(process.cwd(), 'json' in opts);
303
+ return runChecks(process.cwd(), 'json' in opts, 'check');
242
304
  }
243
305
 
244
306
  // verify: run the same verdict on ANY Node project — used to judge projects
245
307
  // this tool never scaffolded (benchmark control arm, external code).
246
308
  async function cmdVerify(rest) {
247
- const dir = rest.find((a) => !a.startsWith('--'));
309
+ const opts = parseFlags(rest, [], ['json']);
310
+ checkPositionalCount(opts, 1, 'verify takes one optional directory (verify [dir])');
311
+ const dir = opts._[0];
248
312
  const target = resolve(process.cwd(), dir ?? '.');
249
313
  if (!existsSync(target) || !statSync(target).isDirectory()) {
250
314
  die(`no such directory: ${target}`);
251
315
  }
252
- return runChecks(target, rest.includes('--json'));
316
+ return runChecks(target, 'json' in opts, 'verify');
253
317
  }
254
318
 
255
- async function runChecks(projectDir, asJson) {
319
+ async function runChecks(projectDir, asJson, mode = 'check') {
256
320
  const marker = await readMarker(projectDir); // null for foreign projects (verify mode)
257
321
  const steps = [];
258
322
  const syntax = await stepSyntax(projectDir);
259
323
  steps.push(syntax);
260
324
  const tests = await stepTests(projectDir, syntax.status === 'pass');
261
325
  steps.push(tests);
262
- const boot = await stepBoot(projectDir, tests.status === 'pass', marker);
326
+ const boot = await stepBoot(projectDir, tests, marker);
263
327
  steps.push(boot);
264
328
 
329
+ // An empty directory is not a project: judge mode must not hand out a
330
+ // green verdict for a dir with nothing to check (no sources, no tests).
331
+ if (!marker && tests.status === 'skip' && steps[0].files === 0) {
332
+ steps.push({ name: 'project', status: 'fail', detail: 'no Node project found (no sources parsed, no test files)' });
333
+ }
334
+
265
335
  const failed = steps.filter((s) => s.status === 'fail');
266
336
  const summary = failed.length === 0 ? 'PASS' : 'FAIL';
267
337
  const passedCount = steps.filter((s) => s.status === 'pass').length;
@@ -278,7 +348,7 @@ async function runChecks(projectDir, asJson) {
278
348
  }, null, 2));
279
349
  } else {
280
350
  SAY(``);
281
- SAY(`Idea2Prototype check — ${summary} (${passedCount}/${steps.length} passed) [${projectDir}]`);
351
+ SAY(`Idea2Prototype ${mode} — ${summary} (${passedCount}/${steps.length} passed) [${projectDir}]`);
282
352
  for (const s of steps) {
283
353
  const icon = s.status === 'pass' ? 'PASS ' : s.status === 'fail' ? 'FAIL ' : 'SKIP ';
284
354
  SAY(` ${s.name.padEnd(7)} ${icon} ${s.detail}`);
@@ -329,8 +399,8 @@ async function stepSyntax(projectDir) {
329
399
  detail = `${serverFiles.length} files parse clean`;
330
400
  }
331
401
  return failures.length
332
- ? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3) }
333
- : { name: 'syntax', status: 'pass', detail };
402
+ ? { name: 'syntax', status: 'fail', detail, notes: failures.slice(0, 3), files: total }
403
+ : { name: 'syntax', status: 'pass', detail, files: total };
334
404
  }
335
405
 
336
406
  // Browser JS can't take the plain `node --check <file>` path: an .js file
@@ -349,22 +419,61 @@ function parsesClean(source, inputType) {
349
419
  return res.status === 0;
350
420
  }
351
421
 
422
+ // Test discovery for judge mode, mirroring `node --test`'s own default
423
+ // conventions: **/*.test.{js,mjs,cjs}, test.js, test-*.*, *-test.*/_*_test.*
424
+ // basenames at any depth, plus EVERY .js/.mjs/.cjs under a `test/` directory
425
+ // (any depth) — so projects this tool never scaffolded are judged by their
426
+ // real tests instead of silently passing with none found.
427
+ // Untrusted (uploads/) and generated (dist/, coverage/, data/, public/)
428
+ // trees are never entered: the syntax step already refuses to READ uploads,
429
+ // and discovery must not hand an uploaded *.test.js to `node --test` to
430
+ // EXECUTE. Dot-dirs and node_modules are skipped the same way.
431
+ const TEST_DIR_SKIP = new Set(['node_modules', 'public', 'data', 'uploads', 'dist', 'coverage']);
432
+ const TEST_BASENAME = /^(test(?:-.+)?|.+[._-]test)\.(js|mjs|cjs)$/;
433
+
434
+ async function discoverTestFiles(projectDir) {
435
+ const found = new Set();
436
+ const JS = /\.(js|mjs|cjs)$/;
437
+ async function walk(dir, depth, underTestDir) {
438
+ if (depth > 6) return;
439
+ let entries;
440
+ try {
441
+ entries = await readdir(dir, { withFileTypes: true });
442
+ } catch {
443
+ return;
444
+ }
445
+ for (const entry of entries) {
446
+ if (entry.name.startsWith('.') || TEST_DIR_SKIP.has(entry.name)) continue;
447
+ const full = join(dir, entry.name);
448
+ if (entry.isDirectory()) {
449
+ await walk(full, depth + 1, underTestDir || entry.name === 'test');
450
+ } else if (underTestDir ? JS.test(entry.name) : TEST_BASENAME.test(entry.name)) {
451
+ found.add(full);
452
+ }
453
+ }
454
+ }
455
+ await walk(projectDir, 0, false);
456
+ return [...found].sort();
457
+ }
458
+
352
459
  async function stepTests(projectDir, syntaxPassed) {
353
- const testDir = join(projectDir, 'tests');
354
- if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)' };
355
- let testFiles = [];
356
- try {
357
- testFiles = (await readdir(testDir)).filter((f) => f.endsWith('.test.mjs')).sort();
358
- } catch {
359
- return { name: 'tests', status: 'skip', detail: 'no tests/ directory' };
460
+ if (!syntaxPassed) return { name: 'tests', status: 'skip', detail: 'skipped (syntax failed)', cause: 'syntax' };
461
+ const testFiles = (await discoverTestFiles(projectDir)).map((f) => relative(projectDir, f));
462
+ if (!testFiles.length) {
463
+ return {
464
+ name: 'tests',
465
+ status: 'skip',
466
+ detail: 'no test files found',
467
+ cause: 'no-tests',
468
+ 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 covers syntax + boot only'],
469
+ };
360
470
  }
361
- if (!testFiles.length) return { name: 'tests', status: 'skip', detail: 'no *.test.mjs files' };
362
471
 
363
472
  // Pass explicit file paths: glob args to `node --test` are Node >= 21 only.
364
473
  // Strip NODE_TEST_CONTEXT so a check spawned from inside a test runner
365
474
  // doesn't put the child into child-reporter mode (no TAP summary).
366
475
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'NODE_TEST_CONTEXT'));
367
- const fileArgs = testFiles.map((f) => join('tests', f));
476
+ const fileArgs = testFiles.map((f) => f.split(sep).join('/'));
368
477
  // Serial execution trades wall-clock for determinism — scale the spawn
369
478
  // budget with the file count so slow-but-passing suites aren't killed
370
479
  // (90s floor, 30s per file, 5-minute ceiling).
@@ -396,6 +505,23 @@ async function stepTests(projectDir, syntaxPassed) {
396
505
  res = spawnSync(process.execPath, ['--test', ...fileArgs], spawnOpts);
397
506
  out = capture(res);
398
507
  }
508
+ // A hung test run surfaces two ways, by Node version. 22's runner enforces
509
+ // --test-timeout at the FILE level: a top-level infinite loop is killed by
510
+ // the runner itself (~15s, "testTimeoutFailure" + "# cancelled 1", clean
511
+ // exit). 24's runner never fires there, so the spawn budget is the only
512
+ // bound (ETIMEDOUT, no summary). Both must read as a timeout verdict —
513
+ // never "? failed of ?" or spawn plumbing.
514
+ const fileTimeoutMs = /test timed out after (\d+)ms/.exec(out)?.[1];
515
+ if (res.error?.code === 'ETIMEDOUT' || fileTimeoutMs !== undefined) {
516
+ return {
517
+ name: 'tests',
518
+ status: 'fail',
519
+ detail: res.error?.code === 'ETIMEDOUT'
520
+ ? `tests timed out after ${Math.round(spawnOpts.timeout / 1000)}s — no summary produced`
521
+ : `tests timed out — the runner's own timeout killed the blocked test (after ${fileTimeoutMs}ms)`,
522
+ notes: ['a test file blocked past every bound — infinite loop, hung worker, or slower than the per-test budget?'],
523
+ };
524
+ }
399
525
  const counts = {};
400
526
  for (const m of out.matchAll(/^# (tests|pass|fail|skipped|cancelled) (\d+)$/gm)) counts[m[1]] = Number(m[2]);
401
527
 
@@ -416,11 +542,28 @@ async function stepTests(projectDir, syntaxPassed) {
416
542
  };
417
543
  }
418
544
 
419
- async function stepBoot(projectDir, testsPassed, marker) {
420
- if (!testsPassed) return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
421
- // Library projects have no server to probe — boot = import the public API.
422
- if (marker?.template === 'library') return bootLibrary(projectDir);
423
- return bootServer(projectDir, marker);
545
+ async function stepBoot(projectDir, testsStep, marker) {
546
+ if (testsStep.status === 'fail') return { name: 'boot', status: 'skip', detail: 'skipped (tests failed)' };
547
+ // A syntax failure is why nothing ran — importing broken code proves nothing.
548
+ if (testsStep.cause === 'syntax') return { name: 'boot', status: 'skip', detail: 'skipped (syntax failed)' };
549
+ // "No tests found" is NOT a reason to skip boot: a project without tests is
550
+ // exactly the one whose only remaining evidence is whether it starts (a
551
+ // server that throws in start() used to verify PASS on syntax alone).
552
+ //
553
+ // Boot runs in THIS process, so the project's cwd-relative paths (the
554
+ // templates' ROOT/routes/data, a foreign readFileSync('config.json')) must
555
+ // resolve against the project — as they do for the tests step (cwd:
556
+ // projectDir) and for `npm start`. Otherwise `verify <dir>` from elsewhere
557
+ // boots a different app than `verify` from inside it.
558
+ const previousCwd = process.cwd();
559
+ process.chdir(projectDir);
560
+ try {
561
+ // Library projects have no server to probe — boot = import the public API.
562
+ if (marker?.template === 'library') return await bootLibrary(projectDir);
563
+ return await bootServer(projectDir, marker);
564
+ } finally {
565
+ process.chdir(previousCwd);
566
+ }
424
567
  }
425
568
 
426
569
  async function bootLibrary(projectDir) {
@@ -444,73 +587,103 @@ async function bootServer(projectDir, marker) {
444
587
  if (!existsSync(serverPath)) return { name: 'boot', status: 'skip', detail: 'no server.mjs' };
445
588
 
446
589
  const started = Date.now();
447
- let app; // closed in finally once started — a leaked listener hangs the process forever
448
- // The import ran but no app handle came back to close: the module may hold
590
+ let app; // closed below once started — a leaked listener hangs the process forever
591
+ // The import ran but no closable app handle came back: the module may hold
449
592
  // its own listeners (foreign entry points often listen at import time).
450
593
  // Mark the step so runChecks exits explicitly after printing the verdict —
451
594
  // those handles are unreachable from here and would hang the process.
452
595
  const leakedImport = { unreachableHandles: true };
596
+ let result;
453
597
  try {
454
598
  const mod = await withTimeout(import(pathToFileURL(serverPath).href), 15_000, 'server import');
455
599
  if (typeof mod.start !== 'function') {
456
600
  return { name: 'boot', status: 'skip', detail: 'server.mjs does not export start()', ...leakedImport };
457
601
  }
458
602
  app = await withTimeout(mod.start(0), 10_000, 'server start');
459
- // redirect: 'manual' — never follow a redirect off the probe target; the
460
- // 3xx itself counts as serving (foreign ok-range is 200–399).
461
- const probe = async (path) =>
462
- withTimeout(fetch(`http://127.0.0.1:${app.port}${path}`, { redirect: 'manual' }), 10_000, `${path} request`);
463
- let res = await probe('/api/health');
464
- let detail = `GET /api/health -> ${res.status}`;
465
- // Foreign projects (verify mode) may not implement our template's health
466
- // route — a server that starts and serves its own surface still boots.
467
- let rootProbed = false;
468
- if (res.status === 404) {
469
- res = await probe('/');
470
- detail = `GET /api/health -> 404, GET / -> ${res.status}`;
471
- rootProbed = true;
472
- }
473
- const body = await res.json().catch(() => ({}));
474
-
475
- // Scaffolded projects follow the template contract (health returns
476
- // {"status":"ok"}); foreign projects only have to start and serve.
477
- const ok = marker
478
- ? res.status === 200 && body.status === 'ok'
479
- : res.status >= 200 && res.status < 400;
480
- if (!ok) {
481
- return { name: 'boot', status: 'fail', detail: `${detail} ${JSON.stringify(body)}` };
482
- }
603
+ result = await probeServer(app, marker, started);
604
+ } catch (err) {
605
+ result = { name: 'boot', status: 'fail', detail: String(err?.message ?? err) };
606
+ }
607
+ // Not a finally: a close() that throws or never settles must not replace
608
+ // the verdict (foreign handles are untrusted) — it only flags the exit.
609
+ if (!(await closeApp(app))) Object.assign(result, leakedImport);
610
+ return result;
611
+ }
483
612
 
484
- // #18: a health route alone can certify an app whose every real route
485
- // 500s — also probe the root. 4xx on / is legitimate (API-only projects),
486
- // a 5xx root is a real defect. Symmetric for scaffolded and foreign
487
- // projects; a request error here surfaces via the outer catch.
488
- if (!rootProbed) {
489
- const root = await probe('/');
490
- detail += `, GET / -> ${root.status}`;
491
- if (root.status >= 500) {
492
- return { name: 'boot', status: 'fail', detail: `${detail} — root serves an error` };
493
- }
613
+ // start(0) may hand back the template contract ({ port, close() }) or, in
614
+ // foreign projects, a bare listening http.Server — read the port from either.
615
+ function appPort(app) {
616
+ return app?.port ?? app?.address?.()?.port ?? app?.server?.address?.()?.port;
617
+ }
618
+
619
+ // Close whatever start() returned: a promise-returning close() (template
620
+ // contract) or a callback-style http.Server close(cb). Returns false when
621
+ // nothing closable came back or the close failed/hung — the caller then
622
+ // exits explicitly instead of hanging on handles it cannot reach.
623
+ async function closeApp(app) {
624
+ if (typeof app?.close !== 'function') return false;
625
+ try {
626
+ app.closeAllConnections?.(); // bare http.Server: drop keep-alive sockets so close(cb) settles
627
+ await withTimeout(new Promise((resolve, reject) => {
628
+ const ret = app.close((err) => (err ? reject(err) : resolve()));
629
+ if (typeof ret?.then === 'function') ret.then(resolve, reject);
630
+ }), 5_000, 'server close');
631
+ return true;
632
+ } catch {
633
+ return false;
634
+ }
635
+ }
636
+
637
+ async function probeServer(app, marker, started) {
638
+ const port = appPort(app);
639
+ if (!Number.isInteger(port)) {
640
+ return { name: 'boot', status: 'fail', detail: 'start() returned no port — expected { port, close() } or a listening http.Server' };
641
+ }
642
+ // redirect: 'manual' — never follow a redirect off the probe target; the
643
+ // 3xx itself counts as serving (foreign ok-range is 200–399).
644
+ const probe = async (path) =>
645
+ withTimeout(fetch(`http://127.0.0.1:${port}${path}`, { redirect: 'manual' }), 10_000, `${path} request`);
646
+ let res = await probe('/api/health');
647
+ let detail = `GET /api/health -> ${res.status}`;
648
+ // Foreign projects (verify mode) may not implement our template's health
649
+ // route — a server that starts and serves its own surface still boots.
650
+ let rootProbed = false;
651
+ if (res.status === 404) {
652
+ res = await probe('/');
653
+ detail = `GET /api/health -> 404, GET / -> ${res.status}`;
654
+ rootProbed = true;
655
+ }
656
+ const body = await res.json().catch(() => ({}));
657
+
658
+ // Scaffolded projects follow the template contract (health returns
659
+ // {"status":"ok"}); foreign projects only have to start and serve.
660
+ const ok = marker
661
+ ? res.status === 200 && body.status === 'ok'
662
+ : res.status >= 200 && res.status < 400;
663
+ if (!ok) {
664
+ return { name: 'boot', status: 'fail', detail: `${detail} ${JSON.stringify(body)}` };
665
+ }
666
+
667
+ // #18: a health route alone can certify an app whose every real route
668
+ // 500s — also probe the root. 4xx on / is legitimate (API-only projects),
669
+ // a 5xx root is a real defect. Symmetric for scaffolded and foreign
670
+ // projects; a request error here surfaces via bootServer's catch.
671
+ if (!rootProbed) {
672
+ const root = await probe('/');
673
+ detail += `, GET / -> ${root.status}`;
674
+ if (root.status >= 500) {
675
+ return { name: 'boot', status: 'fail', detail: `${detail} — root serves an error` };
494
676
  }
495
- return { name: 'boot', status: 'pass', detail: `${detail} in ${Date.now() - started}ms` };
496
- } catch (err) {
497
- // Import or start() threw before app was assigned → nothing was closed.
498
- // (Probe failures after a successful start still run the finally close.)
499
- return {
500
- name: 'boot',
501
- status: 'fail',
502
- detail: String(err?.message ?? err),
503
- ...(app ? {} : leakedImport),
504
- };
505
- } finally {
506
- if (typeof app?.close === 'function') await app.close().catch(() => {});
507
677
  }
678
+ return { name: 'boot', status: 'pass', detail: `${detail} in ${Date.now() - started}ms` };
508
679
  }
509
680
 
510
681
  // ---------------------------------------------------------------------------
511
682
  // list / help
512
683
  // ---------------------------------------------------------------------------
513
- async function cmdList() {
684
+ async function cmdList(rest) {
685
+ const opts = parseFlags(rest, []);
686
+ checkPositionalCount(opts, 0, 'list takes no arguments');
514
687
  const templates = (await readdir(TEMPLATES_DIR, { withFileTypes: true }))
515
688
  .filter((d) => d.isDirectory() && d.name !== 'features')
516
689
  .map((d) => d.name);
@@ -521,8 +694,10 @@ async function cmdList() {
521
694
  // map — compact repo map (paths + LOC + exports) so an agent can decide what
522
695
  // to read instead of loading whole files into context.
523
696
  async function cmdMap(rest) {
524
- const dir = rest.find((a) => !a.startsWith('--'));
525
- const asJson = rest.includes('--json');
697
+ const opts = parseFlags(rest, [], ['json']);
698
+ checkPositionalCount(opts, 1, 'map takes one optional directory (map [dir])');
699
+ const dir = opts._[0];
700
+ const asJson = 'json' in opts;
526
701
  const root = resolve(process.cwd(), dir ?? '.');
527
702
  if (!existsSync(root) || !statSync(root).isDirectory()) die(`no such directory: ${root}`);
528
703
 
@@ -594,14 +769,25 @@ const withTimeout = (promise, ms, label) => {
594
769
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
595
770
  };
596
771
 
597
- function parseFlags(args, valueFlags) {
772
+ function parseFlags(args, valueFlags, booleanFlags = []) {
598
773
  const out = { _: [] };
774
+ const allowed = [
775
+ ...valueFlags.map((f) => `--${f} <value>`),
776
+ ...booleanFlags.map((f) => `--${f}`),
777
+ ].join(', ') || 'none';
599
778
  for (let i = 0; i < args.length; i++) {
600
779
  const arg = args[i];
601
780
  if (arg.startsWith('--')) {
602
781
  const key = arg.slice(2);
603
- if (valueFlags.includes(key)) out[key] = args[++i];
604
- else out[key] = true;
782
+ if (valueFlags.includes(key)) {
783
+ const val = args[++i];
784
+ if (val === undefined || val === '' || val.startsWith('-')) die(`flag "${arg}" needs a value (allowed: ${allowed})`);
785
+ out[key] = val;
786
+ } else if (booleanFlags.includes(key)) {
787
+ out[key] = true;
788
+ } else {
789
+ die(`unknown flag "${arg}" (allowed: ${allowed})`);
790
+ }
605
791
  } else {
606
792
  out._.push(arg);
607
793
  }
@@ -609,6 +795,13 @@ function parseFlags(args, valueFlags) {
609
795
  return out;
610
796
  }
611
797
 
798
+ // Positionals beyond what a command consumes die instead of being silently
799
+ // ignored — `check ./proj` that quietly ignores ./proj is a silent wrong answer.
800
+ function checkPositionalCount(opts, max, what) {
801
+ const extra = opts._[max];
802
+ if (extra !== undefined) die(`unexpected argument "${extra}" — ${what}`);
803
+ }
804
+
612
805
  // Canonical marker reader: null when absent or unparseable.
613
806
  async function readMarker(projectDir) {
614
807
  try {
@@ -705,7 +898,7 @@ try {
705
898
  case 'check': await cmdCheck(rest); break;
706
899
  case 'verify': await cmdVerify(rest); break;
707
900
  case 'map': await cmdMap(rest); break;
708
- case 'list': await cmdList(); break;
901
+ case 'list': await cmdList(rest); break;
709
902
  case 'version': case '--version': case '-v': SAY(VERSION); break;
710
903
  case 'help': case undefined: case '--help': case '-h': help(); break;
711
904
  default: die(`unknown command "${command}" — run: idea2prototype help`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "tool": "idea2prototype",
3
- "version": "0.9.1",
3
+ "version": "1.0.1",
4
4
  "template": "api",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",
@@ -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.1",
3
+ "version": "1.0.1",
4
4
  "template": "cli",
5
5
  "name": "{{name}}",
6
6
  "slug": "{{slug}}",