@danypops/pi-packed 0.21.13 → 0.21.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Projects the daemon's already-registered 29-operation Vehicle surface (see
3
+ * service/src/daemon/vehicle-registration.ts) onto real Pi tools, under Vehicle Shell curation --
4
+ * the same registerVehicleTools({ shell }) pattern @danypops/pi-papyrus's registerNotesVehicle uses
5
+ * (see its extension/src/tools/vehicle-notes-client.ts).
6
+ *
7
+ * Read-only permissions ONLY, deliberately: every mutating package operation
8
+ * (package.install/install_service/restart_service/reconcile_services/update/remove,
9
+ * setup.apply, package.security.set, resources.toggle) is gated server-side purely by an
10
+ * `approved` boolean threaded through its own request shape -- see security.ts's
11
+ * assertPackagePermission(). Vehicle's own operation schema here is passthroughVehicleSchema
12
+ * (see vehicle-registration.ts), which would hand that same `approved` field to the model as an
13
+ * ordinary, undocumented-but-guessable parameter it could set itself, bypassing the interactive
14
+ * ctx.ui.confirm() gate tools.ts's pkg_install/pkg_update/pkg_remove already enforce. Granting
15
+ * only "packed:read" here (never "packed:write") makes every non-read operation permanently
16
+ * permission-unsatisfied -- classifyVehicleOperationSafety() resolves that to "blocked" -- so
17
+ * those tools are registered (visible in tools_list, inspectable via tools_man) but can never
18
+ * actually activate through this path. Exposing them safely needs a real Vehicle approval gate
19
+ * (VehicleRegistry.configureApprovals()) wired server-side first, not merely a client-side option
20
+ * here; until then, package.install/update/remove/etc. keep their own dedicated, human-confirmed
21
+ * tools in tools.ts, unrelated to this file.
22
+ *
23
+ * package.search/package.info are excluded from the projected manifest for the same
24
+ * one-capability-one-tool reason pkg-tools.md documents for pkg_search/pkg_info themselves --
25
+ * they already have dedicated, carefully-worded tools; a second "package_search"/"package_info"
26
+ * doing the identical thing under a different name would only be confusing, not additive.
27
+ *
28
+ * Must be called from session_start, not the top-level extension factory: registerVehicleTools()
29
+ * needs pi.getAllTools()/getActiveTools()/setActiveTools(), which Pi's extension runtime only
30
+ * exposes once every extension's own factory has resolved (confirmed live in the identical
31
+ * pi-papyrus/pi-tickets bug -- see index.ts's own session_start wiring).
32
+ */
33
+ import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon-client";
34
+ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
35
+ import { registerVehicleTools } from "@danypops/vehicle-client-pi";
36
+ import type { VehicleClient, VehicleManifest } from "@danypops/vehicle-core";
37
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
+ import { currentVehicleClientTarget } from "./vehicle-target.js";
39
+
40
+ /** Already covered by their own dedicated, approval-aware tools in tools.ts -- see this file's own doc comment. */
41
+ const EXCLUDED_OPERATIONS = new Set(["package.search", "package.info"]);
42
+
43
+ function withoutExcludedOperations(client: VehicleClient): VehicleClient {
44
+ return {
45
+ ...client,
46
+ async manifest(): Promise<VehicleManifest> {
47
+ const manifest = await client.manifest();
48
+ return { ...manifest, operations: manifest.operations.filter((operation) => !EXCLUDED_OPERATIONS.has(operation.name)) };
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Illustrative starting core set, not fixed -- tune from real usage the same way pi-papyrus's own
55
+ * CORE_OPERATIONS comment invites. installed/updates/pi.status are the read-only questions a
56
+ * session asks most often without first needing tools_man ("what's installed", "what's stale",
57
+ * "is Pi itself up to date"); everything else (catalog, index, check, pack, score, setup.plan,
58
+ * package.security.get, resources.list, advisories.scan, doctor.run, package.updates.project)
59
+ * boots inactive, reachable via tools_list/tools_man.
60
+ */
61
+ const CORE_OPERATIONS = ["package.installed", "package.updates", "pi.status"];
62
+
63
+ export async function registerPackedVehicle(pi: ExtensionAPI): Promise<void> {
64
+ const target = currentVehicleClientTarget();
65
+ if (!target) return;
66
+ try {
67
+ const client = withoutExcludedOperations(
68
+ createReconnectingVehicleClient(async () => {
69
+ const resolved = currentVehicleClientTarget();
70
+ if (!resolved) throw new Error("Packed daemon is not running");
71
+ return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
72
+ }),
73
+ );
74
+ await registerVehicleTools(pi, client, {
75
+ permissions: ["packed:read"],
76
+ principal: { id: "pi-packed" },
77
+ shell: { coreOperations: CORE_OPERATIONS },
78
+ });
79
+ } catch {
80
+ // Daemon state is stale/unreachable -- degrade silently, matching
81
+ // pi-papyrus's registerNotesVehicle and pi-packed's own natives.updates()
82
+ // tolerance in index.ts's session_start handler.
83
+ }
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.21.13",
3
+ "version": "0.21.15",
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.2.0",
33
33
  "@danypops/vehicle-client": "^0.5.2",
34
- "@danypops/vehicle-client-pi": "^0.16.9",
34
+ "@danypops/vehicle-client-pi": "^0.18.4",
35
35
  "@danypops/vehicle-core": "^0.12.3",
36
36
  "@danypops/vehicle-server": "^0.17.1",
37
37
  "jiti": "^2.7.0",
@@ -54,6 +54,21 @@ export interface InstallValidator {
54
54
  validate(source: string): Promise<InstallValidationResult>;
55
55
  }
56
56
 
57
+ /**
58
+ * Shared with ExecInstaller.install() (the single ad hoc path, which validates then commits
59
+ * itself) and SetupManager.apply() (the batch path, which validates every package change
60
+ * concurrently via Installer.validate() before ever calling installOnly() on any of them) --
61
+ * one formatting rule for "why was this install refused", not two copies that could drift.
62
+ */
63
+ export function assertInstallValidationOk(validation: InstallValidationResult): void {
64
+ if (validation.ok) return;
65
+ const detail = validation.extensions
66
+ .filter((extension) => !extension.ok)
67
+ .map((extension) => `${extension.path}: ${extension.message}`)
68
+ .join("; ");
69
+ throw new Error(`install refused -- ${detail || validation.message || "extension failed a headless load check"}`);
70
+ }
71
+
57
72
  const DEFAULT_LOAD_TIMEOUT_MS = 8_000;
58
73
  const MAX_LOAD_TIMEOUT_MS = 30_000;
59
74
  const PACK_TIMEOUT_MS = 30_000;
@@ -81,7 +96,10 @@ interface CommandResult {
81
96
  }
82
97
 
83
98
  async function runCommand(command: string[], cwd: string, timeoutMs: number): Promise<CommandResult> {
84
- const proc = Bun.spawn(command, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
99
+ // env explicit, not Bun.spawn's own default-inherited env -- see install.ts's ExecInstaller.run()
100
+ // for why a runtime process.env mutation (e.g. a caller redirecting Pi's home directory) must be
101
+ // threaded through explicitly rather than trusted to Bun's own default inheritance.
102
+ const proc = Bun.spawn(command, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: process.env });
85
103
  let timedOut = false;
86
104
  const timer = setTimeout(() => {
87
105
  timedOut = true;
@@ -322,7 +322,23 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
322
322
  const name = url.searchParams.get("name") ?? "";
323
323
  if (!name) return err(400, "missing name");
324
324
  try {
325
- return json(await deps.reg.info(name));
325
+ const info = await deps.reg.info(name);
326
+ // The one single-package, user-facing call site (pkg_info / the Find tab's "i"
327
+ // inspector) -- deliberately NOT inside Registry.info() itself, which every bulk
328
+ // caller (build-index.ts, score.ts, setup.ts, cli.ts, publish.ts) also shares.
329
+ // build-index.ts's own doc comment documents a real incident: calling downloads()
330
+ // per package at catalog scale (thousands of entries) hits npm's downloads API 429s
331
+ // within minutes -- exactly what baking this into info() itself would reintroduce
332
+ // for every bulk consumer. One package, on demand, interactively, is a fundamentally
333
+ // different volume of call than a background catalog scan. Both enrichments are
334
+ // individually tolerant of their own failure (same rule info()'s own README fetch
335
+ // already follows) -- a slow or unavailable api.npmjs.org must never fail the whole
336
+ // inspect.
337
+ const [modified, downloads] = await Promise.all([
338
+ deps.reg.modifiedAt?.(name).catch(() => undefined),
339
+ deps.reg.downloads?.(name).catch(() => undefined),
340
+ ]);
341
+ return json({ ...info, modified: modified ?? info.modified, downloads: downloads ?? info.downloads });
326
342
  } catch (e) {
327
343
  return err(502, e instanceof Error ? e.message : String(e));
328
344
  }
@@ -1,10 +1,21 @@
1
1
  /** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
2
2
 
3
3
  import { join } from "node:path";
4
- import { HeadlessInstallValidator, type InstallValidator } from "../adoption/install-validation.ts";
4
+ import type { Logger } from "@danypops/vehicle-server/logging";
5
+ import {
6
+ assertInstallValidationOk,
7
+ HeadlessInstallValidator,
8
+ type InstallValidationResult,
9
+ type InstallValidator,
10
+ } from "../adoption/install-validation.ts";
11
+ import { createLogger } from "../shared/log.ts";
5
12
  import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
6
13
  import type { Installer, UpdateOutcome } from "./package.ts";
7
14
 
15
+ function round1(ms: number): number {
16
+ return Math.round(ms * 10) / 10;
17
+ }
18
+
8
19
  /** Bare npm package name (for `packed remove`). */
9
20
  export const NAME_RE = /^(@[A-Za-z0-9._-]+\/)?[A-Za-z0-9._-]+$/;
10
21
 
@@ -22,10 +33,27 @@ export class ExecInstaller implements Installer {
22
33
  private piHome = defaultPiHome(),
23
34
  private validator: InstallValidator = new HeadlessInstallValidator(),
24
35
  private npmBin = defaultNpmBin(),
36
+ /**
37
+ * Instrumentation only -- never gates behavior. Each install()/update() call logs one
38
+ * "install timing"/"update timing" debug line breaking down where its wall-clock time went
39
+ * (validateMs/installMs/reresolveMs, plus totalMs): the real, previously-invisible cost of
40
+ * installing/updating several packages one at a time (see SetupManager.apply()'s own
41
+ * sequential loop) is dominated by reresolveDependencyTree() -- a FULL `npm install` at
42
+ * piHome/npm re-run after every single package, not just the newly touched one. Surfacing
43
+ * that split here is what let service/test/perf/multi-install.perf.test.ts quantify it
44
+ * against a real daemon instead of guessing from wall-clock totals alone.
45
+ */
46
+ private readonly logger: Logger = createLogger("install"),
25
47
  ) {}
26
48
 
27
49
  private async run(args: string[]): Promise<string> {
28
- const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe" });
50
+ // env is explicit, not Bun.spawn's own default-inherited env: a caller that redirects Pi's
51
+ // home via a runtime process.env mutation (e.g. PI_CODING_AGENT_DIR, set after this process
52
+ // already started -- confirmed live from service/test/perf/multi-install.perf.test.ts) needs
53
+ // that reflected in the CURRENT process.env object, not whatever snapshot Bun's own default
54
+ // inheritance captured. Silently missing this sent a real run straight into the developer's
55
+ // actual ~/.pi/agent instead of the intended isolated temp directory.
56
+ const proc = Bun.spawn([this.bin, ...args], { stdout: "pipe", stderr: "pipe", env: process.env });
29
57
  const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
30
58
  const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
31
59
  const code = await proc.exited;
@@ -42,28 +70,70 @@ export class ExecInstaller implements Installer {
42
70
  * resolution silently walks up to a stale copy nothing wanted. A full
43
71
  * `npm install` (no args) at piHome/npm is npm's own documented reliable
44
72
  * fix: it re-resolves the whole tree, not just the last-touched package.
73
+ *
74
+ * Public (not private) so a batch caller -- see SetupManager.apply() -- can call this ONCE
75
+ * after committing every package in a batch via installOnly(), instead of install()/update()
76
+ * each calling it once per package. Safe to call as often as today regardless: re-resolving an
77
+ * already-consistent tree is a cheap no-op for npm, never itself a source of drift.
45
78
  */
46
- private async reresolveDependencyTree(): Promise<string> {
79
+ async reresolveDependencyTree(): Promise<string> {
80
+ const t0 = performance.now();
47
81
  const cwd = join(this.piHome, "npm");
48
- const proc = Bun.spawn([this.npmBin, "install"], { cwd, stdout: "pipe", stderr: "pipe" });
82
+ // See run()'s own comment: env is explicit for the identical reason.
83
+ const proc = Bun.spawn([this.npmBin, "install"], { cwd, stdout: "pipe", stderr: "pipe", env: process.env });
49
84
  const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
50
85
  const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
51
86
  const code = await proc.exited;
52
87
  if (code !== 0) throw new Error(`npm install failed to re-resolve the dependency tree at ${cwd} (exit ${code}): ${out || "no output"}`);
88
+ this.logger.debug("reresolve timing", { reresolveMs: round1(performance.now() - t0) });
53
89
  return out;
54
90
  }
55
91
 
56
- async install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
57
- const validation = await this.validator.validate(source);
58
- if (!validation.ok) {
59
- const detail = validation.extensions
60
- .filter((extension) => !extension.ok)
61
- .map((extension) => `${extension.path}: ${extension.message}`)
62
- .join("; ");
63
- throw new Error(`install refused -- ${detail || validation.message || "extension failed a headless load check"}`);
64
- }
92
+ /**
93
+ * Safe to run CONCURRENTLY across many sources -- see Installer.validate's own doc comment.
94
+ * Delegates to whatever InstallValidator this instance was constructed with (real:
95
+ * HeadlessInstallValidator, stages into its own throwaway temp dir per call).
96
+ */
97
+ async validate(source: string): Promise<InstallValidationResult> {
98
+ const t0 = performance.now();
99
+ const result = await this.validator.validate(source);
100
+ this.logger.debug("validate timing", { source, validateMs: round1(performance.now() - t0) });
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * The real `pi install` mutation alone -- no validation (call validate() first; install()
106
+ * does both, in order, for a single ad hoc caller), no trailing reresolveDependencyTree() (a
107
+ * batch caller defers that to once per batch, not once per package). MUST be awaited
108
+ * sequentially across a batch, never run concurrently -- see Installer.installOnly's own doc
109
+ * comment for the confirmed settings.json/package.json corruption a naive Promise.all here
110
+ * produces.
111
+ */
112
+ async installOnly(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
113
+ const t0 = performance.now();
65
114
  const output = await this.run(["install", ...(options?.local ? ["-l"] : []), source]);
115
+ this.logger.debug("installOnly timing", { source, installMs: round1(performance.now() - t0) });
116
+ return output;
117
+ }
118
+
119
+ async install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
120
+ const t0 = performance.now();
121
+ const validation = await this.validate(source);
122
+ const validateMs = performance.now() - t0;
123
+ assertInstallValidationOk(validation);
124
+ const t1 = performance.now();
125
+ const output = await this.installOnly(source, options);
126
+ const installMs = performance.now() - t1;
127
+ const t2 = performance.now();
66
128
  await this.reresolveDependencyTree();
129
+ const reresolveMs = performance.now() - t2;
130
+ this.logger.debug("install timing", {
131
+ source,
132
+ validateMs: round1(validateMs),
133
+ installMs: round1(installMs),
134
+ reresolveMs: round1(reresolveMs),
135
+ totalMs: round1(performance.now() - t0),
136
+ });
67
137
  return output;
68
138
  }
69
139
 
@@ -72,10 +142,21 @@ export class ExecInstaller implements Installer {
72
142
  }
73
143
 
74
144
  async update(source: string, _options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome> {
145
+ const t0 = performance.now();
75
146
  const pinned = isPinnedNpmSource(source);
76
147
  const previousVersion = readResolvedVersion(this.piHome, source);
148
+ const t1 = performance.now();
77
149
  const output = await this.run(["update", "--extension", source]);
150
+ const updateMs = performance.now() - t1;
151
+ const t2 = performance.now();
78
152
  await this.reresolveDependencyTree();
153
+ const reresolveMs = performance.now() - t2;
154
+ this.logger.debug("update timing", {
155
+ source,
156
+ updateMs: round1(updateMs),
157
+ reresolveMs: round1(reresolveMs),
158
+ totalMs: round1(performance.now() - t0),
159
+ });
79
160
  const currentVersion = readResolvedVersion(this.piHome, source);
80
161
  // Only trust a "nothing changed" conclusion when we actually read a
81
162
  // real version both before and after (npm source, resolvable in
@@ -5,6 +5,13 @@
5
5
  * shells out to pi).
6
6
  */
7
7
 
8
+ // InstallValidationResult is a plain, adapter-neutral DTO (ok/source/extensions/message) --
9
+ // imported type-only (erased at compile time) from the one adapter that currently produces it
10
+ // (HeadlessInstallValidator) rather than duplicating its shape here and risking drift. Installer
11
+ // itself stays a pure port: nothing here creates a runtime dependency on install-validation.ts's
12
+ // own npm-pack/tar/subprocess machinery.
13
+ import type { InstallValidationResult } from "../adoption/install-validation.ts";
14
+
8
15
  export type PackageVerification = "keyword-only" | "manifest" | "conventional";
9
16
 
10
17
  export interface PackageEvidence {
@@ -30,6 +37,10 @@ export interface Pkg {
30
37
  version: string;
31
38
  description?: string;
32
39
  date?: string;
40
+ /** From npm's own search API response (each hit already carries this inline -- no extra
41
+ * request per result, unlike PkgInfo.downloads, which needs its own dedicated fetch for a
42
+ * single already-known package). See HttpRegistry.searchPage(). */
43
+ downloads?: DownloadObservations;
33
44
  packageEvidence?: PackageEvidence;
34
45
  publication?: PublicationEvidence;
35
46
  }
@@ -104,6 +115,30 @@ export interface Installer {
104
115
  install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
105
116
  remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
106
117
  update(source: string, options?: { approved?: boolean; local?: boolean }): Promise<UpdateOutcome>;
118
+ /**
119
+ * Optional batch-oriented split of install(), for a caller installing several packages
120
+ * together (see SetupManager.apply()) -- a single ad hoc install() still does all three
121
+ * steps itself and needs none of this; every existing Installer implementation that omits
122
+ * these three keeps compiling and behaving exactly as before.
123
+ *
124
+ * validate() is safe to run CONCURRENTLY across many sources: each call stages into its own
125
+ * throwaway temp directory (see HeadlessInstallValidator) with zero shared state between
126
+ * packages. installOnly() does the real `pi install` mutation WITHOUT install()'s own
127
+ * trailing full-tree reresolve, so a batch caller can defer that to
128
+ * reresolveDependencyTree(), called ONCE after every package in the batch instead of once
129
+ * per package.
130
+ *
131
+ * installOnly() itself must stay SEQUENTIAL across a batch, never fanned out the way
132
+ * validate() can be: `pi install` does its own non-atomic read-modify-write of both
133
+ * settings.json and the npm project's package.json/package-lock.json in the single shared
134
+ * piHome, with no locking on either side (confirmed empirically -- three concurrent `pi
135
+ * install` calls against one piHome silently lost two of three entries from settings.json
136
+ * and npm/package.json, while all three packages' node_modules ended up physically present
137
+ * but untracked, exactly the state a later reresolveDependencyTree() would then prune).
138
+ */
139
+ validate?(source: string): Promise<InstallValidationResult>;
140
+ installOnly?(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
141
+ reresolveDependencyTree?(): Promise<string>;
107
142
  }
108
143
 
109
144
  export interface InstalledPkg {
@@ -89,6 +89,35 @@ export function resolvePackedClientPaths(options: PackedPathOptions = {}): Packe
89
89
  return { token: paths.token, handle: paths.handle, serviceDescriptor: paths.serviceDescriptor };
90
90
  }
91
91
 
92
+ export interface PackedVehicleClientTarget {
93
+ /** Base URL for the daemon's Vehicle-projected surface (see service.ts's createApp, which mounts
94
+ * createVehicleHttpApp() at /vehicle/* on this same port, alongside /api/v1/ops) -- @danypops/
95
+ * vehicle-client's RemoteVehicleClient mounts its own /vehicle/manifest, /vehicle/invoke,
96
+ * /vehicle/cancel routes under this. */
97
+ baseUrl: string;
98
+ token: string;
99
+ }
100
+
101
+ /**
102
+ * Narrow surface for a Vehicle-projected operation consumer -- same daemon, same handle file, same
103
+ * Bearer token every other Packed RPC call already uses (connectPackedClient reads both the same
104
+ * way). Returns undefined rather than throwing when the daemon has never started -- no handle/token
105
+ * on disk yet -- matching resolvePushChannelTarget/resolveVehicleClientTarget's own tolerance in
106
+ * @danypops/papyrus for the identical condition.
107
+ */
108
+ export function resolveVehicleClientTarget(paths = resolvePackedClientPaths()): PackedVehicleClientTarget | undefined {
109
+ const handle = readDaemonHandle(paths.handle);
110
+ if (!handle) return undefined;
111
+ let token: string;
112
+ try {
113
+ token = readFileSync(paths.token, "utf8").trim();
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ if (!/^[a-f0-9]{64}$/.test(token)) return undefined;
118
+ return { baseUrl: `http://${handle.host}:${handle.port}`, token };
119
+ }
120
+
92
121
  /** Checks Armada's authoritative desired fleet rather than native descriptor files. */
93
122
  function isPackedServiceInstalled(): boolean {
94
123
  return vehicleIsServiceInstalled(SERVICE_NAME, createNodeServiceInstallDeps());
@@ -8,11 +8,17 @@ export interface PublicationEvidence {
8
8
  provenanceUrl?: string;
9
9
  trustedPublisher: "verified" | "not-verified" | "unknown";
10
10
  }
11
+ export interface DownloadObservations {
12
+ weekly?: number;
13
+ monthly?: number;
14
+ observedAt: string;
15
+ }
11
16
  export interface PackageSummary {
12
17
  name: string;
13
18
  version: string;
14
19
  description?: string;
15
20
  date?: string;
21
+ downloads?: DownloadObservations;
16
22
  packageEvidence?: PackageEvidence;
17
23
  publication?: PublicationEvidence;
18
24
  }
@@ -71,10 +71,20 @@ export class HttpRegistry implements Registry {
71
71
  if (!res.ok) throw new Error(`npm search: HTTP ${res.status}`);
72
72
  const doc = (await res.json()) as {
73
73
  total?: number;
74
- objects?: { package?: { name?: string; version?: string; description?: string; date?: string } }[];
74
+ objects?: {
75
+ package?: { name?: string; version?: string; description?: string; date?: string };
76
+ // Already inline on every search hit -- confirmed live against the real registry, no
77
+ // separate api.npmjs.org/downloads/point/* call needed the way PkgInfo.downloads (one
78
+ // already-known package) requires. Fetching that per search RESULT would be an N+1
79
+ // (20 results = 20 extra requests); this is free.
80
+ downloads?: { weekly?: number; monthly?: number };
81
+ }[];
75
82
  };
83
+ const observedAt = new Date().toISOString();
76
84
  const results: Pkg[] = (doc.objects ?? []).flatMap((o) => {
77
85
  const p = o.package;
86
+ const weekly = boundedCount(o.downloads?.weekly);
87
+ const monthly = boundedCount(o.downloads?.monthly);
78
88
  return p?.name
79
89
  ? [
80
90
  {
@@ -82,6 +92,7 @@ export class HttpRegistry implements Registry {
82
92
  version: boundedString(p.version, 128) ?? "",
83
93
  description: boundedString(p.description, 512),
84
94
  date: boundedString(p.date, 64),
95
+ ...(weekly !== undefined || monthly !== undefined ? { downloads: { weekly, monthly, observedAt } } : {}),
85
96
  packageEvidence: {
86
97
  shape: "keyword-only" as const,
87
98
  verified: false,
@@ -130,6 +141,14 @@ export class HttpRegistry implements Registry {
130
141
  dist?: { unpackedSize?: number; integrity?: string; attestations?: { url?: string; provenance?: unknown } };
131
142
  };
132
143
  const manifestFields = v.pi ? Object.keys(v.pi).filter((key) => ["extensions", "skills", "prompts", "themes"].includes(key)) : [];
144
+ // Deliberately NOT modified/downloads here -- info() is shared by bulk callers
145
+ // (build-index.ts, score.ts, setup.ts's resolvePackage, cli.ts, publish.ts), and
146
+ // build-index.ts's own doc comment documents a real incident: calling downloads() per
147
+ // package at catalog scale (thousands of entries) hits npm's downloads API 429s within
148
+ // minutes. Those bulk callers already opt into modifiedAt() explicitly themselves when they
149
+ // want it, and never call downloads() at all. The single-package, user-facing enrichment
150
+ // (pkg_info / the Find tab's inspector) lives one layer up, at service.ts's own "package.info"
151
+ // handler -- the one call site that's genuinely one-package-at-a-time, never bulk.
133
152
  const readme = await this.publishedReadme(encoded);
134
153
  return {
135
154
  name: boundedString(v.name, 214) ?? boundedString(name, 214)!,
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Type } from "typebox";
6
6
  import { Compile } from "typebox/compile";
7
7
  import type { Diagnostic } from "../adoption/check.ts";
8
+ import { assertInstallValidationOk } from "../adoption/install-validation.ts";
8
9
  import { npmPackageName, readResolvedIntegrity, readResolvedVersion } from "../packages/installed.ts";
9
10
  import type { Installer, Registry } from "../packages/package.ts";
10
11
  import { writeJsonAtomic } from "../shared/atomic-json.ts";
@@ -644,9 +645,33 @@ export class SetupManager {
644
645
  (item): item is Extract<SetupOperation, { kind: "install-package" | "update-package" }> =>
645
646
  item.kind === "install-package" || item.kind === "update-package",
646
647
  );
647
- for (const operation of packageChanges) {
648
+ // Batch mode requires all three -- see Installer.validate/installOnly/reresolveDependencyTree's
649
+ // own doc comments. Any Installer missing even one (e.g. a remote daemon proxy that only ever
650
+ // implements the plain install()) falls all the way back to today's exact per-package install()
651
+ // loop below, never a partial mix of the two strategies.
652
+ const batch = this.installer.validate && this.installer.installOnly && this.installer.reresolveDependencyTree;
653
+ // Phase 1 (batch mode only): validate every package change CONCURRENTLY. Each call stages
654
+ // into its own throwaway temp directory with zero shared state between packages -- unlike
655
+ // the actual `pi install` mutation below, which must stay sequential (a real, confirmed
656
+ // race: concurrent `pi install` calls against one piHome silently lose entries from BOTH
657
+ // settings.json and npm/package.json, leaving node_modules with untracked "phantom" installs
658
+ // a later reresolveDependencyTree() would then prune).
659
+ const validations = batch
660
+ ? await Promise.allSettled(packageChanges.map((operation) => this.installer.validate!(operation.source)))
661
+ : undefined;
662
+ // Phase 2: sequential commit -- unavoidable while `pi install` itself owns unlocked,
663
+ // shared-file mutation of piHome's settings.json and npm project. In batch mode this calls
664
+ // installOnly() (no per-package reresolve); otherwise the original install() (validates,
665
+ // installs, AND re-resolves the whole tree, all per package) exactly as before.
666
+ for (const [index, operation] of packageChanges.entries()) {
648
667
  try {
649
- const output = await this.installer.install(operation.source, { local: operation.scope === "project" });
668
+ const validation = validations?.[index];
669
+ if (validation?.status === "rejected") throw validation.reason;
670
+ if (validation?.status === "fulfilled") assertInstallValidationOk(validation.value);
671
+ const output =
672
+ batch && this.installer.installOnly
673
+ ? await this.installer.installOnly(operation.source, { local: operation.scope === "project" })
674
+ : await this.installer.install(operation.source, { local: operation.scope === "project" });
650
675
  outcomes.push({
651
676
  kind: operation.kind,
652
677
  target: operation.source,
@@ -676,6 +701,29 @@ export class SetupManager {
676
701
  };
677
702
  }
678
703
  }
704
+ // Phase 3 (batch mode only): re-resolve ONCE for the whole batch instead of once per
705
+ // package. Only reached once every packageChanges entry has already succeeded above (a
706
+ // failure already returned early) -- never runs on a partially-applied batch.
707
+ if (batch && this.installer.reresolveDependencyTree && packageChanges.length > 0) {
708
+ try {
709
+ await this.installer.reresolveDependencyTree();
710
+ } catch (error) {
711
+ return {
712
+ ok: false,
713
+ manifestPath: plan.manifestPath,
714
+ operations: outcomes,
715
+ reloadRequired: true,
716
+ diagnostics: [
717
+ diagnostic(
718
+ "SETUP_APPLY_FAILED",
719
+ "error",
720
+ "reresolveDependencyTree",
721
+ `batch dependency re-resolution failed after every package installed/updated successfully: ${error instanceof Error ? error.message : String(error)}`,
722
+ ),
723
+ ],
724
+ };
725
+ }
726
+ }
679
727
  const root = dirname(plan.manifestPath);
680
728
  for (const scope of ["global", "project"] as const) {
681
729
  const changes = plan.operations.filter(