@danypops/pi-packed 0.21.10 → 0.21.12
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/package.json +2 -2
- package/service/src/adoption/install-validation.ts +67 -3
- package/service/src/adoption/verify-load-paths-child.mjs +55 -0
- package/service/test/fixtures/install-validation/dep-package/index.js +3 -0
- package/service/test/fixtures/install-validation/dep-package/package.json +7 -0
- package/service/test/install-validation.test.ts +94 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.12",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@danypops/packed": "^0.7.0",
|
|
32
32
|
"@danypops/pi-extension-harness": "^0.2.0",
|
|
33
33
|
"@danypops/vehicle-client": "^0.5.2",
|
|
34
|
+
"@danypops/vehicle-client-pi": "^0.16.9",
|
|
34
35
|
"@danypops/vehicle-core": "^0.12.3",
|
|
35
36
|
"@danypops/vehicle-server": "^0.17.1",
|
|
36
37
|
"jiti": "^2.7.0",
|
|
@@ -40,7 +41,6 @@
|
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@danypops/pi-tui-harness": "^0.0.1",
|
|
43
|
-
"@danypops/vehicle-client-pi": "^0.16.2",
|
|
44
44
|
"@types/semver": "^7.7.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
* for ExecInstaller.install(), so a package that crashes at registration
|
|
4
4
|
* time never gets fully wired into ~/.pi/npm and ~/.pi/agent in the first
|
|
5
5
|
* place. Stages the real npm tarball into a throwaway temp dir (never the
|
|
6
|
-
* live piHome),
|
|
6
|
+
* live piHome), installs its own declared dependencies there, then runs
|
|
7
|
+
* @danypops/pi-extension-harness's own mock-pi-cli
|
|
7
8
|
* subprocess -- a real, isolated process exercising the same production
|
|
8
9
|
* jiti load path Pi's own binary uses -- against every declared
|
|
9
|
-
* pi.extensions entry.
|
|
10
|
+
* pi.extensions entry. Also runs @danypops/vehicle-client-pi's
|
|
11
|
+
* pi-load-harness (native ESM, jiti tryNative:false) in its own isolated
|
|
12
|
+
* subprocess as non-gating, observational evidence alongside that gating
|
|
13
|
+
* check -- see ExtensionLoadResult.additionalLoadPaths.
|
|
10
14
|
*/
|
|
11
15
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
12
16
|
import { createRequire } from "node:module";
|
|
@@ -17,6 +21,26 @@ export interface ExtensionLoadResult {
|
|
|
17
21
|
path: string;
|
|
18
22
|
ok: boolean;
|
|
19
23
|
message?: string;
|
|
24
|
+
/** Non-gating: @danypops/vehicle-client-pi's pi-load-harness checks two
|
|
25
|
+
* further Pi extension load paths (native ESM, jiti tryNative:false)
|
|
26
|
+
* beyond the one path above (jiti tryNative:true) already gates install.
|
|
27
|
+
* Surfaced as observational evidence only, for two independent reasons:
|
|
28
|
+
* (1) native-esm can legitimately fail for a perfectly loadable extension
|
|
29
|
+
* on an older Node without TS type-stripping support, with no real-world
|
|
30
|
+
* signal yet on how often that's a false alarm; (2) this check only
|
|
31
|
+
* verifies the module *imports* cleanly -- unlike the gating check above,
|
|
32
|
+
* it never calls the extension's exported factory, so it cannot catch a
|
|
33
|
+
* factory that throws once actually registered (confirmed directly: the
|
|
34
|
+
* BROKEN test fixture, whose factory always throws, reports ok:true on
|
|
35
|
+
* every one of these paths). Complementary evidence for an import-time
|
|
36
|
+
* failure class, not a broader replacement for the gating check. */
|
|
37
|
+
additionalLoadPaths?: PiLoadPathResult[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PiLoadPathResult {
|
|
41
|
+
path: "native-esm" | "jiti-try-native-false" | "jiti-try-native-true";
|
|
42
|
+
ok: boolean;
|
|
43
|
+
error?: string;
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
export interface InstallValidationResult {
|
|
@@ -33,6 +57,7 @@ export interface InstallValidator {
|
|
|
33
57
|
const DEFAULT_LOAD_TIMEOUT_MS = 8_000;
|
|
34
58
|
const MAX_LOAD_TIMEOUT_MS = 30_000;
|
|
35
59
|
const PACK_TIMEOUT_MS = 30_000;
|
|
60
|
+
const INSTALL_TIMEOUT_MS = 60_000;
|
|
36
61
|
const MAX_EXTENSIONS = 20;
|
|
37
62
|
|
|
38
63
|
function bounded(value: number | undefined, fallback: number, maximum: number): number {
|
|
@@ -96,6 +121,19 @@ async function stageNpmTarball(spec: string, stageDir: string): Promise<StageRes
|
|
|
96
121
|
return { ok: true, root };
|
|
97
122
|
}
|
|
98
123
|
|
|
124
|
+
/** Installs the staged tarball's own declared dependencies -- npm pack +
|
|
125
|
+
* tar only extract the package's own files, never fetch what its
|
|
126
|
+
* package.json requires. --ignore-scripts keeps an untrusted candidate's
|
|
127
|
+
* lifecycle scripts from running during a headless check nobody explicitly
|
|
128
|
+
* approved; --omit=dev matches what the entry point actually needs at
|
|
129
|
+
* runtime (jiti loads its TS source directly, no separate build step). */
|
|
130
|
+
async function installStagedDependencies(root: string): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
131
|
+
const install = await runCommand(["npm", "install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], root, INSTALL_TIMEOUT_MS);
|
|
132
|
+
if (install.timedOut) return { ok: false, message: `npm install exceeded ${INSTALL_TIMEOUT_MS}ms installing the staged package's own dependencies` };
|
|
133
|
+
if (install.code !== 0) return { ok: false, message: (install.stderr.trim() || `npm install exited ${install.code}`).slice(0, 2_000) };
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
99
137
|
/** Runs pi-extension-harness's mock-pi-cli against one extension entry
|
|
100
138
|
* point, in its own load-only mode (--tool omitted): a real, isolated
|
|
101
139
|
* subprocess exercising Pi's own production jiti load path. */
|
|
@@ -136,6 +174,28 @@ export async function validateExtensionLoadsHeadless(entryPath: string, timeoutM
|
|
|
136
174
|
};
|
|
137
175
|
}
|
|
138
176
|
|
|
177
|
+
/** Runs the extra two Pi extension load paths @danypops/vehicle-client-pi's
|
|
178
|
+
* pi-load-harness knows about (native ESM, jiti tryNative:false) against
|
|
179
|
+
* one entry point, in their own isolated subprocess -- same trust boundary
|
|
180
|
+
* as validateExtensionLoadsHeadless, never inside the daemon process.
|
|
181
|
+
* Returns undefined (not a failure) when the probe subprocess itself
|
|
182
|
+
* couldn't run at all -- vehicle-client-pi is an optional enrichment here,
|
|
183
|
+
* not a hard requirement the way pi-extension-harness is. */
|
|
184
|
+
async function verifyAllLoadPathsHeadless(entryPath: string, timeoutMs?: number): Promise<PiLoadPathResult[] | undefined> {
|
|
185
|
+
const bound = bounded(timeoutMs, DEFAULT_LOAD_TIMEOUT_MS, MAX_LOAD_TIMEOUT_MS);
|
|
186
|
+
const childPath = new URL("verify-load-paths-child.mjs", import.meta.url).pathname;
|
|
187
|
+
const result = await runCommand(["node", childPath, "--extension", entryPath], tmpdir(), bound);
|
|
188
|
+
if (result.timedOut) return undefined;
|
|
189
|
+
const lastLine = result.stdout.trim().split("\n").at(-1);
|
|
190
|
+
if (!lastLine) return undefined;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(lastLine) as { results?: PiLoadPathResult[]; error?: string };
|
|
193
|
+
return Array.isArray(parsed.results) ? parsed.results : undefined;
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
139
199
|
/** Stages the real npm tarball in isolation and headlessly load-checks
|
|
140
200
|
* every pi.extensions entry it declares. A package with no pi.extensions
|
|
141
201
|
* (most npm packages) or a non-npm source has nothing to validate and
|
|
@@ -165,6 +225,9 @@ export class HeadlessInstallValidator implements InstallValidator {
|
|
|
165
225
|
: [];
|
|
166
226
|
if (declared.length === 0) return { ok: true, source, extensions: [] };
|
|
167
227
|
|
|
228
|
+
const installed = await installStagedDependencies(staged.root);
|
|
229
|
+
if (!installed.ok) return { ok: false, source, extensions: [], message: installed.message };
|
|
230
|
+
|
|
168
231
|
const extensions: ExtensionLoadResult[] = [];
|
|
169
232
|
for (const entry of declared) {
|
|
170
233
|
const entryPath = resolve(staged.root, entry);
|
|
@@ -176,7 +239,8 @@ export class HeadlessInstallValidator implements InstallValidator {
|
|
|
176
239
|
// path into a throwaway temp stage dir -- meaningful to a caller,
|
|
177
240
|
// matches what package.json itself says.
|
|
178
241
|
const loadResult = await validateExtensionLoadsHeadless(entryPath, this.timeoutMs);
|
|
179
|
-
|
|
242
|
+
const additionalLoadPaths = await verifyAllLoadPathsHeadless(entryPath, this.timeoutMs);
|
|
243
|
+
extensions.push({ ...loadResult, path: entry, ...(additionalLoadPaths ? { additionalLoadPaths } : {}) });
|
|
180
244
|
}
|
|
181
245
|
const ok = extensions.every((extension) => extension.ok);
|
|
182
246
|
return { ok, source, extensions, ...(ok ? {} : { message: "one or more declared extensions failed a headless load check" }) };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verify-load-paths-child.mjs — spawned in isolation by install-validation.ts
|
|
5
|
+
* to check a staged candidate's extension entry against every Pi extension
|
|
6
|
+
* load path @danypops/vehicle-client-pi's pi-load-harness knows about
|
|
7
|
+
* (native ESM, jiti tryNative:false, jiti tryNative:true) -- not just the
|
|
8
|
+
* single tryNative:true path mock-pi-cli's own headless check exercises.
|
|
9
|
+
*
|
|
10
|
+
* Runs as its own subprocess, same trust boundary as mock-pi-cli: the
|
|
11
|
+
* candidate's real code executes here, never inside the trusted daemon
|
|
12
|
+
* process. Uses jiti itself (the same technique mock-pi-cli.mjs already
|
|
13
|
+
* relies on) to import pi-load-harness's raw TypeScript source -- that
|
|
14
|
+
* package deliberately ships this subpath uncompiled, since its intended
|
|
15
|
+
* consumers already run through a TS-transforming toolchain.
|
|
16
|
+
*
|
|
17
|
+
* Accepts:
|
|
18
|
+
* --extension <path> candidate extension entry point to check
|
|
19
|
+
*
|
|
20
|
+
* Emits exactly one JSON line on stdout, then exits 0 (a probe failure is
|
|
21
|
+
* data, not a process failure -- the caller decides what a failing path
|
|
22
|
+
* means):
|
|
23
|
+
* { "results": [{ "path": "native-esm", "ok": true }, ...] }
|
|
24
|
+
* or, if the harness itself couldn't even be loaded/invoked:
|
|
25
|
+
* { "error": "..." }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createJiti } from "jiti";
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const get = (flag) => {
|
|
32
|
+
const i = args.indexOf(flag);
|
|
33
|
+
return i !== -1 ? args[i + 1] : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const extensionPath = get("--extension");
|
|
37
|
+
if (!extensionPath) {
|
|
38
|
+
process.stderr.write("--extension required\n");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emit(obj) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
48
|
+
const { verifyLoadableUnderPi } = await jiti.import("@danypops/vehicle-client-pi/pi-load-harness");
|
|
49
|
+
const results = await verifyLoadableUnderPi(extensionPath);
|
|
50
|
+
emit({ results });
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
emit({ error: err?.message ?? String(err) });
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* pass.
|
|
9
9
|
*/
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
|
-
import { mkdirSync, mkdtempSync } from "node:fs";
|
|
11
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,6 +21,39 @@ const FIXTURES = join(__dirname, "fixtures/install-validation");
|
|
|
21
21
|
const HEALTHY = join(FIXTURES, "healthy-package");
|
|
22
22
|
const BROKEN = join(FIXTURES, "broken-package");
|
|
23
23
|
const NO_MANIFEST = join(FIXTURES, "no-manifest-package");
|
|
24
|
+
const DEP_PACKAGE = join(FIXTURES, "dep-package");
|
|
25
|
+
|
|
26
|
+
/** Writes a fresh package into `dir` whose extension entry point has a real
|
|
27
|
+
* runtime import (`packed-fixture-dep`, a `file:` dependency resolvable
|
|
28
|
+
* fully offline) -- otherwise healthy, structurally identical to HEALTHY,
|
|
29
|
+
* except its one declared dependency must actually be installed into the
|
|
30
|
+
* staged tarball for its entry point to load at all. The file: target is
|
|
31
|
+
* an absolute path computed at test time (checkout-independent), so this
|
|
32
|
+
* can't be a static checked-in fixture the way HEALTHY/BROKEN are. */
|
|
33
|
+
function writePackageWithRealDependency(dir: string): void {
|
|
34
|
+
mkdirSync(join(dir, "extension"), { recursive: true });
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(dir, "package.json"),
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
name: "packed-install-validation-fixture-with-dependency",
|
|
39
|
+
version: "1.0.0",
|
|
40
|
+
private: true,
|
|
41
|
+
pi: { extensions: ["extension/index.ts"] },
|
|
42
|
+
dependencies: { "packed-fixture-dep": `file:${DEP_PACKAGE}` },
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
writeFileSync(
|
|
46
|
+
join(dir, "extension/index.ts"),
|
|
47
|
+
[
|
|
48
|
+
'import { greet } from "packed-fixture-dep";',
|
|
49
|
+
"",
|
|
50
|
+
"export default function withDependencyFixtureExtension(pi: { registerCommand: (name: string, def: unknown) => void }) {",
|
|
51
|
+
'\tpi.registerCommand("with-dependency-fixture", { description: greet(), handler: async () => {} });',
|
|
52
|
+
"}",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n"),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
24
57
|
|
|
25
58
|
describe("bareNpmSpec", () => {
|
|
26
59
|
it("strips the npm: scheme", () => {
|
|
@@ -73,10 +106,69 @@ describe("HeadlessInstallValidator (real npm pack + tar extraction, no mocking)"
|
|
|
73
106
|
const validator = new HeadlessInstallValidator();
|
|
74
107
|
const result = await validator.validate(`npm:${HEALTHY}`);
|
|
75
108
|
expect(result.ok).toBe(true);
|
|
76
|
-
expect(result.extensions).
|
|
109
|
+
expect(result.extensions).toHaveLength(1);
|
|
110
|
+
expect(result.extensions[0]).toMatchObject({ path: "extension/index.ts", ok: true });
|
|
77
111
|
}, 20_000);
|
|
78
112
|
});
|
|
79
113
|
|
|
114
|
+
describe("HeadlessInstallValidator (vehicle-client-pi pi-load-harness, non-gating diagnostic)", () => {
|
|
115
|
+
it("attaches all-three-path evidence for a real healthy package without changing its ok:true verdict", async () => {
|
|
116
|
+
const validator = new HeadlessInstallValidator();
|
|
117
|
+
const result = await validator.validate(`npm:${HEALTHY}`);
|
|
118
|
+
expect(result.ok).toBe(true);
|
|
119
|
+
expect(result.extensions).toHaveLength(1);
|
|
120
|
+
const paths = result.extensions[0]?.additionalLoadPaths;
|
|
121
|
+
expect(paths).toBeDefined();
|
|
122
|
+
expect(paths?.map((p) => p.path).sort()).toEqual(["jiti-try-native-false", "jiti-try-native-true", "native-esm"]);
|
|
123
|
+
expect(paths?.every((p) => p.ok)).toBe(true);
|
|
124
|
+
}, 20_000);
|
|
125
|
+
|
|
126
|
+
it("reports the broken fixture's additional paths as ok:true -- documents a real, deliberate difference in what's checked, not a bug", async () => {
|
|
127
|
+
// verifyLoadableUnderPi only checks that *importing* the module succeeds;
|
|
128
|
+
// it never calls the extension's exported factory the way mock-pi-cli's
|
|
129
|
+
// gating check does. BROKEN's factory only throws once invoked, so
|
|
130
|
+
// merely importing it succeeds on every path -- confirmed directly
|
|
131
|
+
// against the real child process, not assumed. The two checks answer
|
|
132
|
+
// genuinely different questions (import-time vs. factory-execution-time
|
|
133
|
+
// failure) and are complementary for exactly that reason; this test
|
|
134
|
+
// exists so a future change that makes them silently agree (e.g. an
|
|
135
|
+
// accidental factory invocation creeping into the load-path check)
|
|
136
|
+
// gets caught as a real behavior change.
|
|
137
|
+
const validator = new HeadlessInstallValidator();
|
|
138
|
+
const result = await validator.validate(`npm:${BROKEN}`);
|
|
139
|
+
expect(result.ok).toBe(false); // the gating check still refuses it
|
|
140
|
+
const paths = result.extensions[0]?.additionalLoadPaths;
|
|
141
|
+
expect(paths).toBeDefined();
|
|
142
|
+
expect(paths?.every((p) => p.ok === true)).toBe(true);
|
|
143
|
+
}, 20_000);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("HeadlessInstallValidator (bug repro, packed-headlessinstallvalidator-never-installs-the): staged tarball's own declared dependencies are never installed before the load check", () => {
|
|
147
|
+
it("approves an otherwise-healthy package whose entry point needs its one declared file: dependency", async () => {
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "packed-install-validation-dep-fixture-"));
|
|
149
|
+
writePackageWithRealDependency(dir);
|
|
150
|
+
|
|
151
|
+
// Ground truth this isn't a broken fixture: a plain `bun install` in an
|
|
152
|
+
// identical staged copy resolves and loads it fine (mirrors the real
|
|
153
|
+
// @danypops/pi-pipes repro: `npm pack` + `tar -xzf` + `bun install`
|
|
154
|
+
// succeeds outside of HeadlessInstallValidator).
|
|
155
|
+
const install = await Bun.spawn(["bun", "install", "--no-save"], { cwd: dir, stdout: "pipe", stderr: "pipe" }).exited;
|
|
156
|
+
expect(install).toBe(0);
|
|
157
|
+
const directLoad = await validateExtensionLoadsHeadless(join(dir, "extension/index.ts"));
|
|
158
|
+
expect(directLoad.ok).toBe(true);
|
|
159
|
+
|
|
160
|
+
// The real bug: HeadlessInstallValidator stages its own fresh copy via
|
|
161
|
+
// npm pack + tar (never running bun/npm install in that copy), so the
|
|
162
|
+
// same otherwise-healthy package fails the load check purely because
|
|
163
|
+
// its declared dependency was never installed into *that* copy.
|
|
164
|
+
const validator = new HeadlessInstallValidator();
|
|
165
|
+
const result = await validator.validate(`npm:${dir}`);
|
|
166
|
+
expect(result.ok).toBe(true);
|
|
167
|
+
expect(result.extensions).toHaveLength(1);
|
|
168
|
+
expect(result.extensions[0]).toMatchObject({ path: "extension/index.ts", ok: true });
|
|
169
|
+
}, 30_000);
|
|
170
|
+
});
|
|
171
|
+
|
|
80
172
|
describe("ExecInstaller.install() -- refuses before ever spawning the real pi binary", () => {
|
|
81
173
|
function fakeValidator(result: InstallValidationResult): InstallValidator {
|
|
82
174
|
return { validate: async () => result };
|