@danypops/pi-packed 0.21.14 → 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 (41) 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/approval/reload.ts +51 -2
  7. package/extension/src/menu-theme.ts +13 -3
  8. package/extension/src/packed.ts +2 -2
  9. package/extension/src/tabs/discover.ts +13 -2
  10. package/extension/src/tool-output.ts +14 -1
  11. package/extension/src/tools.ts +46 -14
  12. package/extension/src/tui.ts +7 -3
  13. package/package.json +4 -4
  14. package/service/src/adoption/doctor.ts +15 -0
  15. package/service/src/adoption/install-validation.ts +97 -5
  16. package/service/src/adoption/module-freshness.ts +100 -0
  17. package/service/src/adoption/smoke.ts +26 -2
  18. package/service/src/cli/cli.ts +30 -5
  19. package/service/src/daemon/client.ts +13 -7
  20. package/service/src/daemon/daemon.ts +35 -3
  21. package/service/src/daemon/service.ts +42 -7
  22. package/service/src/daemon/vehicle-registration.ts +118 -35
  23. package/service/src/daemon/watcher.ts +1 -18
  24. package/service/src/packages/deploy-verify.ts +151 -0
  25. package/service/src/packages/install.ts +120 -3
  26. package/service/src/packages/package.ts +67 -1
  27. package/service/src/packages/resources.ts +63 -0
  28. package/service/src/public/client.ts +9 -3
  29. package/service/src/public/protocol.ts +13 -1
  30. package/service/test/cli.test.ts +75 -1
  31. package/service/test/deploy-verify.test.ts +168 -0
  32. package/service/test/doctor.test.ts +104 -1
  33. package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
  34. package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
  35. package/service/test/install-validation.test.ts +14 -1
  36. package/service/test/install.test.ts +229 -0
  37. package/service/test/module-freshness.test.ts +146 -0
  38. package/service/test/resources.test.ts +66 -1
  39. package/service/test/service.test.ts +68 -1
  40. package/service/test/smoke.test.ts +49 -2
  41. package/service/test/vehicle-registration.test.ts +97 -5
@@ -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-")));
@@ -0,0 +1,146 @@
1
+ /**
2
+ * module-freshness.test.ts — the "X is not a constructor" pattern this
3
+ * exists to catch: a long-running process's in-memory copy of a dependency
4
+ * going stale after an on-disk update, with the process itself none the
5
+ * wiser until restarted.
6
+ */
7
+ import { afterEach, describe, expect, it } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ captureLoadedModule,
13
+ captureLoadedModules,
14
+ checkModuleFreshness,
15
+ checkModuleFreshnessAll,
16
+ ownRuntimeDependencyNames,
17
+ } from "../src/adoption/module-freshness.ts";
18
+
19
+ const roots: string[] = [];
20
+ afterEach(() => {
21
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
22
+ });
23
+
24
+ function track(dir: string): string {
25
+ roots.push(dir);
26
+ return dir;
27
+ }
28
+
29
+ function writeDependency(nodeModulesDir: string, name: string, version: string): string {
30
+ const dir = join(nodeModulesDir, name);
31
+ mkdirSync(dir, { recursive: true });
32
+ const packageJsonPath = join(dir, "package.json");
33
+ writeFileSync(packageJsonPath, JSON.stringify({ name, version }));
34
+ return packageJsonPath;
35
+ }
36
+
37
+ function fromPackageDir(): string {
38
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-")));
39
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "consumer", version: "1.0.0" }));
40
+ return dir;
41
+ }
42
+
43
+ describe("captureLoadedModule / checkModuleFreshness", () => {
44
+ it("reports not stale immediately after capture -- nothing has changed yet", () => {
45
+ const fromDir = fromPackageDir();
46
+ writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
47
+
48
+ const snapshot = captureLoadedModule(fromDir, "watched-dep");
49
+ expect(snapshot).toBeDefined();
50
+ expect(snapshot!.version).toBe("1.0.0");
51
+
52
+ const diagnostic = checkModuleFreshness(snapshot!);
53
+ expect(diagnostic.stale).toBe(false);
54
+ expect(diagnostic.loadedVersion).toBe("1.0.0");
55
+ expect(diagnostic.currentVersion).toBe("1.0.0");
56
+ });
57
+
58
+ it("reports stale once the on-disk package.json is rewritten after the snapshot was taken -- the exact confirmed-live pattern", async () => {
59
+ const fromDir = fromPackageDir();
60
+ const packageJsonPath = writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
61
+ const snapshot = captureLoadedModule(fromDir, "watched-dep")!;
62
+
63
+ // Simulate a pkg_update landing a new build while this process (whose
64
+ // own snapshot was already taken) keeps running -- rewrite the file
65
+ // and force its mtime forward, since two writes in the same tick can
66
+ // otherwise land on an indistinguishable mtime on a fast filesystem.
67
+ writeFileSync(packageJsonPath, JSON.stringify({ name: "watched-dep", version: "2.0.0" }));
68
+ const future = new Date(Date.now() + 5_000);
69
+ utimesSync(packageJsonPath, future, future);
70
+
71
+ const diagnostic = checkModuleFreshness(snapshot);
72
+ expect(diagnostic.stale).toBe(true);
73
+ expect(diagnostic.loadedVersion).toBe("1.0.0");
74
+ expect(diagnostic.currentVersion).toBe("2.0.0");
75
+ });
76
+
77
+ it("never reports stale when either mtime is unknown -- never guesses from version alone", () => {
78
+ const fromDir = fromPackageDir();
79
+ const packageJsonPath = writeDependency(join(fromDir, "node_modules"), "watched-dep", "1.0.0");
80
+ const snapshot = { name: "watched-dep", packageJsonPath, version: "1.0.0", mtimeMs: undefined };
81
+
82
+ expect(checkModuleFreshness(snapshot).stale).toBe(false);
83
+ });
84
+
85
+ it("returns undefined (never throws) when the dependency isn't resolvable from here at all", () => {
86
+ const fromDir = fromPackageDir();
87
+ expect(captureLoadedModule(fromDir, "does-not-exist-anywhere")).toBeUndefined();
88
+ });
89
+ });
90
+
91
+ describe("captureLoadedModules / checkModuleFreshnessAll -- bulk, one unresolvable name never blocks the rest", () => {
92
+ it("captures every resolvable name and silently skips the rest", () => {
93
+ const fromDir = fromPackageDir();
94
+ writeDependency(join(fromDir, "node_modules"), "dep-a", "1.0.0");
95
+ writeDependency(join(fromDir, "node_modules"), "dep-b", "2.0.0");
96
+
97
+ const snapshots = captureLoadedModules(fromDir, ["dep-a", "dep-b", "dep-missing"]);
98
+
99
+ expect(snapshots.map((s) => s.name)).toEqual(["dep-a", "dep-b"]);
100
+ });
101
+
102
+ it("checks every captured snapshot and reports which ones went stale", () => {
103
+ const fromDir = fromPackageDir();
104
+ const staleDepPath = writeDependency(join(fromDir, "node_modules"), "dep-a", "1.0.0");
105
+ writeDependency(join(fromDir, "node_modules"), "dep-b", "2.0.0");
106
+ const snapshots = captureLoadedModules(fromDir, ["dep-a", "dep-b"]);
107
+
108
+ writeFileSync(staleDepPath, JSON.stringify({ name: "dep-a", version: "1.1.0" }));
109
+ const future = new Date(Date.now() + 5_000);
110
+ utimesSync(staleDepPath, future, future);
111
+
112
+ const diagnostics = checkModuleFreshnessAll(snapshots);
113
+ expect(diagnostics.find((d) => d.name === "dep-a")?.stale).toBe(true);
114
+ expect(diagnostics.find((d) => d.name === "dep-b")?.stale).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("ownRuntimeDependencyNames", () => {
119
+ it("returns exactly the dependencies field's keys, never devDependencies/peerDependencies", () => {
120
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-pkg-")));
121
+ const packageJsonPath = join(dir, "package.json");
122
+ writeFileSync(
123
+ packageJsonPath,
124
+ JSON.stringify({
125
+ name: "pkg",
126
+ dependencies: { "runtime-a": "^1.0.0", "runtime-b": "^2.0.0" },
127
+ devDependencies: { "dev-only": "^1.0.0" },
128
+ peerDependencies: { "peer-only": "*" },
129
+ }),
130
+ );
131
+
132
+ expect(ownRuntimeDependencyNames(packageJsonPath)).toEqual(["runtime-a", "runtime-b"]);
133
+ });
134
+
135
+ it("returns an empty array, never throws, for a missing or malformed package.json", () => {
136
+ expect(ownRuntimeDependencyNames("/nonexistent/package.json")).toEqual([]);
137
+ });
138
+
139
+ it("returns an empty array when dependencies is absent", () => {
140
+ const dir = track(mkdtempSync(join(tmpdir(), "packed-module-freshness-pkg-")));
141
+ const packageJsonPath = join(dir, "package.json");
142
+ writeFileSync(packageJsonPath, JSON.stringify({ name: "pkg" }));
143
+
144
+ expect(ownRuntimeDependencyNames(packageJsonPath)).toEqual([]);
145
+ });
146
+ });
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
6
  import { createApp, type OperationInputs, type OperationName, type OperationOutputs } from "../src/daemon/service.ts";
7
7
  import type { Installer, PkgInfo, Registry, SearchPage } from "../src/packages/package.ts";
8
- import { listPackageResources, toggleResource } from "../src/packages/resources.ts";
8
+ import { applyEntryFilters, captureEntryFilters, listPackageResources, toggleResource } from "../src/packages/resources.ts";
9
9
 
10
10
  const roots: string[] = [];
11
11
  afterEach(() => {
@@ -258,3 +258,68 @@ describe("toggleResource", () => {
258
258
  expect(result.error).toContain("not found");
259
259
  });
260
260
  });
261
+
262
+ describe("captureEntryFilters / applyEntryFilters (preserving a pinned package's own filter overrides across a replace)", () => {
263
+ it("captures every filter field an object-form entry declares", () => {
264
+ const home = piHome([{ source: "npm:pi-demo@1.0.0", extensions: ["-extensions/one.ts"], skills: ["+skills/two.md"] }]);
265
+
266
+ const filters = captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0");
267
+
268
+ expect(filters).toEqual({ extensions: ["-extensions/one.ts"], skills: ["+skills/two.md"] });
269
+ });
270
+
271
+ it("returns undefined for a bare string entry -- nothing to preserve", () => {
272
+ const home = piHome(["npm:pi-demo@1.0.0"]);
273
+ expect(captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0")).toBeUndefined();
274
+ });
275
+
276
+ it("returns undefined when the source isn't found at all", () => {
277
+ const home = piHome(["npm:other"]);
278
+ expect(captureEntryFilters(join(home, "settings.json"), "npm:pi-demo@1.0.0")).toBeUndefined();
279
+ });
280
+
281
+ it("re-applies captured filters onto the new source's own freshly-installed bare-string entry", async () => {
282
+ const home = piHome(["npm:pi-demo@2.0.0"]);
283
+ const settingsPath = join(home, "settings.json");
284
+
285
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", { extensions: ["-extensions/one.ts"] });
286
+
287
+ expect(ok).toBe(true);
288
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
289
+ expect(written.packages).toEqual([{ source: "npm:pi-demo@2.0.0", extensions: ["-extensions/one.ts"] }]);
290
+ });
291
+
292
+ it("is a no-op (still ok:true) when there are no filters to preserve", async () => {
293
+ const home = piHome(["npm:pi-demo@2.0.0"]);
294
+ const settingsPath = join(home, "settings.json");
295
+
296
+ expect(await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", undefined)).toBe(true);
297
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
298
+ expect(written.packages).toEqual(["npm:pi-demo@2.0.0"]);
299
+ });
300
+
301
+ it("reports failure (never throws) when the target entry doesn't exist yet -- e.g. install itself failed", async () => {
302
+ const home = piHome(["npm:unrelated"]);
303
+ const settingsPath = join(home, "settings.json");
304
+
305
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", { extensions: ["-extensions/one.ts"] });
306
+
307
+ expect(ok).toBe(false);
308
+ });
309
+
310
+ it("round-trips through a real capture-then-apply replace simulation", async () => {
311
+ const home = piHome([{ source: "npm:pi-demo@1.0.0", extensions: ["-extensions/one.ts"] }]);
312
+ const settingsPath = join(home, "settings.json");
313
+ const filters = captureEntryFilters(settingsPath, "npm:pi-demo@1.0.0");
314
+
315
+ // Simulate remove()+install(): the old entry is gone, a fresh bare-string
316
+ // entry exists for the new pinned version.
317
+ writeFileSync(settingsPath, JSON.stringify({ packages: ["npm:pi-demo@2.0.0"] }));
318
+
319
+ const ok = await applyEntryFilters(settingsPath, "npm:pi-demo@2.0.0", filters);
320
+
321
+ expect(ok).toBe(true);
322
+ const written = JSON.parse(readFileSync(settingsPath, "utf8"));
323
+ expect(written.packages).toEqual([{ source: "npm:pi-demo@2.0.0", extensions: ["-extensions/one.ts"] }]);
324
+ });
325
+ });
@@ -52,8 +52,10 @@ class FakeInstaller implements Installer {
52
52
  return this.output;
53
53
  }
54
54
  updateOutcome: Partial<UpdateOutcome> = {};
55
- async update(source: string): Promise<UpdateOutcome> {
55
+ gotTarget: string | undefined;
56
+ async update(source: string, options?: { target?: string }): Promise<UpdateOutcome> {
56
57
  this.updated = source;
58
+ this.gotTarget = options?.target;
57
59
  return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
58
60
  }
59
61
  }
@@ -539,6 +541,71 @@ describe("service app", () => {
539
541
  });
540
542
  });
541
543
 
544
+ it("POST /update threads target through to the installer and validates it the same way as source", async () => {
545
+ const inst = new FakeInstaller();
546
+ const app = createApp(deps({ inst }));
547
+ const invalidTarget = await app.fetch(
548
+ new Request("http://x/update", {
549
+ method: "POST",
550
+ headers: { ...auth, "content-type": "application/json" },
551
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "not a valid source!", approved: true }),
552
+ }),
553
+ );
554
+ expect(invalidTarget.status).toBe(400);
555
+ expect(inst.updated).toBe("");
556
+
557
+ const valid = await app.fetch(
558
+ new Request("http://x/update", {
559
+ method: "POST",
560
+ headers: { ...auth, "content-type": "application/json" },
561
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
562
+ }),
563
+ );
564
+ expect(valid.status).toBe(200);
565
+ expect(inst.updated).toBe("npm:@scope/pkg@1.0.0");
566
+ expect(inst.gotTarget).toBe("npm:@scope/pkg@2.0.0");
567
+ });
568
+
569
+ it("POST /update reconciles the Vehicle under the NEW (target) source after a replace, not the removed one", async () => {
570
+ const svc = new FakeDaemonServiceInstaller();
571
+ const inst = new FakeInstaller();
572
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-vehicle-replace-")));
573
+ const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
574
+ const response = await app.fetch(
575
+ new Request("http://x/update", {
576
+ method: "POST",
577
+ headers: { ...auth, "content-type": "application/json" },
578
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
579
+ }),
580
+ );
581
+ expect(await response.json()).toMatchObject({ ok: true, serviceReconciled: true });
582
+ expect(svc.restartGotSource).toBe("npm:@scope/pkg@2.0.0");
583
+ });
584
+
585
+ it("POST /update surfaces pinnedSourceRequiresTarget/replaced/before/after/rollback through the wire response", async () => {
586
+ const inst = new FakeInstaller();
587
+ inst.updateOutcome = {
588
+ replaced: true,
589
+ before: { source: "npm:@scope/pkg@1.0.0", version: "1.0.0" },
590
+ after: { source: "npm:@scope/pkg@2.0.0", version: "2.0.0" },
591
+ rollback: { attempted: false, ok: true },
592
+ };
593
+ const app = createApp(deps({ inst }));
594
+ const response = await app.fetch(
595
+ new Request("http://x/update", {
596
+ method: "POST",
597
+ headers: { ...auth, "content-type": "application/json" },
598
+ body: JSON.stringify({ source: "npm:@scope/pkg@1.0.0", target: "npm:@scope/pkg@2.0.0", approved: true }),
599
+ }),
600
+ );
601
+ expect(await response.json()).toMatchObject({
602
+ replaced: true,
603
+ before: { source: "npm:@scope/pkg@1.0.0", version: "1.0.0" },
604
+ after: { source: "npm:@scope/pkg@2.0.0", version: "2.0.0" },
605
+ rollback: { attempted: false, ok: true },
606
+ });
607
+ });
608
+
542
609
  it("GET /updates serves the watcher snapshot", async () => {
543
610
  const d = deps();
544
611
  await saveUpdates(d.stateDir, {
@@ -1,10 +1,10 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { checkPackage } from "../src/adoption/check.ts";
7
- import { runExtensionSmoke } from "../src/adoption/smoke.ts";
7
+ import { resolveDependencyModulesDir, runExtensionSmoke } from "../src/adoption/smoke.ts";
8
8
 
9
9
  const roots: string[] = [];
10
10
  afterEach(() => {
@@ -47,6 +47,53 @@ function extension(source: string): { root: string; path: string } {
47
47
  return { root, path };
48
48
  }
49
49
 
50
+ /** A minimal fake dependency package.json -- no "exports" map, so a bare
51
+ * `require.resolve("<name>/package.json")` subpath is allowed by Node's
52
+ * default (no-exports-map) resolution rules without needing to replicate
53
+ * the real jiti package's own exports field. */
54
+ function writeFakeDependency(nodeModulesDir: string, name: string): void {
55
+ const dir = join(nodeModulesDir, name);
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name, version: "0.0.0-fixture" }));
58
+ }
59
+
60
+ describe("resolveDependencyModulesDir (packed doctor's real-layout dependency resolution)", () => {
61
+ it("resolves a dependency nested directly under the package's own node_modules", () => {
62
+ const root = mkdtempSync(join(tmpdir(), "packed-resolve-nested-"));
63
+ roots.push(root);
64
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" }));
65
+ writeFakeDependency(join(root, "node_modules"), "fake-loader");
66
+
67
+ const resolved = resolveDependencyModulesDir(root, "fake-loader");
68
+
69
+ expect(resolved).toBe(realpathSync(join(root, "node_modules", "fake-loader")));
70
+ });
71
+
72
+ it("resolves a dependency hoisted to a shared ancestor node_modules -- confirmed live bug: packed doctor --json crashed assuming a fixed nested-only path", () => {
73
+ const workspaceRoot = mkdtempSync(join(tmpdir(), "packed-resolve-hoisted-"));
74
+ roots.push(workspaceRoot);
75
+ const packageDir = join(workspaceRoot, "node_modules", "@danypops", "pi-packed");
76
+ mkdirSync(packageDir, { recursive: true });
77
+ writeFileSync(join(packageDir, "package.json"), JSON.stringify({ name: "@danypops/pi-packed", version: "1.0.0" }));
78
+ // The dependency sits in the WORKSPACE's node_modules, never nested
79
+ // under the package's own -- exactly what npm's hoisting produces
80
+ // when another package in the tree also depends on it.
81
+ writeFakeDependency(join(workspaceRoot, "node_modules"), "fake-loader");
82
+
83
+ const resolved = resolveDependencyModulesDir(packageDir, "fake-loader");
84
+
85
+ expect(resolved).toBe(realpathSync(join(workspaceRoot, "node_modules", "fake-loader")));
86
+ });
87
+
88
+ it("returns undefined (never throws) when the dependency is genuinely unresolvable from here", () => {
89
+ const root = mkdtempSync(join(tmpdir(), "packed-resolve-missing-"));
90
+ roots.push(root);
91
+ writeFileSync(join(root, "package.json"), JSON.stringify({ name: "pkg", version: "1.0.0" }));
92
+
93
+ expect(resolveDependencyModulesDir(root, "does-not-exist-anywhere")).toBeUndefined();
94
+ });
95
+ });
96
+
50
97
  describeIfSandboxed("isolated extension smoke runner", () => {
51
98
  it("captures bounded registrations without a model", async () => {
52
99
  const fixture = extension(`export default function (pi: any) {