@danypops/pi-packed 0.21.15 → 0.22.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.
Files changed (38) hide show
  1. package/dist/client.d.ts +2 -2
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +1 -1
  4. package/dist/protocol.d.ts +33 -0
  5. package/dist/protocol.d.ts.map +1 -1
  6. package/extension/src/menu-theme.ts +13 -3
  7. package/extension/src/packed.ts +2 -2
  8. package/extension/src/tools.ts +24 -8
  9. package/extension/src/tui.ts +7 -3
  10. package/package.json +4 -4
  11. package/service/src/adoption/doctor.ts +15 -0
  12. package/service/src/adoption/install-validation.ts +97 -5
  13. package/service/src/adoption/module-freshness.ts +100 -0
  14. package/service/src/adoption/smoke.ts +26 -2
  15. package/service/src/cli/cli.ts +30 -5
  16. package/service/src/daemon/client.ts +13 -7
  17. package/service/src/daemon/daemon.ts +35 -3
  18. package/service/src/daemon/service.ts +42 -7
  19. package/service/src/daemon/vehicle-registration.ts +118 -35
  20. package/service/src/daemon/watcher.ts +1 -18
  21. package/service/src/packages/deploy-verify.ts +151 -0
  22. package/service/src/packages/install.ts +120 -3
  23. package/service/src/packages/package.ts +67 -1
  24. package/service/src/packages/resources.ts +63 -0
  25. package/service/src/public/client.ts +9 -3
  26. package/service/src/public/protocol.ts +13 -1
  27. package/service/test/cli.test.ts +75 -1
  28. package/service/test/deploy-verify.test.ts +168 -0
  29. package/service/test/doctor.test.ts +104 -1
  30. package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
  31. package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
  32. package/service/test/install-validation.test.ts +14 -1
  33. package/service/test/install.test.ts +229 -0
  34. package/service/test/module-freshness.test.ts +146 -0
  35. package/service/test/resources.test.ts +66 -1
  36. package/service/test/service.test.ts +68 -1
  37. package/service/test/smoke.test.ts +49 -2
  38. package/service/test/vehicle-registration.test.ts +97 -5
@@ -0,0 +1,168 @@
1
+ /**
2
+ * deploy-verify.test.ts — the exact hand-diffing ceremony this module
3
+ * replaces: comparing a package's on-disk version at every known install
4
+ * location plus stale shadow copies, against an expected version.
5
+ */
6
+ import { afterEach, describe, expect, it } from "bun:test";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { findShadowCopies, formatDeployVerification, verifyDeploy } from "../src/packages/deploy-verify.ts";
11
+
12
+ const roots: string[] = [];
13
+ afterEach(() => {
14
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
15
+ });
16
+
17
+ function track(dir: string): string {
18
+ roots.push(dir);
19
+ return dir;
20
+ }
21
+
22
+ function writePackageAt(packageJsonPath: string, version: string): void {
23
+ mkdirSync(join(packageJsonPath, ".."), { recursive: true });
24
+ writeFileSync(packageJsonPath, JSON.stringify({ version }));
25
+ }
26
+
27
+ function piHomeWith(name: string, version: string | undefined): string {
28
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-deploy-verify-")));
29
+ if (version !== undefined) writePackageAt(join(dir, "npm", "node_modules", name, "package.json"), version);
30
+ else mkdirSync(join(dir, "npm", "node_modules"), { recursive: true });
31
+ return dir;
32
+ }
33
+
34
+ describe("verifyDeploy", () => {
35
+ it("reports ok when the only known location matches the expected version and no shadow copy exists", () => {
36
+ const piHome = piHomeWith("@scope/pkg", "1.2.3");
37
+
38
+ const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
39
+
40
+ expect(report.ok).toBe(true);
41
+ expect(report.expectedVersion).toBe("1.2.3");
42
+ expect(report.locations[0]!.upToDate).toBe(true);
43
+ expect(report.shadowCopies).toEqual([]);
44
+ });
45
+
46
+ it("defaults expectedVersion to whatever's resolved at the primary (Pi npm project) location when none is given", () => {
47
+ const piHome = piHomeWith("@scope/pkg", "2.0.0");
48
+
49
+ const report = verifyDeploy(piHome, "@scope/pkg");
50
+
51
+ expect(report.expectedVersion).toBe("2.0.0");
52
+ expect(report.ok).toBe(true);
53
+ });
54
+
55
+ it("reports not-ok and version-unknown when the package isn't found anywhere and no expected version was given", () => {
56
+ const piHome = piHomeWith("@scope/pkg", undefined);
57
+
58
+ const report = verifyDeploy(piHome, "@scope/other-pkg");
59
+
60
+ expect(report.expectedVersion).toBeUndefined();
61
+ expect(report.ok).toBe(false);
62
+ });
63
+
64
+ it("flags a location still on an older version as stale, not just missing", () => {
65
+ const piHome = piHomeWith("@scope/pkg", "1.0.0");
66
+ const fakeHome = track(mkdtempSync(join(tmpdir(), "packed-deploy-verify-home-")));
67
+ writePackageAt(join(fakeHome, ".cache", ".bun", "install", "global", "node_modules", "@scope/pkg", "package.json"), "0.9.0");
68
+
69
+ const report = verifyDeploy(piHome, "@scope/pkg", "1.0.0", fakeHome);
70
+
71
+ const bunLocation = report.locations.find((l) => l.label === "Bun global install cache")!;
72
+ expect(bunLocation.present).toBe(true);
73
+ expect(bunLocation.upToDate).toBe(false);
74
+ expect(bunLocation.version).toBe("0.9.0");
75
+ expect(report.ok).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe("findShadowCopies — the exact layout ExecInstaller.reresolveDependencyTree() already fixes for the install path", () => {
80
+ it("finds a nested copy under an unscoped sibling package's own node_modules", () => {
81
+ const piHome = piHomeWith("shared", "2.0.0");
82
+ writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
83
+
84
+ const shadows = findShadowCopies(piHome, "shared");
85
+
86
+ expect(shadows).toHaveLength(1);
87
+ expect(shadows[0]!.label).toBe("shadow copy under leaf");
88
+ expect(shadows[0]!.packageJsonPath).toBe(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"));
89
+ });
90
+
91
+ it("finds a nested copy one level under a scoped sibling package", () => {
92
+ const piHome = piHomeWith("shared", "2.0.0");
93
+ writePackageAt(join(piHome, "npm", "node_modules", "@scope", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
94
+
95
+ const shadows = findShadowCopies(piHome, "shared");
96
+
97
+ expect(shadows).toHaveLength(1);
98
+ expect(shadows[0]!.label).toBe("shadow copy under @scope/leaf");
99
+ });
100
+
101
+ it("finds nothing when the tree is clean", () => {
102
+ const piHome = piHomeWith("shared", "2.0.0");
103
+ mkdirSync(join(piHome, "npm", "node_modules", "unrelated"), { recursive: true });
104
+
105
+ expect(findShadowCopies(piHome, "shared")).toEqual([]);
106
+ });
107
+
108
+ it("never descends into a shadow copy's own node_modules", () => {
109
+ const piHome = piHomeWith("shared", "2.0.0");
110
+ writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "1.0.0");
111
+ // A second-level nested copy inside the shadow copy itself -- out of scope.
112
+ writePackageAt(
113
+ join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "node_modules", "shared", "package.json"),
114
+ "0.5.0",
115
+ );
116
+
117
+ const shadows = findShadowCopies(piHome, "shared");
118
+
119
+ expect(shadows).toHaveLength(1);
120
+ });
121
+
122
+ it("reports every shadow copy in verifyDeploy's own report, making the overall result not-ok regardless of the shadow's own version", () => {
123
+ const piHome = piHomeWith("shared", "2.0.0");
124
+ // The shadow copy itself happens to already match the expected version --
125
+ // still a real problem: two copies existing at all is what matters, not
126
+ // whether this particular one happens to agree right now.
127
+ writePackageAt(join(piHome, "npm", "node_modules", "leaf", "node_modules", "shared", "package.json"), "2.0.0");
128
+
129
+ const report = verifyDeploy(piHome, "shared", "2.0.0");
130
+
131
+ expect(report.shadowCopies).toHaveLength(1);
132
+ expect(report.shadowCopies[0]!.upToDate).toBe(true);
133
+ expect(report.ok).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("formatDeployVerification", () => {
138
+ it("renders a human-readable PASS report with no shadow copies", () => {
139
+ const piHome = piHomeWith("@scope/pkg", "1.2.3");
140
+ const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
141
+
142
+ const text = formatDeployVerification(report, false);
143
+
144
+ expect(text).toContain("PASS");
145
+ expect(text).toContain("@scope/pkg@1.2.3");
146
+ expect(text).toContain("no shadow copies found");
147
+ });
148
+
149
+ it("renders valid JSON when json is true", () => {
150
+ const piHome = piHomeWith("@scope/pkg", "1.2.3");
151
+ const report = verifyDeploy(piHome, "@scope/pkg", "1.2.3");
152
+
153
+ const parsed = JSON.parse(formatDeployVerification(report, true));
154
+
155
+ expect(parsed.ok).toBe(true);
156
+ expect(parsed.packageName).toBe("@scope/pkg");
157
+ });
158
+
159
+ it("renders a human-readable FAIL report listing a stale location", () => {
160
+ const piHome = piHomeWith("@scope/pkg", "1.0.0");
161
+ const report = verifyDeploy(piHome, "@scope/pkg", "9.9.9");
162
+
163
+ const text = formatDeployVerification(report, false);
164
+
165
+ expect(text).toContain("FAIL");
166
+ expect(text).toContain("STALE (1.0.0)");
167
+ });
168
+ });
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
7
- import { runDoctor } from "../src/adoption/doctor.ts";
7
+ import { formatDoctorReport, runDoctor } from "../src/adoption/doctor.ts";
8
8
  import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
9
9
  import type { Installer, PkgInfo, Registry, SearchPage } from "../src/packages/package.ts";
10
10
 
@@ -237,3 +237,106 @@ describeIfSandboxed("doctor.run (daemon RPC wiring)", () => {
237
237
  expect(result.conflicts).toHaveLength(1);
238
238
  });
239
239
  });
240
+
241
+ describe("doctor.run — module freshness (a long-running daemon process's own stale in-memory dependency, see module-freshness.ts)", () => {
242
+ it("merges in moduleFreshness and flips ok:false when the injected checker reports a stale dependency", async () => {
243
+ const home = piHome([]);
244
+ const app = createApp({
245
+ reg: new NoopRegistry(),
246
+ inst: new NoopInstaller(),
247
+ token: "test-token",
248
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
249
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
250
+ piHome: home,
251
+ moduleFreshness: () => [
252
+ { name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
253
+ { name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
254
+ ],
255
+ });
256
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
257
+ label: "Packed",
258
+ transport: (request) => app.fetch(request),
259
+ });
260
+
261
+ const result = await client.call("doctor.run", {});
262
+
263
+ expect(result.moduleFreshness).toEqual([
264
+ { name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
265
+ { name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
266
+ ]);
267
+ // The base doctor report (no extensions installed) would otherwise be ok:true.
268
+ expect(result.ok).toBe(false);
269
+ });
270
+
271
+ it("stays ok:true and reports no stale entries when the injected checker finds nothing stale", async () => {
272
+ const home = piHome([]);
273
+ const app = createApp({
274
+ reg: new NoopRegistry(),
275
+ inst: new NoopInstaller(),
276
+ token: "test-token",
277
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
278
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
279
+ piHome: home,
280
+ moduleFreshness: () => [{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false }],
281
+ });
282
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
283
+ label: "Packed",
284
+ transport: (request) => app.fetch(request),
285
+ });
286
+
287
+ const result = await client.call("doctor.run", {});
288
+
289
+ expect(result.moduleFreshness).toEqual([{ name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false }]);
290
+ expect(result.ok).toBe(true);
291
+ });
292
+
293
+ it("omits moduleFreshness entirely when nothing injects it -- a standalone runDoctor() call has no snapshot to compare against", async () => {
294
+ const home = piHome([]);
295
+ const app = createApp({
296
+ reg: new NoopRegistry(),
297
+ inst: new NoopInstaller(),
298
+ token: "test-token",
299
+ stateDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-state-"))),
300
+ dataDir: track(mkdtempSync(join(tmpdir(), "packed-doctor-data-"))),
301
+ piHome: home,
302
+ });
303
+ const client = new AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>("http://packed.test", "test-token", {
304
+ label: "Packed",
305
+ transport: (request) => app.fetch(request),
306
+ });
307
+
308
+ const result = await client.call("doctor.run", {});
309
+
310
+ expect(result.moduleFreshness).toBeUndefined();
311
+ expect(result.ok).toBe(true);
312
+ });
313
+ });
314
+
315
+ describe("formatDoctorReport — module freshness rendering", () => {
316
+ const base = { ok: true, conflicts: [], extensions: [], scanned: 0, truncated: false, serviceUnits: [] };
317
+
318
+ it("prints a STALE_MODULE_CACHE line with an actionable restart hint for each stale entry, and nothing for a fresh one", () => {
319
+ const text = formatDoctorReport(
320
+ {
321
+ ...base,
322
+ ok: false,
323
+ moduleFreshness: [
324
+ { name: "stale-dep", loadedVersion: "1.0.0", currentVersion: "2.0.0", stale: true },
325
+ { name: "fresh-dep", loadedVersion: "1.0.0", currentVersion: "1.0.0", stale: false },
326
+ ],
327
+ },
328
+ false,
329
+ );
330
+
331
+ expect(text).toContain("STALE_MODULE_CACHE stale-dep");
332
+ expect(text).toContain("loaded 1.0.0 at startup");
333
+ expect(text).toContain("2.0.0 is now on disk");
334
+ expect(text).toContain("systemctl --user restart pi-packed.service");
335
+ expect(text).not.toContain("fresh-dep");
336
+ });
337
+
338
+ it("prints nothing extra when moduleFreshness is absent or empty", () => {
339
+ expect(formatDoctorReport(base, false)).not.toContain("STALE_MODULE_CACHE");
340
+ expect(formatDoctorReport({ ...base, moduleFreshness: [] }, false)).not.toContain("STALE_MODULE_CACHE");
341
+ });
342
+ });
@@ -0,0 +1,3 @@
1
+ export default function conventionOnlyFixtureExtension(pi: { registerCommand: (name: string, def: unknown) => void }) {
2
+ pi.registerCommand("convention-only-fixture", { description: "registers cleanly", handler: async () => {} });
3
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "packed-install-validation-fixture-convention-only",
3
+ "version": "1.0.0",
4
+ "private": true
5
+ }
@@ -21,6 +21,7 @@ const FIXTURES = join(__dirname, "fixtures/install-validation");
21
21
  const HEALTHY = join(FIXTURES, "healthy-package");
22
22
  const BROKEN = join(FIXTURES, "broken-package");
23
23
  const NO_MANIFEST = join(FIXTURES, "no-manifest-package");
24
+ const CONVENTION_ONLY = join(FIXTURES, "convention-only-package");
24
25
  const DEP_PACKAGE = join(FIXTURES, "dep-package");
25
26
 
26
27
  const roots: string[] = [];
@@ -96,10 +97,22 @@ describe("HeadlessInstallValidator (real npm pack + tar extraction, no mocking)"
96
97
  expect(result).toEqual({ ok: true, source: "git:github.com/u/r@main", extensions: [] });
97
98
  });
98
99
 
99
- it("passes a package with no pi.extensions -- most npm packages, nothing to validate", async () => {
100
+ it("refuses a package with no pi manifest and no convention resource directory -- not a Pi package (the is-number/is-buffer shape)", async () => {
100
101
  const validator = new HeadlessInstallValidator();
101
102
  const result = await validator.validate(`npm:${NO_MANIFEST}`);
103
+ expect(result.ok).toBe(false);
104
+ expect(result.extensions).toEqual([]);
105
+ expect(result.message).toContain("not a Pi package");
106
+ }, 20_000);
107
+
108
+ it("passes a package with no pi manifest but a real convention extensions/ directory", async () => {
109
+ const validator = new HeadlessInstallValidator();
110
+ const result = await validator.validate(`npm:${CONVENTION_ONLY}`);
102
111
  expect(result.ok).toBe(true);
112
+ // Convention-discovered extensions (no pi.extensions manifest entry) are
113
+ // not headless load-checked here -- only presence gates admission; the
114
+ // per-extension mock-pi-cli check only ever runs against manifest-
115
+ // declared pi.extensions entries.
103
116
  expect(result.extensions).toEqual([]);
104
117
  }, 20_000);
105
118
 
@@ -159,6 +159,235 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
159
159
  });
160
160
  });
161
161
 
162
+ describe("ExecInstaller.update() — out-of-range update detection (confirmed live: a 0.x package past its own caret boundary reported a bare 'already up to date')", () => {
163
+ it("alreadyUpToDate but a real newer version exists outside the declared range: reports outOfRangeUpdateAvailable", async () => {
164
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
165
+ const bin = writeFakePi(scriptDir); // no rewrite: `pi update` genuinely no-ops, bound by ^0.9.8
166
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
167
+ const piHome = writePiHome({ "@scope/pkg": "0.9.8" });
168
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin, undefined, (name) =>
169
+ name === "@scope/pkg" ? "0.10.0" : undefined,
170
+ );
171
+
172
+ const outcome = await installer.update("npm:@scope/pkg@0.9.8");
173
+
174
+ expect(outcome.alreadyUpToDate).toBe(true);
175
+ expect(outcome.reloadRequired).toBe(false);
176
+ expect(outcome.outOfRangeUpdateAvailable).toBe("0.10.0");
177
+ });
178
+
179
+ it("alreadyUpToDate and genuinely current against the mirror too: no outOfRangeUpdateAvailable", async () => {
180
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
181
+ const bin = writeFakePi(scriptDir);
182
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
183
+ const piHome = writePiHome({ plain: "0.5.0" });
184
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin, undefined, () => "0.5.0");
185
+
186
+ const outcome = await installer.update("npm:plain");
187
+
188
+ expect(outcome.alreadyUpToDate).toBe(true);
189
+ expect(outcome.outOfRangeUpdateAvailable).toBeUndefined();
190
+ });
191
+
192
+ it("no latestOf wired in at all: behaves exactly as before (no cross-check attempted)", async () => {
193
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
194
+ const bin = writeFakePi(scriptDir);
195
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
196
+ const piHome = writePiHome({ "@scope/pkg": "0.9.8" });
197
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
198
+
199
+ const outcome = await installer.update("npm:@scope/pkg@0.9.8");
200
+
201
+ expect(outcome.alreadyUpToDate).toBe(true);
202
+ expect(outcome.outOfRangeUpdateAvailable).toBeUndefined();
203
+ });
204
+
205
+ it("a real version change happened: never reports outOfRangeUpdateAvailable even if the mirror knows a still-newer version", async () => {
206
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
207
+ const piHome = writePiHome({ plain: "0.5.0" });
208
+ const bin = writeFakePi(scriptDir, { piHome, name: "plain", newVersion: "0.6.0" });
209
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
210
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin, undefined, () => "0.7.0");
211
+
212
+ const outcome = await installer.update("npm:plain");
213
+
214
+ expect(outcome.alreadyUpToDate).toBe(false);
215
+ expect(outcome.reloadRequired).toBe(true);
216
+ expect(outcome.outOfRangeUpdateAvailable).toBeUndefined();
217
+ });
218
+ });
219
+
220
+ /**
221
+ * A real, general-purpose fake `pi` binary for replace() tests -- unlike
222
+ * writeFakePi/writeFakeNpm above (each tailored to one specific pre-baked
223
+ * rewrite), this one genuinely implements `install <source>`/`remove
224
+ * <source>` against a real piHome: writes/removes the real
225
+ * node_modules/<name>/package.json entry AND the real settings.json
226
+ * packages[] entry, exactly like the two-step remove+install sequence
227
+ * replace() actually drives. A plain bun script (not bash) so exact-pin
228
+ * name/version parsing (scoped packages, the LAST "@" only) is real JS,
229
+ * not fragile shell string splitting.
230
+ */
231
+ function writeFakeReplacePi(dir: string, piHome: string, options: { failInstallFor?: string } = {}): string {
232
+ const script = join(dir, "fake-replace-pi");
233
+ const body = `
234
+ const piHome = ${JSON.stringify(piHome)};
235
+ const failInstallFor = ${JSON.stringify(options.failInstallFor ?? null)};
236
+ const fs = require("node:fs");
237
+ const path = require("node:path");
238
+
239
+ function bareName(source) {
240
+ const spec = source.startsWith("npm:") ? source.slice(4) : source;
241
+ const at = spec.lastIndexOf("@");
242
+ return at > 0 ? spec.slice(0, at) : spec;
243
+ }
244
+ function versionOf(source) {
245
+ const spec = source.startsWith("npm:") ? source.slice(4) : source;
246
+ const at = spec.lastIndexOf("@");
247
+ return at > 0 ? spec.slice(at + 1) : undefined;
248
+ }
249
+ function readSettings() {
250
+ try {
251
+ return JSON.parse(fs.readFileSync(path.join(piHome, "settings.json"), "utf8"));
252
+ } catch {
253
+ return { packages: [] };
254
+ }
255
+ }
256
+ function writeSettings(settings) {
257
+ fs.writeFileSync(path.join(piHome, "settings.json"), JSON.stringify(settings, null, 2));
258
+ }
259
+ function entrySource(entry) {
260
+ return typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.source : undefined;
261
+ }
262
+
263
+ const [cmd, ...rest] = process.argv.slice(2);
264
+ const args = rest.filter((a) => a !== "-l");
265
+ const source = args[args.length - 1];
266
+ const name = bareName(source);
267
+
268
+ if (cmd === "install") {
269
+ if (failInstallFor && source === failInstallFor) {
270
+ process.stderr.write("simulated install failure for " + source + "\\n");
271
+ process.exit(1);
272
+ }
273
+ const version = versionOf(source) ?? "0.0.0";
274
+ const pkgDir = path.join(piHome, "npm", "node_modules", name);
275
+ fs.mkdirSync(pkgDir, { recursive: true });
276
+ fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify({ name, version }));
277
+ const settings = readSettings();
278
+ const packages = (settings.packages ?? []).filter((e) => bareName(entrySource(e) ?? "") !== name);
279
+ packages.push(source);
280
+ writeSettings({ ...settings, packages });
281
+ process.stdout.write("Installed " + source + "\\n");
282
+ process.exit(0);
283
+ }
284
+ if (cmd === "remove") {
285
+ const pkgDir = path.join(piHome, "npm", "node_modules", name);
286
+ fs.rmSync(pkgDir, { recursive: true, force: true });
287
+ const settings = readSettings();
288
+ const packages = (settings.packages ?? []).filter((e) => bareName(entrySource(e) ?? "") !== name);
289
+ writeSettings({ ...settings, packages });
290
+ process.stdout.write("Removed " + source + "\\n");
291
+ process.exit(0);
292
+ }
293
+ process.stderr.write("unsupported command: " + cmd + "\\n");
294
+ process.exit(1);
295
+ `;
296
+ writeFileSync(script, `#!/usr/bin/env bun\n${body}`);
297
+ chmodSync(script, 0o755);
298
+ return script;
299
+ }
300
+
301
+ describe("ExecInstaller.update({ target }) — the supervised replace() workflow for moving an exact pin", () => {
302
+ function setup(options: { failInstallFor?: string } = {}) {
303
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-replace-bin-")));
304
+ const piHome = writePiHome();
305
+ mkdirSync(piHome, { recursive: true });
306
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: [] }));
307
+ const bin = writeFakeReplacePi(scriptDir, piHome, options);
308
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
309
+ const validator = { validate: async (source: string) => ({ ok: true as const, source, extensions: [] }) };
310
+ const installer = new ExecInstaller(bin, piHome, validator, npmBin);
311
+ return { piHome, installer };
312
+ }
313
+
314
+ function configurePinned(piHome: string, entry: unknown): void {
315
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: [entry] }));
316
+ }
317
+
318
+ it("replaces an exact pin end to end: removes the old entry, installs the new one, verifies both postconditions", async () => {
319
+ const { piHome, installer } = setup();
320
+ configurePinned(piHome, "npm:@scope/pkg@1.0.0");
321
+ mkdirSync(join(piHome, "npm", "node_modules", "@scope", "pkg"), { recursive: true });
322
+ writeFileSync(join(piHome, "npm", "node_modules", "@scope", "pkg", "package.json"), JSON.stringify({ version: "1.0.0" }));
323
+
324
+ const outcome = await installer.update("npm:@scope/pkg@1.0.0", { target: "npm:@scope/pkg@2.0.0" });
325
+
326
+ expect(outcome.replaced).toBe(true);
327
+ expect(outcome.before).toEqual({ source: "npm:@scope/pkg@1.0.0", version: "1.0.0" });
328
+ expect(outcome.after).toEqual({ source: "npm:@scope/pkg@2.0.0", version: "2.0.0" });
329
+ expect(outcome.reloadRequired).toBe(true);
330
+ expect(outcome.alreadyUpToDate).toBe(false);
331
+ expect(outcome.currentVersion).toBe("2.0.0");
332
+ const settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
333
+ expect(settings.packages).toEqual(["npm:@scope/pkg@2.0.0"]);
334
+ });
335
+
336
+ it("rejects a target that names a different package, before mutating anything", async () => {
337
+ const { piHome, installer } = setup();
338
+ configurePinned(piHome, "npm:@scope/pkg@1.0.0");
339
+
340
+ await expect(installer.update("npm:@scope/pkg@1.0.0", { target: "npm:@scope/other@1.0.0" })).rejects.toThrow(
341
+ /same package/,
342
+ );
343
+ const settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
344
+ expect(settings.packages).toEqual(["npm:@scope/pkg@1.0.0"]);
345
+ });
346
+
347
+ it("rolls back to the original pin when installing the new target fails", async () => {
348
+ const { piHome, installer } = setup({ failInstallFor: "npm:@scope/pkg@2.0.0" });
349
+ configurePinned(piHome, "npm:@scope/pkg@1.0.0");
350
+ mkdirSync(join(piHome, "npm", "node_modules", "@scope", "pkg"), { recursive: true });
351
+ writeFileSync(join(piHome, "npm", "node_modules", "@scope", "pkg", "package.json"), JSON.stringify({ version: "1.0.0" }));
352
+
353
+ await expect(installer.update("npm:@scope/pkg@1.0.0", { target: "npm:@scope/pkg@2.0.0" })).rejects.toThrow(
354
+ /rolled back to npm:@scope\/pkg@1\.0\.0/,
355
+ );
356
+ const settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
357
+ expect(settings.packages).toEqual(["npm:@scope/pkg@1.0.0"]);
358
+ expect(JSON.parse(readFileSync(join(piHome, "npm", "node_modules", "@scope", "pkg", "package.json"), "utf8")).version).toBe("1.0.0");
359
+ });
360
+
361
+ it("preserves the old entry's own filter overrides onto the freshly-installed replacement entry", async () => {
362
+ const { piHome, installer } = setup();
363
+ configurePinned(piHome, { source: "npm:@scope/pkg@1.0.0", extensions: ["-extensions/one.ts"] });
364
+ mkdirSync(join(piHome, "npm", "node_modules", "@scope", "pkg"), { recursive: true });
365
+ writeFileSync(join(piHome, "npm", "node_modules", "@scope", "pkg", "package.json"), JSON.stringify({ version: "1.0.0" }));
366
+
367
+ await installer.update("npm:@scope/pkg@1.0.0", { target: "npm:@scope/pkg@2.0.0" });
368
+
369
+ const settings = JSON.parse(readFileSync(join(piHome, "settings.json"), "utf8"));
370
+ expect(settings.packages).toEqual([{ source: "npm:@scope/pkg@2.0.0", extensions: ["-extensions/one.ts"] }]);
371
+ });
372
+
373
+ it("reports pinnedSourceRequiresTarget on a plain update() call against an unchanged exact pin, with no target given", async () => {
374
+ // This scenario needs the ORIGINAL "always prints Updated, changes
375
+ // nothing for a pin" fake pi -- writeFakeReplacePi only implements
376
+ // install/remove, not update --extension.
377
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-replace-bin-")));
378
+ const piHome = writePiHome({ "@scope/pkg": "1.0.0" });
379
+ const bin = writeFakePi(scriptDir);
380
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
381
+ const plainInstaller = new ExecInstaller(bin, piHome, undefined, npmBin);
382
+
383
+ const outcome = await plainInstaller.update("npm:@scope/pkg@1.0.0");
384
+
385
+ expect(outcome.pinned).toBe(true);
386
+ expect(outcome.alreadyUpToDate).toBe(true);
387
+ expect(outcome.pinnedSourceRequiresTarget).toBe(true);
388
+ });
389
+ });
390
+
162
391
  describe("ExecInstaller — forces full dependency re-resolution, not just the target's own subtree", () => {
163
392
  it("update() fixes a stale root-level sibling that the targeted pi update never touched", async () => {
164
393
  const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));