@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.
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +33 -0
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/approval/reload.ts +51 -2
- package/extension/src/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tabs/discover.ts +13 -2
- package/extension/src/tool-output.ts +14 -1
- package/extension/src/tools.ts +46 -14
- package/extension/src/tui.ts +7 -3
- package/package.json +4 -4
- package/service/src/adoption/doctor.ts +15 -0
- package/service/src/adoption/install-validation.ts +97 -5
- package/service/src/adoption/module-freshness.ts +100 -0
- package/service/src/adoption/smoke.ts +26 -2
- package/service/src/cli/cli.ts +30 -5
- package/service/src/daemon/client.ts +13 -7
- package/service/src/daemon/daemon.ts +35 -3
- package/service/src/daemon/service.ts +42 -7
- package/service/src/daemon/vehicle-registration.ts +118 -35
- package/service/src/daemon/watcher.ts +1 -18
- package/service/src/packages/deploy-verify.ts +151 -0
- package/service/src/packages/install.ts +120 -3
- package/service/src/packages/package.ts +67 -1
- package/service/src/packages/resources.ts +63 -0
- package/service/src/public/client.ts +9 -3
- package/service/src/public/protocol.ts +13 -1
- package/service/test/cli.test.ts +75 -1
- package/service/test/deploy-verify.test.ts +168 -0
- package/service/test/doctor.test.ts +104 -1
- package/service/test/fixtures/install-validation/convention-only-package/extensions/index.ts +3 -0
- package/service/test/fixtures/install-validation/convention-only-package/package.json +5 -0
- package/service/test/install-validation.test.ts +14 -1
- package/service/test/install.test.ts +229 -0
- package/service/test/module-freshness.test.ts +146 -0
- package/service/test/resources.test.ts +66 -1
- package/service/test/service.test.ts +68 -1
- package/service/test/smoke.test.ts +49 -2
- 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
|
-
|
|
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) {
|