@webjsdev/cli 0.10.56 → 0.10.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +6 -1
  2. package/bin/webjs.js +219 -9
  3. package/lib/app-tasks.js +70 -10
  4. package/lib/check-target.js +1 -1
  5. package/lib/ci-config.js +250 -0
  6. package/lib/ci-runner.js +499 -0
  7. package/lib/create.js +59 -2
  8. package/lib/doctor/codes.js +1 -0
  9. package/lib/doctor/probes/framework-resolves.js +182 -6
  10. package/lib/doctor/runner.js +2 -1
  11. package/lib/doctor.js +1 -1
  12. package/lib/run-tasks.js +23 -3
  13. package/package.json +3 -3
  14. package/templates/.agents/rules/workflow.md +19 -12
  15. package/templates/.agents/skills/webjs/SKILL.md +6 -2
  16. package/templates/.agents/skills/webjs/references/built-ins.md +45 -1
  17. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +32 -3
  18. package/templates/.agents/skills/webjs/references/components.md +9 -1
  19. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +26 -2
  20. package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
  21. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  22. package/templates/.agents/skills/webjs/references/styling.md +48 -3
  23. package/templates/.agents/skills/webjs/references/testing.md +11 -0
  24. package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
  25. package/templates/.github/pull_request_template.md +3 -8
  26. package/templates/.github/workflows/ci.yml +38 -88
  27. package/templates/.hooks/pre-commit +5 -4
  28. package/templates/gallery/app/features/client-router/page.ts +5 -1
  29. package/templates/gallery/app/features/metadata/page.ts +7 -1
  30. package/templates/gallery/modules/client-router/components/router-controls.ts +7 -0
  31. package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
  32. package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
  33. package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
  34. package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
  35. package/templates/partials/agents-playbook-api.md +14 -8
  36. package/templates/partials/agents-playbook-fullstack.md +16 -10
@@ -0,0 +1,499 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { envWithLocalBin, killChildTree } from './run-tasks.js';
3
+
4
+ /**
5
+ * The `webjs ci` runner (#1471): executes a normalized step tree (see
6
+ * `ci-config.js`) the way Rails 8.1's `ActiveSupport::ContinuousIntegration`
7
+ * does. Each step prints a heading (title + command), runs, and prints
8
+ * `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends
9
+ * with a failure list and one total line. A group with `parallel > 1` runs
10
+ * its steps on that many slots with each step's output CAPTURED and replayed
11
+ * whole when it finishes, so two steps never interleave, and a progress line
12
+ * names what is running; a group nested inside it takes ONE slot and runs its
13
+ * steps in order.
14
+ *
15
+ * Two spawn shapes, deliberately different:
16
+ *
17
+ * - A SEQUENTIAL step inherits stdio and is NOT detached, so it owns the
18
+ * terminal while it runs and a Ctrl-C reaches it natively, the same split
19
+ * `webjs dev` makes for its `before` steps versus its `parallel` watchers
20
+ * (`run-tasks.js`). It resolves on `exit`.
21
+ * - A CAPTURED step is detached (its own process group, so `interrupt()` can
22
+ * take down the whole tree, shell wrapper included) with stdin IGNORED (a
23
+ * child that reads the TTY would otherwise stop on SIGTTIN and hang the
24
+ * pool), stdout and stderr piped and buffered in arrival order, and it
25
+ * resolves on `close`, not `exit`, because data can still be in the pipe
26
+ * after `exit`. A bounded grace after `exit` covers a leaked grandchild that
27
+ * holds the pipe open forever: the step completes with what was captured
28
+ * and is marked truncated instead of hanging the run.
29
+ *
30
+ * Every child gets `CI=true` (so an app can branch on it, as under any CI
31
+ * provider) and every ancestor `node_modules/.bin` on PATH (`envWithLocalBin`,
32
+ * the npm-run behaviour), then the step's own `env`. A captured child gets
33
+ * `FORCE_COLOR=1` only when the runner itself colours (the PARENT's stdout is
34
+ * a TTY and NO_COLOR is unset), so a terminal keeps the tools' colours through
35
+ * the pipe, a log file never gets escape codes, and a user's NO_COLOR reaches
36
+ * the tools instead of being overridden.
37
+ * Node has no PTY without a native dependency, which a buildless framework
38
+ * will not take on, so this is the whole colour story.
39
+ *
40
+ * Pure of `process.exit`, `console`, and the real clock: the bin owns the exit
41
+ * code and the writer, `spawn` / `now` / `timers` / `write` are injectable, so
42
+ * the pool cap, the fail-fast cutoff, the replay atomicity, and the
43
+ * exit-then-data ordering are all deterministically unit-testable with a
44
+ * fake child (the same discipline as `run-tasks.js`).
45
+ *
46
+ * @typedef {import('./ci-config.js').CiNode} CiNode
47
+ * @typedef {import('./ci-config.js').CiStep} CiStep
48
+ * @typedef {{
49
+ * title: string, run: string, group: string | null,
50
+ * ok: boolean, code: number | null, signal: string | null, interrupted: boolean,
51
+ * seconds: number, output: string | null, truncated: boolean,
52
+ * }} StepResult
53
+ * @typedef {{ ok: boolean, seconds: number, steps: StepResult[], interrupted: boolean }} CiResult
54
+ */
55
+
56
+ const COLORS = {
57
+ banner: '\x1b[1;32m',
58
+ title: '\x1b[1;35m',
59
+ subtitle: '\x1b[1;90m',
60
+ error: '\x1b[1;31m',
61
+ success: '\x1b[1;32m',
62
+ progress: '\x1b[1;36m',
63
+ };
64
+ const RESET = '\x1b[0m';
65
+
66
+ /** Grace after `exit` for a captured child whose pipe a grandchild still holds. */
67
+ const CLOSE_GRACE_MS = 2000;
68
+ const PROGRESS_INTERVAL_MS = 100;
69
+
70
+ /**
71
+ * @param {string} text
72
+ * @param {keyof typeof COLORS} type
73
+ * @param {boolean} color
74
+ */
75
+ export function colorize(text, type, color) {
76
+ return color ? `${COLORS[type]}${text}${RESET}` : text;
77
+ }
78
+
79
+ /**
80
+ * Rails' `format_elapsed`: `2.11s`, or `1m2.11s` past a minute.
81
+ * @param {number} seconds
82
+ */
83
+ export function formatElapsed(seconds) {
84
+ const s = Math.max(0, seconds);
85
+ const min = Math.floor(s / 60);
86
+ const sec = s - min * 60;
87
+ return `${min > 0 ? `${min}m` : ''}${sec.toFixed(2)}s`;
88
+ }
89
+
90
+ /** The brief form the progress line uses (`12s`, `1m3s`). @param {number} seconds */
91
+ function formatElapsedBrief(seconds) {
92
+ const s = Math.max(0, seconds);
93
+ const min = Math.floor(s / 60);
94
+ const sec = Math.floor(s - min * 60);
95
+ return `${min > 0 ? `${min}m` : ''}${sec}s`;
96
+ }
97
+
98
+ /**
99
+ * A step heading: the title in the title colour, the command as a subtitle,
100
+ * padded by two blank lines like Rails' `heading`.
101
+ * @param {{ title: string, run: string }} step
102
+ * @param {boolean} color
103
+ */
104
+ export function formatHeading(step, color) {
105
+ return `\n\n${colorize(step.title, 'title', color)}\n${colorize(step.run, 'subtitle', color)}\n`;
106
+ }
107
+
108
+ /**
109
+ * The per-step result line, and the total line (same shape, Rails' `result_line`).
110
+ * @param {{ title: string, ok: boolean, seconds: number, interrupted?: boolean }} r
111
+ * @param {boolean} color
112
+ */
113
+ export function formatResult(r, color) {
114
+ if (r.interrupted) return `\n${colorize(`❌ ${r.title} interrupted`, 'error', color)}\n`;
115
+ const elapsed = formatElapsed(r.seconds);
116
+ return r.ok
117
+ ? `\n${colorize(`✅ ${r.title} passed in ${elapsed}`, 'success', color)}\n`
118
+ : `\n${colorize(`❌ ${r.title} failed in ${elapsed}`, 'error', color)}\n`;
119
+ }
120
+
121
+ /**
122
+ * The single-line progress indicator for a parallel group: which steps are
123
+ * running and for how long the group has been at it.
124
+ * @param {string} label the group title
125
+ * @param {number} seconds elapsed since the group started
126
+ * @param {string[]} running titles currently in flight
127
+ * @param {boolean} color
128
+ */
129
+ export function formatProgress(label, seconds, running, color) {
130
+ return colorize(`${label} (${formatElapsedBrief(seconds)}) - ${running.join(' | ')}...`, 'progress', color);
131
+ }
132
+
133
+ /**
134
+ * The end-of-run block: every failed step (when more than one step ran, as
135
+ * Rails does), then the total line.
136
+ * @param {CiResult} result
137
+ * @param {string} title
138
+ * @param {boolean} color
139
+ */
140
+ export function formatSummary(result, title, color) {
141
+ let out = '';
142
+ const failed = result.steps.filter((s) => !s.ok);
143
+ if (failed.length > 0 && result.steps.length > 1) {
144
+ for (const s of failed) {
145
+ out += `${colorize(` ↳ ${s.title} ${s.interrupted ? 'interrupted' : 'failed'}`, 'error', color)}\n`;
146
+ }
147
+ }
148
+ out += formatResult({ title, ok: result.ok, seconds: result.seconds, interrupted: result.interrupted }, color);
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * A GitHub Actions job-summary table (`$GITHUB_STEP_SUMMARY`), so a single
154
+ * cloud job running the whole list still shows one row per layer. Pipes are
155
+ * escaped because a title or command may carry one.
156
+ * @param {CiResult} result
157
+ */
158
+ export function stepSummaryMarkdown(result) {
159
+ const esc = (s) => String(s).replace(/\|/g, '\\|');
160
+ const rows = result.steps.map((s) => {
161
+ const state = s.interrupted ? '⏹ interrupted' : s.ok ? '✅ passed' : `❌ failed (exit ${s.code ?? s.signal})`;
162
+ return `| ${esc(s.title)} | \`${esc(s.run)}\` | ${state} | ${formatElapsed(s.seconds)} |`;
163
+ });
164
+ const head = result.ok ? '✅ Local CI passed' : '❌ Local CI failed';
165
+ return `### ${head} in ${formatElapsed(result.seconds)}\n\n| Step | Command | Result | Time |\n|---|---|---|---|\n${rows.join('\n')}\n`;
166
+ }
167
+
168
+ /**
169
+ * Run a step tree. Returns `{ done, interrupt }`: `done` resolves to the
170
+ * `CiResult` (never rejects; a spawn error is a failed step), `interrupt()` is
171
+ * what the bin wires to SIGINT: it kills every running child (the whole
172
+ * process group of a captured one), stops any further dequeue, and lets the
173
+ * run wind down to a result flagged `interrupted`.
174
+ *
175
+ * @param {CiNode[]} steps
176
+ * @param {string} cwd
177
+ * @param {{
178
+ * spawn?: typeof nodeSpawn,
179
+ * write?: (s: string) => void,
180
+ * isTTY?: boolean,
181
+ * color?: boolean,
182
+ * now?: () => number,
183
+ * timers?: { setInterval: Function, clearInterval: Function, setTimeout: Function, clearTimeout: Function },
184
+ * failFast?: boolean,
185
+ * captureAll?: boolean,
186
+ * env?: NodeJS.ProcessEnv,
187
+ * actions?: boolean,
188
+ * closeGraceMs?: number,
189
+ * }} [opts]
190
+ * @returns {{ done: Promise<CiResult>, interrupt: () => void }}
191
+ */
192
+ export function runCi(steps, cwd, opts = {}) {
193
+ const ctx = {
194
+ spawn: opts.spawn || nodeSpawn,
195
+ write: opts.write || ((s) => { process.stdout.write(s); }),
196
+ isTTY: !!opts.isTTY,
197
+ color: opts.color ?? !!opts.isTTY,
198
+ now: opts.now || (() => performance.now() / 1000),
199
+ timers: opts.timers || { setInterval, clearInterval, setTimeout, clearTimeout },
200
+ failFast: !!opts.failFast,
201
+ captureAll: !!opts.captureAll,
202
+ baseEnv: envWithLocalBin(cwd, opts.env || process.env),
203
+ actions: !!opts.actions,
204
+ closeGraceMs: opts.closeGraceMs ?? CLOSE_GRACE_MS,
205
+ cwd,
206
+ /** @type {StepResult[]} */
207
+ results: [],
208
+ /** @type {Set<import('node:child_process').ChildProcess>} */
209
+ running: new Set(),
210
+ interrupted: false,
211
+ /** Fail-fast cutoff: set on the first failure when `failFast` is on. */
212
+ stopped: false,
213
+ };
214
+
215
+ const interrupt = () => {
216
+ if (ctx.interrupted) return;
217
+ ctx.interrupted = true;
218
+ ctx.stopped = true;
219
+ for (const child of ctx.running) {
220
+ if (child.__webjsDetached) killChildTree(child);
221
+ else { try { child.kill('SIGINT'); } catch {} }
222
+ }
223
+ };
224
+
225
+ const done = (async () => {
226
+ const started = ctx.now();
227
+ await runSequence(steps, null, ctx);
228
+ const seconds = ctx.now() - started;
229
+ const ok = !ctx.interrupted && ctx.results.length > 0 && ctx.results.every((r) => r.ok);
230
+ return { ok, seconds, steps: ctx.results, interrupted: ctx.interrupted };
231
+ })();
232
+
233
+ return { done, interrupt };
234
+ }
235
+
236
+ /** Whether the run should stop dequeuing (interrupt, or a fail-fast cutoff). */
237
+ function halted(ctx) {
238
+ return ctx.stopped;
239
+ }
240
+
241
+ /**
242
+ * Run nodes in order (the sequential path). A parallel group hands off to the
243
+ * pool; a sequential group simply flattens into this walk, as Rails'
244
+ * `instance_eval` does. `capture` is true when this sequence is itself inside
245
+ * a parallel slot, so each of its steps is captured rather than inheriting.
246
+ *
247
+ * @param {CiNode[]} nodes
248
+ * @param {string | null} group
249
+ * @param {object} ctx
250
+ * @param {boolean} [capture]
251
+ */
252
+ async function runSequence(nodes, group, ctx, capture = false) {
253
+ for (const node of nodes) {
254
+ if (halted(ctx)) return;
255
+ if (node.kind === 'group') {
256
+ if (node.parallel > 1 && !capture) await runPool(node, ctx);
257
+ else await runSequence(node.steps, node.title, ctx, capture);
258
+ continue;
259
+ }
260
+ const r = await runOne(node, group, ctx, capture || ctx.captureAll);
261
+ if (capture || ctx.captureAll) report(r, ctx);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * A parallel group: N slots pulling from the group's task list. A task is one
267
+ * step, or one nested group run sequentially in that slot. Each finished step
268
+ * is reported whole (heading + captured output + result line) as it lands.
269
+ * Fail-fast stops the DEQUEUE after the first failure; in-flight steps finish.
270
+ *
271
+ * @param {{ title: string, parallel: number, steps: CiNode[] }} group
272
+ * @param {object} ctx
273
+ */
274
+ async function runPool(group, ctx) {
275
+ const queue = [...group.steps];
276
+ const inFlight = new Set();
277
+ const startedAt = ctx.now();
278
+ const progress = ctx.isTTY ? startProgress(group.title, startedAt, inFlight, ctx) : null;
279
+
280
+ // One slot's work: a step, or a nested group's steps in order (a nested
281
+ // group is always sequential, the reader refuses `parallel` on it). Every
282
+ // step goes through the SAME bookkeeping, so a nested step shows in the
283
+ // progress line and its replay clears the line first; the scaffold's
284
+ // default list nests its three longest steps this way.
285
+ const runInSlot = async (nodes, groupTitle) => {
286
+ for (const node of nodes) {
287
+ if (halted(ctx)) return;
288
+ if (node.kind === 'group') {
289
+ await runInSlot(node.steps, node.title);
290
+ continue;
291
+ }
292
+ inFlight.add(node.title);
293
+ const r = await runOne(node, groupTitle, ctx, true);
294
+ inFlight.delete(node.title);
295
+ progress?.clear();
296
+ report(r, ctx);
297
+ progress?.redraw();
298
+ }
299
+ };
300
+ const worker = async () => {
301
+ while (queue.length > 0 && !halted(ctx)) {
302
+ await runInSlot([queue.shift()], group.title);
303
+ }
304
+ };
305
+ const slots = Math.min(group.parallel, queue.length);
306
+ await Promise.all(Array.from({ length: slots }, () => worker()));
307
+ progress?.stop();
308
+ }
309
+
310
+ /**
311
+ * The 10 Hz progress line: what the group is running and for how long. TTY
312
+ * only (the bin never asks for it otherwise), cleared before every replay so
313
+ * it never lands inside a step's output, `unref`ed so a hung child does not
314
+ * keep the process alive through the timer.
315
+ */
316
+ function startProgress(label, startedAt, inFlight, ctx) {
317
+ let visible = false;
318
+ const draw = () => {
319
+ if (inFlight.size === 0) return;
320
+ ctx.write(`\r\x1b[K${formatProgress(label, ctx.now() - startedAt, [...inFlight], ctx.color)}`);
321
+ visible = true;
322
+ };
323
+ const clear = () => {
324
+ if (!visible) return;
325
+ ctx.write('\r\x1b[K');
326
+ visible = false;
327
+ };
328
+ const handle = ctx.timers.setInterval(draw, PROGRESS_INTERVAL_MS);
329
+ if (handle && typeof handle.unref === 'function') handle.unref();
330
+ return {
331
+ clear,
332
+ redraw: draw,
333
+ stop: () => { ctx.timers.clearInterval(handle); clear(); },
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Write a captured step's heading, its replayed output, and its result line,
339
+ * as one uninterrupted sequence (JS is single-threaded, so nothing else can
340
+ * write between these calls). A GitHub Actions run additionally folds the
341
+ * step into a log group and annotates a failure, which is what keeps "a
342
+ * failure names its layer" true inside a single job.
343
+ * @param {StepResult} r
344
+ * @param {object} ctx
345
+ */
346
+ function report(r, ctx) {
347
+ if (ctx.actions) ctx.write(`::group::${r.title}\n`);
348
+ ctx.write(formatHeading(r, ctx.color));
349
+ if (r.output) ctx.write(r.output.endsWith('\n') ? r.output : `${r.output}\n`);
350
+ if (r.truncated) ctx.write(colorize('(output truncated: a child process kept the pipe open after exit)', 'subtitle', ctx.color) + '\n');
351
+ ctx.write(formatResult(r, ctx.color));
352
+ if (ctx.actions) {
353
+ ctx.write('::endgroup::\n');
354
+ if (!r.ok) ctx.write(`::error title=${r.title}::${r.title} ${r.interrupted ? 'was interrupted' : `failed (exit ${r.code ?? r.signal})`}\n`);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Run one command step and record its result. Inherit mode writes the heading
360
+ * up front (the child owns the terminal next); capture mode returns the
361
+ * output for the caller to report atomically.
362
+ *
363
+ * @param {CiStep} step
364
+ * @param {string | null} group
365
+ * @param {object} ctx
366
+ * @param {boolean} capture
367
+ * @returns {Promise<StepResult>}
368
+ */
369
+ async function runOne(step, group, ctx, capture) {
370
+ const env = {
371
+ ...ctx.baseEnv,
372
+ CI: 'true',
373
+ // Colour for a captured child follows the runner's own colour decision
374
+ // (a TTY with no NO_COLOR), so a user's NO_COLOR is honoured inside the
375
+ // tools too rather than overridden by FORCE_COLOR.
376
+ ...(capture && ctx.color ? { FORCE_COLOR: '1' } : {}),
377
+ ...step.env,
378
+ };
379
+ if (!capture) {
380
+ if (ctx.actions) ctx.write(`::group::${step.title}\n`);
381
+ ctx.write(formatHeading(step, ctx.color));
382
+ }
383
+ const started = ctx.now();
384
+ const exit = capture ? await spawnCaptured(step, env, ctx) : await spawnInherited(step, env, ctx);
385
+ const seconds = ctx.now() - started;
386
+ const interrupted = ctx.interrupted && exit.signal !== null;
387
+ const ok = exit.code === 0 && exit.signal === null;
388
+ /** @type {StepResult} */
389
+ const r = {
390
+ title: step.title,
391
+ run: step.run,
392
+ group,
393
+ ok,
394
+ code: exit.code,
395
+ signal: exit.signal,
396
+ interrupted,
397
+ seconds,
398
+ output: capture ? exit.output : null,
399
+ truncated: !!exit.truncated,
400
+ };
401
+ ctx.results.push(r);
402
+ if (!ok && ctx.failFast) ctx.stopped = true;
403
+ if (!capture) {
404
+ ctx.write(formatResult(r, ctx.color));
405
+ if (ctx.actions) {
406
+ ctx.write('::endgroup::\n');
407
+ if (!ok) ctx.write(`::error title=${r.title}::${r.title} ${interrupted ? 'was interrupted' : `failed (exit ${r.code ?? r.signal})`}\n`);
408
+ }
409
+ }
410
+ return r;
411
+ }
412
+
413
+ /** @returns {Promise<{ code: number | null, signal: string | null }>} */
414
+ function spawnInherited(step, env, ctx) {
415
+ return new Promise((resolve) => {
416
+ let child;
417
+ try {
418
+ child = ctx.spawn(step.run, { shell: true, stdio: 'inherit', cwd: ctx.cwd, env });
419
+ } catch {
420
+ resolve({ code: 1, signal: null });
421
+ return;
422
+ }
423
+ ctx.running.add(child);
424
+ let settled = false;
425
+ const finish = (code, signal) => {
426
+ if (settled) return;
427
+ settled = true;
428
+ ctx.running.delete(child);
429
+ resolve({ code: code ?? (signal ? null : 0), signal: signal || null });
430
+ };
431
+ child.on('exit', (code, signal) => finish(code, signal));
432
+ child.on('error', () => finish(1, null));
433
+ });
434
+ }
435
+
436
+ /**
437
+ * @returns {Promise<{ code: number | null, signal: string | null, output: string, truncated: boolean }>}
438
+ */
439
+ function spawnCaptured(step, env, ctx) {
440
+ return new Promise((resolve) => {
441
+ let child;
442
+ try {
443
+ child = ctx.spawn(step.run, {
444
+ shell: true,
445
+ stdio: ['ignore', 'pipe', 'pipe'],
446
+ cwd: ctx.cwd,
447
+ env,
448
+ detached: true,
449
+ });
450
+ } catch (e) {
451
+ resolve({ code: 1, signal: null, output: `${e && e.message ? e.message : String(e)}\n`, truncated: false });
452
+ return;
453
+ }
454
+ child.__webjsDetached = true;
455
+ ctx.running.add(child);
456
+ /** @type {Buffer[]} */
457
+ const chunks = [];
458
+ const collect = (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); };
459
+ child.stdout?.on('data', collect);
460
+ child.stderr?.on('data', collect);
461
+
462
+ let settled = false;
463
+ let exited = null;
464
+ let grace = null;
465
+ const finish = (truncated) => {
466
+ if (settled) return;
467
+ settled = true;
468
+ if (grace) ctx.timers.clearTimeout(grace);
469
+ ctx.running.delete(child);
470
+ const { code, signal } = exited || { code: 1, signal: null };
471
+ resolve({ code, signal, output: Buffer.concat(chunks).toString('utf8'), truncated });
472
+ };
473
+ child.on('exit', (code, signal) => {
474
+ exited = { code: code ?? (signal ? null : 0), signal: signal || null };
475
+ // `close` normally follows within a tick. A leaked grandchild holding the
476
+ // pipe would keep it from ever firing, so bound the wait, and when the
477
+ // bound fires REAP the group (it is still addressable, the child is
478
+ // detached) and drop the pipe handles. Without both, the open pipes keep
479
+ // the parent's event loop alive and the bin, which exits through
480
+ // exitCode, sits after its summary until the grandchild dies on its own.
481
+ grace = ctx.timers.setTimeout(() => {
482
+ killChildTree(child);
483
+ try { child.stdout?.destroy(); } catch {}
484
+ try { child.stderr?.destroy(); } catch {}
485
+ finish(true);
486
+ }, ctx.closeGraceMs);
487
+ if (grace && typeof grace.unref === 'function') grace.unref();
488
+ });
489
+ child.on('close', (code, signal) => {
490
+ if (!exited) exited = { code: code ?? (signal ? null : 0), signal: signal || null };
491
+ finish(false);
492
+ });
493
+ child.on('error', (e) => {
494
+ chunks.push(Buffer.from(`${e && e.message ? e.message : String(e)}\n`));
495
+ exited = exited || { code: 1, signal: null };
496
+ finish(false);
497
+ });
498
+ });
499
+ }
package/lib/create.js CHANGED
@@ -292,6 +292,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
292
292
  // points (`webjs create` and `npx create-webjs-app`) explicitly set
293
293
  // `install: true` unless the user passes `--no-install`.
294
294
  const shouldInstall = opts.install === true;
295
+ // `--skip-ci` (#1471, `rails new --skip-ci` parity): omit the GitHub
296
+ // workflow. The local `webjs ci` list in package.json is always emitted, so
297
+ // the app still has its gate; only the cloud half is optional.
298
+ const skipCi = opts.skipCi === true;
295
299
  // Defence in depth. The CLI already validates this, but library
296
300
  // callers (tests, programmatic use) might pass anything.
297
301
  const VALID_TEMPLATES = ['full-stack', 'api'];
@@ -421,6 +425,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
421
425
  // the environment-shaped checks (env drift, pin freshness over the
422
426
  // network, the git hook) stay warns and cannot make CI flaky.
423
427
  doctor: 'webjs doctor',
428
+ // Local CI (#1471): every check above plus the test layers, one command,
429
+ // from the step list in the `webjs.ci` block below. Runtime-neutral like
430
+ // the other tooling scripts (it spawns `webjs ...` children).
431
+ ci: 'webjs ci',
424
432
  'db:generate': 'webjs db generate',
425
433
  'db:migrate': 'webjs db migrate',
426
434
  'db:push': 'webjs db push',
@@ -448,7 +456,14 @@ export async function scaffoldApp(name, cwd, opts = {}) {
448
456
  // The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs
449
457
  // tsc --noEmit). Not needed at runtime (Node strips types in place), only
450
458
  // to type-check the app.
451
- typescript: '^5.6.0',
459
+ // Must not resolve below the floor the tsconfig this same generator
460
+ // writes requires: `erasableSyntaxOnly` landed in TypeScript 5.8, and a
461
+ // 5.6 or 5.7 resolution refuses the whole config with
462
+ // `TS5023: Unknown compiler option`. Kept on the major the repo's own
463
+ // apps use, so an app and the framework that generated it type-check
464
+ // under the same compiler. Guarded by
465
+ // test/scaffolds/scaffold-typescript-floor.test.js.
466
+ typescript: '^6.0.3',
452
467
  '@types/node': '^24.0.0',
453
468
  '@web/test-runner': '^0.20.0',
454
469
  '@web/test-runner-playwright': '^0.11.0',
@@ -544,6 +559,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
544
559
  }),
545
560
  },
546
561
  start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
562
+ // No `db` block on purpose (#1468). `webjs db` defaults to drizzle-kit,
563
+ // and Drizzle is the scaffold default by OMISSION: an app that swaps the
564
+ // ORM adds `"db": { "migrate": "prisma migrate deploy", ... }` here and
565
+ // the `webjs db migrate` spelling in `before` (and the Dockerfile, and
566
+ // CI) keeps working. Emitting the Drizzle mapping would only duplicate
567
+ // the default into every app.
547
568
  // Which doctor findings are FATAL is the app's own call (#1257), declared
548
569
  // here rather than in the CI workflow so `npm run doctor` locally and the
549
570
  // workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
@@ -555,6 +576,39 @@ export async function scaffoldApp(name, cwd, opts = {}) {
555
576
  // everything else keeps its default warn. Add a code with "off" to
556
577
  // silence it, or "error" to make it fatal too.
557
578
  doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
579
+ // Local CI (#1471), the Rails `bin/ci` posture. `npm run ci` runs this
580
+ // list on a developer machine and the generated GitHub workflow runs the
581
+ // SAME list through the same command, so the two cannot drift. Bare
582
+ // `webjs ...` commands, like the before-steps above (a Bun app's image
583
+ // has no npm). The `Checks` group runs two steps at a time with each
584
+ // step's output replayed whole; `Tests` inside it stays SEQUENTIAL, one
585
+ // slot, because the server and e2e layers share one SQLite file.
586
+ ci: {
587
+ steps: [
588
+ { title: 'Setup', run: 'webjs db migrate' },
589
+ {
590
+ title: 'Checks',
591
+ parallel: 2,
592
+ steps: [
593
+ { title: 'Conventions', run: 'webjs check' },
594
+ { title: 'Health', run: 'webjs doctor' },
595
+ { title: 'Types', run: 'webjs typecheck' },
596
+ {
597
+ title: 'Security: dependency audit',
598
+ run: isBun ? 'bun audit --audit-level=high' : 'npm audit --audit-level=high',
599
+ },
600
+ {
601
+ title: 'Tests',
602
+ steps: [
603
+ { title: 'Tests: server', run: 'webjs test --server' },
604
+ { title: 'Tests: browser', run: 'webjs test --browser' },
605
+ { title: 'Tests: e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } },
606
+ ],
607
+ },
608
+ ],
609
+ },
610
+ ],
611
+ },
558
612
  },
559
613
  }, null, 2) + '\n');
560
614
 
@@ -650,7 +704,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
650
704
  // Shipped without a dot (npm strips a published .gitignore) and renamed on copy.
651
705
  'gitignore',
652
706
  '.github/pull_request_template.md',
653
- // CI runs webjs check + the test layers on every PR and push to main.
707
+ // The cloud half of CI: one job that runs `npm run ci`, the same step list
708
+ // the app declares under `webjs.ci` (#1471). Omitted by `--skip-ci`.
654
709
  '.github/workflows/ci.yml',
655
710
  '.editorconfig',
656
711
  '.vscode/settings.json',
@@ -679,6 +734,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
679
734
  '.github/workflows/ci.yml': bunifyCi,
680
735
  };
681
736
  for (const f of templateFiles) {
737
+ // `--skip-ci` drops only the workflow; the PR template still ships.
738
+ if (skipCi && f === '.github/workflows/ci.yml') continue;
682
739
  const src = join(TEMPLATES, f);
683
740
  if (existsSync(src)) {
684
741
  // `gitignore` ships without a dot (npm strips a published `.gitignore`)
@@ -42,6 +42,7 @@ export const DOCTOR_CODES = {
42
42
  'vendor-gitignore': 'VENDOR_GITIGNORE',
43
43
  'webjs-versions': 'WEBJS_VERSIONS',
44
44
  'framework-resolve': 'FRAMEWORK_RESOLVE',
45
+ 'framework-links': 'FRAMEWORK_LINKS',
45
46
  'importmap-coherence': 'IMPORTMAP_COHERENCE',
46
47
  'git-hook': 'GIT_HOOK',
47
48
  'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',