import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import type { Command, ShoutFile } from "./parse.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[] timeout: number verbose: boolean onCommand?: (cmd: Command) => void } const SENTINEL_PREFIX = "__SHOUT_SENTINEL_" function buildScript(commands: Command[], sentinel: string): string { const lines: string[] = ["exec 2>&1"] for (let i = 0; i < commands.length; i++) { const cmd = commands[i]! lines.push(cmd.command) // Sentinel: printf to avoid echo interpretation issues // Format: __SHOUT_SENTINEL____ lines.push( `printf '\\n${sentinel}%s_${i}__\\n' "$?"`, ) } return lines.join("\n") + "\n" } function parseSentinelOutput( raw: string, sentinel: string, commandCount: number, ): { outputs: string[][]; exitCodes: number[] } { const outputs: string[][] = [] const exitCodes: number[] = [] // Split by sentinel lines const sentinelRegex = new RegExp( `${escapeRegex(sentinel)}(\\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 } } function trimTrailingEmpty(lines: string[]): string[] { let end = lines.length while (end > 0 && lines[end - 1] === "") end-- return lines.slice(0, end) } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } 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 script = buildScript(file.commands, sentinel) const env: Record = options.cleanEnv ? {} : { ...process.env as Record } env["HOME"] = tmpDir env["SHOUT_DIR"] = tmpDir if (options.pathDirs?.length) { env["PATH"] = options.pathDirs.join(":") + ":" + (env["PATH"] ?? "") } try { const proc = Bun.spawn(["/bin/sh"], { stdin: "pipe", stdout: "pipe", stderr: "pipe", cwd: tmpDir, env, }) proc.stdin.write(script) proc.stdin.end() const stdout = await readWithTimeout(proc.stdout, options.timeout * file.commands.length) const stderr = await readWithTimeout(proc.stderr, 1000).catch(() => "") await proc.exited const { outputs, exitCodes } = parseSentinelOutput( stdout, sentinel, file.commands.length, ) const results: CommandResult[] = file.commands.map((cmd, i) => { if (options.verbose && options.onCommand) { options.onCommand(cmd) } return { command: cmd, actual: outputs[i] ?? [], exitCode: exitCodes[i] ?? 1, } }) return { file, results, tmpDir } } catch (err) { return { file, results: [], tmpDir, error: err instanceof Error ? err.message : String(err), } } } async function readWithTimeout( stream: ReadableStream, timeoutMs: number, ): Promise { const reader = stream.getReader() const chunks: Uint8Array[] = [] const timeout = new Promise((_, reject) => 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 { 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 }) }