@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,188 @@
1
+ /**
2
+ * Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
3
+ *
4
+ * Buffers are reused to avoid allocating on every peek: sync uses one growable
5
+ * `Uint8Array`; async uses a small fixed pool of `Buffer`s with a bounded wait
6
+ * queue, falling back to a fresh allocation when the pool and queue are saturated
7
+ * or when `maxBytes` exceeds the pool slot size.
8
+ */
9
+ import * as fs from "node:fs";
10
+
11
+ /** Async pool slot size; larger peeks allocate ad hoc. */
12
+ const POOLED_BUFFER_SIZE = 512;
13
+ const ASYNC_POOL_SIZE = 10;
14
+ /** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
15
+ const MAX_ASYNC_WAITERS = 4;
16
+ const INITIAL_SYNC_BUFFER_SIZE = 1024;
17
+ const EMPTY_BUFFER = Buffer.alloc(0);
18
+
19
+ const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
20
+ const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
21
+ const asyncPoolWaiters: Array<(index: number) => void> = [];
22
+ let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
23
+
24
+ /** Returns a pool slot index, or `-1` when the caller should use a standalone buffer. */
25
+ function acquireAsyncPoolIndex(): Promise<number> | number {
26
+ const index = availableAsyncPoolIndexes.pop();
27
+ if (index !== undefined) {
28
+ return index;
29
+ }
30
+ if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
31
+ return -1;
32
+ }
33
+ const { promise, resolve } = Promise.withResolvers<number>();
34
+ asyncPoolWaiters.push(resolve);
35
+ return promise;
36
+ }
37
+
38
+ function releaseAsyncPoolIndex(index: number): void {
39
+ if (index < 0) {
40
+ return;
41
+ }
42
+ const waiter = asyncPoolWaiters.shift();
43
+ if (waiter) {
44
+ waiter(index);
45
+ return;
46
+ }
47
+ availableAsyncPoolIndexes.push(index);
48
+ }
49
+
50
+ async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
51
+ if (maxBytes <= 0) {
52
+ return op(EMPTY_BUFFER);
53
+ }
54
+ if (maxBytes > POOLED_BUFFER_SIZE) {
55
+ return op(Buffer.allocUnsafe(maxBytes));
56
+ }
57
+
58
+ const poolIndex = await acquireAsyncPoolIndex();
59
+ const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
60
+ try {
61
+ return await op(buffer.subarray(0, maxBytes));
62
+ } finally {
63
+ releaseAsyncPoolIndex(poolIndex);
64
+ }
65
+ }
66
+
67
+ function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
68
+ if (maxBytes <= 0) {
69
+ return op(EMPTY_BUFFER);
70
+ }
71
+ if (maxBytes > syncPool.byteLength) {
72
+ syncPool = new Uint8Array(maxBytes + (maxBytes >> 1));
73
+ }
74
+ return op(syncPool.subarray(0, maxBytes));
75
+ }
76
+
77
+ /**
78
+ * Synchronously reads up to `maxBytes` from the start of `filePath` and returns `op(header)`.
79
+ * If the file is shorter, `header` is only the bytes actually read.
80
+ */
81
+ export function peekFileSync<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): T {
82
+ if (maxBytes <= 0) {
83
+ return op(EMPTY_BUFFER);
84
+ }
85
+
86
+ const fileHandle = fs.openSync(filePath, "r");
87
+ try {
88
+ return withSyncPoolBuffer(maxBytes, buffer => {
89
+ const bytesRead = fs.readSync(fileHandle, buffer, 0, buffer.byteLength, 0);
90
+ return op(buffer.subarray(0, bytesRead));
91
+ });
92
+ } finally {
93
+ fs.closeSync(fileHandle);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Like {@link peekFileSync} but uses async I/O.
99
+ */
100
+ export async function peekFile<T>(filePath: string, maxBytes: number, op: (header: Uint8Array) => T): Promise<T> {
101
+ if (maxBytes <= 0) {
102
+ return op(EMPTY_BUFFER);
103
+ }
104
+
105
+ const fileHandle = await fs.promises.open(filePath, "r");
106
+ try {
107
+ return await withAsyncPoolBuffer(maxBytes, async buffer => {
108
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
109
+ return op(buffer.subarray(0, bytesRead));
110
+ });
111
+ } finally {
112
+ await fileHandle.close();
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
118
+ *
119
+ * The tail mirror of {@link peekFile}: same pooled-buffer strategy (no per-call
120
+ * allocation for small reads), but the read is positioned at `size - len` so the
121
+ * window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
122
+ * returned. A multi-byte codepoint straddling the leading cut decodes to a
123
+ * replacement char — callers that parse line-oriented tails drop the partial
124
+ * leading line anyway.
125
+ */
126
+ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (tail: Uint8Array) => T): Promise<T> {
127
+ if (maxBytes <= 0) {
128
+ return op(EMPTY_BUFFER);
129
+ }
130
+
131
+ const fileHandle = await fs.promises.open(filePath, "r");
132
+ try {
133
+ const { size } = await fileHandle.stat();
134
+ const len = Math.min(maxBytes, size);
135
+ if (len <= 0) {
136
+ return op(EMPTY_BUFFER);
137
+ }
138
+ return await withAsyncPoolBuffer(len, async buffer => {
139
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
140
+ return op(buffer.subarray(0, bytesRead));
141
+ });
142
+ } finally {
143
+ await fileHandle.close();
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Read up to the first `prefixBytes` and last `suffixBytes` of `filePath`, then
149
+ * pass both slices to `op`.
150
+ *
151
+ * Uses a single open/stat sequence. When the whole file fits in the head window,
152
+ * the tail is sliced from the already-read head bytes instead of issuing a
153
+ * second read.
154
+ */
155
+ export async function peekFileEnds<T>(
156
+ filePath: string,
157
+ prefixBytes: number,
158
+ suffixBytes: number,
159
+ op: (head: Uint8Array, tail: Uint8Array) => T,
160
+ ): Promise<T> {
161
+ if (prefixBytes <= 0 && suffixBytes <= 0) {
162
+ return op(EMPTY_BUFFER, EMPTY_BUFFER);
163
+ }
164
+
165
+ const fileHandle = await fs.promises.open(filePath, "r");
166
+ try {
167
+ const { size } = await fileHandle.stat();
168
+ const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
169
+ const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
170
+
171
+ const head = headLen > 0 ? Buffer.allocUnsafe(headLen) : EMPTY_BUFFER;
172
+ const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
173
+ const headSlice = head.subarray(0, headBytesRead);
174
+
175
+ if (tailLen <= 0) {
176
+ return op(headSlice, EMPTY_BUFFER);
177
+ }
178
+ if (size <= headLen) {
179
+ return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
180
+ }
181
+
182
+ const tail = Buffer.allocUnsafe(tailLen);
183
+ const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
184
+ return op(headSlice, tail.subarray(0, tailBytesRead));
185
+ } finally {
186
+ await fileHandle.close();
187
+ }
188
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Cleanup and postmortem handler utilities.
3
+ *
4
+ * This module provides a system for registering and running cleanup callbacks
5
+ * in response to process exit, signals, or fatal exceptions. It is intended to
6
+ * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
+ */
8
+ import inspector from "node:inspector";
9
+ import { isMainThread } from "node:worker_threads";
10
+ import { logger } from ".";
11
+
12
+ // Cleanup reasons, in order of priority/meaning.
13
+ export enum Reason {
14
+ PRE_EXIT = "pre_exit", // Pre-exit phase (not used by default)
15
+ EXIT = "exit", // Normal process exit
16
+ SIGINT = "sigint", // Ctrl-C or SIGINT
17
+ SIGTERM = "sigterm", // SIGTERM
18
+ SIGHUP = "sighup", // SIGHUP
19
+ UNCAUGHT_EXCEPTION = "uncaught_exception", // Fatal exception
20
+ UNHANDLED_REJECTION = "unhandled_rejection", // Unhandled promise rejection
21
+ MANUAL = "manual", // Manual cleanup (not triggered by process)
22
+ }
23
+
24
+ // Internal list of active cleanup callbacks (in registration order)
25
+ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
26
+ // Tracks cleanup run state (to prevent recursion/reentry issues)
27
+ let cleanupStage: "idle" | "running" | "complete" = "idle";
28
+
29
+ /**
30
+ * Internal: runs all registered cleanup callbacks for the given reason.
31
+ * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
32
+ *
33
+ * Returns a Promise that settles after all cleanups complete or error out.
34
+ */
35
+ function runCleanup(reason: Reason): Promise<void> {
36
+ switch (cleanupStage) {
37
+ case "idle":
38
+ cleanupStage = "running";
39
+ break;
40
+ case "running":
41
+ logger.error("Cleanup invoked recursively", { stack: new Error().stack });
42
+ return Promise.resolve();
43
+ case "complete":
44
+ return Promise.resolve();
45
+ }
46
+
47
+ // Call .cleanup() for each callback that is still "armed".
48
+ // Use Promise.try to handle sync/async, but only those armed.
49
+ const promises = callbackList.toReversed().map(callback => {
50
+ return Promise.try(() => callback(reason));
51
+ });
52
+
53
+ return Promise.allSettled(promises).then(results => {
54
+ for (const result of results) {
55
+ if (result.status === "rejected") {
56
+ const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
57
+ logger.error("Cleanup callback failed", { err, stack: err.stack });
58
+ }
59
+ }
60
+ cleanupStage = "complete";
61
+ });
62
+ }
63
+
64
+ // Register signal and error event handlers to trigger cleanup before exit.
65
+ // Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
66
+ // Worker thread: exit only (workers use self.addEventListener for exceptions)
67
+ let inspectorOpened = false;
68
+
69
+ function formatFatalError(label: string, err: Error): string {
70
+ const name = err.name || "Error";
71
+ const message = err.message || "(no message)";
72
+ const stack = err.stack || "";
73
+ const stackLines = stack.split("\n").slice(1);
74
+ const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
75
+ return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
76
+ }
77
+
78
+ if (isMainThread) {
79
+ process
80
+ .on("SIGINT", async () => {
81
+ await runCleanup(Reason.SIGINT);
82
+ process.exit(130); // 128 + SIGINT (2)
83
+ })
84
+ .on("SIGUSR1", () => {
85
+ if (inspectorOpened) return;
86
+ inspectorOpened = true;
87
+ inspector.open(undefined, undefined, false);
88
+ const url = inspector.url();
89
+ process.stderr.write(`Inspector opened: ${url}\n`);
90
+ })
91
+ .on("uncaughtException", async err => {
92
+ process.stderr.write(formatFatalError("Uncaught Exception", err));
93
+ logger.error("Uncaught exception", { err });
94
+ await runCleanup(Reason.UNCAUGHT_EXCEPTION);
95
+ process.exit(1);
96
+ })
97
+ .on("unhandledRejection", async reason => {
98
+ const err = reason instanceof Error ? reason : new Error(String(reason));
99
+ process.stderr.write(formatFatalError("Unhandled Rejection", err));
100
+ logger.error("Unhandled rejection", { err });
101
+ await runCleanup(Reason.UNHANDLED_REJECTION);
102
+ process.exit(1);
103
+ })
104
+ .on("exit", async () => {
105
+ void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
106
+ })
107
+ .on("SIGTERM", async () => {
108
+ await runCleanup(Reason.SIGTERM);
109
+ process.exit(143); // 128 + SIGTERM (15)
110
+ })
111
+ .on("SIGHUP", async () => {
112
+ await runCleanup(Reason.SIGHUP);
113
+ process.exit(129); // 128 + SIGHUP (1)
114
+ });
115
+ } else {
116
+ // Worker thread: only register exit handler for cleanup.
117
+ // DO NOT register uncaughtException/unhandledRejection handlers here -
118
+ // they would swallow errors before the worker's own handlers (self.addEventListener)
119
+ // can report failures back to the parent thread.
120
+ process.on("exit", () => {
121
+ void runCleanup(Reason.EXIT);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
127
+ *
128
+ * Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
129
+ * If register is called after cleanup already began, invokes callback on a microtask.
130
+ */
131
+ export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
132
+ let done = false;
133
+ const exec = (reason: Reason) => {
134
+ if (done) return;
135
+ done = true;
136
+ try {
137
+ return callback(reason);
138
+ } catch (e) {
139
+ const err = e instanceof Error ? e : new Error(String(e));
140
+ logger.error("Cleanup callback failed", { err, id, stack: err.stack });
141
+ }
142
+ };
143
+
144
+ const cancel = () => {
145
+ const index = callbackList.indexOf(exec);
146
+ if (index >= 0) {
147
+ callbackList.splice(index, 1);
148
+ }
149
+ done = true;
150
+ };
151
+
152
+ if (cleanupStage !== "idle") {
153
+ // If cleanup is already running/completed, warn and run on microtask.
154
+ logger.warn("Cleanup invoked recursively", { id });
155
+ try {
156
+ callback(Reason.MANUAL);
157
+ } catch (e) {
158
+ const err = e instanceof Error ? e : new Error(String(e));
159
+ logger.error("Cleanup callback failed", { err, id, stack: err.stack });
160
+ }
161
+ return () => {};
162
+ }
163
+
164
+ // Register callback as "armed" (active).
165
+ callbackList.push(exec);
166
+ return cancel;
167
+ }
168
+
169
+ /**
170
+ * Runs all cleanup callbacks without exiting.
171
+ * Use this in workers or when you need to clean up but continue execution.
172
+ */
173
+ export function cleanup(): Promise<void> {
174
+ return runCleanup(Reason.MANUAL);
175
+ }
176
+
177
+ /**
178
+ * Runs all cleanup callbacks and exits.
179
+ *
180
+ * In main thread: waits for stdout drain, then calls process.exit().
181
+ * In workers: runs cleanup only (process.exit would kill entire process).
182
+ */
183
+ export async function quit(code: number = 0): Promise<void> {
184
+ await runCleanup(Reason.MANUAL);
185
+
186
+ if (!isMainThread) {
187
+ return; // Workers: cleanup done, let worker exit naturally
188
+ }
189
+
190
+ if (process.stdout.writableLength > 0) {
191
+ const { promise, resolve } = Promise.withResolvers<void>();
192
+ process.stdout.once("drain", resolve);
193
+ await Promise.race([promise, Bun.sleep(5000)]);
194
+ }
195
+ process.exit(code);
196
+ }
package/src/procmgr.ts ADDED
@@ -0,0 +1,195 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { Process, ProcessStatus } from "@prometheus-ai/natives";
4
+ import type { Subprocess } from "bun";
5
+ import { $env, filterProcessEnv } from "./env";
6
+ import { $which } from "./which";
7
+
8
+ export interface ShellConfig {
9
+ shell: string;
10
+ args: string[];
11
+ env: Record<string, string>;
12
+ prefix: string | undefined;
13
+ }
14
+ let cachedShellConfig: ShellConfig | null = null;
15
+
16
+ /**
17
+ * Check if a shell binary is executable.
18
+ */
19
+ function isExecutable(path: string): boolean {
20
+ try {
21
+ fs.accessSync(path, fs.constants.X_OK);
22
+ return true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Build the spawn environment (cached).
30
+ */
31
+ function buildSpawnEnv(shell: string): Record<string, string> {
32
+ const noCI = $env.PROMETHEUS_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
33
+ return {
34
+ ...filterProcessEnv(Bun.env),
35
+ SHELL: shell,
36
+ GIT_EDITOR: "true",
37
+ GPG_TTY: "not a tty",
38
+ PROMETHEUS_CODE: "1",
39
+ CLAUDECODE: "1",
40
+ ...(noCI ? {} : { CI: "true" }),
41
+ } as Record<string, string>;
42
+ }
43
+
44
+ /**
45
+ * Get shell args, optionally including login shell flag.
46
+ * Supports PROMETHEUS_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
47
+ */
48
+ function getShellArgs(): string[] {
49
+ const noLogin = $env.PROMETHEUS_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
50
+ return noLogin ? ["-c"] : ["-l", "-c"];
51
+ }
52
+
53
+ /**
54
+ * Get shell prefix for wrapping commands (profilers, strace, etc.).
55
+ */
56
+ function getShellPrefix(): string | undefined {
57
+ return $env.PROMETHEUS_SHELL_PREFIX || $env.CLAUDE_CODE_SHELL_PREFIX;
58
+ }
59
+
60
+ /**
61
+ * Build full shell config from a shell path.
62
+ */
63
+ function buildConfig(shell: string): ShellConfig {
64
+ return {
65
+ shell,
66
+ args: getShellArgs(),
67
+ env: buildSpawnEnv(shell),
68
+ prefix: getShellPrefix(),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Resolve a basic shell (bash or sh) as fallback.
74
+ */
75
+ export function resolveBasicShell(): string | undefined {
76
+ for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
77
+ const resolved = $which(name);
78
+ if (resolved) return resolved;
79
+ }
80
+
81
+ if (process.platform !== "win32") {
82
+ const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
83
+ const candidates = ["bash", "sh"];
84
+
85
+ for (const name of candidates) {
86
+ for (const dir of searchPaths) {
87
+ const fullPath = path.join(dir, name);
88
+ if (fs.existsSync(fullPath)) return fullPath;
89
+ }
90
+ }
91
+ }
92
+
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Get shell configuration based on platform.
98
+ * Resolution order:
99
+ * 1. User-specified shellPath in settings.json
100
+ * 2. On Windows: Git Bash in known locations, then bash on PATH
101
+ * 3. On Unix: $SHELL if bash/zsh, then fallback paths
102
+ * 4. Fallback: sh
103
+ */
104
+ export function getShellConfig(customShellPath?: string): ShellConfig {
105
+ if (cachedShellConfig) {
106
+ return cachedShellConfig;
107
+ }
108
+
109
+ // 1. Check user-specified shell path
110
+ if (customShellPath) {
111
+ if (fs.existsSync(customShellPath)) {
112
+ cachedShellConfig = buildConfig(customShellPath);
113
+ return cachedShellConfig;
114
+ }
115
+ throw new Error(
116
+ `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.prometheus/agent/settings.json`,
117
+ );
118
+ }
119
+
120
+ if (process.platform === "win32") {
121
+ // 2. Try Git Bash in known locations
122
+ const paths: string[] = [];
123
+ const programFiles = Bun.env.ProgramFiles;
124
+ if (programFiles) {
125
+ paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
126
+ }
127
+ const programFilesX86 = Bun.env["ProgramFiles(x86)"];
128
+ if (programFilesX86) {
129
+ paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
130
+ }
131
+
132
+ for (const path of paths) {
133
+ if (fs.existsSync(path)) {
134
+ cachedShellConfig = buildConfig(path);
135
+ return cachedShellConfig;
136
+ }
137
+ }
138
+
139
+ // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
140
+ const bashOnPath = $which("bash.exe");
141
+ if (bashOnPath) {
142
+ cachedShellConfig = buildConfig(bashOnPath);
143
+ return cachedShellConfig;
144
+ }
145
+
146
+ throw new Error(
147
+ `No bash shell found. Options:\n` +
148
+ ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
149
+ ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
150
+ ` 3. Set shellPath in ~/.prometheus/agent/settings.json\n\n` +
151
+ `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
152
+ );
153
+ }
154
+
155
+ // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
156
+ const userShell = Bun.env.SHELL;
157
+ const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
158
+ if (isValidShell && isExecutable(userShell)) {
159
+ cachedShellConfig = buildConfig(userShell);
160
+ return cachedShellConfig;
161
+ }
162
+
163
+ // 4. Fallback: use basic shell
164
+ const basicShell = resolveBasicShell();
165
+ if (basicShell) {
166
+ cachedShellConfig = buildConfig(basicShell);
167
+ return cachedShellConfig;
168
+ }
169
+ cachedShellConfig = buildConfig("sh");
170
+ return cachedShellConfig;
171
+ }
172
+
173
+ /**
174
+ * Check if a process is running.
175
+ */
176
+ export function isPidRunning(pid: number | Subprocess): boolean {
177
+ if (typeof pid !== "number") {
178
+ if (pid.killed) return false;
179
+ if (pid.exitCode !== null) return false;
180
+ return true;
181
+ }
182
+
183
+ return Process.fromPid(pid)?.status() === ProcessStatus.Running;
184
+ }
185
+
186
+ export async function onProcessExit(proc: Subprocess | number, abortSignal?: AbortSignal): Promise<boolean> {
187
+ if (typeof proc !== "number") {
188
+ return proc.exited.then(
189
+ () => true,
190
+ () => true,
191
+ );
192
+ }
193
+
194
+ return (await Process.fromPid(proc)?.waitForExit({ signal: abortSignal })) ?? true;
195
+ }