@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.
@@ -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
- **Options:**
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
  ---