@danypops/pi-packed 0.19.11 → 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.
@@ -25,6 +25,7 @@ import type { OperationInputs, OperationName, OperationOutputs } from "./service
25
25
 
26
26
  export type FetchTransport = (request: Request) => Promise<Response>;
27
27
  type RpcClient = AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs>;
28
+ type ServiceSummary = { name: string; binPath: string };
28
29
 
29
30
  export interface PackageDaemonPort {
30
31
  search(query: string, limit: number, offline?: boolean): Promise<{ query: string; total: number; results: SearchPage["results"] }>;
@@ -46,14 +47,9 @@ export interface PackageDaemonPort {
46
47
  security(): Promise<SecuritySettings>;
47
48
  setMutationApproval(value: MutationApproval, approved?: boolean): Promise<SecuritySettings>;
48
49
  install(source: string, approved?: boolean): Promise<string>;
49
- installService(
50
- source: string,
51
- approved?: boolean,
52
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
53
- restartService(
54
- source: string,
55
- approved?: boolean,
56
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }>;
50
+ installService(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }>;
51
+ restartService(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
52
+ reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
57
53
  remove(name: string, approved?: boolean): Promise<string>;
58
54
  update(source: string, approved?: boolean): Promise<UpdateOutcome>;
59
55
  piStatus(): Promise<PiVersionReport>;
@@ -230,10 +226,7 @@ export class PackageDaemonClient implements PackageDaemonPort {
230
226
  return result.output;
231
227
  }
232
228
 
233
- async installService(
234
- source: string,
235
- approved = false,
236
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }> {
229
+ async installService(source: string, approved = false): Promise<{ output: string; spec?: ServiceSummary }> {
237
230
  const result = await this.call("package.install_service", { source, approved });
238
231
  if (!result.ok)
239
232
  throw new PackageDaemonError(
@@ -248,7 +241,7 @@ export class PackageDaemonClient implements PackageDaemonPort {
248
241
  async restartService(
249
242
  source: string,
250
243
  approved = false,
251
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }> {
244
+ ): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }> {
252
245
  const result = await this.call("package.restart_service", { source, approved });
253
246
  if (!result.ok)
254
247
  throw new PackageDaemonError(
@@ -260,6 +253,12 @@ export class PackageDaemonClient implements PackageDaemonPort {
260
253
  return { output: result.output, restarted: result.restarted, spec: result.spec };
261
254
  }
262
255
 
256
+ async reconcileServices(approved = false, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]> {
257
+ const result = await this.call("package.reconcile_services", { approved, projectRoot });
258
+ if (!result.ok) throw new PackageDaemonError(result.output || "failed to reconcile Vehicle services", "package.reconcile_services");
259
+ return result;
260
+ }
261
+
263
262
  async remove(name: string, approved = false): Promise<string> {
264
263
  const result = await this.call("package.remove", { name, approved });
265
264
  if (!result.ok) throw new PackageDaemonError(result.output || `failed to remove ${name}`, "package.remove");
@@ -319,14 +318,9 @@ export class DaemonBackedInstaller implements Installer {
319
318
  }
320
319
 
321
320
  export interface DaemonServiceInstallerPort {
322
- install(
323
- source: string,
324
- approved?: boolean,
325
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
326
- restart(
327
- source: string,
328
- approved?: boolean,
329
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }>;
321
+ install(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }>;
322
+ restart(source: string, approved?: boolean): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }>;
323
+ reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
330
324
  }
331
325
 
332
326
  export class DaemonBackedDaemonServiceInstaller implements DaemonServiceInstallerPort {
@@ -334,15 +328,15 @@ export class DaemonBackedDaemonServiceInstaller implements DaemonServiceInstalle
334
328
  async restart(
335
329
  source: string,
336
330
  approved?: boolean,
337
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }> {
331
+ ): Promise<{ output: string; restarted?: boolean; spec?: ServiceSummary }> {
338
332
  return (await connectPackageDaemon(this.paths)).restartService(source, approved);
339
333
  }
340
- async install(
341
- source: string,
342
- approved?: boolean,
343
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }> {
334
+ async install(source: string, approved?: boolean): Promise<{ output: string; spec?: ServiceSummary }> {
344
335
  return (await connectPackageDaemon(this.paths)).installService(source, approved);
345
336
  }
337
+ async reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]> {
338
+ return (await connectPackageDaemon(this.paths)).reconcileServices(approved, projectRoot);
339
+ }
346
340
  }
347
341
 
348
342
  export class DaemonRegistry implements Registry {
@@ -26,25 +26,30 @@
26
26
  * on disk instead of asking for one more declaration.
27
27
  */
28
28
  import { readFileSync } from "node:fs";
29
- import { basename, join } from "node:path";
29
+ import { join } from "node:path";
30
+ import { createVehicleRegistrar, type VehicleRegistrar } from "@danypops/armada";
30
31
  import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
31
32
  import {
32
- createNodeServiceInstallDeps,
33
- installUserService,
34
- isServiceInstalled,
35
- type ServiceInstallDeps,
33
+ isVehicleServiceRegistered,
34
+ registerVehicleService,
36
35
  type ServiceInstallResult,
37
36
  type ServiceSpec,
37
+ unregisterVehicleService,
38
38
  } from "@danypops/vehicle-server/service";
39
- import { npmPackageName } from "../packages/installed.ts";
39
+ import { npmPackageName, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
40
40
 
41
41
  export interface DaemonServiceManifest {
42
42
  /** Relative to the installed package's own root directory. */
43
43
  binPath: string;
44
44
  args?: string[];
45
- /** Defaults to the bare npm package name (scope stripped). Used for the state-directory/unit name, matching each package's own service-install convention (e.g. web-spider.service). */
45
+ /** Defaults to the bare npm package name (scope stripped). */
46
46
  name?: string;
47
47
  displayName?: string;
48
+ /** Runtime handle filename inside the Vehicle's XDG runtime directory. */
49
+ handleFilename?: string;
50
+ workingDirectory?: string;
51
+ restartOnFailure?: boolean;
52
+ restartSec?: number;
48
53
  }
49
54
 
50
55
  export type ResolveDaemonServiceResult =
@@ -63,10 +68,16 @@ interface ResolvedDaemonEntrypoint {
63
68
  args?: string[];
64
69
  name: string;
65
70
  displayName?: string;
71
+ handleFilename?: string;
72
+ workingDirectory?: string;
73
+ restartOnFailure?: boolean;
74
+ restartSec?: number;
75
+ version: string;
66
76
  }
67
77
 
68
78
  interface InstalledPackageJson {
69
79
  name?: string;
80
+ version?: string;
70
81
  bin?: string | Record<string, string>;
71
82
  dependencies?: Record<string, string>;
72
83
  packed?: { daemonService?: DaemonServiceManifest };
@@ -122,8 +133,8 @@ export function detectVehicleDaemonService(
122
133
  if (!own) return undefined;
123
134
 
124
135
  const ownBin = firstBinPath(own);
125
- if (ownBin && dependsOnVehicle(own)) {
126
- return { binPath: join(packageDir, ownBin), args: ["serve"], name: unscopedName(own.name ?? fallbackName) };
136
+ if (ownBin && dependsOnVehicle(own) && own.version) {
137
+ return { binPath: join(packageDir, ownBin), args: ["serve"], name: unscopedName(own.name ?? fallbackName), version: own.version };
127
138
  }
128
139
 
129
140
  for (const depName of Object.keys(own.dependencies ?? {})) {
@@ -135,8 +146,8 @@ export function detectVehicleDaemonService(
135
146
  const dep = readPackageJson(depDir);
136
147
  if (!dep) continue;
137
148
  const depBin = firstBinPath(dep);
138
- if (depBin && dependsOnVehicle(dep)) {
139
- return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName) };
149
+ if (depBin && dependsOnVehicle(dep) && dep.version) {
150
+ return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName), version: dep.version };
140
151
  }
141
152
  }
142
153
  }
@@ -146,20 +157,21 @@ export function detectVehicleDaemonService(
146
157
  function buildSpec(entry: ResolvedDaemonEntrypoint): ServiceSpec {
147
158
  const paths = resolveDaemonPaths({
148
159
  stateDirectoryName: entry.name,
149
- // Only serviceDescriptor is used below; these three exist purely to
150
- // satisfy resolveDaemonPaths()'s shared shape for a package this
151
- // module never opens the db/token/handle of.
152
160
  databaseFilename: "unused.db",
153
161
  tokenFilename: "unused-token",
154
- handleFilename: "unused-handle.json",
162
+ handleFilename: entry.handleFilename ?? "handle.json",
155
163
  systemdUnitName: `${entry.name}.service`,
156
164
  });
157
165
  return {
158
166
  name: entry.name,
159
167
  displayName: entry.displayName,
168
+ version: entry.version,
160
169
  binPath: entry.binPath,
161
170
  args: entry.args,
162
- descriptorPath: paths.serviceDescriptor,
171
+ handlePath: paths.handle,
172
+ workingDirectory: entry.workingDirectory,
173
+ ...(entry.restartOnFailure === undefined ? {} : { restartOnFailure: entry.restartOnFailure }),
174
+ ...(entry.restartSec === undefined ? {} : { restartSec: entry.restartSec }),
163
175
  };
164
176
  }
165
177
 
@@ -187,6 +199,11 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
187
199
  args: manifest.args,
188
200
  name: manifest.name ?? unscopedName(packageName),
189
201
  displayName: manifest.displayName,
202
+ handleFilename: manifest.handleFilename,
203
+ workingDirectory: manifest.workingDirectory,
204
+ restartOnFailure: manifest.restartOnFailure,
205
+ restartSec: manifest.restartSec,
206
+ version: pkg.version ?? "0.0.0",
190
207
  }),
191
208
  };
192
209
  }
@@ -201,49 +218,122 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
201
218
  };
202
219
  }
203
220
 
221
+ export interface ReconcileAllResult {
222
+ reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
223
+ skipped: number;
224
+ failed: Array<{ packageName: string; reason: string }>;
225
+ }
226
+
227
+ /** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
228
+ const MAX_RECONCILE_PACKAGES = 500;
229
+
230
+ /**
231
+ * Sweeps every installed Packed package (global scope, plus a project's own
232
+ * pins when projectRoot is given), resolves each to a Vehicle-shaped daemon
233
+ * exactly as install()/restart() already do per-package, and upserts +
234
+ * reconciles it through Armada. This is what makes Armada authoritative for
235
+ * every Vehicle Packed knows about, not just the one source a single
236
+ * install/update call happened to touch -- it also self-heals a Vehicle
237
+ * whose daemon package version bumped as someone else's transitive
238
+ * dependency, and a Vehicle a prior Packed version never registered at all.
239
+ * Idempotent and safe to call unconditionally: a non-daemon package costs
240
+ * one or two file reads (see resolveDaemonServiceSpec) and never reaches
241
+ * Armada. Two packages that resolve to the same Vehicle (a Pi extension and
242
+ * its own daemon dependency, both separately Packed-tracked) reconcile it
243
+ * once, not twice.
244
+ */
245
+ export async function reconcileAllDaemonServices(
246
+ piHome: string,
247
+ projectRoot: string | undefined,
248
+ installer: Pick<DaemonServiceInstaller, "install">,
249
+ ): Promise<ReconcileAllResult> {
250
+ const packages = readInstalledPackagesAcrossScopes(piHome, projectRoot).slice(0, MAX_RECONCILE_PACKAGES);
251
+ const reconciled: ReconcileAllResult["reconciled"] = [];
252
+ const failed: ReconcileAllResult["failed"] = [];
253
+ const seen = new Set<string>();
254
+ let skipped = 0;
255
+ for (const pkg of packages) {
256
+ const resolved = await installer.install(piHome, `npm:${pkg.name}`);
257
+ if (!resolved.ok) {
258
+ if (resolved.notADaemon) {
259
+ skipped++;
260
+ continue;
261
+ }
262
+ failed.push({ packageName: pkg.name, reason: resolved.reason });
263
+ continue;
264
+ }
265
+ if (seen.has(resolved.spec.name)) continue;
266
+ seen.add(resolved.spec.name);
267
+ reconciled.push({
268
+ packageName: pkg.name,
269
+ vehicleName: resolved.spec.name,
270
+ installed: resolved.result.installed,
271
+ ...(resolved.result.installed ? {} : { reason: resolved.result.reason }),
272
+ });
273
+ }
274
+ return { reconciled, skipped, failed };
275
+ }
276
+
204
277
  export interface DaemonServiceInstaller {
205
278
  install(
206
279
  piHome: string,
207
280
  source: string,
208
- ): { ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean };
281
+ ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
282
+ remove(
283
+ piHome: string,
284
+ source: string,
285
+ ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
209
286
  restart(
210
287
  piHome: string,
211
288
  source: string,
212
- ): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean };
289
+ ): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }>;
213
290
  }
214
291
 
292
+ /**
293
+ * Calls Armada's own VehicleRegistrar directly (in-process), rather than
294
+ * shelling out to its CLI as a subprocess -- the same registration logic
295
+ * `@danypops/armada` exposes to any other library consumer, not a Packed-
296
+ * specific reimplementation. One registrar per instance so its manifest
297
+ * path/native controller are resolved once, not on every call.
298
+ */
215
299
  export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
216
- install(
300
+ constructor(private readonly registrar: VehicleRegistrar = createVehicleRegistrar()) {}
301
+
302
+ async install(
217
303
  piHome: string,
218
304
  source: string,
219
- ): { ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
305
+ ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
220
306
  const resolved = resolveDaemonServiceSpec(piHome, source);
221
307
  if (!resolved.ok) return resolved;
222
- const result = installUserService(resolved.spec, createNodeServiceInstallDeps());
308
+ const result = await registerVehicleService(resolved.spec, this.registrar);
223
309
  return { ok: true, result, spec: resolved.spec };
224
310
  }
225
311
 
226
- restart(
312
+ async remove(
313
+ piHome: string,
314
+ source: string,
315
+ ): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
316
+ const resolved = resolveDaemonServiceSpec(piHome, source);
317
+ if (!resolved.ok) return resolved;
318
+ const result = await unregisterVehicleService(resolved.spec.name, this.registrar);
319
+ return { ok: true, result, spec: resolved.spec };
320
+ }
321
+
322
+ async restart(
227
323
  piHome: string,
228
324
  source: string,
229
- ): { ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean } {
325
+ ): Promise<{ ok: true; restarted: boolean; reason?: string; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
230
326
  const resolved = resolveDaemonServiceSpec(piHome, source);
231
327
  if (!resolved.ok) return resolved;
232
- const deps = createNodeServiceInstallDeps();
233
- if (!isServiceInstalled(resolved.spec, deps)) {
328
+ if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
234
329
  return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
235
330
  }
236
- const result = restartUserService(resolved.spec, deps);
237
- return { ok: true, restarted: result.restarted, reason: result.reason, spec: resolved.spec };
331
+ const result = await registerVehicleService(resolved.spec, this.registrar);
332
+ return {
333
+ ok: true,
334
+ restarted: result.installed,
335
+ ...(result.installed ? {} : { reason: result.reason }),
336
+ spec: resolved.spec,
337
+ };
238
338
  }
239
339
  }
240
-
241
- /** Linux/systemd only, matching installUserService's platform coverage order -- macOS/Windows report unsupported rather than guessing at launchctl/reg.exe restart equivalents this module doesn't implement. */
242
- export function restartUserService(spec: ServiceSpec, deps: ServiceInstallDeps): { restarted: boolean; reason?: string } {
243
- const platform = deps.platform ?? process.platform;
244
- if (platform !== "linux")
245
- return { restarted: false, reason: `restart is not supported on platform "${platform}" yet -- restart ${spec.name} manually` };
246
- const result = deps.runCommand("systemctl", ["--user", "restart", basename(spec.descriptorPath)]);
247
- if (!result.ok) return { restarted: false, reason: `systemctl --user restart failed: ${result.output}` };
248
- return { restarted: true };
249
- }
@@ -7,6 +7,7 @@ import {
7
7
  startDaemon,
8
8
  } from "@danypops/vehicle-server/daemon";
9
9
  import { ensureAuthToken } from "@danypops/vehicle-server/paths";
10
+ import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
10
11
  import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
11
12
  import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
12
13
  import { latestVersion, openDb } from "../packages/db.ts";
@@ -19,6 +20,7 @@ import {
19
20
  ENV,
20
21
  IDLE_BUDGET_DEFAULT_MS,
21
22
  INDEX_INTERVAL_DEFAULT_MS,
23
+ RECONCILE_INTERVAL_DEFAULT_MS,
22
24
  WATCH_INTERVAL_DEFAULT_MS,
23
25
  WATCHDOG_TICK_MS,
24
26
  } from "../shared/constants.ts";
@@ -35,12 +37,13 @@ export interface StartPackedDaemonOptions {
35
37
  reg?: Registry;
36
38
  inst?: Installer;
37
39
  piHome?: string;
40
+ daemonServiceInstaller?: DaemonServiceInstaller;
38
41
  maintenanceTasks?: MaintenanceTask[];
39
42
  idleBudgetMs?: number;
40
43
  migrateLegacy?: boolean;
41
44
  }
42
45
 
43
- function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
46
+ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
44
47
  const paths = options.paths ?? resolvePackedPaths();
45
48
  if (options.migrateLegacy ?? options.paths === undefined) migrateLegacyPackedState(paths, legacyPackedStateDirectory());
46
49
  const token = ensureAuthToken(paths.token, "Packed");
@@ -48,6 +51,7 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
48
51
  const inst = options.inst ?? new ExecInstaller();
49
52
  const piHome = options.piHome ?? defaultPiHome();
50
53
  const database = openDb(paths.database);
54
+ const daemonServiceInstaller = options.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
51
55
  const configuredMaintenanceTasks = options.maintenanceTasks ?? [
52
56
  {
53
57
  name: "package-update-check",
@@ -75,6 +79,24 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
75
79
  if (indexStatus(indexPath(dataDirectory), ttlMs).stale) await generateIndex(reg, dataDirectory, indexPath(dataDirectory));
76
80
  },
77
81
  },
82
+ {
83
+ // Self-heals a Vehicle a running daemon never picked up -- an out-of-band
84
+ // npm install/update, or a transitive dependency bump that resolveDaemonServiceSpec
85
+ // would only otherwise re-check the next time /install or /restart-service ran
86
+ // for that exact package.
87
+ name: "vehicle-reconcile",
88
+ intervalMs: envMs(ENV.RECONCILE_SECS, RECONCILE_INTERVAL_DEFAULT_MS),
89
+ run: async () => {
90
+ const result = await reconcileAllDaemonServices(piHome, undefined, daemonServiceInstaller);
91
+ if (result.failed.length > 0) {
92
+ logger.warn("vehicle-reconcile completed with failures", {
93
+ reconciled: result.reconciled.length,
94
+ skipped: result.skipped,
95
+ failed: result.failed.length,
96
+ });
97
+ }
98
+ },
99
+ },
78
100
  ];
79
101
  const maintenanceTasks = configuredMaintenanceTasks.filter((task) => task.intervalMs > 0);
80
102
 
@@ -93,7 +115,7 @@ function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
93
115
  maintenanceTasks,
94
116
  idleBudgetMs: options.idleBudgetMs ?? envMs(ENV.IDLE_SECS, IDLE_BUDGET_DEFAULT_MS),
95
117
  idleTickMs: WATCHDOG_TICK_MS,
96
- buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome }),
118
+ buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome, daemonServiceInstaller }),
97
119
  onShutdown: () => database.close(),
98
120
  };
99
121
  }
@@ -5,8 +5,11 @@
5
5
  */
6
6
 
7
7
  import { existsSync as fileExistsSync } from "node:fs";
8
+ import { VehicleRegistry } from "@danypops/vehicle-server";
9
+ import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
8
10
  import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
9
11
  import type { ServiceSpec } from "@danypops/vehicle-server/service";
12
+ import { registerPackedVehicleOperations } from "./vehicle-registration.ts";
10
13
  import { type AdvisoryReport, resolveInstalledVersions, scanInstalledPackages } from "../adoption/advisories.ts";
11
14
  import { type CheckReport, type PackageChecker, StaticPackageChecker } from "../adoption/check.ts";
12
15
  import { type DoctorReport, runDoctor } from "../adoption/doctor.ts";
@@ -42,7 +45,7 @@ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts";
42
45
  import { createLogger } from "../shared/log.ts";
43
46
  import { VERSION } from "../shared/version.ts";
44
47
  import { formatCleanupSummary, runCleanup } from "./cleanup.ts";
45
- import { type DaemonServiceInstaller, RealDaemonServiceInstaller } from "./daemon-service.ts";
48
+ import { type DaemonServiceInstaller, type ReconcileAllResult, reconcileAllDaemonServices, RealDaemonServiceInstaller } from "./daemon-service.ts";
46
49
  import { checkUpdates, loadUpdates } from "./watcher.ts";
47
50
 
48
51
  const log = createLogger("service");
@@ -90,6 +93,7 @@ export type OperationName =
90
93
  | "package.install"
91
94
  | "package.install_service"
92
95
  | "package.restart_service"
96
+ | "package.reconcile_services"
93
97
  | "package.remove"
94
98
  | "package.update"
95
99
  | "resources.list"
@@ -120,6 +124,7 @@ export interface OperationInputs {
120
124
  "package.install": { source: string; approved?: boolean };
121
125
  "package.install_service": { source: string; approved?: boolean };
122
126
  "package.restart_service": { source: string; approved?: boolean };
127
+ "package.reconcile_services": { approved?: boolean; projectRoot?: string };
123
128
  "package.remove": { name: string; approved?: boolean };
124
129
  "package.update": { source: string; approved?: boolean };
125
130
  "resources.list": { projectRoot?: string };
@@ -138,12 +143,13 @@ interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOu
138
143
  interface InstallServiceResponse {
139
144
  ok: boolean;
140
145
  output: string;
141
- spec?: Pick<ServiceSpec, "name" | "binPath" | "descriptorPath">;
146
+ spec?: Pick<ServiceSpec, "name" | "binPath">;
142
147
  notADaemon?: boolean;
143
148
  }
144
149
  interface RestartServiceResponse extends InstallServiceResponse {
145
150
  restarted?: boolean;
146
151
  }
152
+ interface ReconcileServicesResponse extends MutationResponse, ReconcileAllResult {}
147
153
 
148
154
  export interface OperationOutputs {
149
155
  "package.search": { query: string; total: number; results: SearchPage["results"]; offline?: boolean };
@@ -166,6 +172,7 @@ export interface OperationOutputs {
166
172
  "package.install": MutationResponse;
167
173
  "package.install_service": InstallServiceResponse;
168
174
  "package.restart_service": RestartServiceResponse;
175
+ "package.reconcile_services": ReconcileServicesResponse;
169
176
  "package.remove": MutationResponse;
170
177
  "package.update": UpdateMutationResponse;
171
178
  "resources.list": { global: PackageResources[]; project: PackageResources[] };
@@ -197,6 +204,7 @@ export const OPERATION_NAMES: readonly OperationName[] = [
197
204
  "package.install",
198
205
  "package.install_service",
199
206
  "package.restart_service",
207
+ "package.reconcile_services",
200
208
  "package.remove",
201
209
  "package.update",
202
210
  "resources.list",
@@ -227,8 +235,8 @@ function err(status: number, msg: string, details: Record<string, unknown> = {})
227
235
  return jsonResponse({ error: msg, ...details }, { status });
228
236
  }
229
237
 
230
- function pickSpec(spec: ServiceSpec): Pick<ServiceSpec, "name" | "binPath" | "descriptorPath"> {
231
- return { name: spec.name, binPath: spec.binPath, descriptorPath: spec.descriptorPath };
238
+ function pickSpec(spec: ServiceSpec): Pick<ServiceSpec, "name" | "binPath"> {
239
+ return { name: spec.name, binPath: spec.binPath };
232
240
  }
233
241
 
234
242
  export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Response> } {
@@ -240,6 +248,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
240
248
  const daemonServiceInstaller = deps.daemonServiceInstaller ?? new RealDaemonServiceInstaller();
241
249
  const piHomeForServiceInstall = deps.piHome ?? defaultPiHome();
242
250
 
251
+ // Additive, real Vehicle protocol surface for this daemon's full operation set -- served
252
+ // at /vehicle/* alongside (not replacing) /api/v1/ops below. Every operation delegates to
253
+ // the exact same executeOperation() this route's own /api/v1/ops handler calls.
254
+ const vehicleRegistry = new VehicleRegistry({ name: "packed", version: VERSION, description: "Pi package lifecycle daemon" });
255
+ registerPackedVehicleOperations(vehicleRegistry, executeOperation);
256
+ const vehicleApp = createVehicleHttpApp({ registry: vehicleRegistry, token: deps.token });
257
+
243
258
  function authorize(operation: PackageOperation, approved: boolean): Response | undefined {
244
259
  try {
245
260
  assertPackagePermission(readSecuritySettings(deps.stateDir), operation, approved);
@@ -331,11 +346,21 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
331
346
  // pi.cleanup is read and applied before delegating to pi remove --
332
347
  // once pi remove finishes, an npm-sourced package's own directory
333
348
  // (and its manifest) may already be gone.
334
- const installedDir = resolveInstalledDir(deps.piHome ?? defaultPiHome(), `npm:${name}`);
349
+ const piHome = deps.piHome ?? defaultPiHome();
350
+ const installedDir = resolveInstalledDir(piHome, `npm:${name}`);
351
+ let serviceRemoved = false;
352
+ if (installedDir) {
353
+ const service = await daemonServiceInstaller.remove(piHome, `npm:${name}`);
354
+ if (!service.ok && !service.notADaemon) return json({ ok: false, name, output: service.reason });
355
+ if (service.ok) {
356
+ if (!service.result.installed) return json({ ok: false, name, output: service.result.reason });
357
+ serviceRemoved = true;
358
+ }
359
+ }
335
360
  const cleanup = installedDir ? runCleanup(installedDir) : [];
336
361
  try {
337
362
  const output = await deps.inst.remove(`npm:${name}`, { approved });
338
- return json({ ok: true, name, output: output + formatCleanupSummary(cleanup) });
363
+ return json({ ok: true, name, output: output + formatCleanupSummary(cleanup), serviceRemoved });
339
364
  } catch (e) {
340
365
  const message = e instanceof Error ? e.message : String(e);
341
366
  return json({ ok: false, name, output: message + formatCleanupSummary(cleanup) });
@@ -359,7 +384,19 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
359
384
  if (denied) return denied;
360
385
  try {
361
386
  const output = await deps.inst.install(source, { approved });
362
- return json({ ok: true, source, output });
387
+ if (!source.startsWith("npm:")) return json({ ok: true, source, output });
388
+ const service = await daemonServiceInstaller.install(piHomeForServiceInstall, source);
389
+ if (!service.ok) {
390
+ if (service.notADaemon) return json({ ok: true, source, output });
391
+ return json({ ok: false, source, output: `${output}\n${service.reason}` });
392
+ }
393
+ if (!service.result.installed) return json({ ok: false, source, output: `${output}\n${service.result.reason}` });
394
+ return json({
395
+ ok: true,
396
+ source,
397
+ output: `${output}\ninstalled persistent Vehicle ${service.spec.name}`,
398
+ service: pickSpec(service.spec),
399
+ });
363
400
  } catch (e) {
364
401
  return json({ ok: false, source, output: e instanceof Error ? e.message : String(e) });
365
402
  }
@@ -380,7 +417,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
380
417
  }
381
418
  const denied = authorize("install_service", approved);
382
419
  if (denied) return denied;
383
- const resolved = daemonServiceInstaller.install(piHomeForServiceInstall, source);
420
+ const resolved = await daemonServiceInstaller.install(piHomeForServiceInstall, source);
384
421
  if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
385
422
  if (!resolved.result.installed) return json({ ok: false, output: resolved.result.reason, spec: pickSpec(resolved.spec) });
386
423
  return json({ ok: true, output: `installed a persistent service for ${resolved.spec.name}`, spec: pickSpec(resolved.spec) });
@@ -401,7 +438,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
401
438
  }
402
439
  const denied = authorize("restart_service", approved);
403
440
  if (denied) return denied;
404
- const resolved = daemonServiceInstaller.restart(piHomeForServiceInstall, source);
441
+ const resolved = await daemonServiceInstaller.restart(piHomeForServiceInstall, source);
405
442
  if (!resolved.ok) return json({ ok: false, output: resolved.reason, notADaemon: resolved.notADaemon });
406
443
  const output = resolved.restarted
407
444
  ? `restarted the persistent service for ${resolved.spec.name}`
@@ -409,6 +446,23 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
409
446
  return json({ ok: true, output, restarted: resolved.restarted, spec: pickSpec(resolved.spec) });
410
447
  }
411
448
 
449
+ if (path === "/reconcile-services" && req.method === "POST") {
450
+ let approved = false;
451
+ let projectRoot: string | undefined;
452
+ try {
453
+ const body = (await req.json()) as { approved?: unknown; projectRoot?: unknown };
454
+ approved = body.approved === true;
455
+ projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : undefined;
456
+ } catch {
457
+ /* fall through -- approved stays false, projectRoot stays undefined */
458
+ }
459
+ const denied = authorize("reconcile_services", approved);
460
+ if (denied) return denied;
461
+ const result = await reconcileAllDaemonServices(piHomeForServiceInstall, projectRoot, daemonServiceInstaller);
462
+ const output = `reconciled ${result.reconciled.length} Vehicle(s), skipped ${result.skipped} non-daemon package(s)${result.failed.length > 0 ? `, ${result.failed.length} failure(s)` : ""}`;
463
+ return json({ ok: result.failed.length === 0, output, ...result });
464
+ }
465
+
412
466
  if (path === "/update" && req.method === "POST") {
413
467
  let source = "";
414
468
  let approved = false;
@@ -426,7 +480,13 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
426
480
  if (denied) return denied;
427
481
  try {
428
482
  const outcome = await deps.inst.update(source, { approved });
429
- return json({ ok: true, source, ...outcome });
483
+ if (!source.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
484
+ const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, source);
485
+ if (!service.ok) {
486
+ if (service.notADaemon) return json({ ok: true, source, ...outcome });
487
+ return json({ ok: false, source, ...outcome, output: `${outcome.output}\n${service.reason}` });
488
+ }
489
+ return json({ ok: true, source, ...outcome, serviceReconciled: service.restarted });
430
490
  } catch (error) {
431
491
  return json({ ok: false, source, output: error instanceof Error ? error.message : String(error), reloadRequired: false });
432
492
  }
@@ -601,6 +661,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
601
661
  path = "/restart-service";
602
662
  init = { method: "POST", body: JSON.stringify(input) };
603
663
  break;
664
+ case "package.reconcile_services":
665
+ path = "/reconcile-services";
666
+ init = { method: "POST", body: JSON.stringify(input) };
667
+ break;
604
668
  case "package.remove":
605
669
  path = "/remove";
606
670
  init = { method: "POST", body: JSON.stringify(input) };
@@ -626,8 +690,9 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
626
690
  return {
627
691
  async fetch(req: Request): Promise<Response> {
628
692
  const t0 = Date.now();
629
- if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401);
630
693
  const requestUrl = new URL(req.url);
694
+ if (requestUrl.pathname.startsWith("/vehicle/")) return vehicleApp.fetch(req);
695
+ if (!requireBearerToken(req, deps.token)) return errorResponse("missing or invalid bearer token", 401);
631
696
  if (req.method === "GET" && requestUrl.pathname === "/api/v1/ops") return jsonResponse({ operations: OPERATION_NAMES });
632
697
  if (req.method === "POST" && requestUrl.pathname === "/api/v1/ops") {
633
698
  try {