@prometheus-ai/utils 0.5.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.
Files changed (61) hide show
  1. package/dist/types/abortable.d.ts +27 -0
  2. package/dist/types/async.d.ts +6 -0
  3. package/dist/types/cli.d.ts +117 -0
  4. package/dist/types/color.d.ts +102 -0
  5. package/dist/types/dirs.d.ts +171 -0
  6. package/dist/types/env.d.ts +55 -0
  7. package/dist/types/fetch-retry.d.ts +80 -0
  8. package/dist/types/format.d.ts +37 -0
  9. package/dist/types/frontmatter.d.ts +25 -0
  10. package/dist/types/fs-error.d.ts +31 -0
  11. package/dist/types/glob.d.ts +28 -0
  12. package/dist/types/hook-fetch.d.ts +16 -0
  13. package/dist/types/index.d.ts +29 -0
  14. package/dist/types/json.d.ts +4 -0
  15. package/dist/types/logger.d.ts +66 -0
  16. package/dist/types/mermaid-ascii.d.ts +11 -0
  17. package/dist/types/mime.d.ts +29 -0
  18. package/dist/types/peek-file.d.ts +29 -0
  19. package/dist/types/postmortem.d.ts +29 -0
  20. package/dist/types/procmgr.d.ts +25 -0
  21. package/dist/types/prompt.d.ts +18 -0
  22. package/dist/types/ptree.d.ts +108 -0
  23. package/dist/types/ring.d.ts +93 -0
  24. package/dist/types/sanitize-text.d.ts +14 -0
  25. package/dist/types/snowflake.d.ts +25 -0
  26. package/dist/types/stream.d.ts +68 -0
  27. package/dist/types/tab-spacing.d.ts +9 -0
  28. package/dist/types/temp.d.ts +14 -0
  29. package/dist/types/type-guards.d.ts +3 -0
  30. package/dist/types/which.d.ts +37 -0
  31. package/package.json +61 -0
  32. package/src/abortable.ts +73 -0
  33. package/src/async.ts +50 -0
  34. package/src/cli.ts +432 -0
  35. package/src/color.ts +302 -0
  36. package/src/dirs.ts +584 -0
  37. package/src/env.ts +172 -0
  38. package/src/fetch-retry.ts +325 -0
  39. package/src/format.ts +113 -0
  40. package/src/frontmatter.ts +128 -0
  41. package/src/fs-error.ts +56 -0
  42. package/src/glob.ts +189 -0
  43. package/src/hook-fetch.ts +30 -0
  44. package/src/index.ts +49 -0
  45. package/src/json.ts +10 -0
  46. package/src/logger.ts +417 -0
  47. package/src/mermaid-ascii.ts +31 -0
  48. package/src/mime.ts +159 -0
  49. package/src/peek-file.ts +188 -0
  50. package/src/postmortem.ts +196 -0
  51. package/src/procmgr.ts +195 -0
  52. package/src/prompt.ts +471 -0
  53. package/src/ptree.ts +390 -0
  54. package/src/ring.ts +169 -0
  55. package/src/sanitize-text.ts +38 -0
  56. package/src/snowflake.ts +136 -0
  57. package/src/stream.ts +403 -0
  58. package/src/tab-spacing.ts +342 -0
  59. package/src/temp.ts +77 -0
  60. package/src/type-guards.ts +11 -0
  61. package/src/which.ts +232 -0
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Default tab width (display / tab expansion) and per-file width from `.editorconfig`.
3
+ * Mirrors former `prometheus-natives` `indent` + `text` default-tab-width behavior (no N-API).
4
+ */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import { isFsError } from "./fs-error";
8
+
9
+ export const MIN_TAB_WIDTH = 1;
10
+ export const MAX_TAB_WIDTH = 16;
11
+ export const DEFAULT_TAB_WIDTH = 3;
12
+
13
+ /**
14
+ * Per-component path length cap on common filesystems (`NAME_MAX = 255` on
15
+ * Linux ext4 / macOS APFS / Windows NTFS). Paths with components longer than
16
+ * this cannot be opened at all, so editorconfig discovery short-circuits to
17
+ * the default instead of running into `ENAMETOOLONG` from `readFileSync`.
18
+ */
19
+ const NAME_MAX_BYTES = 255;
20
+
21
+ const EDITORCONFIG_NAME = ".editorconfig";
22
+
23
+ let defaultTabWidth = DEFAULT_TAB_WIDTH;
24
+
25
+ const editorConfigCache = new Map<string, ParsedEditorConfig | null>();
26
+ const editorConfigChainCache = new Map<string, ChainEntry[]>();
27
+ const indentationCache = new Map<string, number>();
28
+
29
+ interface EditorConfigSection {
30
+ pattern: string;
31
+ properties: Map<string, string>;
32
+ }
33
+
34
+ interface ParsedEditorConfig {
35
+ root: boolean;
36
+ sections: EditorConfigSection[];
37
+ }
38
+
39
+ interface ChainEntry {
40
+ dir: string;
41
+ parsed: ParsedEditorConfig;
42
+ }
43
+
44
+ const enum IndentStyle {
45
+ Space,
46
+ Tab,
47
+ }
48
+
49
+ type IndentSize = { kind: "spaces"; n: number } | { kind: "tab" };
50
+
51
+ interface EditorConfigMatch {
52
+ indentStyle?: IndentStyle;
53
+ indentSize?: IndentSize;
54
+ tabWidth?: number;
55
+ }
56
+
57
+ function clampTabWidth(value: number): number {
58
+ return Math.min(MAX_TAB_WIDTH, Math.max(MIN_TAB_WIDTH, Math.trunc(value)));
59
+ }
60
+
61
+ function parsePositiveInteger(raw: string | undefined): number | undefined {
62
+ if (raw === undefined) return undefined;
63
+ if (!/^\d+$/.test(raw)) return undefined;
64
+ const parsed = Number.parseInt(raw, 10);
65
+ if (parsed === 0) return undefined;
66
+ return clampTabWidth(parsed);
67
+ }
68
+
69
+ function fixUnclosedBraces(pattern: string): string {
70
+ const opens = [...pattern].filter(c => c === "{").length;
71
+ const closes = [...pattern].filter(c => c === "}").length;
72
+ if (opens > closes) {
73
+ return pattern + "}".repeat(opens - closes);
74
+ }
75
+ return pattern;
76
+ }
77
+
78
+ /** Match `crates/prometheus-natives/src/glob_util.rs` `build_glob_pattern`. */
79
+ function buildGlobPattern(globStr: string, recursive: boolean): string {
80
+ const normalized = globStr.replace(/\\/g, "/");
81
+ const pattern =
82
+ !recursive || normalized.includes("/") || normalized.startsWith("**") ? normalized : `**/${normalized}`;
83
+ return fixUnclosedBraces(pattern);
84
+ }
85
+
86
+ function globMatches(pattern: string, relativePath: string): boolean {
87
+ try {
88
+ const g = new Bun.Glob(pattern);
89
+ return g.match(relativePath);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function matchesEditorConfigPattern(pattern: string, relativePath: string): boolean {
96
+ const normalized = pattern.replace(/^\/+/, "");
97
+ if (!normalized) {
98
+ return false;
99
+ }
100
+
101
+ const candidates = normalized.includes("/")
102
+ ? [buildGlobPattern(normalized, false)]
103
+ : [buildGlobPattern(normalized, false), buildGlobPattern(normalized, true)];
104
+
105
+ for (const p of candidates) {
106
+ if (globMatches(p, relativePath)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ function parseEditorConfigFile(content: string): ParsedEditorConfig {
114
+ const parsed: ParsedEditorConfig = { root: false, sections: [] };
115
+ let currentSectionIdx: number | undefined;
116
+
117
+ for (const rawLine of content.split(/\n/)) {
118
+ const line = rawLine.trim();
119
+ if (line === "") continue;
120
+ if (line.startsWith("#") || line.startsWith(";")) continue;
121
+
122
+ if (line.startsWith("[") && line.endsWith("]") && line.length >= 2) {
123
+ const secPattern = line.slice(1, -1).trim();
124
+ if (secPattern === "") {
125
+ currentSectionIdx = undefined;
126
+ continue;
127
+ }
128
+ parsed.sections.push({ pattern: secPattern, properties: new Map() });
129
+ currentSectionIdx = parsed.sections.length - 1;
130
+ continue;
131
+ }
132
+
133
+ const eq = line.indexOf("=");
134
+ if (eq === -1) continue;
135
+ const key = line.slice(0, eq).trim().toLowerCase();
136
+ const value = line
137
+ .slice(eq + 1)
138
+ .trim()
139
+ .toLowerCase();
140
+ if (key === "") continue;
141
+
142
+ if (currentSectionIdx !== undefined) {
143
+ parsed.sections[currentSectionIdx]!.properties.set(key, value);
144
+ } else if (key === "root") {
145
+ parsed.root = value === "true";
146
+ }
147
+ }
148
+
149
+ return parsed;
150
+ }
151
+
152
+ function parseCachedEditorConfig(configPath: string): ParsedEditorConfig | undefined {
153
+ const key = path.resolve(configPath);
154
+ const hit = editorConfigCache.get(key);
155
+ if (hit !== undefined) {
156
+ return hit ?? undefined;
157
+ }
158
+
159
+ let content: string;
160
+ try {
161
+ content = fs.readFileSync(key, "utf8");
162
+ } catch (err) {
163
+ // editorconfig discovery is best-effort. Any filesystem error
164
+ // (`ENOENT`, `ENAMETOOLONG`, `ENOTDIR`, `EACCES`, `ELOOP`, `EINVAL`,
165
+ // …) means "no usable config at this path" — never a fatal condition
166
+ // for callers like the edit renderer that hand us arbitrary strings.
167
+ if (isFsError(err)) return undefined;
168
+ throw err;
169
+ }
170
+ const parsed = parseEditorConfigFile(content);
171
+ editorConfigCache.set(key, parsed);
172
+ return parsed;
173
+ }
174
+
175
+ function resolveFilePath(projectDir: string, file: string): string {
176
+ if (path.isAbsolute(file)) {
177
+ return path.normalize(path.resolve(file));
178
+ }
179
+ return path.normalize(path.resolve(projectDir, file));
180
+ }
181
+
182
+ /** Like `pathdiff::diff_paths` + forward slashes (see `indent.rs`). */
183
+ function relativePathUnified(baseDir: string, absoluteFile: string): string {
184
+ const base = path.resolve(baseDir);
185
+ const file = path.resolve(absoluteFile);
186
+ const rel = path.relative(base, file);
187
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
188
+ return ".";
189
+ }
190
+ return rel.replace(/\\/g, "/");
191
+ }
192
+
193
+ function collectEditorConfigChain(startDir: string): ChainEntry[] {
194
+ const key = path.resolve(startDir);
195
+ const cached = editorConfigChainCache.get(key);
196
+ if (cached !== undefined) {
197
+ return cached;
198
+ }
199
+
200
+ const chain: ChainEntry[] = [];
201
+ let cursor = key;
202
+ for (;;) {
203
+ const configPath = path.join(cursor, EDITORCONFIG_NAME);
204
+ const parsed = parseCachedEditorConfig(configPath);
205
+ if (parsed !== undefined) {
206
+ chain.push({ dir: cursor, parsed });
207
+ if (parsed.root) {
208
+ break;
209
+ }
210
+ }
211
+
212
+ const parent = path.dirname(cursor);
213
+ if (parent === cursor) {
214
+ break;
215
+ }
216
+ cursor = parent;
217
+ }
218
+
219
+ chain.reverse();
220
+ editorConfigChainCache.set(key, chain);
221
+ return chain;
222
+ }
223
+
224
+ function resolveEditorConfigMatch(absoluteFile: string): EditorConfigMatch | undefined {
225
+ const fileDir = path.dirname(absoluteFile);
226
+ const chain = collectEditorConfigChain(fileDir);
227
+ if (chain.length === 0) {
228
+ return undefined;
229
+ }
230
+
231
+ const match: EditorConfigMatch = {};
232
+ for (const { dir, parsed } of chain) {
233
+ const relativePath = relativePathUnified(dir, absoluteFile);
234
+ for (const section of parsed.sections) {
235
+ if (!matchesEditorConfigPattern(section.pattern, relativePath)) {
236
+ continue;
237
+ }
238
+
239
+ const style = section.properties.get("indent_style");
240
+ if (style === "space") {
241
+ match.indentStyle = IndentStyle.Space;
242
+ } else if (style === "tab") {
243
+ match.indentStyle = IndentStyle.Tab;
244
+ }
245
+
246
+ const rawSize = section.properties.get("indent_size");
247
+ if (rawSize === "tab") {
248
+ match.indentSize = { kind: "tab" };
249
+ } else if (rawSize !== undefined) {
250
+ const n = parsePositiveInteger(rawSize);
251
+ if (n !== undefined) {
252
+ match.indentSize = { kind: "spaces", n };
253
+ }
254
+ }
255
+
256
+ const tw = parsePositiveInteger(section.properties.get("tab_width"));
257
+ if (tw !== undefined) {
258
+ match.tabWidth = tw;
259
+ }
260
+ }
261
+ }
262
+
263
+ if (match.indentStyle === undefined && match.indentSize === undefined && match.tabWidth === undefined) {
264
+ return undefined;
265
+ }
266
+ return match;
267
+ }
268
+
269
+ function resolveEditorConfigTabWidth(match: EditorConfigMatch | undefined, fallback: number): number | undefined {
270
+ if (match === undefined) return undefined;
271
+
272
+ if (match.indentSize?.kind === "spaces") {
273
+ return match.indentSize.n;
274
+ }
275
+
276
+ if (match.indentSize?.kind === "tab") {
277
+ if (match.tabWidth !== undefined) {
278
+ return match.tabWidth;
279
+ }
280
+ return fallback;
281
+ }
282
+
283
+ if (match.tabWidth !== undefined) {
284
+ return match.tabWidth;
285
+ }
286
+
287
+ if (match.indentStyle === IndentStyle.Tab) {
288
+ return fallback;
289
+ }
290
+
291
+ return undefined;
292
+ }
293
+
294
+ function hasOverlongPathComponent(filePath: string): boolean {
295
+ for (const part of filePath.split(/[\\/]/)) {
296
+ if (part.length > 0 && Buffer.byteLength(part) > NAME_MAX_BYTES) {
297
+ return true;
298
+ }
299
+ }
300
+ return false;
301
+ }
302
+
303
+ export function getDefaultTabWidth(): number {
304
+ return defaultTabWidth;
305
+ }
306
+
307
+ export function setDefaultTabWidth(width: number): void {
308
+ defaultTabWidth = clampTabWidth(width);
309
+ }
310
+
311
+ /**
312
+ * Visible tab width in columns for `file` (from `.editorconfig` + default), or the default when `file` is omitted.
313
+ */
314
+ export function getIndentation(file?: string | null, projectDir?: string | null): number {
315
+ const fallback = defaultTabWidth;
316
+ if (file === undefined || file === null || file === "") {
317
+ return fallback;
318
+ }
319
+
320
+ const cwd = projectDir ?? process.cwd();
321
+ const absoluteFile = resolveFilePath(cwd, file);
322
+
323
+ // Renderers can hand us arbitrary strings (e.g. a malformed edit tool
324
+ // call whose `file_path` is gibberish). Reject paths whose normalized
325
+ // absolute form still has any component longer than `NAME_MAX_BYTES` —
326
+ // the editorconfig chain would only trip `ENAMETOOLONG` from
327
+ // `readFileSync` and escape.
328
+ if (hasOverlongPathComponent(absoluteFile)) {
329
+ return fallback;
330
+ }
331
+ const absKey = absoluteFile;
332
+ const cached = indentationCache.get(absKey);
333
+ if (cached !== undefined) {
334
+ return cached;
335
+ }
336
+
337
+ const editorMatch = resolveEditorConfigMatch(absoluteFile);
338
+ const resolved = resolveEditorConfigTabWidth(editorMatch, fallback) ?? fallback;
339
+ const clamped = clampTabWidth(resolved);
340
+ indentationCache.set(absKey, clamped);
341
+ return clamped;
342
+ }
package/src/temp.ts ADDED
@@ -0,0 +1,77 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export class TempDir {
6
+ #path: string;
7
+ private constructor(path: string) {
8
+ this.#path = path;
9
+ }
10
+
11
+ static createSync(prefix?: string): TempDir {
12
+ return new TempDir(fs.mkdtempSync(normalizePrefix(prefix)));
13
+ }
14
+
15
+ static async create(prefix?: string): Promise<TempDir> {
16
+ return new TempDir(await fs.promises.mkdtemp(normalizePrefix(prefix)));
17
+ }
18
+
19
+ #removePromise: Promise<void> | null = null;
20
+
21
+ path(): string {
22
+ return this.#path;
23
+ }
24
+
25
+ absolute(): string {
26
+ return path.resolve(this.#path);
27
+ }
28
+
29
+ remove(): Promise<void> {
30
+ if (this.#removePromise) {
31
+ return this.#removePromise;
32
+ }
33
+ const removePromise = fs.promises.rm(this.#path, { recursive: true, force: true });
34
+ this.#removePromise = removePromise;
35
+ return removePromise;
36
+ }
37
+
38
+ removeSync(): void {
39
+ fs.rmSync(this.#path, { recursive: true, force: true });
40
+ this.#removePromise = Promise.resolve();
41
+ }
42
+
43
+ toString(): string {
44
+ return this.#path;
45
+ }
46
+
47
+ join(...paths: string[]): string {
48
+ return path.join(this.#path, ...paths);
49
+ }
50
+
51
+ async [Symbol.asyncDispose](): Promise<void> {
52
+ try {
53
+ await this.remove();
54
+ } catch {
55
+ // Ignore cleanup errors
56
+ }
57
+ }
58
+
59
+ [Symbol.dispose](): void {
60
+ try {
61
+ this.removeSync();
62
+ } catch {
63
+ // Ignore cleanup errors
64
+ }
65
+ }
66
+ }
67
+
68
+ const kTempDir = os.tmpdir();
69
+
70
+ function normalizePrefix(prefix?: string): string {
71
+ if (!prefix) {
72
+ return `${kTempDir}${path.sep}pi-temp-`;
73
+ } else if (prefix.startsWith("@")) {
74
+ return path.join(kTempDir, prefix.slice(1));
75
+ }
76
+ return prefix;
77
+ }
@@ -0,0 +1,11 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return !!value && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ export function asRecord(value: unknown): Record<string, unknown> | null {
6
+ return isRecord(value) ? value : null;
7
+ }
8
+
9
+ export function toError(value: unknown): Error {
10
+ return value instanceof Error ? value : new Error(String(value));
11
+ }
package/src/which.ts ADDED
@@ -0,0 +1,232 @@
1
+ // OS-agnostic "which" helper with robust macOS toolchain lookup and flexible cache control.
2
+ //
3
+ // - Falls back to macOS Xcode/CLT toolchain directories if standard `Bun.which()` fails on Darwin.
4
+ // Resolves the active developer directory via $DEVELOPER_DIR / /var/db/xcode_select_link symlink
5
+ // to avoid spawning xcrun subprocesses.
6
+ // - Supports four cache modes (`none`, `fresh`, `ro`, `cached`) for control over discovery cost and determinism.
7
+ // - Computes a stable cache key from command + options to avoid redundant lookups within a process.
8
+ // - Returns path to resolved binary or null if not found.
9
+ //
10
+
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+
15
+ type CacheKey = string | bigint | number;
16
+
17
+ // Tools shipped by Xcode / Command Line Tools that callers actually look up.
18
+ // Keeps the set small so darwinWhich can fast-reject non-Xcode commands without
19
+ // touching the filesystem. Only needs entries for binaries that live *exclusively*
20
+ // in toolchain dirs (not on a typical $PATH).
21
+ const XCODE_BINS = new Set([
22
+ // Compilers & driver aliases
23
+ "clang",
24
+ "clang++",
25
+ "gcc",
26
+ "g++",
27
+ "cc",
28
+ "c++",
29
+ "cpp",
30
+ "c89",
31
+ "c99",
32
+ "swift",
33
+ "swiftc",
34
+ "swift-frontend",
35
+ // Language servers (LSP)
36
+ "clangd",
37
+ "sourcekit-lsp",
38
+ // Linker & archive tools
39
+ "ld",
40
+ "ld-classic",
41
+ "ar",
42
+ "ranlib",
43
+ "libtool",
44
+ "as",
45
+ "lipo",
46
+ "install_name_tool",
47
+ "codesign_allocate",
48
+ // Build utilities
49
+ "make",
50
+ "gnumake",
51
+ "m4",
52
+ "flex",
53
+ "bison",
54
+ "yacc",
55
+ "lex",
56
+ // VCS (CLT ships git)
57
+ "git",
58
+ "git-receive-pack",
59
+ "git-upload-pack",
60
+ "git-upload-archive",
61
+ "git-shell",
62
+ "scalar",
63
+ // Debugger
64
+ "lldb",
65
+ "lldb-dap",
66
+ // Binary inspection
67
+ "nm",
68
+ "otool",
69
+ "objdump",
70
+ "strings",
71
+ "strip",
72
+ "size",
73
+ "dsymutil",
74
+ "dwarfdump",
75
+ "lipo",
76
+ "vtool",
77
+ // Clang tooling
78
+ "clang-format",
79
+ "swift-format",
80
+ ]);
81
+
82
+ // Prefixes for versioned binaries (e.g. python3.9, pip3.12, pydoc3.9, 2to3-3.9)
83
+ const XCODE_BIN_PREFIXES = ["python", "pip", "pydoc", "2to3"];
84
+
85
+ function isXcodeBin(command: string): boolean {
86
+ if (XCODE_BINS.has(command)) return true;
87
+ for (const prefix of XCODE_BIN_PREFIXES) {
88
+ if (command.startsWith(prefix)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ // Resolve the active Xcode developer directory once, without spawning any process.
94
+ // Priority: $DEVELOPER_DIR env → /var/db/xcode_select_link symlink → common fallback paths.
95
+ function getDeveloperDirs(): string | null {
96
+ // 1. Explicit env override
97
+ const envDir = process.env.DEVELOPER_DIR;
98
+ if (envDir && fs.existsSync(envDir)) {
99
+ return envDir;
100
+ }
101
+
102
+ // 2. xcode-select stores the active path as a symlink
103
+ try {
104
+ return fs.readlinkSync("/var/db/xcode_select_link");
105
+ } catch {
106
+ // symlink may not exist on minimal installs
107
+ }
108
+ // 3. Common locations
109
+ for (const candidate of ["/Applications/Xcode.app/Contents/Developer", "/Library/Developer/CommandLineTools"]) {
110
+ if (fs.existsSync(candidate)) {
111
+ return candidate;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // Build the list of extra toolchain bin directories to check on macOS.
118
+ // Computed lazily once from the resolved developer directory.
119
+ let macosToolPaths: Map<string, string> | undefined;
120
+ function getMacosToolPaths(): Map<string, string> {
121
+ if (macosToolPaths) return macosToolPaths;
122
+ const paths: string[] = [
123
+ // Always check Command Line Tools (may be independent of Xcode)
124
+ "/Library/Developer/CommandLineTools/usr/bin",
125
+ ];
126
+ const devDir = getDeveloperDirs();
127
+ if (devDir) {
128
+ paths.push(path.join(devDir, "usr/bin"), path.join(devDir, "Toolchains/XcodeDefault.xctoolchain/usr/bin"));
129
+ }
130
+
131
+ // Deduplicate (e.g. devDir may already be CommandLineTools)
132
+ macosToolPaths = new Map<string, string>();
133
+ for (const dir of Array.from(new Set(paths))) {
134
+ try {
135
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
136
+ if (entry.isFile() || entry.isSymbolicLink()) {
137
+ if (macosToolPaths.has(entry.name)) {
138
+ continue;
139
+ }
140
+ macosToolPaths.set(entry.name, path.join(dir, entry.name));
141
+ }
142
+ }
143
+ } catch {
144
+ // dir doesn't exist or isn't readable
145
+ }
146
+ }
147
+ return macosToolPaths;
148
+ }
149
+
150
+ // Map: cache key -> resolved binary path or null (not found)
151
+ const toolCache = new Map<CacheKey, string | null>();
152
+
153
+ /**
154
+ * Cache policy for which lookups.
155
+ */
156
+ export const enum WhichCachePolicy {
157
+ /**
158
+ * Use cached result if available.
159
+ */
160
+ Cached = 0,
161
+ /**
162
+ * Bypass cache and perform a new lookup.
163
+ */
164
+ Bypass,
165
+ /**
166
+ * Always update cache.
167
+ */
168
+ Fresh,
169
+ /**
170
+ * Read-only, serves from cache if present, but doesn't write.
171
+ */
172
+ ReadOnly,
173
+ }
174
+
175
+ // Extension: additional cache policy for tool path lookup
176
+ export interface WhichOptions extends Bun.WhichOptions {
177
+ /**
178
+ * Cache policy for the lookup.
179
+ * Defaults to `WhichCachePolicy.Fresh`.
180
+ */
181
+ cache?: WhichCachePolicy;
182
+ }
183
+
184
+ // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
185
+ // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses.
186
+ function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null {
187
+ const regular = Bun.which(command);
188
+ if (regular) return regular;
189
+ if (isXcodeBin(command)) {
190
+ return getMacosToolPaths().get(command) ?? null;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ // Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'
196
+ export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which;
197
+
198
+ // Derive stable cache key from command and lookup options
199
+ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
200
+ if (!options) return command;
201
+ if (!options.cwd && !options.PATH) return command;
202
+ let h = Bun.hash(command);
203
+ if (options.cwd) h = Bun.hash(options.cwd, h);
204
+ if (options.PATH) h = Bun.hash(options.PATH, h);
205
+ return h;
206
+ }
207
+
208
+ /**
209
+ * Locate binary on PATH (with flexible caching).
210
+ *
211
+ * @param command - Binary name to resolve
212
+ * @param options - Bun.WhichOptions plus `cache` control
213
+ * @returns Filesystem path if found, else null
214
+ */
215
+ export function $which(command: string, options?: WhichOptions): string | null {
216
+ const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
217
+ let key: CacheKey | undefined;
218
+
219
+ if (cachePolicy !== WhichCachePolicy.Bypass) {
220
+ key = cacheKey(command, options);
221
+ if (cachePolicy !== WhichCachePolicy.Fresh) {
222
+ const cached = toolCache.get(key);
223
+ if (cached !== undefined) return cached;
224
+ }
225
+ }
226
+
227
+ const result = whichFresh(command, options);
228
+ if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
229
+ toolCache.set(key, result);
230
+ }
231
+ return result;
232
+ }