import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import type { Command, ShoutFile } from "./parse.ts" import { trimTrailingEmpty } from "./utils.ts" export type CommandResult = { command: Command actual: string[] exitCode: number } export type FileResult = { file: ShoutFile results: CommandResult[] tmpDir: string error?: string } type RunOptions = { cleanEnv: boolean pathDirs?: string[] envVars?: Record sourceDir?: string projectDir?: string timeout: number verbose?: boolean onCommand?: (cmd: Command) => void onCommandResult?: (index: number, result: CommandResult) => void } function killTree(pid: number): void { // Find any processes that escaped the process group (e.g. via setsid). // This assumes pid === pgid, which holds because the child is spawned // with detached: true (making it a process group leader). try { const result = Bun.spawnSync(["ps", "-eo", "pid,pgid"]) const output = result.stdout.toString() const pgid = String(pid) for (const line of output.split("\n")) { const parts = line.trim().split(/\s+/) if (parts[1] === pgid) { const p = parseInt(parts[0]!, 10) if (!isNaN(p) && p !== pid && p > 1) { try { process.kill(p, "SIGKILL") } catch {} } } } } catch {} // Kill the process group try { process.kill(-pid, "SIGKILL") } catch {} } const SENTINEL_PREFIX = "__SHOUT_SENTINEL_" const VERBOSE_MARKER = "__SHOUT_CMD_" function buildScript(commands: Command[], sentinel: string, verbose: boolean): string { const lines: string[] = [] if (verbose) { // Save original stderr to fd 3 before merging stderr into stdout // Save the pipe on fd 9 so we can restore after each command lines.push("exec 3>&2 2>&1 9>&1") } else { lines.push("exec 2>&1 9>&1") } for (let i = 0; i < commands.length; i++) { const cmd = commands[i]! if (verbose) { lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`) } // Redirect stdout+stderr to a temp file so background processes // from previous commands can't pollute this command's output. // Background processes keep their fd pointing at the old temp file, // which becomes orphaned after rm — their output goes nowhere. lines.push(`__shout_out=$(mktemp)`) lines.push(`exec 1>"$__shout_out" 2>&1`) lines.push(cmd.command) lines.push(`__shout_ec=$?`) lines.push(`exec 1>&9 2>&1`) lines.push(`cat "$__shout_out"`) lines.push(`rm -f "$__shout_out"`) // Sentinel: printf to avoid echo interpretation issues // Format: __SHOUT_SENTINEL____ lines.push( `printf '\\n${sentinel}%s_${i}__\\n' "$__shout_ec"`, ) } return lines.join("\n") + "\n" } function parseSentinelOutput( raw: string, commandCount: number, ): { outputs: string[][]; exitCodes: number[] } { const outputs: string[][] = [] const exitCodes: number[] = [] // Split by sentinel lines const sentinelRegex = new RegExp( `${SENTINEL_PREFIX}(\\d+)_(\\d+)__`, ) let remaining = raw for (let i = 0; i < commandCount; i++) { const match = remaining.match(sentinelRegex) if (!match) { // No sentinel found — rest is output for this command const lines = remaining.split("\n") // Remove leading empty line (from printf \n prefix) if (lines.length > 0 && lines[0] === "") lines.shift() outputs.push(trimTrailingEmpty(lines)) exitCodes.push(1) // assume failure break } const idx = remaining.indexOf(match[0]) const before = remaining.slice(0, idx) const afterSentinel = remaining.slice(idx + match[0].length) // Parse output lines let lines = before.split("\n") // Remove leading empty line from previous sentinel's trailing \n if (lines.length > 0 && lines[0] === "") lines.shift() // Remove trailing empty lines (from printf's \n prefix) lines = trimTrailingEmpty(lines) outputs.push(lines.length === 1 && lines[0] === "" ? [] : lines) exitCodes.push(parseInt(match[1]!, 10)) // Skip past sentinel line (including trailing newline) remaining = afterSentinel.startsWith("\n") ? afterSentinel.slice(1) : afterSentinel } // Fill missing entries while (outputs.length < commandCount) { outputs.push([]) exitCodes.push(1) } return { outputs, exitCodes } } // eslint-disable-next-line no-control-regex const ANSI_REGEX = /[\u001b\u009b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g function stripAnsi(line: string): string { return line.replace(ANSI_REGEX, "") } function streamVerboseMarkers( stderr: ReadableStream, commands: Command[], onCommand: (cmd: Command) => void, ): void { const reader = stderr.getReader() const decoder = new TextDecoder() let buffer = "" const pump = (): void => { reader.read().then(({ done, value }) => { if (done) return if (value) buffer += decoder.decode(value, { stream: true }) let nlIdx: number while ((nlIdx = buffer.indexOf("\n")) !== -1) { const line = buffer.slice(0, nlIdx) buffer = buffer.slice(nlIdx + 1) if (line.startsWith(VERBOSE_MARKER)) { const i = parseInt(line.slice(VERBOSE_MARKER.length), 10) if (i >= 0 && i < commands.length) { onCommand(commands[i]!) } } } pump() }).catch(() => {}) } pump() } export async function runFile( file: ShoutFile, options: RunOptions, ): Promise { const tmpDir = await mkdtemp(join(tmpdir(), "shout-")) if (file.commands.length === 0) { return { file, results: [], tmpDir } } const sentinel = SENTINEL_PREFIX const verbose = !!(options.verbose && options.onCommand) const script = buildScript(file.commands, sentinel, verbose) const env: Record = options.cleanEnv ? {} : { ...process.env as Record } env["HOME"] = tmpDir env["SHOUT_DIR"] = tmpDir if (options.sourceDir) { env["SHOUT_SOURCE_DIR"] = options.sourceDir } if (options.projectDir) { env["SHOUT_PROJECT_DIR"] = options.projectDir } if (options.envVars) { Object.assign(env, options.envVars) } if (options.pathDirs?.length) { env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") } const proc = Bun.spawn(["/bin/sh"], { detached: true, stdin: "pipe", stdout: "pipe", stderr: "pipe", cwd: tmpDir, env, }) try { if (verbose) { // Stream stderr for verbose command markers before writing script streamVerboseMarkers(proc.stderr, file.commands, options.onCommand!) } proc.stdin.write(script) proc.stdin.end() const totalTimeout = options.timeout * file.commands.length const lastSentinelSuffix = `_${file.commands.length - 1}__` const onSentinel = options.onCommandResult ? (index: number, exitCode: number, rawOutput: string) => { let lines = rawOutput.split("\n") if (lines.length > 0 && lines[0] === "") lines.shift() lines = trimTrailingEmpty(lines) if (lines.length === 1 && lines[0] === "") lines = [] const result: CommandResult = { command: file.commands[index]!, actual: lines.map(stripAnsi), exitCode, } options.onCommandResult!(index, result) } : undefined const stdout = await readUntilSentinel(proc.stdout, sentinel, lastSentinelSuffix, totalTimeout, onSentinel) if (!verbose) { await readWithTimeout(proc.stderr, 1000).catch(() => "") } const { outputs, exitCodes } = parseSentinelOutput( stdout, file.commands.length, ) const results: CommandResult[] = file.commands.map((cmd, i) => ({ command: cmd, actual: (outputs[i] ?? []).map(stripAnsi), exitCode: exitCodes[i] ?? 1, })) return { file, results, tmpDir } } catch (err) { return { file, results: [], tmpDir, error: err instanceof Error ? err.message : String(err), } } finally { if (proc.pid) { killTree(proc.pid) } } } async function readUntilSentinel( stream: ReadableStream, sentinelPrefix: string, sentinelSuffix: string, timeoutMs: number, onSentinel?: (index: number, exitCode: number, outputBefore: string) => void, ): Promise { const reader = stream.getReader() const decoder = new TextDecoder() let accumulated = "" let sentinelsReported = 0 let lastSentinelEnd = 0 let timerId: ReturnType const timeout = new Promise((_, reject) => timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs), ) try { while (true) { const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult if (done) break if (value) { accumulated += decoder.decode(value, { stream: true }) // Detect new sentinels incrementally for streaming results if (onSentinel) { const regex = new RegExp(`${sentinelPrefix}(\\d+)_(\\d+)__`, "g") regex.lastIndex = lastSentinelEnd let match while ((match = regex.exec(accumulated)) !== null) { const idx = parseInt(match[2]!, 10) if (idx >= sentinelsReported) { const exitCode = parseInt(match[1]!, 10) const output = accumulated.slice(lastSentinelEnd, match.index) onSentinel(idx, exitCode, output) sentinelsReported = idx + 1 lastSentinelEnd = match.index + match[0].length if (accumulated[lastSentinelEnd] === "\n") lastSentinelEnd++ } } } // Check if the last sentinel has appeared (prefix + exitcode + suffix) const prefixIdx = accumulated.lastIndexOf(sentinelPrefix) if (prefixIdx !== -1 && accumulated.indexOf(sentinelSuffix, prefixIdx) !== -1) break } } } finally { clearTimeout(timerId!) reader.releaseLock() } return accumulated + decoder.decode() } async function readWithTimeout( stream: ReadableStream, timeoutMs: number, ): Promise { const reader = stream.getReader() const chunks: Uint8Array[] = [] let timerId: ReturnType const timeout = new Promise((_, reject) => timerId = setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs), ) try { while (true) { const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult if (done) break if (value) chunks.push(value) } } finally { clearTimeout(timerId!) reader.releaseLock() } const decoder = new TextDecoder() return chunks.map(c => decoder.decode(c, { stream: true })).join("") + decoder.decode() } export async function cleanupTmpDir(dir: string): Promise { await rm(dir, { recursive: true, force: true }) }