@danypops/pi-packed 0.19.7 → 0.19.10

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.
Files changed (94) hide show
  1. package/dist/client.d.ts +109 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +1 -0
  4. package/dist/protocol.d.ts +221 -0
  5. package/dist/protocol.d.ts.map +1 -0
  6. package/dist/protocol.js +1 -0
  7. package/extension/src/{permission.ts → approval/permission.ts} +1 -1
  8. package/extension/src/index.ts +1 -1
  9. package/extension/src/packed.ts +2 -2
  10. package/extension/src/{discover.ts → tabs/discover.ts} +4 -4
  11. package/extension/src/{resource-config.ts → tabs/resource-config.ts} +4 -4
  12. package/extension/src/{security-tui.ts → tabs/security-tui.ts} +3 -3
  13. package/extension/src/tool-output.ts +1 -1
  14. package/extension/src/tools.ts +2 -2
  15. package/extension/src/tui.ts +4 -4
  16. package/package.json +31 -8
  17. package/service/schema/pi-setup-v1.schema.json +70 -0
  18. package/service/setup/danypops-ecosystem.pi-setup.json +15 -0
  19. package/service/src/adoption/advisories.ts +268 -0
  20. package/service/src/adoption/check.ts +872 -0
  21. package/service/src/adoption/commit-freshness.ts +167 -0
  22. package/service/src/adoption/doctor.ts +135 -0
  23. package/service/src/adoption/install-validation.ts +187 -0
  24. package/service/src/adoption/pack.ts +291 -0
  25. package/service/src/adoption/score.ts +466 -0
  26. package/service/src/adoption/smoke-child.ts +113 -0
  27. package/service/src/adoption/smoke.ts +282 -0
  28. package/service/src/cli/cli.ts +926 -0
  29. package/service/src/daemon/cleanup.ts +76 -0
  30. package/service/src/daemon/client.ts +412 -0
  31. package/service/src/daemon/daemon-service.ts +249 -0
  32. package/service/src/daemon/daemon.ts +110 -0
  33. package/service/src/daemon/service.ts +664 -0
  34. package/service/src/daemon/watcher.ts +92 -0
  35. package/service/src/index/build-index.ts +256 -0
  36. package/service/src/packages/catalog.ts +61 -0
  37. package/service/src/packages/db.ts +224 -0
  38. package/service/src/packages/install.ts +60 -0
  39. package/service/src/packages/installed.ts +123 -0
  40. package/service/src/packages/package.ts +141 -0
  41. package/service/src/packages/resources.ts +203 -0
  42. package/service/src/pi/pi-version.ts +171 -0
  43. package/service/src/public/atomic-json.ts +32 -0
  44. package/service/src/public/client.ts +277 -0
  45. package/service/src/public/protocol.ts +169 -0
  46. package/service/src/publish/publish.ts +855 -0
  47. package/service/src/registry/registry.ts +246 -0
  48. package/service/src/security/security.ts +128 -0
  49. package/service/src/self-update/self-update.ts +148 -0
  50. package/service/src/setup/setup.ts +761 -0
  51. package/service/src/shared/atomic-json.ts +33 -0
  52. package/service/src/shared/cache.ts +21 -0
  53. package/service/src/shared/constants.ts +73 -0
  54. package/service/src/shared/log.ts +21 -0
  55. package/service/src/shared/paths.ts +88 -0
  56. package/service/src/shared/state.ts +15 -0
  57. package/service/src/shared/version.ts +46 -0
  58. package/service/test/advisories.test.ts +287 -0
  59. package/service/test/check.test.ts +368 -0
  60. package/service/test/cleanup.test.ts +220 -0
  61. package/service/test/cli.test.ts +1303 -0
  62. package/service/test/core.test.ts +181 -0
  63. package/service/test/daemon-kit-migration.test.ts +181 -0
  64. package/service/test/daemon-service.test.ts +238 -0
  65. package/service/test/db.test.ts +178 -0
  66. package/service/test/doctor.test.ts +234 -0
  67. package/service/test/domain.test.ts +291 -0
  68. package/service/test/fixtures/install-validation/broken-package/extension/index.ts +3 -0
  69. package/service/test/fixtures/install-validation/broken-package/package.json +8 -0
  70. package/service/test/fixtures/install-validation/healthy-package/extension/index.ts +3 -0
  71. package/service/test/fixtures/install-validation/healthy-package/package.json +8 -0
  72. package/service/test/fixtures/install-validation/no-manifest-package/package.json +5 -0
  73. package/service/test/index.test.ts +353 -0
  74. package/service/test/install-validation.test.ts +114 -0
  75. package/service/test/install.test.ts +113 -0
  76. package/service/test/log.test.ts +42 -0
  77. package/service/test/pack-score.test.ts +513 -0
  78. package/service/test/pi-version.test.ts +318 -0
  79. package/service/test/public-boundary.test.ts +54 -0
  80. package/service/test/public-client.test.ts +127 -0
  81. package/service/test/public-consumer.ts +8 -0
  82. package/service/test/publish.test.ts +333 -0
  83. package/service/test/registry-contract.test.ts +148 -0
  84. package/service/test/resources.test.ts +255 -0
  85. package/service/test/security.test.ts +89 -0
  86. package/service/test/self-update.test.ts +257 -0
  87. package/service/test/service.test.ts +555 -0
  88. package/service/test/setup.test.ts +375 -0
  89. package/service/test/smoke.test.ts +118 -0
  90. package/service/test/version.test.ts +37 -0
  91. package/service/tsconfig.consumer.json +13 -0
  92. package/service/tsconfig.public.json +12 -0
  93. /package/extension/src/{reload.ts → approval/reload.ts} +0 -0
  94. /package/extension/src/{discover-model.ts → tabs/discover-model.ts} +0 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * commit-freshness.ts — real commit-date lookups backing the `freshness`
3
+ * adoption dimension (score.ts). Two independent, best-effort sources:
4
+ * a local git checkout (free, no network) and GitHub's Commits API for a
5
+ * pre-install registry candidate (bounded, single attempt, GitHub-only).
6
+ * Both resolve to undefined on any failure -- never throw, never guess.
7
+ */
8
+ import { runBounded } from "../publish/publish.ts";
9
+ import {
10
+ GITHUB_MAX_TOTAL_BACKOFF_MS,
11
+ GITHUB_RETRY_MAX_ATTEMPTS,
12
+ GITHUB_SECONDARY_RATE_LIMIT_FALLBACK_MS,
13
+ GITHUB_TRANSIENT_BASE_DELAY_MS,
14
+ } from "../shared/constants.ts";
15
+ import { createLogger } from "../shared/log.ts";
16
+
17
+ const log = createLogger("commit-freshness");
18
+
19
+ const GITHUB_API_BASE = "https://api.github.com";
20
+ const GITHUB_COMMITS_TIMEOUT_MS = 8_000;
21
+ /** Matches github.com in every form Pi packages actually declare it in:
22
+ * https://, git+https://, git://, git@host:path (SSH shorthand), with or
23
+ * without a trailing .git. Any other host (GitLab, Bitbucket, sourcehut --
24
+ * all confirmed present in the real pi-extension ecosystem) never matches,
25
+ * so it never reaches the network call below. */
26
+ const GITHUB_REPO_PATTERN = /^(?:git\+)?(?:https?:\/\/|git:\/\/|git@)(?:www\.)?github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?(?:[/#?].*)?$/i;
27
+
28
+ function parseGithubRepo(repository: string | undefined): { owner: string; repo: string } | undefined {
29
+ if (!repository) return undefined;
30
+ const match = GITHUB_REPO_PATTERN.exec(repository.trim());
31
+ return match?.[1] && match[2] ? { owner: match[1], repo: match[2] } : undefined;
32
+ }
33
+
34
+ /** Seconds to wait before the next attempt, or undefined when the response
35
+ * isn't a rate-limit signal at all (a plain success or an unrelated error).
36
+ * Reads GitHub's two real signals -- confirmed against GitHub's own docs --
37
+ * never guesses a wait time neither header supports. */
38
+ function githubRetryAfterSeconds(res: Response): number | undefined {
39
+ const retryAfter = res.headers.get("retry-after");
40
+ if (retryAfter !== null) {
41
+ const seconds = Number(retryAfter);
42
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : GITHUB_SECONDARY_RATE_LIMIT_FALLBACK_MS / 1000;
43
+ }
44
+ if (res.headers.get("x-ratelimit-remaining") === "0") {
45
+ const reset = Number(res.headers.get("x-ratelimit-reset"));
46
+ if (Number.isFinite(reset)) return Math.max(0, reset - Date.now() / 1000);
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /**
52
+ * Self-throttling wrapper for the single-candidate GitHub Commits API call:
53
+ * a rate-limit or transient failure backs off and retries rather than
54
+ * giving up on the first hiccup, but the total added wait across every
55
+ * retry is capped at GITHUB_MAX_TOTAL_BACKOFF_MS -- a real primary-limit
56
+ * exhaustion (X-RateLimit-Reset up to an hour away) is treated as
57
+ * immediately exhausted rather than blocking an interactive `packed score`
58
+ * call for anywhere near that long. "Exhaustion" means either the retry
59
+ * count or the total-wait budget runs out, whichever comes first; the
60
+ * caller then gets undefined, the same fail-open contract as before this
61
+ * task, never a thrown error.
62
+ */
63
+ async function fetchGithubWithBackoff(url: string, init: RequestInit, timeoutMs: number): Promise<Response | undefined> {
64
+ let totalWaitMs = 0;
65
+ for (let attempt = 1; attempt <= GITHUB_RETRY_MAX_ATTEMPTS; attempt++) {
66
+ // Fresh per-attempt timeout, same as registry.ts's fetchWithRetry --
67
+ // reusing one AbortSignal.timeout() across retries would have it fire
68
+ // mid-backoff-sleep instead of timing each attempt independently.
69
+ const signal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
70
+ let res: Response;
71
+ try {
72
+ res = await fetch(url, { ...init, signal });
73
+ } catch (e) {
74
+ if (attempt === GITHUB_RETRY_MAX_ATTEMPTS) return undefined;
75
+ const delayMs = GITHUB_TRANSIENT_BASE_DELAY_MS * 2 ** (attempt - 1);
76
+ if (totalWaitMs + delayMs > GITHUB_MAX_TOTAL_BACKOFF_MS) return undefined;
77
+ totalWaitMs += delayMs;
78
+ log.warn("network error, retrying", { attempt, delayMs, error: e instanceof Error ? e.message : String(e) });
79
+ await Bun.sleep(delayMs);
80
+ continue;
81
+ }
82
+ if (res.ok) return res;
83
+ const rateLimitWaitSeconds = githubRetryAfterSeconds(res);
84
+ const isRetryable = rateLimitWaitSeconds !== undefined || res.status >= 500;
85
+ if (!isRetryable || attempt === GITHUB_RETRY_MAX_ATTEMPTS) return res;
86
+ const delayMs = rateLimitWaitSeconds !== undefined ? rateLimitWaitSeconds * 1000 : GITHUB_TRANSIENT_BASE_DELAY_MS * 2 ** (attempt - 1);
87
+ if (totalWaitMs + delayMs > GITHUB_MAX_TOTAL_BACKOFF_MS) {
88
+ log.warn("rate-limit wait exceeds total backoff budget, giving up", { attempt, delayMs, status: res.status });
89
+ return res;
90
+ }
91
+ totalWaitMs += delayMs;
92
+ log.warn("rate-limited, backing off", { attempt, delayMs, status: res.status });
93
+ await Bun.sleep(delayMs);
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ function packedUserAgent(): string {
99
+ const runtime = process.versions.bun ? `bun/${process.versions.bun}` : `node/${process.version}`;
100
+ return `packed (${process.platform}; ${runtime}; ${process.arch})`;
101
+ }
102
+
103
+ /**
104
+ * Reads a checkout's real last-commit timestamp via `git log`, scoped to
105
+ * `directory` when the package lives in a monorepo subdirectory. Bounded
106
+ * single call (runBounded's own timeout), no network at all. Undefined
107
+ * for a missing git binary, a non-git directory, or any parse failure --
108
+ * never a guess.
109
+ */
110
+ export async function lastLocalCommitAt(root: string, directory?: string): Promise<string | undefined> {
111
+ const args = ["git", "-C", root, "log", "-1", "--format=%cI"];
112
+ if (directory) args.push("--", directory);
113
+ const result = await runBounded(args).catch((): { code: number; stdout: string; stderr: string } => ({
114
+ code: 1,
115
+ stdout: "",
116
+ stderr: "",
117
+ }));
118
+ if (result.code !== 0) return undefined;
119
+ const date = result.stdout.trim();
120
+ return date && Number.isFinite(Date.parse(date)) ? date : undefined;
121
+ }
122
+
123
+ export type FetchGithubLastCommitAt = (
124
+ repository: string | undefined,
125
+ directory?: string,
126
+ timeoutMs?: number,
127
+ ) => Promise<string | undefined>;
128
+
129
+ /**
130
+ * Bounded, self-throttling, GitHub-only commit-date lookup for a candidate
131
+ * that hasn't been cloned yet (pre-install registry scoring). Any
132
+ * non-GitHub host, or a missing repository field, short-circuits to
133
+ * undefined with zero network calls. A transient failure or a short
134
+ * rate-limit wait is retried (see fetchGithubWithBackoff); a real
135
+ * primary-limit exhaustion or total-backoff-budget exhaustion still
136
+ * resolves to undefined rather than blocking. Never intended for a bulk
137
+ * sweep across every installed package -- see index/build-index.ts's own
138
+ * doc comment for why bulk generation never calls this function at all,
139
+ * regardless of how resilient a single call now is. `baseUrl` is
140
+ * test-only, matching this codebase's Bun.serve() HTTP fixture convention
141
+ * (see pi-version.ts's createFetchLatestPiRelease).
142
+ */
143
+ export function createGithubLastCommitAt(baseUrl: string = GITHUB_API_BASE): FetchGithubLastCommitAt {
144
+ return async (repository, directory, timeoutMs = GITHUB_COMMITS_TIMEOUT_MS) => {
145
+ const parsed = parseGithubRepo(repository);
146
+ if (!parsed) return undefined;
147
+ const params = new URLSearchParams({ per_page: "1" });
148
+ if (directory) params.set("path", directory);
149
+ try {
150
+ const res = await fetchGithubWithBackoff(
151
+ `${baseUrl}/repos/${parsed.owner}/${parsed.repo}/commits?${params}`,
152
+ {
153
+ headers: { accept: "application/vnd.github+json", "user-agent": packedUserAgent() },
154
+ },
155
+ timeoutMs,
156
+ );
157
+ if (!res?.ok) return undefined;
158
+ const commits = (await res.json()) as Array<{ commit?: { committer?: { date?: unknown }; author?: { date?: unknown } } }>;
159
+ const date = commits[0]?.commit?.committer?.date ?? commits[0]?.commit?.author?.date;
160
+ return typeof date === "string" && Number.isFinite(Date.parse(date)) ? date : undefined;
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ };
165
+ }
166
+
167
+ export const githubLastCommitAt: FetchGithubLastCommitAt = createGithubLastCommitAt();
@@ -0,0 +1,135 @@
1
+ /**
2
+ * doctor.ts — unions every currently-configured package's declared,
3
+ * enabled extensions across BOTH global and project scope, smoke-tests
4
+ * each in isolation, and reports any tool/command/shortcut/flag name
5
+ * claimed by more than one. Pi's own extension loader only discovers this
6
+ * kind of collision at actual startup, one package at a time; doctor
7
+ * answers the same question proactively, before pi ever runs.
8
+ */
9
+ import { join } from "node:path";
10
+ import { listPackageResources, type PackageResources, resolveInstalledDir } from "../packages/resources.ts";
11
+ import { runExtensionSmoke, type SmokeOptions, type SmokeRegistrations } from "./smoke.ts";
12
+
13
+ const REGISTRATION_KINDS = ["tools", "commands", "shortcuts", "flags"] as const;
14
+ type RegistrationKind = (typeof REGISTRATION_KINDS)[number];
15
+ const CONFLICT_KIND: Record<RegistrationKind, "tool" | "command" | "shortcut" | "flag"> = {
16
+ tools: "tool",
17
+ commands: "command",
18
+ shortcuts: "shortcut",
19
+ flags: "flag",
20
+ };
21
+
22
+ const MAX_EXTENSIONS_SCANNED = 50;
23
+
24
+ export interface DoctorClaim {
25
+ name: string;
26
+ source: string;
27
+ scope: "global" | "project";
28
+ extension: string;
29
+ }
30
+
31
+ export interface DoctorConflict {
32
+ kind: "tool" | "command" | "shortcut" | "flag";
33
+ name: string;
34
+ claimants: DoctorClaim[];
35
+ }
36
+
37
+ export interface DoctorExtensionResult {
38
+ name: string;
39
+ source: string;
40
+ scope: "global" | "project";
41
+ extension: string;
42
+ status: string;
43
+ registrations: SmokeRegistrations;
44
+ message?: string;
45
+ }
46
+
47
+ export interface DoctorReport {
48
+ ok: boolean;
49
+ conflicts: DoctorConflict[];
50
+ extensions: DoctorExtensionResult[];
51
+ scanned: number;
52
+ truncated: boolean;
53
+ }
54
+
55
+ interface ScanJob {
56
+ group: PackageResources;
57
+ dir: string;
58
+ extension: string;
59
+ }
60
+
61
+ function collectJobs(piHome: string, projectRoot: string | undefined): ScanJob[] {
62
+ const { global, project } = listPackageResources(piHome, projectRoot);
63
+ const groups: { group: PackageResources; baseHome: string }[] = [
64
+ ...global.map((group) => ({ group, baseHome: piHome })),
65
+ ...(projectRoot ? project.map((group) => ({ group, baseHome: join(projectRoot, ".pi") })) : []),
66
+ ];
67
+ const jobs: ScanJob[] = [];
68
+ for (const { group, baseHome } of groups) {
69
+ const dir = resolveInstalledDir(baseHome, group.source);
70
+ if (!dir) continue;
71
+ for (const item of group.extensions) {
72
+ if (item.enabled) jobs.push({ group, dir, extension: item.path });
73
+ }
74
+ }
75
+ return jobs;
76
+ }
77
+
78
+ export async function runDoctor(piHome: string, projectRoot?: string, options: SmokeOptions = {}): Promise<DoctorReport> {
79
+ const jobs = collectJobs(piHome, projectRoot);
80
+ const truncated = jobs.length > MAX_EXTENSIONS_SCANNED;
81
+ const bounded = jobs.slice(0, MAX_EXTENSIONS_SCANNED);
82
+
83
+ const claims = new Map<RegistrationKind, Map<string, DoctorClaim[]>>(REGISTRATION_KINDS.map((kind) => [kind, new Map()]));
84
+ const extensions: DoctorExtensionResult[] = [];
85
+ let anyNotOk = false;
86
+
87
+ for (const job of bounded) {
88
+ const result = await runExtensionSmoke(job.dir, join(job.dir, job.extension), options);
89
+ if (result.status !== "ok") anyNotOk = true;
90
+ extensions.push({
91
+ name: job.group.name,
92
+ source: job.group.source,
93
+ scope: job.group.scope,
94
+ extension: job.extension,
95
+ status: result.status,
96
+ registrations: result.registrations,
97
+ ...(result.message ? { message: result.message } : {}),
98
+ });
99
+ if (result.status !== "ok") continue;
100
+ const claim: DoctorClaim = { name: job.group.name, source: job.group.source, scope: job.group.scope, extension: job.extension };
101
+ for (const kind of REGISTRATION_KINDS) {
102
+ const byName = claims.get(kind)!;
103
+ for (const name of result.registrations[kind]) byName.set(name, [...(byName.get(name) ?? []), claim]);
104
+ }
105
+ }
106
+
107
+ const conflicts: DoctorConflict[] = [];
108
+ for (const kind of REGISTRATION_KINDS) {
109
+ for (const [name, claimants] of claims.get(kind)!) {
110
+ if (claimants.length > 1) conflicts.push({ kind: CONFLICT_KIND[kind], name, claimants });
111
+ }
112
+ }
113
+ conflicts.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
114
+
115
+ return { ok: conflicts.length === 0 && !anyNotOk, conflicts, extensions, scanned: bounded.length, truncated };
116
+ }
117
+
118
+ function formatClaimant(claim: DoctorClaim): string {
119
+ return `${claim.name} (${claim.source}) [${claim.scope}] ${claim.extension}`;
120
+ }
121
+
122
+ export function formatDoctorReport(report: DoctorReport, json: boolean): string {
123
+ if (json) return `${JSON.stringify(report)}\n`;
124
+ let out = `${report.ok ? "PASS" : "FAIL"} — ${report.scanned} extension(s) scanned, ${report.conflicts.length} conflict(s)\n`;
125
+ for (const conflict of report.conflicts) {
126
+ out += `\nCONFLICT ${conflict.kind} "${conflict.name}" claimed by:\n`;
127
+ for (const claimant of conflict.claimants) out += ` - ${formatClaimant(claimant)}\n`;
128
+ }
129
+ for (const extension of report.extensions) {
130
+ if (extension.status === "ok") continue;
131
+ out += `\n${extension.status.toUpperCase()} ${formatClaimant({ name: extension.name, source: extension.source, scope: extension.scope, extension: extension.extension })}${extension.message ? `: ${extension.message}` : ""}\n`;
132
+ }
133
+ if (report.truncated) out += "\nOutput truncated: more enabled extensions exist than this run's bound.\n";
134
+ return out;
135
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * install-validation.ts — a headless "does this extension even load" gate
3
+ * for ExecInstaller.install(), so a package that crashes at registration
4
+ * time never gets fully wired into ~/.pi/npm and ~/.pi/agent in the first
5
+ * place. Stages the real npm tarball into a throwaway temp dir (never the
6
+ * live piHome), then runs @danypops/pi-extension-harness's own mock-pi-cli
7
+ * subprocess -- a real, isolated process exercising the same production
8
+ * jiti load path Pi's own binary uses -- against every declared
9
+ * pi.extensions entry.
10
+ */
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
+ import { createRequire } from "node:module";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+
16
+ export interface ExtensionLoadResult {
17
+ path: string;
18
+ ok: boolean;
19
+ message?: string;
20
+ }
21
+
22
+ export interface InstallValidationResult {
23
+ ok: boolean;
24
+ source: string;
25
+ extensions: ExtensionLoadResult[];
26
+ message?: string;
27
+ }
28
+
29
+ export interface InstallValidator {
30
+ validate(source: string): Promise<InstallValidationResult>;
31
+ }
32
+
33
+ const DEFAULT_LOAD_TIMEOUT_MS = 8_000;
34
+ const MAX_LOAD_TIMEOUT_MS = 30_000;
35
+ const PACK_TIMEOUT_MS = 30_000;
36
+ const MAX_EXTENSIONS = 20;
37
+
38
+ function bounded(value: number | undefined, fallback: number, maximum: number): number {
39
+ if (!Number.isFinite(value) || value === undefined || value <= 0) return fallback;
40
+ return Math.min(Math.floor(value), maximum);
41
+ }
42
+
43
+ /** Strips packed's own "npm:" scheme -- what npm pack itself expects is a
44
+ * bare registry spec (name, name@version) or a local path. Returns
45
+ * undefined for git:/https:/local sources: nothing here to stage from an
46
+ * npm tarball, so headless validation is out of scope for them. */
47
+ export function bareNpmSpec(source: string): string | undefined {
48
+ return source.startsWith("npm:") ? source.slice(4) : undefined;
49
+ }
50
+
51
+ interface CommandResult {
52
+ code: number;
53
+ stdout: string;
54
+ stderr: string;
55
+ timedOut: boolean;
56
+ }
57
+
58
+ async function runCommand(command: string[], cwd: string, timeoutMs: number): Promise<CommandResult> {
59
+ const proc = Bun.spawn(command, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
60
+ let timedOut = false;
61
+ const timer = setTimeout(() => {
62
+ timedOut = true;
63
+ try {
64
+ proc.kill();
65
+ } catch {
66
+ /* already exited */
67
+ }
68
+ }, timeoutMs);
69
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
70
+ clearTimeout(timer);
71
+ return { code, stdout, stderr, timedOut };
72
+ }
73
+
74
+ type StageResult = { ok: true; root: string } | { ok: false; message: string };
75
+
76
+ /** Downloads (or, for a local path spec, packs) the real tarball into
77
+ * stageDir and extracts it -- never touches the live piHome. */
78
+ async function stageNpmTarball(spec: string, stageDir: string): Promise<StageResult> {
79
+ const pack = await runCommand(["npm", "pack", spec, "--json"], stageDir, PACK_TIMEOUT_MS);
80
+ if (pack.timedOut) return { ok: false, message: `npm pack exceeded ${PACK_TIMEOUT_MS}ms` };
81
+ if (pack.code !== 0) return { ok: false, message: (pack.stderr.trim() || `npm pack exited ${pack.code}`).slice(0, 2_000) };
82
+ let filename: string | undefined;
83
+ try {
84
+ const parsed = JSON.parse(pack.stdout) as Array<{ filename?: string }>;
85
+ filename = parsed[0]?.filename;
86
+ } catch (e) {
87
+ return { ok: false, message: `npm pack produced unparseable JSON: ${e instanceof Error ? e.message : e}` };
88
+ }
89
+ if (!filename) return { ok: false, message: "npm pack reported no tarball filename" };
90
+ const tarPath = join(stageDir, filename);
91
+ if (!existsSync(tarPath)) return { ok: false, message: `expected tarball ${filename} is missing after npm pack` };
92
+ const extract = await runCommand(["tar", "-xzf", tarPath, "-C", stageDir], stageDir, PACK_TIMEOUT_MS);
93
+ if (extract.code !== 0) return { ok: false, message: (extract.stderr.trim() || `tar exited ${extract.code}`).slice(0, 2_000) };
94
+ const root = join(stageDir, "package"); // npm's own tarball layout convention
95
+ if (!existsSync(join(root, "package.json"))) return { ok: false, message: "extracted tarball has no package.json at its expected root" };
96
+ return { ok: true, root };
97
+ }
98
+
99
+ /** Runs pi-extension-harness's mock-pi-cli against one extension entry
100
+ * point, in its own load-only mode (--tool omitted): a real, isolated
101
+ * subprocess exercising Pi's own production jiti load path. */
102
+ export async function validateExtensionLoadsHeadless(entryPath: string, timeoutMs?: number): Promise<ExtensionLoadResult> {
103
+ const bound = bounded(timeoutMs, DEFAULT_LOAD_TIMEOUT_MS, MAX_LOAD_TIMEOUT_MS);
104
+ let cliPath: string;
105
+ try {
106
+ cliPath = createRequire(import.meta.url).resolve("@danypops/pi-extension-harness/mock-pi-cli");
107
+ } catch (e) {
108
+ return { path: entryPath, ok: false, message: `pi-extension-harness is not resolvable: ${e instanceof Error ? e.message : e}` };
109
+ }
110
+ const result = await runCommand(["node", cliPath, "--extension", entryPath], tmpdir(), bound);
111
+ if (result.timedOut) return { path: entryPath, ok: false, message: `extension load exceeded ${bound}ms` };
112
+ const lastEvent = result.stdout
113
+ .trim()
114
+ .split("\n")
115
+ .filter((line) => line.startsWith("{"))
116
+ .at(-1);
117
+ if (lastEvent) {
118
+ try {
119
+ const event = JSON.parse(lastEvent) as { type?: string; error?: string };
120
+ if (event.type === "load_ok") return { path: entryPath, ok: true };
121
+ if (event.type === "load_error") {
122
+ return {
123
+ path: entryPath,
124
+ ok: false,
125
+ message: (typeof event.error === "string" ? event.error : "extension failed to load").slice(0, 1_000),
126
+ };
127
+ }
128
+ } catch {
129
+ // Falls through to the generic failure below.
130
+ }
131
+ }
132
+ return {
133
+ path: entryPath,
134
+ ok: false,
135
+ message: (result.stderr.trim() || `mock-pi-cli exited ${result.code} with no recognizable event`).slice(0, 1_000),
136
+ };
137
+ }
138
+
139
+ /** Stages the real npm tarball in isolation and headlessly load-checks
140
+ * every pi.extensions entry it declares. A package with no pi.extensions
141
+ * (most npm packages) or a non-npm source has nothing to validate and
142
+ * passes through as ok. */
143
+ export class HeadlessInstallValidator implements InstallValidator {
144
+ constructor(private readonly timeoutMs?: number) {}
145
+
146
+ async validate(source: string): Promise<InstallValidationResult> {
147
+ const spec = bareNpmSpec(source);
148
+ if (!spec) return { ok: true, source, extensions: [] };
149
+
150
+ const stageDir = mkdtempSync(join(tmpdir(), "packed-install-validate-"));
151
+ try {
152
+ const staged = await stageNpmTarball(spec, stageDir);
153
+ if (!staged.ok) return { ok: false, source, extensions: [], message: staged.message };
154
+
155
+ let manifest: Record<string, unknown>;
156
+ try {
157
+ manifest = JSON.parse(readFileSync(join(staged.root, "package.json"), "utf8")) as Record<string, unknown>;
158
+ } catch (e) {
159
+ return { ok: false, source, extensions: [], message: `package.json unreadable: ${e instanceof Error ? e.message : e}` };
160
+ }
161
+
162
+ const pi = typeof manifest.pi === "object" && manifest.pi !== null ? (manifest.pi as Record<string, unknown>) : undefined;
163
+ const declared = Array.isArray(pi?.extensions)
164
+ ? pi.extensions.filter((entry): entry is string => typeof entry === "string").slice(0, MAX_EXTENSIONS)
165
+ : [];
166
+ if (declared.length === 0) return { ok: true, source, extensions: [] };
167
+
168
+ const extensions: ExtensionLoadResult[] = [];
169
+ for (const entry of declared) {
170
+ const entryPath = resolve(staged.root, entry);
171
+ if (!existsSync(entryPath)) {
172
+ extensions.push({ path: entry, ok: false, message: "declared pi.extensions entry is absent from the tarball" });
173
+ continue;
174
+ }
175
+ // Report the declared manifest entry, not the resolved absolute
176
+ // path into a throwaway temp stage dir -- meaningful to a caller,
177
+ // matches what package.json itself says.
178
+ const loadResult = await validateExtensionLoadsHeadless(entryPath, this.timeoutMs);
179
+ extensions.push({ ...loadResult, path: entry });
180
+ }
181
+ const ok = extensions.every((extension) => extension.ok);
182
+ return { ok, source, extensions, ...(ok ? {} : { message: "one or more declared extensions failed a headless load check" }) };
183
+ } finally {
184
+ rmSync(stageDir, { recursive: true, force: true });
185
+ }
186
+ }
187
+ }