@danypops/pi-packed 0.19.12 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -79,29 +79,48 @@ class FakeDaemonServiceInstaller {
79
79
  restartGotSource = "";
80
80
  restartApproved = false;
81
81
  restartFail = false;
82
- async install(
83
- source: string,
84
- approved?: boolean,
85
- ): Promise<{ output: string; spec?: { name: string; binPath: string; descriptorPath: string } }> {
82
+ reconcileGotApproved: boolean | undefined;
83
+ reconcileGotProjectRoot: string | undefined;
84
+ reconcileFail = false;
85
+ async install(source: string, approved?: boolean) {
86
86
  this.gotSource = source;
87
87
  this.approved = approved === true;
88
88
  if (this.fail) throw new Error("install-service failed");
89
89
  return {
90
90
  output: `installed a persistent service for ${source}`,
91
- spec: { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" },
91
+ spec: {
92
+ name: "probe",
93
+ version: "1.0.0",
94
+ binPath: "/opt/probe/cli.js",
95
+ handlePath: "/tmp/probe.handle.json",
96
+ },
92
97
  };
93
98
  }
94
- async restart(
95
- source: string,
96
- approved?: boolean,
97
- ): Promise<{ output: string; restarted?: boolean; spec?: { name: string; binPath: string; descriptorPath: string } }> {
99
+ async restart(source: string, approved?: boolean) {
98
100
  this.restartGotSource = source;
99
101
  this.restartApproved = approved === true;
100
102
  if (this.restartFail) throw new Error("restart-service failed");
101
103
  return {
102
104
  output: `restarted the persistent service for ${source}`,
103
105
  restarted: true,
104
- spec: { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" },
106
+ spec: {
107
+ name: "probe",
108
+ version: "1.0.0",
109
+ binPath: "/opt/probe/cli.js",
110
+ handlePath: "/tmp/probe.handle.json",
111
+ },
112
+ };
113
+ }
114
+ async reconcileAll(approved?: boolean, projectRoot?: string) {
115
+ this.reconcileGotApproved = approved;
116
+ this.reconcileGotProjectRoot = projectRoot;
117
+ if (this.reconcileFail) throw new Error("reconcile-services failed");
118
+ return {
119
+ ok: true,
120
+ output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
121
+ reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
122
+ skipped: 0,
123
+ failed: [],
105
124
  };
106
125
  }
107
126
  }
@@ -454,67 +473,15 @@ describe("CLI", () => {
454
473
  it("install runs with stable human and JSON output", async () => {
455
474
  const d = deps();
456
475
  expect((await cliRun(["install", "npm:foo"], d)).code).toBe(1);
457
- // --no-service scopes this test to the base install output; service
458
- // auto-registration composition has its own dedicated tests below.
459
- const human = await cliRun(["install", "npm:foo", "--approve", "--no-service"], d);
476
+ const human = await cliRun(["install", "npm:foo", "--approve"], d);
460
477
  expect(human.code).toBe(0);
461
478
  expect(human.out).toContain("Installed npm:foo");
462
479
  expect((d.inst as FakeInstaller).approved).toBe(true);
463
- const json = await cliRun(["install", "npm:foo", "--approve", "--no-service", "--json"], d);
480
+ const json = await cliRun(["install", "npm:foo", "--approve", "--json"], d);
464
481
  expect(json.code).toBe(0);
465
482
  expect(JSON.parse(json.out)).toEqual({ ok: true, source: "npm:foo", output: "Installed npm:foo" });
466
483
  });
467
484
 
468
- it("install auto-registers a detected daemon service under the same approval, silently for a non-daemon package", async () => {
469
- const d = deps();
470
- const human = await cliRun(["install", "npm:foo", "--approve"], d);
471
- expect(human.code).toBe(0);
472
- expect(human.out).toContain("Installed npm:foo");
473
- expect(human.out).toContain("installed a persistent service for npm:foo");
474
- expect((d.daemonService as FakeDaemonServiceInstaller).approved).toBe(true);
475
- const json = await cliRun(["install", "npm:foo", "--approve", "--json"], d);
476
- expect(json.code).toBe(0);
477
- expect(JSON.parse(json.out)).toEqual({
478
- ok: true,
479
- source: "npm:foo",
480
- output: "Installed npm:foo",
481
- serviceInstall: { detected: true, ok: true, output: "installed a persistent service for npm:foo" },
482
- });
483
-
484
- // notADaemon: the overwhelmingly common case (an ordinary, non-daemon package) stays silent.
485
- const notADaemon = deps();
486
- (notADaemon.daemonService as FakeDaemonServiceInstaller).install = async () => {
487
- throw Object.assign(
488
- new Error("foo does not declare a packed.daemonService manifest and no Vehicle-shaped daemon dependency was detected"),
489
- { notADaemon: true },
490
- );
491
- };
492
- const silent = await cliRun(["install", "npm:foo", "--approve", "--json"], notADaemon);
493
- expect(JSON.parse(silent.out)).toEqual({ ok: true, source: "npm:foo", output: "Installed npm:foo" });
494
-
495
- // A genuine failure (a daemon was detected but registration itself failed) is reported
496
- // without failing the install, which already succeeded.
497
- const realFailure = deps();
498
- (realFailure.daemonService as FakeDaemonServiceInstaller).fail = true;
499
- const failed = await cliRun(["install", "npm:foo", "--approve", "--json"], realFailure);
500
- expect(failed.code).toBe(0);
501
- expect(JSON.parse(failed.out)).toEqual({
502
- ok: true,
503
- source: "npm:foo",
504
- output: "Installed npm:foo",
505
- serviceInstall: { detected: true, ok: false, output: "install-service failed" },
506
- });
507
-
508
- // --no-service skips the attempt entirely -- the daemonService fake is never called.
509
- const skipped = deps();
510
- await cliRun(["install", "npm:foo", "--approve", "--no-service"], skipped);
511
- expect((skipped.daemonService as FakeDaemonServiceInstaller).gotSource).toBe("");
512
-
513
- // A non-npm source never attempts service detection -- daemon-service resolution only supports npm: today.
514
- const gitSource = deps();
515
- await cliRun(["install", "git:github.com/u/r@v1", "--approve"], gitSource);
516
- expect((gitSource.daemonService as FakeDaemonServiceInstaller).gotSource).toBe("");
517
- });
518
485
 
519
486
  it("install-service validates source", async () => {
520
487
  const d = deps();
@@ -536,7 +503,12 @@ describe("CLI", () => {
536
503
  ok: true,
537
504
  source: "npm:foo",
538
505
  output: "installed a persistent service for npm:foo",
539
- spec: { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" },
506
+ spec: {
507
+ name: "probe",
508
+ version: "1.0.0",
509
+ binPath: "/opt/probe/cli.js",
510
+ handlePath: "/tmp/probe.handle.json",
511
+ },
540
512
  });
541
513
  });
542
514
 
@@ -555,33 +527,52 @@ describe("CLI", () => {
555
527
  expect(out).toContain("requires a running packed daemon");
556
528
  });
557
529
 
558
- it("update delegates one configured source with stable output and approval", async () => {
530
+ it("reconcile-services requires approval, then sweeps every installed package with stable human and JSON output", async () => {
559
531
  const d = deps();
560
- expect((await cliRun(["update", "npm:foo"], d)).code).toBe(1);
561
- // --no-service scopes this test to the base update output; service restart
562
- // composition has its own dedicated test below, mirroring install's own split.
563
- const human = await cliRun(["update", "npm:foo", "--approve", "--no-service"], d);
564
- expect(human.out).toContain("Updated npm:foo");
565
- expect((d.inst as FakeInstaller).updated).toBe("npm:foo");
566
- expect((d.inst as FakeInstaller).approved).toBe(true);
567
- const json = await cliRun(["update", "npm:foo", "--approve", "--no-service", "--json"], d);
532
+ expect((await cliRun(["reconcile-services"], d)).code).toBe(1);
533
+ const human = await cliRun(["reconcile-services", "--approve"], d);
534
+ expect(human.code).toBe(0);
535
+ expect(human.out).toContain("reconciled 1 Vehicle(s)");
536
+ expect((d.daemonService as FakeDaemonServiceInstaller).reconcileGotApproved).toBe(true);
537
+ const json = await cliRun(["reconcile-services", "--approve", "--json"], d);
538
+ expect(json.code).toBe(0);
568
539
  expect(JSON.parse(json.out)).toEqual({
569
540
  ok: true,
570
- source: "npm:foo",
571
- output: "Updated npm:foo",
572
- reloadRequired: true,
573
- alreadyUpToDate: false,
574
- pinned: false,
541
+ output: "reconciled 1 Vehicle(s), skipped 0 non-daemon package(s)",
542
+ reconciled: [{ packageName: "@danypops/probe", vehicleName: "probe", installed: true }],
543
+ skipped: 0,
544
+ failed: [],
575
545
  });
576
546
  });
577
547
 
578
- it("update restarts a registered daemon service after a real change, silently for a non-daemon package", async () => {
548
+ it("reconcile-services threads --project through to the sweep", async () => {
549
+ const d = deps();
550
+ await cliRun(["reconcile-services", "--approve", "--project", "/repo"], d);
551
+ expect((d.daemonService as FakeDaemonServiceInstaller).reconcileGotProjectRoot).toBe("/repo");
552
+ });
553
+
554
+ it("reconcile-services reports a sweep failure in-band with exit code 1", async () => {
555
+ const d = deps();
556
+ (d.daemonService as FakeDaemonServiceInstaller).reconcileFail = true;
557
+ const { code, out } = await cliRun(["reconcile-services", "--approve"], d);
558
+ expect(code).toBe(1);
559
+ expect(out).toContain("reconcile-services failed");
560
+ });
561
+
562
+ it("reconcile-services fails closed without a running daemon", async () => {
563
+ const d = deps({ daemonService: undefined });
564
+ const { code, out } = await cliRun(["reconcile-services", "--approve"], d);
565
+ expect(code).toBe(1);
566
+ expect(out).toContain("requires a running packed daemon");
567
+ });
568
+
569
+ it("update delegates one configured source with stable output and approval", async () => {
579
570
  const d = deps();
571
+ expect((await cliRun(["update", "npm:foo"], d)).code).toBe(1);
580
572
  const human = await cliRun(["update", "npm:foo", "--approve"], d);
581
- expect(human.code).toBe(0);
582
573
  expect(human.out).toContain("Updated npm:foo");
583
- expect(human.out).toContain("restarted the persistent service for npm:foo");
584
- expect((d.daemonService as FakeDaemonServiceInstaller).restartApproved).toBe(true);
574
+ expect((d.inst as FakeInstaller).updated).toBe("npm:foo");
575
+ expect((d.inst as FakeInstaller).approved).toBe(true);
585
576
  const json = await cliRun(["update", "npm:foo", "--approve", "--json"], d);
586
577
  expect(JSON.parse(json.out)).toEqual({
587
578
  ok: true,
@@ -590,47 +581,10 @@ describe("CLI", () => {
590
581
  reloadRequired: true,
591
582
  alreadyUpToDate: false,
592
583
  pinned: false,
593
- serviceRestart: { detected: true, ok: true, output: "restarted the persistent service for npm:foo" },
594
584
  });
595
-
596
- // notADaemon: the overwhelmingly common case (an ordinary, non-daemon package) stays silent.
597
- const notADaemon = deps();
598
- (notADaemon.daemonService as FakeDaemonServiceInstaller).restart = async () => {
599
- throw Object.assign(
600
- new Error("foo does not declare a packed.daemonService manifest and no Vehicle-shaped daemon dependency was detected"),
601
- { notADaemon: true },
602
- );
603
- };
604
- const silent = await cliRun(["update", "npm:foo", "--approve", "--json"], notADaemon);
605
- expect(JSON.parse(silent.out)).toEqual({
606
- ok: true,
607
- source: "npm:foo",
608
- output: "Updated npm:foo",
609
- reloadRequired: true,
610
- alreadyUpToDate: false,
611
- pinned: false,
612
- });
613
-
614
- // A genuine failure (a daemon was detected but restarting it failed) is reported
615
- // without failing the update, which already succeeded.
616
- const realFailure = deps();
617
- (realFailure.daemonService as FakeDaemonServiceInstaller).restartFail = true;
618
- const failed = await cliRun(["update", "npm:foo", "--approve", "--json"], realFailure);
619
- expect(failed.code).toBe(0);
620
- const failedBody = JSON.parse(failed.out);
621
- expect(failedBody.serviceRestart).toEqual({ detected: true, ok: false, output: "restart-service failed" });
622
-
623
- // --no-service skips the attempt entirely -- the daemonService fake is never called.
624
- const skipped = deps();
625
- await cliRun(["update", "npm:foo", "--approve", "--no-service"], skipped);
626
- expect((skipped.daemonService as FakeDaemonServiceInstaller).restartGotSource).toBe("");
627
-
628
- // A non-npm source never attempts service detection -- daemon-service resolution only supports npm: today.
629
- const gitSource = deps({ inst: new FakeInstaller() });
630
- await cliRun(["update", "git:github.com/u/r@v1", "--approve"], gitSource);
631
- expect((gitSource.daemonService as FakeDaemonServiceInstaller).restartGotSource).toBe("");
632
585
  });
633
586
 
587
+
634
588
  it("update --self requires approval under the guarded default, same as every other mutation", async () => {
635
589
  const d = deps({
636
590
  selfUpdater: {
@@ -851,19 +805,36 @@ describe("CLI", () => {
851
805
  },
852
806
  async installService(source) {
853
807
  calls.push(`installService:${source}`);
854
- return { output: source, spec: { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" } };
808
+ return {
809
+ output: source,
810
+ spec: {
811
+ name: "probe",
812
+ version: "1.0.0",
813
+ binPath: "/opt/probe/cli.js",
814
+ handlePath: "/tmp/probe.handle.json",
815
+ },
816
+ };
855
817
  },
856
818
  async restartService(source) {
857
819
  calls.push(`restartService:${source}`);
858
820
  return {
859
821
  output: source,
860
822
  restarted: true,
861
- spec: { name: "probe", binPath: "/opt/probe/cli.js", descriptorPath: "/tmp/probe.service" },
823
+ spec: {
824
+ name: "probe",
825
+ version: "1.0.0",
826
+ binPath: "/opt/probe/cli.js",
827
+ handlePath: "/tmp/probe.handle.json",
828
+ },
862
829
  };
863
830
  },
864
831
  async remove(name) {
865
832
  return name;
866
833
  },
834
+ async reconcileServices(approved, projectRoot) {
835
+ calls.push(`reconcileServices:${approved}:${projectRoot}`);
836
+ return { ok: true, output: "reconciled 0 Vehicle(s), skipped 0 non-daemon package(s)", reconciled: [], skipped: 0, failed: [] };
837
+ },
867
838
  async update(source) {
868
839
  return { output: source, reloadRequired: false, alreadyUpToDate: true, pinned: false };
869
840
  },
@@ -1050,7 +1021,7 @@ describe("CLI", () => {
1050
1021
  const d = deps({ piHome });
1051
1022
 
1052
1023
  const clean = await cliRun(["doctor", "--json"], d);
1053
- expect(clean.code).toBe(0);
1024
+ expect(clean.code, clean.out).toBe(0);
1054
1025
  expect(JSON.parse(clean.out)).toMatchObject({ ok: true, conflicts: [] });
1055
1026
 
1056
1027
  const withProject = await cliRun(["doctor", "--project", projectRoot, "--json"], d);
@@ -1105,16 +1076,27 @@ describe("daemon client", () => {
1105
1076
  reg: new FakeRegistry([{ name: "pi-lsp", version: "0.3.0" }]),
1106
1077
  inst: daemonInstaller,
1107
1078
  daemonServiceInstaller: {
1108
- install: () => ({
1109
- ok: true,
1110
- result: { installed: true },
1111
- spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js", descriptorPath: "/tmp/pi-lsp.service" },
1112
- }),
1113
- restart: () => ({
1114
- ok: true,
1115
- restarted: true,
1116
- spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js", descriptorPath: "/tmp/pi-lsp.service" },
1117
- }),
1079
+ async install() {
1080
+ return {
1081
+ ok: true,
1082
+ result: { installed: true },
1083
+ spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
1084
+ };
1085
+ },
1086
+ async remove() {
1087
+ return {
1088
+ ok: true,
1089
+ result: { installed: true },
1090
+ spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
1091
+ };
1092
+ },
1093
+ async restart() {
1094
+ return {
1095
+ ok: true,
1096
+ restarted: true,
1097
+ spec: { name: "pi-lsp", version: "1.0.0", binPath: "/opt/pi-lsp/cli.js", handlePath: "/tmp/pi-lsp.handle.json" },
1098
+ };
1099
+ },
1118
1100
  },
1119
1101
  token: daemonToken,
1120
1102
  stateDir: daemonDir,
@@ -1244,11 +1226,11 @@ describe("daemon client", () => {
1244
1226
  expect((await client.setupApply(`${checkRoot}/pi-setup.json`, true)).ok).toBe(true);
1245
1227
  expect(await client.security()).toEqual({ mutationApproval: "always" });
1246
1228
  await expect(client.install("npm:pi-lsp")).rejects.toThrow("approval required");
1247
- expect(await client.install("npm:pi-lsp", true)).toBe("Installed npm:pi-lsp");
1229
+ expect(await client.install("npm:pi-lsp", true)).toBe("Installed npm:pi-lsp\ninstalled persistent Vehicle pi-lsp");
1248
1230
  await expect(client.installService("npm:pi-lsp")).rejects.toThrow("approval required");
1249
1231
  expect(await client.installService("npm:pi-lsp", true)).toEqual({
1250
1232
  output: "installed a persistent service for pi-lsp",
1251
- spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js", descriptorPath: "/tmp/pi-lsp.service" },
1233
+ spec: { name: "pi-lsp", binPath: "/opt/pi-lsp/cli.js" },
1252
1234
  });
1253
1235
  expect(await client.remove("pi-lsp", true)).toBe("Removed npm:pi-lsp");
1254
1236
  expect(await client.piStatus()).toEqual({ current: "0.82.1", latest: "0.83.0", upToDate: false });
@@ -1261,7 +1243,9 @@ describe("daemon client", () => {
1261
1243
  currentVersion: undefined,
1262
1244
  });
1263
1245
  const installer = new PackageDaemonInstaller(client);
1264
- expect(await installer.install("npm:pi-lsp@1.0.0", { approved: true })).toBe("Installed npm:pi-lsp@1.0.0");
1246
+ expect(await installer.install("npm:pi-lsp@1.0.0", { approved: true })).toBe(
1247
+ "Installed npm:pi-lsp@1.0.0\ninstalled persistent Vehicle pi-lsp",
1248
+ );
1265
1249
  expect(await installer.remove("npm:pi-lsp", { approved: true })).toBe("Removed npm:pi-lsp");
1266
1250
  expect((await installer.update("npm:pi-lsp", { approved: true })).output).toBe("Updated npm:pi-lsp");
1267
1251
  expect(await client.setMutationApproval("never", true)).toEqual({ mutationApproval: "never" });
@@ -142,6 +142,7 @@ describe("daemon-kit migration", () => {
142
142
  "package.install",
143
143
  "package.install_service",
144
144
  "package.restart_service",
145
+ "package.reconcile_services",
145
146
  "package.remove",
146
147
  "package.update",
147
148
  "resources.list",