import { execFile } from "node:child_process";import type { ChildProcess } from "node:child_process";import { promisify } from "node:util";import { Data, Effect } from "effect";import { runEffectPromise } from "./effect-runtime"; const execFileAsync = promisify(execFile) as unknown as ( command: string, args: readonly string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv; maxBuffer?: number; killSignal?: NodeJS.Signals | number; signal?: AbortSignal; },) => Promise<unknown> & { child?: ChildProcess }; export interface SubprocessOptions { command: string; args?: readonly string[]; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; signal?: AbortSignal; maxBufferBytes?: number; killSignal?: NodeJS.Signals | number; acceptedExitCodes?: readonly number[]; redactValues?: readonly string[]; redact?: (value: string) => string;} export interface SubprocessResult { stdout: string; stderr: string; exitCode: number; signal?: NodeJS.Signals;} export class SubprocessError extends Data.TaggedError("SubprocessError")<{ readonly message: string; readonly command: string; readonly args: readonly string[]; readonly stdout: string; readonly stderr: string; readonly exitCode?: number; readonly signal?: NodeJS.Signals; readonly code?: string | number; readonly causeWasError: boolean; readonly timedOut: boolean; readonly aborted: boolean;}> {} type ExecFailure = Error & { code?: string | number; killed?: boolean; signal?: NodeJS.Signals; stdout?: string | Buffer; stderr?: string | Buffer;}; function outputText(value: unknown) { if (typeof value === "string") return value; if (Buffer.isBuffer(value)) return value.toString("utf8"); return "";} function resultOutput(value: unknown) { if (typeof value === "string" || Buffer.isBuffer(value)) { return { stdout: outputText(value), stderr: "" }; } if (!value || typeof value !== "object") { return { stdout: "", stderr: "" }; } return { stdout: outputText("stdout" in value ? value.stdout : ""), stderr: outputText("stderr" in value ? value.stderr : ""), };} function redactSecretUrls(value: string) { return value.replace( /([a-z][a-z0-9+.-]*:\/\/)([^/@:\s]+)(?::([^/@\s]+))?@/gi, (_match, protocol: string) => `${protocol}REDACTED@`, );} function createRedactor(options: SubprocessOptions) { return (value: string) => { let redacted = redactSecretUrls(value); for (const secret of options.redactValues ?? []) { if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]"); } return options.redact ? options.redact(redacted) : redacted; };} function failureDetails(cause: unknown) { const failure = cause instanceof Error ? (cause as ExecFailure) : undefined; return { message: failure?.message ?? String(cause), stdout: outputText(failure?.stdout), stderr: outputText(failure?.stderr), code: failure?.code, exitCode: typeof failure?.code === "number" ? failure.code : undefined, signal: failure?.signal, };} function subprocessError( options: SubprocessOptions, cause: unknown, state: { timedOut: boolean; aborted: boolean },) { const redact = createRedactor(options); const details = failureDetails(cause); const command = redact(options.command); const args = (options.args ?? []).map(redact); const fallback = `${command}${args.length > 0 ? ` ${args.join(" ")}` : ""} failed`; const message = state.timedOut ? `${fallback}: timed out (aborted)` : state.aborted ? `${fallback}: aborted` : redact(details.message || fallback); return new SubprocessError({ message, command, args, stdout: redact(details.stdout), stderr: redact(details.stderr), ...(details.exitCode === undefined ? {} : { exitCode: details.exitCode }), ...(details.signal === undefined ? {} : { signal: details.signal }), ...(details.code === undefined ? {} : { code: details.code }), causeWasError: cause instanceof Error, timedOut: state.timedOut, aborted: state.aborted, });} function validatedTimeout(options: SubprocessOptions) { if (options.timeoutMs === undefined) return undefined; if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0) { throw new TypeError("timeoutMs must be a finite non-negative number"); } return options.timeoutMs;} export function runSubprocessEffect( options: SubprocessOptions,): Effect.Effect<SubprocessResult, SubprocessError | TypeError> { return Effect.suspend(() => { let timeoutMs: number | undefined; try { timeoutMs = validatedTimeout(options); } catch (error) { return Effect.fail(error as TypeError); } return Effect.async<SubprocessResult, SubprocessError>((resume) => { const args = options.args ?? []; const acceptedExitCodes = new Set(options.acceptedExitCodes ?? [0]); const controller = timeoutMs !== undefined || options.signal ? new AbortController() : undefined; let timedOut = false; let aborted = Boolean(options.signal?.aborted); let settled = false; let timer: NodeJS.Timeout | undefined; const onAbort = () => { aborted = true; controller?.abort(); }; const cleanup = () => { if (timer) clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); }; const finish = ( exit: Effect.Effect<SubprocessResult, SubprocessError>, ) => { if (settled) return; settled = true; cleanup(); resume(exit); }; if (aborted || timeoutMs === 0) { timedOut = !aborted; finish( Effect.fail( subprocessError(options, new Error("aborted"), { timedOut, aborted, }), ), ); return Effect.void; } options.signal?.addEventListener("abort", onAbort, { once: true }); if (timeoutMs !== undefined) { timer = setTimeout(() => { timedOut = true; controller?.abort(); }, timeoutMs); } const execOptions = { ...(options.cwd ? { cwd: options.cwd } : {}), ...(options.env ? { env: options.env } : {}), ...(options.maxBufferBytes === undefined ? {} : { maxBuffer: options.maxBufferBytes }), ...(options.killSignal === undefined ? {} : { killSignal: options.killSignal }), ...(controller ? { signal: controller.signal } : {}), }; const hasExecOptions = Object.keys(execOptions).length > 0; let pending: ReturnType<typeof execFileAsync>; try { pending = hasExecOptions ? execFileAsync(options.command, args, execOptions) : execFileAsync(options.command, args); } catch (cause) { finish( Effect.fail(subprocessError(options, cause, { timedOut, aborted })), ); return Effect.void; } void pending.then( (value) => { const output = resultOutput(value); finish( Effect.succeed({ ...output, exitCode: 0 } as SubprocessResult), ); }, (cause: unknown) => { const details = failureDetails(cause); if ( details.exitCode !== undefined && acceptedExitCodes.has(details.exitCode) ) { finish( Effect.succeed({ stdout: details.stdout, stderr: details.stderr, exitCode: details.exitCode, ...(details.signal ? { signal: details.signal } : {}), }), ); return; } finish( Effect.fail(subprocessError(options, cause, { timedOut, aborted })), ); }, ); return Effect.sync(() => { cleanup(); if (!settled) { settled = true; controller?.abort(); pending.child?.kill(options.killSignal); } }); }); });} export function runSubprocess(options: SubprocessOptions) { return runEffectPromise(runSubprocessEffect(options));}
import { execFile } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { promisify } from "node:util";
import { Data, Effect } from "effect";
import { runEffectPromise } from "./effect-runtime";
const execFileAsync = promisify(execFile) as unknown as (
command: string,
args: readonly string[],
options?: {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxBuffer?: number;
killSignal?: NodeJS.Signals | number;
signal?: AbortSignal;
},
) => Promise<unknown> & { child?: ChildProcess };
export interface SubprocessOptions {
command: string;
args?: readonly string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
signal?: AbortSignal;
maxBufferBytes?: number;
killSignal?: NodeJS.Signals | number;
acceptedExitCodes?: readonly number[];
redactValues?: readonly string[];
redact?: (value: string) => string;
}
export interface SubprocessResult {
stdout: string;
stderr: string;
exitCode: number;
signal?: NodeJS.Signals;
}
export class SubprocessError extends Data.TaggedError("SubprocessError")<{
readonly message: string;
readonly command: string;
readonly args: readonly string[];
readonly stdout: string;
readonly stderr: string;
readonly exitCode?: number;
readonly signal?: NodeJS.Signals;
readonly code?: string | number;
readonly causeWasError: boolean;
readonly timedOut: boolean;
readonly aborted: boolean;
}> {}
type ExecFailure = Error & {
code?: string | number;
killed?: boolean;
signal?: NodeJS.Signals;
stdout?: string | Buffer;
stderr?: string | Buffer;
};
function outputText(value: unknown) {
if (typeof value === "string") return value;
if (Buffer.isBuffer(value)) return value.toString("utf8");
return "";
}
function resultOutput(value: unknown) {
if (typeof value === "string" || Buffer.isBuffer(value)) {
return { stdout: outputText(value), stderr: "" };
}
if (!value || typeof value !== "object") {
return { stdout: "", stderr: "" };
}
return {
stdout: outputText("stdout" in value ? value.stdout : ""),
stderr: outputText("stderr" in value ? value.stderr : ""),
};
}
function redactSecretUrls(value: string) {
return value.replace(
/([a-z][a-z0-9+.-]*:\/\/)([^/@:\s]+)(?::([^/@\s]+))?@/gi,
(_match, protocol: string) => `${protocol}REDACTED@`,
);
}
function createRedactor(options: SubprocessOptions) {
return (value: string) => {
let redacted = redactSecretUrls(value);
for (const secret of options.redactValues ?? []) {
if (secret) redacted = redacted.replaceAll(secret, "[REDACTED]");
}
return options.redact ? options.redact(redacted) : redacted;
};
}
function failureDetails(cause: unknown) {
const failure = cause instanceof Error ? (cause as ExecFailure) : undefined;
return {
message: failure?.message ?? String(cause),
stdout: outputText(failure?.stdout),
stderr: outputText(failure?.stderr),
code: failure?.code,
exitCode: typeof failure?.code === "number" ? failure.code : undefined,
signal: failure?.signal,
};
}
function subprocessError(
options: SubprocessOptions,
cause: unknown,
state: { timedOut: boolean; aborted: boolean },
) {
const redact = createRedactor(options);
const details = failureDetails(cause);
const command = redact(options.command);
const args = (options.args ?? []).map(redact);
const fallback = `${command}${args.length > 0 ? ` ${args.join(" ")}` : ""} failed`;
const message = state.timedOut
? `${fallback}: timed out (aborted)`
: state.aborted
? `${fallback}: aborted`
: redact(details.message || fallback);
return new SubprocessError({
message,
command,
args,
stdout: redact(details.stdout),
stderr: redact(details.stderr),
...(details.exitCode === undefined ? {} : { exitCode: details.exitCode }),
...(details.signal === undefined ? {} : { signal: details.signal }),
...(details.code === undefined ? {} : { code: details.code }),
causeWasError: cause instanceof Error,
timedOut: state.timedOut,
aborted: state.aborted,
});
}
function validatedTimeout(options: SubprocessOptions) {
if (options.timeoutMs === undefined) return undefined;
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 0) {
throw new TypeError("timeoutMs must be a finite non-negative number");
}
return options.timeoutMs;
}
export function runSubprocessEffect(
options: SubprocessOptions,
): Effect.Effect<SubprocessResult, SubprocessError | TypeError> {
return Effect.suspend(() => {
let timeoutMs: number | undefined;
try {
timeoutMs = validatedTimeout(options);
} catch (error) {
return Effect.fail(error as TypeError);
}
return Effect.async<SubprocessResult, SubprocessError>((resume) => {
const args = options.args ?? [];
const acceptedExitCodes = new Set(options.acceptedExitCodes ?? [0]);
const controller =
timeoutMs !== undefined || options.signal
? new AbortController()
: undefined;
let timedOut = false;
let aborted = Boolean(options.signal?.aborted);
let settled = false;
let timer: NodeJS.Timeout | undefined;
const onAbort = () => {
aborted = true;
controller?.abort();
};
const cleanup = () => {
if (timer) clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
};
const finish = (
exit: Effect.Effect<SubprocessResult, SubprocessError>,
) => {
if (settled) return;
settled = true;
cleanup();
resume(exit);
};
if (aborted || timeoutMs === 0) {
timedOut = !aborted;
finish(
Effect.fail(
subprocessError(options, new Error("aborted"), {
timedOut,
aborted,
}),
),
);
return Effect.void;
}
options.signal?.addEventListener("abort", onAbort, { once: true });
if (timeoutMs !== undefined) {
timer = setTimeout(() => {
timedOut = true;
controller?.abort();
}, timeoutMs);
}
const execOptions = {
...(options.cwd ? { cwd: options.cwd } : {}),
...(options.env ? { env: options.env } : {}),
...(options.maxBufferBytes === undefined
? {}
: { maxBuffer: options.maxBufferBytes }),
...(options.killSignal === undefined
? {}
: { killSignal: options.killSignal }),
...(controller ? { signal: controller.signal } : {}),
};
const hasExecOptions = Object.keys(execOptions).length > 0;
let pending: ReturnType<typeof execFileAsync>;
try {
pending = hasExecOptions
? execFileAsync(options.command, args, execOptions)
: execFileAsync(options.command, args);
} catch (cause) {
finish(
Effect.fail(subprocessError(options, cause, { timedOut, aborted })),
);
return Effect.void;
}
void pending.then(
(value) => {
const output = resultOutput(value);
finish(
Effect.succeed({ ...output, exitCode: 0 } as SubprocessResult),
);
},
(cause: unknown) => {
const details = failureDetails(cause);
if (
details.exitCode !== undefined &&
acceptedExitCodes.has(details.exitCode)
) {
finish(
Effect.succeed({
stdout: details.stdout,
stderr: details.stderr,
exitCode: details.exitCode,
...(details.signal ? { signal: details.signal } : {}),
}),
);
return;
}
finish(
Effect.fail(subprocessError(options, cause, { timedOut, aborted })),
);
},
);
return Effect.sync(() => {
cleanup();
if (!settled) {
settled = true;
controller?.abort();
pending.child?.kill(options.killSignal);
}
});
});
});
}
export function runSubprocess(options: SubprocessOptions) {
return runEffectPromise(runSubprocessEffect(options));
}