@lenne.tech/cli 1.43.0 → 1.44.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.
|
@@ -154,23 +154,46 @@ const DoctorCommand = {
|
|
|
154
154
|
else if (gs.file && gs.hasDbReset) {
|
|
155
155
|
line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
|
|
156
156
|
}
|
|
157
|
-
// 7.5 check
|
|
157
|
+
// 7.5 check wrapper drift: the root wrapper is canonical (bundled with the
|
|
158
158
|
// CLI, synced by `lt fullstack update`). A diverged copy silently
|
|
159
159
|
// misses fixes (idle-watchdog, install hoisting, summed test
|
|
160
160
|
// metrics) — surface it instead of letting copies drift apart.
|
|
161
|
+
//
|
|
162
|
+
// Checked over the WHOLE copy set, not just `check.mjs`: the wrapper
|
|
163
|
+
// imports siblings, so a missing or stale one makes `check` die with
|
|
164
|
+
// ERR_MODULE_NOT_FOUND (or an import mismatch) before running a single
|
|
165
|
+
// step. Reporting only on `check.mjs` meant doctor printed a green
|
|
166
|
+
// "matches the canonical CLI version" for a project whose `check` was
|
|
167
|
+
// completely broken — and doctor is the tool people reach for exactly
|
|
168
|
+
// then.
|
|
161
169
|
try {
|
|
162
170
|
const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
|
|
163
171
|
const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
|
|
164
|
-
const
|
|
172
|
+
const { resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
|
|
165
173
|
const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
|
|
166
|
-
if (filesystem.exists(
|
|
167
|
-
|
|
168
|
-
|
|
174
|
+
if (filesystem.exists(bundledCheck)) {
|
|
175
|
+
const missing = [];
|
|
176
|
+
const drifted = [];
|
|
177
|
+
for (const { rel, source } of resolveCopySet(bundledCheck)) {
|
|
178
|
+
const target = j(layout.root, rel);
|
|
179
|
+
if (!filesystem.exists(target)) {
|
|
180
|
+
missing.push(rel);
|
|
181
|
+
}
|
|
182
|
+
else if (read(target, 'utf8') !== read(source, 'utf8')) {
|
|
183
|
+
drifted.push(rel);
|
|
184
|
+
}
|
|
169
185
|
}
|
|
170
|
-
|
|
171
|
-
line('
|
|
186
|
+
if (missing.length > 0) {
|
|
187
|
+
line('ERROR', colors.red, `check wrapper incomplete — missing ${missing.join(', ')}`);
|
|
188
|
+
line('ERROR', colors.red, ' `pnpm run check` cannot start; run `lt fullstack update` to install it');
|
|
189
|
+
}
|
|
190
|
+
else if (drifted.length > 0) {
|
|
191
|
+
line('WARN', colors.yellow, `${drifted.join(', ')} differs from the canonical CLI version`);
|
|
172
192
|
line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
|
|
173
193
|
}
|
|
194
|
+
else if (filesystem.exists(j(layout.root, 'scripts', 'check.mjs'))) {
|
|
195
|
+
line('OK', colors.green, 'check wrapper matches the canonical CLI version');
|
|
196
|
+
}
|
|
174
197
|
}
|
|
175
198
|
}
|
|
176
199
|
catch (_a) {
|
|
@@ -198,7 +198,20 @@ const NewCommand = {
|
|
|
198
198
|
const changedCheck = (0, heal_check_wrapper_1.healCheckWrapper)(cwd, checkAsset);
|
|
199
199
|
if (changedCheck.length > 0) {
|
|
200
200
|
info('');
|
|
201
|
-
success
|
|
201
|
+
// A skip entry is NOT a success — it means the wrapper stayed on its old
|
|
202
|
+
// version. Reporting the whole list through `success()` painted a refusal
|
|
203
|
+
// green.
|
|
204
|
+
const skipped = changedCheck.filter((entry) => entry.includes('skipped'));
|
|
205
|
+
const applied = changedCheck.filter((entry) => !entry.includes('skipped'));
|
|
206
|
+
if (applied.length > 0) {
|
|
207
|
+
success(` Installed/updated the check wrapper: ${applied.join(', ')}`);
|
|
208
|
+
info(' `check` now serialises build/typecheck against the test suites,');
|
|
209
|
+
info(' so it takes longer in wall-clock but no longer destabilises API e2e runs.');
|
|
210
|
+
info(' The wrapper imports its siblings — keep them together, or `check` will not start.');
|
|
211
|
+
}
|
|
212
|
+
for (const entry of skipped) {
|
|
213
|
+
warning(` Check wrapper NOT updated: ${entry}`);
|
|
214
|
+
}
|
|
202
215
|
}
|
|
203
216
|
// ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
|
|
204
217
|
//
|
|
@@ -209,6 +222,16 @@ const NewCommand = {
|
|
|
209
222
|
info('');
|
|
210
223
|
success(' Added `.lt-dev/` to .gitignore');
|
|
211
224
|
}
|
|
225
|
+
// ── Self-heal: keep the check's isolated Nuxt build dir out of git ──────
|
|
226
|
+
//
|
|
227
|
+
// The check wrapper pins `NUXT_BUILD_DIR=.nuxt-check` so it never writes the
|
|
228
|
+
// `.nuxt/` a parked `nuxt dev` reads. Current starters already ignore it
|
|
229
|
+
// (via their `.nuxt-*` glob); projects scaffolded before that glob do not,
|
|
230
|
+
// and a build dir is a plausible place for a resolved runtimeConfig to be
|
|
231
|
+
// committed by accident. Idempotent.
|
|
232
|
+
if ((0, dev_patches_1.addToGitignore)(cwd, '.nuxt-check')) {
|
|
233
|
+
success(' Added `.nuxt-check` to .gitignore');
|
|
234
|
+
}
|
|
212
235
|
// ── Self-heal: repair the vendor-mode migration store ──────────────────
|
|
213
236
|
//
|
|
214
237
|
// `migrations-utils/migrate.js` is written ONCE, at conversion time. Projects
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.healCheckWrapper = healCheckWrapper;
|
|
4
|
+
exports.resolveCopySet = resolveCopySet;
|
|
4
5
|
const child_process_1 = require("child_process");
|
|
5
6
|
const fs_1 = require("fs");
|
|
6
7
|
const path_1 = require("path");
|
|
7
8
|
/** Marker value for the report-driven check wrapper. */
|
|
8
9
|
const WRAPPER = 'node scripts/check.mjs';
|
|
10
|
+
/** Where the wrapper and its imports live inside a project. */
|
|
11
|
+
const SCRIPTS_DIR = 'scripts';
|
|
9
12
|
/**
|
|
10
|
-
* Idempotently install the report-driven
|
|
13
|
+
* Idempotently install the report-driven check wrapper — and every module it
|
|
14
|
+
* imports — into a project.
|
|
11
15
|
*
|
|
12
|
-
* Copies the bundled
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* Copies the bundled wrapper to `<root>/scripts/check.mjs`, copies its whole
|
|
17
|
+
* relative-import closure alongside it under the names the wrapper imports them
|
|
18
|
+
* by, and rewrites the root `package.json` so that `check` runs the wrapper
|
|
19
|
+
* while the original chain is preserved as `check:raw`. A no-op once already
|
|
20
|
+
* wired (so it is safe to run on every `lt fullstack update`).
|
|
16
21
|
*
|
|
17
22
|
* `lt fullstack init` already ships the wrapper via the template clone; this is
|
|
18
23
|
* the MIGRATION path that brings it into pre-existing projects.
|
|
19
24
|
*
|
|
25
|
+
* The copy set moves ATOMICALLY: if any member must be skipped, none are
|
|
26
|
+
* written. A partial update would leave `check.mjs` and a sibling on different
|
|
27
|
+
* versions, and the project's `check` then dies on an import mismatch before
|
|
28
|
+
* running a single step.
|
|
29
|
+
*
|
|
20
30
|
* @param projectRoot Absolute path to the (workspace) project root.
|
|
21
|
-
* @param assetPath Absolute path to the bundled
|
|
31
|
+
* @param assetPath Absolute path to the bundled wrapper. Its DIRECTORY is
|
|
32
|
+
* also probed: every module the wrapper imports relatively
|
|
33
|
+
* (transitively) is shipped from there. The asset always
|
|
34
|
+
* lands as `scripts/check.mjs` regardless of its own name.
|
|
22
35
|
* @returns The list of changed file paths (relative to `projectRoot`); empty when nothing changed.
|
|
23
36
|
*/
|
|
24
37
|
function healCheckWrapper(projectRoot, assetPath) {
|
|
@@ -39,22 +52,35 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
39
52
|
if (!scripts || typeof scripts.check !== 'string') {
|
|
40
53
|
return changed;
|
|
41
54
|
}
|
|
42
|
-
// 1. Ensure
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// 1. Ensure the wrapper — and everything it imports — exists in the project
|
|
56
|
+
// and matches the canonical version.
|
|
57
|
+
//
|
|
58
|
+
// The set is derived from the wrapper's own import statements rather than a
|
|
59
|
+
// hard-coded name: the wrapper grew a sibling (`build-test-gate.mjs`, which
|
|
60
|
+
// serialises the CPU-heavy build against the API e2e suite), and copying only
|
|
61
|
+
// `check.mjs` installs a file whose very first import resolves to nothing —
|
|
62
|
+
// the project's `check` then dies with ERR_MODULE_NOT_FOUND before running a
|
|
63
|
+
// single step. Deriving it from the imports (not from "every .mjs in the
|
|
64
|
+
// directory") keeps the next sibling free of changes here while making sure a
|
|
65
|
+
// stray file in the asset dir never claims a path in the project's scripts/.
|
|
66
|
+
const copies = resolveCopySet(assetPath);
|
|
67
|
+
// Decide EVERY member before writing ANY of them — see the atomicity note in
|
|
68
|
+
// the doc block above.
|
|
69
|
+
const plans = copies.map((copy) => planCopy(projectRoot, copy));
|
|
70
|
+
const blocked = plans.filter((p) => p.action === 'skip');
|
|
71
|
+
if (blocked.length > 0) {
|
|
72
|
+
// One entry for the whole set: the set is what could not be updated, and
|
|
73
|
+
// naming only the blocking member would suggest the others did land.
|
|
74
|
+
const names = blocked.map((p) => p.rel).join(', ');
|
|
75
|
+
changed.push(`${copies.map((c) => c.rel).join(' + ')} (skipped: uncommitted changes in ${names} — commit or discard them, then re-run)`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
for (const plan of plans) {
|
|
79
|
+
if (plan.action === 'up-to-date') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
writeCopy(projectRoot, plan);
|
|
83
|
+
changed.push(plan.rel);
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
86
|
// 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
|
|
@@ -69,11 +95,57 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
69
95
|
return changed;
|
|
70
96
|
}
|
|
71
97
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
98
|
+
* The wrapper plus the transitive closure of its relative imports.
|
|
99
|
+
*
|
|
100
|
+
* Matches both quote styles: the bundled `.mjs` files are formatted by the
|
|
101
|
+
* consuming project's formatter, not by this repo's, so their quote style is
|
|
102
|
+
* not ours to assume.
|
|
103
|
+
*/
|
|
104
|
+
function resolveCopySet(assetPath) {
|
|
105
|
+
const assetDir = (0, path_1.dirname)(assetPath);
|
|
106
|
+
// Regular files only — a directory named `*.mjs` would otherwise reach
|
|
107
|
+
// copyFileSync and abort the whole migration with EISDIR.
|
|
108
|
+
const available = new Set((0, fs_1.readdirSync)(assetDir, { withFileTypes: true })
|
|
109
|
+
.filter((entry) => entry.isFile())
|
|
110
|
+
.map((entry) => entry.name));
|
|
111
|
+
const copies = [{ rel: `${SCRIPTS_DIR}/check.mjs`, source: assetPath }];
|
|
112
|
+
// Keyed by target rel, NOT by source basename: the asset lands as
|
|
113
|
+
// `scripts/check.mjs` whatever it is called, so a `check.mjs` sitting beside a
|
|
114
|
+
// differently-named asset must not claim that same path a second time.
|
|
115
|
+
const claimed = new Set(copies.map((c) => c.rel));
|
|
116
|
+
const queue = [assetPath];
|
|
117
|
+
const visited = new Set();
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
const file = queue.shift();
|
|
120
|
+
if (visited.has(file)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
visited.add(file);
|
|
124
|
+
let source;
|
|
125
|
+
try {
|
|
126
|
+
source = (0, fs_1.readFileSync)(file, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
catch (_a) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const match of source.matchAll(/\bfrom\s+['"]\.\/([^'"/]+)['"]/g)) {
|
|
132
|
+
const name = match[1];
|
|
133
|
+
const rel = `${SCRIPTS_DIR}/${name}`;
|
|
134
|
+
if (claimed.has(rel) || !available.has(name)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
claimed.add(rel);
|
|
138
|
+
const resolved = (0, path_1.join)(assetDir, name);
|
|
139
|
+
copies.push({ rel, source: resolved });
|
|
140
|
+
queue.push(resolved);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return copies;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* True when the TRACKED `relPath` has uncommitted modifications. Overwriting
|
|
147
|
+
* such a file would destroy work that exists nowhere else. Only meaningful for
|
|
148
|
+
* a tracked path — see `isTracked`.
|
|
77
149
|
*/
|
|
78
150
|
function hasUncommittedChanges(projectRoot, relPath) {
|
|
79
151
|
try {
|
|
@@ -87,3 +159,93 @@ function hasUncommittedChanges(projectRoot, relPath) {
|
|
|
87
159
|
return false;
|
|
88
160
|
}
|
|
89
161
|
}
|
|
162
|
+
/** True when `target` is a symlink (checked without following it). */
|
|
163
|
+
function isSymlink(target) {
|
|
164
|
+
try {
|
|
165
|
+
return (0, fs_1.lstatSync)(target).isSymbolicLink();
|
|
166
|
+
}
|
|
167
|
+
catch (_a) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* True when git tracks `relPath`, i.e. it holds a recoverable copy.
|
|
173
|
+
*
|
|
174
|
+
* An empty `git status --porcelain` alone does NOT establish that: it is also
|
|
175
|
+
* empty for an ignored file, and for a path in a directory git knows nothing
|
|
176
|
+
* about. Those are precisely the cases where an overwrite is unrecoverable.
|
|
177
|
+
*/
|
|
178
|
+
function isTracked(projectRoot, relPath) {
|
|
179
|
+
try {
|
|
180
|
+
(0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
|
|
181
|
+
stdio: 'ignore',
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (_a) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Decide what should happen to one copy target, without touching the disk. */
|
|
190
|
+
function planCopy(projectRoot, copy) {
|
|
191
|
+
const target = (0, path_1.join)(projectRoot, copy.rel);
|
|
192
|
+
const plan = Object.assign(Object.assign({}, copy), { action: 'write', backup: false });
|
|
193
|
+
if (!(0, fs_1.existsSync)(target)) {
|
|
194
|
+
return plan;
|
|
195
|
+
}
|
|
196
|
+
// A symlink here resolves OUTSIDE the project, and git reports the (unchanged)
|
|
197
|
+
// link blob as clean, so the guard below cannot see it. Writing would silently
|
|
198
|
+
// modify a file somewhere else entirely.
|
|
199
|
+
if (isSymlink(target)) {
|
|
200
|
+
plan.action = 'skip';
|
|
201
|
+
return plan;
|
|
202
|
+
}
|
|
203
|
+
if ((0, fs_1.readFileSync)(target, 'utf8') === (0, fs_1.readFileSync)(copy.source, 'utf8')) {
|
|
204
|
+
plan.action = 'up-to-date';
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
|
207
|
+
// A TRACKED file whose working copy diverges carries edits that exist nowhere
|
|
208
|
+
// else — never overwrite it. A tracked-and-clean file is safe to replace
|
|
209
|
+
// (git can restore it). Anything git does not track is not recoverable at
|
|
210
|
+
// all, so it gets a `.bak` instead of a refusal: refusing would be the worse
|
|
211
|
+
// outcome, because the wrapper's OWN previous output is untracked until the
|
|
212
|
+
// user commits it, and a refusal there permanently blocks the update.
|
|
213
|
+
if (isTracked(projectRoot, copy.rel)) {
|
|
214
|
+
if (hasUncommittedChanges(projectRoot, copy.rel)) {
|
|
215
|
+
plan.action = 'skip';
|
|
216
|
+
}
|
|
217
|
+
return plan;
|
|
218
|
+
}
|
|
219
|
+
plan.backup = true;
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
|
222
|
+
/** Write one planned copy, backing up an unversioned target first. */
|
|
223
|
+
function writeCopy(projectRoot, plan) {
|
|
224
|
+
const target = (0, path_1.join)(projectRoot, plan.rel);
|
|
225
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
|
|
226
|
+
if (plan.backup && (0, fs_1.existsSync)(target)) {
|
|
227
|
+
const backup = `${target}.bak`;
|
|
228
|
+
// Keep the FIRST backup — a later run must not overwrite the original with
|
|
229
|
+
// an already-generated copy.
|
|
230
|
+
if (!(0, fs_1.existsSync)(backup)) {
|
|
231
|
+
(0, fs_1.copyFileSync)(target, backup);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// temp + rename so an interrupted run can never leave a half-written wrapper.
|
|
235
|
+
const tmp = `${target}.lt-tmp-${process.pid}`;
|
|
236
|
+
try {
|
|
237
|
+
(0, fs_1.copyFileSync)(plan.source, tmp);
|
|
238
|
+
(0, fs_1.renameSync)(tmp, target);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
try {
|
|
242
|
+
if ((0, fs_1.existsSync)(tmp)) {
|
|
243
|
+
(0, fs_1.unlinkSync)(tmp);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (_a) {
|
|
247
|
+
/* best effort */
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-group mutual exclusion between CPU-heavy steps and contention-sensitive
|
|
3
|
+
* test suites (DEV-2524, cause 2).
|
|
4
|
+
*
|
|
5
|
+
* The root check wrapper (scripts/check.mjs) runs each workspace project's step
|
|
6
|
+
* chain concurrently by default. The api group's `test` step is the API e2e
|
|
7
|
+
* suite, whose Better-Auth session validation is sensitive to CPU contention:
|
|
8
|
+
* it tips into intermittent 401/500 the moment another group saturates the
|
|
9
|
+
* machine — most reliably the app group's `nuxt build`. Measured in DEV-2524:
|
|
10
|
+
* the same commit was red under the parallel run and green (2418/2418) under
|
|
11
|
+
* `--sequential`. This gate guarantees a heavy step and a sensitive test step
|
|
12
|
+
* never run at the same time, while:
|
|
13
|
+
* - same-class steps may still overlap (two builds, or two test suites)
|
|
14
|
+
* → the parallel design is preserved elsewhere;
|
|
15
|
+
* - every other step kind (format, lint, server-start, …) never touches the
|
|
16
|
+
* gate at all and stays fully parallel.
|
|
17
|
+
*
|
|
18
|
+
* Which steps belong to which class is `gateClass()` in check.mjs, not this
|
|
19
|
+
* file: the gate is a generic two-class lock and does not know what a "build"
|
|
20
|
+
* is. Note that the classes are named "build" and "test" only because those are
|
|
21
|
+
* the labels the caller passes; the lock treats them as two opaque, mutually
|
|
22
|
+
* exclusive classes.
|
|
23
|
+
*
|
|
24
|
+
* It is a two-class fair lock: whichever class is active admits every waiter of
|
|
25
|
+
* that class; the other class waits until the active class fully drains, then
|
|
26
|
+
* takes over as a batch. Handover is FIFO between the two classes, so neither
|
|
27
|
+
* can starve the other.
|
|
28
|
+
*
|
|
29
|
+
* Starvation-freedom is workload-bounded, not unconditional: an arriving acquire
|
|
30
|
+
* of the ACTIVE class barges ahead of an already-queued opposite-class waiter, so
|
|
31
|
+
* an unbounded stream of same-class arrivals could in theory starve the other
|
|
32
|
+
* class. That cannot happen here — the check wrapper issues FINITELY MANY gated
|
|
33
|
+
* steps per group, so the active class always drains and the queued batch is then
|
|
34
|
+
* admitted. (Do not restate this as "one test then one build per group": the root
|
|
35
|
+
* group has a `test` and no build at all, and a chain's shape is the project's to
|
|
36
|
+
* choose. Finiteness is what the argument needs, and finiteness is what holds.)
|
|
37
|
+
*/
|
|
38
|
+
export function createBuildTestGate() {
|
|
39
|
+
let activeClass = null; // 'test' | 'build' | null
|
|
40
|
+
let activeCount = 0;
|
|
41
|
+
const queue = []; // FIFO of { klass, resolve }
|
|
42
|
+
|
|
43
|
+
function admit(klass, resolve) {
|
|
44
|
+
activeClass = klass;
|
|
45
|
+
activeCount += 1;
|
|
46
|
+
resolve();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function acquire(klass) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
if (activeClass === null || activeClass === klass) {
|
|
52
|
+
admit(klass, resolve);
|
|
53
|
+
} else {
|
|
54
|
+
queue.push({ klass, resolve });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function release() {
|
|
60
|
+
// Guard against an unbalanced release. Without it the counter goes negative
|
|
61
|
+
// and the lock stops excluding: with two holders and one stray release,
|
|
62
|
+
// `activeCount` reaches 0 while a holder is still running, so the opposite
|
|
63
|
+
// class is admitted alongside it — silently, and precisely when the machine
|
|
64
|
+
// is busiest. No current caller can double-release (runGroup acquires and
|
|
65
|
+
// releases exactly once per gated step), but this is shipped into every
|
|
66
|
+
// generated project and there are two release sites per acquire.
|
|
67
|
+
if (activeCount <= 0) return;
|
|
68
|
+
|
|
69
|
+
activeCount -= 1;
|
|
70
|
+
if (activeCount > 0) return;
|
|
71
|
+
|
|
72
|
+
activeClass = null;
|
|
73
|
+
if (queue.length === 0) return;
|
|
74
|
+
|
|
75
|
+
// Active class fully drained with waiters pending: hand over to the class of
|
|
76
|
+
// the oldest waiter, admitting every queued waiter of that class as a batch.
|
|
77
|
+
//
|
|
78
|
+
// Via the public API the queue only ever holds a SINGLE class at a time (a
|
|
79
|
+
// same-class acquire is admitted immediately and never queues, so only the
|
|
80
|
+
// opposite class waits while one class is active). The `carried` re-queue is
|
|
81
|
+
// therefore always empty in practice — kept as a defensive guard so the
|
|
82
|
+
// handover stays correct if the admission rule ever changes.
|
|
83
|
+
const nextClass = queue[0].klass;
|
|
84
|
+
const carried = [];
|
|
85
|
+
for (const waiter of queue) {
|
|
86
|
+
if (waiter.klass === nextClass) {
|
|
87
|
+
admit(waiter.klass, waiter.resolve);
|
|
88
|
+
} else {
|
|
89
|
+
carried.push(waiter);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
queue.length = 0;
|
|
93
|
+
queue.push(...carried);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
acquire,
|
|
98
|
+
release,
|
|
99
|
+
// Read-only accessors, exposed for assertions / telemetry only.
|
|
100
|
+
get activeClass() {
|
|
101
|
+
return activeClass;
|
|
102
|
+
},
|
|
103
|
+
get activeCount() {
|
|
104
|
+
return activeCount;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -26,10 +26,12 @@
|
|
|
26
26
|
* lt-dev `running-check-script` skill relies on: non-zero === failed).
|
|
27
27
|
*/
|
|
28
28
|
import { execSync, spawn } from "node:child_process";
|
|
29
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
|
|
33
|
+
import { createBuildTestGate } from "./build-test-gate.mjs";
|
|
34
|
+
|
|
33
35
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
36
|
const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
35
37
|
const SEQUENTIAL = process.argv.includes("--sequential") || process.argv.includes("--seq");
|
|
@@ -59,25 +61,118 @@ function fmtDuration(ms) {
|
|
|
59
61
|
return `${m}m ${Math.round(s - m * 60)}s`;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// ── Nuxt build-dir isolation for the check's OWN package-manager calls ───────
|
|
65
|
+
// The app's `build:check` / `typecheck:*` scripts pin `NUXT_BUILD_DIR` so a
|
|
66
|
+
// check never writes the `.nuxt/` a parked `nuxt dev` reads. One writer has no
|
|
67
|
+
// script to pin it in: `postinstall: nuxt prepare`. It inherits the env of
|
|
68
|
+
// whatever triggered the install — and this script triggers one on every run
|
|
69
|
+
// (the hoisted install below). Unpinned, that install rewrites
|
|
70
|
+
// `.nuxt/tsconfig.json` under a running dev server, which then type-checks
|
|
71
|
+
// without the `~`/`#` aliases and dies on code that is fine.
|
|
72
|
+
//
|
|
73
|
+
// Applied on TWO levels: as a textual prefix, so `buildGroups` stays a pure
|
|
74
|
+
// function a guard can assert against, AND as a real environment variable at
|
|
75
|
+
// spawn time. The prefix alone is not enough — a `VAR=value cmd` assignment
|
|
76
|
+
// binds only to the first simple command, so a step written with `;` or a
|
|
77
|
+
// leading `cd` would be reported as pinned and run unpinned. Both carry the
|
|
78
|
+
// same value, so they cannot disagree.
|
|
79
|
+
const CHECK_BUILD_DIR = ".nuxt-check";
|
|
80
|
+
|
|
81
|
+
// Deliberately narrow: a blanket prefix would override the dirs the package.json
|
|
82
|
+
// scripts pin themselves. These are the commands that run lifecycle hooks (or,
|
|
83
|
+
// for `audit`, may resolve after fixing) and so have no pin of their own.
|
|
84
|
+
//
|
|
85
|
+
// SINGLE SOURCE: `classify()` derives its install/audit branches from these very
|
|
86
|
+
// patterns, so "the pin predicate is at least as wide as the hoist predicate" is
|
|
87
|
+
// true BY CONSTRUCTION rather than by assertion. It used to be two hand-written
|
|
88
|
+
// regexes that were supposed to agree, and they did not: `classify()` matched a
|
|
89
|
+
// bare `\baudit\b`, which also catches `npx audit-ci`, `bash scripts/audit.sh`
|
|
90
|
+
// and `pnpm --filter api audit`. Those were hoisted (so no longer ordinary
|
|
91
|
+
// steps) but not pinned — and worse, `runAudit` appends ` --json` to whatever
|
|
92
|
+
// was labelled "audit", so the check failed on a flag it invented itself.
|
|
93
|
+
const PM = String.raw`(?:pnpm|npm|yarn|bun)`;
|
|
94
|
+
/** `<pm> install|ci|i` — including the `i` shorthand. */
|
|
95
|
+
export const PM_INSTALL = new RegExp(String.raw`\b${PM}\s+(?:install|ci|i)\b`);
|
|
96
|
+
/**
|
|
97
|
+
* A REAL package-manager audit (`pnpm audit`, `npm audit`, `yarn npm audit`).
|
|
98
|
+
*
|
|
99
|
+
* Only these are hoisted, because only these produce the JSON that `runAudit`
|
|
100
|
+
* parses — and it appends ` --json` to whatever it is handed. A project script
|
|
101
|
+
* that merely has "audit" in its name (`pnpm run audit:ci`) is a normal step:
|
|
102
|
+
* hoisting it fed it a flag it does not accept, so the check failed on an
|
|
103
|
+
* argument the wrapper invented.
|
|
104
|
+
*/
|
|
105
|
+
export const PM_AUDIT = new RegExp(String.raw`\b${PM}\s+audit\b`);
|
|
106
|
+
/** A project script whose name mentions audit or install — an ordinary step that still needs the pin. */
|
|
107
|
+
const PM_RUN_SCRIPT = new RegExp(String.raw`\b${PM}\s+run\s+\S*(?:audit|install)\S*`);
|
|
108
|
+
/** 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
|
+
);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Prefix a package-manager command with the check's isolated Nuxt build dir.
|
|
115
|
+
*
|
|
116
|
+
* Idempotent, and never overrides a pin the command already carries — the
|
|
117
|
+
* existing-pin test is NOT anchored to the start of the string, because the
|
|
118
|
+
* shape the nuxt starter actually ships is `cross-env NUXT_BUILD_DIR=… pnpm …`,
|
|
119
|
+
* which a `^` anchor does not see.
|
|
120
|
+
*/
|
|
121
|
+
export function pinCheckBuildDir(cmd) {
|
|
122
|
+
if (!PM_INVOCATION.test(cmd) || /(^|\s)NUXT_BUILD_DIR=/.test(cmd)) return cmd;
|
|
123
|
+
return `NUXT_BUILD_DIR=${CHECK_BUILD_DIR} ${cmd}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The environment a step needs beyond the inherited one.
|
|
128
|
+
*
|
|
129
|
+
* Mirrors the textual pin so a shell construct the prefix cannot reach (a `;`
|
|
130
|
+
* separator, a leading `cd`) still gets the isolated build dir. Only for
|
|
131
|
+
* commands the pin applies to — a step that pins itself keeps its own value,
|
|
132
|
+
* because the prefix check already declined to touch it.
|
|
133
|
+
*/
|
|
134
|
+
function stepEnv(step) {
|
|
135
|
+
return /(^|\s)NUXT_BUILD_DIR=/.test(step.cmd) ? { NUXT_BUILD_DIR: CHECK_BUILD_DIR } : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
62
138
|
// ── step classification ────────────────────────────────────────────────────
|
|
63
139
|
// Map a raw command from a `check` chain onto a stable kind + label so the
|
|
64
140
|
// report stays readable regardless of the underlying tool (oxfmt/oxlint/tsc/…).
|
|
65
141
|
function classify(cmd) {
|
|
66
142
|
const c = cmd.toLowerCase();
|
|
67
|
-
if (c.includes("vendor-freshness"))
|
|
68
|
-
return { fatal: false, kind: "vendor", label: "vendor-freshness" };
|
|
69
143
|
// Dependency install — hoisted to ONE workspace-level run (see buildGroups):
|
|
70
144
|
// api and app chains both start with `pnpm install --frozen-lockfile`, and
|
|
71
145
|
// running those CONCURRENTLY (parallel groups) mutates the same workspace
|
|
72
146
|
// node_modules from two processes at once.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
147
|
+
//
|
|
148
|
+
// Checked BEFORE `vendor-freshness`: that branch is a plain substring test, so
|
|
149
|
+
// a command that mentions it anywhere (`pnpm install --filter vendor-freshness`)
|
|
150
|
+
// used to short-circuit past this one and land in the ordinary step list, where
|
|
151
|
+
// nothing pins NUXT_BUILD_DIR for it.
|
|
152
|
+
//
|
|
153
|
+
// Both predicates come from the pin patterns above, so a command can never be
|
|
154
|
+
// hoisted-but-unpinned. See the SINGLE SOURCE note there.
|
|
155
|
+
if (PM_INSTALL.test(c)) return { fatal: true, kind: "install", label: "install" };
|
|
156
|
+
if (PM_AUDIT.test(c)) return { fatal: true, kind: "audit", label: "audit" };
|
|
157
|
+
if (c.includes("vendor-freshness"))
|
|
158
|
+
return { fatal: false, kind: "vendor", label: "vendor-freshness" };
|
|
76
159
|
if (c.includes("format:check") || c.includes("oxfmt"))
|
|
77
160
|
return { fatal: true, kind: "format", label: "format" };
|
|
78
161
|
if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
|
|
79
|
-
|
|
162
|
+
// A unit-only run is named explicitly, is short, and is not contention
|
|
163
|
+
// sensitive — it carries `light` so the build⊥test gate lets it through (see
|
|
164
|
+
// GATE_CLASS). A bare `pnpm test` is NOT assumed to be light: in the starters
|
|
165
|
+
// 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
|
+
return { fatal: true, kind: "test", label: "test", light: true };
|
|
168
|
+
if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:ci/.test(c))
|
|
80
169
|
return { fatal: true, kind: "test", label: "test" };
|
|
170
|
+
// `typecheck` runs vue-tsc / tsc, which saturates the machine just like a
|
|
171
|
+
// build — and it does NOT contain the substrings "build" or "tsc", so it used
|
|
172
|
+
// to fall through to `other` and run ungated, fully concurrent with the API
|
|
173
|
+
// e2e suite. The gate then paid its serialisation cost while the second
|
|
174
|
+
// heaviest CPU load in the chain still ran alongside the suite it protects.
|
|
175
|
+
if (/\btypecheck\b/.test(c)) return { fatal: true, kind: "build", label: "typecheck" };
|
|
81
176
|
if (c.includes("build") || c.includes("nuxt build") || c.includes("tsc"))
|
|
82
177
|
return { fatal: true, kind: "build", label: "build" };
|
|
83
178
|
if (c.includes("check-server-start") || c.includes("server-start"))
|
|
@@ -85,6 +180,21 @@ function classify(cmd) {
|
|
|
85
180
|
return { fatal: true, kind: "other", label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
|
|
86
181
|
}
|
|
87
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Which mutual-exclusion class a step belongs to, or null when it is ungated.
|
|
185
|
+
*
|
|
186
|
+
* The gate keeps CPU-heavy work off the contention-sensitive API e2e suite. It
|
|
187
|
+
* is deliberately CONSERVATIVE: a bare `test` step is treated as sensitive even
|
|
188
|
+
* though a project's may be light, because the two failure directions are not
|
|
189
|
+
* symmetric — too wide costs wall-clock, too narrow costs the flaky-suite bug
|
|
190
|
+
* this gate exists to fix (DEV-2524).
|
|
191
|
+
*/
|
|
192
|
+
export function gateClass(step) {
|
|
193
|
+
if (step.kind === "build") return "build";
|
|
194
|
+
if (step.kind === "test") return step.light ? null : "test";
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
88
198
|
// Rewrite a check-only format/lint command into its auto-fixing variant, so a
|
|
89
199
|
// `check` run repairs every fixable finding instead of only reporting it.
|
|
90
200
|
function toFixCommand(kind, cmd) {
|
|
@@ -120,9 +230,22 @@ function sumMatches(clean, re) {
|
|
|
120
230
|
// showed "16 passed" (unit only) while its 69 e2e tests ran unseen.
|
|
121
231
|
function parseVitest(out) {
|
|
122
232
|
const clean = stripAnsi(out);
|
|
123
|
-
|
|
233
|
+
let passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
|
|
124
234
|
const files = sumMatches(clean, /Test Files\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
|
|
125
|
-
|
|
235
|
+
let failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
|
|
236
|
+
// `node --test` (a chain may run one, e.g. over scripts/) reports in node:test format
|
|
237
|
+
// ("ℹ pass N" / "ℹ fail N", or "# pass N" under the TAP reporter), not Vitest's
|
|
238
|
+
// "Tests N passed". Without this fallback parseVitest returned null for it, so
|
|
239
|
+
// the gate tests went uncounted and a green run could show "Total 0 passed".
|
|
240
|
+
if (passed == null) {
|
|
241
|
+
passed = sumMatches(clean, /(?:^|\n)[^\S\n]*[#ℹ][^\S\n]+pass[^\S\n]+(\d+)\b/gi);
|
|
242
|
+
}
|
|
243
|
+
// Checked independently of `passed`: a node:test run that reports only
|
|
244
|
+
// failures has no `pass` line at all, and nesting this inside the branch above
|
|
245
|
+
// made those runs parse as "no tests" instead of as failures.
|
|
246
|
+
if (failed == null) {
|
|
247
|
+
failed = sumMatches(clean, /(?:^|\n)[^\S\n]*[#ℹ][^\S\n]+fail[^\S\n]+(\d+)\b/gi);
|
|
248
|
+
}
|
|
126
249
|
if (passed == null && files == null) return null;
|
|
127
250
|
return {
|
|
128
251
|
failed: failed ?? 0,
|
|
@@ -148,17 +271,48 @@ const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
|
|
|
148
271
|
// the command's own exit code, so `check` blocks precisely when a bare
|
|
149
272
|
// `<auditCmd>` would — never with a narrower scope than the chain. (The old
|
|
150
273
|
// hardcoded `--prod` hid devDependency vulns for library packages.)
|
|
274
|
+
/**
|
|
275
|
+
* How many findings are counted in `metadata.vulnerabilities` but absent from
|
|
276
|
+
* `advisories` — advisories suppressed via auditConfig.ignoreGhsas, plus (under
|
|
277
|
+
* pnpm) findings below `--audit-level`.
|
|
278
|
+
*
|
|
279
|
+
* `metadata.vulnerabilities` still counts suppressed advisories while
|
|
280
|
+
* `advisories` drops them, and that difference is the only signal separating an
|
|
281
|
+
* assessed advisory from a new one — without it the summary shows a permanent
|
|
282
|
+
* red "high 1" next to a green gate.
|
|
283
|
+
*
|
|
284
|
+
* Returns 0 when `advisories` is ABSENT rather than deriving from it. npm 7+
|
|
285
|
+
* emits `auditReportVersion: 2` with a `vulnerabilities` map and no `advisories`
|
|
286
|
+
* key at all, so deriving there made every finding — including a real,
|
|
287
|
+
* unassessed critical — look suppressed. That is exactly the confusion this
|
|
288
|
+
* accounting exists to prevent, produced in reverse.
|
|
289
|
+
*
|
|
290
|
+
* Named "unlisted", not "ignored": under pnpm the number also contains
|
|
291
|
+
* below-threshold findings nobody assessed. It is "counted but not listed" — an
|
|
292
|
+
* observation, not a claim about anyone's judgement.
|
|
293
|
+
*/
|
|
294
|
+
export function countUnlisted(parsed) {
|
|
295
|
+
const counts = parsed?.metadata?.vulnerabilities ?? null;
|
|
296
|
+
if (!parsed?.advisories) return 0;
|
|
297
|
+
const listed = Object.keys(parsed.advisories).length;
|
|
298
|
+
const counted = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
|
|
299
|
+
return Math.max(0, counted - listed);
|
|
300
|
+
}
|
|
301
|
+
|
|
151
302
|
async function runAudit(auditCmd) {
|
|
152
303
|
const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
|
|
153
|
-
const { code, out } = await capture(cmd, ROOT);
|
|
304
|
+
const { code, out } = await capture(cmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
|
|
154
305
|
let counts = null;
|
|
306
|
+
let unlisted = 0;
|
|
155
307
|
try {
|
|
156
|
-
|
|
308
|
+
const parsed = JSON.parse(out.slice(out.indexOf("{")));
|
|
309
|
+
counts = parsed?.metadata?.vulnerabilities ?? null;
|
|
310
|
+
unlisted = countUnlisted(parsed);
|
|
157
311
|
} catch {
|
|
158
312
|
/* fall through to raw reason */
|
|
159
313
|
}
|
|
160
314
|
const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
|
|
161
|
-
return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
|
|
315
|
+
return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total, unlisted };
|
|
162
316
|
}
|
|
163
317
|
|
|
164
318
|
// Watchdog: kill a TEST step whose child produces NO output for this long. A
|
|
@@ -220,9 +374,14 @@ function killTree(child, signal = "SIGTERM") {
|
|
|
220
374
|
// idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
|
|
221
375
|
// runs it unwatched. Only callers that KNOW the child streams progress (test
|
|
222
376
|
// steps) should pass a timeout — see runGroup.
|
|
223
|
-
function capture(cmd, cwd, idleTimeoutMs = 0) {
|
|
377
|
+
function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
|
|
224
378
|
return new Promise((resolve) => {
|
|
225
|
-
|
|
379
|
+
// `extraEnv` carries the build-dir pin as a real environment variable IN
|
|
380
|
+
// ADDITION to the textual prefix. A `VAR=value cmd` prefix binds only to the
|
|
381
|
+
// first simple command, so a step written with `;` or a leading `cd` would be
|
|
382
|
+
// reported as pinned and run unpinned. The env reaches every command in the
|
|
383
|
+
// string, and the prefix still wins where both apply (same value).
|
|
384
|
+
const child = spawn(cmd, { cwd, env: extraEnv ? { ...process.env, ...extraEnv } : process.env, shell: true });
|
|
226
385
|
RUNNING.add(child);
|
|
227
386
|
let out = "";
|
|
228
387
|
let idleTimer = null;
|
|
@@ -325,6 +484,42 @@ function statusLines(order, states) {
|
|
|
325
484
|
// ── project discovery + step grouping ────────────────────────────────────────
|
|
326
485
|
const IS_ORCHESTRATOR = (script) => !script || script.includes("check.mjs");
|
|
327
486
|
|
|
487
|
+
/**
|
|
488
|
+
* True when a command re-enters `check` across workspace members.
|
|
489
|
+
*
|
|
490
|
+
* Such a command must be stripped from the root chain: this wrapper ALREADY
|
|
491
|
+
* runs every member as its own group, so letting the fan-out through runs them
|
|
492
|
+
* a second time — and, because the root group runs under the same
|
|
493
|
+
* `Promise.all`, CONCURRENTLY with the wrapper's own member groups. That means
|
|
494
|
+
* two `pnpm install` against one node_modules (exactly what the install hoist
|
|
495
|
+
* exists to prevent), two builds writing the same build dir, and two API e2e
|
|
496
|
+
* suites sharing one database.
|
|
497
|
+
*
|
|
498
|
+
* A positive test for "re-enters check", not a match on one spelling: `run` is
|
|
499
|
+
* optional in pnpm (`pnpm -r check`), the scope may be given as `--filter`
|
|
500
|
+
* rather than `-r`, and npm/yarn/turbo/lerna/nx each spell it differently. The
|
|
501
|
+
* previous version required a literal `pnpm … run check` and let every other
|
|
502
|
+
* form survive.
|
|
503
|
+
*/
|
|
504
|
+
export function isRecursiveCheck(cmd) {
|
|
505
|
+
const c = cmd.toLowerCase();
|
|
506
|
+
if (!/\bcheck\b/.test(c)) return false;
|
|
507
|
+
// Fans out over workspace members. NOTE the `(?:^|\s)` rather than `\b`: there
|
|
508
|
+
// is no word boundary between a space and a `-`, so `\b-r` never matches
|
|
509
|
+
// anything — the flag forms have to be anchored on whitespace.
|
|
510
|
+
if (/(?:^|\s)(?:-r|--recursive|--filter\S*|--workspaces?|foreach)(?:\s|=|$)/.test(c)) {
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
// … or delegates to a monorepo task runner, which does the same.
|
|
514
|
+
return /(?:^|\s)(?:turbo|lerna|nx)(?:\s|$)/.test(c);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** True when this command is hoisted to a single workspace-level run. */
|
|
518
|
+
function isHoisted(cmd) {
|
|
519
|
+
const kind = classify(cmd).kind;
|
|
520
|
+
return kind === "install" || kind === "audit";
|
|
521
|
+
}
|
|
522
|
+
|
|
328
523
|
// Read the `packages:` globs from pnpm-workspace.yaml (monorepos). A simple
|
|
329
524
|
// value-list parse — enough for the globs lt projects use (e.g. `projects/*`).
|
|
330
525
|
function workspaceGlobs() {
|
|
@@ -412,12 +607,30 @@ function discoverProjects() {
|
|
|
412
607
|
if (chain) projects.push(asProject(rel, chain));
|
|
413
608
|
}
|
|
414
609
|
}
|
|
610
|
+
const root = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
611
|
+
const rootChain =
|
|
612
|
+
root.scripts?.["check:raw"] ??
|
|
613
|
+
(IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
|
|
415
614
|
if (projects.length === 0) {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
615
|
+
if (rootChain) projects.push(asProject(".", rootChain));
|
|
616
|
+
} else if (rootChain) {
|
|
617
|
+
// With members present, the root's own chain must not be dropped: beyond
|
|
618
|
+
// install/audit (hoisted later) and the member fan-out (replaced by the
|
|
619
|
+
// member expansion above) it may carry root-ONLY steps — in the assembled
|
|
620
|
+
// monorepo that is `check:workspace` / `check:pin`, which exist precisely
|
|
621
|
+
// for the case where members are present. Strip the fan-out command and
|
|
622
|
+
// keep whatever remains as a root project.
|
|
623
|
+
const ownSteps = rootChain
|
|
624
|
+
.split("&&")
|
|
625
|
+
.map((s) => s.trim())
|
|
626
|
+
.filter(Boolean)
|
|
627
|
+
.filter((c) => !isRecursiveCheck(c));
|
|
628
|
+
// Only when something is actually LEFT: a chain that reduces to nothing but
|
|
629
|
+
// hoisted steps would otherwise add an empty group that occupies a live-view
|
|
630
|
+
// row and reports a phantom success it never earned.
|
|
631
|
+
if (ownSteps.some((c) => !isHoisted(c))) {
|
|
632
|
+
projects.unshift(asProject(".", ownSteps.join(" && ")));
|
|
633
|
+
}
|
|
421
634
|
}
|
|
422
635
|
if (PROJECT_FILTERS.length)
|
|
423
636
|
return projects.filter((p) =>
|
|
@@ -429,7 +642,7 @@ function discoverProjects() {
|
|
|
429
642
|
// One group per project: its ordered, fix-mapped steps. The audit step is
|
|
430
643
|
// hoisted to a single workspace-level run; its EXACT command (scope + level +
|
|
431
644
|
// package manager) is captured so the run mirrors the chain's own audit.
|
|
432
|
-
function buildGroups(projects) {
|
|
645
|
+
export function buildGroups(projects) {
|
|
433
646
|
let auditCmd = null;
|
|
434
647
|
let installCmd = null;
|
|
435
648
|
const groups = projects.map((project) => {
|
|
@@ -439,19 +652,36 @@ function buildGroups(projects) {
|
|
|
439
652
|
.map((s) => s.trim())
|
|
440
653
|
.filter(Boolean)) {
|
|
441
654
|
const meta = classify(raw);
|
|
655
|
+
const pinned = pinCheckBuildDir(raw);
|
|
656
|
+
// Both kinds are hoisted to ONE workspace-level run and every further
|
|
657
|
+
// occurrence is dropped — deliberately, and regardless of how it is
|
|
658
|
+
// spelled: in a pnpm workspace each member's install resolves the whole
|
|
659
|
+
// workspace anyway, so a second one is redundant, and running two
|
|
660
|
+
// concurrently races on the same node_modules. The same holds for the
|
|
661
|
+
// audit, which is a workspace-wide question.
|
|
662
|
+
//
|
|
663
|
+
// This is safe to drop silently ONLY because `classify` now hoists just
|
|
664
|
+
// the real `<pm> install` / `<pm> audit` forms. A project script that
|
|
665
|
+
// merely mentions audit in its name stays an ordinary step, so a chain can
|
|
666
|
+
// no longer lose a gate here without a trace.
|
|
442
667
|
if (meta.kind === "audit") {
|
|
443
|
-
if (!auditCmd) auditCmd =
|
|
668
|
+
if (!auditCmd) auditCmd = pinned;
|
|
444
669
|
continue;
|
|
445
670
|
}
|
|
446
671
|
if (meta.kind === "install") {
|
|
447
|
-
|
|
448
|
-
// fan-out. In a pnpm workspace every member's install resolves the
|
|
449
|
-
// whole workspace anyway, and two parallel installs race on the same
|
|
450
|
-
// node_modules.
|
|
451
|
-
if (!installCmd) installCmd = raw;
|
|
672
|
+
if (!installCmd) installCmd = pinned;
|
|
452
673
|
continue;
|
|
453
674
|
}
|
|
454
|
-
|
|
675
|
+
// Pinned here TOO, not only on the two hoists. classify() routes every
|
|
676
|
+
// install and audit into a hoist, so for those this is redundant — but the
|
|
677
|
+
// step list also carries the non-hoisted remainder above, and both layers
|
|
678
|
+
// are idempotent (pinCheckBuildDir never double-prefixes and never
|
|
679
|
+
// overrides an existing pin), so defending it costs nothing.
|
|
680
|
+
steps.push({
|
|
681
|
+
...meta,
|
|
682
|
+
cmd: pinCheckBuildDir(toFixCommand(meta.kind, raw)),
|
|
683
|
+
cwd: project.dir,
|
|
684
|
+
});
|
|
455
685
|
}
|
|
456
686
|
return { project, steps };
|
|
457
687
|
});
|
|
@@ -460,22 +690,70 @@ function buildGroups(projects) {
|
|
|
460
690
|
|
|
461
691
|
// ── per-project runner ───────────────────────────────────────────────────────
|
|
462
692
|
// Runs a group's steps in order, recording results + live state. Stops early
|
|
463
|
-
// when another project already failed (abort.hit).
|
|
464
|
-
|
|
693
|
+
// when another project already failed (abort.hit). The `gate` keeps CPU-heavy
|
|
694
|
+
// steps (build, typecheck) from overlapping a contention-sensitive test suite
|
|
695
|
+
// across groups (DEV-2524, cause 2) — see build-test-gate.mjs.
|
|
696
|
+
async function runGroup(group, states, results, abort, gate) {
|
|
465
697
|
const rel = group.project.rel;
|
|
466
698
|
const st = states.get(rel);
|
|
467
699
|
const startedAt = Date.now();
|
|
468
700
|
for (const step of group.steps) {
|
|
469
701
|
if (abort.hit) return;
|
|
702
|
+
// A parallel `nuxt build` saturating the machine tips the API e2e suite's
|
|
703
|
+
// Better-Auth session validation into intermittent 401/500 (DEV-2524). Hold
|
|
704
|
+
// the two-class gate so heavy CPU work and a sensitive test suite never
|
|
705
|
+
// overlap across groups; same-class steps still run concurrently and every
|
|
706
|
+
// other step kind ignores the gate entirely.
|
|
707
|
+
const klass = gateClass(step);
|
|
708
|
+
let waited = 0;
|
|
709
|
+
if (klass) {
|
|
710
|
+
const queuedAt = Date.now();
|
|
711
|
+
st.current = `${step.label} (queued)`;
|
|
712
|
+
st.stepStart = queuedAt;
|
|
713
|
+
// Surface the wait in CI too: the step line below is only printed AFTER
|
|
714
|
+
// the acquire, so a gate-blocked group would otherwise emit nothing at all
|
|
715
|
+
// for the length of a full build and read like a hang.
|
|
716
|
+
if (!TTY) process.stdout.write(` ${C.dim("⋯")} ${shortRel(rel)} · ${step.label} ${C.dim("(queued)")}\n`);
|
|
717
|
+
await gate.acquire(klass);
|
|
718
|
+
waited = Date.now() - queuedAt;
|
|
719
|
+
// Another group may have failed while we waited — abort before starting.
|
|
720
|
+
//
|
|
721
|
+
// Not raced against an abort signal on purpose: the fatal path calls
|
|
722
|
+
// killAll(), which terminates the holder's process tree, so its capture()
|
|
723
|
+
// resolves, its finally releases, and this waiter is admitted within
|
|
724
|
+
// milliseconds. Racing would hand the queue a waiter that never releases
|
|
725
|
+
// its slot, which is the one way to actually deadlock the opposite class.
|
|
726
|
+
if (abort.hit) {
|
|
727
|
+
gate.release();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
470
731
|
st.current = step.label;
|
|
471
732
|
st.stepStart = Date.now();
|
|
472
733
|
if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
|
|
473
|
-
// Watchdog
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
734
|
+
// Watchdog on every GATED step, not just tests. A test runner streams output
|
|
735
|
+
// continuously, so prolonged silence == deadlocked workers; a build is
|
|
736
|
+
// normally left unwatched because it buffers. But a gated build holds a slot
|
|
737
|
+
// that blocks every test step in every other group, so a wedged one now
|
|
738
|
+
// hangs the whole run rather than just its own chain — it needs the same
|
|
739
|
+
// watchdog. Ungated steps still run unwatched.
|
|
740
|
+
const watch = step.kind === "test" || klass ? IDLE_TIMEOUT_MS : 0;
|
|
741
|
+
let code;
|
|
742
|
+
let out;
|
|
743
|
+
try {
|
|
744
|
+
({ code, out } = await capture(step.cmd, step.cwd, watch, stepEnv(step)));
|
|
745
|
+
} finally {
|
|
746
|
+
// Release on every exit path — normal completion or the fatal-failure
|
|
747
|
+
// return below. A leaked slot would deadlock the opposite class under
|
|
748
|
+
// Promise.all.
|
|
749
|
+
if (klass) gate.release();
|
|
750
|
+
}
|
|
477
751
|
const dur = Date.now() - st.stepStart;
|
|
478
752
|
const r = { dur, kind: step.kind, label: step.label, project: rel };
|
|
753
|
+
// Recorded separately from `dur`: the report must not hide where the
|
|
754
|
+
// wall-clock went. A step that waited 8 minutes behind another group's build
|
|
755
|
+
// and then ran for 2 is not a 2-minute step.
|
|
756
|
+
if (waited > 0) r.waited = waited;
|
|
479
757
|
if (step.kind === "test") r.tests = parseVitest(out);
|
|
480
758
|
if (step.kind === "lint") r.lint = parseLint(out);
|
|
481
759
|
results.push(r);
|
|
@@ -534,7 +812,7 @@ async function main() {
|
|
|
534
812
|
const t = Date.now();
|
|
535
813
|
if (!TTY) process.stdout.write(` ${C.dim("→")} install\n`);
|
|
536
814
|
else drawLive([`${C.cyan(FRAMES[0])} install`]);
|
|
537
|
-
const { code, out } = await capture(installCmd, ROOT);
|
|
815
|
+
const { code, out } = await capture(installCmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
|
|
538
816
|
const dur = Date.now() - t;
|
|
539
817
|
if (code !== 0) {
|
|
540
818
|
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
@@ -558,18 +836,18 @@ async function main() {
|
|
|
558
836
|
if (audit.blocking) {
|
|
559
837
|
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
560
838
|
const summary = audit.counts
|
|
561
|
-
? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
|
|
839
|
+
? `${audit.total} vuln (${renderVulnLine(audit.counts, audit.unlisted, true)})`
|
|
562
840
|
: "failed";
|
|
563
841
|
console.log(`${C.red("✗")} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
564
842
|
return fail(
|
|
565
843
|
`audit (${auditCmd})`,
|
|
566
|
-
audit.counts ? renderVulnLine(audit.counts) : audit.reason,
|
|
844
|
+
audit.counts ? renderVulnLine(audit.counts, audit.unlisted, true) : audit.reason,
|
|
567
845
|
started,
|
|
568
846
|
);
|
|
569
847
|
}
|
|
570
848
|
if (!TTY) {
|
|
571
849
|
process.stdout.write(
|
|
572
|
-
` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
850
|
+
` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts, audit.unlisted) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
573
851
|
);
|
|
574
852
|
}
|
|
575
853
|
// TTY success: NO permanent line — the live status view overwrites the audit
|
|
@@ -583,16 +861,20 @@ async function main() {
|
|
|
583
861
|
const order = groups.map((g) => g.project.rel);
|
|
584
862
|
const states = new Map(order.map((rel) => [rel, { current: "queued" }]));
|
|
585
863
|
const abort = { failure: null, hit: false };
|
|
864
|
+
// Serializes CPU-heavy `build` steps against the contention-sensitive `test`
|
|
865
|
+
// suites across groups so a parallel `nuxt build` can never destabilize the
|
|
866
|
+
// API-e2e run (DEV-2524). Inert in --sequential mode (steps never overlap).
|
|
867
|
+
const gate = createBuildTestGate();
|
|
586
868
|
const ticker = TTY ? setInterval(() => drawLive(statusLines(order, states)), 80) : null;
|
|
587
869
|
if (TTY) drawLive(statusLines(order, states));
|
|
588
870
|
|
|
589
871
|
if (SEQUENTIAL) {
|
|
590
872
|
for (const g of groups) {
|
|
591
|
-
await runGroup(g, states, results, abort);
|
|
873
|
+
await runGroup(g, states, results, abort, gate);
|
|
592
874
|
if (abort.hit) break;
|
|
593
875
|
}
|
|
594
876
|
} else {
|
|
595
|
-
await Promise.all(groups.map((g) => runGroup(g, states, results, abort)));
|
|
877
|
+
await Promise.all(groups.map((g) => runGroup(g, states, results, abort, gate)));
|
|
596
878
|
}
|
|
597
879
|
|
|
598
880
|
if (ticker) clearInterval(ticker);
|
|
@@ -605,13 +887,27 @@ async function main() {
|
|
|
605
887
|
}
|
|
606
888
|
|
|
607
889
|
// ── rendering helpers ─────────────────────────────────────────────────────────
|
|
608
|
-
|
|
609
|
-
|
|
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) => {
|
|
610
904
|
const n = counts[s] || 0;
|
|
611
905
|
const txt = `${s} ${n}`;
|
|
612
|
-
if (n
|
|
613
|
-
|
|
906
|
+
if (n === 0 || allUnlisted) return C.dim(txt);
|
|
907
|
+
if (s === "critical" || s === "high") return C.red(txt);
|
|
908
|
+
return C.yellow(txt);
|
|
614
909
|
}).join(C.dim(" · "));
|
|
910
|
+
return unlisted > 0 ? `${line}${C.dim(` (${unlisted} not listed)`)}` : line;
|
|
615
911
|
}
|
|
616
912
|
|
|
617
913
|
function metricSuffix(r) {
|
|
@@ -619,6 +915,11 @@ function metricSuffix(r) {
|
|
|
619
915
|
const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : "";
|
|
620
916
|
return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ""}`)}${failed}`;
|
|
621
917
|
}
|
|
918
|
+
if (r.waited != null && r.waited >= 1000) {
|
|
919
|
+
// The gate wait is NOT part of `dur`, so without this the report would show
|
|
920
|
+
// a two-minute step that actually occupied ten minutes of wall-clock.
|
|
921
|
+
return ` ${C.dim(`queued ${fmtDuration(r.waited)}`)}`;
|
|
922
|
+
}
|
|
622
923
|
if (r.kind === "lint" && r.lint) {
|
|
623
924
|
return r.lint.warnings > 0
|
|
624
925
|
? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? "" : "s"}`)}`
|
|
@@ -655,17 +956,36 @@ function report(started, results) {
|
|
|
655
956
|
console.log(C.green(bar));
|
|
656
957
|
|
|
657
958
|
console.log(`\n${C.bold("Steps")}`);
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
959
|
+
const steps = results.filter((x) => x.kind !== "audit");
|
|
960
|
+
// Group by project when more than one is involved: workspace-level steps
|
|
961
|
+
// (hoisted install/audit, root-only checks) under "monorepo", then one block
|
|
962
|
+
// per member. Steps within a project run sequentially, so per-group order is
|
|
963
|
+
// chain order. A single-project run keeps the flat list — a header is noise.
|
|
964
|
+
const stepGroups = [...new Set(steps.map((r) => r.project))].sort((a, b) =>
|
|
965
|
+
a === "." ? -1 : b === "." ? 1 : shortRel(a).localeCompare(shortRel(b)),
|
|
966
|
+
);
|
|
967
|
+
if (stepGroups.length > 1) {
|
|
968
|
+
for (const project of stepGroups) {
|
|
969
|
+
console.log(` ${C.bold(project === "." ? "monorepo" : shortRel(project))}`);
|
|
970
|
+
for (const r of steps.filter((x) => x.project === project)) {
|
|
971
|
+
console.log(
|
|
972
|
+
` ${C.green("✓")} ${r.label.padEnd(24)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
} else {
|
|
977
|
+
for (const r of steps) {
|
|
978
|
+
console.log(
|
|
979
|
+
` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
662
982
|
}
|
|
663
983
|
|
|
664
984
|
console.log(
|
|
665
985
|
`\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`,
|
|
666
986
|
);
|
|
667
987
|
console.log(
|
|
668
|
-
` ${audit?.counts ? renderVulnLine(audit.counts) : C.dim(audit ? "counts unavailable" : "—")}`,
|
|
988
|
+
` ${audit?.counts ? renderVulnLine(audit.counts, audit.unlisted, audit.blocking) : C.dim(audit ? "counts unavailable" : "—")}`,
|
|
669
989
|
);
|
|
670
990
|
|
|
671
991
|
console.log(`\n${C.bold("Tests")}`);
|
|
@@ -691,7 +1011,57 @@ function report(started, results) {
|
|
|
691
1011
|
console.log(`\n${C.green("All checks passed.")}\n`);
|
|
692
1012
|
}
|
|
693
1013
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1014
|
+
// Run only when invoked as the CLI (`node scripts/check.mjs`). Importing this
|
|
1015
|
+
// module must never kick off a full check run — a sibling test does exactly
|
|
1016
|
+
// that to assert the pure helpers, where the project has one. (Do not name a
|
|
1017
|
+
// specific test file here: a project scaffolded by `lt fullstack init` ships
|
|
1018
|
+
// one, a project migrated by `lt fullstack update` does not, and naming it
|
|
1019
|
+
// tells half the readers to look for something that was never installed.)
|
|
1020
|
+
//
|
|
1021
|
+
// Split into a pure DECISION and its side effect on purpose. With the
|
|
1022
|
+
// `process.exit(1)` inlined, the fail-closed branch was unreachable from a test
|
|
1023
|
+
// (it would take the test process down with it), so nothing caught a regression
|
|
1024
|
+
// that turned it into a silent `return false` — which is exactly the "green gate
|
|
1025
|
+
// that never ran" this guard exists to prevent.
|
|
1026
|
+
export function resolveCliEntry(entry = process.argv[1], self = fileURLToPath(import.meta.url)) {
|
|
1027
|
+
if (!entry) return { isEntry: false };
|
|
1028
|
+
try {
|
|
1029
|
+
return { isEntry: realpathSync(entry) === realpathSync(self) };
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
// "Cannot tell" is NOT "not the entry" — the caller must fail closed.
|
|
1032
|
+
return { isEntry: false, unresolvable: err };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function isCliEntry() {
|
|
1037
|
+
const { isEntry, unresolvable } = resolveCliEntry();
|
|
1038
|
+
if (unresolvable) {
|
|
1039
|
+
// Fail CLOSED. Treating this as "not the CLI" would make `node
|
|
1040
|
+
// scripts/check.mjs` print nothing and exit 0 — a green gate that never ran.
|
|
1041
|
+
process.stderr.write(
|
|
1042
|
+
`[check] cannot resolve the CLI entry (${unresolvable?.code || unresolvable}) — refusing to report success\n`,
|
|
1043
|
+
);
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
return isEntry;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (isCliEntry()) {
|
|
1050
|
+
// Never leave the child tree behind. Without this, Ctrl-C or a crash detaches
|
|
1051
|
+
// every running `pnpm test` / build / e2e fork pool: they keep the test
|
|
1052
|
+
// database and ports held, and the next run fails for a reason that has
|
|
1053
|
+
// nothing to do with the code.
|
|
1054
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
1055
|
+
process.on(signal, () => {
|
|
1056
|
+
killAll();
|
|
1057
|
+
// Conventional 128+n, and it makes the interruption distinguishable from
|
|
1058
|
+
// an ordinary failure.
|
|
1059
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
main().catch((err) => {
|
|
1063
|
+
killAll();
|
|
1064
|
+
console.error(C.red(`\ncheck.mjs crashed: ${err?.stack || err}`));
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
});
|
|
1067
|
+
}
|