@danypops/pi-packed 0.19.12 → 0.20.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.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +0 -1
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/model.ts +11 -4
- package/package.json +17 -5
- package/service/src/adoption/doctor.ts +5 -1
- package/service/src/adoption/service-doctor.ts +67 -94
- package/service/src/cli/cli.ts +32 -60
- package/service/src/daemon/client.ts +20 -26
- package/service/src/daemon/daemon-service.ts +127 -37
- package/service/src/daemon/daemon.ts +24 -2
- package/service/src/daemon/service.ts +76 -11
- package/service/src/daemon/vehicle-registration.ts +173 -0
- package/service/src/public/client.ts +7 -19
- package/service/src/public/protocol.ts +0 -1
- package/service/src/security/security.ts +2 -0
- package/service/src/shared/constants.ts +2 -0
- package/service/test/cli.test.ts +121 -137
- package/service/test/daemon-kit-migration.test.ts +1 -0
- package/service/test/daemon-maintenance.test.ts +127 -0
- package/service/test/daemon-service.test.ts +93 -40
- package/service/test/public-client.test.ts +1 -1
- package/service/test/security.test.ts +1 -0
- package/service/test/service-doctor.test.ts +53 -95
- package/service/test/service.test.ts +89 -13
- package/service/test/vehicle-registration.test.ts +121 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { ServiceSpec } from "@danypops/vehicle-server/service";
|
|
@@ -62,30 +62,45 @@ class FakeDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
62
62
|
gotPiHome = "";
|
|
63
63
|
gotSource = "";
|
|
64
64
|
resolveFailure: string | undefined;
|
|
65
|
+
notADaemon = false;
|
|
65
66
|
installFailure: string | undefined;
|
|
67
|
+
removeGotPiHome = "";
|
|
68
|
+
removeGotSource = "";
|
|
66
69
|
restartGotPiHome = "";
|
|
67
70
|
restartGotSource = "";
|
|
68
71
|
restartResolveFailure: string | undefined;
|
|
72
|
+
restartNotADaemon = false;
|
|
69
73
|
restartReason: string | undefined;
|
|
70
74
|
restarted = true;
|
|
71
|
-
spec: ServiceSpec = {
|
|
72
|
-
|
|
75
|
+
spec: ServiceSpec = {
|
|
76
|
+
name: "probe",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
binPath: "/opt/probe/cli.js",
|
|
79
|
+
handlePath: "/tmp/probe.handle.json",
|
|
80
|
+
};
|
|
81
|
+
async install(
|
|
73
82
|
piHome: string,
|
|
74
83
|
source: string,
|
|
75
|
-
): { ok: true; result: { installed: true } | { installed: false; reason: string }; spec: ServiceSpec } | { ok: false; reason: string } {
|
|
84
|
+
): Promise<{ ok: true; result: { installed: true } | { installed: false; reason: string }; spec: ServiceSpec } | { ok: false; reason: string }> {
|
|
76
85
|
this.gotPiHome = piHome;
|
|
77
86
|
this.gotSource = source;
|
|
78
|
-
if (this.resolveFailure) return { ok: false, reason: this.resolveFailure };
|
|
87
|
+
if (this.resolveFailure) return { ok: false, reason: this.resolveFailure, ...(this.notADaemon ? { notADaemon: true } : {}) };
|
|
79
88
|
if (this.installFailure) return { ok: true, result: { installed: false, reason: this.installFailure }, spec: this.spec };
|
|
80
89
|
return { ok: true, result: { installed: true }, spec: this.spec };
|
|
81
90
|
}
|
|
82
|
-
|
|
91
|
+
async remove(piHome: string, source: string): Promise<{ ok: true; result: { installed: true }; spec: ServiceSpec }> {
|
|
92
|
+
this.removeGotPiHome = piHome;
|
|
93
|
+
this.removeGotSource = source;
|
|
94
|
+
return { ok: true, result: { installed: true }, spec: this.spec };
|
|
95
|
+
}
|
|
96
|
+
async restart(
|
|
83
97
|
piHome: string,
|
|
84
98
|
source: string,
|
|
85
|
-
): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
|
|
99
|
+
): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
|
|
86
100
|
this.restartGotPiHome = piHome;
|
|
87
101
|
this.restartGotSource = source;
|
|
88
|
-
if (this.restartResolveFailure)
|
|
102
|
+
if (this.restartResolveFailure)
|
|
103
|
+
return { ok: false, reason: this.restartResolveFailure, ...(this.restartNotADaemon ? { notADaemon: true } : {}) };
|
|
89
104
|
return { ok: true, restarted: this.restarted, reason: this.restartReason, spec: this.spec };
|
|
90
105
|
}
|
|
91
106
|
}
|
|
@@ -224,7 +239,10 @@ describe("service app", () => {
|
|
|
224
239
|
|
|
225
240
|
it("POST /install accepts valid sources, reports failures in-band", async () => {
|
|
226
241
|
const inst = new FakeInstaller();
|
|
227
|
-
const
|
|
242
|
+
const service = new FakeDaemonServiceInstaller();
|
|
243
|
+
service.resolveFailure = "not a Vehicle";
|
|
244
|
+
service.notADaemon = true;
|
|
245
|
+
const app = createApp(deps({ inst, daemonServiceInstaller: service }));
|
|
228
246
|
for (const source of ["npm:foo", "npm:@scope/pkg@1.2.3", "git:github.com/u/r@v1", "https://github.com/u/r"]) {
|
|
229
247
|
const res = await app.fetch(
|
|
230
248
|
new Request("http://x/install", {
|
|
@@ -252,6 +270,23 @@ describe("service app", () => {
|
|
|
252
270
|
expect(body.output).toContain("npm ERR! 404");
|
|
253
271
|
});
|
|
254
272
|
|
|
273
|
+
it("POST /install configures an npm package's persistent Vehicle under the same approval", async () => {
|
|
274
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
275
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-install-vehicle-"));
|
|
276
|
+
const app = createApp(deps({ daemonServiceInstaller: svc, piHome }));
|
|
277
|
+
const response = await app.fetch(
|
|
278
|
+
new Request("http://x/install", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
281
|
+
body: JSON.stringify({ source: "npm:probe", approved: true }),
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
expect(response.status).toBe(200);
|
|
285
|
+
expect(await response.json()).toMatchObject({ ok: true, service: { name: "probe", binPath: "/opt/probe/cli.js" } });
|
|
286
|
+
expect(svc.gotPiHome).toBe(piHome);
|
|
287
|
+
expect(svc.gotSource).toBe("npm:probe");
|
|
288
|
+
});
|
|
289
|
+
|
|
255
290
|
it("POST /install-service rejects invalid sources and requires approval, matching /install's own guard", async () => {
|
|
256
291
|
const svc = new FakeDaemonServiceInstaller();
|
|
257
292
|
const app = createApp(deps({ daemonServiceInstaller: svc }));
|
|
@@ -307,7 +342,7 @@ describe("service app", () => {
|
|
|
307
342
|
const body = (await res.json()) as any;
|
|
308
343
|
expect(body.ok).toBe(true);
|
|
309
344
|
expect(body.output).toContain("probe");
|
|
310
|
-
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js"
|
|
345
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js" });
|
|
311
346
|
expect(svc.gotPiHome).toBe(piHome);
|
|
312
347
|
expect(svc.gotSource).toBe("npm:web-spider-daemon");
|
|
313
348
|
});
|
|
@@ -340,7 +375,7 @@ describe("service app", () => {
|
|
|
340
375
|
const body = (await installFailure.json()) as any;
|
|
341
376
|
expect(body.ok).toBe(false);
|
|
342
377
|
expect(body.output).toContain("no supported Linux init system");
|
|
343
|
-
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js"
|
|
378
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js" });
|
|
344
379
|
});
|
|
345
380
|
|
|
346
381
|
it("POST /restart-service rejects invalid sources and requires approval, matching /install-service's own guard", async () => {
|
|
@@ -385,7 +420,7 @@ describe("service app", () => {
|
|
|
385
420
|
const body = (await res.json()) as any;
|
|
386
421
|
expect(body.ok).toBe(true);
|
|
387
422
|
expect(body.restarted).toBe(true);
|
|
388
|
-
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js"
|
|
423
|
+
expect(body.spec).toEqual({ name: "probe", binPath: "/opt/probe/cli.js" });
|
|
389
424
|
expect(svc.restartGotPiHome).toBe(piHome);
|
|
390
425
|
expect(svc.restartGotSource).toBe("npm:web-spider-daemon");
|
|
391
426
|
});
|
|
@@ -424,7 +459,10 @@ describe("service app", () => {
|
|
|
424
459
|
|
|
425
460
|
it("POST /update validates, authorizes, and delegates one Pi package source", async () => {
|
|
426
461
|
const inst = new FakeInstaller();
|
|
427
|
-
const
|
|
462
|
+
const service = new FakeDaemonServiceInstaller();
|
|
463
|
+
service.restartResolveFailure = "not a Vehicle";
|
|
464
|
+
service.restartNotADaemon = true;
|
|
465
|
+
const app = createApp(deps({ inst, daemonServiceInstaller: service }));
|
|
428
466
|
const denied = await app.fetch(
|
|
429
467
|
new Request("http://x/update", {
|
|
430
468
|
method: "POST",
|
|
@@ -452,6 +490,22 @@ describe("service app", () => {
|
|
|
452
490
|
expect(inst.updated).toBe("npm:pi-lsp");
|
|
453
491
|
});
|
|
454
492
|
|
|
493
|
+
it("POST /update reconciles an installed Vehicle after a real package change", async () => {
|
|
494
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
495
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-update-vehicle-"));
|
|
496
|
+
const app = createApp(deps({ daemonServiceInstaller: svc, piHome }));
|
|
497
|
+
const response = await app.fetch(
|
|
498
|
+
new Request("http://x/update", {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
501
|
+
body: JSON.stringify({ source: "npm:probe", approved: true }),
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
expect(await response.json()).toMatchObject({ ok: true, serviceReconciled: true });
|
|
505
|
+
expect(svc.restartGotPiHome).toBe(piHome);
|
|
506
|
+
expect(svc.restartGotSource).toBe("npm:probe");
|
|
507
|
+
});
|
|
508
|
+
|
|
455
509
|
it("POST /update reports an honest no-op instead of trusting pi's always-0-exit-code text", async () => {
|
|
456
510
|
const inst = new FakeInstaller();
|
|
457
511
|
inst.updateOutcome = { reloadRequired: false, alreadyUpToDate: true, pinned: true, previousVersion: "1.0.0", currentVersion: "1.0.0" };
|
|
@@ -527,6 +581,28 @@ describe("service app", () => {
|
|
|
527
581
|
expect(inst.removed).toBe("npm:pi-lsp");
|
|
528
582
|
});
|
|
529
583
|
|
|
584
|
+
it("POST /remove removes declared Vehicle state before deleting the package", async () => {
|
|
585
|
+
const piHome = mkdtempSync(join(tmpdir(), "packed-remove-vehicle-"));
|
|
586
|
+
const packageDir = join(piHome, "npm", "node_modules", "probe");
|
|
587
|
+
mkdirSync(packageDir, { recursive: true });
|
|
588
|
+
writeFileSync(
|
|
589
|
+
join(packageDir, "package.json"),
|
|
590
|
+
JSON.stringify({ name: "probe", version: "1.0.0", packed: { daemonService: { binPath: "cli.js" } } }),
|
|
591
|
+
);
|
|
592
|
+
const svc = new FakeDaemonServiceInstaller();
|
|
593
|
+
const app = createApp(deps({ piHome, daemonServiceInstaller: svc }));
|
|
594
|
+
const response = await app.fetch(
|
|
595
|
+
new Request("http://x/remove", {
|
|
596
|
+
method: "POST",
|
|
597
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
598
|
+
body: JSON.stringify({ name: "probe", approved: true }),
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
expect((await response.json()) as unknown).toMatchObject({ ok: true, serviceRemoved: true });
|
|
602
|
+
expect(svc.removeGotPiHome).toBe(piHome);
|
|
603
|
+
expect(svc.removeGotSource).toBe("npm:probe");
|
|
604
|
+
});
|
|
605
|
+
|
|
530
606
|
it("GET /catalog serves the SQLite mirror", async () => {
|
|
531
607
|
const d = deps();
|
|
532
608
|
const db = openDb(dbPath(d.stateDir));
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { OPERATION_NAMES } from "../src/daemon/service.ts";
|
|
6
|
+
import { createApp, type Deps } from "../src/daemon/service.ts";
|
|
7
|
+
import type { Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
|
|
8
|
+
|
|
9
|
+
class FakeRegistry implements Registry {
|
|
10
|
+
constructor(
|
|
11
|
+
private results: Pkg[] = [],
|
|
12
|
+
private total = 0,
|
|
13
|
+
) {}
|
|
14
|
+
async search(query: string, limit: number): Promise<SearchPage> {
|
|
15
|
+
return { results: this.results, total: this.total };
|
|
16
|
+
}
|
|
17
|
+
async searchPage(): Promise<SearchPage> {
|
|
18
|
+
return { results: this.results, total: this.total };
|
|
19
|
+
}
|
|
20
|
+
async searchAll(): Promise<Pkg[]> {
|
|
21
|
+
return this.results;
|
|
22
|
+
}
|
|
23
|
+
async info(name: string): Promise<PkgInfo> {
|
|
24
|
+
return { name, version: "1.0.0" };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class FakeInstaller implements Installer {
|
|
29
|
+
async install(source: string): Promise<string> {
|
|
30
|
+
return `installed ${source}`;
|
|
31
|
+
}
|
|
32
|
+
async remove(source: string): Promise<string> {
|
|
33
|
+
return `removed ${source}`;
|
|
34
|
+
}
|
|
35
|
+
async update(source: string): Promise<UpdateOutcome> {
|
|
36
|
+
return { output: `updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: false };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deps(over: Partial<Deps> = {}): Deps {
|
|
41
|
+
return {
|
|
42
|
+
reg: new FakeRegistry([{ name: "pi-lsp", version: "1.0.0", description: "An LSP package" }], 1),
|
|
43
|
+
inst: new FakeInstaller(),
|
|
44
|
+
token: "test-token",
|
|
45
|
+
stateDir: mkdtempSync(join(tmpdir(), "packed-vehicle-")),
|
|
46
|
+
...over,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function invoke(app: { fetch(request: Request): Promise<Response> }, name: string, input: Record<string, unknown>, token = "test-token") {
|
|
51
|
+
const response = await app.fetch(
|
|
52
|
+
new Request("http://packed.internal/vehicle/invoke", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ name, version: 1, input, permissions: ["packed:read", "packed:write"] }),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return { status: response.status, body: (await response.json()) as { output?: unknown; error?: { category: string; message: string } } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("packed's daemon operation surface, through the real Vehicle wire protocol", () => {
|
|
62
|
+
it("GET /vehicle/manifest lists all 29 operations, authenticated the same as /api/v1/ops", async () => {
|
|
63
|
+
const app = createApp(deps());
|
|
64
|
+
const unauthorized = await app.fetch(new Request("http://packed.internal/vehicle/manifest"));
|
|
65
|
+
expect(unauthorized.status).toBe(401);
|
|
66
|
+
const response = await app.fetch(new Request("http://packed.internal/vehicle/manifest", { headers: { authorization: "Bearer test-token" } }));
|
|
67
|
+
expect(response.status).toBe(200);
|
|
68
|
+
const body = (await response.json()) as { operations: Array<{ name: string }> };
|
|
69
|
+
expect(body.operations.map((o) => o.name).sort()).toEqual([...OPERATION_NAMES].sort());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("package.search round-trips through /vehicle/invoke, matching /api/v1/ops's own shape", async () => {
|
|
73
|
+
const app = createApp(deps());
|
|
74
|
+
const result = await invoke(app, "package.search", { query: "lsp", limit: 10 });
|
|
75
|
+
expect(result.status).toBe(200);
|
|
76
|
+
expect((result.body.output as { total: number }).total).toBe(1);
|
|
77
|
+
expect((result.body.output as { results: Array<{ name: string }> }).results[0]?.name).toBe("pi-lsp");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("package.installed and pi.status (daemon-only, never a Pi tool) are still reachable through /vehicle/invoke", async () => {
|
|
81
|
+
const app = createApp(deps({ piHome: mkdtempSync(join(tmpdir(), "packed-vehicle-pihome-")) }));
|
|
82
|
+
const installed = await invoke(app, "package.installed", {});
|
|
83
|
+
expect(installed.status).toBe(200);
|
|
84
|
+
expect(installed.body.output).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a denied mutation (default mutationApproval: always, no approved flag) surfaces Vehicle's authorization category -> HTTP 403, same as /api/v1/ops's own 403", async () => {
|
|
88
|
+
const app = createApp(deps());
|
|
89
|
+
const result = await invoke(app, "package.remove", { name: "pi-lsp" });
|
|
90
|
+
expect(result.status).toBe(403);
|
|
91
|
+
expect(result.body.error?.category).toBe("authorization");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("an approved mutation succeeds through /vehicle/invoke exactly like an approved /api/v1/ops call", async () => {
|
|
95
|
+
const app = createApp(deps());
|
|
96
|
+
const result = await invoke(app, "package.remove", { name: "pi-lsp", approved: true });
|
|
97
|
+
expect(result.status).toBe(200);
|
|
98
|
+
expect((result.body.output as { ok: boolean }).ok).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("a handler-thrown validation error keeps its own real message, mapped to Vehicle's validation category (not a generic internal 500)", async () => {
|
|
102
|
+
const app = createApp(deps());
|
|
103
|
+
const result = await invoke(app, "package.check", { path: "" });
|
|
104
|
+
expect(result.status).toBe(400);
|
|
105
|
+
expect(result.body.error?.category).toBe("validation");
|
|
106
|
+
expect(result.body.error?.message).toContain("path must be a non-empty string");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("the old /api/v1/ops route keeps working unchanged, alongside /vehicle/invoke", async () => {
|
|
110
|
+
const app = createApp(deps());
|
|
111
|
+
const legacy = await app.fetch(
|
|
112
|
+
new Request("http://packed.internal/api/v1/ops", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { authorization: "Bearer test-token", "content-type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ op: "package.search", input: { query: "lsp", limit: 10 } }),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(legacy.status).toBe(200);
|
|
119
|
+
expect(((await legacy.json()) as { result: { total: number } }).result.total).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
});
|