2026-02-21 16:03:06 -07:00
|
|
|
import { spawn } from "node:child_process";
|
|
|
|
|
import { getHomeDirectory } from "./home-directory";
|
|
|
|
|
import { findBashPath, findZshPath } from "./shell-path";
|
2026-02-08 15:01:42 +09:00
|
|
|
|
|
|
|
|
export interface CommandResult {
|
2026-02-21 16:03:06 -07:00
|
|
|
exitCode: number;
|
|
|
|
|
stdout?: string;
|
|
|
|
|
stderr?: string;
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 16:03:06 -07:00
|
|
|
const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
|
|
|
|
const SIGKILL_GRACE_MS = 5_000;
|
2026-02-21 15:42:47 -07:00
|
|
|
|
2026-02-08 15:01:42 +09:00
|
|
|
export interface ExecuteHookOptions {
|
2026-02-21 16:03:06 -07:00
|
|
|
forceZsh?: boolean;
|
|
|
|
|
zshPath?: string;
|
|
|
|
|
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
|
|
|
|
|
timeoutMs?: number;
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function executeHookCommand(
|
2026-02-21 16:03:06 -07:00
|
|
|
command: string,
|
|
|
|
|
stdin: string,
|
|
|
|
|
cwd: string,
|
|
|
|
|
options?: ExecuteHookOptions,
|
2026-02-08 15:01:42 +09:00
|
|
|
): Promise<CommandResult> {
|
2026-02-21 16:03:06 -07:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
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}'`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise(resolve => {
|
|
|
|
|
let settled = false;
|
|
|
|
|
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
2026-02-21 16:29:22 -07:00
|
|
|
const isWin32 = process.platform === "win32";
|
2026-02-21 16:03:06 -07:00
|
|
|
const proc = spawn(finalCommand, {
|
|
|
|
|
cwd,
|
|
|
|
|
shell: true,
|
2026-02-21 16:29:22 -07:00
|
|
|
detached: !isWin32,
|
2026-02-21 16:03:06 -07:00
|
|
|
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let stdout = "";
|
|
|
|
|
let stderr = "";
|
|
|
|
|
|
|
|
|
|
proc.stdout?.on("data", (data: Buffer) => {
|
|
|
|
|
stdout += data.toString();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
proc.stderr?.on("data", (data: Buffer) => {
|
|
|
|
|
stderr += data.toString();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 16:29:22 -07:00
|
|
|
proc.stdin?.on("error", () => {});
|
2026-02-21 16:03:06 -07:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
proc.on("close", code => {
|
|
|
|
|
settle({
|
|
|
|
|
exitCode: code ?? 1,
|
|
|
|
|
stdout: stdout.trim(),
|
|
|
|
|
stderr: stderr.trim(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
proc.on("error", err => {
|
|
|
|
|
settle({ exitCode: 1, stderr: err.message });
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 16:29:22 -07:00
|
|
|
const killProcessGroup = (signal: NodeJS.Signals) => {
|
2026-02-21 16:03:06 -07:00
|
|
|
try {
|
2026-02-21 16:29:22 -07:00
|
|
|
if (!isWin32 && proc.pid) {
|
|
|
|
|
process.kill(-proc.pid, signal);
|
|
|
|
|
} else {
|
|
|
|
|
proc.kill(signal);
|
|
|
|
|
}
|
2026-02-21 16:03:06 -07:00
|
|
|
} catch {}
|
2026-02-21 16:29:22 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeoutTimer = setTimeout(() => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
// Kill entire process group to avoid orphaned children
|
|
|
|
|
killProcessGroup("SIGTERM");
|
2026-02-21 16:03:06 -07:00
|
|
|
killTimer = setTimeout(() => {
|
|
|
|
|
if (settled) return;
|
2026-02-21 16:29:22 -07:00
|
|
|
killProcessGroup("SIGKILL");
|
2026-02-21 16:03:06 -07:00
|
|
|
}, 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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-08 15:01:42 +09:00
|
|
|
}
|