import { spawn } from "node:child_process"; import { chmod, mkdtemp, open, rmdir, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; const DEFAULT_OUTPUT_BOUND = 1024 * 1024; const DEFAULT_BODY_FILE_THRESHOLD = 8 * 1024; export type AmqExitCategory = | "success" | "error" | "usage" | "not-found" | "timeout" | "context-mismatch" | "invalid-json" | "output-limit" | "cancelled" | "spawn-error" | "process-crash" | "cleanup-failed"; export interface AmqCliAdapterOptions { readonly binaryPath: string; readonly maxStdoutBytes?: number; readonly maxStderrBytes?: number; readonly bodyFileThresholdBytes?: number; readonly tempDirectory?: string; readonly env?: Readonly; } export interface AmqRunOptions { readonly cwd?: string; readonly env?: Readonly; readonly signal?: AbortSignal; readonly parse?: (value: unknown) => T; } export interface AmqCommandResult { readonly ok: boolean; readonly category: AmqExitCategory; readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; readonly stdout: string; readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; readonly json?: T; readonly jsonError?: Error; readonly processError?: Error; } interface ProcessCapture { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; readonly stdout: string; readonly stderr: string; readonly stdoutTruncated: boolean; readonly stderrTruncated: boolean; readonly aborted: boolean; readonly processError?: Error; } interface TemporaryBody { readonly directory: string; readonly path: string; } /** * The sole process boundary for AMQ commands. * * It never invokes a shell, never infers routing identity, and always preserves * bounded stdout for callers to inspect even when AMQ exits nonzero. */ export class AmqCliAdapter { readonly #binaryPath: string; readonly #maxStdoutBytes: number; readonly #maxStderrBytes: number; readonly #bodyFileThresholdBytes: number; readonly #tempDirectory: string; readonly #env: NodeJS.ProcessEnv; constructor(options: AmqCliAdapterOptions) { if (!isAbsolute(options.binaryPath)) { throw new TypeError("AMQ binary path must be absolute"); } this.#binaryPath = options.binaryPath; this.#maxStdoutBytes = positiveInteger( options.maxStdoutBytes ?? DEFAULT_OUTPUT_BOUND, "maxStdoutBytes", ); this.#maxStderrBytes = positiveInteger( options.maxStderrBytes ?? DEFAULT_OUTPUT_BOUND, "maxStderrBytes", ); this.#bodyFileThresholdBytes = nonNegativeInteger( options.bodyFileThresholdBytes ?? DEFAULT_BODY_FILE_THRESHOLD, "bodyFileThresholdBytes", ); this.#tempDirectory = options.tempDirectory ?? tmpdir(); this.#env = { ...process.env, ...options.env }; } async runJson( argv: readonly string[], options: AmqRunOptions = {}, ): Promise> { validateArgv(argv); const jsonArgv = argv.includes("--json") ? [...argv] : [...argv, "--json"]; const capture = await this.#execute(jsonArgv, options); return parseJsonCapture(capture, options.parse); } async runJsonWithBody( argv: readonly string[], body: string, options: AmqRunOptions = {}, ): Promise> { validateArgv(argv); const requiresFile = body.includes("\0") || Buffer.byteLength(body) > this.#bodyFileThresholdBytes; if (!requiresFile) { return this.runJson([...argv, "--body", body], options); } const temporary = await createTemporaryBody(this.#tempDirectory, body); let result: AmqCommandResult | undefined; let executionError: Error | undefined; try { result = await this.runJson([...argv, "--body", `@${temporary.path}`], options); } catch (error) { executionError = toError(error); } const cleanupError = await cleanupTemporaryBody(temporary); if (executionError) { if (cleanupError) { throw new AggregateError( [executionError, cleanupError], "AMQ command and temporary body cleanup both failed", ); } throw executionError; } if (!result) throw new Error("AMQ command completed without a result"); if (!cleanupError) return result; return { ...result, ok: false, category: "cleanup-failed", processError: cleanupError, }; } async #execute( argv: readonly string[], options: AmqRunOptions, ): Promise { if (options.signal?.aborted) { return emptyCapture(true); } return new Promise((resolve) => { const stdout = new BoundedCapture(this.#maxStdoutBytes); const stderr = new BoundedCapture(this.#maxStderrBytes); let child; try { child = spawn(this.#binaryPath, argv, { cwd: options.cwd, env: { ...this.#env, ...options.env }, shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); } catch (error) { resolve({ ...emptyCapture(false), processError: toError(error) }); return; } let settled = false; let aborted = false; let processError: Error | undefined; let outputLimitExceeded = false; const stopForOutputLimit = (): void => { if (outputLimitExceeded) return; outputLimitExceeded = true; child.kill("SIGKILL"); }; child.stdout.on("data", (chunk: Buffer) => { if (stdout.append(chunk)) stopForOutputLimit(); }); child.stderr.on("data", (chunk: Buffer) => { if (stderr.append(chunk)) stopForOutputLimit(); }); const onAbort = (): void => { aborted = true; child.kill("SIGTERM"); }; options.signal?.addEventListener("abort", onAbort, { once: true }); const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return; settled = true; options.signal?.removeEventListener("abort", onAbort); resolve({ exitCode, signal, stdout: stdout.value(), stderr: stderr.value(), stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, aborted, ...(processError ? { processError } : {}), }); }; child.once("error", (error) => { processError = error; }); child.once("close", settle); }); } } class BoundedCapture { readonly #limit: number; readonly #chunks: Buffer[] = []; #capturedBytes = 0; truncated = false; constructor(limit: number) { this.#limit = limit; } append(chunk: Buffer): boolean { const remaining = this.#limit - this.#capturedBytes; if (remaining > 0) { const captured = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining); this.#chunks.push(Buffer.from(captured)); this.#capturedBytes += captured.length; } if (chunk.length > remaining) this.truncated = true; return this.truncated; } value(): string { return Buffer.concat(this.#chunks, this.#capturedBytes).toString("utf8"); } } function parseJsonCapture( capture: ProcessCapture, parser: ((value: unknown) => T) | undefined, ): AmqCommandResult { let json: T | undefined; let jsonError: Error | undefined; try { const raw = capture.stdout.trim(); if (raw.length === 0) throw new Error("AMQ returned empty JSON stdout"); const value: unknown = JSON.parse(raw); json = parser ? parser(value) : (value as T); } catch (error) { jsonError = toError(error); } const category = categorize(capture, jsonError); return { ok: category === "success", category, exitCode: capture.exitCode, signal: capture.signal, stdout: capture.stdout, stderr: capture.stderr, stdoutTruncated: capture.stdoutTruncated, stderrTruncated: capture.stderrTruncated, ...(json === undefined ? {} : { json }), ...(jsonError ? { jsonError } : {}), ...(capture.processError ? { processError: capture.processError } : {}), }; } function categorize(capture: ProcessCapture, jsonError: Error | undefined): AmqExitCategory { if (capture.aborted) return "cancelled"; if (capture.stdoutTruncated || capture.stderrTruncated) return "output-limit"; if (capture.processError) return "spawn-error"; if (capture.exitCode === null) return "process-crash"; switch (capture.exitCode) { case 0: return jsonError ? "invalid-json" : "success"; case 1: return "error"; case 2: return "usage"; case 3: return "not-found"; case 4: return "timeout"; case 5: return "context-mismatch"; default: return "error"; } } async function createTemporaryBody(parent: string, body: string): Promise { const directory = await mkdtemp(join(parent, "pi-amq-body-")); await chmod(directory, 0o700); const path = join(directory, "body"); try { const handle = await open(path, "wx", 0o600); try { await handle.writeFile(body, "utf8"); } finally { await handle.close(); } await chmod(path, 0o600); return { directory, path }; } catch (error) { await cleanupTemporaryBody({ directory, path }); throw error; } } async function cleanupTemporaryBody(temporary: TemporaryBody): Promise { const errors: Error[] = []; try { await unlink(temporary.path); } catch (error) { if (!isMissing(error)) errors.push(toError(error)); } try { await rmdir(temporary.directory); } catch (error) { if (!isMissing(error)) errors.push(toError(error)); } if (errors.length === 0) return undefined; return errors.length === 1 ? errors[0] : new AggregateError(errors, "temporary AMQ body cleanup failed"); } function validateArgv(argv: readonly string[]): void { if (argv.length === 0) throw new TypeError("AMQ argv must include a command"); for (const argument of argv) { if (argument.includes("\0")) throw new TypeError("AMQ argv cannot contain NUL bytes"); } } function positiveInteger(value: number, name: string): number { if (!Number.isSafeInteger(value) || value <= 0) { throw new TypeError(`${name} must be a positive safe integer`); } return value; } function nonNegativeInteger(value: number, name: string): number { if (!Number.isSafeInteger(value) || value < 0) { throw new TypeError(`${name} must be a non-negative safe integer`); } return value; } function emptyCapture(aborted: boolean): ProcessCapture { return { exitCode: null, signal: null, stdout: "", stderr: "", stdoutTruncated: false, stderrTruncated: false, aborted, }; } function isMissing(error: unknown): boolean { return error instanceof Error && "code" in error && error.code === "ENOENT"; } function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }