209 lines
5.2 KiB
TypeScript
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 })
|
|
}
|