// A child process with the call shapes the runner was reviewed with: `output()` // collects both streams, `spawn()` hands them out, and `clearEnv` passes only // the given environment. Streams default as Deno's did: stdin is inherited, // stdout and stderr are piped for `output()` and inherited for `spawn()`. import { spawn as spawnProcess } from "node:child_process"; import type { ChildProcess, StdioOptions } from "node:child_process"; import { Buffer } from "node:buffer"; import { constants } from "node:os"; import process from "node:process"; import type { Readable, Writable } from "node:stream"; import { buffer } from "node:stream/consumers"; import { finished } from "node:stream/promises"; export type StdioMode = "inherit" | "piped" | "null"; export interface CommandOptions { args?: string[]; cwd?: string; env?: Record; clearEnv?: boolean; uid?: number; gid?: number; stdin?: StdioMode; stdout?: StdioMode; stderr?: StdioMode; } export interface CommandStatus { success: boolean; code: number; } export interface CommandOutput extends CommandStatus { stdout: Uint8Array; stderr: Uint8Array; } export interface CommandChild { status: Promise; stdin: Writable; stdout: Readable; stderr: Readable; } const STDIO: Record = { inherit: "inherit", piped: "pipe", null: "ignore", }; function signalNumber(signal: NodeJS.Signals): number { return constants.signals[signal]; } function startProcess( command: string, options: CommandOptions, outputDefault: StdioMode, ): ChildProcess { const stdio: StdioOptions = [ STDIO[options.stdin ?? "inherit"], STDIO[options.stdout ?? outputDefault], STDIO[options.stderr ?? outputDefault], ]; const environment = options.clearEnv === true ? options.env : { ...process.env, ...options.env }; return spawnProcess(command, options.args ?? [], { stdio, ...(options.cwd !== undefined && { cwd: options.cwd }), ...(environment !== undefined && { env: environment }), ...(options.uid !== undefined && { uid: options.uid }), ...(options.gid !== undefined && { gid: options.gid }), }); } // Settles when the process exits, before its streams close, as Deno's did. A // signal-killed process reports 128 plus the signal number, as a shell does. function waitForStatus(child: ChildProcess): Promise { return new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", (code, signal) => { const exitCode = code ?? (signal === null ? 1 : 128 + signalNumber(signal)); resolve({ success: exitCode === 0, code: exitCode }); }); }); } function piped(stream: T | null, name: string): T { if (stream === null) { throw new TypeError(`${name} is not piped`); } return stream; } function collect(stream: Readable | null): Promise { return stream === null ? Promise.resolve(new Uint8Array()) : buffer(stream); } // A stream's chunks as bytes, whether or not it has an encoding set. export async function* chunks(stream: Readable): AsyncGenerator { for await (const chunk of stream) { const value: unknown = chunk; if (value instanceof Uint8Array) { yield value; } else if (typeof value === "string") { yield Buffer.from(value); } } } export function spawn(command: string, options: CommandOptions = {}): CommandChild { const child = startProcess(command, options, "inherit"); const status = waitForStatus(child); // A failed start rejects `status`; the caller sees it once it awaits that. status.catch(() => {}); return { status, get stdin(): Writable { return piped(child.stdin, "stdin"); }, get stdout(): Readable { return piped(child.stdout, "stdout"); }, get stderr(): Readable { return piped(child.stderr, "stderr"); }, }; } export async function output( command: string, options: CommandOptions = {}, ): Promise { const child = startProcess(command, options, "piped"); const [status, stdout, stderr] = await Promise.all([ waitForStatus(child), collect(child.stdout), collect(child.stderr), ]); return { ...status, stdout, stderr }; } // Writes one chunk and settles once the stream has taken it, as Deno's // `Deno.stdout.write` did. export async function writeChunk(stream: Writable, chunk: Uint8Array): Promise { await new Promise((resolve, reject) => { stream.write(chunk, (error) => { if (error) { reject(error); } else { resolve(); } }); }); } // Writes the last chunk and settles once the stream has flushed and closed. export async function endWith(stream: Writable, chunk: string): Promise { stream.end(chunk); await finished(stream); }