@webjsdev/cli 0.10.57 → 0.10.59

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.
@@ -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
+ }