@lenne.tech/cli 1.46.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postinstall.js +28 -5
- package/build/commands/dev/doctor.js +45 -3
- package/build/commands/fullstack/update.js +31 -0
- package/build/commands/git/force-pull.js +11 -2
- package/build/commands/git/rebase.js +11 -1
- package/build/commands/git/reset.js +36 -4
- package/build/commands/git/update.js +42 -6
- package/build/extensions/api-mode.js +32 -8
- package/build/extensions/git.js +23 -25
- package/build/extensions/server.js +24 -17
- package/build/lib/check-template-sync.js +127 -0
- package/build/lib/claude-cli.js +14 -25
- package/build/lib/codex-cli.js +24 -31
- package/build/lib/dev-api-launch.js +58 -6
- package/build/lib/dev-process.js +66 -30
- package/build/lib/dev-state.js +13 -2
- package/build/lib/dev-test-session.js +85 -20
- package/build/lib/git-env.js +46 -0
- package/build/lib/heal-check-wrapper.js +128 -11
- package/build/lib/heal-oxlintrc.js +232 -0
- package/build/lib/platform.js +107 -0
- package/build/lib/plugin-utils.js +9 -8
- package/build/lib/strip-vendor-schema-augmentation.js +13 -2
- package/build/lib/vendor-claude-md.js +9 -6
- package/build/lib/workspace-integration.js +12 -2
- package/build/templates/check/check.mjs +270 -268
- package/build/templates/check/lib/ansi.mjs +22 -0
- package/build/templates/check/lib/audit-report.mjs +412 -0
- package/build/templates/check/lib/workspace-packages.mjs +150 -0
- package/build/templates/check/lt-monorepo-pin.json +13 -0
- package/docs/commands.md +1 -11
- package/package.json +13 -14
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI helpers, shared by `check.mjs` and the audit-report renderer.
|
|
3
|
+
*
|
|
4
|
+
* Extracted so `audit-report.mjs` can be imported by a test without pulling in
|
|
5
|
+
* `check.mjs`, which runs `main()` at module scope.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const C = {
|
|
9
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
10
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
11
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
12
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
13
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
14
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Matching ESC is the entire point of this function: it removes the SGR sequences `C` above
|
|
18
|
+
// writes, so tests can assert on the text and `fail()` can print captured child output without
|
|
19
|
+
// escape noise. Rewriting it to dodge the rule (a RegExp built from `String.fromCharCode(27)`)
|
|
20
|
+
// would hide that intent from the reader in order to satisfy a linter.
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
export const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit summary accounting and rendering.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so a test can import these functions. `check.mjs` calls `main()` at
|
|
5
|
+
* module scope, so importing IT would run the whole check as a side effect. The alternative —
|
|
6
|
+
* exporting from `check.mjs` behind an `import.meta.url === argv[1]` entry-point guard — was
|
|
7
|
+
* rejected deliberately: when such a guard mis-fires (a symlinked bin, a `realpath` difference)
|
|
8
|
+
* `check.mjs` becomes a silent no-op that exits 0, which is a permanently green gate that runs
|
|
9
|
+
* nothing. A wrong display is worth less than a wrong gate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Deliberately NOT the same shape as nest-server's, and that is worth knowing before "fixing" it.
|
|
14
|
+
*
|
|
15
|
+
* nest-server's `scripts/check.mjs` solves the same problem the other way round: it DERIVES the
|
|
16
|
+
* displayed counts from `advisories` and reports a separate `ignored` total, so its line reads
|
|
17
|
+
* "high 0" plus "1 ignored". Here (and in nuxt-extensions) the raw tally stays authoritative and
|
|
18
|
+
* the gap is reported beside it as `unlisted`. Both avoid the original defect — a green check
|
|
19
|
+
* printed next to a permanent "high 1" — and both are guarded against the missing `advisories`
|
|
20
|
+
* key. The labels differ accordingly (`ignored` there, `not listed` here).
|
|
21
|
+
*
|
|
22
|
+
* Two designs, one concern, on purpose: nest-server's display is its own and was hardened rather
|
|
23
|
+
* than swapped out, because rewriting a sibling repo's report format is a bigger change than the
|
|
24
|
+
* bug being fixed. If the two are ever unified, unify them deliberately — the difference is a
|
|
25
|
+
* decision, not drift.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execSync } from 'node:child_process';
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
import { C } from './ansi.mjs';
|
|
33
|
+
|
|
34
|
+
export const SEVERITIES = ['critical', 'high', 'moderate', 'low', 'info'];
|
|
35
|
+
|
|
36
|
+
/** Sum a `metadata.vulnerabilities`-shaped object across the severities we report. */
|
|
37
|
+
export function sumSeverities(counts) {
|
|
38
|
+
return SEVERITIES.reduce((n, s) => n + (counts?.[s] || 0), 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The audit could not RUN — as opposed to running and finding vulnerabilities.
|
|
43
|
+
*
|
|
44
|
+
* The difference decides whether `check` blocks. A finding is something a person can act on; an
|
|
45
|
+
* unreachable advisory service is not, and no version bump fixes it. Blocking on the latter paints
|
|
46
|
+
* every run red for an outage nobody can do anything about, which is how people learn to ignore a
|
|
47
|
+
* red audit — the one failure mode an audit gate cannot survive.
|
|
48
|
+
*
|
|
49
|
+
* Note this does NOT weaken `.gitlab-ci.yml`'s documented stance that a red audit job must block.
|
|
50
|
+
* That rule is about ADVISORIES ("a red audit means a NEW advisory nobody has assessed yet"), and
|
|
51
|
+
* it still holds: a real finding always yields parseable `metadata`/`advisories` and stays fatal.
|
|
52
|
+
* "The service was unreachable" was never the case that rule was written about.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately a SIGNATURE match, never "the output did not parse". The loose rule would also
|
|
55
|
+
* swallow a genuine audit failure whose output merely happens to be unparseable, and then the gate
|
|
56
|
+
* would be green for a reason nobody chose. Callers ask only after failing to parse counts.
|
|
57
|
+
*/
|
|
58
|
+
export function isAuditEndpointUnavailable(out) {
|
|
59
|
+
// 1. npm retired `/-/npm/v1/security/audits/quick` and `/audits` (both 410) while pnpm still
|
|
60
|
+
// called them. Every project everywhere exited non-zero with nothing reported.
|
|
61
|
+
if (/ERR_PNPM_AUDIT_BAD_RESPONSE/.test(out) || (/\baudit\b/i.test(out) && /\bretired\b/i.test(out))) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. The WORKING bulk endpoint failing transiently. Observed 2026-09-04:
|
|
66
|
+
// `registry.npmjs.org` answered 200 in 0.24s while
|
|
67
|
+
// `/-/npm/v1/security/advisories/bulk` timed out, and pnpm surfaced its own envelope:
|
|
68
|
+
//
|
|
69
|
+
// {"error":{"code":23,"message":"The operation was aborted due to timeout"}}
|
|
70
|
+
//
|
|
71
|
+
// It reded the check in nest-server, nest-server-starter and nuxt-extensions inside one hour.
|
|
72
|
+
// Matched on the envelope rather than on the exit code, so an auth failure (401/403) or a
|
|
73
|
+
// malformed request stays fatal — those are configuration, not weather.
|
|
74
|
+
let envelope;
|
|
75
|
+
try {
|
|
76
|
+
envelope = JSON.parse(out.slice(out.indexOf('{')))?.error;
|
|
77
|
+
} catch {
|
|
78
|
+
envelope = undefined;
|
|
79
|
+
}
|
|
80
|
+
if (envelope) {
|
|
81
|
+
const errorCode = envelope.code;
|
|
82
|
+
const errorMessage = String(envelope.message ?? '');
|
|
83
|
+
|
|
84
|
+
// Authentication and authorization are CONFIGURATION, never weather — and they must keep
|
|
85
|
+
// blocking. Checked first and explicitly, because the word list below would otherwise degrade
|
|
86
|
+
// them on a coincidence: `401 Unauthorized: session timeout` and `403 Forbidden, request
|
|
87
|
+
// aborted` both matched, which silently tolerates a broken registry credential. A misconfigured
|
|
88
|
+
// token that reports "vulnerabilities not checked" forever is worse than one that fails loudly.
|
|
89
|
+
if (errorCode === 401 || errorCode === 403 || /^\s*(unauthorized|forbidden)\b/i.test(errorMessage)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A 5xx is read from the numeric CODE only. Matching `\b5\d\d\b` in free text degraded on any
|
|
94
|
+
// number in that range that happened to appear in a message — `audited 503 packages` was enough.
|
|
95
|
+
if (typeof errorCode === 'number' && errorCode >= 500 && errorCode < 600) return true;
|
|
96
|
+
|
|
97
|
+
const message = `${errorCode ?? ''} ${errorMessage}`;
|
|
98
|
+
// `fetch failed` is undici's generic transport error, and it is what pnpm actually surfaces
|
|
99
|
+
// when the registry connection is refused — measured 2026-09-04 against an unreachable
|
|
100
|
+
// registry: `{"error":{"code":"pnpm","message":"fetch failed"}}`. It was missing here, because
|
|
101
|
+
// this list was assembled from the observed TIMEOUT case alone and never tested against a
|
|
102
|
+
// refusal. Widening is safe: the list is consulted ONLY on an `error` envelope, and a real
|
|
103
|
+
// audit report carries no `error` key — so no advisory whose title mentions a timeout or a
|
|
104
|
+
// failed fetch can reach this test.
|
|
105
|
+
return /\btimeout\b|\baborted\b|fetch failed|socket hang up|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ENOTFOUND|EAI_AGAIN/i.test(
|
|
106
|
+
message,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The advisory endpoint of the registry pnpm ACTUALLY uses — not of npmjs.org.
|
|
114
|
+
*
|
|
115
|
+
* The probe that resolves an ambiguous audit has to ask the same service the audit asked. Pointing
|
|
116
|
+
* it at `registry.npmjs.org` while pnpm resolves against a private registry or a proxy reproduces
|
|
117
|
+
* the exact bug it exists to prevent, one layer deeper: pnpm fails silently against the configured
|
|
118
|
+
* registry, the probe asks npmjs.org, npmjs.org answers, and the run reports a green tick. A
|
|
119
|
+
* false all-clear inside the safeguard against false all-clears.
|
|
120
|
+
*
|
|
121
|
+
* Kept pure and separate from the lookup so a test can reach it. `registry` is whatever
|
|
122
|
+
* `pnpm config get registry` returns, trailing slash or not.
|
|
123
|
+
*/
|
|
124
|
+
export function advisoryBulkUrl(registry) {
|
|
125
|
+
const base = String(registry || '').trim() || 'https://registry.npmjs.org';
|
|
126
|
+
return `${base.replace(/\/+$/, '')}/-/npm/v1/security/advisories/bulk`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What `pnpm config get registry` reports, or npmjs.org when that cannot be asked.
|
|
131
|
+
*
|
|
132
|
+
* Falling back rather than failing: an unreadable pnpm config is not a reason to skip the check,
|
|
133
|
+
* and the default is what pnpm itself would use.
|
|
134
|
+
*/
|
|
135
|
+
export function configuredRegistry(env = process.env) {
|
|
136
|
+
// Environment FIRST, and this order is not cosmetic. pnpm honours `npm_config_registry` for the
|
|
137
|
+
// audit itself, but `pnpm config get registry` does NOT report it — measured 2026-09-04:
|
|
138
|
+
//
|
|
139
|
+
// npm_config_registry=http://127.0.0.1:9/ pnpm audit uses 127.0.0.1:9, fails
|
|
140
|
+
// npm_config_registry=http://127.0.0.1:9/ pnpm config get registry https://registry.npmjs.org/
|
|
141
|
+
//
|
|
142
|
+
// Asking pnpm alone therefore points the probe at npmjs.org while the audit talked to somewhere
|
|
143
|
+
// else. npmjs.org answers, the run concludes "no outage", and the green tick is back — the same
|
|
144
|
+
// false all-clear as a hardcoded URL, one layer further in. Found by nuxt-extensions-f7, whose
|
|
145
|
+
// end-to-end test was green and should not have been.
|
|
146
|
+
const fromEnv = env.npm_config_registry ?? env.NPM_CONFIG_REGISTRY;
|
|
147
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return execSync('pnpm config get registry', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
151
|
+
} catch {
|
|
152
|
+
return 'https://registry.npmjs.org';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What the CI audit gate should DO, given one audit attempt.
|
|
158
|
+
*
|
|
159
|
+
* Extracted so a test can reach it. It used to live inline in `check-audit.mjs`, where the only way
|
|
160
|
+
* to exercise it was to run a real `pnpm audit` — which means the branch that decides whether a
|
|
161
|
+
* security gate passes was, in practice, never tested. That is the shape this whole module exists
|
|
162
|
+
* to argue against.
|
|
163
|
+
*
|
|
164
|
+
* `serviceReachable` is passed in rather than probed here: the probe is I/O, the decision is not,
|
|
165
|
+
* and a decision that cannot be tested without a network is not much better than one that cannot be
|
|
166
|
+
* tested at all. Callers pass `undefined` when they have not asked — only the `ambiguous` verdict
|
|
167
|
+
* needs the answer.
|
|
168
|
+
*
|
|
169
|
+
* Verdicts:
|
|
170
|
+
* 'degraded-unreachable' the service could not be asked — warn, do not block
|
|
171
|
+
* 'degraded-unreadable' exit 0 with nothing parseable — warn, do not block, look locally
|
|
172
|
+
* 'fail' a real failure, or real findings — block
|
|
173
|
+
* 'ok' clean AND the service confirmed it answered
|
|
174
|
+
* 'ask-service' ambiguous, the caller must probe and call again with the answer
|
|
175
|
+
*/
|
|
176
|
+
export function auditVerdict({ code, out, parsed, serviceReachable, timedOut }) {
|
|
177
|
+
const counts = parsed?.metadata?.vulnerabilities ?? null;
|
|
178
|
+
|
|
179
|
+
// A hang and a known infrastructure signature are the same fact: it could not run.
|
|
180
|
+
if (timedOut || (code !== 0 && !counts && isAuditEndpointUnavailable(out))) return 'degraded-unreachable';
|
|
181
|
+
|
|
182
|
+
// Checked BEFORE the readability test and kept narrow: a non-zero exit without an infrastructure
|
|
183
|
+
// signature is a real failure and must block. Folding this together with the `!counts` case below
|
|
184
|
+
// is the obvious simplification and it would turn every genuine audit failure into a warning.
|
|
185
|
+
if (code !== 0) return 'fail';
|
|
186
|
+
|
|
187
|
+
// Exit 0 with nothing parseable. Not a finding (findings parse), not a failure (the exit says so),
|
|
188
|
+
// and it established nothing — it used to render as a green tick beside a literal `0`.
|
|
189
|
+
if (!counts) return 'degraded-unreadable';
|
|
190
|
+
|
|
191
|
+
if (isAuditResultAmbiguous(parsed)) {
|
|
192
|
+
if (serviceReachable === undefined) return 'ask-service';
|
|
193
|
+
return serviceReachable ? 'ok' : 'degraded-unreachable';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Exit 0 with a readable, unambiguous report: pnpm looked and did not object. NOT re-judged by
|
|
197
|
+
// counting severities here — a count above zero with exit 0 is precisely what `--audit-level`
|
|
198
|
+
// means, and blocking on it would make this gate STRICTER than a bare `pnpm audit`. Both
|
|
199
|
+
// `.gitlab-ci.yml` and `check.mjs` state the opposite as the contract: the gate blocks exactly
|
|
200
|
+
// when the bare command would, never on a threshold this wrapper invented. Real findings arrive
|
|
201
|
+
// as a non-zero exit and are caught above.
|
|
202
|
+
return 'ok';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* True when the report is indistinguishable from "the service was never reached".
|
|
207
|
+
*
|
|
208
|
+
* MEASURED 2026-09-04, with `/-/npm/v1/security/advisories/bulk` answering HTTP 000 after 25s
|
|
209
|
+
* while `registry.npmjs.org` answered 200 in 0.17s:
|
|
210
|
+
*
|
|
211
|
+
* pnpm audit --json -> exit 0
|
|
212
|
+
* metadata.vulnerabilities {info:0,low:0,moderate:0,high:0,critical:0}
|
|
213
|
+
* advisories {} (present, empty)
|
|
214
|
+
* NO error envelope
|
|
215
|
+
*
|
|
216
|
+
* pnpm fails OPEN: it reports a clean tree when it could not ask. Byte for byte the same report a
|
|
217
|
+
* genuinely clean repository produces, so nothing in the JSON can tell them apart — which is why
|
|
218
|
+
* `isAuditEndpointUnavailable` cannot help here. That function keys on a non-zero exit and an error
|
|
219
|
+
* envelope, and this failure has neither.
|
|
220
|
+
*
|
|
221
|
+
* The consequence is the worst shape a gate can take: `✓ audit critical 0 · high 0 · …`, exit 0,
|
|
222
|
+
* in `check` AND in the CI audit job, while nothing was verified. A hang would at least be loud.
|
|
223
|
+
*
|
|
224
|
+
* So this only says "ambiguous", never "broken". The caller resolves it by asking whether the
|
|
225
|
+
* advisory service was actually reachable — the one question the report cannot answer about itself.
|
|
226
|
+
*/
|
|
227
|
+
export function isAuditResultAmbiguous(parsed) {
|
|
228
|
+
if (!parsed?.advisories) return false; // npm-v2 shape: a different report, judged elsewhere
|
|
229
|
+
if (Object.keys(parsed.advisories).length > 0) return false; // it listed something — it answered
|
|
230
|
+
return sumSeverities(parsed?.metadata?.vulnerabilities) === 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Per severity: how many findings are counted in `metadata.vulnerabilities` but absent from
|
|
235
|
+
* `advisories`.
|
|
236
|
+
*
|
|
237
|
+
* Two causes produce this gap, and the report cannot tell them apart:
|
|
238
|
+
* 1. advisories suppressed via `auditConfig.ignoreGhsas` — somebody assessed those.
|
|
239
|
+
* 2. findings below pnpm's `--audit-level` — nobody has looked at those.
|
|
240
|
+
* Which is why the number is called "unlisted" and never "ignored": it is an observation
|
|
241
|
+
* ("counted, but not in the list"), not a claim about anyone's judgement. `assessedSeverities`
|
|
242
|
+
* below is where that distinction is finally acted on.
|
|
243
|
+
*
|
|
244
|
+
* Cause 2 is NOT hypothetical and NOT opt-in. pnpm's default `--audit-level` is `low`, not
|
|
245
|
+
* `info` (`AUDIT_LEVEL_NUMBER = { info: 0, low: 1, ... }`, filter `severity >= auditLevel`), so
|
|
246
|
+
* `info` findings are dropped from `advisories` while still being counted — under a bare
|
|
247
|
+
* `pnpm audit`, with no flag and no config. Any repo can hit this today.
|
|
248
|
+
*
|
|
249
|
+
* Returns all-zero when `advisories` is ABSENT rather than deriving from it. npm 7+ emits
|
|
250
|
+
* `auditReportVersion: 2` with a `vulnerabilities` map and no `advisories` key at all, so
|
|
251
|
+
* deriving the counts there would make every finding — including a real, unassessed critical —
|
|
252
|
+
* look suppressed. That is the confusion this accounting exists to prevent, produced in reverse,
|
|
253
|
+
* which is why the raw tally stays authoritative and this is reported beside it.
|
|
254
|
+
*
|
|
255
|
+
* Note `advisories: {}` (present, empty) is NOT the same as absent: pnpm emits it on every clean
|
|
256
|
+
* run and on every run where the threshold filtered everything out. Only `undefined` means "this
|
|
257
|
+
* package manager does not report advisories at all".
|
|
258
|
+
*/
|
|
259
|
+
export function countUnlistedBySeverity(parsed) {
|
|
260
|
+
const empty = Object.fromEntries(SEVERITIES.map((s) => [s, 0]));
|
|
261
|
+
if (!parsed?.advisories) return empty;
|
|
262
|
+
|
|
263
|
+
const counts = parsed?.metadata?.vulnerabilities ?? null;
|
|
264
|
+
const listed = { ...empty };
|
|
265
|
+
for (const advisory of Object.values(parsed.advisories)) {
|
|
266
|
+
const severity = advisory?.severity;
|
|
267
|
+
if (severity in listed) listed[severity] += 1;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Clamped per severity: a package manager that counts vulnerable PATHS while listing one entry
|
|
271
|
+
// per advisory would otherwise produce negatives. pnpm counts one per advisory (verified: 25
|
|
272
|
+
// counted = 25 listed across 29 paths), and performs this same subtraction itself.
|
|
273
|
+
return Object.fromEntries(SEVERITIES.map((s) => [s, Math.max(0, (counts?.[s] || 0) - listed[s])]));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Total across severities — the single number the summary line annotates. */
|
|
277
|
+
export function countUnlisted(parsed) {
|
|
278
|
+
return sumSeverities(countUnlistedBySeverity(parsed));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* How many advisories this workspace suppresses via `auditConfig.ignoreGhsas`.
|
|
283
|
+
*
|
|
284
|
+
* This is the ONLY evidence that an unlisted finding was actually assessed by a human, and it is
|
|
285
|
+
* what separates cause 1 from cause 2 above. It cannot be recovered from the audit report: a
|
|
286
|
+
* suppressed advisory disappears from `advisories` completely, leaving nothing that says who
|
|
287
|
+
* removed it or why.
|
|
288
|
+
*
|
|
289
|
+
* Deliberately narrow. It counts GHSA ids that appear as list entries under an `ignoreGhsas:`
|
|
290
|
+
* key, and ignores commented-out lines — the sibling base repos document RETIRED suppressions in
|
|
291
|
+
* comments right below that key, and counting those would claim an assessment that was withdrawn.
|
|
292
|
+
* Anything it cannot parse returns 0, which renders the numbers loud: the safe direction.
|
|
293
|
+
*/
|
|
294
|
+
export function countSuppressions(root) {
|
|
295
|
+
for (const file of ['pnpm-workspace.yaml', 'package.json']) {
|
|
296
|
+
let text;
|
|
297
|
+
try {
|
|
298
|
+
text = readFileSync(join(root, file), 'utf8');
|
|
299
|
+
} catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Located by LINE, not by character offset. `text.search(/(^|\n)…/)` returns the index of the
|
|
303
|
+
// preceding newline, so slicing there and dropping one line landed back on the
|
|
304
|
+
// `ignoreGhsas:` line itself — neither a comment nor an entry, so the block loop broke on its
|
|
305
|
+
// first iteration and returned 0. That only showed up when the key is nested (under
|
|
306
|
+
// `auditConfig:`), which is every real workspace; a top-level key happened to work because
|
|
307
|
+
// the `^` branch matches at index 0.
|
|
308
|
+
const lines = text.split('\n');
|
|
309
|
+
const keyIdx = lines.findIndex((l) => /^\s*"?ignoreGhsas"?\s*:/.test(l));
|
|
310
|
+
if (keyIdx === -1) continue;
|
|
311
|
+
|
|
312
|
+
// Inline form: `ignoreGhsas: [GHSA-x, GHSA-y]` or the JSON equivalent.
|
|
313
|
+
const inline = lines[keyIdx].match(/ignoreGhsas"?\s*:\s*\[([^\]]*)\]/);
|
|
314
|
+
if (inline) return (inline[1].match(/GHSA-[0-9a-z]+(?:-[0-9a-z]+)*/gi) || []).length;
|
|
315
|
+
|
|
316
|
+
// Block form: subsequent `- GHSA-...` lines, stopping at the first line that is neither a
|
|
317
|
+
// list entry nor a comment.
|
|
318
|
+
let found = 0;
|
|
319
|
+
for (const line of lines.slice(keyIdx + 1)) {
|
|
320
|
+
if (/^\s*#/.test(line) || !line.trim()) continue;
|
|
321
|
+
const entry = line.match(/^\s*-\s*"?(GHSA-[0-9a-z]+(?:-[0-9a-z]+)*)"?/i);
|
|
322
|
+
if (!entry) break;
|
|
323
|
+
found += 1;
|
|
324
|
+
}
|
|
325
|
+
return found;
|
|
326
|
+
}
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Which severities were ASSESSED — every finding in the row suppressed by a human decision.
|
|
332
|
+
*
|
|
333
|
+
* Named for the state, not for what the renderer does with it: it used to be `assessedSeverities`,
|
|
334
|
+
* which baked one presentation into the name and then outlived it. Three conditions, all necessary:
|
|
335
|
+
*
|
|
336
|
+
* - The gate passed. On a FAILING run, "you already looked at this" is exactly the wrong thing
|
|
337
|
+
* to say, whatever the derivation suggests.
|
|
338
|
+
* - At least one suppression is configured. Without one, every unlisted finding is below the
|
|
339
|
+
* threshold and nobody has assessed anything — dimming there produces the very "teaches the
|
|
340
|
+
* reader to ignore the number" failure this accounting exists to prevent, pointed the other
|
|
341
|
+
* way. This is the condition that keeps the label honest.
|
|
342
|
+
* - The severity is ENTIRELY unlisted. A row with one listed and two unlisted findings still
|
|
343
|
+
* holds something live, so it stays loud.
|
|
344
|
+
*
|
|
345
|
+
* Per severity rather than all-or-nothing, because a single scalar cannot say WHICH row was
|
|
346
|
+
* assessed: `critical 3 · moderate 1 (3 not listed)` reads as "the criticals are handled" whether
|
|
347
|
+
* or not that is true.
|
|
348
|
+
*
|
|
349
|
+
* What the renderer does with this is YELLOW, not grey — changed 2026-09-04, matching
|
|
350
|
+
* nest-server-starter ("never hide a suppression: a silently filtered advisory is
|
|
351
|
+
* indistinguishable from one that never existed"). The evidence is concrete: a suppression in
|
|
352
|
+
* nest-server sat obsolete for FIVE WEEKS because the backport landed one day after the check and
|
|
353
|
+
* the greyed-out row never drew a second look.
|
|
354
|
+
*
|
|
355
|
+
* The honest counter-argument, recorded because it is not wrong: a permanently yellow `high 1` is
|
|
356
|
+
* wallpaper too, just quieter, and the original complaint was desensitisation. What actually keeps
|
|
357
|
+
* a suppression from rotting is `check-overrides.mjs`, which re-checks every suppressed GHSA
|
|
358
|
+
* against the advisory API and reds when a fix has appeared. The colour is the reminder; that guard
|
|
359
|
+
* is the safeguard. Yellow is right HERE because this repo runs it — a repo without it gets the
|
|
360
|
+
* reminder and not the safeguard.
|
|
361
|
+
*/
|
|
362
|
+
export function assessedSeverities({ blocking = false, counts, suppressions = 0, unlisted }) {
|
|
363
|
+
if (blocking || suppressions <= 0) return new Set();
|
|
364
|
+
return new Set(SEVERITIES.filter((s) => (counts?.[s] || 0) > 0 && (unlisted?.[s] || 0) >= (counts?.[s] || 0)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The vulnerability summary line.
|
|
369
|
+
*
|
|
370
|
+
* Takes the whole audit record rather than positional arguments: every call site already has one,
|
|
371
|
+
* and a defaulted `blocking` parameter fails OPEN — an omitted argument is `undefined`, `!undefined`
|
|
372
|
+
* is true, and dimming would be permitted on exactly the failing run the rules above forbid it on.
|
|
373
|
+
* (Dropping the default would not help; `undefined` is still falsy.)
|
|
374
|
+
*
|
|
375
|
+
* Note what `blocking` does and does not suppress: it gates the DIMMING only. The annotation is
|
|
376
|
+
* appended either way, because "3 of these are not in the list" stays true and useful on a failing
|
|
377
|
+
* run — it is the colour that would be making the false claim there, not the count.
|
|
378
|
+
*/
|
|
379
|
+
export function renderVulnLine(audit) {
|
|
380
|
+
const counts = audit?.counts ?? {};
|
|
381
|
+
const unlisted = audit?.unlisted ?? {};
|
|
382
|
+
const assessed = assessedSeverities({
|
|
383
|
+
blocking: audit?.blocking,
|
|
384
|
+
counts,
|
|
385
|
+
suppressions: audit?.suppressions,
|
|
386
|
+
unlisted,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const line = SEVERITIES.map((s) => {
|
|
390
|
+
const n = counts[s] || 0;
|
|
391
|
+
const txt = `${s} ${n}`;
|
|
392
|
+
if (n === 0) return C.dim(txt);
|
|
393
|
+
if (assessed.has(s)) return C.yellow(txt);
|
|
394
|
+
if (s === 'critical' || s === 'high') return C.red(txt);
|
|
395
|
+
return C.yellow(txt);
|
|
396
|
+
}).join(C.dim(' · '));
|
|
397
|
+
|
|
398
|
+
// "not listed", never "assessed": half the findings this number covers are below `--audit-level`
|
|
399
|
+
// and nobody has looked at them, so a label claiming assessment would be wrong for them — and it
|
|
400
|
+
// would contradict the docblock above, which is the whole reason the value is called `unlisted`.
|
|
401
|
+
// Same wording as lt-monorepo: this line is read by people, and one thing must not have two
|
|
402
|
+
// names across the base repos.
|
|
403
|
+
const total = sumSeverities(unlisted);
|
|
404
|
+
if (total === 0) return line;
|
|
405
|
+
|
|
406
|
+
// Name the severities when that is shorter than the reader guessing. A bare "(3 not listed)"
|
|
407
|
+
// next to a red row invites the wrong row to be read as the handled one.
|
|
408
|
+
const named = SEVERITIES.filter((s) => (unlisted[s] || 0) > 0)
|
|
409
|
+
.map((s) => `${unlisted[s]} ${s}`)
|
|
410
|
+
.join(', ');
|
|
411
|
+
return `${line}${C.dim(` (${named} not listed)`)}`;
|
|
412
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One reader for `pnpm-workspace.yaml`'s `packages:` list, shared by every guard
|
|
3
|
+
* that needs to know which directories are workspace members.
|
|
4
|
+
*
|
|
5
|
+
* There were three copies of this parse — `check.mjs`, `check-workspace-consistency.mjs`
|
|
6
|
+
* and (newest, and the weakest of the three) `check-ci-consistency.mjs`. They had already
|
|
7
|
+
* drifted: the two older ones stripped comments and tolerated a list whose dashes sit at
|
|
8
|
+
* column 0, the newest handled neither. Three parsers of one format do not stay equal, and
|
|
9
|
+
* the guards built on them fail in the direction that is hardest to notice — silently
|
|
10
|
+
* resolving nothing and then reporting that every rule holds.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately still a line reader rather than a YAML dependency: these scripts run before
|
|
13
|
+
* `pnpm install` has necessarily succeeded, so they may not import anything.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The raw `packages:` globs, in file order. `[]` when the file is absent, unreadable,
|
|
20
|
+
* or declares no `packages:` key — every caller treats "no globs" as "nothing to check",
|
|
21
|
+
* which is why the callers that can must SAY so rather than pass quietly.
|
|
22
|
+
*
|
|
23
|
+
* Accepts the three spellings pnpm accepts, because a workspace that is valid to pnpm and
|
|
24
|
+
* invisible to the guard is the worst of the two failure modes:
|
|
25
|
+
*
|
|
26
|
+
* packages: packages: packages: ['projects/*']
|
|
27
|
+
* - 'projects/*' - 'projects/*'
|
|
28
|
+
*
|
|
29
|
+
* The middle one — a block sequence indented to column 0 — is valid YAML and common, and
|
|
30
|
+
* is what the newest copy of this parser silently dropped: it treated the dash line as the
|
|
31
|
+
* next top-level key and stopped before reading anything.
|
|
32
|
+
*/
|
|
33
|
+
export function workspaceGlobs(root) {
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
37
|
+
} catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const globs = [];
|
|
42
|
+
let inside = false;
|
|
43
|
+
for (const raw of text.split('\n')) {
|
|
44
|
+
// Globs never contain `#`, so a naive strip is safe here and keeps both a whole
|
|
45
|
+
// comment line and a trailing one (`packages: # the members`) from being read as
|
|
46
|
+
// content — or, worse, as the end of the list.
|
|
47
|
+
const line = raw.replace(/#.*$/, '');
|
|
48
|
+
|
|
49
|
+
if (/^packages:\s*$/.test(line)) {
|
|
50
|
+
inside = true;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Flow style is a complete list on one line; there is no block to walk after it.
|
|
54
|
+
const flow = /^packages:\s*\[(.*)\]\s*$/.exec(line);
|
|
55
|
+
if (flow) {
|
|
56
|
+
for (const item of flow[1].split(',')) {
|
|
57
|
+
const value = item.trim().replace(/^(["'])([\s\S]*)\1$/, '$2');
|
|
58
|
+
if (value) globs.push(value);
|
|
59
|
+
}
|
|
60
|
+
return globs;
|
|
61
|
+
}
|
|
62
|
+
if (!inside) continue;
|
|
63
|
+
|
|
64
|
+
const entry = /^\s*-\s*['"]?([^'"\s]+)/.exec(line);
|
|
65
|
+
if (entry) {
|
|
66
|
+
globs.push(entry[1]);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// A non-empty line that is neither an entry nor indented is the next top-level key.
|
|
70
|
+
// Checked only AFTER the entry match, so a column-0 dash counts as list content.
|
|
71
|
+
if (line.trim() && !/^\s/.test(line)) break;
|
|
72
|
+
}
|
|
73
|
+
return globs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The directories one glob expands to, relative to `root` and always `/`-separated.
|
|
78
|
+
*
|
|
79
|
+
* Handles the two shapes lt workspaces use — `dir/*` and a literal path. Anything else
|
|
80
|
+
* (`**`, a mid-path star, a negation) expands to nothing rather than to a guess.
|
|
81
|
+
*
|
|
82
|
+
* Symlinked members count. `lt fullstack init --api-link` / `--frontend-link` make
|
|
83
|
+
* `projects/api` / `projects/app` symlinks into the developer's own checkout of the
|
|
84
|
+
* starter, and `Dirent.isDirectory()` reflects an lstat — it is FALSE for a symlink.
|
|
85
|
+
* Filtering on it alone made every link-mode workspace look empty, so all three guards
|
|
86
|
+
* resolved zero packages and reported that everything held.
|
|
87
|
+
*/
|
|
88
|
+
export function expandGlob(root, glob) {
|
|
89
|
+
const base = /^([^*]+)\/\*$/.exec(glob)?.[1];
|
|
90
|
+
if (!base) {
|
|
91
|
+
// A literal path is a member if it is there; a glob shape we do not model is not.
|
|
92
|
+
if (glob.includes('*')) return [];
|
|
93
|
+
return isDirectory(join(root, glob)) ? [glob] : [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = readdirSync(join(root, base), { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
out.push(`${base}/${entry.name}`);
|
|
106
|
+
} else if (entry.isSymbolicLink() && isDirectory(join(root, base, entry.name))) {
|
|
107
|
+
out.push(`${base}/${entry.name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every workspace member directory, relative to `root`, deduplicated and sorted. */
|
|
114
|
+
export function workspacePackageDirs(root) {
|
|
115
|
+
const dirs = new Set();
|
|
116
|
+
for (const glob of workspaceGlobs(root)) {
|
|
117
|
+
for (const dir of expandGlob(root, glob)) dirs.add(dir);
|
|
118
|
+
}
|
|
119
|
+
return [...dirs].sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Workspace package NAME -> its directory.
|
|
124
|
+
*
|
|
125
|
+
* The map a `--filter=<name>` call needs: the filter names a package, the caller needs a
|
|
126
|
+
* path. A member whose `package.json` is absent, unparseable, or nameless is left OUT —
|
|
127
|
+
* the caller then sees an unresolved name, which is the honest answer, and the callers
|
|
128
|
+
* that care report the unresolved case rather than swallowing it.
|
|
129
|
+
*/
|
|
130
|
+
export function packageDirsByName(root) {
|
|
131
|
+
const byName = new Map();
|
|
132
|
+
for (const dir of workspacePackageDirs(root)) {
|
|
133
|
+
try {
|
|
134
|
+
const name = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')).name;
|
|
135
|
+
if (name) byName.set(name, dir);
|
|
136
|
+
} catch {
|
|
137
|
+
// Deliberately not reported here: this helper's job is resolution, and a broken
|
|
138
|
+
// member surfaces through whichever guard actually reads that package.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return byName;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isDirectory(path) {
|
|
145
|
+
try {
|
|
146
|
+
return statSync(path).isDirectory();
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"commit": "61f8c9f9b3292cca71d9bfdfbf18e11d47a9a4a8",
|
|
3
|
+
"files": {
|
|
4
|
+
"build-test-gate.mjs": "865bf1b9dd28151f53b9f2dfe4c3c46d29b9303fb1c2f497ce0e3136bcd37d71",
|
|
5
|
+
"check.mjs": "783dce99aad1d2faf3d880184e43422c6b8433afcfa50c1f652b2d8ef6b2a1db",
|
|
6
|
+
"lib/ansi.mjs": "f997ee6e9bf1c7ee54ab612d4040fcc778064ec160eae19bf5c3960a75a6e2ff",
|
|
7
|
+
"lib/audit-report.mjs": "503825c3342dec1ee5d8d70e5af21b73e798faee320426b2492190cde0801df2",
|
|
8
|
+
"lib/workspace-packages.mjs": "ccdd133622a88186c924bc8271d35cf1f8357dc26d4a607998ca5e91fce5bf88"
|
|
9
|
+
},
|
|
10
|
+
"ref": "v3.13.0",
|
|
11
|
+
"repository": "lenneTech/lt-monorepo",
|
|
12
|
+
"version": "3.13.0"
|
|
13
|
+
}
|
package/docs/commands.md
CHANGED
|
@@ -1980,11 +1980,7 @@ lt claude plugins [plugin-name] [options]
|
|
|
1980
1980
|
|-----------|-------------|
|
|
1981
1981
|
| `plugin-name` | Optional. Name of a specific plugin to install |
|
|
1982
1982
|
|
|
1983
|
-
|
|
1984
|
-
| Option | Description |
|
|
1985
|
-
|--------|-------------|
|
|
1986
|
-
| `--list` | List available plugins from all marketplaces |
|
|
1987
|
-
| `--uninstall` | Uninstall a plugin |
|
|
1983
|
+
To list or uninstall installed plugins, use Claude Code's own `/plugin` menu.
|
|
1988
1984
|
|
|
1989
1985
|
**Plugin Sources:**
|
|
1990
1986
|
- [lenne-tech marketplace](https://github.com/lenneTech/claude-code) - lenne.Tech plugins for NestJS development
|
|
@@ -2001,12 +1997,6 @@ lt claude plugins
|
|
|
2001
1997
|
|
|
2002
1998
|
# Install a specific plugin
|
|
2003
1999
|
lt claude plugins typescript-lsp
|
|
2004
|
-
|
|
2005
|
-
# List available plugins
|
|
2006
|
-
lt claude plugins --list
|
|
2007
|
-
|
|
2008
|
-
# Uninstall a plugin
|
|
2009
|
-
lt claude plugins lt-dev --uninstall
|
|
2010
2000
|
```
|
|
2011
2001
|
|
|
2012
2002
|
---
|