shout/src/run.ts

209 lines
5.2 KiB
TypeScript

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_<exitcode>_<index>__
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<FileResult> {
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<string, string> = options.cleanEnv
? {}
: { ...process.env as Record<string, string> }
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<Uint8Array>,
timeoutMs: number,
): Promise<string> {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout reading output")), timeoutMs),
)
try {
while (true) {
const { done, value } = await Promise.race([reader.read(), timeout]) as ReadableStreamReadResult<Uint8Array>
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<void> {
await rm(dir, { recursive: true, force: true })
}