@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.
- package/dist/client.d.ts +16 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +6 -0
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/index.ts +9 -1
- package/extension/src/model.ts +35 -0
- package/extension/src/package-inspector.ts +12 -0
- package/extension/src/tabs/discover.ts +14 -1
- package/extension/src/tools.ts +31 -20
- package/extension/src/vehicle-target.ts +24 -0
- package/extension/src/vehicle-tools.ts +84 -0
- package/package.json +2 -2
- package/service/src/adoption/install-validation.ts +19 -1
- package/service/src/daemon/service.ts +17 -1
- package/service/src/packages/install.ts +94 -13
- package/service/src/packages/package.ts +35 -0
- package/service/src/public/client.ts +29 -0
- package/service/src/public/protocol.ts +6 -0
- package/service/src/registry/registry.ts +20 -1
- package/service/src/setup/setup.ts +50 -2
- package/service/test/install.test.ts +109 -2
- package/service/test/npm-metadata-e2e.test.ts +25 -3
- package/service/test/perf/multi-install.perf.test.ts +222 -0
- package/service/test/setup.test.ts +160 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
79
|
+
async reresolveDependencyTree(): Promise<string> {
|
|
80
|
+
const t0 = performance.now();
|
|
47
81
|
const cwd = join(this.piHome, "npm");
|
|
48
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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?: {
|
|
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
|
-
|
|
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
|
|
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)
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|