@webjsdev/cli 0.10.57 → 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.
- package/README.md +6 -1
- package/bin/webjs.js +219 -9
- package/lib/app-tasks.js +70 -10
- package/lib/check-target.js +1 -1
- package/lib/ci-config.js +250 -0
- package/lib/ci-runner.js +499 -0
- package/lib/create.js +51 -1
- package/lib/run-tasks.js +23 -3
- package/package.json +3 -3
- package/templates/.agents/rules/workflow.md +15 -9
- package/templates/.agents/skills/webjs/SKILL.md +1 -0
- package/templates/.agents/skills/webjs/references/built-ins.md +42 -0
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +11 -0
- package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
- package/templates/.github/pull_request_template.md +3 -4
- package/templates/.github/workflows/ci.yml +38 -88
- package/templates/.hooks/pre-commit +5 -4
- package/templates/partials/agents-playbook-api.md +14 -8
- package/templates/partials/agents-playbook-fullstack.md +16 -10
package/lib/ci-runner.js
ADDED
|
@@ -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',
|
|
@@ -551,6 +559,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
551
559
|
}),
|
|
552
560
|
},
|
|
553
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.
|
|
554
568
|
// Which doctor findings are FATAL is the app's own call (#1257), declared
|
|
555
569
|
// here rather than in the CI workflow so `npm run doctor` locally and the
|
|
556
570
|
// workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
|
|
@@ -562,6 +576,39 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
562
576
|
// everything else keeps its default warn. Add a code with "off" to
|
|
563
577
|
// silence it, or "error" to make it fatal too.
|
|
564
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
|
+
},
|
|
565
612
|
},
|
|
566
613
|
}, null, 2) + '\n');
|
|
567
614
|
|
|
@@ -657,7 +704,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
657
704
|
// Shipped without a dot (npm strips a published .gitignore) and renamed on copy.
|
|
658
705
|
'gitignore',
|
|
659
706
|
'.github/pull_request_template.md',
|
|
660
|
-
//
|
|
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`.
|
|
661
709
|
'.github/workflows/ci.yml',
|
|
662
710
|
'.editorconfig',
|
|
663
711
|
'.vscode/settings.json',
|
|
@@ -686,6 +734,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
686
734
|
'.github/workflows/ci.yml': bunifyCi,
|
|
687
735
|
};
|
|
688
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;
|
|
689
739
|
const src = join(TEMPLATES, f);
|
|
690
740
|
if (existsSync(src)) {
|
|
691
741
|
// `gitignore` ships without a dot (npm strips a published `.gitignore`)
|
package/lib/run-tasks.js
CHANGED
|
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
|
|
|
12
12
|
* @param {string} cwd
|
|
13
13
|
* @param {NodeJS.ProcessEnv} [env]
|
|
14
14
|
*/
|
|
15
|
-
function envWithLocalBin(cwd, env = process.env) {
|
|
15
|
+
export function envWithLocalBin(cwd, env = process.env) {
|
|
16
16
|
const bins = [];
|
|
17
17
|
let dir = cwd;
|
|
18
18
|
// Walk up to the filesystem root, collecting each node_modules/.bin.
|
|
@@ -44,7 +44,10 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
|
|
|
44
44
|
if (opts.onStep) opts.onStep(step);
|
|
45
45
|
const code = await new Promise((res) => {
|
|
46
46
|
const c = spawn(step, { shell: true, stdio: 'inherit', cwd, env });
|
|
47
|
-
|
|
47
|
+
// A child killed by a signal exits with `code` null and `signal` set.
|
|
48
|
+
// That is a failure (an OOM-killed `db migrate` must not boot the
|
|
49
|
+
// server over a half-applied schema), so it maps to 1, never 0.
|
|
50
|
+
c.on('exit', (code, signal) => res(code ?? (signal ? 1 : 0)));
|
|
48
51
|
c.on('error', () => res(1));
|
|
49
52
|
});
|
|
50
53
|
if (code !== 0) return { ok: false, step, code };
|
|
@@ -52,6 +55,23 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
|
|
|
52
55
|
return { ok: true };
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Quote one argv entry for a POSIX shell so it survives `shell: true` as ONE
|
|
60
|
+
* word with no expansion. A plain word passes through untouched; anything
|
|
61
|
+
* else is single-quoted, with an embedded single quote spliced as `'\''`.
|
|
62
|
+
* Used by `webjs db <verb> [args]` (#1468) to append the CLI's args to a
|
|
63
|
+
* mapped command string, so `--name "add users"` reaches the ORM as one arg
|
|
64
|
+
* and a `$` / `;` / glob is never expanded, matching what the drizzle-kit
|
|
65
|
+
* default (a real argv, no shell) already guarantees.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} arg
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function shellQuote(arg) {
|
|
71
|
+
if (/^[A-Za-z0-9_\-.\/=:@,+%]+$/.test(arg)) return arg;
|
|
72
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
/**
|
|
56
76
|
* Spawn the configured dev `parallel` tasks (#550) as long-lived children and
|
|
57
77
|
* return a killer that tears them ALL down (idempotent), so a watcher cannot
|
|
@@ -90,7 +110,7 @@ export function startParallelTasks(commands, cwd, opts = {}) {
|
|
|
90
110
|
*
|
|
91
111
|
* @param {import('node:child_process').ChildProcess} child
|
|
92
112
|
*/
|
|
93
|
-
function killChildTree(child) {
|
|
113
|
+
export function killChildTree(child) {
|
|
94
114
|
try {
|
|
95
115
|
if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGTERM');
|
|
96
116
|
else child.kill();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.58",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@webjsdev/mcp": "^0.1.0",
|
|
21
|
-
"@webjsdev/server": "^0.8.
|
|
22
|
-
"@webjsdev/ui": "^0.3.
|
|
21
|
+
"@webjsdev/server": "^0.8.67",
|
|
22
|
+
"@webjsdev/ui": "^0.3.15"
|
|
23
23
|
},
|
|
24
24
|
"publishConfig": {
|
|
25
25
|
"access": "public"
|