@lenne.tech/cli 1.50.0 → 1.50.1

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @lt-check-wrapper 3.13.1
2
+ // @lt-check-wrapper 3.14.1
3
3
  /**
4
4
  * Quiet, report-driven wrapper around the project `check` pipeline.
5
5
  *
@@ -33,7 +33,7 @@
33
33
  * scripts/check-wrapper-version.test.mjs fails when it drifts from
34
34
  * package.json. In a generated project it keeps the release it came from.
35
35
  */
36
- import { execFileSync, execSync, spawn } from 'node:child_process';
36
+ import { execFileSync, spawn } from 'node:child_process';
37
37
  import { readFileSync, realpathSync } from 'node:fs';
38
38
  import { dirname, join } from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
@@ -430,7 +430,21 @@ const IDLE_TIMEOUT_MS = (() => {
430
430
  const RUNNING = new Set();
431
431
 
432
432
  /**
433
- * How to kill a process tree on this platform.
433
+ * Whether a number may become a kill target at all.
434
+ *
435
+ * A spawn that failed leaves `child.pid` undefined. On POSIX that only made `pgrep` and
436
+ * `process.kill` throw, but on Windows it became `taskkill /PID undefined /T /F` — a kill
437
+ * command built from a word that is not a pid. And the small numbers are not processes you
438
+ * own: POSIX pid 1 is init/launchd, and 0 and -1 are the group and the kill(2) broadcast to
439
+ * every process of the user (the shape that rebooted a Mac on 2026-09-23). Windows reserves
440
+ * 0 (System Idle) and 4 (System). Nothing a check step spawns can have any of these pids.
441
+ */
442
+ export function isKillablePid(pid, platform = process.platform) {
443
+ return Number.isInteger(pid) && pid > (platform === 'win32' ? 4 : 1);
444
+ }
445
+
446
+ /**
447
+ * How to kill a process tree on this platform, or null when `pid` must not be touched.
434
448
  *
435
449
  * Windows has neither `pgrep` nor signals: `process.kill(pid, 'SIGTERM')` there ends the one
436
450
  * process and orphans its children, and `taskkill /T` alone was measured to leave the tree
@@ -445,46 +459,85 @@ const RUNNING = new Set();
445
459
  * Split out as a pure function so both branches can be tested from either platform.
446
460
  */
447
461
  export function killTreePlan(pid, signal, platform = process.platform) {
462
+ if (!isKillablePid(pid, platform)) return null;
448
463
  return platform === 'win32' ? { args: ['/PID', String(pid), '/T', '/F'], command: 'taskkill' } : { signal };
449
464
  }
450
465
 
451
- // Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
452
- // fork workers). Killing only the direct child orphans the tree — exactly the
453
- // zombie workers a deadlock leaves behind. Children are collected via pgrep
454
- // and killed leaves-first.
455
- function killTree(child, signal = 'SIGTERM') {
456
- const plan = killTreePlan(child.pid, signal);
466
+ /**
467
+ * Kill `pid` and its whole process tree, with every effect injected.
468
+ *
469
+ * Injecting the platform alone is not enough: the effects would still run for real, against
470
+ * this machine, with whatever pid the test chose. So all four are required and nothing
471
+ * defaults — a test cannot reach a real `taskkill`, `pgrep` or `process.kill` by leaving one
472
+ * out. `killTree` below is the only caller that passes the real ones.
473
+ *
474
+ * - `run(command, args)` executes the Windows plan.
475
+ * - `childrenOf(pid)` returns the direct children of `pid` (POSIX).
476
+ * - `signal(pid, sig)` delivers `sig` to `pid` (POSIX).
477
+ *
478
+ * POSIX order is leaves first, so a parent cannot respawn a child that was just killed, and
479
+ * each pid is signalled at most once. Pids a lookup returns pass the same check as the root —
480
+ * a stray 1 in `pgrep` output must not become a signal to init.
481
+ */
482
+ export function killTreeWith(pid, sig, { childrenOf, platform, run, signal }) {
483
+ for (const [name, fn] of Object.entries({ childrenOf, run, signal })) {
484
+ if (typeof fn !== 'function') throw new TypeError(`killTreeWith: \`${name}\` must be injected`);
485
+ }
486
+ if (typeof platform !== 'string') throw new TypeError('killTreeWith: `platform` must be injected');
487
+ const plan = killTreePlan(pid, sig, platform);
488
+ if (!plan) return;
457
489
  if (plan.command) {
458
490
  try {
459
- execFileSync(plan.command, plan.args, { stdio: 'ignore' });
491
+ run(plan.command, plan.args);
460
492
  } catch {
461
493
  /* already gone, or taskkill refused — nothing further to try */
462
494
  }
463
495
  return;
464
496
  }
465
- const pids = [];
466
- const collect = (pid) => {
467
- pids.push(pid);
468
- let out = '';
497
+ const seen = new Set();
498
+ const order = [];
499
+ const collect = (p) => {
500
+ if (seen.has(p) || !isKillablePid(p, platform)) return;
501
+ seen.add(p);
502
+ let children = [];
469
503
  try {
470
- out = execSync(`pgrep -P ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })
471
- .toString()
472
- .trim();
504
+ children = childrenOf(p);
473
505
  } catch {
474
506
  /* no children */
475
507
  }
476
- if (out) for (const p of out.split('\n')) collect(Number(p));
508
+ for (const c of children) collect(c);
509
+ order.push(p);
477
510
  };
478
- collect(child.pid);
479
- for (const pid of pids.reverse()) {
511
+ collect(pid);
512
+ for (const p of order) {
480
513
  try {
481
- process.kill(pid, signal);
514
+ signal(p, plan.signal);
482
515
  } catch {
483
516
  /* already gone */
484
517
  }
485
518
  }
486
519
  }
487
520
 
521
+ /** Direct children of `pid` via `pgrep -P`; empty when there are none (pgrep exits 1). */
522
+ function pgrepChildren(pid) {
523
+ const out = execFileSync('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] })
524
+ .toString()
525
+ .trim();
526
+ return out ? out.split('\n').map(Number) : [];
527
+ }
528
+
529
+ // Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
530
+ // fork workers). Killing only the direct child orphans the tree — exactly the
531
+ // zombie workers a deadlock leaves behind.
532
+ function killTree(child, signal = 'SIGTERM') {
533
+ killTreeWith(child.pid, signal, {
534
+ childrenOf: pgrepChildren,
535
+ platform: process.platform,
536
+ run: (command, args) => execFileSync(command, args, { stdio: 'ignore' }),
537
+ signal: (pid, sig) => process.kill(pid, sig),
538
+ });
539
+ }
540
+
488
541
  // idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
489
542
  // runs it unwatched. Only callers that KNOW the child streams progress (test
490
543
  // steps) should pass a timeout — see runGroup.
@@ -1,13 +1,13 @@
1
1
  {
2
- "commit": "fcf7c83cffa37b09db88481833e7ccc0dd2fbe0f",
2
+ "commit": "26365f7c8acaa98bc48f360886055d0fe45c593d",
3
3
  "files": {
4
4
  "build-test-gate.mjs": "865bf1b9dd28151f53b9f2dfe4c3c46d29b9303fb1c2f497ce0e3136bcd37d71",
5
- "check.mjs": "fed669a625b56d907ab2e3693c2e5797a877c460588bd79f89249d88c568c178",
5
+ "check.mjs": "6be5506f5fb63f53c65ea438c67d810de1afe1f3299828ef4531c37cc2457d4b",
6
6
  "lib/ansi.mjs": "f997ee6e9bf1c7ee54ab612d4040fcc778064ec160eae19bf5c3960a75a6e2ff",
7
7
  "lib/audit-report.mjs": "503825c3342dec1ee5d8d70e5af21b73e798faee320426b2492190cde0801df2",
8
8
  "lib/workspace-packages.mjs": "ccdd133622a88186c924bc8271d35cf1f8357dc26d4a607998ca5e91fce5bf88"
9
9
  },
10
- "ref": "v3.13.1",
10
+ "ref": "v3.14.1",
11
11
  "repository": "lenneTech/lt-monorepo",
12
- "version": "3.13.1"
12
+ "version": "3.14.1"
13
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.50.0",
3
+ "version": "1.50.1",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",