@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.
@@ -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 {
@@ -0,0 +1,173 @@
1
+ /**
2
+ * packed's full 29-operation daemon surface projected onto the real Vehicle
3
+ * protocol. Every operation delegates to the exact same executeOperation()
4
+ * function /api/v1/ops already calls (one implementation, two projections --
5
+ * the same shape every other Vehicle-migrated daemon in this ecosystem
6
+ * uses) -- no behavior change, only a second real transport served
7
+ * alongside (not replacing) the existing /api/v1/ops route.
8
+ *
9
+ * Every operation's input is already a well-typed OperationInputs[Name] that
10
+ * executeOperation() validates and dispatches internally (throwing
11
+ * PackageOperationError on a bad shape/denied approval) -- there is no
12
+ * separate Vehicle-side schema to duplicate that logic, so both input and
13
+ * output use passthroughVehicleSchema and let executeOperation's own
14
+ * validation (already covered by service.test.ts) be the single source of
15
+ * truth for what's accepted.
16
+ *
17
+ * Effect classification is grounded in security.ts's own PACKAGE_OPERATIONS
18
+ * classification (read/maintenance/code-execution/settings-mutation/
19
+ * security-mutation) -- the codebase's own, already-deployed risk model --
20
+ * translated to Vehicle's effect vocabulary rather than independently
21
+ * re-derived, with two deliberate refinements documented at each entry
22
+ * where Vehicle's own taxonomy draws a finer distinction Packed's doesn't
23
+ * (package.remove -> destructive, since Vehicle has a dedicated category
24
+ * for irreversible deletion; restart_service/reconcile_services ->
25
+ * external-write rather than open-world, since restarting/reconciling an
26
+ * ALREADY-installed, already-vetted service introduces no new code, unlike
27
+ * install/install_service/update/setup.apply which can fetch and run
28
+ * arbitrary newly-published code).
29
+ *
30
+ * Approval/authorization is NOT reimplemented here: executeOperation()
31
+ * (and the route()-based operations it delegates to internally) already
32
+ * calls authorize()/assertPackagePermission() and throws
33
+ * PackageOperationError(status: 403) on a denied mutation -- reusing
34
+ * executeOperation() verbatim means this Vehicle surface automatically
35
+ * inherits the exact same approval gate, with zero duplicated policy logic
36
+ * to drift out of sync.
37
+ */
38
+
39
+ import type { VehicleEffect, VehicleIdempotency } from "@danypops/vehicle-core";
40
+ import { bindVehicleOperation, defineVehicleOperation, passthroughVehicleSchema, VehicleError } from "@danypops/vehicle-core";
41
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
42
+ import { OPERATION_NAMES, type OperationInputs, type OperationName, type OperationOutputs } from "./service.ts";
43
+
44
+ /** Thrown by executeOperation() with an HTTP-shaped .status; preserves /api/v1/ops's own status->meaning exactly instead of letting VehicleRegistry.invoke()'s catch-all flatten every failure into a generic internal/500 with the message discarded. */
45
+ interface StatusCarryingError extends Error {
46
+ readonly status: number;
47
+ }
48
+
49
+ function hasStatus(error: unknown): error is StatusCarryingError {
50
+ return error instanceof Error && typeof (error as { status?: unknown }).status === "number";
51
+ }
52
+
53
+ async function withPackedErrorParity<T>(run: () => T | Promise<T>): Promise<T> {
54
+ try {
55
+ return await run();
56
+ } catch (error) {
57
+ if (error instanceof VehicleError) throw error;
58
+ if (hasStatus(error)) {
59
+ const category =
60
+ error.status === 403
61
+ ? "authorization"
62
+ : error.status === 404
63
+ ? "not_found"
64
+ : error.status === 400
65
+ ? "validation"
66
+ : error.status >= 500
67
+ ? "unavailable"
68
+ : "validation";
69
+ throw new VehicleError("operation-rejected", error.message, { category, cause: error });
70
+ }
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ throw new VehicleError("operation-rejected", message, { category: "validation", cause: error });
73
+ }
74
+ }
75
+
76
+ const OWNER = "packed";
77
+ const LIMITS = { defaultTimeoutMs: 30_000, maxTimeoutMs: 120_000, maxRequestBytes: 65_536, maxResponseBytes: 4_194_304 };
78
+
79
+ const READ: VehicleIdempotency = { mode: "safe" };
80
+ const WRITE: VehicleIdempotency = { mode: "unsafe" };
81
+
82
+ interface OperationMeta {
83
+ readonly description: string;
84
+ readonly effect: VehicleEffect;
85
+ }
86
+
87
+ /**
88
+ * One entry per OPERATION_NAMES member. See this file's own doc comment for
89
+ * the general translation rule (security.ts's PACKAGE_OPERATIONS
90
+ * classification) and its two deliberate refinements.
91
+ */
92
+ const OPERATION_META: Record<OperationName, OperationMeta> = {
93
+ "package.search": { description: "Searches Pi packages on npm.", effect: "read" },
94
+ "package.info": { description: "Reads bounded metadata for one package.", effect: "read" },
95
+ "package.installed": { description: "Lists locally installed Pi packages.", effect: "read" },
96
+ "package.catalog": { description: "Reads the local SQLite catalog mirror.", effect: "read" },
97
+ "package.catalog.sync": {
98
+ description: "Refreshes the local catalog mirror from the Pi-package-tagged npm registry subset.",
99
+ effect: "external-write",
100
+ },
101
+ "package.index": { description: "Reads the locally built adoption-score index, if one exists.", effect: "read" },
102
+ "package.index.build": {
103
+ description: "Builds the adoption-score index by scoring catalog entries against the npm registry.",
104
+ effect: "external-write",
105
+ },
106
+ "package.updates": { description: "Reads the last background update-check snapshot.", effect: "read" },
107
+ "package.check": { description: "Runs static (and optionally smoke-test) quality checks against a local package path.", effect: "read" },
108
+ "package.pack": { description: "Verifies a local package path via npm pack.", effect: "read" },
109
+ "package.score": { description: "Computes an adoption-readiness score for a local path or registry package.", effect: "read" },
110
+ "setup.export": { description: "Exports the current Pi setup as a portable manifest.", effect: "local-write" },
111
+ "setup.update": { description: "Updates an existing setup manifest in place.", effect: "local-write" },
112
+ "setup.plan": { description: "Computes a setup manifest's install/update/remove plan without applying it.", effect: "read" },
113
+ "setup.apply": {
114
+ description: "Applies a setup manifest's plan -- can install, update, or remove packages.",
115
+ effect: "open-world",
116
+ },
117
+ "package.security.get": { description: "Reads this daemon's mutation-approval security settings.", effect: "read" },
118
+ "package.security.set": { description: "Writes this daemon's mutation-approval security settings.", effect: "local-write" },
119
+ "package.install": { description: "Installs a Pi package from an npm, git, or https source.", effect: "open-world" },
120
+ "package.install_service": {
121
+ description: "Installs a persistent supervised service for an already-installed daemon package.",
122
+ effect: "open-world",
123
+ },
124
+ "package.restart_service": {
125
+ description: "Restarts an already-installed package's persistent service -- no new code introduced.",
126
+ effect: "external-write",
127
+ },
128
+ "package.reconcile_services": {
129
+ description: "Reconciles every installed daemon package's persistent service against desired state.",
130
+ effect: "external-write",
131
+ },
132
+ "package.remove": { description: "Removes an installed Pi package. Irreversible.", effect: "destructive" },
133
+ "package.update": { description: "Updates a configured Pi package to its latest available version.", effect: "open-world" },
134
+ "resources.list": { description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).", effect: "read" },
135
+ "resources.toggle": { description: "Enables or disables one Pi resource in a settings file.", effect: "local-write" },
136
+ "pi.status": { description: "Reports the locally running Pi version against the latest published release.", effect: "read" },
137
+ "advisories.scan": { description: "Scans installed package versions against known advisories.", effect: "read" },
138
+ "doctor.run": { description: "Runs diagnostic health checks (service install drift, resource config, ...).", effect: "read" },
139
+ "package.updates.project": { description: "Checks for updates across every scope visible to one project.", effect: "read" },
140
+ };
141
+
142
+ /** Read effects need only packed:read; every other effect needs both (writes commonly also read first). */
143
+ function permissionsFor(effect: VehicleEffect): readonly string[] {
144
+ return effect === "read" ? ["packed:read"] : ["packed:read", "packed:write"];
145
+ }
146
+
147
+ export function registerPackedVehicleOperations(
148
+ registry: VehicleRegistry,
149
+ executeOperation: <Name extends OperationName>(op: Name, input: OperationInputs[Name]) => Promise<OperationOutputs[Name]>,
150
+ ): void {
151
+ for (const name of OPERATION_NAMES) {
152
+ const meta = OPERATION_META[name];
153
+ const operation = defineVehicleOperation({
154
+ name,
155
+ version: 1,
156
+ description: meta.description,
157
+ input: passthroughVehicleSchema,
158
+ output: passthroughVehicleSchema,
159
+ permissions: permissionsFor(meta.effect),
160
+ effect: meta.effect,
161
+ idempotency: meta.effect === "read" ? READ : WRITE,
162
+ limits: LIMITS,
163
+ });
164
+ registry.register(
165
+ OWNER,
166
+ bindVehicleOperation(
167
+ operation,
168
+ () => async (context) =>
169
+ withPackedErrorParity(() => executeOperation(name, context.input as OperationInputs[typeof name])),
170
+ ),
171
+ );
172
+ }
173
+ }
@@ -1,10 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
6
  import { readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
7
- import { isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service";
7
+ import { createNodeServiceInstallDeps, isServiceInstalled as vehicleIsServiceInstalled } from "@danypops/vehicle-server/service";
8
8
  import type {
9
9
  ExtensionOperationInputs,
10
10
  ExtensionOperationName,
@@ -89,21 +89,9 @@ export function resolvePackedClientPaths(options: PackedPathOptions = {}): Packe
89
89
  return { token: paths.token, handle: paths.handle, serviceDescriptor: paths.serviceDescriptor };
90
90
  }
91
91
 
92
- /** Real, file-existence-only check on Linux/macOS (Windows checks a
93
- * registry Run key instead) -- cheap, synchronous, no subprocess. */
94
- function isPackedServiceInstalled(serviceDescriptor: string): boolean {
95
- return vehicleIsServiceInstalled(
96
- { name: SERVICE_NAME, binPath: "", descriptorPath: serviceDescriptor },
97
- {
98
- fileExists: existsSync,
99
- writeFile: () => {},
100
- readFile: () => null,
101
- removeFile: () => {},
102
- mkdirp: () => {},
103
- runCommand: () => ({ ok: false, output: "" }),
104
- which: () => false,
105
- },
106
- );
92
+ /** Checks Armada's authoritative desired fleet rather than native descriptor files. */
93
+ function isPackedServiceInstalled(): boolean {
94
+ return vehicleIsServiceInstalled(SERVICE_NAME, createNodeServiceInstallDeps());
107
95
  }
108
96
 
109
97
  export type FetchTransport = (request: Request) => Promise<Response>;
@@ -251,7 +239,7 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
251
239
  const waitedSeconds = (attempts * delayMs) / 1000;
252
240
  throw new Error(
253
241
  serviceInstalled
254
- ? `Packed daemon did not become ready within ${waitedSeconds} seconds, but a supervised service is installed -- check it directly (e.g. systemctl --user status pi-packed.service) rather than auto-spawning a second one`
242
+ ? `Packed daemon did not become ready within ${waitedSeconds} seconds, but a managed service is installed -- run packed doctor rather than auto-spawning a second one`
255
243
  : `Packed daemon did not become ready within ${waitedSeconds} seconds`,
256
244
  );
257
245
  }
@@ -259,7 +247,7 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
259
247
  export async function ensurePackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise<PackedClient> {
260
248
  return ensureClient({
261
249
  connect: () => connectPackedClient(paths, transport),
262
- isServiceInstalled: () => isPackedServiceInstalled(paths.serviceDescriptor),
250
+ isServiceInstalled: () => isPackedServiceInstalled(),
263
251
  spawn: () => {
264
252
  const override = process.env.PI_PACKED_BIN;
265
253
  const command = override ?? process.env.PI_PACKED_BUN ?? "bun";
@@ -92,7 +92,6 @@ export type PackageResources = { source: string; name: string; scope: "global" |
92
92
  export interface ServiceSpecSummary {
93
93
  name: string;
94
94
  binPath: string;
95
- descriptorPath: string;
96
95
  }
97
96
 
98
97
  /** Mirrors service/src/pi/pi-version.ts's CURRENT_PI_PACKAGE_NAME
@@ -23,6 +23,7 @@ export const PACKAGE_OPERATIONS = [
23
23
  "install",
24
24
  "install_service",
25
25
  "restart_service",
26
+ "reconcile_services",
26
27
  "setup.apply",
27
28
  "update",
28
29
  "update.self",
@@ -70,6 +71,7 @@ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification>
70
71
  install: "code-execution",
71
72
  install_service: "code-execution",
72
73
  restart_service: "code-execution",
74
+ reconcile_services: "code-execution",
73
75
  "setup.apply": "code-execution",
74
76
  update: "code-execution",
75
77
  "update.self": "code-execution",
@@ -52,6 +52,7 @@ export const INDEX_OPERATION_TIMEOUT_MS = 10 * 60_000;
52
52
  export const WATCH_INTERVAL_DEFAULT_MS = 30 * 60_000; // updates diff cadence
53
53
  export const CATALOG_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // full mirror TTL
54
54
  export const INDEX_INTERVAL_DEFAULT_MS = 6 * 3_600_000; // static index regeneration TTL, same cadence as the catalog mirror it reads from
55
+ export const RECONCILE_INTERVAL_DEFAULT_MS = 30 * 60_000; // Vehicle-service drift sweep cadence -- catches an out-of-band npm install/update a running daemon never picked up
55
56
  export const IDLE_BUDGET_DEFAULT_MS = 10 * 60_000; // on-demand self-exit
56
57
  export const WATCHDOG_TICK_MS = 15_000;
57
58
 
@@ -69,5 +70,6 @@ export const ENV = {
69
70
  WATCH_SECS: "PI_PACKED_WATCH_SECS",
70
71
  CATALOG_SECS: "PI_PACKED_CATALOG_SECS",
71
72
  INDEX_SECS: "PI_PACKED_INDEX_SECS",
73
+ RECONCILE_SECS: "PI_PACKED_RECONCILE_SECS",
72
74
  IDLE_SECS: "PI_PACKED_IDLE_SECS",
73
75
  } as const;