@danypops/pi-packed 0.22.6 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -74,11 +74,10 @@ 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
- // 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" } },
77
+ // tools_list/tools_man are a neutral, process-wide singleton now (vehicle-client-pi's own
78
+ // ensureVehicleShellHandle) -- no ownVehicleName/broker option needed here anymore; every
79
+ // vehicle in the process (including packed's own) is discovered and namespaced uniformly.
80
+ shell: { coreOperations: CORE_OPERATIONS },
82
81
  });
83
82
  } catch {
84
83
  // 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.6",
3
+ "version": "0.24.0",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "@danypops/packed": "^0.7.0",
32
32
  "@danypops/pi-extension-harness": "^0.7.0",
33
33
  "@danypops/vehicle-client": "^0.8.1",
34
- "@danypops/vehicle-client-pi": "^0.39.0",
34
+ "@danypops/vehicle-client-pi": "^0.40.1",
35
35
  "@danypops/vehicle-core": "^0.15.0",
36
36
  "@danypops/vehicle-server": "^0.22.0",
37
37
  "jiti": "^2.7.0",
@@ -171,6 +171,7 @@ interface Flags {
171
171
  machineLocal: boolean;
172
172
  ecosystem: boolean;
173
173
  self: boolean;
174
+ all: boolean;
174
175
  project?: string;
175
176
  version?: string;
176
177
  to?: string;
@@ -189,6 +190,7 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
189
190
  machineLocal: false,
190
191
  ecosystem: false,
191
192
  self: false,
193
+ all: false,
192
194
  };
193
195
  const pos: string[] = [];
194
196
  for (let i = 0; i < rest.length; i++) {
@@ -203,6 +205,7 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
203
205
  else if (a === "--machine-local") flags.machineLocal = true;
204
206
  else if (a === "--ecosystem") flags.ecosystem = true;
205
207
  else if (a === "--self") flags.self = true;
208
+ else if (a === "--all") flags.all = true;
206
209
  else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
207
210
  else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
208
211
  else if (a === "--project" && i + 1 < rest.length) flags.project = rest[++i];
@@ -671,8 +674,29 @@ const commands: Record<string, { usage: string; run: Command }> = {
671
674
 
672
675
  update: {
673
676
  usage:
674
- "packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks)",
677
+ "packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] | packed update --all [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks; --all batches every currently-stale package, re-resolving once instead of once per package)",
675
678
  async run(_rest, d, flags, pos) {
679
+ if (flags.all) {
680
+ if (!d.daemon) return fail("update --all requires a running packed daemon\n");
681
+ try {
682
+ const result = await d.daemon.updateAll(undefined, flags.approved);
683
+ if (flags.json) return ok(`${JSON.stringify(result)}\n`);
684
+ if (result.results.length === 0) return ok("all pi packages up to date (per the local mirror)\n");
685
+ let out = `${result.output}\n\n`;
686
+ for (const item of result.results) {
687
+ const transition = item.previousVersion && item.currentVersion ? ` (${item.previousVersion} → ${item.currentVersion})` : "";
688
+ const status = item.ok ? (item.alreadyUpToDate ? "already up to date" : `updated${transition}`) : `FAILED: ${item.output}`;
689
+ out += ` ${item.source} ${status}\n`;
690
+ }
691
+ if (result.reresolveError) out += `\nWARNING: final dependency re-resolution failed: ${result.reresolveError}\n`;
692
+ const anyChanged = result.results.some((item) => item.ok && !item.alreadyUpToDate);
693
+ if (anyChanged) out += "\nReload Pi with /reload to activate the updated packages.\n";
694
+ return result.ok ? ok(out) : fail(out);
695
+ } catch (error) {
696
+ const message = error instanceof Error ? error.message : String(error);
697
+ return flags.json ? fail(`${JSON.stringify({ ok: false, error: message })}\n`) : fail(`${message}\n`);
698
+ }
699
+ }
676
700
  if (flags.self) {
677
701
  try {
678
702
  assertPackagePermission(await d.security.security(), "update.self", flags.approved);
@@ -52,6 +52,7 @@ export interface PackageDaemonPort {
52
52
  reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
53
53
  remove(name: string, approved?: boolean): Promise<string>;
54
54
  update(source: string, approved?: boolean, target?: string): Promise<UpdateOutcome>;
55
+ updateAll(sources?: string[], approved?: boolean): Promise<OperationOutputs["package.update_all"]>;
55
56
  piStatus(): Promise<PiVersionReport>;
56
57
  resourcesList(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
57
58
  resourcesToggle(
@@ -283,6 +284,14 @@ export class PackageDaemonClient implements PackageDaemonPort {
283
284
  rollback: result.rollback,
284
285
  };
285
286
  }
287
+
288
+ /** Batch update -- see updateManyPackages()'s own doc comment (install.ts). sources omitted
289
+ * defaults to every currently-stale global package the mirror already knows about. */
290
+ async updateAll(sources?: string[], approved = false): Promise<OperationOutputs["package.update_all"]> {
291
+ const result = await this.call("package.update_all", { sources, approved });
292
+ if (!result.ok) throw new PackageDaemonError(result.output || "failed to update packages", "package.update_all");
293
+ return result;
294
+ }
286
295
  }
287
296
 
288
297
  export class PackageDaemonInstaller implements Installer {
@@ -18,6 +18,7 @@ import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
18
18
  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
+ import { updateManyPackages } from "../packages/install.ts";
21
22
  import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
22
23
  import { defaultPiHome, npmPackageName, readInstalledPackagesAcrossScopes, splitNpmSource } from "../packages/installed.ts";
23
24
  import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts";
@@ -113,6 +114,7 @@ export type OperationName =
113
114
  | "package.reconcile_services"
114
115
  | "package.remove"
115
116
  | "package.update"
117
+ | "package.update_all"
116
118
  | "resources.list"
117
119
  | "resources.toggle"
118
120
  | "pi.status"
@@ -144,6 +146,7 @@ export interface OperationInputs {
144
146
  "package.reconcile_services": { approved?: boolean; projectRoot?: string };
145
147
  "package.remove": { name: string; approved?: boolean };
146
148
  "package.update": { source: string; approved?: boolean; target?: string };
149
+ "package.update_all": { sources?: string[]; approved?: boolean };
147
150
  "resources.list": { projectRoot?: string };
148
151
  "resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
149
152
  "pi.status": Record<string, never>;
@@ -157,6 +160,10 @@ interface MutationResponse {
157
160
  output: string;
158
161
  }
159
162
  interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOutcome, "output">> {}
163
+ interface UpdateAllMutationResponse extends MutationResponse {
164
+ results: Array<{ source: string; ok: boolean; output: string; serviceReconciled?: boolean } & Partial<Omit<UpdateOutcome, "output">>>;
165
+ reresolveError?: string;
166
+ }
160
167
  interface InstallServiceResponse {
161
168
  ok: boolean;
162
169
  output: string;
@@ -192,6 +199,7 @@ export interface OperationOutputs {
192
199
  "package.reconcile_services": ReconcileServicesResponse;
193
200
  "package.remove": MutationResponse;
194
201
  "package.update": UpdateMutationResponse;
202
+ "package.update_all": UpdateAllMutationResponse;
195
203
  "resources.list": { global: PackageResources[]; project: PackageResources[] };
196
204
  "resources.toggle": MutationResponse;
197
205
  "pi.status": PiVersionReport;
@@ -224,6 +232,7 @@ export const OPERATION_NAMES: readonly OperationName[] = [
224
232
  "package.reconcile_services",
225
233
  "package.remove",
226
234
  "package.update",
235
+ "package.update_all",
227
236
  "resources.list",
228
237
  "resources.toggle",
229
238
  "pi.status",
@@ -577,6 +586,55 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
577
586
  }
578
587
  }
579
588
 
589
+ if (path === "/update-all" && req.method === "POST") {
590
+ let approved = false;
591
+ let sources: string[] | undefined;
592
+ try {
593
+ const body = (await req.json()) as { approved?: unknown; sources?: unknown };
594
+ approved = body.approved === true;
595
+ if (Array.isArray(body.sources)) sources = body.sources.filter((item): item is string => typeof item === "string");
596
+ } catch {
597
+ /* fall through -- approved stays false, sources stays undefined (defaults to every stale package) */
598
+ }
599
+ if (sources && sources.some((source) => !SOURCE_RE.test(source))) {
600
+ return err(400, "invalid source; want a configured npm:, git:, or https package source");
601
+ }
602
+ const denied = authorize("update_all", approved);
603
+ if (denied) return denied;
604
+ // Default: every currently-stale GLOBAL package the mirror already knows about -- same set
605
+ // `packed updates` (no --project) reports. A caller wanting project-scoped sources too
606
+ // passes them explicitly via body.sources; this endpoint never guesses project scope.
607
+ if (!sources) {
608
+ const snap = await loadUpdates(deps.stateDir);
609
+ sources = (snap?.updates ?? []).map((entry) => `npm:${entry.name}`);
610
+ }
611
+ // Daemon-dependency sources (see classifyUpdateSource's own doc comment) still route through
612
+ // updateOnly()/update() here, exactly like a bare `pi update --extension` would today -- a
613
+ // known, non-regressive scope limit for this batch endpoint's first version, not a silent
614
+ // misclassification: each such source simply reports its already-existing, honest
615
+ // "No matching package found" failure as its own independent per-source outcome.
616
+ const batchResult = await updateManyPackages(deps.inst, sources, { approved });
617
+ const results: UpdateAllMutationResponse["results"] = batchResult.outcomes.map((item) =>
618
+ item.status === "succeeded" && item.outcome
619
+ ? { source: item.source, ok: true, ...item.outcome }
620
+ : { source: item.source, ok: false, output: item.error ?? "update failed" },
621
+ );
622
+ // Best-effort per-source service-restart reconciliation, same as /update's own -- one
623
+ // package's restart failing never fails the batch or blocks a sibling's own reconciliation.
624
+ for (const result of results) {
625
+ if (!result.ok || result.alreadyUpToDate || !result.source.startsWith("npm:")) continue;
626
+ try {
627
+ const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, result.source);
628
+ if (service.ok) result.serviceReconciled = service.restarted;
629
+ } catch {
630
+ /* best-effort -- a restart failure never fails the batch */
631
+ }
632
+ }
633
+ const ok = results.every((result) => result.ok) && !batchResult.reresolveError;
634
+ const output = `updated ${results.filter((result) => result.ok && !result.alreadyUpToDate).length}/${results.length} package(s)`;
635
+ return json({ ok, output, results, ...(batchResult.reresolveError ? { reresolveError: batchResult.reresolveError } : {}) });
636
+ }
637
+
580
638
  if (path === "/updates" && req.method === "GET") {
581
639
  const snap = await loadUpdates(deps.stateDir);
582
640
  return json(snap ?? { updates: [] });
@@ -762,6 +820,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
762
820
  path = "/update";
763
821
  init = { method: "POST", body: JSON.stringify(input) };
764
822
  break;
823
+ case "package.update_all":
824
+ path = "/update-all";
825
+ init = { method: "POST", body: JSON.stringify(input) };
826
+ break;
765
827
  default:
766
828
  throw new PackageOperationError(`unknown operation: ${String(op)}`, 404);
767
829
  }
@@ -170,6 +170,11 @@ const OPERATION_META: Record<OperationName, OperationMeta> = {
170
170
  effect: "open-world",
171
171
  requiresApproval: true,
172
172
  },
173
+ "package.update_all": {
174
+ description: "Updates every currently-stale Pi package in one batch, re-resolving the dependency tree once instead of once per package.",
175
+ effect: "open-world",
176
+ requiresApproval: true,
177
+ },
173
178
  "resources.list": {
174
179
  description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).",
175
180
  effect: "read",
@@ -151,23 +151,22 @@ export class ExecInstaller implements Installer {
151
151
  return this.run(["remove", ...(options?.local ? ["-l"] : []), source]);
152
152
  }
153
153
 
154
- async update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
154
+ /**
155
+ * The real `pi update --extension <source>` mutation alone -- no trailing reresolveDependencyTree()
156
+ * (a batch caller defers that to once per batch via updateManyPackages(), not once per package).
157
+ * Returns the full UpdateOutcome (unlike installOnly()'s raw string): a single package's own
158
+ * before/after version diff is already correct right after ITS OWN update -- reresolveDependencyTree()
159
+ * exists to fix a DIFFERENT sibling's drift, not this package's own resolved version. MUST be
160
+ * awaited sequentially across a batch, never run concurrently -- same shared settings.json/
161
+ * package.json mutation hazard as installOnly(), see its own doc comment.
162
+ */
163
+ async updateOnly(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
155
164
  if (options?.target) return this.replace(source, options.target, options);
156
165
  const t0 = performance.now();
157
166
  const pinned = isPinnedNpmSource(source);
158
167
  const previousVersion = readResolvedVersion(this.piHome, source);
159
- const t1 = performance.now();
160
168
  const output = await this.run(["update", "--extension", source]);
161
- const updateMs = performance.now() - t1;
162
- const t2 = performance.now();
163
- await this.reresolveDependencyTree();
164
- const reresolveMs = performance.now() - t2;
165
- this.logger.debug("update timing", {
166
- source,
167
- updateMs: round1(updateMs),
168
- reresolveMs: round1(reresolveMs),
169
- totalMs: round1(performance.now() - t0),
170
- });
169
+ this.logger.debug("updateOnly timing", { source, updateMs: round1(performance.now() - t0) });
171
170
  const currentVersion = readResolvedVersion(this.piHome, source);
172
171
  // Only trust a "nothing changed" conclusion when we actually read a
173
172
  // real version both before and after (npm source, resolvable in
@@ -202,6 +201,24 @@ export class ExecInstaller implements Installer {
202
201
  };
203
202
  }
204
203
 
204
+ async update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
205
+ if (options?.target) return this.replace(source, options.target, options);
206
+ const t0 = performance.now();
207
+ const t1 = performance.now();
208
+ const result = await this.updateOnly(source, options);
209
+ const updateMs = performance.now() - t1;
210
+ const t2 = performance.now();
211
+ await this.reresolveDependencyTree();
212
+ const reresolveMs = performance.now() - t2;
213
+ this.logger.debug("update timing", {
214
+ source,
215
+ updateMs: round1(updateMs),
216
+ reresolveMs: round1(reresolveMs),
217
+ totalMs: round1(performance.now() - t0),
218
+ });
219
+ return result;
220
+ }
221
+
205
222
  /**
206
223
  * The alternate mutation path for a source classifyUpdateSource() flags as
207
224
  * "daemon-dependency" -- genuinely installed and npm-resolvable, but NOT
@@ -325,3 +342,57 @@ export class ExecInstaller implements Installer {
325
342
  };
326
343
  }
327
344
  }
345
+
346
+ export interface UpdateManyOutcome {
347
+ readonly source: string;
348
+ readonly status: "succeeded" | "failed";
349
+ readonly outcome?: UpdateOutcome;
350
+ readonly error?: string;
351
+ }
352
+
353
+ export interface UpdateManyResult {
354
+ readonly outcomes: readonly UpdateManyOutcome[];
355
+ /** True whenever any succeeded source actually changed version -- one combined signal instead of a caller re-deriving it per outcome. */
356
+ readonly reloadRequired: boolean;
357
+ /** Set only when the final batch-wide reresolve itself failed -- every individual outcome above already reflects its own real result regardless. */
358
+ readonly reresolveError?: string;
359
+ }
360
+
361
+ /**
362
+ * Batch update over several sources at once -- same validate-concurrently/commit-sequentially/
363
+ * reresolve-once shape as SetupManager.apply()'s own batch mode (see Installer.updateOnly's own
364
+ * doc comment): every source is updated in turn via updateOnly() (no per-source reresolve), then
365
+ * reresolveDependencyTree() runs exactly ONCE for the whole batch -- the real cost `packed update`
366
+ * one source at a time was paying N times over (see ExecInstaller's own constructor doc comment).
367
+ * Sequential across sources, never fanned out -- same shared-file mutation hazard as installOnly().
368
+ * One source failing never stops the rest; each gets its own independent outcome. Falls back to
369
+ * one update() call per source (each paying its own reresolve) when the installer doesn't
370
+ * implement the updateOnly/reresolveDependencyTree batch trio -- matching SetupManager.apply()'s
371
+ * own graceful degradation for a non-batch-capable Installer (e.g. a plain test double).
372
+ */
373
+ export async function updateManyPackages(
374
+ installer: Installer,
375
+ sources: readonly string[],
376
+ options?: { approved?: boolean },
377
+ ): Promise<UpdateManyResult> {
378
+ const batch = Boolean(installer.updateOnly && installer.reresolveDependencyTree);
379
+ const outcomes: UpdateManyOutcome[] = [];
380
+ for (const source of sources) {
381
+ try {
382
+ const outcome =
383
+ batch && installer.updateOnly ? await installer.updateOnly(source, options) : await installer.update(source, options);
384
+ outcomes.push({ source, status: "succeeded", outcome });
385
+ } catch (error) {
386
+ outcomes.push({ source, status: "failed", error: error instanceof Error ? error.message : String(error) });
387
+ }
388
+ }
389
+ const reloadRequired = outcomes.some((item) => item.status === "succeeded" && item.outcome?.reloadRequired);
390
+ if (batch && installer.reresolveDependencyTree && outcomes.some((item) => item.status === "succeeded")) {
391
+ try {
392
+ await installer.reresolveDependencyTree();
393
+ } catch (error) {
394
+ return { outcomes, reloadRequired, reresolveError: error instanceof Error ? error.message : String(error) };
395
+ }
396
+ }
397
+ return { outcomes, reloadRequired };
398
+ }
@@ -219,6 +219,15 @@ export interface Installer {
219
219
  */
220
220
  validate?(source: string): Promise<InstallValidationResult>;
221
221
  installOnly?(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
222
+ /**
223
+ * updateOnly() is update()'s own equivalent split: the real `pi update --extension` mutation
224
+ * (or replace(), when options.target is given) WITHOUT the trailing reresolveDependencyTree(),
225
+ * returning the same UpdateOutcome update() itself returns -- a single package's own before/
226
+ * after version diff is already correct right after its own mutation, reresolveDependencyTree()
227
+ * exists to fix a DIFFERENT sibling's drift. See updateManyPackages() (install.ts) for the
228
+ * batch caller. Same SEQUENTIAL-only constraint as installOnly() above.
229
+ */
230
+ updateOnly?(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
222
231
  reresolveDependencyTree?(): Promise<string>;
223
232
  }
224
233
 
@@ -26,6 +26,7 @@ export const PACKAGE_OPERATIONS = [
26
26
  "reconcile_services",
27
27
  "setup.apply",
28
28
  "update",
29
+ "update_all",
29
30
  "update.self",
30
31
  "remove",
31
32
  "resources.list",
@@ -74,6 +75,7 @@ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification>
74
75
  reconcile_services: "code-execution",
75
76
  "setup.apply": "code-execution",
76
77
  update: "code-execution",
78
+ update_all: "code-execution",
77
79
  "update.self": "code-execution",
78
80
  remove: "settings-mutation",
79
81
  "resources.list": "read",
@@ -788,6 +788,71 @@ describe("CLI", () => {
788
788
  });
789
789
  });
790
790
 
791
+ /** A PackageDaemonPort stub that throws "unexpected call" for anything the test doesn't
792
+ * explicitly override -- update --all only ever calls updateAll(), so nothing else should
793
+ * ever be invoked; a Proxy avoids hand-writing 25+ unused method stubs. */
794
+ function baseDaemonPort(): PackageDaemonPort {
795
+ return new Proxy(
796
+ {},
797
+ {
798
+ get: (_target, prop) => async () => {
799
+ throw new Error(`unexpected call: ${String(prop)}`);
800
+ },
801
+ },
802
+ ) as PackageDaemonPort;
803
+ }
804
+
805
+ it("update --all fails closed without a running daemon", async () => {
806
+ const d = deps({ daemon: undefined });
807
+ const { code, out } = await cliRun(["update", "--all", "--approve"], d);
808
+ expect(code).toBe(1);
809
+ expect(out).toContain("requires a running packed daemon");
810
+ });
811
+
812
+ it("update --all delegates to the daemon's batch endpoint and reports a per-package summary", async () => {
813
+ const calls: Array<[string[] | undefined, boolean | undefined]> = [];
814
+ const daemon = {
815
+ ...baseDaemonPort(),
816
+ async updateAll(sources?: string[], approved?: boolean) {
817
+ calls.push([sources, approved]);
818
+ return {
819
+ ok: true,
820
+ output: "updated 1/2 package(s)",
821
+ results: [
822
+ { source: "npm:pi-lsp", ok: true, output: "Updated npm:pi-lsp", previousVersion: "1.0.0", currentVersion: "1.1.0" },
823
+ { source: "npm:pi-tickets", ok: true, output: "Updated npm:pi-tickets", alreadyUpToDate: true },
824
+ ],
825
+ };
826
+ },
827
+ };
828
+ const d = deps({ daemon });
829
+ const { code, out } = await cliRun(["update", "--all", "--approve"], d);
830
+ expect(code).toBe(0);
831
+ expect(calls).toEqual([[undefined, true]]);
832
+ expect(out).toContain("npm:pi-lsp updated (1.0.0 \u2192 1.1.0)");
833
+ expect(out).toContain("npm:pi-tickets already up to date");
834
+ expect(out).toContain("Reload Pi with /reload to activate the updated packages.");
835
+ const json = await cliRun(["update", "--all", "--approve", "--json"], d);
836
+ expect(JSON.parse(json.out).results).toHaveLength(2);
837
+ });
838
+
839
+ it("update --all reports failures inline with exit code 1, without hiding the packages that did succeed", async () => {
840
+ const daemon = {
841
+ ...baseDaemonPort(),
842
+ async updateAll() {
843
+ return {
844
+ ok: false,
845
+ output: "updated 0/1 package(s)",
846
+ results: [{ source: "npm:broken", ok: false, output: "No matching package found" }],
847
+ };
848
+ },
849
+ };
850
+ const d = deps({ daemon });
851
+ const { code, out } = await cliRun(["update", "--all", "--approve"], d);
852
+ expect(code).toBe(1);
853
+ expect(out).toContain("npm:broken FAILED: No matching package found");
854
+ });
855
+
791
856
  it("remove wants a bare name and has stable JSON output", async () => {
792
857
  const d = deps();
793
858
  expect((await cliRun(["remove", "npm:foo"], d)).code).toBe(2);
@@ -947,6 +1012,10 @@ describe("CLI", () => {
947
1012
  async update(source) {
948
1013
  return { output: source, reloadRequired: false, alreadyUpToDate: true, pinned: false };
949
1014
  },
1015
+ async updateAll(sources) {
1016
+ calls.push(`updateAll:${sources?.join(",")}`);
1017
+ return { ok: true, output: "updated 0/0 package(s)", results: [] };
1018
+ },
950
1019
  async piStatus() {
951
1020
  calls.push("piStatus");
952
1021
  return { current: "0.82.1", latest: "0.83.0", upToDate: false };
@@ -145,6 +145,7 @@ describe("daemon-kit migration", () => {
145
145
  "package.reconcile_services",
146
146
  "package.remove",
147
147
  "package.update",
148
+ "package.update_all",
148
149
  "resources.list",
149
150
  "resources.toggle",
150
151
  "pi.status",
@@ -11,10 +11,11 @@
11
11
  * version diff is what actually drives reloadRequired/alreadyUpToDate.
12
12
  */
13
13
  import { afterEach, describe, expect, it } from "bun:test";
14
- import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
- import { ExecInstaller } from "../src/packages/install.ts";
17
+ import { ExecInstaller, updateManyPackages } from "../src/packages/install.ts";
18
+ import type { Installer, UpdateOutcome } from "../src/packages/package.ts";
18
19
  import { createLogger } from "../src/shared/log.ts";
19
20
 
20
21
  /**
@@ -710,3 +711,98 @@ describe("ExecInstaller — run()/reresolveDependencyTree() thread the CURRENT p
710
711
  expect(readFileSync(npmLog, "utf8")).toBe("reresolve-sees-this-too");
711
712
  });
712
713
  });
714
+
715
+ describe("updateManyPackages -- batch update over several sources at once, reresolving ONCE for the whole batch", () => {
716
+ it("updates every source via updateOnly() and runs exactly one npm install afterward, not one per source", async () => {
717
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
718
+ const piHome = writePiHome({ "@scope/a": "1.0.0", "@scope/b": "1.0.0" });
719
+ const bin = writeFakePi(scriptDir);
720
+ const npmLog = join(scriptDir, "npm.log");
721
+ const npmBin = writeFakeNpm(scriptDir, npmLog);
722
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
723
+
724
+ const result = await updateManyPackages(installer, ["npm:@scope/a", "npm:@scope/b"]);
725
+
726
+ expect(result.outcomes.map((o) => o.source)).toEqual(["npm:@scope/a", "npm:@scope/b"]);
727
+ expect(result.outcomes.every((o) => o.status === "succeeded")).toBe(true);
728
+ // The real point of this batch path: ONE npm install for two sources, not two.
729
+ const invocations = readFileSync(npmLog, "utf8").trim().split("\n").filter((line) => line === "install");
730
+ expect(invocations).toHaveLength(1);
731
+ });
732
+
733
+ it("one source failing never stops the rest, and each gets its own independent outcome", async () => {
734
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
735
+ const piHome = writePiHome({ "@scope/a": "1.0.0", "@scope/b": "1.0.0" });
736
+ const failingPi = join(scriptDir, "fake-pi-fail-once");
737
+ writeFileSync(
738
+ failingPi,
739
+ [
740
+ "#!/usr/bin/env bash",
741
+ "set -euo pipefail",
742
+ 'source="${3:-}"',
743
+ 'if [ "$source" = "npm:@scope/a" ]; then echo "No matching package found" >&2; exit 1; fi',
744
+ 'echo "Updated $source"',
745
+ "exit 0",
746
+ ].join("\n"),
747
+ );
748
+ chmodSync(failingPi, 0o755);
749
+ const npmLog = join(scriptDir, "npm.log");
750
+ const npmBin = writeFakeNpm(scriptDir, npmLog);
751
+ const installer = new ExecInstaller(failingPi, piHome, undefined, npmBin);
752
+
753
+ const result = await updateManyPackages(installer, ["npm:@scope/a", "npm:@scope/b"]);
754
+
755
+ expect(result.outcomes[0]).toMatchObject({ source: "npm:@scope/a", status: "failed" });
756
+ expect(result.outcomes[1]).toMatchObject({ source: "npm:@scope/b", status: "succeeded" });
757
+ // The batch-wide reresolve still runs once, since @scope/b did succeed.
758
+ expect(readFileSync(npmLog, "utf8").trim().split("\n").filter((line) => line === "install")).toHaveLength(1);
759
+ });
760
+
761
+ it("never runs the batch-wide reresolve when every source failed", async () => {
762
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
763
+ const piHome = writePiHome({ "@scope/a": "1.0.0" });
764
+ const failingPi = join(scriptDir, "fake-pi-always-fail");
765
+ writeFileSync(failingPi, ["#!/usr/bin/env bash", "echo 'No matching package found' >&2", "exit 1"].join("\n"));
766
+ chmodSync(failingPi, 0o755);
767
+ const npmLog = join(scriptDir, "npm.log");
768
+ const npmBin = writeFakeNpm(scriptDir, npmLog);
769
+ const installer = new ExecInstaller(failingPi, piHome, undefined, npmBin);
770
+
771
+ await updateManyPackages(installer, ["npm:@scope/a"]);
772
+
773
+ expect(existsSync(npmLog)).toBe(false);
774
+ });
775
+
776
+ it("reports a failed final reresolve distinctly, after every individual source already succeeded", async () => {
777
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
778
+ const piHome = writePiHome({ "@scope/a": "1.0.0" });
779
+ const bin = writeFakePi(scriptDir);
780
+ const failingNpm = join(scriptDir, "fake-npm-fail");
781
+ writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
782
+ chmodSync(failingNpm, 0o755);
783
+ const installer = new ExecInstaller(bin, piHome, undefined, failingNpm);
784
+
785
+ const result = await updateManyPackages(installer, ["npm:@scope/a"]);
786
+
787
+ expect(result.outcomes[0]?.status).toBe("succeeded");
788
+ expect(result.reresolveError).toMatch(/npm install failed to re-resolve/);
789
+ });
790
+
791
+ it("falls back to one update() call per source (each paying its own reresolve) when the installer lacks the batch trio", async () => {
792
+ const calls: string[] = [];
793
+ const fake: Installer = {
794
+ install: async () => "",
795
+ remove: async () => "",
796
+ update: async (source): Promise<UpdateOutcome> => {
797
+ calls.push(source);
798
+ return { output: `updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: false };
799
+ },
800
+ };
801
+
802
+ const result = await updateManyPackages(fake, ["npm:a", "npm:b"]);
803
+
804
+ expect(calls).toEqual(["npm:a", "npm:b"]);
805
+ expect(result.outcomes.every((o) => o.status === "succeeded")).toBe(true);
806
+ expect(result.reloadRequired).toBe(true);
807
+ });
808
+ });
@@ -39,6 +39,7 @@ describe("package permission policy", () => {
39
39
  "reconcile_services",
40
40
  "setup.apply",
41
41
  "update",
42
+ "update_all",
42
43
  "update.self",
43
44
  "remove",
44
45
  "resources.list",
@@ -70,6 +71,7 @@ describe("package permission policy", () => {
70
71
  restart_service: { classification: "code-execution", approvalRequired: true },
71
72
  "setup.apply": { classification: "code-execution", approvalRequired: true },
72
73
  update: { classification: "code-execution", approvalRequired: true },
74
+ update_all: { classification: "code-execution", approvalRequired: true },
73
75
  "update.self": { classification: "code-execution", approvalRequired: true },
74
76
  remove: { classification: "settings-mutation", approvalRequired: true },
75
77
  "resources.list": { classification: "read", approvalRequired: false },
@@ -53,10 +53,20 @@ class FakeInstaller implements Installer {
53
53
  }
54
54
  updateOutcome: Partial<UpdateOutcome> = {};
55
55
  gotTarget: string | undefined;
56
+ updatedSources: string[] = [];
57
+ updateOutcomeFor: Record<string, Partial<UpdateOutcome>> = {};
56
58
  async update(source: string, options?: { target?: string }): Promise<UpdateOutcome> {
57
59
  this.updated = source;
60
+ this.updatedSources.push(source);
58
61
  this.gotTarget = options?.target;
59
- return { output: this.output, reloadRequired: true, alreadyUpToDate: false, pinned: false, ...this.updateOutcome };
62
+ return {
63
+ output: this.output,
64
+ reloadRequired: true,
65
+ alreadyUpToDate: false,
66
+ pinned: false,
67
+ ...this.updateOutcome,
68
+ ...this.updateOutcomeFor[source],
69
+ };
60
70
  }
61
71
  updateDaemonDependencyGotName = "";
62
72
  updateDaemonDependencyGotVersion: string | undefined;
@@ -680,6 +690,89 @@ describe("service app", () => {
680
690
  expect(inst.updateDaemonDependencyGotName).toBe("");
681
691
  });
682
692
 
693
+ it("POST /update-all validates, authorizes, and delegates every explicitly given source", async () => {
694
+ const inst = new FakeInstaller();
695
+ const app = createApp(deps({ inst }));
696
+ const denied = await app.fetch(
697
+ new Request("http://x/update-all", {
698
+ method: "POST",
699
+ headers: { ...auth, "content-type": "application/json" },
700
+ body: JSON.stringify({ sources: ["npm:pi-lsp"] }),
701
+ }),
702
+ );
703
+ expect(denied.status).toBe(403);
704
+ const allowed = await app.fetch(
705
+ new Request("http://x/update-all", {
706
+ method: "POST",
707
+ headers: { ...auth, "content-type": "application/json" },
708
+ body: JSON.stringify({ sources: ["npm:pi-lsp", "npm:pi-tickets"], approved: true }),
709
+ }),
710
+ );
711
+ expect(allowed.status).toBe(200);
712
+ const body = (await allowed.json()) as any;
713
+ expect(body.ok).toBe(true);
714
+ expect(inst.updatedSources).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
715
+ expect(body.results.map((r: any) => r.source)).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
716
+ expect(body.results.every((r: any) => r.ok)).toBe(true);
717
+ });
718
+
719
+ it("POST /update-all defaults to every currently-stale global package from the mirror when sources is omitted", async () => {
720
+ const inst = new FakeInstaller();
721
+ const d = deps({ inst });
722
+ await saveUpdates(d.stateDir, {
723
+ checkedAt: new Date().toISOString(),
724
+ updates: [
725
+ { name: "pi-lsp", installed: "1.0.0", latest: "1.1.0" },
726
+ { name: "pi-tickets", installed: "2.0.0", latest: "2.1.0" },
727
+ ],
728
+ });
729
+ const app = createApp(d);
730
+ const response = await app.fetch(
731
+ new Request("http://x/update-all", {
732
+ method: "POST",
733
+ headers: { ...auth, "content-type": "application/json" },
734
+ body: JSON.stringify({ approved: true }),
735
+ }),
736
+ );
737
+ expect(response.status).toBe(200);
738
+ expect(inst.updatedSources).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
739
+ });
740
+
741
+ it("POST /update-all reports one failure without blocking the rest of the batch", async () => {
742
+ const inst = new FakeInstaller();
743
+ inst.updateOutcomeFor["npm:broken"] = undefined as unknown as Partial<UpdateOutcome>;
744
+ const originalUpdate = inst.update.bind(inst);
745
+ inst.update = async (source: string, options?: { target?: string }) => {
746
+ if (source === "npm:broken") throw new Error("No matching package found");
747
+ return originalUpdate(source, options);
748
+ };
749
+ const app = createApp(deps({ inst }));
750
+ const response = await app.fetch(
751
+ new Request("http://x/update-all", {
752
+ method: "POST",
753
+ headers: { ...auth, "content-type": "application/json" },
754
+ body: JSON.stringify({ sources: ["npm:broken", "npm:pi-lsp"], approved: true }),
755
+ }),
756
+ );
757
+ expect(response.status).toBe(200);
758
+ const body = (await response.json()) as any;
759
+ expect(body.ok).toBe(false);
760
+ expect(body.results[0]).toMatchObject({ source: "npm:broken", ok: false });
761
+ expect(body.results[1]).toMatchObject({ source: "npm:pi-lsp", ok: true });
762
+ });
763
+
764
+ it("POST /update-all rejects an invalid source the same way /update does", async () => {
765
+ const app = createApp(deps({ inst: new FakeInstaller() }));
766
+ const response = await app.fetch(
767
+ new Request("http://x/update-all", {
768
+ method: "POST",
769
+ headers: { ...auth, "content-type": "application/json" },
770
+ body: JSON.stringify({ sources: ["not-a-real-source"], approved: true }),
771
+ }),
772
+ );
773
+ expect(response.status).toBe(400);
774
+ });
775
+
683
776
  it("GET /updates serves the watcher snapshot", async () => {
684
777
  const d = deps();
685
778
  await saveUpdates(d.stateDir, {