@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.
- package/CHANGELOG.md +28 -0
- package/README.md +7 -3
- package/agents/AGENTS.md +2 -0
- package/agents/CLAUDE.md +2 -0
- package/package.json +2 -2
- package/skills/idea2prototype/SKILL.md +4 -2
- package/skills/idea2prototype/scripts/idea2prototype.mjs +272 -79
- package/skills/idea2prototype/templates/api/.idea2prototype.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/features/api-crud/route.tmpl.mjs +44 -12
- package/skills/idea2prototype/templates/features/api-crud/test.tmpl.mjs +21 -0
- 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/upload/route.tmpl.mjs +22 -10
- package/skills/idea2prototype/templates/features/upload/test.tmpl.mjs +27 -0
- 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/web/.idea2prototype.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/worker.mjs +23 -3
|
@@ -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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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('
|
|
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,
|
|
420
|
-
if (
|
|
421
|
-
//
|
|
422
|
-
if (
|
|
423
|
-
|
|
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
|
|
448
|
-
// The import ran but no app handle came back
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
525
|
-
|
|
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))
|
|
604
|
-
|
|
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`);
|
|
@@ -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
|
}
|