@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.
- 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/approval/reload.ts +51 -2
- 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 +27 -3
- package/extension/src/tool-output.ts +14 -1
- package/extension/src/tools.ts +55 -28
- 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
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|