@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/src/paths.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * paths.ts — canonical Pi/pix directories, pure and env-injectable.
3
+ *
4
+ * `agentDir()` mirrors Pi's `getAgentDir()` (PI_CODING_AGENT_DIR, `~`-expanded,
5
+ * else `~/.pi/agent`) so code that runs outside a Pi process (CLIs, tests) and
6
+ * code inside it agree. `binDir()` is the same folder Pi downloads fd/rg into.
7
+ *
8
+ * Home resolution never trusts `HOME` alone: it is unset in a Windows Pi process,
9
+ * which silently turned `HOME ?? ""` paths into cwd-relative ones.
10
+ */
11
+
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ /**
17
+ * User home. For the live process this is exactly `os.homedir()` (what Pi's
18
+ * `getAgentDir` uses). An injected env (tests) is read instead: USERPROFILE on
19
+ * Windows, HOME elsewhere, falling back to `os.homedir()`.
20
+ */
21
+ export function homeDir(
22
+ env: NodeJS.ProcessEnv = process.env,
23
+ os: NodeJS.Platform = process.platform,
24
+ ): string {
25
+ if (env === process.env) return homedir();
26
+ const fromEnv = os === "win32" ? env.USERPROFILE : env.HOME;
27
+ return fromEnv || homedir();
28
+ }
29
+
30
+ /** Expand a leading `~` / `~/` / `~\` against {@link homeDir}. */
31
+ export function expandHome(path: string, env: NodeJS.ProcessEnv = process.env): string {
32
+ if (path === "~") return homeDir(env);
33
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(env), path.slice(2));
34
+ return path;
35
+ }
36
+
37
+ /** Pi agent dir: `PI_CODING_AGENT_DIR` (tilde-expanded) or `~/.pi/agent`. */
38
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
39
+ const override = env.PI_CODING_AGENT_DIR;
40
+ if (override) return expandHome(override, env);
41
+ return join(homeDir(env), ".pi", "agent");
42
+ }
43
+
44
+ /** Pi's managed binary dir (`<agentDir>/bin`), shared with Pi's fd/rg downloads. */
45
+ export function binDir(env: NodeJS.ProcessEnv = process.env): string {
46
+ return join(agentDir(env), "bin");
47
+ }
48
+
49
+ /**
50
+ * Project config dir: `<cwd>/.pi` (Pi's project `settings.json`, `mcp.json`,
51
+ * `agents/`, `plans/`, `lsp.json`). With no `cwd` it returns the relative `.pi`,
52
+ * for paths that resolve against the process cwd or show in text.
53
+ */
54
+ export function projectDir(cwd = "."): string {
55
+ return join(cwd, ".pi");
56
+ }
57
+
58
+ /**
59
+ * OS temp dir, shared with every other program. Never delete it whole, only
60
+ * your own entries in it. For the live process this is exactly `os.tmpdir()`.
61
+ * An injected env (tests) is read instead: TEMP/TMP on Windows, TMPDIR
62
+ * elsewhere, falling back to `os.tmpdir()`.
63
+ */
64
+ export function tempDir(
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ os: NodeJS.Platform = process.platform,
67
+ ): string {
68
+ if (env === process.env) return tmpdir();
69
+ const fromEnv = os === "win32" ? env.TEMP || env.TMP : env.TMPDIR;
70
+ return fromEnv || tmpdir();
71
+ }
72
+
73
+ /**
74
+ * Absolute path to a file shipped next to a module: `moduleFile(import.meta.url, "..", "SOP.md")`.
75
+ * Use it for package assets (SOP.md, skills/). It does not use `URL.pathname`, which gives
76
+ * `/C:/...` on Windows. It does not use `require.resolve("<pkg>/package.json")`, which Node
77
+ * rejects when `exports` omits it.
78
+ */
79
+ export function moduleFile(moduleUrl: string, ...segments: string[]): string {
80
+ return resolve(dirname(fileURLToPath(moduleUrl)), ...segments);
81
+ }
82
+
83
+ /** Pi cache root: `$XDG_CACHE_HOME/pi` or `~/.cache/pi`. */
84
+ export function cacheDir(env: NodeJS.ProcessEnv = process.env): string {
85
+ const xdg = env.XDG_CACHE_HOME;
86
+ return join(xdg || join(homeDir(env), ".cache"), "pi");
87
+ }
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  closeSync,
11
11
  existsSync,
12
+ linkSync,
12
13
  mkdirSync,
13
14
  openSync,
14
15
  readFileSync,
@@ -18,6 +19,7 @@ import {
18
19
  writeFileSync,
19
20
  writeSync,
20
21
  } from "node:fs";
22
+ import { hostname } from "node:os";
21
23
  import { dirname, join } from "node:path";
22
24
  import type { RawDocument } from "./schema.ts";
23
25
 
@@ -38,19 +40,62 @@ export class ConfigLockError extends ConfigWriteError {
38
40
  }
39
41
  }
40
42
 
43
+ /** The config file is not valid JSON or not a JSON object. Never overwrite it. */
44
+ export class ConfigParseError extends Error {
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = "ConfigParseError";
48
+ }
49
+ }
50
+
41
51
  /** Filesystem adapter — tests inject an in-memory or temp-dir implementation. */
42
52
  export interface StorageAdapter {
43
53
  readonly path: string;
44
54
  readRaw(): string | undefined;
45
- /** Atomically replace the config file contents. */
46
- writeAtomic(contents: string): void;
55
+ /**
56
+ * Read-modify-write under the cross-process lock: `fn` gets the current file
57
+ * text and returns the new contents (atomically written) or undefined (no
58
+ * write). Another process cannot write between the read and the write.
59
+ */
60
+ transact(fn: (raw: string | undefined) => string | undefined): void;
47
61
  ensureDir(): void;
48
62
  }
49
63
 
50
- const LOCK_STALE_MS = 30_000;
64
+ // A healthy holder keeps the lock for milliseconds (read + write + rename).
65
+ // Stale must be shorter than the retry budget, or a crashed holder makes every
66
+ // writer fail until it expires.
67
+ const LOCK_STALE_MS = 4_000;
51
68
  const LOCK_RETRY_MS = 25;
52
69
  const LOCK_MAX_RETRIES = 200; // ~5s budget
53
70
 
71
+ interface LockOwner {
72
+ pid: number;
73
+ host: string;
74
+ }
75
+
76
+ function parseOwner(text: string): LockOwner | undefined {
77
+ try {
78
+ const o = JSON.parse(text) as Partial<LockOwner>;
79
+ return typeof o.pid === "number" && typeof o.host === "string"
80
+ ? { pid: o.pid, host: o.host }
81
+ : undefined;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ /** True only when the owner ran on this host and its process is gone. */
88
+ function ownerIsDead(owner: LockOwner | undefined): boolean {
89
+ if (!owner || owner.host !== hostname() || owner.pid === process.pid) return false;
90
+ try {
91
+ process.kill(owner.pid, 0);
92
+ return false;
93
+ } catch (err) {
94
+ // EPERM: the process exists under another user.
95
+ return (err as NodeJS.ErrnoException).code === "ESRCH";
96
+ }
97
+ }
98
+
54
99
  /** Node/Bun filesystem storage rooted at `<agentDir>/pix.json`. */
55
100
  export class FileStorage implements StorageAdapter {
56
101
  readonly path: string;
@@ -75,28 +120,63 @@ export class FileStorage implements StorageAdapter {
75
120
  }
76
121
 
77
122
  private acquireLock(): void {
123
+ const me = JSON.stringify({ pid: process.pid, host: hostname() } satisfies LockOwner);
78
124
  for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
79
125
  try {
80
126
  const fd = openSync(this.lockPath, "wx", 0o600);
81
- writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
82
- closeSync(fd);
83
- return;
84
- } catch {
85
- // Reclaim a stale lock only when older than the threshold.
86
127
  try {
87
- const age = Date.now() - statSync(this.lockPath).mtimeMs;
88
- if (age > LOCK_STALE_MS) {
89
- rmSync(this.lockPath, { force: true });
90
- continue;
91
- }
92
- } catch {
93
- // Lock vanished between open and stat — retry immediately.
94
- continue;
128
+ writeSync(fd, me);
129
+ } finally {
130
+ closeSync(fd);
95
131
  }
96
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
132
+ return;
133
+ } catch (err) {
134
+ // Only EEXIST means "another writer holds it". EACCES/EROFS/ENOSPC will
135
+ // not clear by waiting, so fail at once with the real cause.
136
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST")
137
+ throw new ConfigLockError(`could not create ${this.lockPath}`, err);
97
138
  }
139
+ if (this.reclaimIfStale()) continue;
140
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
141
+ }
142
+ throw new ConfigLockError(`could not acquire ${this.lockPath} (held by another process)`);
143
+ }
144
+
145
+ /**
146
+ * Remove the lock when its owner process is dead (same host) or it is older
147
+ * than {@link LOCK_STALE_MS}. Returns true when the caller should retry now.
148
+ *
149
+ * The lock moves away by rename, which is atomic, so only one waiter takes a
150
+ * given lock file. If that file is not the one judged stale (another waiter
151
+ * already reclaimed it and a new holder wrote a fresh lock), it goes back with
152
+ * linkSync, which never overwrites.
153
+ * ponytail: a third writer can still lock in the few microseconds between the
154
+ * rename and the link. Then the restored holder loses mutual exclusion once.
155
+ * Upgrade path: an OS file lock (flock / LockFileEx) through a native addon.
156
+ */
157
+ private reclaimIfStale(): boolean {
158
+ let text: string;
159
+ let mtimeMs: number;
160
+ try {
161
+ text = readFileSync(this.lockPath, "utf-8");
162
+ mtimeMs = statSync(this.lockPath).mtimeMs;
163
+ } catch {
164
+ return true; // The lock vanished between open and read: retry at once.
165
+ }
166
+ if (!ownerIsDead(parseOwner(text)) && Date.now() - mtimeMs <= LOCK_STALE_MS) return false;
167
+ const moved = `${this.lockPath}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
168
+ try {
169
+ renameSync(this.lockPath, moved);
170
+ } catch {
171
+ return true; // Another waiter took it first.
98
172
  }
99
- throw new ConfigLockError(`could not acquire ${this.lockPath}`);
173
+ try {
174
+ if (readFileSync(moved, "utf-8") !== text) linkSync(moved, this.lockPath);
175
+ } catch {
176
+ // EEXIST: a newer lock exists, which is correct. Unreadable: treat as stale.
177
+ }
178
+ rmSync(moved, { force: true });
179
+ return true;
100
180
  }
101
181
 
102
182
  private releaseLock(): void {
@@ -107,9 +187,19 @@ export class FileStorage implements StorageAdapter {
107
187
  }
108
188
  }
109
189
 
110
- writeAtomic(contents: string): void {
190
+ transact(fn: (raw: string | undefined) => string | undefined): void {
111
191
  this.ensureDir();
112
192
  this.acquireLock();
193
+ try {
194
+ const contents = fn(this.readRaw());
195
+ if (contents !== undefined) this.writeAtomic(contents);
196
+ } finally {
197
+ this.releaseLock();
198
+ }
199
+ }
200
+
201
+ /** Temp file + rename. The caller holds the lock. */
202
+ private writeAtomic(contents: string): void {
113
203
  const tmp = `${this.path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
114
204
  try {
115
205
  writeFileSync(tmp, contents, { mode: 0o600 });
@@ -121,8 +211,6 @@ export class FileStorage implements StorageAdapter {
121
211
  /* ignore */
122
212
  }
123
213
  throw new ConfigWriteError(`write failed: ${this.path}`, err);
124
- } finally {
125
- this.releaseLock();
126
214
  }
127
215
  }
128
216
  }
@@ -149,16 +237,22 @@ export class WriteQueue {
149
237
 
150
238
  // ── Raw document read/parse ──────────────────────────────────────────────────
151
239
 
240
+ /**
241
+ * Parse the config file. Missing or empty means `{}`. Invalid JSON or a
242
+ * non-object throws {@link ConfigParseError}: a hand-edit typo must never be
243
+ * read as "all defaults" and then written back over the user's file.
244
+ */
152
245
  export function parseRawDocument(text: string | undefined): RawDocument {
153
- if (!text) return {};
246
+ if (!text?.trim()) return {};
247
+ let parsed: unknown;
154
248
  try {
155
- const parsed = JSON.parse(text) as unknown;
156
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
157
- ? (parsed as RawDocument)
158
- : {};
159
- } catch {
160
- return {};
249
+ parsed = JSON.parse(text);
250
+ } catch (err) {
251
+ throw new ConfigParseError(`invalid JSON: ${(err as Error).message}`);
161
252
  }
253
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
254
+ throw new ConfigParseError("top level is not a JSON object");
255
+ return parsed as RawDocument;
162
256
  }
163
257
 
164
258
  export function serializeRawDocument(doc: RawDocument): string {
@@ -6,6 +6,7 @@
6
6
  * Headless hosts get a notify summary instead of the overlay.
7
7
  */
8
8
 
9
+ import { dirname } from "node:path";
9
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
11
  import {
11
12
  Key,
@@ -15,6 +16,7 @@ import {
15
16
  visibleWidth,
16
17
  wrapTextWithAnsi,
17
18
  } from "@earendil-works/pi-tui";
19
+ import { createBinariesTab } from "./binaries-tab.ts";
18
20
  import { icon } from "./icon-catalog.ts";
19
21
  import type { PixRuntime } from "./runtime.ts";
20
22
  import type { DeepPartial, SectionHandle } from "./schema.ts";
@@ -184,6 +186,13 @@ const SETTINGS: SettingRow<unknown>[] = [
184
186
  }),
185
187
  ];
186
188
 
189
+ /** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
190
+ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
191
+ return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
192
+ }
193
+
194
+ const TABS = ["Settings", "Binaries"] as const;
195
+
187
196
  function buildSummary(runtime: PixRuntime): string {
188
197
  const lines = [`Pix Settings (${runtime.path})`, ""];
189
198
  let lastSection = "";
@@ -197,6 +206,12 @@ function buildSummary(runtime: PixRuntime): string {
197
206
  const isDefault = value === row.read(row.handle.defaults);
198
207
  lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
199
208
  }
209
+ const binaries = createBinariesTab({
210
+ env: binariesEnv(runtime),
211
+ theme: { fg: (_c, t) => t, bold: (t) => t },
212
+ requestRender: () => {},
213
+ });
214
+ lines.push("", "[Binaries]", ...binaries.summary());
200
215
  return lines.join("\n");
201
216
  }
202
217
 
@@ -236,6 +251,29 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
236
251
  let visibleBodyLines = 1;
237
252
  let maxBodyOffset = 0;
238
253
  let inspectingPage = false;
254
+ let tab: (typeof TABS)[number] = "Settings";
255
+ let binaries: ReturnType<typeof createBinariesTab> | undefined;
256
+ const binariesTab = () => {
257
+ binaries ??= createBinariesTab({
258
+ env: binariesEnv(runtime),
259
+ theme,
260
+ requestRender: () => tui.requestRender(),
261
+ });
262
+ return binaries;
263
+ };
264
+ const tabBar = () =>
265
+ TABS.map((name) =>
266
+ name === tab
267
+ ? theme.fg("accent", theme.bold(` ${name} `))
268
+ : theme.fg("muted", ` ${name} `),
269
+ ).join(theme.fg("muted", "│"));
270
+ const switchTab = (direction: -1 | 1) => {
271
+ const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
272
+ tab = TABS[next] ?? "Settings";
273
+ bodyOffset = 0;
274
+ inspectingPage = false;
275
+ if (tab === "Binaries") binariesTab().refresh();
276
+ };
239
277
 
240
278
  const cycle = (direction: -1 | 1) => {
241
279
  const row = SETTINGS[selected];
@@ -274,6 +312,40 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
274
312
 
275
313
  return {
276
314
  render: (width: number) => {
315
+ const frame = {
316
+ width,
317
+ maxHeight: modalHeight(
318
+ tui.terminal?.rows,
319
+ runtime.get(prettySection).maxRenderHeight,
320
+ ),
321
+ title: `${icon("settings")} Pix Settings`,
322
+ titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
323
+ color: (s: string) => theme.fg("accent", s),
324
+ bg: (s: string) => theme.bg("customMessageBg", s),
325
+ };
326
+ const tabGuide = guide("tab", "switch tab");
327
+ if (tab === "Binaries") {
328
+ const bt = binariesTab();
329
+ const view = bt.view(width);
330
+ const hints = view.footer.map(([key, action]) => guide(key, action)).join(guideSep);
331
+ const result = frameModal({
332
+ ...frame,
333
+ header: [tabBar(), "", ...view.header],
334
+ body: view.body,
335
+ footer: [
336
+ "",
337
+ bt.editing
338
+ ? hints
339
+ : hints + guideSep + tabGuide + guideSep + guide("esc", "close"),
340
+ ],
341
+ bodyOffset,
342
+ selectedBodyLine: inspectingPage ? undefined : view.selectedBodyLine,
343
+ });
344
+ bodyOffset = result.bodyOffset;
345
+ visibleBodyLines = Math.max(1, result.visibleBodyLines);
346
+ maxBodyOffset = result.maxBodyOffset;
347
+ return result.lines;
348
+ }
277
349
  const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
278
350
  const body: string[] = [];
279
351
  const settingBodyLines: number[] = [];
@@ -295,14 +367,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
295
367
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
296
368
  }
297
369
  const result = frameModal({
298
- width,
299
- maxHeight: modalHeight(
300
- tui.terminal?.rows,
301
- runtime.get(prettySection).maxRenderHeight,
302
- ),
303
- title: `${icon("settings")} Pix Settings`,
304
- titleColor: (s: string) => theme.fg("accent", theme.bold(s)),
305
- header: [""],
370
+ ...frame,
371
+ header: [tabBar(), ""],
306
372
  body,
307
373
  footer: [
308
374
  "",
@@ -312,12 +378,12 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
312
378
  guideSep +
313
379
  guide("PgUp/PgDn", "inspect") +
314
380
  guideSep +
381
+ tabGuide +
382
+ guideSep +
315
383
  guide("esc", "close"),
316
384
  ],
317
385
  bodyOffset,
318
386
  selectedBodyLine: inspectingPage ? undefined : settingBodyLines[selected],
319
- color: (s: string) => theme.fg("accent", s),
320
- bg: (s: string) => theme.bg("customMessageBg", s),
321
387
  });
322
388
  bodyOffset = result.bodyOffset;
323
389
  visibleBodyLines = Math.max(1, result.visibleBodyLines);
@@ -326,6 +392,24 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
326
392
  },
327
393
  invalidate: () => {},
328
394
  handleInput: (data: string) => {
395
+ if (tab === "Binaries" && binariesTab().editing) {
396
+ // The filter input takes ↑↓ to move through its matches.
397
+ const nav = !binariesTab().editingPath;
398
+ binariesTab().handleInput(data, {
399
+ up: nav && kb.matches(data, "tui.select.up"),
400
+ down: nav && kb.matches(data, "tui.select.down"),
401
+ enter: false,
402
+ });
403
+ inspectingPage = false;
404
+ tui.requestRender();
405
+ return;
406
+ }
407
+ const shiftTab = matchesKey(data, Key.shift(Key.tab));
408
+ if (shiftTab || matchesKey(data, Key.tab)) {
409
+ switchTab(shiftTab ? -1 : 1);
410
+ tui.requestRender();
411
+ return;
412
+ }
329
413
  const pageUp = kb?.matches(data, "tui.select.pageUp") || matchesKey(data, Key.pageUp);
330
414
  const pageDown =
331
415
  kb?.matches(data, "tui.select.pageDown") || matchesKey(data, Key.pageDown);
@@ -340,9 +424,25 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
340
424
  // for letters and special keys alike — raw string compares like
341
425
  // `data === "k"` silently fail under the Kitty keyboard protocol.
342
426
  if (kb.matches(data, "tui.select.cancel")) {
427
+ if (tab === "Binaries" && binariesTab().clearFilter()) {
428
+ tui.requestRender();
429
+ return;
430
+ }
343
431
  done(null);
344
432
  return;
345
433
  }
434
+ if (tab === "Binaries") {
435
+ const handled = binariesTab().handleInput(data, {
436
+ up: kb.matches(data, "tui.select.up"),
437
+ down: kb.matches(data, "tui.select.down"),
438
+ enter: kb.matches(data, "tui.select.confirm"),
439
+ });
440
+ if (handled) {
441
+ inspectingPage = false;
442
+ tui.requestRender();
443
+ }
444
+ return;
445
+ }
346
446
  if (kb.matches(data, "tui.select.up")) move(-1);
347
447
  else if (kb.matches(data, "tui.select.down")) move(1);
348
448
  else if (matchesKey(data, Key.left)) cycle(-1);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * platform.ts — one host description for binary selection and OS branches.
3
+ *
4
+ * Pure except for two cached probes (glibc via `process.report`, WSL via
5
+ * `/proc/version`); every input is injectable so tests cover all hosts.
6
+ */
7
+
8
+ import { readFileSync } from "node:fs";
9
+
10
+ export type HostOs = "win32" | "linux" | "darwin" | "android";
11
+ export type HostArch = "x64" | "arm64";
12
+
13
+ export interface HostPlatform {
14
+ os: HostOs;
15
+ arch: HostArch;
16
+ /** Linux C library; undefined elsewhere. */
17
+ libc?: "glibc" | "musl";
18
+ /** Linux kernel under Windows Subsystem for Linux. */
19
+ wsl: boolean;
20
+ /** Termux on Android. */
21
+ termux: boolean;
22
+ /** Executable suffix. */
23
+ exe: "" | ".exe";
24
+ }
25
+
26
+ export interface HostProbe {
27
+ platform?: NodeJS.Platform;
28
+ arch?: string;
29
+ env?: NodeJS.ProcessEnv;
30
+ /** glibc runtime version, or undefined on musl. */
31
+ glibcVersion?: () => string | undefined;
32
+ /** Contents of `/proc/version`, or undefined when unreadable. */
33
+ procVersion?: () => string | undefined;
34
+ }
35
+
36
+ function defaultGlibc(): string | undefined {
37
+ try {
38
+ const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } };
39
+ return report?.header?.glibcVersionRuntime;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ function defaultProcVersion(): string | undefined {
46
+ try {
47
+ return readFileSync("/proc/version", "utf-8");
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** Describe the current host (or an injected one). */
54
+ export function hostPlatform(probe: HostProbe = {}): HostPlatform {
55
+ const platform = probe.platform ?? process.platform;
56
+ const env = probe.env ?? process.env;
57
+ const arch: HostArch = (probe.arch ?? process.arch) === "arm64" ? "arm64" : "x64";
58
+ const termux = Boolean(env.TERMUX_VERSION) || platform === "android";
59
+ if (platform === "win32") return { os: "win32", arch, wsl: false, termux: false, exe: ".exe" };
60
+ if (platform === "darwin") return { os: "darwin", arch, wsl: false, termux: false, exe: "" };
61
+ if (termux) return { os: "android", arch, wsl: false, termux: true, exe: "" };
62
+ const glibc = (probe.glibcVersion ?? defaultGlibc)();
63
+ const wsl =
64
+ Boolean(env.WSL_DISTRO_NAME || env.WSLENV) ||
65
+ /microsoft|wsl/i.test((probe.procVersion ?? defaultProcVersion)() ?? "");
66
+ return { os: "linux", arch, libc: glibc ? "glibc" : "musl", wsl, termux: false, exe: "" };
67
+ }
68
+
69
+ let cached: HostPlatform | undefined;
70
+
71
+ /** Cached {@link hostPlatform} for the running process. */
72
+ export function currentPlatform(): HostPlatform {
73
+ cached ??= hostPlatform();
74
+ return cached;
75
+ }