@xynogen/pix-runtime 0.10.2 → 0.13.0

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.
@@ -0,0 +1,279 @@
1
+ /**
2
+ * catalog.ts — every external command a pix package runs.
3
+ *
4
+ * One entry per executable: who uses it, where it matters, how to get it, and
5
+ * (for a few) a trusted GitHub release pix may download into `<agentDir>/bin`.
6
+ * User-defined commands (LSP servers, proc commands, skill scripts) are not
7
+ * catalogued; `which.ts` keeps serving those.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import type { HostOs, HostPlatform } from "../platform.ts";
12
+
13
+ export interface DownloadRecipe {
14
+ /** GitHub `owner/repo`. The latest release is found via the `/releases/latest` redirect. */
15
+ repo: string;
16
+ /** Release asset for this host and version (tag minus `v`/`release-`), or undefined if none. */
17
+ asset(host: HostPlatform, version: string): string | undefined;
18
+ /** Checksum manifest asset (`<sha256> <name>` lines). Verified when set. */
19
+ checksums?: string;
20
+ /** Rough archive size shown before download. */
21
+ size?: string;
22
+ }
23
+
24
+ export interface BinarySpec {
25
+ /** Packages that run it. */
26
+ usedBy: readonly string[];
27
+ /** OSes where it is used. */
28
+ os: readonly HostOs[];
29
+ /** On Linux, used only under WSL (Windows interop). */
30
+ wslOnly?: boolean;
31
+ /** Alternative executable names tried in order (defaults to the key). */
32
+ names?: readonly string[];
33
+ /** Install hint per OS; `default` otherwise. */
34
+ hint: Partial<Record<HostOs | "default", string>>;
35
+ /** Nice-to-have: absence is informational, not an error. */
36
+ optional?: boolean;
37
+ /**
38
+ * Standard install locations checked after `<agentDir>/bin` and before PATH
39
+ * (e.g. Git Bash, which Git for Windows keeps off PATH). Empty values skipped.
40
+ */
41
+ knownPaths?(env: NodeJS.ProcessEnv, host: HostPlatform): readonly string[];
42
+ /** Args printing a version, or null when the tool has no safe version probe. */
43
+ versionArgs?: readonly string[] | null;
44
+ download?: DownloadRecipe;
45
+ }
46
+
47
+ const ALL: readonly HostOs[] = ["win32", "linux", "darwin", "android"];
48
+ const UNIX: readonly HostOs[] = ["linux", "darwin", "android"];
49
+ const LINUX: readonly HostOs[] = ["linux"];
50
+
51
+ /** Env lookup ignoring case: a copied Windows env loses process.env's case-insensitivity. */
52
+ function envVar(env: NodeJS.ProcessEnv, key: string): string | undefined {
53
+ if (env[key]) return env[key];
54
+ const k = Object.keys(env).find((x) => x.toLowerCase() === key.toLowerCase());
55
+ return k ? env[k] : undefined;
56
+ }
57
+
58
+ const rustTriple = (h: HostPlatform): string | undefined => {
59
+ if (h.os === "win32") return "x86_64-pc-windows-msvc"; // x64 build also runs on Windows arm64
60
+ if (h.os === "darwin") return `${h.arch === "arm64" ? "aarch64" : "x86_64"}-apple-darwin`;
61
+ if (h.os !== "linux") return undefined;
62
+ if (h.arch === "x64") return "x86_64-unknown-linux-musl";
63
+ return h.libc === "glibc" ? "aarch64-unknown-linux-gnu" : undefined;
64
+ };
65
+
66
+ export const CATALOG = {
67
+ rtk: {
68
+ usedBy: ["pix-optimizer"],
69
+ os: ALL,
70
+ hint: {
71
+ win32: "winget install rtk-ai.rtk",
72
+ default: "download from github.com/rtk-ai/rtk/releases or `cargo install rtk`",
73
+ },
74
+ download: {
75
+ repo: "rtk-ai/rtk",
76
+ checksums: "checksums.txt",
77
+ size: "~5 MB",
78
+ asset: (h) => {
79
+ const t = rustTriple(h);
80
+ if (!t) return undefined;
81
+ return `rtk-${t}.${h.os === "win32" ? "zip" : "tar.gz"}`;
82
+ },
83
+ },
84
+ },
85
+ hunk: {
86
+ usedBy: ["pix-hunk"],
87
+ os: ALL,
88
+ hint: { default: "npm install -g hunkdiff" },
89
+ download: {
90
+ repo: "modem-dev/hunk",
91
+ checksums: "SHA256SUMS",
92
+ size: "~40 MB",
93
+ asset: (h) => {
94
+ if (h.os === "win32") return "hunkdiff-windows-x64.tar.gz";
95
+ if (h.os === "linux" || h.os === "darwin") return `hunkdiff-${h.os}-${h.arch}.tar.gz`;
96
+ return undefined;
97
+ },
98
+ },
99
+ },
100
+ aria2c: {
101
+ usedBy: ["pix-aria2"],
102
+ os: ALL,
103
+ hint: {
104
+ linux: "apt install aria2 · dnf install aria2 · pacman -S aria2",
105
+ darwin: "brew install aria2",
106
+ android: "pkg install aria2",
107
+ win32: "winget install aria2.aria2",
108
+ default: "download from github.com/aria2/aria2/releases",
109
+ },
110
+ download: {
111
+ repo: "aria2/aria2",
112
+ size: "~2.4 MB",
113
+ asset: (h, v) => (h.os === "win32" ? `aria2-${v}-win-64bit-build1.zip` : undefined),
114
+ },
115
+ },
116
+ ffmpeg: {
117
+ usedBy: ["pix-voice"],
118
+ os: ["linux", "win32", "darwin"],
119
+ versionArgs: ["-version"],
120
+ hint: {
121
+ linux: "apt install ffmpeg · dnf install ffmpeg · pacman -S ffmpeg",
122
+ darwin: "brew install ffmpeg",
123
+ win32: "winget install Gyan.FFmpeg",
124
+ default: "install ffmpeg",
125
+ },
126
+ download: {
127
+ repo: "BtbN/FFmpeg-Builds",
128
+ checksums: "checksums.sha256",
129
+ size: "~120 MB",
130
+ asset: (h) => {
131
+ const arm = h.arch === "arm64";
132
+ if (h.os === "win32") return `ffmpeg-master-latest-${arm ? "winarm64" : "win64"}-lgpl.zip`;
133
+ if (h.os === "linux")
134
+ return `ffmpeg-master-latest-${arm ? "linuxarm64" : "linux64"}-lgpl.tar.xz`;
135
+ return undefined;
136
+ },
137
+ },
138
+ },
139
+ rg: {
140
+ usedBy: ["pix-search", "pix-grep", "pix-graph"],
141
+ os: ALL,
142
+ hint: {
143
+ android: "pkg install ripgrep",
144
+ win32: "restart Pi (it downloads rg) or winget install BurntSushi.ripgrep.MSVC",
145
+ default: "restart Pi (it downloads rg) or install ripgrep",
146
+ },
147
+ },
148
+ git: {
149
+ usedBy: ["pix-footer", "pix-welcome", "pix-subagent", "pix-search"],
150
+ os: ALL,
151
+ hint: {
152
+ win32: "winget install Git.Git",
153
+ darwin: "xcode-select --install",
154
+ default: "install git",
155
+ },
156
+ },
157
+ ssh: {
158
+ usedBy: ["pix-ssh"],
159
+ os: ALL,
160
+ versionArgs: ["-V"],
161
+ hint: {
162
+ win32:
163
+ "enable the OpenSSH Client optional feature or winget install Microsoft.OpenSSH.Preview",
164
+ default: "install openssh-client",
165
+ },
166
+ },
167
+ scp: {
168
+ usedBy: ["pix-ssh"],
169
+ os: ALL,
170
+ versionArgs: null,
171
+ hint: {
172
+ win32:
173
+ "enable the OpenSSH Client optional feature or winget install Microsoft.OpenSSH.Preview",
174
+ default: "install openssh-client",
175
+ },
176
+ },
177
+ sshpass: {
178
+ usedBy: ["pix-ssh"],
179
+ os: UNIX,
180
+ versionArgs: ["-V"],
181
+ hint: { darwin: "brew install sshpass", default: "install sshpass" },
182
+ },
183
+ sudo: { usedBy: ["pix-sudo"], os: UNIX, hint: { default: "install sudo" } },
184
+ "wl-paste": { usedBy: ["pix-ask"], os: LINUX, hint: { default: "install wl-clipboard" } },
185
+ xclip: {
186
+ usedBy: ["pix-ask"],
187
+ os: LINUX,
188
+ versionArgs: ["-version"],
189
+ hint: { default: "install xclip" },
190
+ },
191
+ wslpath: {
192
+ usedBy: ["pix-ask"],
193
+ os: LINUX,
194
+ wslOnly: true,
195
+ versionArgs: null,
196
+ hint: { default: "WSL only" },
197
+ },
198
+ open: {
199
+ usedBy: ["pix-mcp"],
200
+ os: ["darwin"],
201
+ versionArgs: null,
202
+ hint: { default: "built into macOS" },
203
+ },
204
+ "xdg-open": { usedBy: ["pix-mcp"], os: LINUX, hint: { default: "install xdg-utils" } },
205
+ cmd: {
206
+ usedBy: ["pix-mcp"],
207
+ os: ["win32"],
208
+ versionArgs: null,
209
+ knownPaths: (env) => [`${envVar(env, "SystemRoot") ?? "C:\\Windows"}\\System32\\cmd.exe`],
210
+ hint: { default: "built into Windows" },
211
+ },
212
+ npm: {
213
+ usedBy: ["pix-mcp"],
214
+ os: ALL,
215
+ hint: { win32: "winget install OpenJS.NodeJS.LTS", default: "install Node.js" },
216
+ },
217
+ npx: {
218
+ usedBy: ["pix-mcp"],
219
+ os: ALL,
220
+ hint: { win32: "winget install OpenJS.NodeJS.LTS", default: "install Node.js" },
221
+ },
222
+ pi: {
223
+ usedBy: ["pix-update", "pix-models", "pix-welcome"],
224
+ os: ALL,
225
+ hint: { default: "npm install -g @earendil-works/pi-coding-agent" },
226
+ },
227
+ powershell: {
228
+ usedBy: ["pix-powershell", "pix-ask", "pix-voice"],
229
+ os: ["win32", "linux"],
230
+ wslOnly: true,
231
+ // WSL reaches Windows PowerShell through interop as powershell.exe.
232
+ names: ["powershell", "powershell.exe"],
233
+ versionArgs: null,
234
+ hint: { linux: "WSL only (Windows interop)", default: "built into Windows" },
235
+ },
236
+ wslview: {
237
+ usedBy: ["pix-mcp"],
238
+ os: LINUX,
239
+ wslOnly: true,
240
+ optional: true,
241
+ versionArgs: null,
242
+ hint: { default: "WSL only: install wslu" },
243
+ },
244
+ bash: {
245
+ usedBy: ["pix-bash", "pix-mcp"],
246
+ os: ALL,
247
+ hint: { win32: "winget install Git.Git (Git Bash)", default: "install bash" },
248
+ // Same locations Pi searches: Git for Windows puts only Git\cmd on PATH.
249
+ knownPaths: (env, h) =>
250
+ h.os === "win32"
251
+ ? [envVar(env, "ProgramFiles"), envVar(env, "ProgramFiles(x86)")]
252
+ .filter((d): d is string => !!d)
253
+ // Native join: backslashes on real Windows, and the fake-win32 test runs anywhere.
254
+ .map((d) => join(d, "Git", "bin", "bash.exe"))
255
+ : [],
256
+ },
257
+ } satisfies Record<string, BinarySpec>;
258
+
259
+ export type BinaryName = keyof typeof CATALOG;
260
+
261
+ export const BINARY_NAMES = Object.keys(CATALOG).sort() as BinaryName[];
262
+
263
+ export function specOf(name: string): BinarySpec | undefined {
264
+ return (CATALOG as Record<string, BinarySpec>)[name];
265
+ }
266
+
267
+ export function hintFor(spec: BinarySpec, host: HostPlatform): string {
268
+ return spec.hint[host.os] ?? spec.hint.default ?? "";
269
+ }
270
+
271
+ /** Release asset pix would download on this host, or undefined when it only checks. */
272
+ export function downloadAsset(
273
+ spec: BinarySpec,
274
+ host: HostPlatform,
275
+ version = "0",
276
+ ): string | undefined {
277
+ if (!spec.download || host.termux) return undefined;
278
+ return spec.download.asset(host, version);
279
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * ensure.ts — resolve a binary, downloading it into `<agentDir>/bin` when the
3
+ * catalog has a trusted release for this host.
4
+ *
5
+ * Mirrors Pi's own fd/rg downloader (latest-release redirect, system tar/unzip,
6
+ * unique temp dir + rename) and adds SHA-256 verification when the release
7
+ * publishes a checksum manifest. Reports progress only through `onStatus`;
8
+ * callers decide where it appears (runtime cannot import UI code).
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import { createHash, randomBytes } from "node:crypto";
13
+ import {
14
+ chmodSync,
15
+ createWriteStream,
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ renameSync,
20
+ rmSync,
21
+ } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { Readable, Transform } from "node:stream";
24
+ import { pipeline } from "node:stream/promises";
25
+ import { binDir } from "../paths.ts";
26
+ import { currentPlatform, type HostPlatform } from "../platform.ts";
27
+ import { type BinaryName, type BinarySpec, downloadAsset, hintFor, specOf } from "./catalog.ts";
28
+ import { BinaryMissingError, lookupTool, type ResolvedTool } from "./resolve.ts";
29
+
30
+ export type ToolStatus =
31
+ | { kind: "downloading"; name: string; version: string; url: string; size?: string }
32
+ | { kind: "installed"; name: string; path: string; version: string; verified: boolean }
33
+ | { kind: "failed"; name: string; error: string; hint: string };
34
+
35
+ export interface EnsureOptions {
36
+ onStatus?: (status: ToolStatus) => void;
37
+ signal?: AbortSignal;
38
+ env?: NodeJS.ProcessEnv;
39
+ host?: HostPlatform;
40
+ /** Injected fetch for tests. */
41
+ fetch?: typeof fetch;
42
+ }
43
+
44
+ const LOOKUP_TIMEOUT_MS = 15_000;
45
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
46
+ const USER_AGENT = "pix-runtime";
47
+
48
+ export function isOffline(env: NodeJS.ProcessEnv = process.env): boolean {
49
+ const v = env.PI_OFFLINE?.toLowerCase();
50
+ return v === "1" || v === "true" || v === "yes";
51
+ }
52
+
53
+ const inflight = new Map<string, Promise<ResolvedTool>>();
54
+
55
+ /**
56
+ * Resolve `name`; if missing and downloadable here, download it. Concurrent
57
+ * calls for one name share a single download. Throws {@link BinaryMissingError}.
58
+ */
59
+ export function ensureTool(
60
+ name: BinaryName | (string & {}),
61
+ opts: EnsureOptions = {},
62
+ ): Promise<ResolvedTool> {
63
+ const hit = lookupTool(name, { env: opts.env, host: opts.host });
64
+ if (hit.state === "ok" && hit.path && hit.source) {
65
+ return Promise.resolve({ name, path: hit.path, source: hit.source });
66
+ }
67
+ if (hit.state === "broken")
68
+ return Promise.reject(new BinaryMissingError(name, "broken", hit.hint, hit.error));
69
+ const spec = specOf(name);
70
+ const host = opts.host ?? currentPlatform();
71
+ if (!spec || !downloadAsset(spec, host)) {
72
+ return Promise.reject(new BinaryMissingError(name, hit.state, hit.hint));
73
+ }
74
+ if (isOffline(opts.env)) {
75
+ return Promise.reject(
76
+ new BinaryMissingError(name, "missing", hit.hint, "PI_OFFLINE is set; skipping download"),
77
+ );
78
+ }
79
+ const key = `${binDir(opts.env)}::${name}`;
80
+ const running = inflight.get(key);
81
+ if (running) return running;
82
+ const job = installFromRelease(name, spec, host, opts).finally(() => inflight.delete(key));
83
+ inflight.set(key, job);
84
+ return job;
85
+ }
86
+
87
+ /** Download + verify + extract one catalog recipe into binDir. Exported for tests. */
88
+ export async function installFromRelease(
89
+ name: string,
90
+ spec: BinarySpec,
91
+ host: HostPlatform,
92
+ opts: EnsureOptions = {},
93
+ ): Promise<ResolvedTool> {
94
+ const recipe = spec.download;
95
+ const hint = hintFor(spec, host);
96
+ const doFetch = opts.fetch ?? fetch;
97
+ const fail = (error: unknown): never => {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ opts.onStatus?.({ kind: "failed", name, error: message, hint });
100
+ throw new BinaryMissingError(name, "missing", hint, `download failed: ${message}`);
101
+ };
102
+ if (!recipe) return fail("no download recipe");
103
+
104
+ const bin = binDir(opts.env);
105
+ const exeName = `${name}${host.exe}`;
106
+ const target = join(bin, exeName);
107
+ const work = join(bin, `.pix-dl-${name}-${process.pid}-${randomBytes(4).toString("hex")}`);
108
+ try {
109
+ const tag = await latestTag(recipe.repo, doFetch, opts.signal);
110
+ const version = tag.replace(/^(v|release-)/, "");
111
+ const asset = recipe.asset(host, version);
112
+ if (!asset) return fail(`no ${recipe.repo} asset for ${host.os}/${host.arch}`);
113
+ const base = `https://github.com/${recipe.repo}/releases/download/${tag}`;
114
+ const url = `${base}/${asset}`;
115
+ opts.onStatus?.({ kind: "downloading", name, version, url, size: recipe.size });
116
+
117
+ mkdirSync(work, { recursive: true });
118
+ const archive = join(work, asset);
119
+ const sha256 = await download(url, archive, doFetch, opts.signal);
120
+ // A recipe that declares a manifest must verify. A missing manifest fails the
121
+ // install, so a renamed or removed file cannot turn verification off quietly.
122
+ const verified = recipe.checksums !== undefined;
123
+ if (recipe.checksums) {
124
+ const expected = await expectedChecksum(
125
+ `${base}/${recipe.checksums}`,
126
+ asset,
127
+ doFetch,
128
+ opts.signal,
129
+ );
130
+ if (expected !== sha256)
131
+ throw new Error(`checksum mismatch for ${asset} (expected ${expected}, got ${sha256})`);
132
+ }
133
+ const out = join(work, "x");
134
+ mkdirSync(out);
135
+ extract(archive, out, host);
136
+ const found = findFile(out, exeName);
137
+ if (!found) throw new Error(`${exeName} not found in ${asset}`);
138
+ if (host.os !== "win32") chmodSync(found, 0o755);
139
+ mkdirSync(bin, { recursive: true });
140
+ try {
141
+ renameSync(found, target);
142
+ } catch (err) {
143
+ // A concurrent installer (another Pi process) may have won the rename,
144
+ // or Windows refuses to replace an in-use exe; either way a binary exists.
145
+ if (!existsSync(target)) throw err;
146
+ }
147
+ opts.onStatus?.({ kind: "installed", name, path: target, version, verified });
148
+ return { name, path: target, source: "download", version };
149
+ } catch (err) {
150
+ if (err instanceof BinaryMissingError) throw err;
151
+ return fail(err);
152
+ } finally {
153
+ rmSync(work, { recursive: true, force: true });
154
+ }
155
+ }
156
+
157
+ function withTimeout(ms: number, signal?: AbortSignal): AbortSignal {
158
+ const t = AbortSignal.timeout(ms);
159
+ return signal ? AbortSignal.any([signal, t]) : t;
160
+ }
161
+
162
+ /** Latest release tag via the `/releases/latest` redirect (no API quota). */
163
+ async function latestTag(
164
+ repo: string,
165
+ doFetch: typeof fetch,
166
+ signal?: AbortSignal,
167
+ ): Promise<string> {
168
+ const res = await doFetch(`https://github.com/${repo}/releases/latest`, {
169
+ redirect: "manual",
170
+ headers: { "User-Agent": USER_AGENT },
171
+ signal: withTimeout(LOOKUP_TIMEOUT_MS, signal),
172
+ });
173
+ await res.body?.cancel().catch(() => {});
174
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
175
+ if (!location?.includes("/releases/tag/")) {
176
+ throw new Error(`cannot resolve latest ${repo} release (HTTP ${res.status})`);
177
+ }
178
+ const tag = new URL(location, "https://github.com").pathname.split("/").pop();
179
+ if (!tag) throw new Error(`unexpected redirect for ${repo}: ${location}`);
180
+ return decodeURIComponent(tag);
181
+ }
182
+
183
+ /** Stream `url` to `dest`, returning its SHA-256 hex. */
184
+ async function download(
185
+ url: string,
186
+ dest: string,
187
+ doFetch: typeof fetch,
188
+ signal?: AbortSignal,
189
+ ): Promise<string> {
190
+ const res = await doFetch(url, {
191
+ headers: { "User-Agent": USER_AGENT },
192
+ signal: withTimeout(DOWNLOAD_TIMEOUT_MS, signal),
193
+ });
194
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`);
195
+ const hash = createHash("sha256");
196
+ const tap = new Transform({
197
+ transform(chunk: Buffer, _enc, cb) {
198
+ hash.update(chunk);
199
+ cb(null, chunk);
200
+ },
201
+ });
202
+ await pipeline(
203
+ Readable.fromWeb(res.body as import("node:stream/web").ReadableStream),
204
+ tap,
205
+ createWriteStream(dest),
206
+ );
207
+ return hash.digest("hex");
208
+ }
209
+
210
+ /** Expected hash for `asset` from a `<sha256> <name>` manifest. Throws when the manifest or entry is missing. */
211
+ async function expectedChecksum(
212
+ url: string,
213
+ asset: string,
214
+ doFetch: typeof fetch,
215
+ signal?: AbortSignal,
216
+ ): Promise<string> {
217
+ const res = await doFetch(url, {
218
+ headers: { "User-Agent": USER_AGENT },
219
+ signal: withTimeout(LOOKUP_TIMEOUT_MS, signal),
220
+ });
221
+ if (res.status === 404) {
222
+ await res.body?.cancel().catch(() => {});
223
+ throw new Error(`checksum manifest missing (HTTP 404): ${url}`);
224
+ }
225
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
226
+ const text = await res.text();
227
+ for (const line of text.split(/\r?\n/)) {
228
+ const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
229
+ if (m?.[1] && m[2]?.trim() === asset) return m[1].toLowerCase();
230
+ }
231
+ throw new Error(`${asset} is not listed in the release checksums`);
232
+ }
233
+
234
+ function run(cmd: string, args: string[]): string | null {
235
+ const r = spawnSync(cmd, args, { stdio: "pipe", windowsHide: true });
236
+ if (!r.error && r.status === 0) return null;
237
+ const msg = r.error?.message || r.stderr?.toString().trim() || `exit ${r.status}`;
238
+ return `${cmd}: ${msg}`;
239
+ }
240
+
241
+ function windowsTar(): string {
242
+ const root = process.env.SystemRoot ?? process.env.WINDIR;
243
+ const sys = root ? join(root, "System32", "tar.exe") : "";
244
+ return sys && existsSync(sys) ? sys : "tar.exe";
245
+ }
246
+
247
+ /** Extract with system tools only (same approach as Pi): no npm deps. */
248
+ function extract(archive: string, dest: string, host: HostPlatform): void {
249
+ const failures: string[] = [];
250
+ const attempt = (cmd: string, args: string[]): boolean => {
251
+ const err = run(cmd, args);
252
+ if (err) failures.push(err);
253
+ return !err;
254
+ };
255
+ if (host.os === "win32") {
256
+ if (attempt(windowsTar(), ["xf", archive, "-C", dest])) return;
257
+ if (archive.endsWith(".zip")) {
258
+ const ps =
259
+ "& { param($a, $d) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $a -DestinationPath $d -Force }";
260
+ if (
261
+ attempt("powershell.exe", [
262
+ "-NoLogo",
263
+ "-NoProfile",
264
+ "-NonInteractive",
265
+ "-Command",
266
+ ps,
267
+ archive,
268
+ dest,
269
+ ])
270
+ )
271
+ return;
272
+ }
273
+ } else if (archive.endsWith(".zip")) {
274
+ if (attempt("unzip", ["-q", archive, "-d", dest])) return;
275
+ if (attempt("tar", ["xf", archive, "-C", dest])) return;
276
+ } else {
277
+ const flag = archive.endsWith(".tar.xz") ? "xJf" : "xzf";
278
+ if (attempt("tar", [flag, archive, "-C", dest])) return;
279
+ }
280
+ throw new Error(`cannot extract ${archive}: ${failures.join("; ")}`);
281
+ }
282
+
283
+ function findFile(root: string, fileName: string): string | undefined {
284
+ const stack = [root];
285
+ while (stack.length) {
286
+ const dir = stack.pop() as string;
287
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
288
+ const full = join(dir, entry.name);
289
+ if (entry.isFile() && entry.name === fileName) return full;
290
+ if (entry.isDirectory()) stack.push(full);
291
+ }
292
+ }
293
+ return undefined;
294
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @xynogen/pix-runtime/binaries — one catalog, resolver, and downloader for
3
+ * every external command pix packages run. See README "Binaries".
4
+ */
5
+
6
+ export {
7
+ BINARY_NAMES,
8
+ type BinaryName,
9
+ type BinarySpec,
10
+ CATALOG,
11
+ type DownloadRecipe,
12
+ downloadAsset,
13
+ hintFor,
14
+ specOf,
15
+ } from "./catalog.ts";
16
+ export {
17
+ type EnsureOptions,
18
+ ensureTool,
19
+ installFromRelease,
20
+ isOffline,
21
+ type ToolStatus,
22
+ } from "./ensure.ts";
23
+ export {
24
+ BinaryMissingError,
25
+ type LookupOptions,
26
+ listTools,
27
+ lookupTool,
28
+ type ResolvedTool,
29
+ requireTool,
30
+ resolveTool,
31
+ type ToolLookup,
32
+ type ToolSource,
33
+ type ToolState,
34
+ toolVersion,
35
+ } from "./resolve.ts";
36
+ export {
37
+ type BinaryChoices,
38
+ type BinaryStoreState,
39
+ binaryFilePath,
40
+ readBinaryStore,
41
+ setBinaryChoice,
42
+ syncBinaryStore,
43
+ } from "./store.ts";