@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.
- 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 +59 -2
- package/lib/doctor/codes.js +1 -0
- package/lib/doctor/probes/framework-resolves.js +182 -6
- package/lib/doctor/runner.js +2 -1
- package/lib/doctor.js +1 -1
- package/lib/run-tasks.js +23 -3
- package/package.json +3 -3
- package/templates/.agents/rules/workflow.md +19 -12
- package/templates/.agents/skills/webjs/SKILL.md +6 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +45 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +32 -3
- package/templates/.agents/skills/webjs/references/components.md +9 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +26 -2
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
- package/templates/.agents/skills/webjs/references/runtime.md +1 -1
- package/templates/.agents/skills/webjs/references/styling.md +48 -3
- 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 -8
- package/templates/.github/workflows/ci.yml +38 -88
- package/templates/.hooks/pre-commit +5 -4
- package/templates/gallery/app/features/client-router/page.ts +5 -1
- package/templates/gallery/app/features/metadata/page.ts +7 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +7 -0
- package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
- package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
- package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
- package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
- 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',
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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`)
|
package/lib/doctor/codes.js
CHANGED
|
@@ -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',
|