/** * Cross-platform shell command spawning for background terminals. * * Windows: PowerShell 7 (pwsh) with -NoProfile, -NonInteractive. * POSIX: /bin/bash -c (detached:true provides process group isolation). */ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { platform } from "node:os"; // ── Public API ────────────────────────────────────────────────────────────────── export interface ShellOptions { /** Working directory. Defaults to process.cwd(). */ cwd?: string; /** Additional environment variables. */ env?: Record; /** Called for each stdout chunk. */ onStdout?: (data: string) => void; /** Called for each stderr chunk. */ onStderr?: (data: string) => void; /** Called when the process exits. */ onExit?: (code: number | null, signal: NodeJS.Signals | null) => void; /** Called on spawn error. */ onError?: (err: Error) => void; } export interface SpawnedShell { /** The underlying child process. */ process: ChildProcess; /** Process ID (also the process group leader on POSIX via setsid). */ pid: number; } /** * Spawn a shell command as a background process with its own process group. * * On POSIX, `detached: true` makes the shell process call setsid() after fork, * so it becomes session leader with its PID as the process group. This allows * SIGTERM→SIGKILL escalation on the entire group via `kill(-pgid)`. * * On Windows, the process must remain attached: detached PowerShell can exit * immediately without running the command or forwarding its output. Full tree * termination still uses `taskkill /T /F`. */ export function spawnBackground( command: string, options: ShellOptions = {}, ): SpawnedShell { const shellPath = getShellPath(); const shellArgs = buildShellArgs(command); const child = spawn(shellPath, shellArgs, { cwd: options.cwd ?? process.cwd(), env: { ...process.env, ...options.env }, stdio: ["ignore", "pipe", "pipe"], detached: platform() !== "win32", windowsHide: true, }); if (options.onStdout) { child.stdout?.on("data", (chunk: Buffer) => options.onStdout!(chunk.toString()), ); } if (options.onStderr) { child.stderr?.on("data", (chunk: Buffer) => options.onStderr!(chunk.toString()), ); } if (options.onExit) { child.on("exit", options.onExit); } if (options.onError) { child.on("error", options.onError); } return { process: child, pid: child.pid ?? 0, }; } /** * Return the shell path for the current platform. */ export function getShellPath(): string { return platform() === "win32" ? getPwshPath() : "/bin/bash"; } /** * Build platform-appropriate shell arguments for `spawn()`. * * Windows: pwsh -NoLogo -NoProfile -NonInteractive -Command * POSIX: bash -c (detached:true isolates process group) */ export function buildShellArgs(command: string, isWin?: boolean): string[] { const win = isWin ?? platform() === "win32"; if (win) { return [ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command, ]; } // POSIX: spawn with detached:true already calls setsid() after fork, // so bash becomes session leader and its process group equals its PID. // SIGTERM/SIGKILL on - will target the entire group. return ["-c", command]; } // ── Internal ──────────────────────────────────────────────────────────────────── let cachedWindowsShell: string | undefined; function getPwshPath(): string { if (process.env.PI_USER_BASH_PWSH) return process.env.PI_USER_BASH_PWSH; if (process.env.PI_USER_BASH_SHELL) return process.env.PI_USER_BASH_SHELL; if (cachedWindowsShell) return cachedWindowsShell; const probe = spawnSync("where.exe", ["pwsh.exe"], { stdio: "ignore", windowsHide: true, timeout: 2_000, }); cachedWindowsShell = probe.status === 0 ? "pwsh.exe" : "powershell.exe"; return cachedWindowsShell; }