@danypops/pi-packed 0.21.13 → 0.21.14

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.
@@ -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(
@@ -15,14 +15,24 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { ExecInstaller } from "../src/packages/install.ts";
18
+ import { createLogger } from "../src/shared/log.ts";
18
19
 
19
20
  /**
20
21
  * A fake `pi` binary: always prints "Updated <source>" and exits 0 (matching
21
22
  * real `pi`'s observed behavior for a no-op). When `rewrite` is given, it
22
23
  * additionally overwrites that exact package's on-disk version -- letting a
23
24
  * test simulate a genuine version change on demand. The rewrite target is
24
- * baked into the script file itself (not an env var) so it is immune to
25
- * Bun.spawn's default env snapshot not picking up late process.env writes.
25
+ * baked into the script file itself (not an env var) rather than read from
26
+ * process.env at spawn time, keeping these fixtures independent of whichever
27
+ * way ExecInstaller happens to thread its own env through -- see the
28
+ * `run()`/`reresolveDependencyTree() thread env explicitly` describe block
29
+ * below for a dedicated test of that env-threading behavior itself, added
30
+ * after service/test/perf/multi-install.perf.test.ts caught it live: a
31
+ * runtime process.env mutation (redirecting Pi's home directory) silently
32
+ * never reached the spawned `pi`/`npm` children because Bun.spawn's own
33
+ * default env inheritance doesn't pick up a mutation made after this
34
+ * process's own startup snapshot -- only an explicit `env: process.env`
35
+ * option re-reads the current object.
26
36
  */
27
37
  const roots: string[] = [];
28
38
  afterEach(() => {
@@ -206,3 +216,100 @@ describe("ExecInstaller — forces full dependency re-resolution, not just the t
206
216
  await expect(installer.update("npm:plain")).rejects.toThrow(/npm install failed to re-resolve/);
207
217
  });
208
218
  });
219
+
220
+ describe("ExecInstaller — timing instrumentation (see service/test/perf/multi-install.perf.test.ts for a real multi-package measurement)", () => {
221
+ it("install() logs a validateMs/installMs/reresolveMs/totalMs breakdown, not just a pass/fail result", async () => {
222
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
223
+ const bin = writeFakePi(scriptDir);
224
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
225
+ const piHome = writePiHome();
226
+ const lines: string[] = [];
227
+ const logger = createLogger("test", (line) => lines.push(line), "debug");
228
+ const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin, logger);
229
+
230
+ await installer.install("npm:plain");
231
+
232
+ const timing = lines.map((line) => JSON.parse(line)).find((entry) => entry.msg === "install timing");
233
+ expect(timing).toBeDefined();
234
+ expect(timing.source).toBe("npm:plain");
235
+ for (const field of ["validateMs", "installMs", "reresolveMs", "totalMs"]) {
236
+ expect(typeof timing[field]).toBe("number");
237
+ expect(timing[field]).toBeGreaterThanOrEqual(0);
238
+ }
239
+ // totalMs is the whole call's own wall clock, not just one phase re-labeled --
240
+ // bounded above by itself plus a small scheduling-noise allowance, never equal to
241
+ // a single phase alone once every phase is genuinely counted once.
242
+ expect(timing.totalMs).toBeGreaterThanOrEqual(timing.validateMs + timing.installMs + timing.reresolveMs - 1);
243
+ });
244
+
245
+ it("update() logs an updateMs/reresolveMs/totalMs breakdown", async () => {
246
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
247
+ const bin = writeFakePi(scriptDir);
248
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
249
+ const piHome = writePiHome({ plain: "0.5.0" });
250
+ const lines: string[] = [];
251
+ const logger = createLogger("test", (line) => lines.push(line), "debug");
252
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin, logger);
253
+
254
+ await installer.update("npm:plain");
255
+
256
+ const timing = lines.map((line) => JSON.parse(line)).find((entry) => entry.msg === "update timing");
257
+ expect(timing).toBeDefined();
258
+ expect(timing.source).toBe("npm:plain");
259
+ for (const field of ["updateMs", "reresolveMs", "totalMs"]) {
260
+ expect(typeof timing[field]).toBe("number");
261
+ expect(timing[field]).toBeGreaterThanOrEqual(0);
262
+ }
263
+ });
264
+ });
265
+
266
+ /**
267
+ * A fake binary that dumps one specific env var's CURRENT value to `logFile` -- proves a
268
+ * process.env mutation made at runtime (after this test process's own startup, the exact shape
269
+ * of a caller redirecting Pi's home directory) actually reaches the spawned child, rather than
270
+ * whatever snapshot Bun.spawn's own default env inheritance captured earlier.
271
+ */
272
+ function writeEnvDumpBinary(dir: string, name: string, varName: string, logFile: string): string {
273
+ const script = join(dir, name);
274
+ writeFileSync(script, ["#!/usr/bin/env bash", `printf '%s' "\$${varName}" > '${logFile}'`, "exit 0"].join("\n"));
275
+ chmodSync(script, 0o755);
276
+ return script;
277
+ }
278
+
279
+ describe("ExecInstaller — run()/reresolveDependencyTree() thread the CURRENT process.env through explicitly", () => {
280
+ const MARKER = "PACKED_TEST_ENV_MARKER";
281
+
282
+ afterEach(() => {
283
+ delete process.env[MARKER];
284
+ });
285
+
286
+ it("install()'s pi spawn sees an env var set on process.env after this process already started", async () => {
287
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-env-")));
288
+ const piLog = join(scriptDir, "pi-env.log");
289
+ const bin = writeEnvDumpBinary(scriptDir, "fake-pi-env", MARKER, piLog);
290
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
291
+ const piHome = writePiHome();
292
+ const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
293
+
294
+ // Mutated well after this test process's own startup -- exactly what redirecting Pi's home
295
+ // via PI_CODING_AGENT_DIR at runtime looks like from ExecInstaller's own point of view.
296
+ process.env[MARKER] = "set-after-startup";
297
+ await installer.install("npm:plain");
298
+
299
+ expect(readFileSync(piLog, "utf8")).toBe("set-after-startup");
300
+ });
301
+
302
+ it("reresolveDependencyTree()'s npm spawn sees the same runtime env mutation", async () => {
303
+ const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-env-")));
304
+ const bin = writeFakePi(scriptDir);
305
+ const npmLog = join(scriptDir, "npm-env.log");
306
+ const npmBin = writeEnvDumpBinary(scriptDir, "fake-npm-env", MARKER, npmLog);
307
+ const piHome = writePiHome();
308
+ const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
309
+
310
+ process.env[MARKER] = "reresolve-sees-this-too";
311
+ await installer.install("npm:plain");
312
+
313
+ expect(readFileSync(npmLog, "utf8")).toBe("reresolve-sees-this-too");
314
+ });
315
+ });
@@ -9,6 +9,9 @@ import { HttpRegistry } from "../src/registry/registry.ts";
9
9
  import { createApp } from "../src/daemon/service.ts";
10
10
 
11
11
  const TOKEN = "e".repeat(64);
12
+ const PUBLISHED_MODIFIED = "2024-03-01T00:00:00.000Z";
13
+ const WEEKLY_DOWNLOADS = 12_345;
14
+ const MONTHLY_DOWNLOADS = 54_321;
12
15
  const PUBLISHED_README = `# @danypops/enigma
13
16
 
14
17
  Encrypted credential vault and supervisor daemon. Holds delegated OAuth/API
@@ -56,17 +59,28 @@ describe("published npm metadata E2E", () => {
56
59
  });
57
60
  }
58
61
  if (url.pathname === "/%40danypops/enigma") {
62
+ // One response body serves both callers that hit this exact path: HttpRegistry's own
63
+ // publishedReadme() (Accept: application/json, reads .readme) and modifiedAt() (Accept:
64
+ // application/vnd.npm.install-v1+json, reads .modified) -- real npm content-negotiates
65
+ // two different document shapes for the same URL; this fixture keeps it simple by
66
+ // returning both fields unconditionally, since each caller only ever reads its own.
59
67
  return Response.json({
60
68
  name: "@danypops/enigma",
61
69
  "dist-tags": { latest: "0.22.1" },
62
70
  readme: PUBLISHED_README,
71
+ modified: PUBLISHED_MODIFIED,
63
72
  });
64
73
  }
74
+ if (url.pathname === "/downloads/point/last-week/%40danypops/enigma") return Response.json({ downloads: WEEKLY_DOWNLOADS });
75
+ if (url.pathname === "/downloads/point/last-month/%40danypops/enigma") return Response.json({ downloads: MONTHLY_DOWNLOADS });
65
76
  return new Response("not found", { status: 404 });
66
77
  },
67
78
  });
68
79
  const app = createApp({
69
- reg: new HttpRegistry(`http://127.0.0.1:${npmServer.port}`, 20, 0, 1),
80
+ // downloadsBase points at the SAME fake server as the registry itself -- real npm splits
81
+ // these across two different hosts (registry.npmjs.org vs api.npmjs.org), but nothing here
82
+ // cares which host a request lands on, only that it's never the real internet.
83
+ reg: new HttpRegistry(`http://127.0.0.1:${npmServer.port}`, 20, 0, 1, `http://127.0.0.1:${npmServer.port}`),
70
84
  inst: new NoopInstaller(),
71
85
  token: TOKEN,
72
86
  stateDir,
@@ -80,15 +94,21 @@ describe("published npm metadata E2E", () => {
80
94
  rmSync(stateDir, { recursive: true, force: true });
81
95
  });
82
96
 
83
- it("preserves an npm-published README through HttpRegistry, daemon RPC, and PackedClient", async () => {
97
+ it("preserves an npm-published README through HttpRegistry, daemon RPC, and PackedClient, plus the daemon's own package.info release-date/downloads enrichment", async () => {
84
98
  const client = new PackedClient(`http://127.0.0.1:${daemonServer.port}`, TOKEN);
85
99
  const info = await client.info("@danypops/enigma");
86
100
 
87
- expect(npmRequests).toHaveLength(2);
101
+ // 2 from HttpRegistry.info() itself (latest + readme) + 1 from the daemon's own
102
+ // package.info enrichment calling modifiedAt() (same path as readme, different Accept) + 2
103
+ // from downloads() (last-week, last-month) -- see service.ts's "/info" handler.
104
+ expect(npmRequests).toHaveLength(5);
88
105
  expect(npmRequests).toEqual(
89
106
  expect.arrayContaining([
90
107
  { path: "/%40danypops/enigma/latest", accept: "application/json" },
91
108
  { path: "/%40danypops/enigma", accept: "application/json" },
109
+ { path: "/%40danypops/enigma", accept: "application/vnd.npm.install-v1+json" },
110
+ { path: "/downloads/point/last-week/%40danypops/enigma", accept: "*/*" },
111
+ { path: "/downloads/point/last-month/%40danypops/enigma", accept: "*/*" },
92
112
  ]),
93
113
  );
94
114
  expect(info).toMatchObject({
@@ -97,6 +117,8 @@ describe("published npm metadata E2E", () => {
97
117
  license: "MIT",
98
118
  readmeAvailable: true,
99
119
  readme: PUBLISHED_README,
120
+ modified: PUBLISHED_MODIFIED,
121
+ downloads: { weekly: WEEKLY_DOWNLOADS, monthly: MONTHLY_DOWNLOADS },
100
122
  });
101
123
  });
102
124
  });