diff --git a/src/shared/command-executor/execute-hook-command.ts b/src/shared/command-executor/execute-hook-command.ts index 65c6a678c..f6534e36b 100644 --- a/src/shared/command-executor/execute-hook-command.ts +++ b/src/shared/command-executor/execute-hook-command.ts @@ -1,114 +1,116 @@ -import { spawn } from "node:child_process" -import { getHomeDirectory } from "./home-directory" -import { findBashPath, findZshPath } from "./shell-path" +import { spawn } from "node:child_process"; +import { getHomeDirectory } from "./home-directory"; +import { findBashPath, findZshPath } from "./shell-path"; export interface CommandResult { - exitCode: number - stdout?: string - stderr?: string + exitCode: number; + stdout?: string; + stderr?: string; } -const DEFAULT_HOOK_TIMEOUT_MS = 30_000 -const SIGKILL_GRACE_MS = 5_000 +const DEFAULT_HOOK_TIMEOUT_MS = 30_000; +const SIGKILL_GRACE_MS = 5_000; export interface ExecuteHookOptions { - forceZsh?: boolean - zshPath?: string - /** Timeout in milliseconds. Process is killed after this. Default: 30000 */ - timeoutMs?: number + forceZsh?: boolean; + zshPath?: string; + /** Timeout in milliseconds. Process is killed after this. Default: 30000 */ + timeoutMs?: number; } export async function executeHookCommand( - command: string, - stdin: string, - cwd: string, - options?: ExecuteHookOptions, + command: string, + stdin: string, + cwd: string, + options?: ExecuteHookOptions, ): Promise { - const home = getHomeDirectory() - const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS + const home = getHomeDirectory(); + const timeoutMs = options?.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS; - const expandedCommand = command - .replace(/^~(?=\/|$)/g, home) - .replace(/\s~(?=\/)/g, ` ${home}`) - .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) - .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd) + const expandedCommand = command + .replace(/^~(?=\/|$)/g, home) + .replace(/\s~(?=\/)/g, ` ${home}`) + .replace(/\$CLAUDE_PROJECT_DIR/g, cwd) + .replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd); - let finalCommand = expandedCommand + let finalCommand = expandedCommand; - if (options?.forceZsh) { - const zshPath = findZshPath(options.zshPath) - const escapedCommand = expandedCommand.replace(/'/g, "'\\''") - if (zshPath) { - finalCommand = `${zshPath} -lc '${escapedCommand}'` - } else { - const bashPath = findBashPath() - if (bashPath) { - finalCommand = `${bashPath} -lc '${escapedCommand}'` - } - } - } + if (options?.forceZsh) { + const zshPath = findZshPath(options.zshPath); + const escapedCommand = expandedCommand.replace(/'/g, "'\\''"); + if (zshPath) { + finalCommand = `${zshPath} -lc '${escapedCommand}'`; + } else { + const bashPath = findBashPath(); + if (bashPath) { + finalCommand = `${bashPath} -lc '${escapedCommand}'`; + } + } + } - return new Promise((resolve) => { - let settled = false - let killTimer: ReturnType | null = null + return new Promise(resolve => { + let settled = false; + let killTimer: ReturnType | null = null; - const proc = spawn(finalCommand, { - cwd, - shell: true, - env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, - }) + const proc = spawn(finalCommand, { + cwd, + shell: true, + env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd }, + }); - let stdout = "" - let stderr = "" + let stdout = ""; + let stderr = ""; - proc.stdout?.on("data", (data: Buffer) => { - stdout += data.toString() - }) + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); - proc.stderr?.on("data", (data: Buffer) => { - stderr += data.toString() - }) + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); - proc.stdin?.write(stdin) - proc.stdin?.end() + proc.stdin?.write(stdin); + proc.stdin?.end(); - const settle = (result: CommandResult) => { - if (settled) return - settled = true - if (killTimer) clearTimeout(killTimer) - if (timeoutTimer) clearTimeout(timeoutTimer) - resolve(result) - } + const settle = (result: CommandResult) => { + if (settled) return; + settled = true; + if (killTimer) clearTimeout(killTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + resolve(result); + }; - proc.on("close", (code) => { - settle({ - exitCode: code ?? 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - }) - }) + proc.on("close", code => { + settle({ + exitCode: code ?? 1, + stdout: stdout.trim(), + stderr: stderr.trim(), + }); + }); - proc.on("error", (err) => { - settle({ exitCode: 1, stderr: err.message }) - }) + proc.on("error", err => { + settle({ exitCode: 1, stderr: err.message }); + }); - const timeoutTimer = setTimeout(() => { - if (settled) return - // Try graceful shutdown first - proc.kill("SIGTERM") - killTimer = setTimeout(() => { - if (settled) return - try { - proc.kill("SIGKILL") - } catch {} - }, SIGKILL_GRACE_MS) - // Append timeout notice to stderr - stderr += `\nHook command timed out after ${timeoutMs}ms` - }, timeoutMs) + const timeoutTimer = setTimeout(() => { + if (settled) return; + // Try graceful shutdown first + try { + proc.kill("SIGTERM"); + } catch {} + killTimer = setTimeout(() => { + if (settled) return; + try { + proc.kill("SIGKILL"); + } catch {} + }, SIGKILL_GRACE_MS); + // Append timeout notice to stderr + stderr += `\nHook command timed out after ${timeoutMs}ms`; + }, timeoutMs); - // Don't let the timeout timer keep the process alive - if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) { - timeoutTimer.unref() - } - }) + // Don't let the timeout timer keep the process alive + if (timeoutTimer && typeof timeoutTimer === "object" && "unref" in timeoutTimer) { + timeoutTimer.unref(); + } + }); }