@danypops/pi-packed 0.22.2 → 0.22.3

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.
@@ -74,7 +74,11 @@ export async function registerPackedVehicle(pi: ExtensionAPI): Promise<void> {
74
74
  await registerVehicleTools(pi, client, {
75
75
  permissions: ["packed:read"],
76
76
  principal: { id: "pi-packed" },
77
- shell: { coreOperations: CORE_OPERATIONS },
77
+ // ownVehicleName must match daemon.ts's own PACKED_VEHICLE_NAME (the shared Vehicle Handle
78
+ // Directory entry Packed's own daemon registers under via daemonOptions()'s vehicleName --
79
+ // see service/src/daemon/daemon.ts). activateForeignOperation is auto-supplied by
80
+ // registerVehicleTools. See enable-vehicle-shell-broker-mode-in-pi-packed.
81
+ shell: { coreOperations: CORE_OPERATIONS, broker: { ownVehicleName: "pi-packed" } },
78
82
  });
79
83
  } catch {
80
84
  // Daemon state is stale/unreachable -- degrade silently, matching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.22.2",
3
+ "version": "0.22.3",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@ import { generateIndex, indexPath, readIndex } from "../index/build-index.ts";
14
14
  import { syncCatalog } from "../packages/catalog.ts";
15
15
  import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
16
16
  import { formatDeployVerification, verifyDeploy } from "../packages/deploy-verify.ts";
17
+ import { listManagedPackages } from "../daemon/daemon-service.ts";
17
18
  import { defaultPiBin, NAME_RE } from "../packages/install.ts";
18
19
  import { npmPackageName, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
19
20
  import type { Installer, Pkg, Registry, UpdateEntry } from "../packages/package.ts";
@@ -556,9 +557,13 @@ const commands: Record<string, { usage: string; run: Command }> = {
556
557
  installed: {
557
558
  usage: "packed installed [--json]",
558
559
  async run(_rest, d, flags) {
559
- const installed = d.daemon ? await d.daemon.installed() : readInstalledPackages(d.piHome);
560
+ const installed = d.daemon ? await d.daemon.installed() : listManagedPackages(d.piHome);
560
561
  if (flags.json) return ok(`${JSON.stringify(installed)}\n`);
561
- return ok(installed.map((p) => ` ${p.name}@${p.pinned ?? p.installed ?? "?"}\n`).join(""));
562
+ return ok(
563
+ installed
564
+ .map((p) => ` ${p.name}@${p.pinned ?? p.installed ?? "?"}${p.kind === "daemon-dependency" ? " [daemon-dependency]" : ""}\n`)
565
+ .join(""),
566
+ );
562
567
  },
563
568
  },
564
569
 
@@ -25,7 +25,7 @@
25
25
  * step isn't a real convention, so detection reads what's already true
26
26
  * on disk instead of asking for one more declaration.
27
27
  */
28
- import { readFileSync } from "node:fs";
28
+ import { readdirSync, readFileSync } from "node:fs";
29
29
  import { join } from "node:path";
30
30
  import { createVehicleRegistrar, type VehicleRegistrar } from "@danypops/armada";
31
31
  import { resolveDaemonPaths } from "@danypops/vehicle-server/paths";
@@ -36,7 +36,8 @@ import {
36
36
  type ServiceSpec,
37
37
  unregisterVehicleService,
38
38
  } from "@danypops/vehicle-server/service";
39
- import { npmPackageName, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
39
+ import { npmPackageName, readInstalledPackagesAcrossScopes, readPackageDeclarations } from "../packages/installed.ts";
40
+ import type { InstalledPkg } from "../packages/package.ts";
40
41
 
41
42
  export interface DaemonServiceManifest {
42
43
  /** Relative to the installed package's own root directory. */
@@ -154,12 +155,25 @@ export function detectVehicleDaemonService(
154
155
  return undefined;
155
156
  }
156
157
 
158
+ /**
159
+ * "daemon.json" -- not "handle.json" -- when a detected daemon carries no explicit
160
+ * packed.daemonService.handleFilename override. Confirmed live across every real Vehicle daemon
161
+ * checked in this ecosystem: jittor, pipes, and web-spider-daemon's own HANDLE_FILENAME constants
162
+ * are all "daemon.json"; only enigma uses "handle.json". A real, reported bug traced back to this
163
+ * default: pipes' Armada vehicle registration kept reverting to the wrong handlePath every time
164
+ * this auto-detect path re-ran, because this guess didn't match pipes' own real convention. Still
165
+ * a guess, not a read of the target package's own actual value -- a package with a real,
166
+ * non-default handleFilename must declare it explicitly via packed.daemonService for a fully
167
+ * correct resolution; this only narrows how often the guess is wrong.
168
+ */
169
+ const DEFAULT_DETECTED_HANDLE_FILENAME = "daemon.json";
170
+
157
171
  function buildSpec(entry: ResolvedDaemonEntrypoint): ServiceSpec {
158
172
  const paths = resolveDaemonPaths({
159
173
  stateDirectoryName: entry.name,
160
174
  databaseFilename: "unused.db",
161
175
  tokenFilename: "unused-token",
162
- handleFilename: entry.handleFilename ?? "handle.json",
176
+ handleFilename: entry.handleFilename ?? DEFAULT_DETECTED_HANDLE_FILENAME,
163
177
  systemdUnitName: `${entry.name}.service`,
164
178
  });
165
179
  return {
@@ -218,6 +232,156 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
218
232
  };
219
233
  }
220
234
 
235
+ export interface DaemonDependencyPkg {
236
+ name: string;
237
+ version: string;
238
+ /** The Armada vehicle name resolveDaemonServiceSpec built for this package -- may differ
239
+ * from `name`'s own bare unscoped form when a packed.daemonService manifest overrides it. */
240
+ vehicleName: string;
241
+ }
242
+
243
+ /** Bare npm package names directly under a node_modules directory -- one level for an
244
+ * unscoped name, two for a @scope/name pair. Never throws; a missing directory (no
245
+ * npm project yet) is just an empty result. */
246
+ function listTopLevelPackageNames(nodeModulesDir: string): string[] {
247
+ let entries: string[];
248
+ try {
249
+ entries = readdirSync(nodeModulesDir);
250
+ } catch {
251
+ return [];
252
+ }
253
+ const names: string[] = [];
254
+ for (const entry of entries) {
255
+ if (!entry.startsWith("@")) {
256
+ names.push(entry);
257
+ continue;
258
+ }
259
+ let scoped: string[];
260
+ try {
261
+ scoped = readdirSync(join(nodeModulesDir, entry));
262
+ } catch {
263
+ continue;
264
+ }
265
+ for (const name of scoped) names.push(`${entry}/${name}`);
266
+ }
267
+ return names;
268
+ }
269
+
270
+ /** Matches reconcileAllDaemonServices' own bound -- a full node_modules sweep never
271
+ * processes an unbounded package list either. */
272
+ const MAX_DAEMON_DEPENDENCY_SCAN = 500;
273
+
274
+ /**
275
+ * Every top-level npm package physically installed under piHome/npm/node_modules
276
+ * that resolveDaemonServiceSpec() itself already recognizes as a real Vehicle-
277
+ * shaped daemon (an explicit packed.daemonService manifest, or
278
+ * detectVehicleDaemonService()'s own-bin-plus-real-vehicle-dependency
279
+ * convention), but that is NOT itself a pi:-configured extension
280
+ * (readPackageDeclarations() has no entry for it) -- e.g. @danypops/lector,
281
+ * added directly to piHome/npm/package.json as an independent pin so it can
282
+ * run ahead of whatever version its own pi-lector wrapper's dependency tree
283
+ * would otherwise resolve. These are real, running Armada vehicles that a
284
+ * pi:-manifest-only "installed packages" notion makes permanently invisible
285
+ * to `packed installed`/`packed update` -- see
286
+ * packed-package-update-restart-service-cant-manage.
287
+ *
288
+ * Deliberately does NOT walk one level into each configured extension's own
289
+ * dependencies the way resolveDaemonServiceSpec's own detection does for
290
+ * install-service/restart-service/doctor (service-doctor.ts's
291
+ * checkServiceUnitPaths already reaches that same dependency through its
292
+ * wrapper extension's own row) -- duplicating that walk here would
293
+ * double-report the same Vehicle under two different package names. This
294
+ * only reports a dependency independently resolvable BY ITS OWN NAME at
295
+ * piHome/npm's own top level -- exactly the shape a caller can actually run
296
+ * `packed update npm:<name>` against directly.
297
+ */
298
+ export function listUnconfiguredDaemonDependencies(piHome: string): DaemonDependencyPkg[] {
299
+ const configured = new Set(
300
+ readPackageDeclarations(piHome).flatMap((source) => {
301
+ const name = npmPackageName(source);
302
+ return name ? [name] : [];
303
+ }),
304
+ );
305
+ const names = listTopLevelPackageNames(join(piHome, "npm", "node_modules")).slice(0, MAX_DAEMON_DEPENDENCY_SCAN);
306
+ const out: DaemonDependencyPkg[] = [];
307
+ for (const name of names) {
308
+ if (configured.has(name)) continue;
309
+ const resolved = resolveDaemonServiceSpec(piHome, `npm:${name}`);
310
+ if (resolved.ok) out.push({ name, version: resolved.spec.version, vehicleName: resolved.spec.name });
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /**
316
+ * True when `packageName` (a bare npm name) is a pi:-configured extension --
317
+ * present in readPackageDeclarations()'s own packages[] list, regardless of
318
+ * whether it's pinned. The other half of the classification
319
+ * classifyUpdateSource() needs: a name that resolves ok via
320
+ * resolveDaemonServiceSpec() but returns false here is a "daemon-dependency"
321
+ * class source, not an "extension" one.
322
+ */
323
+ export function isConfiguredExtension(piHome: string, packageName: string): boolean {
324
+ return readPackageDeclarations(piHome).some((source) => npmPackageName(source) === packageName);
325
+ }
326
+
327
+ export type UpdateSourceKind = "extension" | "daemon-dependency";
328
+
329
+ /**
330
+ * Classifies an npm: source for package.update's own two-path mutation:
331
+ * "extension" (pi:-configured, settings.json's packages[] -- `pi update
332
+ * --extension` already handles this correctly, unchanged) vs.
333
+ * "daemon-dependency" (NOT pi:-configured, but resolveDaemonServiceSpec()
334
+ * resolves it directly BY ITS OWN NAME -- pi-core can never see this one;
335
+ * confirmed via this house's own failing-test repro, service/test/
336
+ * install.test.ts, of pi's real "No matching package found"). Needs the
337
+ * alternate npm-level mutation path (Installer.updateDaemonDependency())
338
+ * instead of shelling to `pi update --extension` at all.
339
+ *
340
+ * undefined for a non-npm: source, or an npm: source that is neither
341
+ * (never installed, or installed but not Vehicle-shaped) -- package.update's
342
+ * existing catch-all path (calling `pi update --extension` and surfacing
343
+ * whatever it reports) still applies unchanged to either of those, exactly
344
+ * as it did before this classification existed.
345
+ */
346
+ export function classifyUpdateSource(piHome: string, source: string): UpdateSourceKind | undefined {
347
+ if (!source.startsWith("npm:")) return undefined;
348
+ const packageName = npmPackageName(source);
349
+ if (!packageName) return undefined;
350
+ if (isConfiguredExtension(piHome, packageName)) return "extension";
351
+ return resolveDaemonServiceSpec(piHome, source).ok ? "daemon-dependency" : undefined;
352
+ }
353
+
354
+ /**
355
+ * `packed installed`'s real, whole-fleet listing: every pi:-configured
356
+ * extension (`kind: "extension"`, exactly today's
357
+ * readInstalledPackagesAcrossScopes() output, unchanged) PLUS every
358
+ * top-level daemon-only dependency listUnconfiguredDaemonDependencies() can
359
+ * independently resolve (`kind: "daemon-dependency"`) -- so a real, running
360
+ * Armada vehicle like @danypops/lector shows up under its own name instead
361
+ * of staying invisibly nested inside its pi-lector wrapper's own row. See
362
+ * packed-package-update-restart-service-cant-manage.
363
+ *
364
+ * projectRoot only ever widens the extension half (matching
365
+ * readInstalledPackagesAcrossScopes' own scope) -- daemon-dependency
366
+ * detection is global-only for now, since the reported gap itself (an
367
+ * independently-pinned piHome/npm/package.json dependency) is a global-scope
368
+ * shape; a project-scoped .pi/npm equivalent is real future work, not
369
+ * something this fixes today.
370
+ */
371
+ export function listManagedPackages(piHome: string, projectRoot?: string): InstalledPkg[] {
372
+ const extensions: InstalledPkg[] = readInstalledPackagesAcrossScopes(piHome, projectRoot).map((pkg) => ({
373
+ ...pkg,
374
+ kind: "extension",
375
+ }));
376
+ const daemonDependencies: InstalledPkg[] = listUnconfiguredDaemonDependencies(piHome).map((dep) => ({
377
+ name: dep.name,
378
+ installed: dep.version,
379
+ scope: "global",
380
+ kind: "daemon-dependency",
381
+ }));
382
+ return [...extensions, ...daemonDependencies];
383
+ }
384
+
221
385
  export interface ReconcileAllResult {
222
386
  reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
223
387
  skipped: number;
@@ -227,7 +391,11 @@ export interface ReconcileAllResult {
227
391
  /** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
228
392
  const MAX_RECONCILE_PACKAGES = 500;
229
393
  const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
230
- const PACKED_VEHICLE_NAME = "pi-packed";
394
+ /** Packed's own stable cross-daemon identity -- both its real Armada vehicle name (this file's own
395
+ * self-registration guards below) and, since it's the same daemon, the name it registers under in
396
+ * the shared Vehicle Handle Directory for Vehicle Shell broker mode (see daemon.ts's own
397
+ * daemonOptions() -- vehicleName: PACKED_VEHICLE_NAME). One canonical name, not two. */
398
+ export const PACKED_VEHICLE_NAME = "pi-packed";
231
399
  const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
232
400
 
233
401
  /**
@@ -9,7 +9,7 @@ import {
9
9
  } from "@danypops/vehicle-server/daemon";
10
10
  import { ensureAuthToken } from "@danypops/vehicle-server/paths";
11
11
  import { captureLoadedModules, checkModuleFreshnessAll, ownRuntimeDependencyNames } from "../adoption/module-freshness.ts";
12
- import { type DaemonServiceInstaller, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
12
+ import { type DaemonServiceInstaller, PACKED_VEHICLE_NAME, RealDaemonServiceInstaller, reconcileAllDaemonServices } from "./daemon-service.ts";
13
13
  import { generateIndex, indexPath, indexStatus } from "../index/build-index.ts";
14
14
  import { catalogStatus, syncCatalog } from "../packages/catalog.ts";
15
15
  import { latestVersion, openDb } from "../packages/db.ts";
@@ -134,6 +134,15 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
134
134
  return {
135
135
  daemonLabel: "Packed",
136
136
  handlePath: paths.handle,
137
+ // Vehicle Shell broker mode (enable-vehicle-shell-broker-mode-in-pi-packed): registers this
138
+ // daemon's own identity in the shared, cross-daemon Vehicle Handle Directory, alongside its
139
+ // existing private handlePath above -- unaffected either way. Must match
140
+ // extension/src/vehicle-tools.ts's own registerVehicleTools({ shell: { broker: { ownVehicleName }
141
+ // } }) literal (extension/ and service/ deliberately never cross-import in this package, so both
142
+ // sides carry the same comment-anchored literal instead of a shared TS import -- see that
143
+ // file's own comment).
144
+ vehicleName: PACKED_VEHICLE_NAME,
145
+ tokenPath: paths.token,
137
146
  logger,
138
147
  maintenanceTasks,
139
148
  ...(idleBudgetMs === undefined ? {} : { idleBudgetMs }),
@@ -19,7 +19,7 @@ import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
19
19
  import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
20
20
  import { syncCatalog } from "../packages/catalog.ts";
21
21
  import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
22
- import { defaultPiHome, readInstalledPackages, readInstalledPackagesAcrossScopes } from "../packages/installed.ts";
22
+ import { defaultPiHome, npmPackageName, readInstalledPackagesAcrossScopes, splitNpmSource } from "../packages/installed.ts";
23
23
  import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts";
24
24
  import { buildSearchQuery, clampLimit } from "../packages/package.ts";
25
25
  import {
@@ -46,7 +46,14 @@ import { SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT } from "../shared/constants.ts";
46
46
  import { createLogger } from "../shared/log.ts";
47
47
  import { VERSION } from "../shared/version.ts";
48
48
  import { formatCleanupSummary, runCleanup } from "./cleanup.ts";
49
- import { type DaemonServiceInstaller, type ReconcileAllResult, reconcileAllDaemonServices, RealDaemonServiceInstaller } from "./daemon-service.ts";
49
+ import {
50
+ classifyUpdateSource,
51
+ type DaemonServiceInstaller,
52
+ listManagedPackages,
53
+ type ReconcileAllResult,
54
+ reconcileAllDaemonServices,
55
+ RealDaemonServiceInstaller,
56
+ } from "./daemon-service.ts";
50
57
  import { checkUpdates, loadUpdates } from "./watcher.ts";
51
58
 
52
59
  const log = createLogger("service");
@@ -366,7 +373,7 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
366
373
  }
367
374
 
368
375
  if (path === "/installed" && req.method === "GET") {
369
- return json(readInstalledPackages(deps.piHome ?? defaultPiHome()));
376
+ return json(listManagedPackages(deps.piHome ?? defaultPiHome()));
370
377
  }
371
378
 
372
379
  if (path === "/remove" && req.method === "POST") {
@@ -529,8 +536,35 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
529
536
  // must reconcile against the NEW installed source, never the one that
530
537
  // was just removed.
531
538
  const restartSource = target ?? source;
539
+ // A real Vehicle-shaped daemon dependency with no pi: manifest of its
540
+ // own (e.g. @danypops/lector, pinned independently of its pi-lector
541
+ // wrapper) can never be found by `pi update --extension` -- pi-core's
542
+ // own "configured packages" notion IS settings.json's packages[].
543
+ // Route it through updateDaemonDependency() instead of ever shelling
544
+ // to pi at all, rather than surfacing its raw, unhelpful "No matching
545
+ // package found". See packed-package-update-restart-service-cant-manage.
546
+ const kind = classifyUpdateSource(piHomeForServiceInstall, source);
532
547
  try {
533
- const outcome = await deps.inst.update(source, { approved, target });
548
+ let outcome: UpdateOutcome;
549
+ if (kind === "daemon-dependency") {
550
+ if (!deps.inst.updateDaemonDependency) {
551
+ return json({
552
+ ok: false,
553
+ source,
554
+ output: `${source} is a real Vehicle-shaped daemon dependency with no pi: manifest of its own -- this installer cannot update it directly`,
555
+ reloadRequired: false,
556
+ });
557
+ }
558
+ const packageName = npmPackageName(source);
559
+ if (!packageName) return err(400, "invalid source; want npm:<pkg>[@ver]");
560
+ if (target !== undefined && npmPackageName(target) !== packageName) {
561
+ return err(400, "target must be the same package as source for a daemon-dependency update");
562
+ }
563
+ const [, targetVersion] = target ? splitNpmSource(target.slice(4)) : [undefined, undefined];
564
+ outcome = await deps.inst.updateDaemonDependency(packageName, { approved, version: targetVersion || undefined });
565
+ } else {
566
+ outcome = await deps.inst.update(source, { approved, target });
567
+ }
534
568
  if (!restartSource.startsWith("npm:") || outcome.alreadyUpToDate) return json({ ok: true, source, ...outcome });
535
569
  const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, restartSource);
536
570
  if (!service.ok) {
@@ -202,6 +202,46 @@ export class ExecInstaller implements Installer {
202
202
  };
203
203
  }
204
204
 
205
+ /**
206
+ * The alternate mutation path for a source classifyUpdateSource() flags as
207
+ * "daemon-dependency" -- genuinely installed and npm-resolvable, but NOT
208
+ * itself a pi:-configured extension, so `pi update --extension` can never
209
+ * find it (confirmed via a real failing-test repro of pi's own "No
210
+ * matching package found"). Runs a real, scoped `npm install
211
+ * <name>[@version]` at piHome/npm directly -- npm's own single-invocation
212
+ * dependency + lockfile update, no separate reresolveDependencyTree() call
213
+ * needed afterward. No `pi:` manifest exists for this package to validate
214
+ * against, so -- unlike install()/update() -- there is no validate() step
215
+ * here at all.
216
+ */
217
+ async updateDaemonDependency(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome> {
218
+ const t0 = performance.now();
219
+ const previousVersion = readResolvedVersion(this.piHome, `npm:${name}`);
220
+ const spec = `${name}@${options?.version ?? "latest"}`;
221
+ const cwd = join(this.piHome, "npm");
222
+ // See run()'s own comment: env is explicit for the identical reason.
223
+ const proc = Bun.spawn([this.npmBin, "install", spec], { cwd, stdout: "pipe", stderr: "pipe", env: process.env });
224
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
225
+ const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
226
+ const code = await proc.exited;
227
+ if (code !== 0) throw new Error(out || `exit ${code}`);
228
+ const currentVersion = readResolvedVersion(this.piHome, `npm:${name}`);
229
+ this.logger.debug("updateDaemonDependency timing", { name, totalMs: round1(performance.now() - t0) });
230
+ // Same honesty discipline as update() above: only trust a "nothing
231
+ // changed" conclusion when a real version was actually read both before
232
+ // and after.
233
+ const knowsBoth = previousVersion !== undefined && currentVersion !== undefined;
234
+ const changed = !knowsBoth || previousVersion !== currentVersion;
235
+ return {
236
+ output: out,
237
+ reloadRequired: changed,
238
+ alreadyUpToDate: !changed,
239
+ pinned: options?.version !== undefined,
240
+ previousVersion,
241
+ currentVersion,
242
+ };
243
+ }
244
+
205
245
  /**
206
246
  * The supervised "move a pin" workflow `pi update --extension` can never
207
247
  * perform by itself (it intentionally skips an exact pin) -- see the
@@ -181,6 +181,21 @@ export interface Installer {
181
181
  * why a plain `pi update` can never move an exact pin by itself.
182
182
  */
183
183
  update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
184
+ /**
185
+ * Updates a package that is genuinely installed and npm-resolvable but is
186
+ * NOT itself a pi:-configured extension -- `pi update --extension` can
187
+ * never find it (pi-core's own "configured packages" notion IS exactly
188
+ * settings.json's packages[]), confirmed via a real failing-test repro
189
+ * (service/test/install.test.ts) of pi's own "No matching package found".
190
+ * `daemon-service.ts`'s `classifyUpdateSource()` decides when this path
191
+ * applies instead of `update()` -- a real Vehicle-shaped daemon dependency
192
+ * with no `pi:` manifest of its own (e.g. @danypops/lector, pinned
193
+ * independently of its pi-lector wrapper). Optional because only a real
194
+ * npm-backed Installer (ExecInstaller) can perform it; a caller checks for
195
+ * its presence before using this path, exactly like validate()/
196
+ * installOnly()/reresolveDependencyTree() below.
197
+ */
198
+ updateDaemonDependency?(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome>;
184
199
  /**
185
200
  * Optional batch-oriented split of install(), for a caller installing several packages
186
201
  * together (see SetupManager.apply()) -- a single ad hoc install() still does all three
@@ -212,6 +227,17 @@ export interface InstalledPkg {
212
227
  pinned?: string;
213
228
  installed?: string;
214
229
  scope?: "global" | "project";
230
+ /**
231
+ * "extension" (a real pi:-manifested package, settings.json's own
232
+ * packages[]) vs. "daemon-dependency" (a Vehicle-shaped daemon Packed
233
+ * independently detects at piHome/npm's own top level -- e.g.
234
+ * @danypops/lector -- that is NOT itself pi:-configured). Undefined from
235
+ * every pre-existing reader (readInstalledPackages/
236
+ * readInstalledPackagesAcrossScopes); only daemon-service.ts's
237
+ * listManagedPackages() sets this on every row it returns. See
238
+ * packed-package-update-restart-service-cant-manage.
239
+ */
240
+ kind?: "extension" | "daemon-dependency";
215
241
  }
216
242
 
217
243
  export interface UpdateEntry {
@@ -387,6 +387,57 @@ describe("CLI", () => {
387
387
  expect(out).toContain('"pinned":"0.8.2"');
388
388
  });
389
389
 
390
+ it("installed --json also surfaces a real Vehicle-shaped daemon dependency that has no pi: manifest of its own, standalone without a daemon -- packed-package-update-restart-service-cant-manage", async () => {
391
+ const d = deps();
392
+ writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
393
+ const piLectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "pi-lector");
394
+ mkdirSync(piLectorDir, { recursive: true });
395
+ writeFileSync(
396
+ join(piLectorDir, "package.json"),
397
+ JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
398
+ );
399
+ const lectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "lector");
400
+ mkdirSync(lectorDir, { recursive: true });
401
+ writeFileSync(
402
+ join(lectorDir, "package.json"),
403
+ JSON.stringify({
404
+ name: "@danypops/lector",
405
+ version: "0.18.9",
406
+ bin: { lector: "src/cli.ts" },
407
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
408
+ }),
409
+ );
410
+ const { code, out } = await cliRun(["installed", "--json"], d);
411
+ expect(code).toBe(0);
412
+ expect(out).toContain('"name":"@danypops/lector"');
413
+ expect(out).toContain('"kind":"daemon-dependency"');
414
+ });
415
+
416
+ it("installed (human-readable) tags a daemon-dependency row distinctly from a plain pi: extension row", async () => {
417
+ const d = deps();
418
+ writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
419
+ const piLectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "pi-lector");
420
+ mkdirSync(piLectorDir, { recursive: true });
421
+ writeFileSync(
422
+ join(piLectorDir, "package.json"),
423
+ JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
424
+ );
425
+ const lectorDir = join(d.piHome, "npm", "node_modules", "@danypops", "lector");
426
+ mkdirSync(lectorDir, { recursive: true });
427
+ writeFileSync(
428
+ join(lectorDir, "package.json"),
429
+ JSON.stringify({
430
+ name: "@danypops/lector",
431
+ version: "0.18.9",
432
+ bin: { lector: "src/cli.ts" },
433
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
434
+ }),
435
+ );
436
+ const { code, out } = await cliRun(["installed"], d);
437
+ expect(code).toBe(0);
438
+ expect(out).toBe(" @danypops/pi-lector@0.12.7\n @danypops/lector@0.18.9 [daemon-dependency]\n");
439
+ });
440
+
390
441
  it("updates computes drift from the local mirror", async () => {
391
442
  const d = deps();
392
443
  writeFileSync(join(d.piHome, "settings.json"), JSON.stringify({ packages: ["npm:pi-extension-manager@0.8.2"] }));
@@ -128,6 +128,17 @@ describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle dr
128
128
  expect(options.idleBudgetMs).toBe(17_000);
129
129
  });
130
130
 
131
+ it("registers pi-packed's own stable identity in the shared Vehicle Handle Directory (Vehicle Shell broker mode) -- enable-vehicle-shell-broker-mode-in-pi-packed", () => {
132
+ const paths = fakePaths();
133
+ const options = daemonOptions({ paths, reg: registry, inst: installer, piHome: fakePiHome([]), maintenanceTasks: [] });
134
+ // Must match extension/src/vehicle-tools.ts's own registerVehicleTools({ shell: { broker:
135
+ // { ownVehicleName } } }) literal -- the two sides can't share a real TS import (extension/
136
+ // and service/ deliberately never cross-import, matching this package's own established
137
+ // convention), so both carry the same comment-anchored literal instead.
138
+ expect(options.vehicleName).toBe("pi-packed");
139
+ expect(options.tokenPath).toBe(paths.token);
140
+ });
141
+
131
142
  it("includes a vehicle-reconcile task, defaulting to RECONCILE_INTERVAL_DEFAULT_MS", () => {
132
143
  const piHome = fakePiHome([]);
133
144
  const paths = fakePaths();
@@ -4,7 +4,14 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { VehicleRegistrar } from "@danypops/armada";
6
6
  import { createArmadaTestHarness } from "@danypops/armada/testing";
7
- import { detectVehicleDaemonService, RealDaemonServiceInstaller, resolveDaemonServiceSpec } from "../src/daemon/daemon-service.ts";
7
+ import {
8
+ classifyUpdateSource,
9
+ detectVehicleDaemonService,
10
+ listManagedPackages,
11
+ listUnconfiguredDaemonDependencies,
12
+ RealDaemonServiceInstaller,
13
+ resolveDaemonServiceSpec,
14
+ } from "../src/daemon/daemon-service.ts";
8
15
 
9
16
  const roots: string[] = [];
10
17
  afterEach(() => {
@@ -118,6 +125,22 @@ describe("resolveDaemonServiceSpec", () => {
118
125
  });
119
126
  });
120
127
 
128
+ it("defaults a detected (manifest-less) daemon's handle filename to 'daemon.json', not 'handle.json' -- confirmed live: jittor, pipes, and web-spider-daemon's own real HANDLE_FILENAME constants are all 'daemon.json'; only enigma uses 'handle.json'", () => {
129
+ const piHome = fakePiHome();
130
+ const dir = join(piHome, "npm", "node_modules", "@danypops/papyrus");
131
+ writeRawPackage(dir, {
132
+ name: "@danypops/papyrus",
133
+ version: "1.0.0",
134
+ bin: { papyrus: "src/cli.ts" },
135
+ dependencies: { "@danypops/vehicle-server": "^0.1.0" },
136
+ });
137
+
138
+ const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/papyrus");
139
+ expect(result.ok).toBe(true);
140
+ if (!result.ok) throw new Error("expected ok");
141
+ expect(result.spec.handlePath.endsWith("daemon.json")).toBe(true);
142
+ });
143
+
121
144
  it("falls back to detection one level into a real dependency -- a Pi extension with no daemon of its own", () => {
122
145
  const piHome = fakePiHome();
123
146
  const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
@@ -208,6 +231,131 @@ describe("resolveDaemonServiceSpec", () => {
208
231
  });
209
232
  });
210
233
 
234
+ describe("listUnconfiguredDaemonDependencies -- the packed-package-update-restart-service-cant-manage visibility gap", () => {
235
+ it("reports a real Vehicle-shaped daemon installed at piHome/npm's own top level that is NOT a pi:-configured extension", () => {
236
+ const piHome = fakePiHome();
237
+ // @danypops/pi-lector IS configured (a real Pi extension, settings.json packages[]).
238
+ // @danypops/lector is a plain top-level dependency -- installed, own bin, real
239
+ // vehicle-server dependency -- but never itself `pi install`ed. Exactly the live
240
+ // shape confirmed in ~/.pi/agent/npm/package.json.
241
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
242
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
243
+ name: "@danypops/pi-lector",
244
+ version: "0.12.7",
245
+ dependencies: { "@danypops/lector": "^0.18.0" },
246
+ });
247
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
248
+ name: "@danypops/lector",
249
+ version: "0.18.9",
250
+ bin: { lector: "src/cli.ts" },
251
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
252
+ });
253
+
254
+ const result = listUnconfiguredDaemonDependencies(piHome);
255
+
256
+ expect(result).toEqual([{ name: "@danypops/lector", version: "0.18.9", vehicleName: "lector" }]);
257
+ });
258
+
259
+ it("never reports a package that is already pi:-configured, even though it independently resolves as a daemon too", () => {
260
+ const piHome = fakePiHome();
261
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
262
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
263
+ name: "@danypops/papyrus",
264
+ version: "1.0.0",
265
+ bin: { papyrus: "src/cli.ts" },
266
+ dependencies: { "@danypops/vehicle-server": "^0.1.0" },
267
+ });
268
+
269
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
270
+ });
271
+
272
+ it("never reports a package with no real Vehicle-shaped daemon of its own -- an ordinary library dependency", () => {
273
+ const piHome = fakePiHome();
274
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: [] }));
275
+ writeRawPackage(join(piHome, "npm", "node_modules", "picomatch"), { name: "picomatch", version: "4.0.5" });
276
+
277
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
278
+ });
279
+
280
+ it("returns [] rather than throwing when piHome/npm/node_modules doesn't exist yet", () => {
281
+ const piHome = fakePiHome();
282
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe("listManagedPackages -- packed installed's real whole-fleet listing (packed-package-update-restart-service-cant-manage)", () => {
287
+ it("tags a pi:-configured extension row 'extension' and a detected top-level daemon dependency 'daemon-dependency', side by side", () => {
288
+ const piHome = fakePiHome();
289
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
290
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
291
+ name: "@danypops/pi-lector",
292
+ version: "0.12.7",
293
+ dependencies: { "@danypops/lector": "^0.18.0" },
294
+ });
295
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
296
+ name: "@danypops/lector",
297
+ version: "0.18.9",
298
+ bin: { lector: "src/cli.ts" },
299
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
300
+ });
301
+
302
+ const result = listManagedPackages(piHome);
303
+
304
+ expect(result).toEqual([
305
+ { name: "@danypops/pi-lector", pinned: "0.12.7", installed: undefined, scope: "global", kind: "extension" },
306
+ { name: "@danypops/lector", installed: "0.18.9", scope: "global", kind: "daemon-dependency" },
307
+ ]);
308
+ });
309
+
310
+ it("is exactly today's extension listing, with kind added, when there are no daemon-only dependencies at all", () => {
311
+ const piHome = fakePiHome();
312
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:plain@1.0.0"] }));
313
+ writeRawPackage(join(piHome, "npm", "node_modules", "plain"), { name: "plain", version: "1.0.0" });
314
+
315
+ expect(listManagedPackages(piHome)).toEqual([{ name: "plain", pinned: "1.0.0", installed: undefined, scope: "global", kind: "extension" }]);
316
+ });
317
+ });
318
+
319
+ describe("classifyUpdateSource -- package.update's own two-path split (packed-package-update-restart-service-cant-manage)", () => {
320
+ it("classifies a pi:-configured extension as 'extension', even when it also happens to resolve as a Vehicle daemon itself", () => {
321
+ const piHome = fakePiHome();
322
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
323
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
324
+ name: "@danypops/papyrus",
325
+ version: "1.0.0",
326
+ bin: { papyrus: "src/cli.ts" },
327
+ dependencies: { "@danypops/vehicle-server": "^0.1.0" },
328
+ });
329
+
330
+ expect(classifyUpdateSource(piHome, "npm:@danypops/papyrus")).toBe("extension");
331
+ });
332
+
333
+ it("classifies a real Vehicle-shaped daemon with no pi:-configured entry of its own as 'daemon-dependency'", () => {
334
+ const piHome = fakePiHome();
335
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
336
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
337
+ name: "@danypops/lector",
338
+ version: "0.18.9",
339
+ bin: { lector: "src/cli.ts" },
340
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
341
+ });
342
+
343
+ expect(classifyUpdateSource(piHome, "npm:@danypops/lector")).toBe("daemon-dependency");
344
+ });
345
+
346
+ it("classifies neither for an npm: source that resolves as nothing at all", () => {
347
+ const piHome = fakePiHome();
348
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: [] }));
349
+
350
+ expect(classifyUpdateSource(piHome, "npm:never-installed")).toBeUndefined();
351
+ });
352
+
353
+ it("classifies neither for a non-npm source -- git:/https: replace/update behavior is untouched", () => {
354
+ const piHome = fakePiHome();
355
+ expect(classifyUpdateSource(piHome, "git:github.com/u/r@v1")).toBeUndefined();
356
+ });
357
+ });
358
+
211
359
  describe("RealDaemonServiceInstaller -- Armada registration through the in-process registrar", () => {
212
360
  it("install() resolves the package and drives Armada's real registrar/reconciler through the isolated harness", async () => {
213
361
  const piHome = fakePiHome();
@@ -298,6 +298,174 @@ process.exit(1);
298
298
  return script;
299
299
  }
300
300
 
301
+ /**
302
+ * A fake `pi` binary reproducing real `pi update --extension <source>`'s own
303
+ * "configured packages" check (settings.json's `packages[]` -- the exact
304
+ * same list installed.ts's readPackageDeclarations()/readInstalledPackages()
305
+ * reads) -- distinct from writeFakePi above, which never looks at
306
+ * settings.json at all and so can't reproduce this failure mode. Real `pi`
307
+ * fails this way for ANY source not itself present in `packages[]`,
308
+ * regardless of whether it is genuinely resolvable on disk in node_modules --
309
+ * exactly the shape of a plain library dependency (e.g. @danypops/lector,
310
+ * added directly to piHome/npm/package.json so it can run as its own
311
+ * standalone Armada vehicle) that was never itself `pi install`ed as a Pi
312
+ * extension. See packed-package-update-restart-service-cant-manage.
313
+ */
314
+ function writeFakePiConfiguredOnly(dir: string, piHome: string): string {
315
+ const script = join(dir, "fake-pi-configured-only");
316
+ const body = `
317
+ const piHome = ${JSON.stringify(piHome)};
318
+ const fs = require("node:fs");
319
+ const path = require("node:path");
320
+
321
+ function bareName(source) {
322
+ const spec = source.startsWith("npm:") ? source.slice(4) : source;
323
+ const at = spec.lastIndexOf("@");
324
+ return at > 0 ? spec.slice(0, at) : spec;
325
+ }
326
+ function entrySource(entry) {
327
+ return typeof entry === "string" ? entry : entry && typeof entry === "object" ? entry.source : undefined;
328
+ }
329
+ function readSettings() {
330
+ try {
331
+ return JSON.parse(fs.readFileSync(path.join(piHome, "settings.json"), "utf8"));
332
+ } catch {
333
+ return { packages: [] };
334
+ }
335
+ }
336
+
337
+ const [cmd, flag, source] = process.argv.slice(2);
338
+ if (cmd === "update" && flag === "--extension") {
339
+ const settings = readSettings();
340
+ const configured = (settings.packages ?? []).some((e) => bareName(entrySource(e) ?? "") === bareName(source));
341
+ if (!configured) {
342
+ process.stderr.write("Error: No matching package found for " + source + "\\n");
343
+ process.exit(1);
344
+ }
345
+ process.stdout.write("Updated " + source + "\\n");
346
+ process.exit(0);
347
+ }
348
+ process.stderr.write("unsupported invocation: " + process.argv.slice(2).join(" ") + "\\n");
349
+ process.exit(1);
350
+ `;
351
+ writeFileSync(script, `#!/usr/bin/env bun\n${body}`);
352
+ chmodSync(script, 0o755);
353
+ return script;
354
+ }
355
+
356
+ describe("ExecInstaller.update() — a real Vehicle daemon dependency with no pi: extension manifest of its own (packed-package-update-restart-service-cant-manage)", () => {
357
+ it("fails with pi's own 'No matching package found' for a package that is genuinely installed on disk but was never configured as a Pi extension -- e.g. a standalone Armada vehicle dependency like @danypops/lector", async () => {
358
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
359
+ const piHome = writePiHome({ "@danypops/lector": "1.0.0", "@danypops/pi-lector": "0.12.7" });
360
+ // Only pi-lector (the real Pi extension, with its own `pi:` manifest) is a
361
+ // configured `packages[]` entry. @danypops/lector is a plain library
362
+ // dependency of piHome/npm/package.json -- installed and resolvable in
363
+ // node_modules, registered as its own independent Armada vehicle, but
364
+ // never itself `pi install`ed.
365
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
366
+ const bin = writeFakePiConfiguredOnly(scriptDir, piHome);
367
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
368
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
369
+
370
+ await expect(installer.update("npm:@danypops/lector")).rejects.toThrow(/No matching package found for npm:@danypops\/lector/);
371
+
372
+ // Its sibling, a real configured Pi extension, updates fine through the
373
+ // exact same code path -- matching the live-confirmed asymmetry this task
374
+ // recorded (`packed update npm:@danypops/pi-lector` reports
375
+ // alreadyUpToDate, `packed update npm:@danypops/lector` fails outright).
376
+ const sibling = await installer.update("npm:@danypops/pi-lector");
377
+ expect(sibling.alreadyUpToDate).toBe(true);
378
+ });
379
+ });
380
+
381
+ /**
382
+ * A fake `npm` binary genuinely implementing `install <name>[@version]` against
383
+ * a real piHome -- distinct from writeFakeNpm above (a no-args
384
+ * reresolveDependencyTree() stand-in) and writeFakeReplacePi (implements `pi
385
+ * install`/`pi remove`, not raw npm). No version given simulates "whatever
386
+ * the registry's own latest happens to be right now" via `latestVersion`.
387
+ */
388
+ function writeFakeNpmInstall(dir: string, piHome: string, latestVersion = "9.9.9"): string {
389
+ const script = join(dir, "fake-npm-install");
390
+ const body = `
391
+ const piHome = ${JSON.stringify(piHome)};
392
+ const latestVersion = ${JSON.stringify(latestVersion)};
393
+ const fs = require("node:fs");
394
+ const path = require("node:path");
395
+
396
+ const [cmd, spec] = process.argv.slice(2);
397
+ if (cmd !== "install" || !spec) {
398
+ process.stderr.write("unsupported invocation: " + process.argv.slice(2).join(" ") + "\\n");
399
+ process.exit(1);
400
+ }
401
+ const at = spec.lastIndexOf("@");
402
+ const name = at > 0 ? spec.slice(0, at) : spec;
403
+ const version = at > 0 ? spec.slice(at + 1) : latestVersion;
404
+ const resolved = version === "latest" ? latestVersion : version;
405
+ const pkgDir = path.join(piHome, "npm", "node_modules", name);
406
+ fs.mkdirSync(pkgDir, { recursive: true });
407
+ fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify({ name, version: resolved }));
408
+ process.stdout.write("added 1 package, changed 1 package\\n");
409
+ process.exit(0);
410
+ `;
411
+ writeFileSync(script, `#!/usr/bin/env bun\n${body}`);
412
+ chmodSync(script, 0o755);
413
+ return script;
414
+ }
415
+
416
+ describe("ExecInstaller.updateDaemonDependency() -- the alternate npm-level mutation path for a package with no pi: manifest of its own (packed-package-update-restart-service-cant-manage)", () => {
417
+ it("bumps to the registry's real latest when no version is given, reporting an honest reloadRequired", async () => {
418
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
419
+ const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
420
+ const npmBin = writeFakeNpmInstall(scriptDir, piHome, "0.19.0");
421
+ const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
422
+
423
+ const outcome = await installer.updateDaemonDependency("@danypops/lector");
424
+
425
+ expect(outcome.previousVersion).toBe("0.18.9");
426
+ expect(outcome.currentVersion).toBe("0.19.0");
427
+ expect(outcome.reloadRequired).toBe(true);
428
+ expect(outcome.alreadyUpToDate).toBe(false);
429
+ expect(outcome.pinned).toBe(false);
430
+ });
431
+
432
+ it("pins to an explicit version when one is given, and reports pinned:true", async () => {
433
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
434
+ const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
435
+ const npmBin = writeFakeNpmInstall(scriptDir, piHome);
436
+ const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
437
+
438
+ const outcome = await installer.updateDaemonDependency("@danypops/lector", { version: "0.20.0" });
439
+
440
+ expect(outcome.currentVersion).toBe("0.20.0");
441
+ expect(outcome.pinned).toBe(true);
442
+ expect(outcome.reloadRequired).toBe(true);
443
+ });
444
+
445
+ it("reports an honest alreadyUpToDate when the version genuinely doesn't change", async () => {
446
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
447
+ const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
448
+ const npmBin = writeFakeNpmInstall(scriptDir, piHome, "0.18.9");
449
+ const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, npmBin);
450
+
451
+ const outcome = await installer.updateDaemonDependency("@danypops/lector");
452
+
453
+ expect(outcome.alreadyUpToDate).toBe(true);
454
+ expect(outcome.reloadRequired).toBe(false);
455
+ });
456
+
457
+ it("surfaces a real npm failure instead of silently reporting success", async () => {
458
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-daemon-dep-")));
459
+ const piHome = writePiHome({ "@danypops/lector": "0.18.9" });
460
+ const failingNpm = join(scriptDir, "fake-npm-fail");
461
+ writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
462
+ chmodSync(failingNpm, 0o755);
463
+ const installer = new ExecInstaller("unused-pi-bin", piHome, undefined, failingNpm);
464
+
465
+ await expect(installer.updateDaemonDependency("@danypops/lector")).rejects.toThrow(/ERESOLVE/);
466
+ });
467
+ });
468
+
301
469
  describe("ExecInstaller.update({ target }) — the supervised replace() workflow for moving an exact pin", () => {
302
470
  function setup(options: { failInstallFor?: string } = {}) {
303
471
  const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-replace-bin-")));
@@ -58,6 +58,14 @@ class FakeInstaller implements Installer {
58
58
  this.gotTarget = options?.target;
59
59
  return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
60
60
  }
61
+ updateDaemonDependencyGotName = "";
62
+ updateDaemonDependencyGotVersion: string | undefined;
63
+ updateDaemonDependencyOutcome: Partial<UpdateOutcome> = {};
64
+ async updateDaemonDependency(name: string, options?: { approved?: boolean; version?: string }): Promise<UpdateOutcome> {
65
+ this.updateDaemonDependencyGotName = name;
66
+ this.updateDaemonDependencyGotVersion = options?.version;
67
+ return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateDaemonDependencyOutcome };
68
+ }
61
69
  }
62
70
 
63
71
  class FakeDaemonServiceInstaller implements DaemonServiceInstaller {
@@ -606,6 +614,72 @@ describe("service app", () => {
606
614
  });
607
615
  });
608
616
 
617
+ it("POST /update routes a real Vehicle-shaped daemon dependency with no pi: manifest of its own through updateDaemonDependency, never pi update --extension -- packed-package-update-restart-service-cant-manage", async () => {
618
+ const inst = new FakeInstaller();
619
+ const svc = new FakeDaemonServiceInstaller();
620
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-daemon-dep-")));
621
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
622
+ const lectorDir = join(piHome, "npm", "node_modules", "@danypops", "lector");
623
+ mkdirSync(lectorDir, { recursive: true });
624
+ writeFileSync(
625
+ join(lectorDir, "package.json"),
626
+ JSON.stringify({
627
+ name: "@danypops/lector",
628
+ version: "0.18.9",
629
+ bin: { lector: "src/cli.ts" },
630
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
631
+ }),
632
+ );
633
+ const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
634
+
635
+ const response = await app.fetch(
636
+ new Request("http://x/update", {
637
+ method: "POST",
638
+ headers: { ...auth, "content-type": "application/json" },
639
+ body: JSON.stringify({ source: "npm:@danypops/lector", approved: true }),
640
+ }),
641
+ );
642
+
643
+ expect(response.status).toBe(200);
644
+ expect(await response.json()).toMatchObject({ ok: true, source: "npm:@danypops/lector", serviceReconciled: true });
645
+ expect(inst.updateDaemonDependencyGotName).toBe("@danypops/lector");
646
+ // Never fell through to pi update --extension -- the exact call that fails
647
+ // live with pi-core's own "No matching package found".
648
+ expect(inst.updated).toBe("");
649
+ expect(svc.restartGotSource).toBe("npm:@danypops/lector");
650
+ });
651
+
652
+ it("POST /update still routes a pi:-configured extension through the ordinary pi update --extension path, unchanged, even when it also happens to resolve as a Vehicle daemon itself", async () => {
653
+ const inst = new FakeInstaller();
654
+ const svc = new FakeDaemonServiceInstaller();
655
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-update-extension-")));
656
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
657
+ const papyrusDir = join(piHome, "npm", "node_modules", "@danypops", "papyrus");
658
+ mkdirSync(papyrusDir, { recursive: true });
659
+ writeFileSync(
660
+ join(papyrusDir, "package.json"),
661
+ JSON.stringify({
662
+ name: "@danypops/papyrus",
663
+ version: "1.0.0",
664
+ bin: { papyrus: "src/cli.ts" },
665
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
666
+ }),
667
+ );
668
+ const app = createApp(deps({ inst, daemonServiceInstaller: svc, piHome }));
669
+
670
+ const response = await app.fetch(
671
+ new Request("http://x/update", {
672
+ method: "POST",
673
+ headers: { ...auth, "content-type": "application/json" },
674
+ body: JSON.stringify({ source: "npm:@danypops/papyrus", approved: true }),
675
+ }),
676
+ );
677
+
678
+ expect(response.status).toBe(200);
679
+ expect(inst.updated).toBe("npm:@danypops/papyrus");
680
+ expect(inst.updateDaemonDependencyGotName).toBe("");
681
+ });
682
+
609
683
  it("GET /updates serves the watcher snapshot", async () => {
610
684
  const d = deps();
611
685
  await saveUpdates(d.stateDir, {
@@ -632,6 +706,37 @@ describe("service app", () => {
632
706
  expect(body.map((p: { name: string }) => p.name)).toEqual(["pi-extension-manager", "obj"]);
633
707
  });
634
708
 
709
+ it("GET /installed also surfaces a real Vehicle-shaped daemon dependency that has no pi: manifest of its own -- packed-package-update-restart-service-cant-manage", async () => {
710
+ const piHome = track(mkdtempSync(join(tmpdir(), "packed-pi-")));
711
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
712
+ const piLectorDir = join(piHome, "npm", "node_modules", "@danypops", "pi-lector");
713
+ mkdirSync(piLectorDir, { recursive: true });
714
+ writeFileSync(
715
+ join(piLectorDir, "package.json"),
716
+ JSON.stringify({ name: "@danypops/pi-lector", version: "0.12.7", dependencies: { "@danypops/lector": "^0.18.0" } }),
717
+ );
718
+ const lectorDir = join(piHome, "npm", "node_modules", "@danypops", "lector");
719
+ mkdirSync(lectorDir, { recursive: true });
720
+ writeFileSync(
721
+ join(lectorDir, "package.json"),
722
+ JSON.stringify({
723
+ name: "@danypops/lector",
724
+ version: "0.18.9",
725
+ bin: { lector: "src/cli.ts" },
726
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
727
+ }),
728
+ );
729
+ const d = deps({ piHome });
730
+ const app = createApp(d);
731
+ const res = await app.fetch(new Request("http://x/installed", { headers: auth }));
732
+ expect(res.status).toBe(200);
733
+ const body = (await res.json()) as any;
734
+ expect(body).toEqual([
735
+ { name: "@danypops/pi-lector", pinned: "0.12.7", installed: undefined, scope: "global", kind: "extension" },
736
+ { name: "@danypops/lector", installed: "0.18.9", scope: "global", kind: "daemon-dependency" },
737
+ ]);
738
+ });
739
+
635
740
  it("POST /remove validates bare names and reports in-band", async () => {
636
741
  const inst = new FakeInstaller();
637
742
  const app = createApp(deps({ inst }));