@xynogen/pix-runtime 0.9.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,7 +29,8 @@
29
29
  "./io": "./src/io.ts",
30
30
  "./lfid": "./src/lfid.ts",
31
31
  "./once": "./src/once.ts",
32
- "./testing": "./src/testing.ts"
32
+ "./testing": "./src/testing.ts",
33
+ "./which": "./src/which.ts"
33
34
  },
34
35
  "keywords": [
35
36
  "pi",
@@ -65,6 +65,10 @@ const CATALOG = {
65
65
  // presentation, ascii = tofu-free token.
66
66
  lock: { nerd: "\u{F0341}", unicode: `\u{1F512}${VS}`, ascii: "[!]" },
67
67
  secret: { nerd: "\u{F0306}", unicode: `\u{1F511}${VS}`, ascii: "[key]" },
68
+ "data.boolean": { nerd: "\u25C6", unicode: `\u25C6${VS}`, ascii: "bool" },
69
+ "data.int": { nerd: "#", unicode: "#", ascii: "int" },
70
+ "data.float": { nerd: "\u2248", unicode: `\u2248${VS}`, ascii: "float" },
71
+ "data.string": { nerd: "\u201C", unicode: `\u201C${VS}`, ascii: "str" },
68
72
  settings: { nerd: "\u{F0493}", unicode: `\u2699${VS}`, ascii: "[*]" },
69
73
  update: { nerd: "\u{F01DA}", unicode: `\u2193${VS}`, ascii: "[v]" },
70
74
 
package/src/index.ts CHANGED
@@ -68,3 +68,8 @@ export {
68
68
  type PrettyConfig,
69
69
  prettySection,
70
70
  } from "./sections/index.ts";
71
+ export {
72
+ type FindExecutableOptions,
73
+ findExecutable,
74
+ findExecutableSync,
75
+ } from "./which.ts";
package/src/which.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cross-platform executable lookup — the `which`/`where` a portable Node program
3
+ * lacks. Scans `PATH` (POSIX + Windows), honouring Windows `PATHEXT` so a bare
4
+ * name like `aria2c` resolves to `aria2c.exe`. Returns the first match, or
5
+ * `undefined` when nothing on `PATH` is runnable.
6
+ *
7
+ * A name that already contains a path separator is treated as a direct path and
8
+ * checked in place (still PATHEXT-expanded on Windows), matching `which` semantics.
9
+ *
10
+ * `ponytail:` POSIX runnability is an `X_OK` access check — it does not re-derive
11
+ * effective-uid permission bits. That matches how the shell picks a binary, which
12
+ * is the intent here. Windows treats "exists with an executable extension" as
13
+ * runnable (there is no X bit); this is the conventional `where` behaviour.
14
+ */
15
+
16
+ import { accessSync, constants, statSync } from "node:fs";
17
+ import { access, stat } from "node:fs/promises";
18
+ import { delimiter, isAbsolute, join, sep } from "node:path";
19
+
20
+ const isWindows = process.platform === "win32";
21
+
22
+ /** Windows executable extensions, from `PATHEXT` with a sane fallback. */
23
+ function pathExtensions(env: NodeJS.ProcessEnv): string[] {
24
+ if (!isWindows) return [""];
25
+ const raw = env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
26
+ // Leading "" lets an already-suffixed name (foo.exe) match as-is.
27
+ return ["", ...raw.split(";").filter(Boolean)];
28
+ }
29
+
30
+ /** Directories to scan, from `PATH`. Windows also probes the current directory first. */
31
+ function pathDirs(env: NodeJS.ProcessEnv): string[] {
32
+ const raw = env.PATH ?? env.Path ?? "";
33
+ const dirs = raw.split(delimiter).filter(Boolean);
34
+ return isWindows ? [".", ...dirs] : dirs;
35
+ }
36
+
37
+ export interface FindExecutableOptions {
38
+ /** Environment to read PATH/PATHEXT from. Defaults to `process.env`. */
39
+ env?: NodeJS.ProcessEnv;
40
+ }
41
+
42
+ function candidatesFor(name: string, env: NodeJS.ProcessEnv): string[] {
43
+ const exts = pathExtensions(env);
44
+ const withExts = (base: string): string[] => exts.map((ext) => base + ext);
45
+ // A name with a separator (or absolute) is a direct path — do not scan PATH.
46
+ if (isAbsolute(name) || name.includes(sep) || (isWindows && name.includes("/"))) {
47
+ return withExts(name);
48
+ }
49
+ return pathDirs(env).flatMap((dir) => withExts(join(dir, name)));
50
+ }
51
+
52
+ function runnableSync(p: string): boolean {
53
+ try {
54
+ if (!statSync(p).isFile()) return false;
55
+ if (isWindows) return true;
56
+ accessSync(p, constants.X_OK);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function runnable(p: string): Promise<boolean> {
64
+ try {
65
+ const info = await stat(p);
66
+ if (!info.isFile()) return false;
67
+ if (isWindows) return true;
68
+ await access(p, constants.X_OK);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Find a runnable executable by name on PATH. Returns its path (as joined from
77
+ * PATH), or `undefined` when not found. Prefer {@link findExecutableSync} unless
78
+ * you specifically need to avoid blocking.
79
+ */
80
+ export async function findExecutable(
81
+ name: string,
82
+ options: FindExecutableOptions = {},
83
+ ): Promise<string | undefined> {
84
+ if (!name) return undefined;
85
+ const env = options.env ?? process.env;
86
+ for (const candidate of candidatesFor(name, env)) {
87
+ if (await runnable(candidate)) return candidate;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /** Synchronous {@link findExecutable} — the common case (startup, gate checks). */
93
+ export function findExecutableSync(
94
+ name: string,
95
+ options: FindExecutableOptions = {},
96
+ ): string | undefined {
97
+ if (!name) return undefined;
98
+ const env = options.env ?? process.env;
99
+ for (const candidate of candidatesFor(name, env)) {
100
+ if (runnableSync(candidate)) return candidate;
101
+ }
102
+ return undefined;
103
+ }