@danypops/pi-packed 0.21.11 → 0.21.13

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.
@@ -29,12 +29,13 @@
29
29
  * inline next to the row currently updating, settling into a real ✓/✗
30
30
  * plus a bounded tail of that row's own actual captured stdout/stderr
31
31
  * once it finishes, never a determinate bar (a single subprocess call has
32
- * no knowable percentage). Rows render through Malevich's Table (real
33
- * column-aligned Package/Version/status cells, per-row selection styling
34
- * baked into each cell since Table's own cellStyle is column-wide, not
35
- * row-wide) inside this tab's own scroll-window slice -- Table
36
- * deliberately owns no pagination of its own, so the visible-window-
37
- * around-selectedIndex math stays here. u/x/d and the Enter action menu
32
+ * no knowable percentage). Rows render as a column-major grid of
33
+ * Malevich Cards (computeCardColumns/renderCardGrid, below) -- one card
34
+ * per package, as many side by side as `width` allows above a fixed
35
+ * minimum card width, rather than one full-width card per line -- inside
36
+ * this tab's own scroll-window slice. No pagination baked into the grid
37
+ * helpers themselves, so the visible-window-around-selectedIndex math
38
+ * stays here. u/x/d and the Enter action menu
38
39
  * all run their whole approve+mutate+confirmReload flow inline, via the
39
40
  * shared TabHost's inlineCtx -- every confirm() along the way renders as
40
41
  * a real Malevich Dialog on this SAME overlay, dispatched by literal y/n
@@ -407,6 +408,87 @@ async function showActionMenu(ctx: ExtensionCommandContext, row: Row): Promise<"
407
408
  );
408
409
  }
409
410
 
411
+ /** A card narrower than this wastes more space on border/padding than it
412
+ * shows of a real scoped package name ("@scope/pi-longer-name") plus its
413
+ * version/status -- the floor `computeCardColumns` uses when deciding how
414
+ * many side-by-side columns actually fit `width`, rather than guessing a
415
+ * fixed row/page count independent of the panel's real width. */
416
+ const MIN_CARD_WIDTH = 28;
417
+ const CARD_GRID_GAP = 1;
418
+
419
+ /** How many side-by-side card columns fit `width` without any of them
420
+ * dropping below MIN_CARD_WIDTH, capped at `itemCount` -- never more
421
+ * columns than there are real items to fill them (a wide terminal with
422
+ * only 2 packages installed gets 2 columns, not 4 mostly-empty ones). */
423
+ function computeCardColumns(width: number, itemCount: number): number {
424
+ if (itemCount <= 0) return 1;
425
+ const fits = Math.floor((width + CARD_GRID_GAP) / (MIN_CARD_WIDTH + CARD_GRID_GAP));
426
+ return Math.max(1, Math.min(fits, itemCount));
427
+ }
428
+
429
+ /** Exact per-column widths for `columns` side-by-side cards inside `width`,
430
+ * every gap included -- sums (with CARD_GRID_GAP between each) to exactly
431
+ * `width`, distributing any remainder across the leading columns instead
432
+ * of leaving it as slack nobody renders into (the same exact-width
433
+ * contract every other line in this panel already holds, verified by this
434
+ * file's own "every rendered line at the exact same real width" test). */
435
+ function distributeColumnWidths(width: number, columns: number): number[] {
436
+ const available = width - CARD_GRID_GAP * (columns - 1);
437
+ const base = Math.floor(available / columns);
438
+ const remainder = available - base * columns;
439
+ return Array.from({ length: columns }, (_, i) => base + (i < remainder ? 1 : 0));
440
+ }
441
+
442
+ /** Lays `items` into a column-major grid of exactly `columns` columns
443
+ * (column 0 filled top-to-bottom first, then column 1, ...) -- the same
444
+ * fill order `ls -C` uses, so a flat Up/Down cycle through `items` moves
445
+ * down one column at a time instead of snaking left-to-right through a
446
+ * row-major grid, with no new keybinding needed (Left/Right stay reserved
447
+ * for the panel's own tab-cycling -- see this file's own header comment).
448
+ * `columns` is always the caller's own already-computed computeCardColumns
449
+ * result, never re-derived here from `items.length` -- a caller windowing
450
+ * `items` into a page shorter than its full list must still render that
451
+ * page at the same column count the rest of the list uses, or the grid
452
+ * would reflow mid-scroll. `selectedIndex` is an index into `items`
453
+ * itself, or -1 when nothing in this grid is selected. */
454
+ function renderCardGrid<T>(
455
+ items: T[],
456
+ columns: number,
457
+ width: number,
458
+ selectedIndex: number,
459
+ renderCard: (item: T, columnWidth: number, selected: boolean) => string[],
460
+ ): string[] {
461
+ if (items.length === 0) return [];
462
+ const columnWidths = distributeColumnWidths(width, columns);
463
+ const rowsPerColumn = Math.ceil(items.length / columns);
464
+ const rendered: string[][] = [];
465
+ for (let c = 0; c < columns; c++) {
466
+ const columnWidth = columnWidths[c] ?? MIN_CARD_WIDTH;
467
+ const lines: string[] = [];
468
+ for (let r = 0; r < rowsPerColumn; r++) {
469
+ const index = c * rowsPerColumn + r;
470
+ const item = items[index];
471
+ if (item === undefined) break;
472
+ lines.push(...renderCard(item, columnWidth, index === selectedIndex));
473
+ }
474
+ rendered.push(lines);
475
+ }
476
+ const height = Math.max(0, ...rendered.map((lines) => lines.length));
477
+ const out: string[] = [];
478
+ for (let row = 0; row < height; row++) {
479
+ out.push(
480
+ rendered
481
+ .map((lines, c) => {
482
+ const line = lines[row] ?? "";
483
+ const pad = Math.max(0, (columnWidths[c] ?? MIN_CARD_WIDTH) - visibleWidth(line));
484
+ return line + " ".repeat(pad);
485
+ })
486
+ .join(" ".repeat(CARD_GRID_GAP)),
487
+ );
488
+ }
489
+ return out;
490
+ }
491
+
410
492
  /** Packages -- the panel's default/"home" tab. A real Component (not the
411
493
  * panel's own top-level ctx.ui.custom owner anymore); the shared TabHost
412
494
  * gives it inline approval/reload dialogs and a way to signal the overlay
@@ -509,33 +591,42 @@ export class PackagesTab implements Component {
509
591
  lines.push(theme.fg("muted", " No packages"));
510
592
  return lines;
511
593
  }
512
- // No scrolling of its own (Malevich's Table is deliberately
513
- // unopinionated about pagination) -- the visible window around
514
- // selectedIndex stays this tab's own job.
515
- const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filtered.length - this.maxVisible));
516
- const end = Math.min(start + this.maxVisible, this.filtered.length);
517
- for (const [offset, row] of this.filtered.slice(start, end).entries()) {
518
- const selected = start + offset === this.selectedIndex;
519
- // A settled row shows its real outcome even for the instant before
520
- // the next row's "start" event moves updatingRowName off it.
521
- const rowSettled = this.settled.get(row.name);
522
- const isUpdating = !rowSettled && this.updatingRowName === row.name;
523
- const status = isUpdating
524
- ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
525
- : rowSettled
526
- ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
527
- : row.hasUpdate
528
- ? theme.fg("warning", `↑${row.latest}`)
529
- : theme.fg("muted", "installed");
530
- const card = new Card({
531
- title: theme.bold(row.name),
532
- content: [`${theme.fg("dim", row.version)} · ${status}`],
533
- selected,
534
- theme: cardTheme(theme),
535
- measure: this.measure,
536
- });
537
- lines.push(...card.render(width));
538
- }
594
+ // Column count is derived from `width` (and the total item count, so a
595
+ // short list never gets more columns than it has items) once per
596
+ // render, then held fixed across the whole page -- otherwise scrolling
597
+ // through a filtered list would reflow the grid mid-scroll. No
598
+ // scrolling of its own beyond that: the visible-window-around-
599
+ // selectedIndex math stays this tab's own job, same as before, just
600
+ // now counting grid rows (maxVisible) times columns instead of a flat
601
+ // one-card-per-line count.
602
+ const columns = computeCardColumns(width, this.filtered.length);
603
+ const pageSize = columns * this.maxVisible;
604
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(pageSize / 2), this.filtered.length - pageSize));
605
+ const end = Math.min(start + pageSize, this.filtered.length);
606
+ const page = this.filtered.slice(start, end);
607
+ lines.push(
608
+ ...renderCardGrid(page, columns, width, this.selectedIndex - start, (row, columnWidth, selected) => {
609
+ // A settled row shows its real outcome even for the instant before
610
+ // the next row's "start" event moves updatingRowName off it.
611
+ const rowSettled = this.settled.get(row.name);
612
+ const isUpdating = !rowSettled && this.updatingRowName === row.name;
613
+ const status = isUpdating
614
+ ? theme.fg("accent", `${this.spinner.glyph()} updating…`)
615
+ : rowSettled
616
+ ? theme.fg(rowSettled.ok ? "success" : "error", `${rowSettled.ok ? "✓" : "✗"}${rowSettled.tail ? ` ${rowSettled.tail}` : ""}`)
617
+ : row.hasUpdate
618
+ ? theme.fg("warning", `↑${row.latest}`)
619
+ : theme.fg("muted", "installed");
620
+ const card = new Card({
621
+ title: theme.bold(row.name),
622
+ content: [`${theme.fg("dim", row.version)} · ${status}`],
623
+ selected,
624
+ theme: cardTheme(theme),
625
+ measure: this.measure,
626
+ });
627
+ return card.render(columnWidth);
628
+ }),
629
+ );
539
630
  const hasScroll = start > 0 || end < this.filtered.length;
540
631
  lines.push(theme.fg("dim", ` ${hasScroll ? `${this.selectedIndex + 1}/${this.filtered.length} ` : ""}${this.mode}`));
541
632
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.21.11",
3
+ "version": "0.21.13",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "@danypops/packed": "^0.7.0",
32
32
  "@danypops/pi-extension-harness": "^0.2.0",
33
33
  "@danypops/vehicle-client": "^0.5.2",
34
+ "@danypops/vehicle-client-pi": "^0.16.9",
34
35
  "@danypops/vehicle-core": "^0.12.3",
35
36
  "@danypops/vehicle-server": "^0.17.1",
36
37
  "jiti": "^2.7.0",
@@ -40,7 +41,6 @@
40
41
  },
41
42
  "devDependencies": {
42
43
  "@danypops/pi-tui-harness": "^0.0.1",
43
- "@danypops/vehicle-client-pi": "^0.16.2",
44
44
  "@types/semver": "^7.7.1"
45
45
  },
46
46
  "peerDependencies": {
@@ -7,7 +7,10 @@
7
7
  * @danypops/pi-extension-harness's own mock-pi-cli
8
8
  * subprocess -- a real, isolated process exercising the same production
9
9
  * jiti load path Pi's own binary uses -- against every declared
10
- * pi.extensions entry.
10
+ * pi.extensions entry. Also runs @danypops/vehicle-client-pi's
11
+ * pi-load-harness (native ESM, jiti tryNative:false) in its own isolated
12
+ * subprocess as non-gating, observational evidence alongside that gating
13
+ * check -- see ExtensionLoadResult.additionalLoadPaths.
11
14
  */
12
15
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
13
16
  import { createRequire } from "node:module";
@@ -18,6 +21,26 @@ export interface ExtensionLoadResult {
18
21
  path: string;
19
22
  ok: boolean;
20
23
  message?: string;
24
+ /** Non-gating: @danypops/vehicle-client-pi's pi-load-harness checks two
25
+ * further Pi extension load paths (native ESM, jiti tryNative:false)
26
+ * beyond the one path above (jiti tryNative:true) already gates install.
27
+ * Surfaced as observational evidence only, for two independent reasons:
28
+ * (1) native-esm can legitimately fail for a perfectly loadable extension
29
+ * on an older Node without TS type-stripping support, with no real-world
30
+ * signal yet on how often that's a false alarm; (2) this check only
31
+ * verifies the module *imports* cleanly -- unlike the gating check above,
32
+ * it never calls the extension's exported factory, so it cannot catch a
33
+ * factory that throws once actually registered (confirmed directly: the
34
+ * BROKEN test fixture, whose factory always throws, reports ok:true on
35
+ * every one of these paths). Complementary evidence for an import-time
36
+ * failure class, not a broader replacement for the gating check. */
37
+ additionalLoadPaths?: PiLoadPathResult[];
38
+ }
39
+
40
+ export interface PiLoadPathResult {
41
+ path: "native-esm" | "jiti-try-native-false" | "jiti-try-native-true";
42
+ ok: boolean;
43
+ error?: string;
21
44
  }
22
45
 
23
46
  export interface InstallValidationResult {
@@ -151,6 +174,28 @@ export async function validateExtensionLoadsHeadless(entryPath: string, timeoutM
151
174
  };
152
175
  }
153
176
 
177
+ /** Runs the extra two Pi extension load paths @danypops/vehicle-client-pi's
178
+ * pi-load-harness knows about (native ESM, jiti tryNative:false) against
179
+ * one entry point, in their own isolated subprocess -- same trust boundary
180
+ * as validateExtensionLoadsHeadless, never inside the daemon process.
181
+ * Returns undefined (not a failure) when the probe subprocess itself
182
+ * couldn't run at all -- vehicle-client-pi is an optional enrichment here,
183
+ * not a hard requirement the way pi-extension-harness is. */
184
+ async function verifyAllLoadPathsHeadless(entryPath: string, timeoutMs?: number): Promise<PiLoadPathResult[] | undefined> {
185
+ const bound = bounded(timeoutMs, DEFAULT_LOAD_TIMEOUT_MS, MAX_LOAD_TIMEOUT_MS);
186
+ const childPath = new URL("verify-load-paths-child.mjs", import.meta.url).pathname;
187
+ const result = await runCommand(["node", childPath, "--extension", entryPath], tmpdir(), bound);
188
+ if (result.timedOut) return undefined;
189
+ const lastLine = result.stdout.trim().split("\n").at(-1);
190
+ if (!lastLine) return undefined;
191
+ try {
192
+ const parsed = JSON.parse(lastLine) as { results?: PiLoadPathResult[]; error?: string };
193
+ return Array.isArray(parsed.results) ? parsed.results : undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
154
199
  /** Stages the real npm tarball in isolation and headlessly load-checks
155
200
  * every pi.extensions entry it declares. A package with no pi.extensions
156
201
  * (most npm packages) or a non-npm source has nothing to validate and
@@ -194,7 +239,8 @@ export class HeadlessInstallValidator implements InstallValidator {
194
239
  // path into a throwaway temp stage dir -- meaningful to a caller,
195
240
  // matches what package.json itself says.
196
241
  const loadResult = await validateExtensionLoadsHeadless(entryPath, this.timeoutMs);
197
- extensions.push({ ...loadResult, path: entry });
242
+ const additionalLoadPaths = await verifyAllLoadPathsHeadless(entryPath, this.timeoutMs);
243
+ extensions.push({ ...loadResult, path: entry, ...(additionalLoadPaths ? { additionalLoadPaths } : {}) });
198
244
  }
199
245
  const ok = extensions.every((extension) => extension.ok);
200
246
  return { ok, source, extensions, ...(ok ? {} : { message: "one or more declared extensions failed a headless load check" }) };
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * verify-load-paths-child.mjs — spawned in isolation by install-validation.ts
5
+ * to check a staged candidate's extension entry against every Pi extension
6
+ * load path @danypops/vehicle-client-pi's pi-load-harness knows about
7
+ * (native ESM, jiti tryNative:false, jiti tryNative:true) -- not just the
8
+ * single tryNative:true path mock-pi-cli's own headless check exercises.
9
+ *
10
+ * Runs as its own subprocess, same trust boundary as mock-pi-cli: the
11
+ * candidate's real code executes here, never inside the trusted daemon
12
+ * process. Uses jiti itself (the same technique mock-pi-cli.mjs already
13
+ * relies on) to import pi-load-harness's raw TypeScript source -- that
14
+ * package deliberately ships this subpath uncompiled, since its intended
15
+ * consumers already run through a TS-transforming toolchain.
16
+ *
17
+ * Accepts:
18
+ * --extension <path> candidate extension entry point to check
19
+ *
20
+ * Emits exactly one JSON line on stdout, then exits 0 (a probe failure is
21
+ * data, not a process failure -- the caller decides what a failing path
22
+ * means):
23
+ * { "results": [{ "path": "native-esm", "ok": true }, ...] }
24
+ * or, if the harness itself couldn't even be loaded/invoked:
25
+ * { "error": "..." }
26
+ */
27
+
28
+ import { createJiti } from "jiti";
29
+
30
+ const args = process.argv.slice(2);
31
+ const get = (flag) => {
32
+ const i = args.indexOf(flag);
33
+ return i !== -1 ? args[i + 1] : null;
34
+ };
35
+
36
+ const extensionPath = get("--extension");
37
+ if (!extensionPath) {
38
+ process.stderr.write("--extension required\n");
39
+ process.exit(1);
40
+ }
41
+
42
+ function emit(obj) {
43
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
44
+ }
45
+
46
+ try {
47
+ const jiti = createJiti(import.meta.url, { moduleCache: false });
48
+ const { verifyLoadableUnderPi } = await jiti.import("@danypops/vehicle-client-pi/pi-load-harness");
49
+ const results = await verifyLoadableUnderPi(extensionPath);
50
+ emit({ results });
51
+ process.exit(0);
52
+ } catch (err) {
53
+ emit({ error: err?.message ?? String(err) });
54
+ process.exit(1);
55
+ }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
@@ -36,11 +36,18 @@ class NoopInstaller implements Installer {
36
36
  }
37
37
 
38
38
  let server: Server<undefined> | undefined;
39
+ const roots: string[] = [];
39
40
  afterEach(() => {
40
41
  server?.stop(true);
41
42
  server = undefined;
43
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
42
44
  });
43
45
 
46
+ function track(dir: string): string {
47
+ roots.push(dir);
48
+ return dir;
49
+ }
50
+
44
51
  const daysAgo = (n: number) => new Date(Date.now() - n * 24 * 60 * 60 * 1000).toISOString();
45
52
 
46
53
  describe("fetchBulkAdvisories", () => {
@@ -221,6 +228,7 @@ describe("scanInstalledPackages", () => {
221
228
  describe("advisories.scan operation (real daemon route)", () => {
222
229
  it("resolves real installed npm packages' on-disk versions and routes through the authenticated operation registry", async () => {
223
230
  const piHome = mkdtempSync(join(tmpdir(), "packed-advisories-daemon-"));
231
+ roots.push(piHome);
224
232
  writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-vuln"] }));
225
233
  const pkgDir = join(piHome, "npm", "node_modules", "pi-vuln");
226
234
  mkdirSync(pkgDir, { recursive: true });
@@ -231,8 +239,8 @@ describe("advisories.scan operation (real daemon route)", () => {
231
239
  reg: new NoopRegistry(),
232
240
  inst: new NoopInstaller(),
233
241
  token: "test-token",
234
- stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
235
- dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
242
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-state-"))),
243
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-data-"))),
236
244
  piHome,
237
245
  // injected exactly like pi.status's piVersion seam -- never a real
238
246
  // network call to the live npm registry from an automated test.
@@ -258,9 +266,9 @@ describe("advisories.scan operation (real daemon route)", () => {
258
266
  reg: new NoopRegistry(),
259
267
  inst: new NoopInstaller(),
260
268
  token: "test-token",
261
- stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
262
- dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
263
- piHome: mkdtempSync(join(tmpdir(), "packed-advisories-pihome-")),
269
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-state-"))),
270
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-data-"))),
271
+ piHome: track(mkdtempSync(join(tmpdir(), "packed-advisories-pihome-"))),
264
272
  });
265
273
  const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
266
274
  label: "Packed",
@@ -274,9 +282,9 @@ describe("advisories.scan operation (real daemon route)", () => {
274
282
  reg: new NoopRegistry(),
275
283
  inst: new NoopInstaller(),
276
284
  token: "test-token",
277
- stateDir: mkdtempSync(join(tmpdir(), "packed-advisories-state-")),
278
- dataDir: mkdtempSync(join(tmpdir(), "packed-advisories-data-")),
279
- piHome: mkdtempSync(join(tmpdir(), "packed-advisories-pihome-")),
285
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-state-"))),
286
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-advisories-data-"))),
287
+ piHome: track(mkdtempSync(join(tmpdir(), "packed-advisories-pihome-"))),
280
288
  });
281
289
  const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
282
290
  label: "Packed",
@@ -12,6 +12,11 @@ afterEach(() => {
12
12
  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
13
13
  });
14
14
 
15
+ function track(dir: string): string {
16
+ roots.push(dir);
17
+ return dir;
18
+ }
19
+
15
20
  function pkg(manifest: Record<string, unknown>): string {
16
21
  const root = mkdtempSync(join(tmpdir(), "packed-cleanup-"));
17
22
  roots.push(root);
@@ -169,8 +174,8 @@ describe("packed remove applies pi.cleanup before delegating to pi remove (real
169
174
  reg: new NoopRegistry(),
170
175
  inst,
171
176
  token: "test-token",
172
- stateDir: mkdtempSync(join(tmpdir(), "packed-cleanup-state-")),
173
- dataDir: mkdtempSync(join(tmpdir(), "packed-cleanup-data-")),
177
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-cleanup-state-"))),
178
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-cleanup-data-"))),
174
179
  piHome,
175
180
  });
176
181
  return new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
@@ -1,6 +1,6 @@
1
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { writeDaemonHandle } from "@danypops/vehicle-server/paths";
@@ -125,6 +125,16 @@ class FakeDaemonServiceInstaller {
125
125
  }
126
126
  }
127
127
 
128
+ const roots: string[] = [];
129
+ afterEach(() => {
130
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
131
+ });
132
+
133
+ function track(dir: string): string {
134
+ roots.push(dir);
135
+ return dir;
136
+ }
137
+
128
138
  function deps(over: Partial<CliDeps> = {}): CliDeps {
129
139
  return {
130
140
  reg: new FakeRegistry(),
@@ -138,8 +148,8 @@ function deps(over: Partial<CliDeps> = {}): CliDeps {
138
148
  return { mutationApproval };
139
149
  },
140
150
  },
141
- stateDir: mkdtempSync(join(tmpdir(), "packed-")),
142
- piHome: mkdtempSync(join(tmpdir(), "packed-pihome-")),
151
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-"))),
152
+ piHome: track(mkdtempSync(join(tmpdir(), "packed-pihome-"))),
143
153
  ...over,
144
154
  };
145
155
  }
@@ -389,7 +399,7 @@ describe("CLI", () => {
389
399
  it("updates --project also checks a project's own .pi/settings.json pins -- global-only misses them entirely", async () => {
390
400
  const d = deps();
391
401
  writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-global@1.0.0"] }));
392
- const projectRoot = mkdtempSync(join(tmpdir(), "packed-project-"));
402
+ const projectRoot = track(mkdtempSync(join(tmpdir(), "packed-project-")));
393
403
  const projectHome = join(projectRoot, ".pi");
394
404
  mkdirSync(projectHome, { recursive: true });
395
405
  writeFileSync(join(projectHome, "settings.json"), JSON.stringify({ packages: ["npm:papyrus@0.21.2"] }));
@@ -938,7 +948,7 @@ describe("CLI", () => {
938
948
  });
939
949
 
940
950
  it("advisories runs standalone without a daemon and degrades to zero findings, never a real network call, when nothing is installed", async () => {
941
- const d = deps({ piHome: mkdtempSync(join(tmpdir(), "packed-advisories-cli-")) });
951
+ const d = deps({ piHome: track(mkdtempSync(join(tmpdir(), "packed-advisories-cli-"))) });
942
952
  const result = await cliRun(["advisories", "--json"], d);
943
953
  expect(result.code).toBe(0);
944
954
  expect(JSON.parse(result.out)).toEqual({ scanned: 0, findings: [], diagnostics: [], truncated: false });
@@ -956,7 +966,7 @@ describe("CLI", () => {
956
966
  });
957
967
 
958
968
  it("resources list and toggle run standalone without a daemon (CLI parity for the daemon-only resources.list/toggle operations)", async () => {
959
- const piHome = mkdtempSync(join(tmpdir(), "packed-resources-cli-"));
969
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-resources-cli-")));
960
970
  writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-demo"] }));
961
971
  const pkgDir = join(piHome, "npm", "node_modules", "pi-demo");
962
972
  mkdirSync(pkgDir, { recursive: true });
@@ -998,7 +1008,7 @@ describe("CLI", () => {
998
1008
  (bwrapUsable ? it : it.skip)(
999
1009
  "doctor runs standalone without a daemon and reproduces the jittor incident through the real CLI (CLI parity for the daemon-only doctor.run operation)",
1000
1010
  async () => {
1001
- const piHome = mkdtempSync(join(tmpdir(), "packed-doctor-cli-"));
1011
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-doctor-cli-")));
1002
1012
  writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-papyrus"] }));
1003
1013
  const globalPkg = join(piHome, "npm", "node_modules", "pi-papyrus");
1004
1014
  mkdirSync(join(globalPkg, "extension"), { recursive: true });
@@ -1007,7 +1017,7 @@ describe("CLI", () => {
1007
1017
  JSON.stringify({ name: "pi-papyrus", version: "1.0.0", pi: { extensions: ["extension/index.ts"] } }),
1008
1018
  );
1009
1019
  writeFileSync(join(globalPkg, "extension", "index.ts"), 'export default function (pi: any) { pi.registerTool({ name: "tasks" }); }');
1010
- const projectRoot = mkdtempSync(join(tmpdir(), "packed-doctor-cli-project-"));
1020
+ const projectRoot = track(mkdtempSync(join(tmpdir(), "packed-doctor-cli-project-")));
1011
1021
  const projectHome = join(projectRoot, ".pi");
1012
1022
  mkdirSync(projectHome, { recursive: true });
1013
1023
  writeFileSync(join(projectHome, "settings.json"), JSON.stringify({ packages: ["npm:papyrus"] }));
@@ -1063,12 +1073,14 @@ describe("CLI", () => {
1063
1073
  describe("daemon client", () => {
1064
1074
  let server: Server<undefined>;
1065
1075
  let daemonDir: string;
1076
+ let daemonPiHome: string;
1066
1077
  let daemonPaths: PackedPaths;
1067
1078
  let daemonInstaller: FakeInstaller;
1068
1079
  const daemonToken = "d".repeat(64);
1069
1080
 
1070
1081
  beforeAll(async () => {
1071
1082
  daemonDir = mkdtempSync(join(tmpdir(), "packed-daemon-"));
1083
+ daemonPiHome = mkdtempSync(join(tmpdir(), "packed-daemon-pi-"));
1072
1084
  daemonPaths = resolvePackedPaths({ env: { PI_PACKED_HOME: daemonDir } });
1073
1085
  writeFileSync(daemonPaths.token, `${daemonToken}\n`);
1074
1086
  daemonInstaller = new FakeInstaller();
@@ -1100,7 +1112,7 @@ describe("daemon client", () => {
1100
1112
  },
1101
1113
  token: daemonToken,
1102
1114
  stateDir: daemonDir,
1103
- piHome: mkdtempSync(join(tmpdir(), "packed-daemon-pi-")),
1115
+ piHome: daemonPiHome,
1104
1116
  packer: {
1105
1117
  async verify(path) {
1106
1118
  return {
@@ -1169,7 +1181,11 @@ describe("daemon client", () => {
1169
1181
  server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => app.fetch(req) });
1170
1182
  writeDaemonHandle(daemonPaths.handle, { host: "127.0.0.1", port: server.port!, pid: process.pid });
1171
1183
  });
1172
- afterAll(() => server.stop(true));
1184
+ afterAll(() => {
1185
+ server.stop(true);
1186
+ rmSync(daemonDir, { recursive: true, force: true });
1187
+ rmSync(daemonPiHome, { recursive: true, force: true });
1188
+ });
1173
1189
 
1174
1190
  it("probe finds a live daemon", async () => {
1175
1191
  const found = await probe(daemonPaths);
@@ -1188,7 +1204,7 @@ describe("daemon client", () => {
1188
1204
  });
1189
1205
 
1190
1206
  it("probe rejects dead state", async () => {
1191
- const directory = mkdtempSync(join(tmpdir(), "packed-"));
1207
+ const directory = track(mkdtempSync(join(tmpdir(), "packed-")));
1192
1208
  expect(await probe(resolvePackedPaths({ env: { PI_PACKED_HOME: directory } }))).toBeUndefined();
1193
1209
  });
1194
1210
 
@@ -1260,7 +1276,7 @@ describe("daemon client", () => {
1260
1276
  it("resolveRegistry prefers daemon, falls back direct", async () => {
1261
1277
  const viaDaemon = await resolveRegistry(daemonPaths, "https://registry.npmjs.org");
1262
1278
  expect(viaDaemon).toBeInstanceOf(DaemonRegistry);
1263
- const directory = mkdtempSync(join(tmpdir(), "packed-"));
1279
+ const directory = track(mkdtempSync(join(tmpdir(), "packed-")));
1264
1280
  const direct = await resolveRegistry(resolvePackedPaths({ env: { PI_PACKED_HOME: directory } }), "https://registry.npmjs.org");
1265
1281
  expect(direct).toBeInstanceOf(HttpRegistry);
1266
1282
  });
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { existsSync, mkdtempSync } from "node:fs";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { syncCatalog } from "../src/packages/catalog.ts";
@@ -127,9 +127,15 @@ class PagedRegistry implements Registry {
127
127
  }
128
128
  }
129
129
 
130
+ const roots: string[] = [];
131
+ afterEach(() => {
132
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
133
+ });
134
+
130
135
  describe("syncCatalog → SQLite", () => {
131
136
  it("accumulates pages into the DB and records sync meta", async () => {
132
137
  const dir = mkdtempSync(join(tmpdir(), "packed-"));
138
+ roots.push(dir);
133
139
  const reg = new PagedRegistry({ 0: PKGS.slice(0, 2), 2: PKGS.slice(2) }, 3);
134
140
  expect(await syncCatalog(reg, dir)).toBe(3);
135
141
  expect(existsSync(dbPath(dir))).toBe(true);
@@ -13,6 +13,11 @@ afterEach(() => {
13
13
  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
14
14
  });
15
15
 
16
+ function track(dir: string): string {
17
+ roots.push(dir);
18
+ return dir;
19
+ }
20
+
16
21
  // Same sandbox-availability probe as smoke.test.ts: binary presence alone
17
22
  // doesn't prove bwrap actually works under this host's user namespaces.
18
23
  function bwrapUsable(): boolean {
@@ -216,8 +221,8 @@ describeIfSandboxed("doctor.run (daemon RPC wiring)", () => {
216
221
  reg: new NoopRegistry(),
217
222
  inst: new NoopInstaller(),
218
223
  token: "test-token",
219
- stateDir: mkdtempSync(join(tmpdir(), "packed-doctor-state-")),
220
- dataDir: mkdtempSync(join(tmpdir(), "packed-doctor-data-")),
224
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
225
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
221
226
  piHome: home,
222
227
  });
223
228
  const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {