@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.
- 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/menu-theme.ts +13 -3
- package/extension/src/packed.ts +2 -2
- package/extension/src/tools.ts +24 -8
- 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
|
@@ -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) {
|
|
@@ -70,14 +70,29 @@ async function invoke(app: { fetch(request: Request): Promise<Response> }, name:
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
describe("packed's daemon operation surface, through the real Vehicle wire protocol", () => {
|
|
73
|
-
it("GET /vehicle/manifest lists all 29 operations, authenticated the same as /api/v1/ops", async () => {
|
|
73
|
+
it("GET /vehicle/manifest lists all 29 operations plus the registry's own vehicle.approval.resolve, authenticated the same as /api/v1/ops", async () => {
|
|
74
74
|
const app = createApp(deps());
|
|
75
75
|
const unauthorized = await app.fetch(new Request("http://packed.internal/vehicle/manifest"));
|
|
76
76
|
expect(unauthorized.status).toBe(401);
|
|
77
77
|
const response = await app.fetch(new Request("http://packed.internal/vehicle/manifest", { headers: { authorization: "Bearer test-token" } }));
|
|
78
78
|
expect(response.status).toBe(200);
|
|
79
79
|
const body = (await response.json()) as { operations: Array<{ name: string }> };
|
|
80
|
-
expect(body.operations.map((o) => o.name).sort()).toEqual([...OPERATION_NAMES].sort());
|
|
80
|
+
expect(body.operations.map((o) => o.name).sort()).toEqual([...OPERATION_NAMES, "vehicle.approval.resolve"].sort());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("a mutation's approvalRequired reflects security.ts's own per-operation classification, not a coarse effect default -- restart_service is gated despite sharing external-write with the never-gated catalog.sync", async () => {
|
|
84
|
+
const app = createApp(deps());
|
|
85
|
+
const response = await app.fetch(new Request("http://packed.internal/vehicle/manifest", { headers: { authorization: "Bearer test-token" } }));
|
|
86
|
+
const body = (await response.json()) as { operations: Array<{ name: string; approvalRequired?: boolean }> };
|
|
87
|
+
const byName = new Map(body.operations.map((o) => [o.name, o.approvalRequired]));
|
|
88
|
+
expect(byName.get("package.restart_service")).toBe(true);
|
|
89
|
+
expect(byName.get("package.reconcile_services")).toBe(true);
|
|
90
|
+
expect(byName.get("package.catalog.sync")).toBe(false);
|
|
91
|
+
expect(byName.get("package.index.build")).toBe(false);
|
|
92
|
+
expect(byName.get("resources.toggle")).toBe(true);
|
|
93
|
+
expect(byName.get("package.security.set")).toBe(true);
|
|
94
|
+
expect(byName.get("setup.export")).toBe(false);
|
|
95
|
+
expect(byName.get("setup.update")).toBe(false);
|
|
81
96
|
});
|
|
82
97
|
|
|
83
98
|
it("package.search round-trips through /vehicle/invoke, matching /api/v1/ops's own shape", async () => {
|
|
@@ -102,11 +117,88 @@ describe("packed's daemon operation surface, through the real Vehicle wire proto
|
|
|
102
117
|
expect(result.body.error?.category).toBe("authorization");
|
|
103
118
|
});
|
|
104
119
|
|
|
105
|
-
it("
|
|
120
|
+
it("a genuinely approved mutation (through Vehicle's own request/resolve/capability dance) succeeds exactly like an approved /api/v1/ops call -- an approved:true field in the input body alone is no longer enough, since Vehicle's own gate never reads it", async () => {
|
|
106
121
|
const app = createApp(deps());
|
|
107
|
-
const
|
|
122
|
+
const stillDenied = await invoke(app, "package.remove", { name: "pi-lsp", approved: true });
|
|
123
|
+
expect(stillDenied.status).toBe(403);
|
|
124
|
+
|
|
125
|
+
const denied = await invoke(app, "package.remove", { name: "pi-lsp" });
|
|
126
|
+
const requestId = (denied.body.error as unknown as { details?: { requestId?: string } }).details?.requestId;
|
|
127
|
+
expect(typeof requestId).toBe("string");
|
|
128
|
+
const resolveResponse = await app.fetch(
|
|
129
|
+
new Request("http://packed.internal/vehicle/invoke", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
name: "vehicle.approval.resolve",
|
|
134
|
+
version: 1,
|
|
135
|
+
input: { requestId, decision: "granted" },
|
|
136
|
+
permissions: ["vehicle:approvals:resolve"],
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
const resolved = {
|
|
141
|
+
status: resolveResponse.status,
|
|
142
|
+
body: (await resolveResponse.json()) as { output?: { capability?: string } },
|
|
143
|
+
};
|
|
144
|
+
expect(resolved.status).toBe(200);
|
|
145
|
+
const capability = (resolved.body.output as { capability?: string }).capability;
|
|
146
|
+
expect(typeof capability).toBe("string");
|
|
147
|
+
|
|
148
|
+
const response = await app.fetch(
|
|
149
|
+
new Request("http://packed.internal/vehicle/invoke", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
name: "package.remove",
|
|
154
|
+
version: 1,
|
|
155
|
+
input: { name: "pi-lsp" },
|
|
156
|
+
permissions: ["packed:read", "packed:write"],
|
|
157
|
+
approvalCapability: capability,
|
|
158
|
+
}),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
expect(response.status).toBe(200);
|
|
162
|
+
const body = (await response.json()) as { output: { ok: boolean } };
|
|
163
|
+
expect(body.output.ok).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("mutationApproval: never disables the registry's own gate too, live, the instant /security POST changes it -- no daemon restart", async () => {
|
|
167
|
+
const app = createApp(deps());
|
|
168
|
+
const deniedFirst = await invoke(app, "package.remove", { name: "pi-lsp" });
|
|
169
|
+
expect(deniedFirst.status).toBe(403);
|
|
170
|
+
|
|
171
|
+
const securityPost = await app.fetch(
|
|
172
|
+
new Request("http://packed.internal/security", {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
|
175
|
+
body: JSON.stringify({ mutationApproval: "never", approved: true }),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
expect(securityPost.status).toBe(200);
|
|
179
|
+
|
|
180
|
+
const allowedNow = await invoke(app, "package.remove", { name: "pi-lsp" });
|
|
181
|
+
expect(allowedNow.status).toBe(200);
|
|
182
|
+
expect((allowedNow.body.output as { ok: boolean }).ok).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("a daemon that boots with mutationApproval already never on disk starts with the gate already disabled, not just after the first /security POST", async () => {
|
|
186
|
+
const stateDir = temporaryRoot("packed-vehicle-never-");
|
|
187
|
+
const bootstrap = createApp(deps({ stateDir }));
|
|
188
|
+
const prep = await bootstrap.fetch(
|
|
189
|
+
new Request("http://packed.internal/security", {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ mutationApproval: "never", approved: true }),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
expect(prep.status).toBe(200);
|
|
196
|
+
|
|
197
|
+
// A brand new app instance against the SAME on-disk state -- simulates a fresh daemon
|
|
198
|
+
// process starting up after the setting was already changed by a previous run.
|
|
199
|
+
const freshBoot = createApp(deps({ stateDir }));
|
|
200
|
+
const result = await invoke(freshBoot, "package.remove", { name: "pi-lsp" });
|
|
108
201
|
expect(result.status).toBe(200);
|
|
109
|
-
expect((result.body.output as { ok: boolean }).ok).toBe(true);
|
|
110
202
|
});
|
|
111
203
|
|
|
112
204
|
it("a handler-thrown validation error keeps its own real message, mapped to Vehicle's validation category (not a generic internal 500)", async () => {
|