Add real-time verbose command streaming via stderr

This commit is contained in:
Chris Wanstrath 2026-03-12 14:48:10 -07:00
parent 10e98e2dc5
commit 18887b9419

View File

@ -29,12 +29,23 @@ type RunOptions = {
} }
const SENTINEL_PREFIX = "__SHOUT_SENTINEL_" const SENTINEL_PREFIX = "__SHOUT_SENTINEL_"
const VERBOSE_MARKER = "__SHOUT_CMD_"
function buildScript(commands: Command[], sentinel: string): string { function buildScript(commands: Command[], sentinel: string, verbose: boolean): string {
const lines: string[] = ["exec 2>&1"] const lines: string[] = []
if (verbose) {
// Save original stderr to fd 3 before merging stderr into stdout
lines.push("exec 3>&2 2>&1")
} else {
lines.push("exec 2>&1")
}
for (let i = 0; i < commands.length; i++) { for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]! const cmd = commands[i]!
if (verbose) {
lines.push(`printf '${VERBOSE_MARKER}${i}\\n' >&3`)
}
lines.push(cmd.command) lines.push(cmd.command)
// Sentinel: printf to avoid echo interpretation issues // Sentinel: printf to avoid echo interpretation issues
// Format: __SHOUT_SENTINEL_<exitcode>_<index>__ // Format: __SHOUT_SENTINEL_<exitcode>_<index>__
@ -111,6 +122,36 @@ function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
} }
function streamVerboseMarkers(
stderr: ReadableStream<Uint8Array>,
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( export async function runFile(
file: ShoutFile, file: ShoutFile,
options: RunOptions, options: RunOptions,
@ -122,7 +163,8 @@ export async function runFile(
} }
const sentinel = SENTINEL_PREFIX const sentinel = SENTINEL_PREFIX
const script = buildScript(file.commands, sentinel) const verbose = options.verbose && !!options.onCommand
const script = buildScript(file.commands, sentinel, verbose)
const env: Record<string, string> = options.cleanEnv const env: Record<string, string> = options.cleanEnv
? {} ? {}
@ -155,11 +197,19 @@ export async function runFile(
}) })
try { 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.write(script)
proc.stdin.end() proc.stdin.end()
const stdout = await readWithTimeout(proc.stdout, options.timeout * file.commands.length) const totalTimeout = options.timeout * file.commands.length
const stderr = await readWithTimeout(proc.stderr, 1000).catch(() => "") const stdout = await readWithTimeout(proc.stdout, totalTimeout)
if (!verbose) {
await readWithTimeout(proc.stderr, 1000).catch(() => "")
}
await proc.exited await proc.exited
@ -169,16 +219,11 @@ export async function runFile(
file.commands.length, file.commands.length,
) )
const results: CommandResult[] = file.commands.map((cmd, i) => { const results: CommandResult[] = file.commands.map((cmd, i) => ({
if (options.verbose && options.onCommand) { command: cmd,
options.onCommand(cmd) actual: outputs[i] ?? [],
} exitCode: exitCodes[i] ?? 1,
return { }))
command: cmd,
actual: outputs[i] ?? [],
exitCode: exitCodes[i] ?? 1,
}
})
return { file, results, tmpDir } return { file, results, tmpDir }
} catch (err) { } catch (err) {