@lenne.tech/cli 1.35.1 → 1.37.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/build/commands/dev/doctor.js +55 -0
- package/build/commands/dev/prune.js +152 -0
- package/build/commands/dev/test.js +3 -0
- package/build/commands/dev/up.js +63 -0
- package/build/commands/fullstack/update.js +10 -0
- package/build/commands/ticket/start.js +108 -11
- package/build/commands/ticket/stop.js +177 -39
- package/build/lib/dev-prune.js +211 -0
- package/build/lib/dev-state.js +10 -9
- package/build/lib/dev-test-session.js +24 -0
- package/build/lib/dev-ticket.js +422 -14
- package/build/lib/heal-check-wrapper.js +33 -3
- package/build/lib/hoist-workspace-pnpm-config.js +110 -2
- package/build/lib/workspace-integration.js +15 -5
- package/build/templates/check/check.mjs +208 -23
- package/docs/commands.md +130 -0
- package/docs/lt-dev-ticket-workflow.html +8 -8
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hoistPackageManager = hoistPackageManager;
|
|
3
4
|
exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
|
|
4
5
|
const js_yaml_1 = require("js-yaml");
|
|
5
6
|
const fs_utils_1 = require("./fs-utils");
|
|
@@ -26,14 +27,92 @@ const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
|
26
27
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
27
28
|
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
|
|
28
29
|
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
30
|
+
/**
|
|
31
|
+
* Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
|
|
32
|
+
* root `package.json`, keeping the highest version and stripping the pin from
|
|
33
|
+
* every sub-project.
|
|
34
|
+
*
|
|
35
|
+
* Unlike the fields above this is a TOP-LEVEL package.json field (not part of the
|
|
36
|
+
* `pnpm` block), and its destination is the root `package.json` — not
|
|
37
|
+
* `pnpm-workspace.yaml` — because Corepack, not pnpm, reads it. Hence its own pass.
|
|
38
|
+
*
|
|
39
|
+
* Why it must not stay in a sub-project: inside a workspace only the ROOT pin governs
|
|
40
|
+
* `pnpm install`. A pin left in `projects/app` is worse than inert — Corepack resolves
|
|
41
|
+
* the NEAREST package.json, so `cd projects/app && pnpm run build` (exactly what
|
|
42
|
+
* projects/app/Dockerfile does) provisions the sub-project's pnpm while the root
|
|
43
|
+
* install ran on another version. One build, two pnpm versions.
|
|
44
|
+
*
|
|
45
|
+
* Why the root needs a pin at all: without `packageManager`, Corepack silently
|
|
46
|
+
* downloads the LATEST pnpm from the registry (verified with an isolated cache, i.e.
|
|
47
|
+
* a fresh container). Together with the root `engines.pnpm: "^11.0.0"` shipped by
|
|
48
|
+
* lt-monorepo, that breaks the day pnpm 12 is released — pnpm enforces `engines.pnpm`
|
|
49
|
+
* hard (`ERR_PNPM_UNSUPPORTED_ENGINE`), so the Docker build dies without a single
|
|
50
|
+
* repo change. The starters carry an exact pin incl. integrity hash
|
|
51
|
+
* (`pnpm@11.13.1+sha512.…`, maintained via `corepack up`); hoisting it preserves both
|
|
52
|
+
* the determinism and the supply-chain check.
|
|
53
|
+
*
|
|
54
|
+
* Mixed package managers (e.g. api pinning yarn, app pinning pnpm) are left untouched
|
|
55
|
+
* rather than silently picking a winner — that is a template bug, not something to
|
|
56
|
+
* paper over.
|
|
57
|
+
*
|
|
58
|
+
* Idempotent: running twice has the same effect as running once.
|
|
59
|
+
*
|
|
60
|
+
* @param options.filesystem Gluegun filesystem tool
|
|
61
|
+
* @param options.projectDir Workspace root (contains the root package.json)
|
|
62
|
+
* @param options.subProjects Sub-project dirs relative to projectDir
|
|
63
|
+
*/
|
|
64
|
+
function hoistPackageManager(options) {
|
|
65
|
+
const { filesystem, projectDir, subProjects } = options;
|
|
66
|
+
const rootPkgPath = `${projectDir}/package.json`;
|
|
67
|
+
const rootPkg = filesystem.exists(rootPkgPath) ? filesystem.read(rootPkgPath, 'json') : null;
|
|
68
|
+
if (!rootPkg)
|
|
69
|
+
return;
|
|
70
|
+
const candidates = [];
|
|
71
|
+
const strippedSubs = [];
|
|
72
|
+
for (const subDir of subProjects) {
|
|
73
|
+
const subPath = `${projectDir}/${subDir}`;
|
|
74
|
+
if (!filesystem.exists(subPath))
|
|
75
|
+
continue;
|
|
76
|
+
// Never mutate a symlinked sub-project — it points at the user's own checkout.
|
|
77
|
+
if ((0, fs_utils_1.isSymlink)(subPath))
|
|
78
|
+
continue;
|
|
79
|
+
const subPkgPath = `${subPath}/package.json`;
|
|
80
|
+
if (!filesystem.exists(subPkgPath))
|
|
81
|
+
continue;
|
|
82
|
+
const subPkg = filesystem.read(subPkgPath, 'json');
|
|
83
|
+
if (typeof (subPkg === null || subPkg === void 0 ? void 0 : subPkg.packageManager) !== 'string')
|
|
84
|
+
continue;
|
|
85
|
+
candidates.push(subPkg.packageManager);
|
|
86
|
+
strippedSubs.push({ path: subPkgPath, pkg: subPkg });
|
|
87
|
+
}
|
|
88
|
+
if (candidates.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const rootPin = typeof rootPkg.packageManager === 'string' ? rootPkg.packageManager : undefined;
|
|
91
|
+
const all = rootPin ? [rootPin, ...candidates] : candidates;
|
|
92
|
+
// Bail out on mixed managers instead of guessing which one is authoritative.
|
|
93
|
+
const names = new Set(all.map(pmName));
|
|
94
|
+
if (names.size > 1)
|
|
95
|
+
return;
|
|
96
|
+
const winner = all.reduce((best, pin) => (comparePmVersions(pin, best) > 0 ? pin : best));
|
|
97
|
+
if (rootPin !== winner) {
|
|
98
|
+
rootPkg.packageManager = winner;
|
|
99
|
+
filesystem.write(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
for (const { path, pkg } of strippedSubs) {
|
|
102
|
+
delete pkg.packageManager;
|
|
103
|
+
filesystem.write(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
29
106
|
/**
|
|
30
107
|
* Hoist workspace-scoped pnpm config from sub-projects into the monorepo
|
|
31
108
|
* root `pnpm-workspace.yaml`. After this runs, sub-project pnpm config
|
|
32
109
|
* (package.json#pnpm or a settings-only pnpm-workspace.yaml) is gone, and
|
|
33
110
|
* the root pnpm-workspace.yaml carries the merged union next to `packages:`.
|
|
34
111
|
*
|
|
35
|
-
* Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo
|
|
36
|
-
*
|
|
112
|
+
* Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo runs
|
|
113
|
+
* pnpm 11 (lt-monorepo ships `engines.pnpm: "^11.0.0"`; the exact version
|
|
114
|
+
* comes from the `packageManager` pin that `hoistPackageManager` lifts to
|
|
115
|
+
* the root), and pnpm 11 SILENTLY IGNORES the
|
|
37
116
|
* `pnpm` block in package.json — overrides/build-allowlists/etc. declared
|
|
38
117
|
* there never take effect, regressing `pnpm audit` and the minimum-release
|
|
39
118
|
* -age exemptions. pnpm-workspace.yaml is the pnpm-recommended home and is
|
|
@@ -93,6 +172,26 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
93
172
|
filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
|
|
94
173
|
}
|
|
95
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
|
|
177
|
+
* Returns >0 if `a` is newer, <0 if older, 0 if equal. Numeric segment-wise
|
|
178
|
+
* comparison; the integrity hash and any pre-release suffix are ignored, which is
|
|
179
|
+
* enough for the exact pins Corepack writes (no ranges are legal here).
|
|
180
|
+
*/
|
|
181
|
+
function comparePmVersions(a, b) {
|
|
182
|
+
var _a, _b;
|
|
183
|
+
const segments = (pin) => pmVersion(pin)
|
|
184
|
+
.split('.')
|
|
185
|
+
.map((s) => Number.parseInt(s, 10) || 0);
|
|
186
|
+
const av = segments(a);
|
|
187
|
+
const bv = segments(b);
|
|
188
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
189
|
+
const diff = ((_a = av[i]) !== null && _a !== void 0 ? _a : 0) - ((_b = bv[i]) !== null && _b !== void 0 ? _b : 0);
|
|
190
|
+
if (diff !== 0)
|
|
191
|
+
return diff;
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
96
195
|
/**
|
|
97
196
|
* Move the workspace-scoped pnpm fields from `source` into `rootWs`,
|
|
98
197
|
* deleting each moved field from `source`. Returns true if anything moved.
|
|
@@ -173,6 +272,15 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
173
272
|
const merged = Object.assign(Object.assign({}, rootObj), subObj);
|
|
174
273
|
return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
175
274
|
}
|
|
275
|
+
/** Extract the manager name from a pin (`pnpm@11.13.1+sha512.…` -> `pnpm`). */
|
|
276
|
+
function pmName(pin) {
|
|
277
|
+
return pin.slice(0, Math.max(0, pin.lastIndexOf('@'))) || pin;
|
|
278
|
+
}
|
|
279
|
+
/** Extract the bare version from a pin (`pnpm@11.13.1+sha512.…` -> `11.13.1`). */
|
|
280
|
+
function pmVersion(pin) {
|
|
281
|
+
const afterAt = pin.slice(pin.lastIndexOf('@') + 1);
|
|
282
|
+
return afterAt.split('+')[0];
|
|
283
|
+
}
|
|
176
284
|
/** Parse a YAML file into a plain object, or null on missing/malformed/non-object. */
|
|
177
285
|
function readYaml(filesystem, path) {
|
|
178
286
|
if (!filesystem.exists(path))
|
|
@@ -135,13 +135,17 @@ function detectWorkspaceLayout(workspaceDir, filesystem) {
|
|
|
135
135
|
}
|
|
136
136
|
/**
|
|
137
137
|
* Normalize a freshly-populated workspace root after adding or removing a
|
|
138
|
-
* sub-project. Runs the
|
|
138
|
+
* sub-project. Runs the four idempotent workspace-hygiene steps that
|
|
139
139
|
* `fullstack init`, `add-api`, and `add-app` all need:
|
|
140
140
|
*
|
|
141
141
|
* 1. hoist pnpm workspace-scoped config (`overrides`, `allowBuilds`, …) out
|
|
142
142
|
* of the sub-projects into the root — pnpm only honours it at the root;
|
|
143
|
-
* 2.
|
|
144
|
-
*
|
|
143
|
+
* 2. hoist the Corepack `packageManager` pin to the root — only the root pin
|
|
144
|
+
* governs the install, and a pin left in a sub-project makes
|
|
145
|
+
* `cd projects/app && pnpm run build` provision a different pnpm than the
|
|
146
|
+
* root install used;
|
|
147
|
+
* 3. remove nested `pnpm-lock.yaml` files the root lockfile supersedes;
|
|
148
|
+
* 4. guarantee a workspace-root `.dockerignore` (Docker never reads a
|
|
145
149
|
* sub-project's own `.dockerignore` when building from the root context).
|
|
146
150
|
*
|
|
147
151
|
* Each step is a no-op when there is nothing to do, so re-runs are safe.
|
|
@@ -151,6 +155,7 @@ function finalizeWorkspaceRoot(options) {
|
|
|
151
155
|
const { filesystem, projectDir } = options;
|
|
152
156
|
const subProjects = (_a = options.subProjects) !== null && _a !== void 0 ? _a : ['projects/api', 'projects/app'];
|
|
153
157
|
(0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects });
|
|
158
|
+
(0, hoist_workspace_pnpm_config_1.hoistPackageManager)({ filesystem, projectDir, subProjects });
|
|
154
159
|
(0, remove_nested_lockfiles_1.removeNestedLockfiles)({ filesystem, projectDir, subProjects });
|
|
155
160
|
(0, ensure_root_dockerignore_1.ensureRootDockerignore)({ filesystem, projectDir });
|
|
156
161
|
}
|
|
@@ -197,8 +202,13 @@ function findWorkspaceRoot(startDir, filesystem, maxDepth = 6) {
|
|
|
197
202
|
function isNonInteractive(noConfirmFlag) {
|
|
198
203
|
if (noConfirmFlag)
|
|
199
204
|
return true;
|
|
200
|
-
//
|
|
201
|
-
|
|
205
|
+
// Non-TTY stdin (pipes, scripts, CI, AI agents) must never sit on an
|
|
206
|
+
// interactive prompt. NOTE: Node leaves `isTTY` UNDEFINED (not false) on
|
|
207
|
+
// non-TTY streams, so the previous `isTTY === false` test classified every
|
|
208
|
+
// piped invocation as interactive — `lt ticket stop` then hung forever on its
|
|
209
|
+
// confirm prompt when run from a script. `!isTTY` treats undefined and false
|
|
210
|
+
// alike; a missing stdin (some test runners) cannot prompt either.
|
|
211
|
+
return !process.stdin || !process.stdin.isTTY;
|
|
202
212
|
}
|
|
203
213
|
/**
|
|
204
214
|
* Reconfigure the cloned nest-base template's `.claude/upstream.json`
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
|
|
26
26
|
* lt-dev `running-check-script` skill relies on: non-zero === failed).
|
|
27
27
|
*/
|
|
28
|
-
import { spawn } from "node:child_process";
|
|
28
|
+
import { execSync, spawn } from "node:child_process";
|
|
29
29
|
import { readdirSync, readFileSync } from "node:fs";
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
@@ -66,8 +66,14 @@ function classify(cmd) {
|
|
|
66
66
|
const c = cmd.toLowerCase();
|
|
67
67
|
if (c.includes("vendor-freshness"))
|
|
68
68
|
return { fatal: false, kind: "vendor", label: "vendor-freshness" };
|
|
69
|
+
// Dependency install — hoisted to ONE workspace-level run (see buildGroups):
|
|
70
|
+
// api and app chains both start with `pnpm install --frozen-lockfile`, and
|
|
71
|
+
// running those CONCURRENTLY (parallel groups) mutates the same workspace
|
|
72
|
+
// node_modules from two processes at once.
|
|
73
|
+
if (/\b(pnpm|npm|yarn|bun)\s+(install|ci)\b/.test(c))
|
|
74
|
+
return { fatal: true, kind: "install", label: "install" };
|
|
69
75
|
if (c.includes("audit")) return { fatal: true, kind: "audit", label: "audit" };
|
|
70
|
-
if (c.includes("format:check") || c.includes("oxfmt")
|
|
76
|
+
if (c.includes("format:check") || c.includes("oxfmt"))
|
|
71
77
|
return { fatal: true, kind: "format", label: "format" };
|
|
72
78
|
if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
|
|
73
79
|
if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:unit|test:ci/.test(c))
|
|
@@ -86,7 +92,6 @@ function toFixCommand(kind, cmd) {
|
|
|
86
92
|
if (kind === "format") {
|
|
87
93
|
if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, "format");
|
|
88
94
|
if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, "");
|
|
89
|
-
if (/\bprettier\b/.test(cmd)) return cmd.replace(/\s--check\b/, " --write");
|
|
90
95
|
return cmd;
|
|
91
96
|
}
|
|
92
97
|
if (kind === "lint") {
|
|
@@ -99,16 +104,30 @@ function toFixCommand(kind, cmd) {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
// ── metric parsers ─────────────────────────────────────────────────────────
|
|
107
|
+
// Sum capture group 1 across every match of `re` (which must carry the `g` flag).
|
|
108
|
+
// Returns null when nothing matched, so callers can tell "absent" from "zero".
|
|
109
|
+
function sumMatches(clean, re) {
|
|
110
|
+
let total = null;
|
|
111
|
+
for (const m of clean.matchAll(re)) {
|
|
112
|
+
const n = Number(m[1]);
|
|
113
|
+
if (Number.isFinite(n)) total = (total ?? 0) + n;
|
|
114
|
+
}
|
|
115
|
+
return total;
|
|
116
|
+
}
|
|
117
|
+
// A single test step may invoke vitest more than once (`test` is
|
|
118
|
+
// `vitest:unit && vitest`), emitting one summary block per run. Sum them all —
|
|
119
|
+
// reading only the first silently under-reports every later run: the api step
|
|
120
|
+
// showed "16 passed" (unit only) while its 69 e2e tests ran unseen.
|
|
102
121
|
function parseVitest(out) {
|
|
103
122
|
const clean = stripAnsi(out);
|
|
104
|
-
const
|
|
105
|
-
const files = clean
|
|
106
|
-
const failed = clean
|
|
107
|
-
if (
|
|
123
|
+
const passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
|
|
124
|
+
const files = sumMatches(clean, /Test Files\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
|
|
125
|
+
const failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
|
|
126
|
+
if (passed == null && files == null) return null;
|
|
108
127
|
return {
|
|
109
|
-
failed: failed
|
|
110
|
-
files
|
|
111
|
-
passed
|
|
128
|
+
failed: failed ?? 0,
|
|
129
|
+
files,
|
|
130
|
+
passed,
|
|
112
131
|
};
|
|
113
132
|
}
|
|
114
133
|
function parseLint(out) {
|
|
@@ -142,21 +161,105 @@ async function runAudit(auditCmd) {
|
|
|
142
161
|
return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
|
|
143
162
|
}
|
|
144
163
|
|
|
164
|
+
// Watchdog: kill a TEST step whose child produces NO output for this long. A
|
|
165
|
+
// wedged test run (workers idle at 0% CPU — e.g. one spec file grinding through
|
|
166
|
+
// retries after its app/socket state broke under load) otherwise spins the live
|
|
167
|
+
// view forever: the spinner only proves the child process exists, not that it
|
|
168
|
+
// progresses. Only test steps are watched: build / typecheck / audit
|
|
169
|
+
// legitimately buffer all their output to the end (and go silent under a
|
|
170
|
+
// non-TTY pipe), so watching them would false-kill a slow-but-progressing run.
|
|
171
|
+
// Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
|
|
172
|
+
// disables it.
|
|
173
|
+
const IDLE_TIMEOUT_MS = (() => {
|
|
174
|
+
const flag = process.argv.find((a) => a.startsWith("--idle-timeout="));
|
|
175
|
+
const raw = flag ? flag.slice("--idle-timeout=".length) : process.env.CHECK_IDLE_TIMEOUT;
|
|
176
|
+
const DEFAULT_MS = 300 * 1000;
|
|
177
|
+
if (raw === undefined || raw === "") return DEFAULT_MS;
|
|
178
|
+
const seconds = Number(raw);
|
|
179
|
+
if (seconds === 0) return 0; // explicit opt-out
|
|
180
|
+
// Invalid value (typo, unit suffix, negative) → keep the protection at its
|
|
181
|
+
// default rather than silently disabling it.
|
|
182
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
183
|
+
process.stderr.write(`[check] ignoring invalid idle-timeout "${raw}", using ${DEFAULT_MS / 1000}s\n`);
|
|
184
|
+
return DEFAULT_MS;
|
|
185
|
+
}
|
|
186
|
+
return seconds * 1000;
|
|
187
|
+
})();
|
|
188
|
+
|
|
145
189
|
// ── command runner ─────────────────────────────────────────────────────────
|
|
146
190
|
const RUNNING = new Set();
|
|
147
|
-
|
|
191
|
+
|
|
192
|
+
// Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
|
|
193
|
+
// fork workers). Killing only the direct child orphans the tree — exactly the
|
|
194
|
+
// zombie workers a deadlock leaves behind. Children are collected via pgrep
|
|
195
|
+
// and killed leaves-first.
|
|
196
|
+
function killTree(child, signal = "SIGTERM") {
|
|
197
|
+
const pids = [];
|
|
198
|
+
const collect = (pid) => {
|
|
199
|
+
pids.push(pid);
|
|
200
|
+
let out = "";
|
|
201
|
+
try {
|
|
202
|
+
out = execSync(`pgrep -P ${pid}`, { stdio: ["ignore", "pipe", "ignore"] })
|
|
203
|
+
.toString()
|
|
204
|
+
.trim();
|
|
205
|
+
} catch {
|
|
206
|
+
/* no children */
|
|
207
|
+
}
|
|
208
|
+
if (out) for (const p of out.split("\n")) collect(Number(p));
|
|
209
|
+
};
|
|
210
|
+
collect(child.pid);
|
|
211
|
+
for (const pid of pids.reverse()) {
|
|
212
|
+
try {
|
|
213
|
+
process.kill(pid, signal);
|
|
214
|
+
} catch {
|
|
215
|
+
/* already gone */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
|
|
221
|
+
// runs it unwatched. Only callers that KNOW the child streams progress (test
|
|
222
|
+
// steps) should pass a timeout — see runGroup.
|
|
223
|
+
function capture(cmd, cwd, idleTimeoutMs = 0) {
|
|
148
224
|
return new Promise((resolve) => {
|
|
149
225
|
const child = spawn(cmd, { cwd, shell: true });
|
|
150
226
|
RUNNING.add(child);
|
|
151
227
|
let out = "";
|
|
228
|
+
let idleTimer = null;
|
|
229
|
+
let killTimer = null;
|
|
230
|
+
let watchdogHit = false;
|
|
231
|
+
// Any output resets the watchdog — only complete silence for the full
|
|
232
|
+
// window counts as wedged. Escalate to SIGKILL for processes that ignore
|
|
233
|
+
// SIGTERM.
|
|
234
|
+
const armWatchdog = () => {
|
|
235
|
+
if (!idleTimeoutMs) return;
|
|
236
|
+
clearTimeout(idleTimer);
|
|
237
|
+
idleTimer = setTimeout(() => {
|
|
238
|
+
watchdogHit = true;
|
|
239
|
+
killTree(child);
|
|
240
|
+
killTimer = setTimeout(() => killTree(child, "SIGKILL"), 5000);
|
|
241
|
+
killTimer.unref();
|
|
242
|
+
}, idleTimeoutMs);
|
|
243
|
+
};
|
|
152
244
|
const onData = (d) => {
|
|
153
245
|
out += d;
|
|
246
|
+
armWatchdog();
|
|
154
247
|
if (VERBOSE) process.stdout.write(d);
|
|
155
248
|
};
|
|
249
|
+
armWatchdog();
|
|
156
250
|
child.stdout.on("data", onData);
|
|
157
251
|
child.stderr.on("data", onData);
|
|
158
252
|
const done = (code, extra) => {
|
|
253
|
+
clearTimeout(idleTimer);
|
|
254
|
+
clearTimeout(killTimer);
|
|
159
255
|
RUNNING.delete(child);
|
|
256
|
+
if (watchdogHit) {
|
|
257
|
+
const note =
|
|
258
|
+
`[watchdog] step produced no output for ${Math.round(idleTimeoutMs / 1000)}s — ` +
|
|
259
|
+
"process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
|
|
260
|
+
`not a slow run. Re-run the step directly to debug: \`${cmd}\``;
|
|
261
|
+
return resolve({ code: 1, out: `${out}\n${note}` });
|
|
262
|
+
}
|
|
160
263
|
resolve({ code, out: extra ? `${out}\n${extra}` : out });
|
|
161
264
|
};
|
|
162
265
|
child.on("close", (code) => done(code ?? 1));
|
|
@@ -166,13 +269,36 @@ function capture(cmd, cwd) {
|
|
|
166
269
|
function killAll() {
|
|
167
270
|
for (const child of RUNNING) {
|
|
168
271
|
try {
|
|
169
|
-
child
|
|
272
|
+
killTree(child);
|
|
170
273
|
} catch {
|
|
171
274
|
/* already gone */
|
|
172
275
|
}
|
|
173
276
|
}
|
|
174
277
|
}
|
|
175
278
|
|
|
279
|
+
// A child killed by a signal surfaces through the package manager as a
|
|
280
|
+
// "Command failed with exit code 143/137" line (SIGTERM/SIGKILL), NOT as a test
|
|
281
|
+
// assertion failure — and the outer shell then reports its own generic exit 1,
|
|
282
|
+
// so `code` alone never reveals it. Surface the signal so the reason isn't
|
|
283
|
+
// mistaken for a real failure: the usual cause is resource pressure (parallel
|
|
284
|
+
// checks/builds swapping the machine) or an external kill.
|
|
285
|
+
function signalExitHint(out) {
|
|
286
|
+
const clean = stripAnsi(out);
|
|
287
|
+
// The watchdog also kills via SIGTERM, so pnpm's "exit code 143" ends up in
|
|
288
|
+
// the output — but that path already carries its own [watchdog] note with the
|
|
289
|
+
// correct (deadlock) diagnosis. Don't stack a contradictory "external kill"
|
|
290
|
+
// hint on top of it.
|
|
291
|
+
if (/\[watchdog\]/.test(clean)) return null;
|
|
292
|
+
const m = clean.match(/Command failed with exit code (137|143)\b/);
|
|
293
|
+
if (!m) return null;
|
|
294
|
+
const sig = m[1] === "143" ? "SIGTERM" : "SIGKILL";
|
|
295
|
+
return (
|
|
296
|
+
`[check] step ended via ${sig} (exit ${m[1]}) — the process was killed, not an assertion failure. ` +
|
|
297
|
+
"Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. " +
|
|
298
|
+
"Re-run this project's check alone to confirm."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
176
302
|
// ── live multi-line status (one line per running project) ────────────────────
|
|
177
303
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
178
304
|
let liveCount = 0;
|
|
@@ -255,9 +381,23 @@ function asProject(rel, check) {
|
|
|
255
381
|
return { check, dir: rel === "." ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
|
|
256
382
|
}
|
|
257
383
|
|
|
258
|
-
// Workspace sub-projects
|
|
384
|
+
// Workspace sub-projects and their real check chain; if there are none (a
|
|
259
385
|
// single-package repo), fall back to the root project — whose real chain lives
|
|
260
386
|
// in `check:raw`, because the root `check` is THIS wrapper.
|
|
387
|
+
//
|
|
388
|
+
// A member's `check` is frequently THIS wrapper too: the lt starters ship their
|
|
389
|
+
// own scripts/check.mjs so they also work standalone (`lt server create`), and
|
|
390
|
+
// `lt fullstack init` clones them verbatim into projects/*. Treating that as
|
|
391
|
+
// "no real chain" silently dropped EVERY member — the run then fell back to the
|
|
392
|
+
// root, whose chain is just `pnpm -r run check`, and reported the whole
|
|
393
|
+
// monorepo as one opaque step with "no test step / 0 passed" while the members'
|
|
394
|
+
// tests were in fact running, unseen. So resolve a member exactly like the root:
|
|
395
|
+
// wrapper `check` means the real chain lives in `check:raw`.
|
|
396
|
+
function realChain(pkg) {
|
|
397
|
+
if (!IS_ORCHESTRATOR(pkg.scripts?.check)) return pkg.scripts?.check ?? null;
|
|
398
|
+
return pkg.scripts?.["check:raw"] ?? null;
|
|
399
|
+
}
|
|
400
|
+
|
|
261
401
|
function discoverProjects() {
|
|
262
402
|
const projects = [];
|
|
263
403
|
for (const glob of workspaceGlobs()) {
|
|
@@ -268,7 +408,8 @@ function discoverProjects() {
|
|
|
268
408
|
} catch {
|
|
269
409
|
continue;
|
|
270
410
|
}
|
|
271
|
-
|
|
411
|
+
const chain = realChain(pkg);
|
|
412
|
+
if (chain) projects.push(asProject(rel, chain));
|
|
272
413
|
}
|
|
273
414
|
}
|
|
274
415
|
if (projects.length === 0) {
|
|
@@ -290,6 +431,7 @@ function discoverProjects() {
|
|
|
290
431
|
// package manager) is captured so the run mirrors the chain's own audit.
|
|
291
432
|
function buildGroups(projects) {
|
|
292
433
|
let auditCmd = null;
|
|
434
|
+
let installCmd = null;
|
|
293
435
|
const groups = projects.map((project) => {
|
|
294
436
|
const steps = [];
|
|
295
437
|
for (const raw of project.check
|
|
@@ -301,11 +443,19 @@ function buildGroups(projects) {
|
|
|
301
443
|
if (!auditCmd) auditCmd = raw;
|
|
302
444
|
continue;
|
|
303
445
|
}
|
|
446
|
+
if (meta.kind === "install") {
|
|
447
|
+
// Hoisted like the audit: one workspace-level install BEFORE the
|
|
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;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
304
454
|
steps.push({ ...meta, cmd: toFixCommand(meta.kind, raw), cwd: project.dir });
|
|
305
455
|
}
|
|
306
456
|
return { project, steps };
|
|
307
457
|
});
|
|
308
|
-
return { auditCmd, groups };
|
|
458
|
+
return { auditCmd, groups, installCmd };
|
|
309
459
|
}
|
|
310
460
|
|
|
311
461
|
// ── per-project runner ───────────────────────────────────────────────────────
|
|
@@ -320,7 +470,10 @@ async function runGroup(group, states, results, abort) {
|
|
|
320
470
|
st.current = step.label;
|
|
321
471
|
st.stepStart = Date.now();
|
|
322
472
|
if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
|
|
323
|
-
|
|
473
|
+
// Watchdog only on test steps (see IDLE_TIMEOUT_MS): a test runner streams
|
|
474
|
+
// output continuously, so prolonged silence == deadlocked workers. Other
|
|
475
|
+
// steps buffer their output and must run unwatched.
|
|
476
|
+
const { code, out } = await capture(step.cmd, step.cwd, step.kind === "test" ? IDLE_TIMEOUT_MS : 0);
|
|
324
477
|
const dur = Date.now() - st.stepStart;
|
|
325
478
|
const r = { dur, kind: step.kind, label: step.label, project: rel };
|
|
326
479
|
if (step.kind === "test") r.tests = parseVitest(out);
|
|
@@ -330,7 +483,12 @@ async function runGroup(group, states, results, abort) {
|
|
|
330
483
|
st.failed = step.label;
|
|
331
484
|
if (!abort.hit) {
|
|
332
485
|
abort.hit = true;
|
|
333
|
-
|
|
486
|
+
const hint = signalExitHint(out);
|
|
487
|
+
abort.failure = {
|
|
488
|
+
out: hint ? `${out}\n${hint}` : out,
|
|
489
|
+
project: rel,
|
|
490
|
+
step: `${shortRel(rel)} · ${step.label}`,
|
|
491
|
+
};
|
|
334
492
|
killAll();
|
|
335
493
|
}
|
|
336
494
|
return;
|
|
@@ -352,8 +510,9 @@ async function main() {
|
|
|
352
510
|
console.error(C.red("No workspace projects with a `check` script found."));
|
|
353
511
|
process.exit(1);
|
|
354
512
|
}
|
|
355
|
-
const { auditCmd, groups } = buildGroups(projects);
|
|
356
|
-
const stepCount =
|
|
513
|
+
const { auditCmd, groups, installCmd } = buildGroups(projects);
|
|
514
|
+
const stepCount =
|
|
515
|
+
groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0) + (installCmd ? 1 : 0);
|
|
357
516
|
const mode = SEQUENTIAL ? "sequential" : "parallel";
|
|
358
517
|
const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
|
|
359
518
|
|
|
@@ -367,6 +526,26 @@ async function main() {
|
|
|
367
526
|
|
|
368
527
|
const results = [];
|
|
369
528
|
|
|
529
|
+
// Step -1 — single hoisted workspace install (before audit and fan-out).
|
|
530
|
+
// The member chains each start with their own `pnpm install --frozen-lockfile`;
|
|
531
|
+
// running it ONCE at the workspace root is equivalent and removes the race of
|
|
532
|
+
// two parallel installs mutating the same node_modules.
|
|
533
|
+
if (installCmd) {
|
|
534
|
+
const t = Date.now();
|
|
535
|
+
if (!TTY) process.stdout.write(` ${C.dim("→")} install\n`);
|
|
536
|
+
else drawLive([`${C.cyan(FRAMES[0])} install`]);
|
|
537
|
+
const { code, out } = await capture(installCmd, ROOT);
|
|
538
|
+
const dur = Date.now() - t;
|
|
539
|
+
if (code !== 0) {
|
|
540
|
+
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
541
|
+
console.log(`${C.red("✗")} install ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
542
|
+
return fail(`install (${installCmd})`, out, started);
|
|
543
|
+
}
|
|
544
|
+
if (!TTY) process.stdout.write(` ${C.green("✓")} install ${C.dim(`(${fmtDuration(dur)})`)}\n`);
|
|
545
|
+
// TTY success: no permanent line — see the audit block below.
|
|
546
|
+
results.push({ dur, kind: "step", label: "install", project: "." });
|
|
547
|
+
}
|
|
548
|
+
|
|
370
549
|
// Step 0 — single workspace audit (blocking gate, runs before the fan-out).
|
|
371
550
|
// Mirrors the chain's own audit command (scope/level/PM); skipped only when
|
|
372
551
|
// the chain has no audit step.
|
|
@@ -375,9 +554,9 @@ async function main() {
|
|
|
375
554
|
if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
|
|
376
555
|
else drawLive([`${C.cyan(FRAMES[0])} audit`]);
|
|
377
556
|
const audit = await runAudit(auditCmd);
|
|
378
|
-
liveCount = 0;
|
|
379
557
|
const dur = Date.now() - t;
|
|
380
558
|
if (audit.blocking) {
|
|
559
|
+
liveCount = 0; // the failure line must survive — nothing may overwrite it
|
|
381
560
|
const summary = audit.counts
|
|
382
561
|
? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
|
|
383
562
|
: "failed";
|
|
@@ -388,10 +567,16 @@ async function main() {
|
|
|
388
567
|
started,
|
|
389
568
|
);
|
|
390
569
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
570
|
+
if (!TTY) {
|
|
571
|
+
process.stdout.write(
|
|
572
|
+
` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
// TTY success: NO permanent line — the live status view overwrites the audit
|
|
576
|
+
// row (like every other step); the result lands in the report twice: the
|
|
577
|
+
// Steps list (entry below) and the Vulnerabilities section.
|
|
394
578
|
results.push({ audit, kind: "audit" });
|
|
579
|
+
results.push({ dur, kind: "step", label: "audit", project: "." });
|
|
395
580
|
}
|
|
396
581
|
|
|
397
582
|
// Per-project steps — parallel by default, serial with --sequential.
|