@lenne.tech/cli 1.32.1 → 1.34.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,512 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quiet, report-driven wrapper around the project `check` pipeline.
4
+ *
5
+ * Replaces the noisy `pnpm audit && pnpm -r --parallel run check` with:
6
+ * - a minimal live view — one status line per running project (spinner +
7
+ * current step), so you always see where the run is;
8
+ * - abort on the first failing step, printing the captured reason;
9
+ * - on success a report: the executed steps + their key metrics
10
+ * (vulnerabilities per level, test counts per area Unit/API/Playwright, …);
11
+ * - format + lint auto-fix every fixable finding (oxfmt writes, oxlint --fix);
12
+ * only non-fixable lint errors then remain and fail the run.
13
+ *
14
+ * Flags:
15
+ * --verbose / -v stream the full tool output live (deep debugging)
16
+ * --sequential/--seq run projects one after another (default: parallel)
17
+ * --no-fix read-only gate — do not auto-fix format/lint
18
+ * --project=<substr> restrict to matching workspace projects (repeatable)
19
+ *
20
+ * Design: the per-project `check` scripts stay the single source of truth for
21
+ * WHAT runs. This wrapper discovers each workspace project's `check` chain,
22
+ * splits it on `&&`, and runs the steps with status + metrics — so adding or
23
+ * removing a step in a project's `check` needs no change here.
24
+ *
25
+ * Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
26
+ * lt-dev `running-check-script` skill relies on: non-zero === failed).
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import { readdirSync, readFileSync } from "node:fs";
30
+ import { dirname, join } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+
33
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
34
+ const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
35
+ const SEQUENTIAL = process.argv.includes("--sequential") || process.argv.includes("--seq");
36
+ const NO_FIX = process.argv.includes("--no-fix");
37
+ const PROJECT_FILTERS = process.argv
38
+ .filter((a) => a.startsWith("--project="))
39
+ .map((a) => a.slice("--project=".length));
40
+ // Verbose streams raw output, so the in-place live view is disabled there.
41
+ const TTY = Boolean(process.stdout.isTTY) && !VERBOSE;
42
+
43
+ // ── tiny ANSI helpers ──────────────────────────────────────────────────────
44
+ const C = {
45
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
46
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
47
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
48
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
49
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
50
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
51
+ };
52
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
53
+ const shortRel = (rel) => rel.replace(/^projects\//, "");
54
+
55
+ function fmtDuration(ms) {
56
+ const s = ms / 1000;
57
+ if (s < 60) return `${s.toFixed(1)}s`;
58
+ const m = Math.floor(s / 60);
59
+ return `${m}m ${Math.round(s - m * 60)}s`;
60
+ }
61
+
62
+ // ── step classification ────────────────────────────────────────────────────
63
+ // Map a raw command from a `check` chain onto a stable kind + label so the
64
+ // report stays readable regardless of the underlying tool (oxfmt/oxlint/tsc/…).
65
+ function classify(cmd) {
66
+ const c = cmd.toLowerCase();
67
+ if (c.includes("vendor-freshness"))
68
+ return { fatal: false, kind: "vendor", label: "vendor-freshness" };
69
+ if (c.includes("audit")) return { fatal: true, kind: "audit", label: "audit" };
70
+ if (c.includes("format:check") || c.includes("oxfmt") || c.includes("prettier"))
71
+ return { fatal: true, kind: "format", label: "format" };
72
+ if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
73
+ if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:unit|test:ci/.test(c))
74
+ return { fatal: true, kind: "test", label: "test" };
75
+ if (c.includes("build") || c.includes("nuxt build") || c.includes("tsc"))
76
+ return { fatal: true, kind: "build", label: "build" };
77
+ if (c.includes("check-server-start") || c.includes("server-start"))
78
+ return { fatal: true, kind: "server", label: "server-start" };
79
+ return { fatal: true, kind: "other", label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
80
+ }
81
+
82
+ // Rewrite a check-only format/lint command into its auto-fixing variant, so a
83
+ // `check` run repairs every fixable finding instead of only reporting it.
84
+ function toFixCommand(kind, cmd) {
85
+ if (NO_FIX) return cmd;
86
+ if (kind === "format") {
87
+ if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, "format");
88
+ 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
+ return cmd;
91
+ }
92
+ if (kind === "lint") {
93
+ if (/\blint:fix\b/.test(cmd) || /--fix\b/.test(cmd)) return cmd;
94
+ if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/, "run lint:fix");
95
+ if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/, "oxlint --fix --fix-suggestions");
96
+ return cmd;
97
+ }
98
+ return cmd;
99
+ }
100
+
101
+ // ── metric parsers ─────────────────────────────────────────────────────────
102
+ function parseVitest(out) {
103
+ const clean = stripAnsi(out);
104
+ const tests = clean.match(/Tests\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
105
+ const files = clean.match(/Test Files\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
106
+ const failed = clean.match(/Tests\s+(\d+)\s+failed/i);
107
+ if (!tests && !files) return null;
108
+ return {
109
+ failed: failed ? Number(failed[1]) : 0,
110
+ files: files ? Number(files[2]) : null,
111
+ passed: tests ? Number(tests[2]) : null,
112
+ };
113
+ }
114
+ function parseLint(out) {
115
+ const clean = stripAnsi(out);
116
+ const summary = clean.match(/Found\s+(\d+)\s+warnings?(?:\s+and\s+(\d+)\s+errors?)?/i);
117
+ if (summary) return { errors: summary[2] ? Number(summary[2]) : 0, warnings: Number(summary[1]) };
118
+ return {
119
+ errors: (clean.match(/\berror\b/gi) || []).length,
120
+ warnings: (clean.match(/\bwarning\b/g) || []).length,
121
+ };
122
+ }
123
+
124
+ // ── audit (faithful: runs the project's OWN audit command) ──────────────────
125
+ const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
126
+
127
+ // Run the audit command exactly as the check chain defines it (same scope /
128
+ // --prod / --audit-level), only appending --json for the counts. The gate is
129
+ // the command's own exit code, so `check` blocks precisely when a bare
130
+ // `<auditCmd>` would — never with a narrower scope than the chain. (The old
131
+ // hardcoded `--prod` hid devDependency vulns for library packages.)
132
+ async function runAudit(auditCmd) {
133
+ const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
134
+ const { code, out } = await capture(cmd, ROOT);
135
+ let counts = null;
136
+ try {
137
+ counts = JSON.parse(out.slice(out.indexOf("{")))?.metadata?.vulnerabilities ?? null;
138
+ } catch {
139
+ /* fall through to raw reason */
140
+ }
141
+ const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
142
+ return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
143
+ }
144
+
145
+ // ── command runner ─────────────────────────────────────────────────────────
146
+ const RUNNING = new Set();
147
+ function capture(cmd, cwd) {
148
+ return new Promise((resolve) => {
149
+ const child = spawn(cmd, { cwd, shell: true });
150
+ RUNNING.add(child);
151
+ let out = "";
152
+ const onData = (d) => {
153
+ out += d;
154
+ if (VERBOSE) process.stdout.write(d);
155
+ };
156
+ child.stdout.on("data", onData);
157
+ child.stderr.on("data", onData);
158
+ const done = (code, extra) => {
159
+ RUNNING.delete(child);
160
+ resolve({ code, out: extra ? `${out}\n${extra}` : out });
161
+ };
162
+ child.on("close", (code) => done(code ?? 1));
163
+ child.on("error", (err) => done(1, err.message));
164
+ });
165
+ }
166
+ function killAll() {
167
+ for (const child of RUNNING) {
168
+ try {
169
+ child.kill("SIGTERM");
170
+ } catch {
171
+ /* already gone */
172
+ }
173
+ }
174
+ }
175
+
176
+ // ── live multi-line status (one line per running project) ────────────────────
177
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
178
+ let liveCount = 0;
179
+ let frame = 0;
180
+ function drawLive(lines) {
181
+ if (!TTY) return;
182
+ if (liveCount > 0) process.stdout.write(`\x1b[${liveCount}A`);
183
+ for (const l of lines) process.stdout.write(`\r\x1b[K${l}\n`);
184
+ liveCount = lines.length;
185
+ }
186
+ function statusLines(order, states) {
187
+ frame += 1;
188
+ return order.map((rel) => {
189
+ const s = states.get(rel);
190
+ if (s.failed) return `${C.red("✗")} ${shortRel(rel).padEnd(5)} ${C.red(`${s.failed} FAILED`)}`;
191
+ if (s.done)
192
+ return `${C.green("✓")} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
193
+ const spin = C.cyan(FRAMES[frame % FRAMES.length]);
194
+ const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) : "";
195
+ return `${spin} ${shortRel(rel).padEnd(5)} ${s.current || "queued"}${el}`;
196
+ });
197
+ }
198
+
199
+ // ── project discovery + step grouping ────────────────────────────────────────
200
+ const IS_ORCHESTRATOR = (script) => !script || script.includes("check.mjs");
201
+
202
+ // Read the `packages:` globs from pnpm-workspace.yaml (monorepos). A simple
203
+ // value-list parse — enough for the globs lt projects use (e.g. `projects/*`).
204
+ function workspaceGlobs() {
205
+ let text;
206
+ try {
207
+ text = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
208
+ } catch {
209
+ return [];
210
+ }
211
+ const globs = [];
212
+ let inPackages = false;
213
+ for (const raw of text.split("\n")) {
214
+ const line = raw.replace(/#.*$/, "");
215
+ if (/^packages:\s*$/.test(line)) {
216
+ inPackages = true;
217
+ continue;
218
+ }
219
+ if (inPackages) {
220
+ const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/);
221
+ if (m) globs.push(m[1]);
222
+ else if (line.trim() && !/^\s/.test(line)) break; // next top-level key
223
+ }
224
+ }
225
+ return globs;
226
+ }
227
+
228
+ // Expand a workspace glob to concrete directories (handles `dir/*` and literals).
229
+ function expandGlob(glob) {
230
+ if (glob.endsWith("/*")) {
231
+ const base = glob.slice(0, -2);
232
+ try {
233
+ return readdirSync(join(ROOT, base), { withFileTypes: true })
234
+ .filter((d) => d.isDirectory())
235
+ .map((d) => join(base, d.name));
236
+ } catch {
237
+ return [];
238
+ }
239
+ }
240
+ return [glob];
241
+ }
242
+
243
+ function asProject(rel, check) {
244
+ let pkg = {};
245
+ try {
246
+ pkg = JSON.parse(
247
+ readFileSync(
248
+ rel === "." ? join(ROOT, "package.json") : join(ROOT, rel, "package.json"),
249
+ "utf8",
250
+ ),
251
+ );
252
+ } catch {
253
+ /* keep defaults */
254
+ }
255
+ return { check, dir: rel === "." ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
256
+ }
257
+
258
+ // Workspace sub-projects whose `check` is a real chain; if there are none (a
259
+ // single-package repo), fall back to the root project — whose real chain lives
260
+ // in `check:raw`, because the root `check` is THIS wrapper.
261
+ function discoverProjects() {
262
+ const projects = [];
263
+ for (const glob of workspaceGlobs()) {
264
+ for (const rel of expandGlob(glob)) {
265
+ let pkg;
266
+ try {
267
+ pkg = JSON.parse(readFileSync(join(ROOT, rel, "package.json"), "utf8"));
268
+ } catch {
269
+ continue;
270
+ }
271
+ if (!IS_ORCHESTRATOR(pkg.scripts?.check)) projects.push(asProject(rel, pkg.scripts.check));
272
+ }
273
+ }
274
+ if (projects.length === 0) {
275
+ const root = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
276
+ const chain =
277
+ root.scripts?.["check:raw"] ??
278
+ (IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
279
+ if (chain) projects.push(asProject(".", chain));
280
+ }
281
+ if (PROJECT_FILTERS.length)
282
+ return projects.filter((p) =>
283
+ PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)),
284
+ );
285
+ return projects;
286
+ }
287
+
288
+ // One group per project: its ordered, fix-mapped steps. The audit step is
289
+ // hoisted to a single workspace-level run; its EXACT command (scope + level +
290
+ // package manager) is captured so the run mirrors the chain's own audit.
291
+ function buildGroups(projects) {
292
+ let auditCmd = null;
293
+ const groups = projects.map((project) => {
294
+ const steps = [];
295
+ for (const raw of project.check
296
+ .split("&&")
297
+ .map((s) => s.trim())
298
+ .filter(Boolean)) {
299
+ const meta = classify(raw);
300
+ if (meta.kind === "audit") {
301
+ if (!auditCmd) auditCmd = raw;
302
+ continue;
303
+ }
304
+ steps.push({ ...meta, cmd: toFixCommand(meta.kind, raw), cwd: project.dir });
305
+ }
306
+ return { project, steps };
307
+ });
308
+ return { auditCmd, groups };
309
+ }
310
+
311
+ // ── per-project runner ───────────────────────────────────────────────────────
312
+ // Runs a group's steps in order, recording results + live state. Stops early
313
+ // when another project already failed (abort.hit).
314
+ async function runGroup(group, states, results, abort) {
315
+ const rel = group.project.rel;
316
+ const st = states.get(rel);
317
+ const startedAt = Date.now();
318
+ for (const step of group.steps) {
319
+ if (abort.hit) return;
320
+ st.current = step.label;
321
+ st.stepStart = Date.now();
322
+ if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
323
+ const { code, out } = await capture(step.cmd, step.cwd);
324
+ const dur = Date.now() - st.stepStart;
325
+ const r = { dur, kind: step.kind, label: step.label, project: rel };
326
+ if (step.kind === "test") r.tests = parseVitest(out);
327
+ if (step.kind === "lint") r.lint = parseLint(out);
328
+ results.push(r);
329
+ if (code !== 0 && step.fatal) {
330
+ st.failed = step.label;
331
+ if (!abort.hit) {
332
+ abort.hit = true;
333
+ abort.failure = { out, project: rel, step: `${shortRel(rel)} · ${step.label}` };
334
+ killAll();
335
+ }
336
+ return;
337
+ }
338
+ if (!TTY)
339
+ process.stdout.write(
340
+ ` ${C.green("✓")} ${shortRel(rel)} · ${step.label}${metricSuffix(r)} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
341
+ );
342
+ }
343
+ st.done = true;
344
+ st.total = Date.now() - startedAt;
345
+ }
346
+
347
+ // ── main ─────────────────────────────────────────────────────────────────────
348
+ async function main() {
349
+ const started = Date.now();
350
+ const projects = discoverProjects();
351
+ if (projects.length === 0) {
352
+ console.error(C.red("No workspace projects with a `check` script found."));
353
+ process.exit(1);
354
+ }
355
+ const { auditCmd, groups } = buildGroups(projects);
356
+ const stepCount = groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0);
357
+ const mode = SEQUENTIAL ? "sequential" : "parallel";
358
+ const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
359
+
360
+ console.log(C.bold(`\nRunning checks for ${C.cyan(pkgName)}`));
361
+ console.log(
362
+ C.dim(
363
+ `${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? "none"}` +
364
+ `${NO_FIX ? "" : " · auto-fix format+lint"}${VERBOSE ? " · verbose" : ""}\n`,
365
+ ),
366
+ );
367
+
368
+ const results = [];
369
+
370
+ // Step 0 — single workspace audit (blocking gate, runs before the fan-out).
371
+ // Mirrors the chain's own audit command (scope/level/PM); skipped only when
372
+ // the chain has no audit step.
373
+ if (auditCmd) {
374
+ const t = Date.now();
375
+ if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
376
+ else drawLive([`${C.cyan(FRAMES[0])} audit`]);
377
+ const audit = await runAudit(auditCmd);
378
+ liveCount = 0;
379
+ const dur = Date.now() - t;
380
+ if (audit.blocking) {
381
+ const summary = audit.counts
382
+ ? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
383
+ : "failed";
384
+ console.log(`${C.red("✗")} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
385
+ return fail(
386
+ `audit (${auditCmd})`,
387
+ audit.counts ? renderVulnLine(audit.counts) : audit.reason,
388
+ started,
389
+ );
390
+ }
391
+ console.log(
392
+ `${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`,
393
+ );
394
+ results.push({ audit, kind: "audit" });
395
+ }
396
+
397
+ // Per-project steps — parallel by default, serial with --sequential.
398
+ const order = groups.map((g) => g.project.rel);
399
+ const states = new Map(order.map((rel) => [rel, { current: "queued" }]));
400
+ const abort = { failure: null, hit: false };
401
+ const ticker = TTY ? setInterval(() => drawLive(statusLines(order, states)), 80) : null;
402
+ if (TTY) drawLive(statusLines(order, states));
403
+
404
+ if (SEQUENTIAL) {
405
+ for (const g of groups) {
406
+ await runGroup(g, states, results, abort);
407
+ if (abort.hit) break;
408
+ }
409
+ } else {
410
+ await Promise.all(groups.map((g) => runGroup(g, states, results, abort)));
411
+ }
412
+
413
+ if (ticker) clearInterval(ticker);
414
+ if (TTY) drawLive(statusLines(order, states)); // final frame
415
+
416
+ if (abort.hit) return fail(abort.failure.step, abort.failure.out, started);
417
+
418
+ report(started, results);
419
+ process.exit(0);
420
+ }
421
+
422
+ // ── rendering helpers ─────────────────────────────────────────────────────────
423
+ function renderVulnLine(counts) {
424
+ return SEVERITIES.map((s) => {
425
+ const n = counts[s] || 0;
426
+ const txt = `${s} ${n}`;
427
+ if (n > 0 && (s === "critical" || s === "high")) return C.red(txt);
428
+ return n > 0 ? C.yellow(txt) : C.dim(txt);
429
+ }).join(C.dim(" · "));
430
+ }
431
+
432
+ function metricSuffix(r) {
433
+ if (r.kind === "test" && r.tests?.passed != null) {
434
+ const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : "";
435
+ return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ""}`)}${failed}`;
436
+ }
437
+ if (r.kind === "lint" && r.lint) {
438
+ return r.lint.warnings > 0
439
+ ? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? "" : "s"}`)}`
440
+ : ` ${C.dim("clean")}`;
441
+ }
442
+ return "";
443
+ }
444
+
445
+ function fail(stepLabel, reason, started) {
446
+ console.log(`\n${C.red(`──── reason · ${stepLabel} ────`)}`);
447
+ console.log(stripAnsi(String(reason)).trimEnd().split("\n").slice(-40).join("\n"));
448
+ console.log(C.red("────────────────────────────────────────\n"));
449
+ console.log(
450
+ C.bold(
451
+ C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`),
452
+ ),
453
+ );
454
+ console.log(C.dim("Re-run with --verbose for the full output of every step."));
455
+ process.exit(1);
456
+ }
457
+
458
+ function report(started, results) {
459
+ const audit = results.find((r) => r.kind === "audit")?.audit;
460
+ const tests = results.filter((r) => r.kind === "test");
461
+ const unit = tests.find((r) => r.project?.includes("app"))?.tests;
462
+ const api = tests.find((r) => r.project?.includes("api"))?.tests;
463
+ const totalPassed = tests.reduce((n, r) => n + (r.tests?.passed || 0), 0);
464
+
465
+ const bar = "═".repeat(52);
466
+ console.log(`\n${C.green(bar)}`);
467
+ console.log(
468
+ C.bold(` ${C.green("✓ Check PASSED")} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`),
469
+ );
470
+ console.log(C.green(bar));
471
+
472
+ console.log(`\n${C.bold("Steps")}`);
473
+ for (const r of results.filter((x) => x.kind !== "audit")) {
474
+ console.log(
475
+ ` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
476
+ );
477
+ }
478
+
479
+ console.log(
480
+ `\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`,
481
+ );
482
+ console.log(
483
+ ` ${audit?.counts ? renderVulnLine(audit.counts) : C.dim(audit ? "counts unavailable" : "—")}`,
484
+ );
485
+
486
+ console.log(`\n${C.bold("Tests")}`);
487
+ if (unit || api) {
488
+ // Monorepo with app and/or api projects → the canonical area breakdown.
489
+ console.log(
490
+ ` ${"Unit (app)".padEnd(18)}${unit?.passed != null ? `${unit.passed} passed` : C.dim("—")}`,
491
+ );
492
+ console.log(
493
+ ` ${"API (api)".padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim("—")}`,
494
+ );
495
+ console.log(` ${"Playwright".padEnd(18)}${C.dim("— (run via `lt dev test` / CI)")}`);
496
+ } else {
497
+ // Single-package repo → one line per test-bearing project.
498
+ for (const r of tests)
499
+ console.log(
500
+ ` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim("—")}`,
501
+ );
502
+ if (tests.length === 0) console.log(` ${C.dim("no test step")}`);
503
+ }
504
+ console.log(` ${C.bold("Total".padEnd(18))}${C.bold(`${totalPassed} passed`)}`);
505
+
506
+ console.log(`\n${C.green("All checks passed.")}\n`);
507
+ }
508
+
509
+ main().catch((err) => {
510
+ console.error(C.red(`\ncheck.mjs crashed: ${err?.stack || err}`));
511
+ process.exit(1);
512
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.32.1",
3
+ "version": "1.34.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",