@lenne.tech/cli 1.46.0 → 1.47.0
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/bin/postinstall.js +28 -5
- package/build/commands/dev/doctor.js +45 -3
- package/build/commands/fullstack/update.js +31 -0
- package/build/lib/check-template-sync.js +127 -0
- package/build/lib/claude-cli.js +14 -25
- package/build/lib/codex-cli.js +24 -31
- package/build/lib/heal-check-wrapper.js +128 -11
- package/build/lib/heal-oxlintrc.js +215 -0
- package/build/lib/platform.js +107 -0
- package/build/lib/plugin-utils.js +9 -8
- package/build/templates/check/check.mjs +270 -268
- package/build/templates/check/lib/ansi.mjs +22 -0
- package/build/templates/check/lib/audit-report.mjs +412 -0
- package/build/templates/check/lib/workspace-packages.mjs +150 -0
- package/build/templates/check/lt-monorepo-pin.json +13 -0
- package/docs/commands.md +1 -11
- package/package.json +13 -14
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// @lt-check-wrapper 3.13.0
|
|
2
3
|
/**
|
|
3
4
|
* Quiet, report-driven wrapper around the project `check` pipeline.
|
|
4
5
|
*
|
|
@@ -24,35 +25,42 @@
|
|
|
24
25
|
*
|
|
25
26
|
* Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
|
|
26
27
|
* lt-dev `running-check-script` skill relies on: non-zero === failed).
|
|
28
|
+
*
|
|
29
|
+
* Version marker: the `@lt-check-wrapper` line above names the lt-monorepo
|
|
30
|
+
* release this wrapper ships with. The lt CLI reads it so `lt fullstack update`
|
|
31
|
+
* never replaces a project's wrapper with an older one. `pnpm run release`
|
|
32
|
+
* bumps it (bumpFiles in .versionrc.json), and
|
|
33
|
+
* scripts/check-wrapper-version.test.mjs fails when it drifts from
|
|
34
|
+
* package.json. In a generated project it keeps the release it came from.
|
|
27
35
|
*/
|
|
28
|
-
import { execSync, spawn } from
|
|
29
|
-
import {
|
|
30
|
-
import { dirname, join } from
|
|
31
|
-
import { fileURLToPath } from
|
|
36
|
+
import { execSync, spawn } from 'node:child_process';
|
|
37
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
38
|
+
import { dirname, join } from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
32
40
|
|
|
33
|
-
import {
|
|
41
|
+
import { C, stripAnsi } from './lib/ansi.mjs';
|
|
42
|
+
import {
|
|
43
|
+
advisoryBulkUrl,
|
|
44
|
+
configuredRegistry,
|
|
45
|
+
countSuppressions,
|
|
46
|
+
countUnlistedBySeverity,
|
|
47
|
+
isAuditEndpointUnavailable,
|
|
48
|
+
isAuditResultAmbiguous,
|
|
49
|
+
renderVulnLine,
|
|
50
|
+
sumSeverities,
|
|
51
|
+
} from './lib/audit-report.mjs';
|
|
52
|
+
import { createBuildTestGate } from './build-test-gate.mjs';
|
|
53
|
+
import { expandGlob as expandWorkspaceGlob, workspaceGlobs as readWorkspaceGlobs } from './lib/workspace-packages.mjs';
|
|
34
54
|
|
|
35
|
-
const ROOT = join(dirname(fileURLToPath(import.meta.url)),
|
|
36
|
-
const VERBOSE = process.argv.includes(
|
|
37
|
-
const SEQUENTIAL = process.argv.includes(
|
|
38
|
-
const NO_FIX = process.argv.includes(
|
|
39
|
-
const PROJECT_FILTERS = process.argv
|
|
40
|
-
.filter((a) => a.startsWith("--project="))
|
|
41
|
-
.map((a) => a.slice("--project=".length));
|
|
55
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
56
|
+
const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
57
|
+
const SEQUENTIAL = process.argv.includes('--sequential') || process.argv.includes('--seq');
|
|
58
|
+
const NO_FIX = process.argv.includes('--no-fix');
|
|
59
|
+
const PROJECT_FILTERS = process.argv.filter((a) => a.startsWith('--project=')).map((a) => a.slice('--project='.length));
|
|
42
60
|
// Verbose streams raw output, so the in-place live view is disabled there.
|
|
43
61
|
const TTY = Boolean(process.stdout.isTTY) && !VERBOSE;
|
|
44
62
|
|
|
45
|
-
|
|
46
|
-
const C = {
|
|
47
|
-
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
48
|
-
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
49
|
-
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
50
|
-
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
51
|
-
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
52
|
-
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
53
|
-
};
|
|
54
|
-
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
55
|
-
const shortRel = (rel) => rel.replace(/^projects\//, "");
|
|
63
|
+
const shortRel = (rel) => rel.replace(/^projects\//, '');
|
|
56
64
|
|
|
57
65
|
function fmtDuration(ms) {
|
|
58
66
|
const s = ms / 1000;
|
|
@@ -76,7 +84,7 @@ function fmtDuration(ms) {
|
|
|
76
84
|
// binds only to the first simple command, so a step written with `;` or a
|
|
77
85
|
// leading `cd` would be reported as pinned and run unpinned. Both carry the
|
|
78
86
|
// same value, so they cannot disagree.
|
|
79
|
-
const CHECK_BUILD_DIR =
|
|
87
|
+
const CHECK_BUILD_DIR = '.nuxt-check';
|
|
80
88
|
|
|
81
89
|
// Deliberately narrow: a blanket prefix would override the dirs the package.json
|
|
82
90
|
// scripts pin themselves. These are the commands that run lifecycle hooks (or,
|
|
@@ -106,9 +114,7 @@ export const PM_AUDIT = new RegExp(String.raw`\b${PM}\s+audit\b`);
|
|
|
106
114
|
/** A project script whose name mentions audit or install — an ordinary step that still needs the pin. */
|
|
107
115
|
const PM_RUN_SCRIPT = new RegExp(String.raw`\b${PM}\s+run\s+\S*(?:audit|install)\S*`);
|
|
108
116
|
/** Any package-manager call that runs lifecycle hooks and carries no pin of its own. */
|
|
109
|
-
export const PM_INVOCATION = new RegExp(
|
|
110
|
-
`${PM_INSTALL.source}|${PM_AUDIT.source}|${PM_RUN_SCRIPT.source}`,
|
|
111
|
-
);
|
|
117
|
+
export const PM_INVOCATION = new RegExp(`${PM_INSTALL.source}|${PM_AUDIT.source}|${PM_RUN_SCRIPT.source}`);
|
|
112
118
|
|
|
113
119
|
/**
|
|
114
120
|
* Prefix a package-manager command with the check's isolated Nuxt build dir.
|
|
@@ -152,32 +158,28 @@ function classify(cmd) {
|
|
|
152
158
|
//
|
|
153
159
|
// Both predicates come from the pin patterns above, so a command can never be
|
|
154
160
|
// hoisted-but-unpinned. See the SINGLE SOURCE note there.
|
|
155
|
-
if (PM_INSTALL.test(c)) return { fatal: true, kind:
|
|
156
|
-
if (PM_AUDIT.test(c)) return { fatal: true, kind:
|
|
157
|
-
if (c.includes(
|
|
158
|
-
|
|
159
|
-
if (c.includes(
|
|
160
|
-
return { fatal: true, kind: "format", label: "format" };
|
|
161
|
-
if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
|
|
161
|
+
if (PM_INSTALL.test(c)) return { fatal: true, kind: 'install', label: 'install' };
|
|
162
|
+
if (PM_AUDIT.test(c)) return { fatal: true, kind: 'audit', label: 'audit' };
|
|
163
|
+
if (c.includes('vendor-freshness')) return { fatal: false, kind: 'vendor', label: 'vendor-freshness' };
|
|
164
|
+
if (c.includes('format:check') || c.includes('oxfmt')) return { fatal: true, kind: 'format', label: 'format' };
|
|
165
|
+
if (c.includes('lint')) return { fatal: true, kind: 'lint', label: 'lint' };
|
|
162
166
|
// A unit-only run is named explicitly, is short, and is not contention
|
|
163
167
|
// sensitive — it carries `light` so the build⊥test gate lets it through (see
|
|
164
168
|
// GATE_CLASS). A bare `pnpm test` is NOT assumed to be light: in the starters
|
|
165
169
|
// it resolves to the API e2e suite, which is exactly what the gate protects.
|
|
166
|
-
if (/(^|&|\s)(pnpm\s+)?test:unit(:|\s|$)/.test(c))
|
|
167
|
-
|
|
168
|
-
if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:ci/.test(c))
|
|
169
|
-
return { fatal: true, kind: "test", label: "test" };
|
|
170
|
+
if (/(^|&|\s)(pnpm\s+)?test:unit(:|\s|$)/.test(c)) return { fatal: true, kind: 'test', label: 'test', light: true };
|
|
171
|
+
if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:ci/.test(c)) return { fatal: true, kind: 'test', label: 'test' };
|
|
170
172
|
// `typecheck` runs vue-tsc / tsc, which saturates the machine just like a
|
|
171
173
|
// build — and it does NOT contain the substrings "build" or "tsc", so it used
|
|
172
174
|
// to fall through to `other` and run ungated, fully concurrent with the API
|
|
173
175
|
// e2e suite. The gate then paid its serialisation cost while the second
|
|
174
176
|
// heaviest CPU load in the chain still ran alongside the suite it protects.
|
|
175
|
-
if (/\btypecheck\b/.test(c)) return { fatal: true, kind:
|
|
176
|
-
if (c.includes(
|
|
177
|
-
return { fatal: true, kind:
|
|
178
|
-
if (c.includes(
|
|
179
|
-
return { fatal: true, kind:
|
|
180
|
-
return { fatal: true, kind:
|
|
177
|
+
if (/\btypecheck\b/.test(c)) return { fatal: true, kind: 'build', label: 'typecheck' };
|
|
178
|
+
if (c.includes('build') || c.includes('nuxt build') || c.includes('tsc'))
|
|
179
|
+
return { fatal: true, kind: 'build', label: 'build' };
|
|
180
|
+
if (c.includes('check-server-start') || c.includes('server-start'))
|
|
181
|
+
return { fatal: true, kind: 'server', label: 'server-start' };
|
|
182
|
+
return { fatal: true, kind: 'other', label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
|
|
181
183
|
}
|
|
182
184
|
|
|
183
185
|
/**
|
|
@@ -190,8 +192,8 @@ function classify(cmd) {
|
|
|
190
192
|
* this gate exists to fix (DEV-2524).
|
|
191
193
|
*/
|
|
192
194
|
export function gateClass(step) {
|
|
193
|
-
if (step.kind ===
|
|
194
|
-
if (step.kind ===
|
|
195
|
+
if (step.kind === 'build') return 'build';
|
|
196
|
+
if (step.kind === 'test') return step.light ? null : 'test';
|
|
195
197
|
return null;
|
|
196
198
|
}
|
|
197
199
|
|
|
@@ -199,15 +201,15 @@ export function gateClass(step) {
|
|
|
199
201
|
// `check` run repairs every fixable finding instead of only reporting it.
|
|
200
202
|
function toFixCommand(kind, cmd) {
|
|
201
203
|
if (NO_FIX) return cmd;
|
|
202
|
-
if (kind ===
|
|
203
|
-
if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/,
|
|
204
|
-
if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/,
|
|
204
|
+
if (kind === 'format') {
|
|
205
|
+
if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, 'format');
|
|
206
|
+
if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, '');
|
|
205
207
|
return cmd;
|
|
206
208
|
}
|
|
207
|
-
if (kind ===
|
|
209
|
+
if (kind === 'lint') {
|
|
208
210
|
if (/\blint:fix\b/.test(cmd) || /--fix\b/.test(cmd)) return cmd;
|
|
209
|
-
if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/,
|
|
210
|
-
if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/,
|
|
211
|
+
if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/, 'run lint:fix');
|
|
212
|
+
if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/, 'oxlint --fix');
|
|
211
213
|
return cmd;
|
|
212
214
|
}
|
|
213
215
|
return cmd;
|
|
@@ -264,55 +266,117 @@ function parseLint(out) {
|
|
|
264
266
|
}
|
|
265
267
|
|
|
266
268
|
// ── audit (faithful: runs the project's OWN audit command) ──────────────────
|
|
267
|
-
const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
|
|
268
|
-
|
|
269
269
|
// Run the audit command exactly as the check chain defines it (same scope /
|
|
270
270
|
// --prod / --audit-level), only appending --json for the counts. The gate is
|
|
271
271
|
// the command's own exit code, so `check` blocks precisely when a bare
|
|
272
272
|
// `<auditCmd>` would — never with a narrower scope than the chain. (The old
|
|
273
273
|
// hardcoded `--prod` hid devDependency vulns for library packages.)
|
|
274
274
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
275
|
+
* Was the advisory service actually reachable?
|
|
276
|
+
*
|
|
277
|
+
* Asked ONLY when the audit reported nothing (see `isAuditResultAmbiguous`), because that report
|
|
278
|
+
* is identical whether the tree is clean or the service was down — pnpm fails open and says
|
|
279
|
+
* "0 vulnerabilities" either way, with exit 0 and no error. A clean run then costs one HEAD-ish
|
|
280
|
+
* request; a run with findings costs nothing, because findings already prove it answered.
|
|
278
281
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* assessed advisory from a new one — without it the summary shows a permanent
|
|
282
|
-
* red "high 1" next to a green gate.
|
|
282
|
+
* Unreachable is NOT treated as an error here. Offline, behind a proxy, or during an npm outage,
|
|
283
|
+
* "we could not check" is the honest answer — the caller degrades, which warns without blocking.
|
|
283
284
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
285
|
+
* Measured cost, so nobody has to guess at it. A clean repository IS the ambiguous shape, so every
|
|
286
|
+
* healthy run pays one request: 0.67s against a reachable registry. Offline it is 0.07s, not the
|
|
287
|
+
* 8s ceiling — DNS and connection refusals fail immediately, and the timeout only bites on a
|
|
288
|
+
* connection that hangs. The visible cost is therefore the warning, not the wait: from here on an
|
|
289
|
+
* offline check reports "vulnerabilities NOT checked" where it used to show a green audit. That is
|
|
290
|
+
* the point of the change and its main annoyance in the same sentence.
|
|
291
|
+
*/
|
|
292
|
+
async function advisoryServiceReachable() {
|
|
293
|
+
// The registry pnpm uses, not npmjs.org — see advisoryBulkUrl for why that distinction is
|
|
294
|
+
// the difference between a safeguard and a second false all-clear.
|
|
295
|
+
const url = advisoryBulkUrl(configuredRegistry());
|
|
296
|
+
const controller = new AbortController();
|
|
297
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetch(url, {
|
|
300
|
+
body: '{}',
|
|
301
|
+
headers: { 'content-type': 'application/json' },
|
|
302
|
+
method: 'POST',
|
|
303
|
+
signal: controller.signal,
|
|
304
|
+
});
|
|
305
|
+
// Any HTTP answer proves the service is up. A 4xx to an empty body is still an answer.
|
|
306
|
+
return res.status > 0;
|
|
307
|
+
} catch {
|
|
308
|
+
return false;
|
|
309
|
+
} finally {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Why the audit established nothing — named, because the two causes need different actions.
|
|
289
316
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
317
|
+
* "unreachable" means wait: a foreign service is down and nothing here fixes it. "unreadable" means
|
|
318
|
+
* look here: the command returned success while saying nothing a report could be built from, which
|
|
319
|
+
* is usually local (a version mismatch, a wrapper eating the output). Collapsing both into one
|
|
320
|
+
* message buries the actionable case under the one nobody can act on.
|
|
293
321
|
*/
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const counted = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
|
|
299
|
-
return Math.max(0, counted - listed);
|
|
322
|
+
function auditDegradedText(audit) {
|
|
323
|
+
return audit.degradedCause === 'unreadable'
|
|
324
|
+
? 'exited 0 but emitted no readable result — vulnerabilities NOT checked (look at the audit command itself)'
|
|
325
|
+
: 'could not run — the advisory service was unreachable; vulnerabilities NOT checked';
|
|
300
326
|
}
|
|
301
327
|
|
|
302
328
|
async function runAudit(auditCmd) {
|
|
303
329
|
const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
|
|
304
330
|
const { code, out } = await capture(cmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
|
|
305
331
|
let counts = null;
|
|
306
|
-
let unlisted =
|
|
332
|
+
let unlisted = {};
|
|
333
|
+
let ambiguous = false;
|
|
307
334
|
try {
|
|
308
|
-
const parsed = JSON.parse(out.slice(out.indexOf(
|
|
335
|
+
const parsed = JSON.parse(out.slice(out.indexOf('{')));
|
|
309
336
|
counts = parsed?.metadata?.vulnerabilities ?? null;
|
|
310
|
-
unlisted =
|
|
337
|
+
unlisted = countUnlistedBySeverity(parsed);
|
|
338
|
+
ambiguous = isAuditResultAmbiguous(parsed);
|
|
311
339
|
} catch {
|
|
312
340
|
/* fall through to raw reason */
|
|
313
341
|
}
|
|
314
|
-
|
|
315
|
-
|
|
342
|
+
// Two different ways an audit fails to establish anything, and they look nothing alike.
|
|
343
|
+
//
|
|
344
|
+
// 1. It errored out. Non-zero exit, no parseable counts, and a known infrastructure signature.
|
|
345
|
+
// All three required — a non-zero exit on its own stays fatal.
|
|
346
|
+
// 2. It reported a clean tree WITHOUT having reached the advisory service. Exit 0, zero counts,
|
|
347
|
+
// empty `advisories`, no error — byte-identical to a genuinely clean run, so the report cannot
|
|
348
|
+
// be asked about itself. The service is probed instead, and only when the result is ambiguous:
|
|
349
|
+
// a run with findings has proven the service answered.
|
|
350
|
+
// 3. It exited 0 and emitted nothing readable. Not a finding — findings parse. Not a failure —
|
|
351
|
+
// the exit code says so. It established NOTHING, and it used to render as `✓ audit 0`: the
|
|
352
|
+
// green tick plus a literal zero, which is the false all-clear in its purest form. Observed:
|
|
353
|
+
// pnpm answering a version mismatch on stderr with exit 0 and no JSON at all.
|
|
354
|
+
//
|
|
355
|
+
// Degraded WITHOUT probing, unlike case 2. A reachable service says nothing about a tally
|
|
356
|
+
// that was never parsed — the probe would answer a question this failure did not ask.
|
|
357
|
+
//
|
|
358
|
+
// The `code !== 0` branch stays FIRST and stays narrow on purpose. Folding these into one
|
|
359
|
+
// `!counts` test is the obvious simplification and it is wrong: it would turn every genuine
|
|
360
|
+
// audit failure into a warning, which is a worse bug than the one being fixed.
|
|
361
|
+
let degradedCause;
|
|
362
|
+
if (code !== 0 && !counts && isAuditEndpointUnavailable(out)) degradedCause = 'unreachable';
|
|
363
|
+
else if (code === 0 && !counts) degradedCause = 'unreadable';
|
|
364
|
+
else if (code === 0 && ambiguous && !(await advisoryServiceReachable())) degradedCause = 'unreachable';
|
|
365
|
+
const degraded = Boolean(degradedCause);
|
|
366
|
+
return {
|
|
367
|
+
auditCmd,
|
|
368
|
+
blocking: code !== 0 && !degraded,
|
|
369
|
+
counts,
|
|
370
|
+
degraded,
|
|
371
|
+
degradedCause,
|
|
372
|
+
reason: counts ? null : out,
|
|
373
|
+
// Read from the workspace, not from the report: a suppressed advisory leaves NOTHING in the
|
|
374
|
+
// JSON that says it was suppressed, so `ignoreGhsas` is the only evidence that a human ever
|
|
375
|
+
// assessed an unlisted finding. Without it, dimming would claim a judgement nobody made.
|
|
376
|
+
suppressions: countSuppressions(ROOT),
|
|
377
|
+
total: sumSeverities(counts),
|
|
378
|
+
unlisted,
|
|
379
|
+
};
|
|
316
380
|
}
|
|
317
381
|
|
|
318
382
|
// Watchdog: kill a TEST step whose child produces NO output for this long. A
|
|
@@ -325,10 +389,10 @@ async function runAudit(auditCmd) {
|
|
|
325
389
|
// Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
|
|
326
390
|
// disables it.
|
|
327
391
|
const IDLE_TIMEOUT_MS = (() => {
|
|
328
|
-
const flag = process.argv.find((a) => a.startsWith(
|
|
329
|
-
const raw = flag ? flag.slice(
|
|
392
|
+
const flag = process.argv.find((a) => a.startsWith('--idle-timeout='));
|
|
393
|
+
const raw = flag ? flag.slice('--idle-timeout='.length) : process.env.CHECK_IDLE_TIMEOUT;
|
|
330
394
|
const DEFAULT_MS = 300 * 1000;
|
|
331
|
-
if (raw === undefined || raw ===
|
|
395
|
+
if (raw === undefined || raw === '') return DEFAULT_MS;
|
|
332
396
|
const seconds = Number(raw);
|
|
333
397
|
if (seconds === 0) return 0; // explicit opt-out
|
|
334
398
|
// Invalid value (typo, unit suffix, negative) → keep the protection at its
|
|
@@ -347,19 +411,19 @@ const RUNNING = new Set();
|
|
|
347
411
|
// fork workers). Killing only the direct child orphans the tree — exactly the
|
|
348
412
|
// zombie workers a deadlock leaves behind. Children are collected via pgrep
|
|
349
413
|
// and killed leaves-first.
|
|
350
|
-
function killTree(child, signal =
|
|
414
|
+
function killTree(child, signal = 'SIGTERM') {
|
|
351
415
|
const pids = [];
|
|
352
416
|
const collect = (pid) => {
|
|
353
417
|
pids.push(pid);
|
|
354
|
-
let out =
|
|
418
|
+
let out = '';
|
|
355
419
|
try {
|
|
356
|
-
out = execSync(`pgrep -P ${pid}`, { stdio: [
|
|
420
|
+
out = execSync(`pgrep -P ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
357
421
|
.toString()
|
|
358
422
|
.trim();
|
|
359
423
|
} catch {
|
|
360
424
|
/* no children */
|
|
361
425
|
}
|
|
362
|
-
if (out) for (const p of out.split(
|
|
426
|
+
if (out) for (const p of out.split('\n')) collect(Number(p));
|
|
363
427
|
};
|
|
364
428
|
collect(child.pid);
|
|
365
429
|
for (const pid of pids.reverse()) {
|
|
@@ -381,9 +445,13 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
|
|
|
381
445
|
// first simple command, so a step written with `;` or a leading `cd` would be
|
|
382
446
|
// reported as pinned and run unpinned. The env reaches every command in the
|
|
383
447
|
// string, and the prefix still wins where both apply (same value).
|
|
384
|
-
const child = spawn(cmd, {
|
|
448
|
+
const child = spawn(cmd, {
|
|
449
|
+
cwd,
|
|
450
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
|
451
|
+
shell: true,
|
|
452
|
+
});
|
|
385
453
|
RUNNING.add(child);
|
|
386
|
-
let out =
|
|
454
|
+
let out = '';
|
|
387
455
|
let idleTimer = null;
|
|
388
456
|
let killTimer = null;
|
|
389
457
|
let watchdogHit = false;
|
|
@@ -396,7 +464,7 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
|
|
|
396
464
|
idleTimer = setTimeout(() => {
|
|
397
465
|
watchdogHit = true;
|
|
398
466
|
killTree(child);
|
|
399
|
-
killTimer = setTimeout(() => killTree(child,
|
|
467
|
+
killTimer = setTimeout(() => killTree(child, 'SIGKILL'), 5000);
|
|
400
468
|
killTimer.unref();
|
|
401
469
|
}, idleTimeoutMs);
|
|
402
470
|
};
|
|
@@ -406,8 +474,8 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
|
|
|
406
474
|
if (VERBOSE) process.stdout.write(d);
|
|
407
475
|
};
|
|
408
476
|
armWatchdog();
|
|
409
|
-
child.stdout.on(
|
|
410
|
-
child.stderr.on(
|
|
477
|
+
child.stdout.on('data', onData);
|
|
478
|
+
child.stderr.on('data', onData);
|
|
411
479
|
const done = (code, extra) => {
|
|
412
480
|
clearTimeout(idleTimer);
|
|
413
481
|
clearTimeout(killTimer);
|
|
@@ -415,14 +483,14 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
|
|
|
415
483
|
if (watchdogHit) {
|
|
416
484
|
const note =
|
|
417
485
|
`[watchdog] step produced no output for ${Math.round(idleTimeoutMs / 1000)}s — ` +
|
|
418
|
-
|
|
486
|
+
'process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), ' +
|
|
419
487
|
`not a slow run. Re-run the step directly to debug: \`${cmd}\``;
|
|
420
488
|
return resolve({ code: 1, out: `${out}\n${note}` });
|
|
421
489
|
}
|
|
422
490
|
resolve({ code, out: extra ? `${out}\n${extra}` : out });
|
|
423
491
|
};
|
|
424
|
-
child.on(
|
|
425
|
-
child.on(
|
|
492
|
+
child.on('close', (code) => done(code ?? 1));
|
|
493
|
+
child.on('error', (err) => done(1, err.message));
|
|
426
494
|
});
|
|
427
495
|
}
|
|
428
496
|
function killAll() {
|
|
@@ -450,16 +518,16 @@ function signalExitHint(out) {
|
|
|
450
518
|
if (/\[watchdog\]/.test(clean)) return null;
|
|
451
519
|
const m = clean.match(/Command failed with exit code (137|143)\b/);
|
|
452
520
|
if (!m) return null;
|
|
453
|
-
const sig = m[1] ===
|
|
521
|
+
const sig = m[1] === '143' ? 'SIGTERM' : 'SIGKILL';
|
|
454
522
|
return (
|
|
455
523
|
`[check] step ended via ${sig} (exit ${m[1]}) — the process was killed, not an assertion failure. ` +
|
|
456
|
-
|
|
524
|
+
'Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. ' +
|
|
457
525
|
"Re-run this project's check alone to confirm."
|
|
458
526
|
);
|
|
459
527
|
}
|
|
460
528
|
|
|
461
529
|
// ── live multi-line status (one line per running project) ────────────────────
|
|
462
|
-
const FRAMES = [
|
|
530
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
463
531
|
let liveCount = 0;
|
|
464
532
|
let frame = 0;
|
|
465
533
|
function drawLive(lines) {
|
|
@@ -472,17 +540,16 @@ function statusLines(order, states) {
|
|
|
472
540
|
frame += 1;
|
|
473
541
|
return order.map((rel) => {
|
|
474
542
|
const s = states.get(rel);
|
|
475
|
-
if (s.failed) return `${C.red(
|
|
476
|
-
if (s.done)
|
|
477
|
-
return `${C.green("✓")} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
|
|
543
|
+
if (s.failed) return `${C.red('✗')} ${shortRel(rel).padEnd(5)} ${C.red(`${s.failed} FAILED`)}`;
|
|
544
|
+
if (s.done) return `${C.green('✓')} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
|
|
478
545
|
const spin = C.cyan(FRAMES[frame % FRAMES.length]);
|
|
479
|
-
const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) :
|
|
480
|
-
return `${spin} ${shortRel(rel).padEnd(5)} ${s.current ||
|
|
546
|
+
const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) : '';
|
|
547
|
+
return `${spin} ${shortRel(rel).padEnd(5)} ${s.current || 'queued'}${el}`;
|
|
481
548
|
});
|
|
482
549
|
}
|
|
483
550
|
|
|
484
551
|
// ── project discovery + step grouping ────────────────────────────────────────
|
|
485
|
-
const IS_ORCHESTRATOR = (script) => !script || script.includes(
|
|
552
|
+
const IS_ORCHESTRATOR = (script) => !script || script.includes('check.mjs');
|
|
486
553
|
|
|
487
554
|
/**
|
|
488
555
|
* True when a command re-enters `check` across workspace members.
|
|
@@ -517,63 +584,25 @@ export function isRecursiveCheck(cmd) {
|
|
|
517
584
|
/** True when this command is hoisted to a single workspace-level run. */
|
|
518
585
|
function isHoisted(cmd) {
|
|
519
586
|
const kind = classify(cmd).kind;
|
|
520
|
-
return kind ===
|
|
587
|
+
return kind === 'install' || kind === 'audit';
|
|
521
588
|
}
|
|
522
589
|
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
return [];
|
|
531
|
-
}
|
|
532
|
-
const globs = [];
|
|
533
|
-
let inPackages = false;
|
|
534
|
-
for (const raw of text.split("\n")) {
|
|
535
|
-
const line = raw.replace(/#.*$/, "");
|
|
536
|
-
if (/^packages:\s*$/.test(line)) {
|
|
537
|
-
inPackages = true;
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
if (inPackages) {
|
|
541
|
-
const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/);
|
|
542
|
-
if (m) globs.push(m[1]);
|
|
543
|
-
else if (line.trim() && !/^\s/.test(line)) break; // next top-level key
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
return globs;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Expand a workspace glob to concrete directories (handles `dir/*` and literals).
|
|
550
|
-
function expandGlob(glob) {
|
|
551
|
-
if (glob.endsWith("/*")) {
|
|
552
|
-
const base = glob.slice(0, -2);
|
|
553
|
-
try {
|
|
554
|
-
return readdirSync(join(ROOT, base), { withFileTypes: true })
|
|
555
|
-
.filter((d) => d.isDirectory())
|
|
556
|
-
.map((d) => join(base, d.name));
|
|
557
|
-
} catch {
|
|
558
|
-
return [];
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
return [glob];
|
|
562
|
-
}
|
|
590
|
+
// Workspace discovery lives in ./lib/workspace-packages.mjs — one reader shared with
|
|
591
|
+
// check-workspace-consistency.mjs and check-ci-consistency.mjs. It had been copied into
|
|
592
|
+
// all three and the copies had already drifted; the shared one also counts SYMLINKED
|
|
593
|
+
// members, which `lt fullstack init --api-link/--frontend-link` creates and which a
|
|
594
|
+
// plain `isDirectory()` filter reported as absent, making a linked workspace look empty.
|
|
595
|
+
const workspaceGlobs = () => readWorkspaceGlobs(ROOT);
|
|
596
|
+
const expandGlob = (glob) => expandWorkspaceGlob(ROOT, glob);
|
|
563
597
|
|
|
564
598
|
function asProject(rel, check) {
|
|
565
599
|
let pkg = {};
|
|
566
600
|
try {
|
|
567
|
-
pkg = JSON.parse(
|
|
568
|
-
readFileSync(
|
|
569
|
-
rel === "." ? join(ROOT, "package.json") : join(ROOT, rel, "package.json"),
|
|
570
|
-
"utf8",
|
|
571
|
-
),
|
|
572
|
-
);
|
|
601
|
+
pkg = JSON.parse(readFileSync(rel === '.' ? join(ROOT, 'package.json') : join(ROOT, rel, 'package.json'), 'utf8'));
|
|
573
602
|
} catch {
|
|
574
603
|
/* keep defaults */
|
|
575
604
|
}
|
|
576
|
-
return { check, dir: rel ===
|
|
605
|
+
return { check, dir: rel === '.' ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
|
|
577
606
|
}
|
|
578
607
|
|
|
579
608
|
// Workspace sub-projects and their real check chain; if there are none (a
|
|
@@ -590,7 +619,7 @@ function asProject(rel, check) {
|
|
|
590
619
|
// wrapper `check` means the real chain lives in `check:raw`.
|
|
591
620
|
function realChain(pkg) {
|
|
592
621
|
if (!IS_ORCHESTRATOR(pkg.scripts?.check)) return pkg.scripts?.check ?? null;
|
|
593
|
-
return pkg.scripts?.[
|
|
622
|
+
return pkg.scripts?.['check:raw'] ?? null;
|
|
594
623
|
}
|
|
595
624
|
|
|
596
625
|
function discoverProjects() {
|
|
@@ -599,7 +628,7 @@ function discoverProjects() {
|
|
|
599
628
|
for (const rel of expandGlob(glob)) {
|
|
600
629
|
let pkg;
|
|
601
630
|
try {
|
|
602
|
-
pkg = JSON.parse(readFileSync(join(ROOT, rel,
|
|
631
|
+
pkg = JSON.parse(readFileSync(join(ROOT, rel, 'package.json'), 'utf8'));
|
|
603
632
|
} catch {
|
|
604
633
|
continue;
|
|
605
634
|
}
|
|
@@ -607,12 +636,10 @@ function discoverProjects() {
|
|
|
607
636
|
if (chain) projects.push(asProject(rel, chain));
|
|
608
637
|
}
|
|
609
638
|
}
|
|
610
|
-
const root = JSON.parse(readFileSync(join(ROOT,
|
|
611
|
-
const rootChain =
|
|
612
|
-
root.scripts?.["check:raw"] ??
|
|
613
|
-
(IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
|
|
639
|
+
const root = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
640
|
+
const rootChain = root.scripts?.['check:raw'] ?? (IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
|
|
614
641
|
if (projects.length === 0) {
|
|
615
|
-
if (rootChain) projects.push(asProject(
|
|
642
|
+
if (rootChain) projects.push(asProject('.', rootChain));
|
|
616
643
|
} else if (rootChain) {
|
|
617
644
|
// With members present, the root's own chain must not be dropped: beyond
|
|
618
645
|
// install/audit (hoisted later) and the member fan-out (replaced by the
|
|
@@ -621,7 +648,7 @@ function discoverProjects() {
|
|
|
621
648
|
// for the case where members are present. Strip the fan-out command and
|
|
622
649
|
// keep whatever remains as a root project.
|
|
623
650
|
const ownSteps = rootChain
|
|
624
|
-
.split(
|
|
651
|
+
.split('&&')
|
|
625
652
|
.map((s) => s.trim())
|
|
626
653
|
.filter(Boolean)
|
|
627
654
|
.filter((c) => !isRecursiveCheck(c));
|
|
@@ -629,13 +656,11 @@ function discoverProjects() {
|
|
|
629
656
|
// hoisted steps would otherwise add an empty group that occupies a live-view
|
|
630
657
|
// row and reports a phantom success it never earned.
|
|
631
658
|
if (ownSteps.some((c) => !isHoisted(c))) {
|
|
632
|
-
projects.unshift(asProject(
|
|
659
|
+
projects.unshift(asProject('.', ownSteps.join(' && ')));
|
|
633
660
|
}
|
|
634
661
|
}
|
|
635
662
|
if (PROJECT_FILTERS.length)
|
|
636
|
-
return projects.filter((p) =>
|
|
637
|
-
PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)),
|
|
638
|
-
);
|
|
663
|
+
return projects.filter((p) => PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)));
|
|
639
664
|
return projects;
|
|
640
665
|
}
|
|
641
666
|
|
|
@@ -648,7 +673,7 @@ export function buildGroups(projects) {
|
|
|
648
673
|
const groups = projects.map((project) => {
|
|
649
674
|
const steps = [];
|
|
650
675
|
for (const raw of project.check
|
|
651
|
-
.split(
|
|
676
|
+
.split('&&')
|
|
652
677
|
.map((s) => s.trim())
|
|
653
678
|
.filter(Boolean)) {
|
|
654
679
|
const meta = classify(raw);
|
|
@@ -664,11 +689,11 @@ export function buildGroups(projects) {
|
|
|
664
689
|
// the real `<pm> install` / `<pm> audit` forms. A project script that
|
|
665
690
|
// merely mentions audit in its name stays an ordinary step, so a chain can
|
|
666
691
|
// no longer lose a gate here without a trace.
|
|
667
|
-
if (meta.kind ===
|
|
692
|
+
if (meta.kind === 'audit') {
|
|
668
693
|
if (!auditCmd) auditCmd = pinned;
|
|
669
694
|
continue;
|
|
670
695
|
}
|
|
671
|
-
if (meta.kind ===
|
|
696
|
+
if (meta.kind === 'install') {
|
|
672
697
|
if (!installCmd) installCmd = pinned;
|
|
673
698
|
continue;
|
|
674
699
|
}
|
|
@@ -713,7 +738,7 @@ async function runGroup(group, states, results, abort, gate) {
|
|
|
713
738
|
// Surface the wait in CI too: the step line below is only printed AFTER
|
|
714
739
|
// the acquire, so a gate-blocked group would otherwise emit nothing at all
|
|
715
740
|
// for the length of a full build and read like a hang.
|
|
716
|
-
if (!TTY) process.stdout.write(` ${C.dim(
|
|
741
|
+
if (!TTY) process.stdout.write(` ${C.dim('⋯')} ${shortRel(rel)} · ${step.label} ${C.dim('(queued)')}\n`);
|
|
717
742
|
await gate.acquire(klass);
|
|
718
743
|
waited = Date.now() - queuedAt;
|
|
719
744
|
// Another group may have failed while we waited — abort before starting.
|
|
@@ -730,14 +755,14 @@ async function runGroup(group, states, results, abort, gate) {
|
|
|
730
755
|
}
|
|
731
756
|
st.current = step.label;
|
|
732
757
|
st.stepStart = Date.now();
|
|
733
|
-
if (!TTY) process.stdout.write(` ${C.dim(
|
|
758
|
+
if (!TTY) process.stdout.write(` ${C.dim('→')} ${shortRel(rel)} · ${step.label}\n`);
|
|
734
759
|
// Watchdog on every GATED step, not just tests. A test runner streams output
|
|
735
760
|
// continuously, so prolonged silence == deadlocked workers; a build is
|
|
736
761
|
// normally left unwatched because it buffers. But a gated build holds a slot
|
|
737
762
|
// that blocks every test step in every other group, so a wedged one now
|
|
738
763
|
// hangs the whole run rather than just its own chain — it needs the same
|
|
739
764
|
// watchdog. Ungated steps still run unwatched.
|
|
740
|
-
const watch = step.kind ===
|
|
765
|
+
const watch = step.kind === 'test' || klass ? IDLE_TIMEOUT_MS : 0;
|
|
741
766
|
let code;
|
|
742
767
|
let out;
|
|
743
768
|
try {
|
|
@@ -754,8 +779,8 @@ async function runGroup(group, states, results, abort, gate) {
|
|
|
754
779
|
// wall-clock went. A step that waited 8 minutes behind another group's build
|
|
755
780
|
// and then ran for 2 is not a 2-minute step.
|
|
756
781
|
if (waited > 0) r.waited = waited;
|
|
757
|
-
if (step.kind ===
|
|
758
|
-
if (step.kind ===
|
|
782
|
+
if (step.kind === 'test') r.tests = parseVitest(out);
|
|
783
|
+
if (step.kind === 'lint') r.lint = parseLint(out);
|
|
759
784
|
results.push(r);
|
|
760
785
|
if (code !== 0 && step.fatal) {
|
|
761
786
|
st.failed = step.label;
|
|
@@ -773,7 +798,7 @@ async function runGroup(group, states, results, abort, gate) {
|
|
|
773
798
|
}
|
|
774
799
|
if (!TTY)
|
|
775
800
|
process.stdout.write(
|
|
776
|
-
` ${C.green(
|
|
801
|
+
` ${C.green('✓')} ${shortRel(rel)} · ${step.label}${metricSuffix(r)} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
777
802
|
);
|
|
778
803
|
}
|
|
779
804
|
st.done = true;
|
|
@@ -785,20 +810,19 @@ async function main() {
|
|
|
785
810
|
const started = Date.now();
|
|
786
811
|
const projects = discoverProjects();
|
|
787
812
|
if (projects.length === 0) {
|
|
788
|
-
console.error(C.red(
|
|
813
|
+
console.error(C.red('No workspace projects with a `check` script found.'));
|
|
789
814
|
process.exit(1);
|
|
790
815
|
}
|
|
791
816
|
const { auditCmd, groups, installCmd } = buildGroups(projects);
|
|
792
|
-
const stepCount =
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
|
|
817
|
+
const stepCount = groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0) + (installCmd ? 1 : 0);
|
|
818
|
+
const mode = SEQUENTIAL ? 'sequential' : 'parallel';
|
|
819
|
+
const pkgName = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).name;
|
|
796
820
|
|
|
797
821
|
console.log(C.bold(`\nRunning checks for ${C.cyan(pkgName)}`));
|
|
798
822
|
console.log(
|
|
799
823
|
C.dim(
|
|
800
|
-
`${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ??
|
|
801
|
-
`${NO_FIX ?
|
|
824
|
+
`${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? 'none'}` +
|
|
825
|
+
`${NO_FIX ? '' : ' · auto-fix format+lint'}${VERBOSE ? ' · verbose' : ''}\n`,
|
|
802
826
|
),
|
|
803
827
|
);
|
|
804
828
|
|
|
@@ -810,18 +834,18 @@ async function main() {
|
|
|
810
834
|
// two parallel installs mutating the same node_modules.
|
|
811
835
|
if (installCmd) {
|
|
812
836
|
const t = Date.now();
|
|
813
|
-
if (!TTY) process.stdout.write(` ${C.dim(
|
|
837
|
+
if (!TTY) process.stdout.write(` ${C.dim('→')} install\n`);
|
|
814
838
|
else drawLive([`${C.cyan(FRAMES[0])} install`]);
|
|
815
839
|
const { code, out } = await capture(installCmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
|
|
816
840
|
const dur = Date.now() - t;
|
|
817
841
|
if (code !== 0) {
|
|
818
842
|
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
819
|
-
console.log(`${C.red(
|
|
843
|
+
console.log(`${C.red('✗')} install ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
820
844
|
return fail(`install (${installCmd})`, out, started);
|
|
821
845
|
}
|
|
822
|
-
if (!TTY) process.stdout.write(` ${C.green(
|
|
846
|
+
if (!TTY) process.stdout.write(` ${C.green('✓')} install ${C.dim(`(${fmtDuration(dur)})`)}\n`);
|
|
823
847
|
// TTY success: no permanent line — see the audit block below.
|
|
824
|
-
results.push({ dur, kind:
|
|
848
|
+
results.push({ dur, kind: 'step', label: 'install', project: '.' });
|
|
825
849
|
}
|
|
826
850
|
|
|
827
851
|
// Step 0 — single workspace audit (blocking gate, runs before the fan-out).
|
|
@@ -829,37 +853,40 @@ async function main() {
|
|
|
829
853
|
// the chain has no audit step.
|
|
830
854
|
if (auditCmd) {
|
|
831
855
|
const t = Date.now();
|
|
832
|
-
if (!TTY) process.stdout.write(` ${C.dim(
|
|
856
|
+
if (!TTY) process.stdout.write(` ${C.dim('→')} audit\n`);
|
|
833
857
|
else drawLive([`${C.cyan(FRAMES[0])} audit`]);
|
|
834
858
|
const audit = await runAudit(auditCmd);
|
|
835
859
|
const dur = Date.now() - t;
|
|
836
860
|
if (audit.blocking) {
|
|
837
861
|
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
838
|
-
const summary = audit.counts
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
862
|
+
const summary = audit.counts ? `${audit.total} vuln (${renderVulnLine(audit)})` : 'failed';
|
|
863
|
+
console.log(`${C.red('✗')} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
864
|
+
return fail(`audit (${auditCmd})`, audit.counts ? renderVulnLine(audit) : audit.reason, started);
|
|
865
|
+
}
|
|
866
|
+
if (audit.degraded) {
|
|
867
|
+
// Loud, and never rendered as a tick. A green ✓ here would claim "no vulnerabilities",
|
|
868
|
+
// which is exactly what was NOT established — the service could not be reached. The run
|
|
869
|
+
// continues because an outage is not a finding and nothing in this repo fixes it.
|
|
870
|
+
liveCount = 0;
|
|
871
|
+
console.log(`${C.yellow('⚠')} audit ${C.yellow(auditDegradedText(audit))} ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
847
872
|
}
|
|
848
|
-
|
|
873
|
+
// `!audit.degraded`: the warning above already printed, and a ✓ underneath it would say
|
|
874
|
+
// "no vulnerabilities" about a run that established nothing.
|
|
875
|
+
if (!TTY && !audit.degraded) {
|
|
849
876
|
process.stdout.write(
|
|
850
|
-
` ${C.green(
|
|
877
|
+
` ${C.green('✓')} audit ${audit.counts ? renderVulnLine(audit) : C.dim('0')} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
851
878
|
);
|
|
852
879
|
}
|
|
853
880
|
// TTY success: NO permanent line — the live status view overwrites the audit
|
|
854
881
|
// row (like every other step); the result lands in the report twice: the
|
|
855
882
|
// Steps list (entry below) and the Vulnerabilities section.
|
|
856
|
-
results.push({ audit, kind:
|
|
857
|
-
results.push({ dur, kind:
|
|
883
|
+
results.push({ audit, kind: 'audit' });
|
|
884
|
+
results.push({ dur, kind: 'step', label: 'audit', project: '.' });
|
|
858
885
|
}
|
|
859
886
|
|
|
860
887
|
// Per-project steps — parallel by default, serial with --sequential.
|
|
861
888
|
const order = groups.map((g) => g.project.rel);
|
|
862
|
-
const states = new Map(order.map((rel) => [rel, { current:
|
|
889
|
+
const states = new Map(order.map((rel) => [rel, { current: 'queued' }]));
|
|
863
890
|
const abort = { failure: null, hit: false };
|
|
864
891
|
// Serializes CPU-heavy `build` steps against the contention-sensitive `test`
|
|
865
892
|
// suites across groups so a parallel `nuxt build` can never destabilize the
|
|
@@ -887,128 +914,103 @@ async function main() {
|
|
|
887
914
|
}
|
|
888
915
|
|
|
889
916
|
// ── rendering helpers ─────────────────────────────────────────────────────────
|
|
890
|
-
// `unlisted` = counted in `metadata.vulnerabilities` but absent from `advisories`
|
|
891
|
-
// — advisories suppressed via auditConfig.ignoreGhsas, and (under pnpm) findings
|
|
892
|
-
// below `--audit-level`. Without accounting for them the line reads as an
|
|
893
|
-
// unresolved finding forever.
|
|
894
|
-
//
|
|
895
|
-
// `blocking` is what decides whether dimming is allowed at all. Dimming says "you
|
|
896
|
-
// already looked at this"; on a run the gate is FAILING, that is exactly the wrong
|
|
897
|
-
// thing to say, and it used to be said — a real critical rendered grey and
|
|
898
|
-
// labelled. When the gate fails, the numbers stay loud whatever the derivation
|
|
899
|
-
// suggests.
|
|
900
|
-
function renderVulnLine(counts, unlisted = 0, blocking = false) {
|
|
901
|
-
const total = SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0);
|
|
902
|
-
const allUnlisted = !blocking && unlisted > 0 && unlisted >= total;
|
|
903
|
-
const line = SEVERITIES.map((s) => {
|
|
904
|
-
const n = counts[s] || 0;
|
|
905
|
-
const txt = `${s} ${n}`;
|
|
906
|
-
if (n === 0 || allUnlisted) return C.dim(txt);
|
|
907
|
-
if (s === "critical" || s === "high") return C.red(txt);
|
|
908
|
-
return C.yellow(txt);
|
|
909
|
-
}).join(C.dim(" · "));
|
|
910
|
-
return unlisted > 0 ? `${line}${C.dim(` (${unlisted} not listed)`)}` : line;
|
|
911
|
-
}
|
|
912
917
|
|
|
913
918
|
function metricSuffix(r) {
|
|
914
|
-
if (r.kind ===
|
|
915
|
-
const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) :
|
|
916
|
-
return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` :
|
|
919
|
+
if (r.kind === 'test' && r.tests?.passed != null) {
|
|
920
|
+
const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : '';
|
|
921
|
+
return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ''}`)}${failed}`;
|
|
917
922
|
}
|
|
918
923
|
if (r.waited != null && r.waited >= 1000) {
|
|
919
924
|
// The gate wait is NOT part of `dur`, so without this the report would show
|
|
920
925
|
// a two-minute step that actually occupied ten minutes of wall-clock.
|
|
921
926
|
return ` ${C.dim(`queued ${fmtDuration(r.waited)}`)}`;
|
|
922
927
|
}
|
|
923
|
-
if (r.kind ===
|
|
928
|
+
if (r.kind === 'lint' && r.lint) {
|
|
924
929
|
return r.lint.warnings > 0
|
|
925
|
-
? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ?
|
|
926
|
-
: ` ${C.dim(
|
|
930
|
+
? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? '' : 's'}`)}`
|
|
931
|
+
: ` ${C.dim('clean')}`;
|
|
927
932
|
}
|
|
928
|
-
return
|
|
933
|
+
return '';
|
|
929
934
|
}
|
|
930
935
|
|
|
931
936
|
function fail(stepLabel, reason, started) {
|
|
932
937
|
console.log(`\n${C.red(`──── reason · ${stepLabel} ────`)}`);
|
|
933
|
-
console.log(stripAnsi(String(reason)).trimEnd().split(
|
|
934
|
-
console.log(C.red(
|
|
935
|
-
console.log(
|
|
936
|
-
|
|
937
|
-
C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`),
|
|
938
|
-
),
|
|
939
|
-
);
|
|
940
|
-
console.log(C.dim("Re-run with --verbose for the full output of every step."));
|
|
938
|
+
console.log(stripAnsi(String(reason)).trimEnd().split('\n').slice(-40).join('\n'));
|
|
939
|
+
console.log(C.red('────────────────────────────────────────\n'));
|
|
940
|
+
console.log(C.bold(C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`)));
|
|
941
|
+
console.log(C.dim('Re-run with --verbose for the full output of every step.'));
|
|
941
942
|
process.exit(1);
|
|
942
943
|
}
|
|
943
944
|
|
|
944
945
|
function report(started, results) {
|
|
945
|
-
const audit = results.find((r) => r.kind ===
|
|
946
|
-
const tests = results.filter((r) => r.kind ===
|
|
947
|
-
const unit = tests.find((r) => r.project?.includes(
|
|
948
|
-
const api = tests.find((r) => r.project?.includes(
|
|
946
|
+
const audit = results.find((r) => r.kind === 'audit')?.audit;
|
|
947
|
+
const tests = results.filter((r) => r.kind === 'test');
|
|
948
|
+
const unit = tests.find((r) => r.project?.includes('app'))?.tests;
|
|
949
|
+
const api = tests.find((r) => r.project?.includes('api'))?.tests;
|
|
949
950
|
const totalPassed = tests.reduce((n, r) => n + (r.tests?.passed || 0), 0);
|
|
950
951
|
|
|
951
|
-
const bar =
|
|
952
|
+
const bar = '═'.repeat(52);
|
|
952
953
|
console.log(`\n${C.green(bar)}`);
|
|
953
|
-
console.log(
|
|
954
|
-
C.bold(` ${C.green("✓ Check PASSED")} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`),
|
|
955
|
-
);
|
|
954
|
+
console.log(C.bold(` ${C.green('✓ Check PASSED')} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`));
|
|
956
955
|
console.log(C.green(bar));
|
|
957
956
|
|
|
958
|
-
console.log(`\n${C.bold(
|
|
959
|
-
const steps = results.filter((x) => x.kind !==
|
|
957
|
+
console.log(`\n${C.bold('Steps')}`);
|
|
958
|
+
const steps = results.filter((x) => x.kind !== 'audit');
|
|
960
959
|
// Group by project when more than one is involved: workspace-level steps
|
|
961
960
|
// (hoisted install/audit, root-only checks) under "monorepo", then one block
|
|
962
961
|
// per member. Steps within a project run sequentially, so per-group order is
|
|
963
962
|
// chain order. A single-project run keeps the flat list — a header is noise.
|
|
964
963
|
const stepGroups = [...new Set(steps.map((r) => r.project))].sort((a, b) =>
|
|
965
|
-
a ===
|
|
964
|
+
a === '.' ? -1 : b === '.' ? 1 : shortRel(a).localeCompare(shortRel(b)),
|
|
966
965
|
);
|
|
967
966
|
if (stepGroups.length > 1) {
|
|
968
967
|
for (const project of stepGroups) {
|
|
969
|
-
console.log(` ${C.bold(project ===
|
|
968
|
+
console.log(` ${C.bold(project === '.' ? 'monorepo' : shortRel(project))}`);
|
|
970
969
|
for (const r of steps.filter((x) => x.project === project)) {
|
|
971
970
|
console.log(
|
|
972
|
-
` ${C.green(
|
|
971
|
+
` ${C.green('✓')} ${r.label.padEnd(24)}${metricSuffix(r) || ' '} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
|
|
973
972
|
);
|
|
974
973
|
}
|
|
975
974
|
}
|
|
976
975
|
} else {
|
|
977
976
|
for (const r of steps) {
|
|
978
977
|
console.log(
|
|
979
|
-
` ${C.green(
|
|
978
|
+
` ${C.green('✓')} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || ' '} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
|
|
980
979
|
);
|
|
981
980
|
}
|
|
982
981
|
}
|
|
983
982
|
|
|
983
|
+
console.log(`\n${C.bold('Vulnerabilities')} ${C.dim(audit ? `(${audit.auditCmd})` : '(no audit step)')}`);
|
|
984
984
|
console.log(
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
985
|
+
` ${
|
|
986
|
+
audit?.counts
|
|
987
|
+
? renderVulnLine(audit)
|
|
988
|
+
: audit?.degraded
|
|
989
|
+
? // Named, not blank. "counts unavailable" reads like a formatting hiccup; the reader
|
|
990
|
+
// has to know the tree was never checked, or a green summary above means more than
|
|
991
|
+
// it should.
|
|
992
|
+
C.yellow(auditDegradedText(audit))
|
|
993
|
+
: C.dim(audit ? 'counts unavailable' : '—')
|
|
994
|
+
}`,
|
|
989
995
|
);
|
|
990
996
|
|
|
991
|
-
console.log(`\n${C.bold(
|
|
997
|
+
console.log(`\n${C.bold('Tests')}`);
|
|
992
998
|
if (unit || api) {
|
|
993
999
|
// Monorepo with app and/or api projects → the canonical area breakdown.
|
|
994
|
-
console.log(
|
|
995
|
-
|
|
996
|
-
);
|
|
997
|
-
console.log(
|
|
998
|
-
` ${"API (api)".padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim("—")}`,
|
|
999
|
-
);
|
|
1000
|
-
console.log(` ${"Playwright".padEnd(18)}${C.dim("— (run via `lt dev test` / CI)")}`);
|
|
1000
|
+
console.log(` ${'Unit (app)'.padEnd(18)}${unit?.passed != null ? `${unit.passed} passed` : C.dim('—')}`);
|
|
1001
|
+
console.log(` ${'API (api)'.padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim('—')}`);
|
|
1002
|
+
console.log(` ${'Playwright'.padEnd(18)}${C.dim('— (run via `lt dev test` / CI)')}`);
|
|
1001
1003
|
} else {
|
|
1002
1004
|
// Single-package repo → one line per test-bearing project.
|
|
1003
1005
|
for (const r of tests)
|
|
1004
1006
|
console.log(
|
|
1005
|
-
` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim(
|
|
1007
|
+
` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim('—')}`,
|
|
1006
1008
|
);
|
|
1007
|
-
if (tests.length === 0) console.log(` ${C.dim(
|
|
1009
|
+
if (tests.length === 0) console.log(` ${C.dim('no test step')}`);
|
|
1008
1010
|
}
|
|
1009
|
-
console.log(` ${C.bold(
|
|
1011
|
+
console.log(` ${C.bold('Total'.padEnd(18))}${C.bold(`${totalPassed} passed`)}`);
|
|
1010
1012
|
|
|
1011
|
-
console.log(`\n${C.green(
|
|
1013
|
+
console.log(`\n${C.green('All checks passed.')}\n`);
|
|
1012
1014
|
}
|
|
1013
1015
|
|
|
1014
1016
|
// Run only when invoked as the CLI (`node scripts/check.mjs`). Importing this
|
|
@@ -1051,12 +1053,12 @@ if (isCliEntry()) {
|
|
|
1051
1053
|
// every running `pnpm test` / build / e2e fork pool: they keep the test
|
|
1052
1054
|
// database and ports held, and the next run fails for a reason that has
|
|
1053
1055
|
// nothing to do with the code.
|
|
1054
|
-
for (const signal of [
|
|
1056
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
1055
1057
|
process.on(signal, () => {
|
|
1056
1058
|
killAll();
|
|
1057
1059
|
// Conventional 128+n, and it makes the interruption distinguishable from
|
|
1058
1060
|
// an ordinary failure.
|
|
1059
|
-
process.exit(signal ===
|
|
1061
|
+
process.exit(signal === 'SIGINT' ? 130 : 143);
|
|
1060
1062
|
});
|
|
1061
1063
|
}
|
|
1062
1064
|
main().catch((err) => {
|