@danypops/pi-packed 0.22.2 → 0.22.4

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.4",
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. */
@@ -145,6 +146,28 @@ export function detectVehicleDaemonService(
145
146
  for (const depDir of candidateDirs) {
146
147
  const dep = readPackageJson(depDir);
147
148
  if (!dep) continue;
149
+ // An explicit manifest on the dependency itself wins over convention detection here too --
150
+ // mirrors resolveDaemonServiceSpec's own explicit-manifest-first behavior for a directly
151
+ // installed package. Without this, a dependency's own correct, explicit
152
+ // handleFilename/name/etc. was silently discarded in favor of a guess whenever the daemon
153
+ // was discovered one level down instead of installed directly by name -- exactly the
154
+ // common case (a Pi extension like pi-papyrus has no daemon of its own). Confirmed live:
155
+ // papyrus's real handle file is "vehicle-handle.json", not the "daemon.json" convention
156
+ // guess, and papyrus is only ever discovered this way, never installed directly by name.
157
+ const depManifest = dep.packed?.daemonService;
158
+ if (depManifest && typeof depManifest.binPath === "string" && depManifest.binPath.length > 0 && dep.version) {
159
+ return {
160
+ binPath: join(depDir, depManifest.binPath),
161
+ args: depManifest.args,
162
+ name: depManifest.name ?? unscopedName(dep.name ?? depName),
163
+ displayName: depManifest.displayName,
164
+ handleFilename: depManifest.handleFilename,
165
+ workingDirectory: depManifest.workingDirectory,
166
+ restartOnFailure: depManifest.restartOnFailure,
167
+ restartSec: depManifest.restartSec,
168
+ version: dep.version,
169
+ };
170
+ }
148
171
  const depBin = firstBinPath(dep);
149
172
  if (depBin && dependsOnVehicle(dep) && dep.version) {
150
173
  return { binPath: join(depDir, depBin), args: ["serve"], name: unscopedName(dep.name ?? depName), version: dep.version };
@@ -154,12 +177,25 @@ export function detectVehicleDaemonService(
154
177
  return undefined;
155
178
  }
156
179
 
180
+ /**
181
+ * "daemon.json" -- not "handle.json" -- when a detected daemon carries no explicit
182
+ * packed.daemonService.handleFilename override. Confirmed live across every real Vehicle daemon
183
+ * checked in this ecosystem: jittor, pipes, and web-spider-daemon's own HANDLE_FILENAME constants
184
+ * are all "daemon.json"; only enigma uses "handle.json". A real, reported bug traced back to this
185
+ * default: pipes' Armada vehicle registration kept reverting to the wrong handlePath every time
186
+ * this auto-detect path re-ran, because this guess didn't match pipes' own real convention. Still
187
+ * a guess, not a read of the target package's own actual value -- a package with a real,
188
+ * non-default handleFilename must declare it explicitly via packed.daemonService for a fully
189
+ * correct resolution; this only narrows how often the guess is wrong.
190
+ */
191
+ const DEFAULT_DETECTED_HANDLE_FILENAME = "daemon.json";
192
+
157
193
  function buildSpec(entry: ResolvedDaemonEntrypoint): ServiceSpec {
158
194
  const paths = resolveDaemonPaths({
159
195
  stateDirectoryName: entry.name,
160
196
  databaseFilename: "unused.db",
161
197
  tokenFilename: "unused-token",
162
- handleFilename: entry.handleFilename ?? "handle.json",
198
+ handleFilename: entry.handleFilename ?? DEFAULT_DETECTED_HANDLE_FILENAME,
163
199
  systemdUnitName: `${entry.name}.service`,
164
200
  });
165
201
  return {
@@ -218,6 +254,156 @@ export function resolveDaemonServiceSpec(piHome: string, source: string): Resolv
218
254
  };
219
255
  }
220
256
 
257
+ export interface DaemonDependencyPkg {
258
+ name: string;
259
+ version: string;
260
+ /** The Armada vehicle name resolveDaemonServiceSpec built for this package -- may differ
261
+ * from `name`'s own bare unscoped form when a packed.daemonService manifest overrides it. */
262
+ vehicleName: string;
263
+ }
264
+
265
+ /** Bare npm package names directly under a node_modules directory -- one level for an
266
+ * unscoped name, two for a @scope/name pair. Never throws; a missing directory (no
267
+ * npm project yet) is just an empty result. */
268
+ function listTopLevelPackageNames(nodeModulesDir: string): string[] {
269
+ let entries: string[];
270
+ try {
271
+ entries = readdirSync(nodeModulesDir);
272
+ } catch {
273
+ return [];
274
+ }
275
+ const names: string[] = [];
276
+ for (const entry of entries) {
277
+ if (!entry.startsWith("@")) {
278
+ names.push(entry);
279
+ continue;
280
+ }
281
+ let scoped: string[];
282
+ try {
283
+ scoped = readdirSync(join(nodeModulesDir, entry));
284
+ } catch {
285
+ continue;
286
+ }
287
+ for (const name of scoped) names.push(`${entry}/${name}`);
288
+ }
289
+ return names;
290
+ }
291
+
292
+ /** Matches reconcileAllDaemonServices' own bound -- a full node_modules sweep never
293
+ * processes an unbounded package list either. */
294
+ const MAX_DAEMON_DEPENDENCY_SCAN = 500;
295
+
296
+ /**
297
+ * Every top-level npm package physically installed under piHome/npm/node_modules
298
+ * that resolveDaemonServiceSpec() itself already recognizes as a real Vehicle-
299
+ * shaped daemon (an explicit packed.daemonService manifest, or
300
+ * detectVehicleDaemonService()'s own-bin-plus-real-vehicle-dependency
301
+ * convention), but that is NOT itself a pi:-configured extension
302
+ * (readPackageDeclarations() has no entry for it) -- e.g. @danypops/lector,
303
+ * added directly to piHome/npm/package.json as an independent pin so it can
304
+ * run ahead of whatever version its own pi-lector wrapper's dependency tree
305
+ * would otherwise resolve. These are real, running Armada vehicles that a
306
+ * pi:-manifest-only "installed packages" notion makes permanently invisible
307
+ * to `packed installed`/`packed update` -- see
308
+ * packed-package-update-restart-service-cant-manage.
309
+ *
310
+ * Deliberately does NOT walk one level into each configured extension's own
311
+ * dependencies the way resolveDaemonServiceSpec's own detection does for
312
+ * install-service/restart-service/doctor (service-doctor.ts's
313
+ * checkServiceUnitPaths already reaches that same dependency through its
314
+ * wrapper extension's own row) -- duplicating that walk here would
315
+ * double-report the same Vehicle under two different package names. This
316
+ * only reports a dependency independently resolvable BY ITS OWN NAME at
317
+ * piHome/npm's own top level -- exactly the shape a caller can actually run
318
+ * `packed update npm:<name>` against directly.
319
+ */
320
+ export function listUnconfiguredDaemonDependencies(piHome: string): DaemonDependencyPkg[] {
321
+ const configured = new Set(
322
+ readPackageDeclarations(piHome).flatMap((source) => {
323
+ const name = npmPackageName(source);
324
+ return name ? [name] : [];
325
+ }),
326
+ );
327
+ const names = listTopLevelPackageNames(join(piHome, "npm", "node_modules")).slice(0, MAX_DAEMON_DEPENDENCY_SCAN);
328
+ const out: DaemonDependencyPkg[] = [];
329
+ for (const name of names) {
330
+ if (configured.has(name)) continue;
331
+ const resolved = resolveDaemonServiceSpec(piHome, `npm:${name}`);
332
+ if (resolved.ok) out.push({ name, version: resolved.spec.version, vehicleName: resolved.spec.name });
333
+ }
334
+ return out;
335
+ }
336
+
337
+ /**
338
+ * True when `packageName` (a bare npm name) is a pi:-configured extension --
339
+ * present in readPackageDeclarations()'s own packages[] list, regardless of
340
+ * whether it's pinned. The other half of the classification
341
+ * classifyUpdateSource() needs: a name that resolves ok via
342
+ * resolveDaemonServiceSpec() but returns false here is a "daemon-dependency"
343
+ * class source, not an "extension" one.
344
+ */
345
+ export function isConfiguredExtension(piHome: string, packageName: string): boolean {
346
+ return readPackageDeclarations(piHome).some((source) => npmPackageName(source) === packageName);
347
+ }
348
+
349
+ export type UpdateSourceKind = "extension" | "daemon-dependency";
350
+
351
+ /**
352
+ * Classifies an npm: source for package.update's own two-path mutation:
353
+ * "extension" (pi:-configured, settings.json's packages[] -- `pi update
354
+ * --extension` already handles this correctly, unchanged) vs.
355
+ * "daemon-dependency" (NOT pi:-configured, but resolveDaemonServiceSpec()
356
+ * resolves it directly BY ITS OWN NAME -- pi-core can never see this one;
357
+ * confirmed via this house's own failing-test repro, service/test/
358
+ * install.test.ts, of pi's real "No matching package found"). Needs the
359
+ * alternate npm-level mutation path (Installer.updateDaemonDependency())
360
+ * instead of shelling to `pi update --extension` at all.
361
+ *
362
+ * undefined for a non-npm: source, or an npm: source that is neither
363
+ * (never installed, or installed but not Vehicle-shaped) -- package.update's
364
+ * existing catch-all path (calling `pi update --extension` and surfacing
365
+ * whatever it reports) still applies unchanged to either of those, exactly
366
+ * as it did before this classification existed.
367
+ */
368
+ export function classifyUpdateSource(piHome: string, source: string): UpdateSourceKind | undefined {
369
+ if (!source.startsWith("npm:")) return undefined;
370
+ const packageName = npmPackageName(source);
371
+ if (!packageName) return undefined;
372
+ if (isConfiguredExtension(piHome, packageName)) return "extension";
373
+ return resolveDaemonServiceSpec(piHome, source).ok ? "daemon-dependency" : undefined;
374
+ }
375
+
376
+ /**
377
+ * `packed installed`'s real, whole-fleet listing: every pi:-configured
378
+ * extension (`kind: "extension"`, exactly today's
379
+ * readInstalledPackagesAcrossScopes() output, unchanged) PLUS every
380
+ * top-level daemon-only dependency listUnconfiguredDaemonDependencies() can
381
+ * independently resolve (`kind: "daemon-dependency"`) -- so a real, running
382
+ * Armada vehicle like @danypops/lector shows up under its own name instead
383
+ * of staying invisibly nested inside its pi-lector wrapper's own row. See
384
+ * packed-package-update-restart-service-cant-manage.
385
+ *
386
+ * projectRoot only ever widens the extension half (matching
387
+ * readInstalledPackagesAcrossScopes' own scope) -- daemon-dependency
388
+ * detection is global-only for now, since the reported gap itself (an
389
+ * independently-pinned piHome/npm/package.json dependency) is a global-scope
390
+ * shape; a project-scoped .pi/npm equivalent is real future work, not
391
+ * something this fixes today.
392
+ */
393
+ export function listManagedPackages(piHome: string, projectRoot?: string): InstalledPkg[] {
394
+ const extensions: InstalledPkg[] = readInstalledPackagesAcrossScopes(piHome, projectRoot).map((pkg) => ({
395
+ ...pkg,
396
+ kind: "extension",
397
+ }));
398
+ const daemonDependencies: InstalledPkg[] = listUnconfiguredDaemonDependencies(piHome).map((dep) => ({
399
+ name: dep.name,
400
+ installed: dep.version,
401
+ scope: "global",
402
+ kind: "daemon-dependency",
403
+ }));
404
+ return [...extensions, ...daemonDependencies];
405
+ }
406
+
221
407
  export interface ReconcileAllResult {
222
408
  reconciled: Array<{ packageName: string; vehicleName: string; installed: boolean; reason?: string }>;
223
409
  skipped: number;
@@ -227,7 +413,11 @@ export interface ReconcileAllResult {
227
413
  /** Matches readPackageDeclarations' own bound -- a reconcile-all sweep never processes an unbounded package list. */
228
414
  const MAX_RECONCILE_PACKAGES = 500;
229
415
  const PACKED_PACKAGE_NAME = "@danypops/pi-packed";
230
- const PACKED_VEHICLE_NAME = "pi-packed";
416
+ /** Packed's own stable cross-daemon identity -- both its real Armada vehicle name (this file's own
417
+ * self-registration guards below) and, since it's the same daemon, the name it registers under in
418
+ * the shared Vehicle Handle Directory for Vehicle Shell broker mode (see daemon.ts's own
419
+ * daemonOptions() -- vehicleName: PACKED_VEHICLE_NAME). One canonical name, not two. */
420
+ export const PACKED_VEHICLE_NAME = "pi-packed";
231
421
  const SELF_REGISTRATION_REASON = "Packed cannot replace its own Armada service from inside the running daemon";
232
422
 
233
423
  /**
@@ -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");
@@ -162,6 +185,29 @@ describe("resolveDaemonServiceSpec", () => {
162
185
  expect(result.spec.name).toBe("papyrus");
163
186
  });
164
187
 
188
+ it("honors a nested dependency's own explicit packed.daemonService manifest, not just bin+dependency convention -- confirmed live: papyrus's real handle file is 'vehicle-handle.json', not the 'daemon.json' convention guess, and papyrus is only ever discovered this way (one level into pi-papyrus's own dependencies), never installed directly by name", () => {
189
+ const piHome = fakePiHome();
190
+ const extDir = join(piHome, "npm", "node_modules", "@danypops/pi-papyrus");
191
+ writeRawPackage(extDir, { name: "@danypops/pi-papyrus", version: "1.0.0", dependencies: { "@danypops/papyrus": "^0.54.0" } });
192
+ const depDir = join(extDir, "node_modules", "@danypops/papyrus");
193
+ writeRawPackage(depDir, {
194
+ name: "@danypops/papyrus",
195
+ version: "0.54.0",
196
+ bin: { papyrus: "src/cli.ts" },
197
+ dependencies: { "@danypops/vehicle-server": "^0.24.0" },
198
+ packed: { daemonService: { binPath: "src/cli.ts", args: ["serve"], handleFilename: "vehicle-handle.json", restartOnFailure: true, restartSec: 2 } },
199
+ });
200
+
201
+ const result = resolveDaemonServiceSpec(piHome, "npm:@danypops/pi-papyrus");
202
+ expect(result.ok).toBe(true);
203
+ if (!result.ok) throw new Error("expected ok");
204
+ expect(result.spec.binPath).toBe(join(depDir, "src/cli.ts"));
205
+ expect(result.spec.name).toBe("papyrus");
206
+ expect(result.spec.handlePath.endsWith("vehicle-handle.json")).toBe(true);
207
+ expect(result.spec.restartOnFailure).toBe(true);
208
+ expect(result.spec.restartSec).toBe(2);
209
+ });
210
+
165
211
  it("also detects the legacy @danypops/daemon-kit dependency name, not just vehicle-server", () => {
166
212
  const piHome = fakePiHome();
167
213
  const dir = join(piHome, "npm", "node_modules", "@danypops/web-spider-daemon");
@@ -208,6 +254,131 @@ describe("resolveDaemonServiceSpec", () => {
208
254
  });
209
255
  });
210
256
 
257
+ describe("listUnconfiguredDaemonDependencies -- the packed-package-update-restart-service-cant-manage visibility gap", () => {
258
+ it("reports a real Vehicle-shaped daemon installed at piHome/npm's own top level that is NOT a pi:-configured extension", () => {
259
+ const piHome = fakePiHome();
260
+ // @danypops/pi-lector IS configured (a real Pi extension, settings.json packages[]).
261
+ // @danypops/lector is a plain top-level dependency -- installed, own bin, real
262
+ // vehicle-server dependency -- but never itself `pi install`ed. Exactly the live
263
+ // shape confirmed in ~/.pi/agent/npm/package.json.
264
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
265
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
266
+ name: "@danypops/pi-lector",
267
+ version: "0.12.7",
268
+ dependencies: { "@danypops/lector": "^0.18.0" },
269
+ });
270
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
271
+ name: "@danypops/lector",
272
+ version: "0.18.9",
273
+ bin: { lector: "src/cli.ts" },
274
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
275
+ });
276
+
277
+ const result = listUnconfiguredDaemonDependencies(piHome);
278
+
279
+ expect(result).toEqual([{ name: "@danypops/lector", version: "0.18.9", vehicleName: "lector" }]);
280
+ });
281
+
282
+ it("never reports a package that is already pi:-configured, even though it independently resolves as a daemon too", () => {
283
+ const piHome = fakePiHome();
284
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
285
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
286
+ name: "@danypops/papyrus",
287
+ version: "1.0.0",
288
+ bin: { papyrus: "src/cli.ts" },
289
+ dependencies: { "@danypops/vehicle-server": "^0.1.0" },
290
+ });
291
+
292
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
293
+ });
294
+
295
+ it("never reports a package with no real Vehicle-shaped daemon of its own -- an ordinary library dependency", () => {
296
+ const piHome = fakePiHome();
297
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: [] }));
298
+ writeRawPackage(join(piHome, "npm", "node_modules", "picomatch"), { name: "picomatch", version: "4.0.5" });
299
+
300
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
301
+ });
302
+
303
+ it("returns [] rather than throwing when piHome/npm/node_modules doesn't exist yet", () => {
304
+ const piHome = fakePiHome();
305
+ expect(listUnconfiguredDaemonDependencies(piHome)).toEqual([]);
306
+ });
307
+ });
308
+
309
+ describe("listManagedPackages -- packed installed's real whole-fleet listing (packed-package-update-restart-service-cant-manage)", () => {
310
+ it("tags a pi:-configured extension row 'extension' and a detected top-level daemon dependency 'daemon-dependency', side by side", () => {
311
+ const piHome = fakePiHome();
312
+ writeFileSync(join(piHome, "settings.json"), JSON.stringify({ packages: ["npm:@danypops/pi-lector@0.12.7"] }));
313
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "pi-lector"), {
314
+ name: "@danypops/pi-lector",
315
+ version: "0.12.7",
316
+ dependencies: { "@danypops/lector": "^0.18.0" },
317
+ });
318
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
319
+ name: "@danypops/lector",
320
+ version: "0.18.9",
321
+ bin: { lector: "src/cli.ts" },
322
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
323
+ });
324
+
325
+ const result = listManagedPackages(piHome);
326
+
327
+ expect(result).toEqual([
328
+ { name: "@danypops/pi-lector", pinned: "0.12.7", installed: undefined, scope: "global", kind: "extension" },
329
+ { name: "@danypops/lector", installed: "0.18.9", scope: "global", kind: "daemon-dependency" },
330
+ ]);
331
+ });
332
+
333
+ it("is exactly today's extension listing, with kind added, when there are no daemon-only dependencies at all", () => {
334
+ const piHome = fakePiHome();
335
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:plain@1.0.0"] }));
336
+ writeRawPackage(join(piHome, "npm", "node_modules", "plain"), { name: "plain", version: "1.0.0" });
337
+
338
+ expect(listManagedPackages(piHome)).toEqual([{ name: "plain", pinned: "1.0.0", installed: undefined, scope: "global", kind: "extension" }]);
339
+ });
340
+ });
341
+
342
+ describe("classifyUpdateSource -- package.update's own two-path split (packed-package-update-restart-service-cant-manage)", () => {
343
+ it("classifies a pi:-configured extension as 'extension', even when it also happens to resolve as a Vehicle daemon itself", () => {
344
+ const piHome = fakePiHome();
345
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/papyrus"] }));
346
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "papyrus"), {
347
+ name: "@danypops/papyrus",
348
+ version: "1.0.0",
349
+ bin: { papyrus: "src/cli.ts" },
350
+ dependencies: { "@danypops/vehicle-server": "^0.1.0" },
351
+ });
352
+
353
+ expect(classifyUpdateSource(piHome, "npm:@danypops/papyrus")).toBe("extension");
354
+ });
355
+
356
+ it("classifies a real Vehicle-shaped daemon with no pi:-configured entry of its own as 'daemon-dependency'", () => {
357
+ const piHome = fakePiHome();
358
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: ["npm:@danypops/pi-lector"] }));
359
+ writeRawPackage(join(piHome, "npm", "node_modules", "@danypops", "lector"), {
360
+ name: "@danypops/lector",
361
+ version: "0.18.9",
362
+ bin: { lector: "src/cli.ts" },
363
+ dependencies: { "@danypops/vehicle-server": "^0.18.2" },
364
+ });
365
+
366
+ expect(classifyUpdateSource(piHome, "npm:@danypops/lector")).toBe("daemon-dependency");
367
+ });
368
+
369
+ it("classifies neither for an npm: source that resolves as nothing at all", () => {
370
+ const piHome = fakePiHome();
371
+ writeFileSync(piHome + "/settings.json", JSON.stringify({ packages: [] }));
372
+
373
+ expect(classifyUpdateSource(piHome, "npm:never-installed")).toBeUndefined();
374
+ });
375
+
376
+ it("classifies neither for a non-npm source -- git:/https: replace/update behavior is untouched", () => {
377
+ const piHome = fakePiHome();
378
+ expect(classifyUpdateSource(piHome, "git:github.com/u/r@v1")).toBeUndefined();
379
+ });
380
+ });
381
+
211
382
  describe("RealDaemonServiceInstaller -- Armada registration through the in-process registrar", () => {
212
383
  it("install() resolves the package and drives Armada's real registrar/reconciler through the isolated harness", async () => {
213
384
  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 }));