@xynogen/pix-runtime 0.10.2 → 0.12.2
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/LICENSE +21 -0
- package/README.md +206 -11
- package/package.json +8 -1
- package/src/audio.ts +298 -0
- package/src/binaries/catalog.ts +279 -0
- package/src/binaries/ensure.ts +294 -0
- package/src/binaries/index.ts +43 -0
- package/src/binaries/resolve.ts +199 -0
- package/src/binaries/store.ts +149 -0
- package/src/binaries-tab.ts +364 -0
- package/src/exec.ts +365 -0
- package/src/extension.ts +35 -3
- package/src/icon-catalog.ts +1 -0
- package/src/os.ts +250 -0
- package/src/paths.ts +87 -0
- package/src/persistence.ts +122 -28
- package/src/pix-command.ts +110 -10
- package/src/platform.ts +75 -0
- package/src/runtime.ts +126 -80
- package/src/safe-path.ts +101 -0
- package/src/testing.ts +19 -3
- package/src/user-shell.ts +51 -0
- package/src/which.ts +21 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve.ts — synchronous, network-free binary lookup.
|
|
3
|
+
*
|
|
4
|
+
* Order: binary.json user choice → `<agentDir>/bin` → known install locations
|
|
5
|
+
* (`system`, e.g. Git Bash on Windows) → PATH. A user choice that
|
|
6
|
+
* does not resolve is "broken" and never silently falls back. An invalid
|
|
7
|
+
* binary.json is "broken" for every tool: its overrides are unknown, so no
|
|
8
|
+
* automatic path may stand in for them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { binDir } from "../paths.ts";
|
|
14
|
+
import { currentPlatform, type HostPlatform } from "../platform.ts";
|
|
15
|
+
import { findExecutableSync } from "../which.ts";
|
|
16
|
+
import {
|
|
17
|
+
BINARY_NAMES,
|
|
18
|
+
type BinaryName,
|
|
19
|
+
type BinarySpec,
|
|
20
|
+
downloadAsset,
|
|
21
|
+
hintFor,
|
|
22
|
+
specOf,
|
|
23
|
+
} from "./catalog.ts";
|
|
24
|
+
import { type BinaryStoreState, cachedBinaryStore, readBinaryStore, userChoice } from "./store.ts";
|
|
25
|
+
|
|
26
|
+
export type ToolSource = "user" | "bin" | "system" | "path" | "download";
|
|
27
|
+
export type ToolState = "ok" | "missing" | "broken" | "unsupported";
|
|
28
|
+
|
|
29
|
+
export interface ResolvedTool {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
source: ToolSource;
|
|
33
|
+
/** Set only right after a download. */
|
|
34
|
+
version?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToolLookup {
|
|
38
|
+
name: string;
|
|
39
|
+
state: ToolState;
|
|
40
|
+
path?: string;
|
|
41
|
+
source?: ToolSource;
|
|
42
|
+
/** The raw binary.json value when the user set one. */
|
|
43
|
+
choice?: string;
|
|
44
|
+
/** Why the state is "broken" when binary.json itself is invalid. */
|
|
45
|
+
error?: string;
|
|
46
|
+
hint: string;
|
|
47
|
+
usedBy: readonly string[];
|
|
48
|
+
/** True when pix can download it on this host. */
|
|
49
|
+
downloadable: boolean;
|
|
50
|
+
/** Nice-to-have: a missing optional tool is not an error. */
|
|
51
|
+
optional: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LookupOptions {
|
|
55
|
+
env?: NodeJS.ProcessEnv;
|
|
56
|
+
host?: HostPlatform;
|
|
57
|
+
/** Pre-read store (avoids re-reading per entry in listTools). */
|
|
58
|
+
store?: BinaryStoreState;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class BinaryMissingError extends Error {
|
|
62
|
+
readonly tool: string;
|
|
63
|
+
readonly hint: string;
|
|
64
|
+
readonly state: ToolState;
|
|
65
|
+
constructor(tool: string, state: ToolState, hint: string, detail?: string) {
|
|
66
|
+
const what =
|
|
67
|
+
state === "broken"
|
|
68
|
+
? `${tool}: ${detail ?? "the path set in binary.json does not exist"}`
|
|
69
|
+
: state === "unsupported"
|
|
70
|
+
? `${tool} is not used on this OS (set a path in binary.json to override)`
|
|
71
|
+
: `${tool} not found`;
|
|
72
|
+
const extra = state === "broken" ? undefined : detail;
|
|
73
|
+
super([what, extra, hint && `install: ${hint}`].filter(Boolean).join(" — "));
|
|
74
|
+
this.name = "BinaryMissingError";
|
|
75
|
+
this.tool = tool;
|
|
76
|
+
this.hint = hint;
|
|
77
|
+
this.state = state;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function namesOf(name: string, spec: BinarySpec | undefined): readonly string[] {
|
|
82
|
+
return spec?.names ?? [name];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Look one binary up. Unknown (non-catalog) names resolve via binary.json → bin → PATH too. */
|
|
86
|
+
export function lookupTool(name: string, opts: LookupOptions = {}): ToolLookup {
|
|
87
|
+
const env = opts.env ?? process.env;
|
|
88
|
+
const host = opts.host ?? currentPlatform();
|
|
89
|
+
const spec = specOf(name);
|
|
90
|
+
const store = opts.store ?? cachedBinaryStore(env);
|
|
91
|
+
const base = {
|
|
92
|
+
name,
|
|
93
|
+
hint: spec ? hintFor(spec, host) : "",
|
|
94
|
+
usedBy: spec?.usedBy ?? [],
|
|
95
|
+
downloadable: spec ? downloadAsset(spec, host) !== undefined : false,
|
|
96
|
+
optional: spec?.optional === true,
|
|
97
|
+
};
|
|
98
|
+
// A tool for another OS stays "unsupported" even when a same-named file exists
|
|
99
|
+
// here (Ubuntu ships /usr/bin/open). pix never runs it on this host.
|
|
100
|
+
const needed =
|
|
101
|
+
!spec || (spec.os.includes(host.os) && !(spec.wslOnly && host.os === "linux" && !host.wsl));
|
|
102
|
+
if (!needed) return { ...base, state: "unsupported" };
|
|
103
|
+
if (store.error)
|
|
104
|
+
return { ...base, state: "broken", error: `binary.json is invalid (${store.error})` };
|
|
105
|
+
const choice = store.choices[name] ?? undefined;
|
|
106
|
+
const pinned = userChoice(store, name, env);
|
|
107
|
+
if (pinned) {
|
|
108
|
+
const found = findExecutableSync(pinned, { env });
|
|
109
|
+
return found
|
|
110
|
+
? { ...base, state: "ok", path: found, source: "user", choice: choice ?? undefined }
|
|
111
|
+
: { ...base, state: "broken", choice: choice ?? undefined };
|
|
112
|
+
}
|
|
113
|
+
const bin = binDir(env);
|
|
114
|
+
for (const n of namesOf(name, spec)) {
|
|
115
|
+
const local = findExecutableSync(join(bin, n), { env });
|
|
116
|
+
if (local) return { ...base, state: "ok", path: local, source: "bin" };
|
|
117
|
+
}
|
|
118
|
+
for (const known of spec?.knownPaths?.(env, host) ?? []) {
|
|
119
|
+
const hit = findExecutableSync(known, { env });
|
|
120
|
+
if (hit) return { ...base, state: "ok", path: hit, source: "system" };
|
|
121
|
+
}
|
|
122
|
+
for (const n of namesOf(name, spec)) {
|
|
123
|
+
const onPath = findExecutableSync(n, { env });
|
|
124
|
+
if (onPath) return { ...base, state: "ok", path: onPath, source: "path" };
|
|
125
|
+
}
|
|
126
|
+
return { ...base, state: "missing" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resolved path, or undefined when missing/broken. Sync, no network. */
|
|
130
|
+
export function resolveTool(
|
|
131
|
+
name: BinaryName | (string & {}),
|
|
132
|
+
opts: LookupOptions = {},
|
|
133
|
+
): ResolvedTool | undefined {
|
|
134
|
+
const hit = lookupTool(name, opts);
|
|
135
|
+
return hit.state === "ok" && hit.path && hit.source
|
|
136
|
+
? { name, path: hit.path, source: hit.source }
|
|
137
|
+
: undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Like {@link resolveTool} but throws {@link BinaryMissingError} with the install hint. */
|
|
141
|
+
export function requireTool(
|
|
142
|
+
name: BinaryName | (string & {}),
|
|
143
|
+
opts: LookupOptions = {},
|
|
144
|
+
): ResolvedTool {
|
|
145
|
+
const hit = lookupTool(name, opts);
|
|
146
|
+
if (hit.state === "ok" && hit.path && hit.source)
|
|
147
|
+
return { name, path: hit.path, source: hit.source };
|
|
148
|
+
throw new BinaryMissingError(name, hit.state, hit.hint, hit.error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every catalog entry plus user-added binary.json keys, sorted by name. */
|
|
152
|
+
export function listTools(opts: LookupOptions = {}): ToolLookup[] {
|
|
153
|
+
const env = opts.env ?? process.env;
|
|
154
|
+
const store = opts.store ?? readBinaryStore(env);
|
|
155
|
+
const names = new Set<string>([...BINARY_NAMES, ...Object.keys(store.choices)]);
|
|
156
|
+
return [...names].sort().map((name) => lookupTool(name, { ...opts, env, store }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** First line of `<path> <versionArgs>` (default `--version`), or undefined. 2 s cap. */
|
|
160
|
+
export function toolVersion(
|
|
161
|
+
name: string,
|
|
162
|
+
path: string,
|
|
163
|
+
timeoutMs = 2000,
|
|
164
|
+
): Promise<string | undefined> {
|
|
165
|
+
const spec = specOf(name);
|
|
166
|
+
if (spec?.versionArgs === null) return Promise.resolve(undefined);
|
|
167
|
+
const args = spec?.versionArgs ?? ["--version"];
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
let out = "";
|
|
170
|
+
let settled = false;
|
|
171
|
+
const finish = (v: string | undefined) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
resolve(v);
|
|
176
|
+
};
|
|
177
|
+
let child: ReturnType<typeof spawn>;
|
|
178
|
+
try {
|
|
179
|
+
child = spawn(path, [...args], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
180
|
+
} catch {
|
|
181
|
+
resolve(undefined);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
child.kill();
|
|
186
|
+
finish(undefined);
|
|
187
|
+
}, timeoutMs);
|
|
188
|
+
const onData = (d: Buffer) => {
|
|
189
|
+
out += d.toString();
|
|
190
|
+
};
|
|
191
|
+
child.stdout?.on("data", onData);
|
|
192
|
+
child.stderr?.on("data", onData);
|
|
193
|
+
child.on("error", () => finish(undefined));
|
|
194
|
+
child.on("close", () => {
|
|
195
|
+
const line = out.split(/\r?\n/).find((l) => l.trim());
|
|
196
|
+
finish(line ? line.trim().slice(0, 80) : undefined);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts — `<agentDir>/binary.json`: the user's binary choices.
|
|
3
|
+
*
|
|
4
|
+
* Holds overrides only: a string = the exact path the user wants. A missing
|
|
5
|
+
* key (or `null`) = automatic (bin/ → known dirs → PATH → download). The full
|
|
6
|
+
* catalog lives in the `/pix` Binaries tab, not in this file. pix only writes
|
|
7
|
+
* the file to apply an edit from that tab, or to drop legacy `null` padding
|
|
8
|
+
* for catalog entries. Discovered paths are never written. Keys unknown to the
|
|
9
|
+
* catalog are preserved untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { writeFileAtomicSync } from "../atomic-write.ts";
|
|
15
|
+
import { agentDir, expandHome } from "../paths.ts";
|
|
16
|
+
import { BINARY_NAMES } from "./catalog.ts";
|
|
17
|
+
|
|
18
|
+
export const BINARY_FILE_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export type BinaryChoices = Record<string, string | null>;
|
|
21
|
+
|
|
22
|
+
export interface BinaryStoreState {
|
|
23
|
+
path: string;
|
|
24
|
+
choices: BinaryChoices;
|
|
25
|
+
/** Parse error message when the file exists but is not valid JSON. */
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function binaryFilePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
30
|
+
return join(agentDir(env), "binary.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseChoices(raw: unknown): BinaryChoices {
|
|
34
|
+
const out: BinaryChoices = {};
|
|
35
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
36
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
37
|
+
if (key.startsWith("$")) continue;
|
|
38
|
+
if (value === null) out[key] = null;
|
|
39
|
+
else if (typeof value === "string" && value.trim()) out[key] = value.trim();
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read binary.json. Missing file → empty choices; invalid JSON → empty choices + error. */
|
|
45
|
+
export function readBinaryStore(env: NodeJS.ProcessEnv = process.env): BinaryStoreState {
|
|
46
|
+
const path = binaryFilePath(env);
|
|
47
|
+
let text: string;
|
|
48
|
+
try {
|
|
49
|
+
text = readFileSync(path, "utf-8");
|
|
50
|
+
} catch {
|
|
51
|
+
return { path, choices: {} };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return { path, choices: parseChoices(JSON.parse(text)) };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { path, choices: {}, error: err instanceof Error ? err.message : String(err) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serialize(choices: BinaryChoices): string {
|
|
61
|
+
const doc: Record<string, unknown> = { $version: BINARY_FILE_VERSION };
|
|
62
|
+
for (const key of Object.keys(choices).sort()) doc[key] = choices[key] ?? null;
|
|
63
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeIfChanged(path: string, choices: BinaryChoices): void {
|
|
67
|
+
const next = serialize(choices);
|
|
68
|
+
let current: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
current = readFileSync(path, "utf-8");
|
|
71
|
+
} catch {
|
|
72
|
+
current = undefined;
|
|
73
|
+
}
|
|
74
|
+
if (current !== next) {
|
|
75
|
+
writeFileAtomicSync(path, next, 0o644);
|
|
76
|
+
cache = undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Lookups run on hot paths (rtk rewrites every bash command); cache the parsed
|
|
81
|
+
// file by path + mtime so a lookup costs one stat.
|
|
82
|
+
let cache: { path: string; mtimeMs: number; state: BinaryStoreState } | undefined;
|
|
83
|
+
|
|
84
|
+
/** {@link readBinaryStore}, cached until the file changes. */
|
|
85
|
+
export function cachedBinaryStore(env: NodeJS.ProcessEnv = process.env): BinaryStoreState {
|
|
86
|
+
const path = binaryFilePath(env);
|
|
87
|
+
let mtimeMs = -1;
|
|
88
|
+
try {
|
|
89
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
90
|
+
} catch {
|
|
91
|
+
/* missing file */
|
|
92
|
+
}
|
|
93
|
+
if (cache && cache.path === path && cache.mtimeMs === mtimeMs) return cache.state;
|
|
94
|
+
const state = readBinaryStore(env);
|
|
95
|
+
cache = { path, mtimeMs, state };
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const CATALOG: ReadonlySet<string> = new Set(BINARY_NAMES);
|
|
100
|
+
|
|
101
|
+
/** Drop `null` for catalog entries: automatic is the default, not an override. */
|
|
102
|
+
function overridesOnly(choices: BinaryChoices): BinaryChoices {
|
|
103
|
+
const out: BinaryChoices = {};
|
|
104
|
+
for (const [key, value] of Object.entries(choices)) {
|
|
105
|
+
if (value === null && CATALOG.has(key)) continue;
|
|
106
|
+
out[key] = value;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Migrate a legacy binary.json (every catalog entry listed as `null`) to the
|
|
113
|
+
* overrides-only form. Never creates the file, and never touches an invalid
|
|
114
|
+
* file (the user must fix it; the tab shows the error).
|
|
115
|
+
*/
|
|
116
|
+
export function syncBinaryStore(env: NodeJS.ProcessEnv = process.env): BinaryStoreState {
|
|
117
|
+
const state = readBinaryStore(env);
|
|
118
|
+
if (state.error) return state;
|
|
119
|
+
const choices = overridesOnly(state.choices);
|
|
120
|
+
if (Object.keys(choices).length !== Object.keys(state.choices).length)
|
|
121
|
+
writeIfChanged(state.path, choices);
|
|
122
|
+
return { path: state.path, choices };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Set (string) or clear (null → key removed, automatic) one user choice. */
|
|
126
|
+
export function setBinaryChoice(
|
|
127
|
+
name: string,
|
|
128
|
+
value: string | null,
|
|
129
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
130
|
+
): BinaryStoreState {
|
|
131
|
+
const state = readBinaryStore(env);
|
|
132
|
+
if (state.error)
|
|
133
|
+
throw new Error(`binary.json is invalid (${state.error}); fix ${state.path} first`);
|
|
134
|
+
const choices = overridesOnly(state.choices);
|
|
135
|
+
if (value?.trim()) choices[name] = value.trim();
|
|
136
|
+
else delete choices[name];
|
|
137
|
+
writeIfChanged(state.path, choices);
|
|
138
|
+
return { path: state.path, choices };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A user choice with `~` expanded, or undefined for automatic. */
|
|
142
|
+
export function userChoice(
|
|
143
|
+
state: BinaryStoreState,
|
|
144
|
+
name: string,
|
|
145
|
+
env = process.env,
|
|
146
|
+
): string | undefined {
|
|
147
|
+
const raw = state.choices[name];
|
|
148
|
+
return raw ? expandHome(raw, env) : undefined;
|
|
149
|
+
}
|