@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.
@@ -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 = { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" };
72
- install(
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
- restart(
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) return { ok: false, reason: 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 app = createApp(deps({ inst }));
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", descriptorPath: "/tmp/probe.service" });
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", descriptorPath: "/tmp/probe.service" });
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", descriptorPath: "/tmp/probe.service" });
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 app = createApp(deps({ inst }));
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
+ });