@danypops/pi-packed 0.19.12 → 0.20.1

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.
@@ -2,8 +2,8 @@ import { describe, expect, it } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import type { ServiceInstallDeps } from "@danypops/vehicle-server/service";
6
- import { detectVehicleDaemonService, resolveDaemonServiceSpec, restartUserService } from "../src/daemon/daemon-service.ts";
5
+ import type { VehicleRegistrar, VehicleRegistrationOutcome } from "@danypops/armada";
6
+ import { detectVehicleDaemonService, RealDaemonServiceInstaller, resolveDaemonServiceSpec } from "../src/daemon/daemon-service.ts";
7
7
 
8
8
  function fakePiHome(): string {
9
9
  return mkdtempSync(join(tmpdir(), "packed-daemon-service-"));
@@ -26,7 +26,12 @@ function writeRawPackage(dir: string, pkg: Record<string, unknown>): void {
26
26
  describe("resolveDaemonServiceSpec", () => {
27
27
  it("builds a ServiceSpec from a package's packed.daemonService manifest", () => {
28
28
  const piHome = fakePiHome();
29
- writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
29
+ writePackage(piHome, "@danypops/web-spider-daemon", {
30
+ binPath: "dist/cli.js",
31
+ args: ["serve"],
32
+ restartOnFailure: true,
33
+ restartSec: 2,
34
+ });
30
35
 
31
36
  const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/web-spider-daemon");
32
37
  expect(result).toEqual({
@@ -34,9 +39,13 @@ describe("resolveDaemonServiceSpec", () => {
34
39
  spec: {
35
40
  name: "web-spider-daemon",
36
41
  displayName: undefined,
42
+ version: "1.0.0",
37
43
  binPath: join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon", "dist/cli.js"),
38
44
  args: ["serve"],
39
- descriptorPath: expect.stringContaining("web-spider-daemon") as unknown as string,
45
+ handlePath: expect.stringContaining("web-spider-daemon") as unknown as string,
46
+ workingDirectory: undefined,
47
+ restartOnFailure: true,
48
+ restartSec: 2,
40
49
  },
41
50
  });
42
51
  });
@@ -92,9 +101,11 @@ describe("resolveDaemonServiceSpec", () => {
92
101
  spec: {
93
102
  name: "papyrus",
94
103
  displayName: undefined,
104
+ version: "1.0.0",
95
105
  binPath: join(dir, "src/cli.ts"),
96
106
  args: ["serve"],
97
- descriptorPath: expect.stringContaining("papyrus") as unknown as string,
107
+ handlePath: expect.stringContaining("papyrus") as unknown as string,
108
+ workingDirectory: undefined,
98
109
  },
99
110
  });
100
111
  });
@@ -189,50 +200,92 @@ describe("resolveDaemonServiceSpec", () => {
189
200
  });
190
201
  });
191
202
 
192
- function fakeInstallDeps(over: Partial<ServiceInstallDeps> = {}): ServiceInstallDeps {
193
- return {
194
- platform: "linux",
195
- writeFile: () => {},
196
- readFile: () => null,
197
- removeFile: () => {},
198
- fileExists: () => false,
199
- mkdirp: () => {},
200
- runCommand: () => ({ ok: true, output: "" }),
201
- which: () => true,
202
- ...over,
203
- };
203
+ class FakeVehicleRegistrar implements VehicleRegistrar {
204
+ registered = new Map<string, unknown>();
205
+ outcome: VehicleRegistrationOutcome = { ok: true, manifestHash: "hash" as never, applied: [], diagnostics: [] };
206
+ async register(vehicle: unknown): Promise<VehicleRegistrationOutcome> {
207
+ if (this.outcome.ok) this.registered.set((vehicle as { name: string }).name, vehicle);
208
+ return this.outcome;
209
+ }
210
+ async unregister(name: string): Promise<VehicleRegistrationOutcome> {
211
+ if (this.outcome.ok) this.registered.delete(name);
212
+ return this.outcome;
213
+ }
214
+ async isRegistered(name: string): Promise<boolean> {
215
+ return this.registered.has(name);
216
+ }
204
217
  }
205
218
 
206
- describe("restartUserService", () => {
207
- const spec = { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/home/user/.config/systemd/user/probe.service" };
219
+ describe("RealDaemonServiceInstaller -- Armada registration through the in-process registrar", () => {
220
+ it("install() resolves the package's Vehicle spec and registers it through Armada directly", async () => {
221
+ const piHome = fakePiHome();
222
+ writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
223
+ const registrar = new FakeVehicleRegistrar();
224
+ const installer = new RealDaemonServiceInstaller(registrar);
225
+
226
+ const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
208
227
 
209
- it("restarts via systemctl --user restart, using the descriptor's basename as the unit name", () => {
210
- const calls: Array<{ command: string; args: string[] }> = [];
211
- const deps = fakeInstallDeps({
212
- runCommand: (command, args) => {
213
- calls.push({ command, args });
214
- return { ok: true, output: "" };
215
- },
216
- });
228
+ expect(outcome).toMatchObject({ ok: true, result: { installed: true }, spec: { name: "web-spider-daemon" } });
229
+ expect(registrar.registered.has("web-spider-daemon")).toBe(true);
230
+ });
231
+
232
+ it("install() reports a non-daemon package without ever touching the registrar", async () => {
233
+ const piHome = fakePiHome();
234
+ writePackage(piHome, "some-pkg", undefined);
235
+ const registrar = new FakeVehicleRegistrar();
236
+ const installer = new RealDaemonServiceInstaller(registrar);
237
+
238
+ const outcome = await installer.install(piHome, "npm:some-pkg");
239
+
240
+ expect(outcome).toMatchObject({ ok: false, notADaemon: true });
241
+ expect(registrar.registered.size).toBe(0);
242
+ });
243
+
244
+ it("remove() unregisters the resolved Vehicle through Armada directly", async () => {
245
+ const piHome = fakePiHome();
246
+ writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
247
+ const registrar = new FakeVehicleRegistrar();
248
+ const installer = new RealDaemonServiceInstaller(registrar);
249
+ await installer.install(piHome, "npm:@danypops/web-spider-daemon");
217
250
 
218
- const result = restartUserService(spec, deps);
219
- expect(result).toEqual({ restarted: true });
220
- expect(calls).toEqual([{ command: "systemctl", args: ["--user", "restart", "probe.service"] }]);
251
+ const outcome = await installer.remove(piHome, "npm:@danypops/web-spider-daemon");
252
+
253
+ expect(outcome).toMatchObject({ ok: true, result: { installed: true } });
254
+ expect(registrar.registered.has("web-spider-daemon")).toBe(false);
221
255
  });
222
256
 
223
- it("reports a real systemctl failure without throwing", () => {
224
- const deps = fakeInstallDeps({ runCommand: () => ({ ok: false, output: "Unit probe.service not loaded" }) });
257
+ it("restart() is a no-op, in-band, when Armada has no registration for this Vehicle yet", async () => {
258
+ const piHome = fakePiHome();
259
+ writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
260
+ const registrar = new FakeVehicleRegistrar();
261
+ const installer = new RealDaemonServiceInstaller(registrar);
262
+
263
+ const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
225
264
 
226
- const result = restartUserService(spec, deps);
227
- expect(result.restarted).toBe(false);
228
- expect(result.reason).toContain("Unit probe.service not loaded");
265
+ expect(outcome).toMatchObject({ ok: true, restarted: false });
229
266
  });
230
267
 
231
- it("refuses on a platform with no supported restart mechanism, rather than guessing at one", () => {
232
- const deps = fakeInstallDeps({ platform: "darwin" });
268
+ it("restart() re-registers (picking up a fresh on-disk version) once already registered", async () => {
269
+ const piHome = fakePiHome();
270
+ writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
271
+ const registrar = new FakeVehicleRegistrar();
272
+ const installer = new RealDaemonServiceInstaller(registrar);
273
+ await installer.install(piHome, "npm:@danypops/web-spider-daemon");
274
+
275
+ const outcome = await installer.restart(piHome, "npm:@danypops/web-spider-daemon");
276
+
277
+ expect(outcome).toMatchObject({ ok: true, restarted: true });
278
+ });
279
+
280
+ it("surfaces a failed Armada registration's diagnostics in-band, never as a thrown error", async () => {
281
+ const piHome = fakePiHome();
282
+ writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
283
+ const registrar = new FakeVehicleRegistrar();
284
+ registrar.outcome = { ok: false, diagnostics: [{ code: "X", severity: "error", path: "/", message: "native failure" }] };
285
+ const installer = new RealDaemonServiceInstaller(registrar);
286
+
287
+ const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
233
288
 
234
- const result = restartUserService(spec, deps);
235
- expect(result.restarted).toBe(false);
236
- expect(result.reason).toContain('platform "darwin"');
289
+ expect(outcome).toMatchObject({ ok: true, result: { installed: false, reason: "native failure" } });
237
290
  });
238
291
  });
@@ -81,7 +81,7 @@ describe("ensureClient (packed daemon auto-spawn-or-wait decision)", () => {
81
81
  retryAttempts: 2,
82
82
  retryDelayMs: 0,
83
83
  }),
84
- ).rejects.toThrow(/supervised service is installed/);
84
+ ).rejects.toThrow(/managed service is installed/);
85
85
  });
86
86
 
87
87
  it("fails with a plain timeout message, and did spawn, when no service is installed and nothing ever becomes reachable", async () => {
@@ -26,6 +26,7 @@ describe("package permission policy", () => {
26
26
  "install",
27
27
  "install_service",
28
28
  "restart_service",
29
+ "reconcile_services",
29
30
  "setup.apply",
30
31
  "update",
31
32
  "update.self",
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { checkServiceUnitPaths, parseExecStartTokens, type ServiceDoctorDeps } from "../src/adoption/service-doctor.ts";
5
+ import type { ServiceDoctorDeps } from "../src/adoption/service-doctor.ts";
6
+ import { checkServiceUnitPaths } from "../src/adoption/service-doctor.ts";
6
7
 
7
8
  const roots: string[] = [];
8
9
  afterEach(() => {
@@ -12,131 +13,88 @@ afterEach(() => {
12
13
  function piHome(settingsPackages: unknown[]): string {
13
14
  const root = mkdtempSync(join(tmpdir(), "packed-service-doctor-"));
14
15
  roots.push(root);
15
- writeFileSync(join(root, "settings.json"), JSON.stringify({ packages: settingsPackages }, null, 2));
16
+ writeFileSync(join(root, "settings.json"), JSON.stringify({ packages: settingsPackages }));
16
17
  return root;
17
18
  }
18
19
 
19
- /** A real installed package on disk, shaped so detectVehicleDaemonService recognizes it as a daemon (own bin + a real dependency on @danypops/vehicle-server). */
20
20
  function installDaemonPackage(home: string, name: string): string {
21
21
  const dir = join(home, "npm", "node_modules", name);
22
22
  mkdirSync(dir, { recursive: true });
23
23
  writeFileSync(
24
24
  join(dir, "package.json"),
25
- JSON.stringify({ name, version: "1.0.0", bin: { [name]: "cli.ts" }, dependencies: { "@danypops/vehicle-server": "^0.7.0" } }, null, 2),
25
+ JSON.stringify({
26
+ name,
27
+ version: "1.0.0",
28
+ packed: { daemonService: { binPath: "cli.ts", handleFilename: "handle.json" } },
29
+ }),
26
30
  );
27
- writeFileSync(join(dir, "cli.ts"), "// real entrypoint, present on disk\n");
31
+ writeFileSync(join(dir, "cli.ts"), "// entrypoint\n");
28
32
  return dir;
29
33
  }
30
34
 
31
- function fakeDeps(unitText: string | null): ServiceDoctorDeps {
35
+ function fakeDeps(status: unknown, fileExists = true): ServiceDoctorDeps {
32
36
  return {
33
- fileExists: () => unitText !== null,
34
- readFile: () => unitText,
35
- platform: "linux",
37
+ armadaCliPath: "/armada/cli.js",
38
+ fileExists: () => fileExists,
39
+ runCommand: () => ({ ok: true, output: JSON.stringify(status) }),
36
40
  };
37
41
  }
38
42
 
39
- describe("parseExecStartTokens", () => {
40
- it("parses shell-quoted tokens, unescaping the same characters shellQuote escapes", () => {
41
- const unit = ['[Service]', 'ExecStart="/home/x/.bun/bin/bun" "/home/x/pkg/cli.ts" "serve"', ''].join("\n");
42
- expect(parseExecStartTokens(unit)).toEqual(["/home/x/.bun/bin/bun", "/home/x/pkg/cli.ts", "serve"]);
43
- });
44
-
45
- it("unescapes a backslash-escaped quote/backslash/dollar/backtick inside a token", () => {
46
- const unit = 'ExecStart="/bin/bun" "/weird \\"path\\" with \\\\ and \\$ and \\`"';
47
- expect(parseExecStartTokens(unit)).toEqual(["/bin/bun", '/weird "path" with \\ and $ and `']);
48
- });
49
-
50
- it("returns undefined when there is no ExecStart line at all", () => {
51
- expect(parseExecStartTokens("[Service]\nType=simple\n")).toBeUndefined();
52
- });
53
-
54
- it("returns undefined for an ExecStart line with no quoted tokens", () => {
55
- expect(parseExecStartTokens("ExecStart=\n")).toBeUndefined();
56
- });
57
- });
58
-
59
43
  describe("checkServiceUnitPaths", () => {
60
- it("is silent on a non-linux platform without even reading installed packages", () => {
61
- const home = piHome(["npm:whatever"]);
62
- const report = checkServiceUnitPaths(home, undefined, { fileExists: () => true, readFile: () => "", platform: "darwin" });
63
- expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
64
- });
65
-
66
- it("skips a package that never resolves to a Vehicle-shaped daemon", () => {
44
+ it("skips packages with no Armada-managed Vehicle", () => {
67
45
  const home = piHome(["npm:not-a-daemon"]);
68
46
  const dir = join(home, "npm", "node_modules", "not-a-daemon");
69
47
  mkdirSync(dir, { recursive: true });
70
- writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "not-a-daemon", version: "1.0.0" }, null, 2));
71
- const report = checkServiceUnitPaths(home, undefined, fakeDeps("ExecStart=\"/bin/true\"\n"));
72
- expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
48
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "not-a-daemon", version: "1.0.0" }));
49
+ expect(checkServiceUnitPaths(home, undefined, fakeDeps({ vehicles: [], diagnostics: [] }))).toEqual({
50
+ ok: true,
51
+ diagnostics: [],
52
+ checked: 0,
53
+ });
73
54
  });
74
55
 
75
- it("skips a daemon-shaped package with no systemd unit currently installed", () => {
76
- const home = piHome(["npm:fakedaemon"]);
77
- installDaemonPackage(home, "fakedaemon");
78
- const report = checkServiceUnitPaths(home, undefined, { fileExists: () => false, readFile: () => null, platform: "linux" });
79
- expect(report).toEqual({ ok: true, diagnostics: [], checked: 0 });
80
- });
81
-
82
- it("reports clean when the installed unit's ExecStart paths all still exist", () => {
56
+ it("reports a clean Armada-managed executable", () => {
83
57
  const home = piHome(["npm:fakedaemon"]);
84
58
  const dir = installDaemonPackage(home, "fakedaemon");
85
- const unit = `ExecStart="/bin/sh" "${join(dir, "cli.ts")}" "serve"\n`;
86
- const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
87
- expect(report.checked).toBe(1);
88
- expect(report.diagnostics).toEqual([]);
89
- expect(report.ok).toBe(true);
90
- });
91
-
92
- it("flags SERVICE_EXEC_PATH_MISSING with the exact missing path when ExecStart references a deleted file", () => {
93
- const home = piHome(["npm:fakedaemon"]);
94
- installDaemonPackage(home, "fakedaemon");
95
- const missing = join(tmpdir(), "packed-service-doctor-definitely-missing", "cli.ts");
96
- const unit = `ExecStart="/bin/sh" "${missing}" "serve"\n`;
97
- const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
98
- expect(report.ok).toBe(false);
99
- expect(report.diagnostics).toEqual([
100
- {
101
- code: "SERVICE_EXEC_PATH_MISSING",
102
- severity: "error",
103
- package: "fakedaemon",
104
- unitName: "fakedaemon",
105
- message: expect.stringContaining(missing),
106
- },
107
- ]);
59
+ const report = checkServiceUnitPaths(
60
+ home,
61
+ undefined,
62
+ fakeDeps({ vehicles: [{ name: "fakedaemon", executable: join(dir, "cli.ts") }], diagnostics: [] }),
63
+ );
64
+ expect(report).toEqual({ ok: true, diagnostics: [], checked: 1 });
108
65
  });
109
66
 
110
- it("never flags a bare subcommand argument (no path separator) as a missing path", () => {
67
+ it("reports a missing Armada-managed executable", () => {
111
68
  const home = piHome(["npm:fakedaemon"]);
112
69
  const dir = installDaemonPackage(home, "fakedaemon");
113
- // "serve" alone would resolve relative to this process's own cwd and could
114
- // spuriously not exist there -- must never be treated as a path to check.
115
- const unit = `ExecStart="/bin/sh" "${join(dir, "cli.ts")}" "serve"\n`;
116
- const report = checkServiceUnitPaths(home, undefined, fakeDeps(unit));
117
- expect(report.diagnostics.some((d) => d.message.includes('"serve"'))).toBe(false);
70
+ const executable = join(dir, "cli.ts");
71
+ const report = checkServiceUnitPaths(
72
+ home,
73
+ undefined,
74
+ fakeDeps({ vehicles: [{ name: "fakedaemon", executable }], diagnostics: [] }, false),
75
+ );
76
+ expect(report).toEqual({
77
+ ok: false,
78
+ checked: 1,
79
+ diagnostics: [
80
+ {
81
+ code: "SERVICE_EXEC_PATH_MISSING",
82
+ severity: "error",
83
+ package: "fakedaemon",
84
+ unitName: "fakedaemon",
85
+ message: expect.stringContaining(executable),
86
+ },
87
+ ],
88
+ });
118
89
  });
119
90
 
120
- it("degrades an unparseable ExecStart to a warning, never a false error", () => {
91
+ it("reports Armada status failure without guessing from native descriptors", () => {
121
92
  const home = piHome(["npm:fakedaemon"]);
122
93
  installDaemonPackage(home, "fakedaemon");
123
- const report = checkServiceUnitPaths(home, undefined, fakeDeps("[Service]\nType=simple\n"));
124
- expect(report.ok).toBe(true);
125
- expect(report.diagnostics).toEqual([
126
- {
127
- code: "SERVICE_EXEC_UNPARSEABLE",
128
- severity: "warning",
129
- package: "fakedaemon",
130
- unitName: "fakedaemon",
131
- message: expect.any(String),
132
- },
133
- ]);
134
- });
135
-
136
- it("treats a raced fileExists=true/readFile=null as transient, not a finding", () => {
137
- const home = piHome(["npm:fakedaemon"]);
138
- installDaemonPackage(home, "fakedaemon");
139
- const report = checkServiceUnitPaths(home, undefined, { fileExists: () => true, readFile: () => null, platform: "linux" });
140
- expect(report).toEqual({ ok: true, diagnostics: [], checked: 1 });
94
+ const deps = fakeDeps({});
95
+ deps.runCommand = () => ({ ok: false, output: "armada unavailable" });
96
+ const report = checkServiceUnitPaths(home, undefined, deps);
97
+ expect(report.ok).toBe(false);
98
+ expect(report.diagnostics[0]?.code).toBe("SERVICE_STATUS_UNAVAILABLE");
141
99
  });
142
100
  });
@@ -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));