@oh-my-pi/pi-utils 8.12.9 → 8.12.10

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": "@oh-my-pi/pi-utils",
3
- "version": "8.12.9",
3
+ "version": "8.12.10",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./fs-error";
4
4
  export * from "./glob";
5
5
  export * as logger from "./logger";
6
6
  export * as postmortem from "./postmortem";
7
+ export * as procmgr from "./procmgr";
7
8
  export * as ptree from "./ptree";
8
9
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
9
10
  export * from "./stream";
package/src/procmgr.ts ADDED
@@ -0,0 +1,317 @@
1
+ import * as fs from "node:fs";
2
+ import * as timers from "node:timers";
3
+ import type { Subprocess } from "bun";
4
+
5
+ export interface ShellConfig {
6
+ shell: string;
7
+ args: string[];
8
+ env: Record<string, string | undefined>;
9
+ prefix: string | undefined;
10
+ }
11
+
12
+ let cachedShellConfig: ShellConfig | null = null;
13
+
14
+ const IS_WINDOWS = process.platform === "win32";
15
+ const TERM_SIGNAL = IS_WINDOWS ? undefined : "SIGTERM";
16
+
17
+ /**
18
+ * Check if a shell binary is executable.
19
+ */
20
+ async function isExecutable(path: string): Promise<boolean> {
21
+ try {
22
+ await fs.promises.access(path, fs.constants.X_OK);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Build the spawn environment (cached).
31
+ */
32
+ function buildSpawnEnv(shell: string): Record<string, string | undefined> {
33
+ const noCI = process.env.OMP_BASH_NO_CI || process.env.CLAUDE_BASH_NO_CI;
34
+ return {
35
+ ...process.env,
36
+ SHELL: shell,
37
+ GIT_EDITOR: "true",
38
+ GPG_TTY: "not a tty",
39
+ OMPCODE: "1",
40
+ CLAUDECODE: "1",
41
+ ...(noCI ? {} : { CI: "true" }),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Get shell args, optionally including login shell flag.
47
+ * Supports OMP_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
48
+ */
49
+ function getShellArgs(): string[] {
50
+ const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
51
+ return noLogin ? ["-c"] : ["-l", "-c"];
52
+ }
53
+
54
+ /**
55
+ * Get shell prefix for wrapping commands (profilers, strace, etc.).
56
+ */
57
+ function getShellPrefix(): string | undefined {
58
+ return process.env.OMP_SHELL_PREFIX || process.env.CLAUDE_CODE_SHELL_PREFIX;
59
+ }
60
+
61
+ /**
62
+ * Find bash executable on PATH (Windows)
63
+ */
64
+ function findBashOnPath(): string | null {
65
+ try {
66
+ return Bun.which("bash.exe");
67
+ } catch {
68
+ // Ignore errors
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Build full shell config from a shell path.
75
+ */
76
+ function buildConfig(shell: string): ShellConfig {
77
+ return {
78
+ shell,
79
+ args: getShellArgs(),
80
+ env: buildSpawnEnv(shell),
81
+ prefix: getShellPrefix(),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Get shell configuration based on platform.
87
+ * Resolution order:
88
+ * 1. User-specified shellPath in settings.json
89
+ * 2. On Windows: Git Bash in known locations, then bash on PATH
90
+ * 3. On Unix: $SHELL if bash/zsh, then fallback paths
91
+ * 4. Fallback: sh
92
+ */
93
+ export async function getShellConfig(customShellPath?: string): Promise<ShellConfig> {
94
+ if (cachedShellConfig) {
95
+ return cachedShellConfig;
96
+ }
97
+
98
+ // 1. Check user-specified shell path
99
+ if (customShellPath) {
100
+ if (await Bun.file(customShellPath).exists()) {
101
+ cachedShellConfig = buildConfig(customShellPath);
102
+ return cachedShellConfig;
103
+ }
104
+ throw new Error(
105
+ `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.omp/agent/settings.json`,
106
+ );
107
+ }
108
+
109
+ if (process.platform === "win32") {
110
+ // 2. Try Git Bash in known locations
111
+ const paths: string[] = [];
112
+ const programFiles = process.env.ProgramFiles;
113
+ if (programFiles) {
114
+ paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
115
+ }
116
+ const programFilesX86 = process.env["ProgramFiles(x86)"];
117
+ if (programFilesX86) {
118
+ paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
119
+ }
120
+
121
+ for (const path of paths) {
122
+ if (await Bun.file(path).exists()) {
123
+ cachedShellConfig = buildConfig(path);
124
+ return cachedShellConfig;
125
+ }
126
+ }
127
+
128
+ // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
129
+ const bashOnPath = findBashOnPath();
130
+ if (bashOnPath) {
131
+ cachedShellConfig = buildConfig(bashOnPath);
132
+ return cachedShellConfig;
133
+ }
134
+
135
+ throw new Error(
136
+ `No bash shell found. Options:\n` +
137
+ ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
138
+ ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
139
+ ` 3. Set shellPath in ~/.omp/agent/settings.json\n\n` +
140
+ `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
141
+ );
142
+ }
143
+
144
+ // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
145
+ const userShell = process.env.SHELL;
146
+ const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
147
+ if (isValidShell && (await isExecutable(userShell))) {
148
+ cachedShellConfig = buildConfig(userShell);
149
+ return cachedShellConfig;
150
+ }
151
+
152
+ // Fallback paths (Claude's approach: check known locations)
153
+ const fallbackPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
154
+ const preferZsh = !userShell?.includes("bash");
155
+ const shellOrder = preferZsh ? ["zsh", "bash"] : ["bash", "zsh"];
156
+
157
+ for (const shellName of shellOrder) {
158
+ for (const dir of fallbackPaths) {
159
+ const shellPath = `${dir}/${shellName}`;
160
+ if (await isExecutable(shellPath)) {
161
+ cachedShellConfig = buildConfig(shellPath);
162
+ return cachedShellConfig;
163
+ }
164
+ }
165
+ }
166
+
167
+ // Last resort: use Bun.which
168
+ const bashPath = Bun.which("bash");
169
+ if (bashPath) {
170
+ cachedShellConfig = buildConfig(bashPath);
171
+ return cachedShellConfig;
172
+ }
173
+
174
+ const shPath = Bun.which("sh");
175
+ cachedShellConfig = buildConfig(shPath || "sh");
176
+ return cachedShellConfig;
177
+ }
178
+
179
+ /**
180
+ * Options for terminating a process and all its descendants.
181
+ */
182
+ export interface TerminateOptions {
183
+ /** The process to terminate */
184
+ target: Subprocess | number;
185
+ /** Whether to terminate the process group (Windows only) */
186
+ group?: boolean;
187
+ /** Timeout in milliseconds */
188
+ timeout?: number;
189
+ /** Abort signal */
190
+ signal?: AbortSignal;
191
+ }
192
+
193
+ /**
194
+ * Check if a process is running.
195
+ */
196
+ export function isPidRunning(pid: number | Subprocess): boolean {
197
+ try {
198
+ if (typeof pid === "number") {
199
+ process.kill(pid, 0);
200
+ } else {
201
+ if (pid.killed) return false;
202
+ if (pid.exitCode !== null) return false;
203
+ }
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ function joinSignals(...sigs: (AbortSignal | null | undefined)[]): AbortSignal | undefined {
211
+ const nn = sigs.filter(Boolean) as AbortSignal[];
212
+ if (nn.length === 0) return undefined;
213
+ if (nn.length === 1) return nn[0];
214
+ return AbortSignal.any(nn);
215
+ }
216
+
217
+ export function onProcessExit(proc: Subprocess | number, abortSignal?: AbortSignal): Promise<boolean> {
218
+ if (typeof proc !== "number") {
219
+ return proc.exited.then(
220
+ () => true,
221
+ () => true,
222
+ );
223
+ }
224
+
225
+ if (!isPidRunning(proc)) {
226
+ return Promise.resolve(true);
227
+ }
228
+
229
+ const { promise, resolve, reject } = Promise.withResolvers<boolean>();
230
+ const localAbortController = new AbortController();
231
+
232
+ const timer = timers.promises.setInterval(300, null, {
233
+ signal: joinSignals(abortSignal, localAbortController.signal),
234
+ });
235
+ void (async () => {
236
+ try {
237
+ for await (const _ of timer) {
238
+ if (!isPidRunning(proc)) {
239
+ resolve(true);
240
+ break;
241
+ }
242
+ }
243
+ } catch (error) {
244
+ return reject(error);
245
+ } finally {
246
+ localAbortController.abort();
247
+ }
248
+ resolve(false);
249
+ })();
250
+
251
+ return promise;
252
+ }
253
+
254
+ /**
255
+ * Terminate a process and all its descendants.
256
+ */
257
+ export async function terminate(options: TerminateOptions): Promise<boolean> {
258
+ const { target, group = false, timeout = 5000, signal } = options;
259
+
260
+ const abortController = new AbortController();
261
+ try {
262
+ const abortSignal = joinSignals(signal, abortController.signal);
263
+
264
+ // Determine PID
265
+ let pid: number | undefined;
266
+ const exitPromise = onProcessExit(target, abortSignal);
267
+ if (typeof target === "number") {
268
+ pid = target;
269
+ } else {
270
+ pid = target.pid;
271
+ if (target.killed) return true;
272
+ }
273
+
274
+ // Give it a moment to exit gracefully first.
275
+ try {
276
+ if (typeof target === "number") {
277
+ process.kill(target, TERM_SIGNAL);
278
+ } else {
279
+ target.kill(TERM_SIGNAL);
280
+ }
281
+
282
+ if (exitPromise) {
283
+ const exited = await Promise.race([Bun.sleep(1000).then(() => false), exitPromise]);
284
+ if (exited) return true;
285
+ }
286
+ } catch {}
287
+
288
+ if (group) {
289
+ try {
290
+ if (IS_WINDOWS) {
291
+ const taskkill = Bun.spawn({
292
+ cmd: ["taskkill", "/F", "/T", "/PID", pid.toString()],
293
+ stdin: "ignore",
294
+ stdout: "ignore",
295
+ stderr: "ignore",
296
+ timeout: 5000,
297
+ });
298
+ void taskkill.exited.catch(() => {});
299
+ taskkill.unref();
300
+ } else {
301
+ process.kill(-pid, "SIGKILL");
302
+ }
303
+ } catch {}
304
+ }
305
+ try {
306
+ if (typeof target === "number") {
307
+ process.kill(target, "SIGKILL");
308
+ } else {
309
+ target.kill("SIGKILL");
310
+ }
311
+ } catch {}
312
+
313
+ return await Promise.race([Bun.sleep(timeout).then(() => false), exitPromise]);
314
+ } finally {
315
+ abortController.abort();
316
+ }
317
+ }
package/src/ptree.ts CHANGED
@@ -8,14 +8,15 @@
8
8
  * - Cross-platform tree kill for process groups (Windows taskkill, Unix -pid).
9
9
  * - Convenience helpers: captureText / execText, AbortSignal, timeouts.
10
10
  */
11
- import { $, type FileSink, type Spawn, type Subprocess } from "bun";
11
+
12
+ import type { Spawn, Subprocess } from "bun";
12
13
  import { postmortem } from ".";
14
+ import { terminate } from "./procmgr";
13
15
 
14
- const isWindows = process.platform === "win32";
15
16
  const managedChildren = new Set<ChildProcess>();
16
17
 
17
18
  /** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
18
- type PipedSubprocess = Subprocess<"pipe" | "ignore" | null, "pipe", "pipe">;
19
+ type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
19
20
 
20
21
  /** Minimal push-based ReadableStream that buffers unboundedly (like the old queue). */
21
22
  function pushStream<T>() {
@@ -97,35 +98,7 @@ async function pump(
97
98
  * - Unix: negative PID signals the process group
98
99
  */
99
100
  async function killChild(child: ChildProcess) {
100
- const pid = child.pid;
101
- if (!pid || child.killed) return;
102
-
103
- const exited = child.proc.exited.then(
104
- () => true,
105
- () => true,
106
- );
107
- const waitForExit = (timeout = 1000) => Promise.race([Bun.sleep(timeout).then(() => false), exited]);
108
-
109
- // Give it a moment to exit gracefully first.
110
- try {
111
- child.proc.kill();
112
- } catch {}
113
- if (await waitForExit(1000)) return true;
114
-
115
- if (child.isProcessGroup) {
116
- try {
117
- if (isWindows) {
118
- await $`taskkill /F /T /PID ${pid}`.quiet().nothrow();
119
- } else {
120
- process.kill(-pid);
121
- }
122
- } catch {}
123
- }
124
- try {
125
- child.proc.kill("SIGKILL");
126
- } catch {}
127
-
128
- return await waitForExit(1000);
101
+ await terminate({ target: child.proc, group: child.isProcessGroup });
129
102
  }
130
103
 
131
104
  postmortem.register("managed-children", async () => {
@@ -211,11 +184,13 @@ export class TimeoutError extends AbortError {
211
184
  }
212
185
  }
213
186
 
187
+ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
188
+
214
189
  /**
215
190
  * ChildProcess wraps a managed subprocess, capturing stderr tail, providing
216
191
  * cross-platform kill/detach logic plus AbortSignal integration.
217
192
  */
218
- export class ChildProcess {
193
+ export class ChildProcess<In extends InMask = InMask> {
219
194
  #nothrow = false;
220
195
 
221
196
  #stderrBuffer = "";
@@ -231,7 +206,7 @@ export class ChildProcess {
231
206
  #exited: Promise<number>;
232
207
 
233
208
  constructor(
234
- public readonly proc: PipedSubprocess,
209
+ public readonly proc: PipedSubprocess<In>,
235
210
  public readonly isProcessGroup: boolean,
236
211
  ) {
237
212
  const { promise: stderrDone, resolve: resolveStderrDone } = Promise.withResolvers<void>();
@@ -329,7 +304,7 @@ export class ChildProcess {
329
304
  get killed(): boolean {
330
305
  return this.proc.killed;
331
306
  }
332
- get stdin(): FileSink | undefined {
307
+ get stdin(): Bun.SpawnOptions.WritableToIO<In> {
333
308
  return this.proc.stdin;
334
309
  }
335
310
  get stdout(): ReadableStream<Uint8Array> {
@@ -443,8 +418,8 @@ export class ChildProcess {
443
418
  /**
444
419
  * Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
445
420
  */
446
- type ChildSpawnOptions = Omit<
447
- Spawn.SpawnOptions<"pipe" | "ignore" | Buffer | Uint8Array | null, "pipe", "pipe">,
421
+ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
422
+ Spawn.SpawnOptions<In, "pipe", "pipe">,
448
423
  "stdout" | "stderr"
449
424
  > & { signal?: AbortSignal; detached?: boolean };
450
425
 
@@ -454,7 +429,7 @@ type ChildSpawnOptions = Omit<
454
429
  * @param options - The options for the spawn.
455
430
  * @returns A ChildProcess instance.
456
431
  */
457
- export function spawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
432
+ export function spawn<In extends InMask = InMask>(cmd: string[], options?: ChildSpawnOptions<In>): ChildProcess<In> {
458
433
  const { detached = false, timeout, signal, ...rest } = options ?? {};
459
434
  const child = Bun.spawn(cmd, {
460
435
  stdin: "ignore",