@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.
@@ -8,6 +8,7 @@ import { formatDoctorReport, runDoctor } from "../adoption/doctor.ts";
8
8
  import { formatPackReport, NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
9
9
  import { type AdoptionReport, formatAdoptionReport, scoreTarget } from "../adoption/score.ts";
10
10
  import type { PackageDaemonPort } from "../daemon/client.ts";
11
+ import type { OperationOutputs } from "../daemon/service.ts";
11
12
  import { checkUpdates } from "../daemon/watcher.ts";
12
13
  import { generateIndex, indexPath, readIndex } from "../index/build-index.ts";
13
14
  import { syncCatalog } from "../packages/catalog.ts";
@@ -83,7 +84,7 @@ usage:
83
84
  packed search <query> [--offline] [--json] search npm (or the local mirror with --offline)
84
85
  packed info <name> [--json] package details
85
86
  packed updates [--project <path>] [--json] updates per the local mirror; --project also checks that project's own .pi/settings.json pins
86
- packed update <source> [--approve] [--no-service] [--json] update one configured package through Pi; restarts its registered persistent service (if any) to pick up the new code, unless --no-service
87
+ packed update <source> [--approve] [--json] update one configured package through Pi and reconcile its registered Vehicle
87
88
  packed update --self [--approve] [--json] update Packed itself (npm-global installs only) and restart its supervised service
88
89
  packed mirror [--json] sync upstream into the local SQLite index
89
90
  packed installed [--json] installed pi packages
@@ -100,9 +101,10 @@ usage:
100
101
  packed setup update [manifest] [--json] deliberately refresh immutable package resolutions
101
102
  packed setup plan [manifest] [--prune] [--json] show an additive or exact setup diff without mutation
102
103
  packed setup apply [manifest] [--prune] [--approve] [--json] apply an approved setup plan
103
- packed install <source> [--approve] [--no-service] [--json] pi install npm:|git:|https://… via daemon; auto-registers a detected npm: Vehicle daemon as a service unless --no-service
104
- packed install-service <source> --approve [--json] register a package's own daemon as a persistent login/boot service (also useful standalone, e.g. re-registering after --no-service)
104
+ packed install <source> [--approve] [--json] pi install npm:|git:|https://… via daemon and reconcile any declared Vehicle
105
+ packed install-service <source> --approve [--json] reconcile a package's declared Vehicle
105
106
  packed restart-service <source> --approve [--json] restart a package's already-registered persistent service standalone (packed update already does this automatically)
107
+ packed reconcile-services --approve [--project <path>] [--json] sweep every installed package and self-heal its Vehicle registration (the daemon also runs this on its own interval and at startup)
106
108
  packed remove <name> [--approve] [--json] remove by bare npm name via daemon
107
109
  packed security [always|never] [--approve] [--json] read or set mutation approval policy
108
110
  packed serve run the long-running daemon
@@ -138,14 +140,12 @@ export interface CliDeps {
138
140
  apply(manifestPath: string, options?: { prune?: boolean }): Promise<SetupApplyResult>;
139
141
  };
140
142
  daemonService?: {
141
- install(
142
- source: string,
143
- approved?: boolean,
144
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }>;
143
+ install(source: string, approved?: boolean): Promise<{ output: string; spec?: { name: string; binPath: string } }>;
145
144
  restart(
146
145
  source: string,
147
146
  approved?: boolean,
148
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }>;
147
+ ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string } }>;
148
+ reconcileAll(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
149
149
  };
150
150
  piVersion?: { check(): Promise<PiVersionReport> };
151
151
  selfUpdater?: { run(): Promise<SelfUpdateReport> };
@@ -166,7 +166,6 @@ interface Flags {
166
166
  force: boolean;
167
167
  prune: boolean;
168
168
  machineLocal: boolean;
169
- noService: boolean;
170
169
  ecosystem: boolean;
171
170
  self: boolean;
172
171
  project?: string;
@@ -183,7 +182,6 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
183
182
  force: false,
184
183
  prune: false,
185
184
  machineLocal: false,
186
- noService: false,
187
185
  ecosystem: false,
188
186
  self: false,
189
187
  };
@@ -198,7 +196,6 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
198
196
  else if (a === "--force") flags.force = true;
199
197
  else if (a === "--prune") flags.prune = true;
200
198
  else if (a === "--machine-local") flags.machineLocal = true;
201
- else if (a === "--no-service") flags.noService = true;
202
199
  else if (a === "--ecosystem") flags.ecosystem = true;
203
200
  else if (a === "--self") flags.self = true;
204
201
  else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
@@ -236,6 +233,7 @@ const PACKAGE_COMMAND_OPERATIONS: Record<string, PackageOperation | undefined> =
236
233
  install: "install",
237
234
  "install-service": "install_service",
238
235
  "restart-service": "restart_service",
236
+ "reconcile-services": "reconcile_services",
239
237
  remove: "remove",
240
238
  pi: "pi.status",
241
239
  advisories: "advisories.scan",
@@ -570,36 +568,14 @@ const commands: Record<string, { usage: string; run: Command }> = {
570
568
  },
571
569
 
572
570
  install: {
573
- usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--no-service] [--json]",
571
+ usage: "packed install npm:<pkg>[@ver] | git:<host>/<owner>/<repo>[@ref] | https://… [--json]",
574
572
  async run(_rest, d, flags, pos) {
575
573
  const source = pos[0] ?? "";
576
574
  if (!SOURCE_RE.test(source)) return usageErr(`usage: ${commands.install!.usage}\n`);
577
575
  try {
578
576
  const output = await d.inst.install(source, { approved: flags.approved });
579
- // A package's own persistent-service registration piggybacks on the same
580
- // approval already granted for install -- both are the same "code-execution"
581
- // mutation tier, so this isn't a new consent surface. Silent for the
582
- // overwhelmingly common case (most Pi packages aren't daemons at all);
583
- // --no-service skips the attempt entirely, and a genuine failure (detected
584
- // a daemon but couldn't register it) is reported without failing the
585
- // install itself, which already succeeded.
586
- let serviceInstall: { detected: true; ok: boolean; output: string } | undefined;
587
- if (!flags.noService && source.startsWith("npm:") && d.daemonService) {
588
- try {
589
- const svc = await d.daemonService.install(source, flags.approved);
590
- serviceInstall = { detected: true, ok: true, output: svc.output };
591
- } catch (e) {
592
- if (!(e instanceof Error) || !(e as { notADaemon?: boolean }).notADaemon) {
593
- serviceInstall = { detected: true, ok: false, output: e instanceof Error ? e.message : String(e) };
594
- }
595
- }
596
- }
597
- if (flags.json) return ok(`${JSON.stringify({ ok: true, source, output, ...(serviceInstall ? { serviceInstall } : {}) })}\n`);
598
- let human = `${output}\n`;
599
- if (serviceInstall?.ok) human += `${serviceInstall.output}\n`;
600
- else if (serviceInstall && !serviceInstall.ok)
601
- human += `note: detected a persistent-service daemon but could not register it: ${serviceInstall.output}\n`;
602
- return ok(human);
577
+ if (flags.json) return ok(`${JSON.stringify({ ok: true, source, output })}\n`);
578
+ return ok(`${output}\n`);
603
579
  } catch (e) {
604
580
  const error = e instanceof Error ? e.message : String(e);
605
581
  return flags.json ? fail(`${JSON.stringify({ ok: false, source, error })}\n`) : fail(`${error}\n`);
@@ -640,6 +616,21 @@ const commands: Record<string, { usage: string; run: Command }> = {
640
616
  },
641
617
  },
642
618
 
619
+ "reconcile-services": {
620
+ usage:
621
+ "packed reconcile-services --approve [--project <path>] [--json] (sweeps every installed package and self-heals its Vehicle registration through Armada)",
622
+ async run(_rest, d, flags) {
623
+ if (!d.daemonService) return fail("reconcile-services requires a running packed daemon\n");
624
+ try {
625
+ const result = await d.daemonService.reconcileAll(flags.approved, flags.project);
626
+ return flags.json ? ok(`${JSON.stringify(result)}\n`) : ok(`${result.output}\n`);
627
+ } catch (e) {
628
+ const error = e instanceof Error ? e.message : String(e);
629
+ return flags.json ? fail(`${JSON.stringify({ ok: false, error })}\n`) : fail(`${error}\n`);
630
+ }
631
+ },
632
+ },
633
+
643
634
  security: {
644
635
  usage: "packed security [always|never] [--json]",
645
636
  async run(_rest, d, flags, pos) {
@@ -685,27 +676,10 @@ const commands: Record<string, { usage: string; run: Command }> = {
685
676
  : `is already up to date${version ? ` at ${version}` : ""}`;
686
677
  return ok(`${source} ${reason}\n`);
687
678
  }
688
- // Mirrors install's own auto-registration composition: same approval tier, silent for
689
- // the common non-daemon case, a failure reported without failing the update itself.
690
- let serviceRestart: { detected: true; ok: boolean; output: string } | undefined;
691
- if (!flags.noService && source.startsWith("npm:") && d.daemonService) {
692
- try {
693
- const svc = await d.daemonService.restart(source, flags.approved);
694
- serviceRestart = { detected: true, ok: true, output: svc.output };
695
- } catch (e) {
696
- if (!(e instanceof Error) || !(e as { notADaemon?: boolean }).notADaemon) {
697
- serviceRestart = { detected: true, ok: false, output: e instanceof Error ? e.message : String(e) };
698
- }
699
- }
700
- }
701
- if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome, ...(serviceRestart ? { serviceRestart } : {}) })}\n`);
679
+ if (flags.json) return ok(`${JSON.stringify({ ok: true, source, ...outcome })}\n`);
702
680
  const transition =
703
681
  outcome.previousVersion && outcome.currentVersion ? ` (${outcome.previousVersion} → ${outcome.currentVersion})` : "";
704
- let human = `${outcome.output}${transition}\n`;
705
- if (serviceRestart?.ok) human += `${serviceRestart.output}\n`;
706
- else if (serviceRestart && !serviceRestart.ok)
707
- human += `note: could not restart its persistent service: ${serviceRestart.output}\n`;
708
- return ok(`${human}Reload Pi with /reload to activate the updated package.\n`);
682
+ return ok(`${outcome.output}${transition}\nReload Pi with /reload to activate the updated package.\n`);
709
683
  } catch (error) {
710
684
  const message = error instanceof Error ? error.message : String(error);
711
685
  return flags.json
@@ -755,10 +729,7 @@ const commands: Record<string, { usage: string; run: Command }> = {
755
729
  },
756
730
  };
757
731
 
758
- /** systemd user unit, delegating to vehicle-server's shared generateSystemdUnit -- idle self-exit
759
- * is disabled (PI_PACKED_IDLE_SECS=0) since systemd's own Restart=always takes over lifecycle. This
760
- * only ever prints (see the `service` command above); nothing here calls installUserService, so
761
- * descriptorPath is never actually read/written -- required by ServiceSpec's shape regardless. */
732
+ /** Renders the legacy diagnostic unit without installing it; Armada owns service-manager mutation. */
762
733
  export function renderUnit(execPath: string, cliPath: string, piBin?: string): string {
763
734
  // systemd does not read shell rc files: PI_BIN must be explicit so the
764
735
  // daemon's install/remove execs can find the pi binary.
@@ -767,10 +738,11 @@ export function renderUnit(execPath: string, cliPath: string, piBin?: string): s
767
738
  return generateSystemdUnit({
768
739
  name: "pi-packed",
769
740
  displayName: "pi-packed package service (Pi agent)",
741
+ version: VERSION,
770
742
  binPath: execPath,
771
743
  args: [cliPath, "serve"],
772
744
  env,
773
- descriptorPath: resolvePackedPaths().serviceDescriptor,
745
+ handlePath: resolvePackedPaths().handle,
774
746
  // Packed's own client (connectPackageDaemon) never auto-spawns -- "start packed.service"
775
747
  // or this unit's own systemd supervision is its only recovery path.
776
748
  restartOnFailure: true,
@@ -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
  }