import { spawn, spawnSync, type ChildProcess } from "node:child_process";import { delimiter, join, resolve } from "node:path";import type { Readable } from "node:stream";import { Effect } from "effect"; export type CommandResult = { status: number | null; stdout: string; stderr: string;}; export type RunSyncOptions = { /** Kill the command after this long. Omit for no timeout (matches bare `spawnSync`). */ timeoutMs?: number | undefined;}; export type SpawnDetachedOptions = { env?: NodeJS.ProcessEnv | undefined; /** "pipe" exposes stdout/stderr for log capture; "ignore" discards them. */ stdio: "pipe" | "ignore";}; /** Minimal view of a detached child process; satisfied by `ChildProcess`. */export interface SpawnedProcess { readonly pid?: number | undefined; readonly exitCode: number | null; readonly stdout: Readable | null; readonly stderr: Readable | null; on(event: "error", listener: (error: Error) => void): void; on(event: "exit", listener: () => void): void; unref(): void;} /** * Injectable process boundary. Production code takes a `ProcessRunner` * defaulting to `realProcessRunner`; tests substitute a scripted fake so spawn * logic (constructed argv, exit handling, output capture) is testable without * touching the host. */export interface ProcessRunner { runSync(command: string, args: string[], options?: RunSyncOptions): CommandResult; spawnDetached(command: string, args: string[], options: SpawnDetachedOptions): SpawnedProcess;} export const realProcessRunner: ProcessRunner = { runSync: (command, args, options = {}) => { try { const result = spawnSync(command, args, { ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), env: process.env, }); return { status: result.status, stdout: result.stdout ? result.stdout.toString("utf-8").trim() : "", stderr: result.stderr ? result.stderr.toString("utf-8").trim() : "", }; } catch (error) { return { status: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), }; } }, spawnDetached: (command, args, options) => spawn(command, args, { stdio: options.stdio === "pipe" ? ["ignore", "pipe", "pipe"] : "ignore", ...(options.env ? { env: options.env } : {}), detached: true, }),}; export type AsyncCommandResult = CommandResult & { timedOut: boolean; signal: NodeJS.Signals | null; exitConfirmed?: boolean | undefined;}; export type AsyncCommandOptions = { timeoutMs: number; maxOutputBytes?: number | undefined; cwd?: string | undefined; env?: NodeJS.ProcessEnv | undefined; stdin?: string | undefined; signal?: AbortSignal | undefined; onOutput?: ((chunk: string) => void) | undefined; onSpawn?: ((child: ChildProcess) => void) | undefined;}; const DEFAULT_TIMEOUT_MS = 3_000;const TIMEOUT_KILL_GRACE_MS = 5_000;const TERMINATION_CONFIRM_GRACE_MS = 5_000;const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; export class CommandTerminationError extends Error { constructor() { super("Command process exit could not be confirmed"); this.name = "CommandTerminationError"; }} const boundedTail = (current: Buffer, chunk: Buffer, maximumBytes: number): Buffer => { if (maximumBytes === 0) return Buffer.alloc(0); if (chunk.length >= maximumBytes) return Buffer.from(chunk.subarray(-maximumBytes)); const retained = current.subarray(Math.max(0, current.length + chunk.length - maximumBytes)); return Buffer.concat([retained, chunk], retained.length + chunk.length);}; export const runCommandEffect = ( command: string, args: string[], timeoutMs = DEFAULT_TIMEOUT_MS,): Effect.Effect<CommandResult> => Effect.sync(() => realProcessRunner.runSync(command, args, { timeoutMs })); export const runCommand = ( command: string, args: string[], timeoutMs = DEFAULT_TIMEOUT_MS,): CommandResult => Effect.runSync(runCommandEffect(command, args, timeoutMs)); export const runCommandAsyncEffect = ( command: string, args: string[], options: AsyncCommandOptions,): Effect.Effect<AsyncCommandResult> => Effect.callback<AsyncCommandResult>((resume) => { const requestedOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const maximumOutputBytes = Number.isSafeInteger(requestedOutputBytes) ? Math.max(0, requestedOutputBytes) : DEFAULT_MAX_OUTPUT_BYTES; const child = spawn(command, args, { env: options.env ?? process.env, ...(options.cwd ? { cwd: options.cwd } : {}), }); options.onSpawn?.(child); if (options.stdin !== undefined) { child.stdin?.on("error", () => {}); child.stdin?.write(options.stdin); child.stdin?.end(); } let stdout: Buffer = Buffer.alloc(0); let stderr: Buffer = Buffer.alloc(0); let timedOut = false; let closed = false; let settled = false; let forceKillTimer: ReturnType<typeof setTimeout> | null = null; let confirmKillTimer: ReturnType<typeof setTimeout> | null = null; const complete = (result: AsyncCommandResult): void => { if (settled) return; settled = true; clearTimeout(timeoutTimer); if (forceKillTimer) clearTimeout(forceKillTimer); if (confirmKillTimer) clearTimeout(confirmKillTimer); options.signal?.removeEventListener("abort", terminate); resume(Effect.succeed(result)); }; const terminate = (): void => { if (closed) return; child.kill("SIGTERM"); if (!forceKillTimer) { forceKillTimer = setTimeout(() => { child.kill("SIGKILL"); confirmKillTimer = setTimeout( () => complete({ status: null, stdout: stdout.toString("utf8").trim(), stderr: new CommandTerminationError().message, timedOut, signal: null, exitConfirmed: false, }), TERMINATION_CONFIRM_GRACE_MS, ); }, TIMEOUT_KILL_GRACE_MS); } }; const timeoutTimer = setTimeout(() => { timedOut = true; terminate(); }, options.timeoutMs); const settle = (result: AsyncCommandResult): void => { complete(result); }; child.stdout?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); stdout = boundedTail(stdout, data, maximumOutputBytes); options.onOutput?.(chunk); }); child.stderr?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); stderr = boundedTail(stderr, data, maximumOutputBytes); options.onOutput?.(chunk); }); child.on("error", (error) => { settle({ status: null, stdout: stdout.toString("utf8").trim(), stderr: error.message, timedOut, signal: null, }); }); child.on("close", (code, signal) => { closed = true; settle({ status: code, stdout: stdout.toString("utf8").trim(), stderr: stderr.toString("utf8").trim(), timedOut, signal, }); }); options.signal?.addEventListener("abort", terminate, { once: true }); if (options.signal?.aborted) terminate(); return Effect.callback<void>((finish) => { if (closed) { finish(Effect.void); return; } child.once("close", () => finish(Effect.void)); terminate(); }).pipe( Effect.timeoutOrElse({ duration: TIMEOUT_KILL_GRACE_MS + TERMINATION_CONFIRM_GRACE_MS, orElse: () => Effect.die(new CommandTerminationError()), }), ); }); export const runCommandAsync = ( command: string, args: string[], options: AsyncCommandOptions,): Promise<AsyncCommandResult> => Effect.runPromise(runCommandAsyncEffect(command, args, options)); const runtimeBinDirectory = (): string | null => process.env["LOCAL_STUDIO_RUNTIME_BIN"] ?? (process.env["SNAP"] ? resolve(process.cwd(), "runtime", "bin") : null); const homeBinDirectories = (): string[] => { const directories: string[] = []; const home = process.env["HOME"]; if (home) directories.push(join(home, ".local", "bin"), join(home, "bin")); const user = process.env["USER"] ?? process.env["LOGNAME"]; if (user) directories.push(join("/home", user, ".local", "bin"), join("/home", user, "bin")); return directories;}; const sanitizePathEntry = (entry: string): string => entry.trim().replace(/^"|"$/g, ""); const binarySearchPath = (): string => { const runtimeBin = runtimeBinDirectory(); const pathEntries = (process.env["PATH"] ?? "") .split(delimiter) .map(sanitizePathEntry) .filter(Boolean); return [...(runtimeBin ? [runtimeBin] : []), ...pathEntries, ...homeBinDirectories()].join( delimiter, );}; const isExplicitPath = (binaryName: string): boolean => binaryName.includes("/") || binaryName.includes("\\"); export const resolveBinary = (binaryName: string): string | null => { if (!binaryName) return null; if (isExplicitPath(binaryName)) return Bun.which(resolve(binaryName)); return Bun.which(binaryName, { PATH: binarySearchPath() });};
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { delimiter, join, resolve } from "node:path";
import type { Readable } from "node:stream";
import { Effect } from "effect";
export type CommandResult = {
status: number | null;
stdout: string;
stderr: string;
};
export type RunSyncOptions = {
/** Kill the command after this long. Omit for no timeout (matches bare `spawnSync`). */
timeoutMs?: number | undefined;
};
export type SpawnDetachedOptions = {
env?: NodeJS.ProcessEnv | undefined;
/** "pipe" exposes stdout/stderr for log capture; "ignore" discards them. */
stdio: "pipe" | "ignore";
};
/** Minimal view of a detached child process; satisfied by `ChildProcess`. */
export interface SpawnedProcess {
readonly pid?: number | undefined;
readonly exitCode: number | null;
readonly stdout: Readable | null;
readonly stderr: Readable | null;
on(event: "error", listener: (error: Error) => void): void;
on(event: "exit", listener: () => void): void;
unref(): void;
}
/**
* Injectable process boundary. Production code takes a `ProcessRunner`
* defaulting to `realProcessRunner`; tests substitute a scripted fake so spawn
* logic (constructed argv, exit handling, output capture) is testable without
* touching the host.
*/
export interface ProcessRunner {
runSync(command: string, args: string[], options?: RunSyncOptions): CommandResult;
spawnDetached(command: string, args: string[], options: SpawnDetachedOptions): SpawnedProcess;
}
export const realProcessRunner: ProcessRunner = {
runSync: (command, args, options = {}) => {
try {
const result = spawnSync(command, args, {
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
env: process.env,
});
return {
status: result.status,
stdout: result.stdout ? result.stdout.toString("utf-8").trim() : "",
stderr: result.stderr ? result.stderr.toString("utf-8").trim() : "",
};
} catch (error) {
return {
status: null,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
};
}
},
spawnDetached: (command, args, options) =>
spawn(command, args, {
stdio: options.stdio === "pipe" ? ["ignore", "pipe", "pipe"] : "ignore",
...(options.env ? { env: options.env } : {}),
detached: true,
}),
};
export type AsyncCommandResult = CommandResult & {
timedOut: boolean;
signal: NodeJS.Signals | null;
exitConfirmed?: boolean | undefined;
};
export type AsyncCommandOptions = {
timeoutMs: number;
maxOutputBytes?: number | undefined;
cwd?: string | undefined;
env?: NodeJS.ProcessEnv | undefined;
stdin?: string | undefined;
signal?: AbortSignal | undefined;
onOutput?: ((chunk: string) => void) | undefined;
onSpawn?: ((child: ChildProcess) => void) | undefined;
};
const DEFAULT_TIMEOUT_MS = 3_000;
const TIMEOUT_KILL_GRACE_MS = 5_000;
const TERMINATION_CONFIRM_GRACE_MS = 5_000;
const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
export class CommandTerminationError extends Error {
constructor() {
super("Command process exit could not be confirmed");
this.name = "CommandTerminationError";
}
}
const boundedTail = (current: Buffer, chunk: Buffer, maximumBytes: number): Buffer => {
if (maximumBytes === 0) return Buffer.alloc(0);
if (chunk.length >= maximumBytes) return Buffer.from(chunk.subarray(-maximumBytes));
const retained = current.subarray(Math.max(0, current.length + chunk.length - maximumBytes));
return Buffer.concat([retained, chunk], retained.length + chunk.length);
};
export const runCommandEffect = (
command: string,
args: string[],
timeoutMs = DEFAULT_TIMEOUT_MS,
): Effect.Effect<CommandResult> =>
Effect.sync(() => realProcessRunner.runSync(command, args, { timeoutMs }));
export const runCommand = (
command: string,
args: string[],
timeoutMs = DEFAULT_TIMEOUT_MS,
): CommandResult => Effect.runSync(runCommandEffect(command, args, timeoutMs));
export const runCommandAsyncEffect = (
command: string,
args: string[],
options: AsyncCommandOptions,
): Effect.Effect<AsyncCommandResult> =>
Effect.callback<AsyncCommandResult>((resume) => {
const requestedOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
const maximumOutputBytes = Number.isSafeInteger(requestedOutputBytes)
? Math.max(0, requestedOutputBytes)
: DEFAULT_MAX_OUTPUT_BYTES;
const child = spawn(command, args, {
env: options.env ?? process.env,
...(options.cwd ? { cwd: options.cwd } : {}),
});
options.onSpawn?.(child);
if (options.stdin !== undefined) {
child.stdin?.on("error", () => {});
child.stdin?.write(options.stdin);
child.stdin?.end();
}
let stdout: Buffer = Buffer.alloc(0);
let stderr: Buffer = Buffer.alloc(0);
let timedOut = false;
let closed = false;
let settled = false;
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
let confirmKillTimer: ReturnType<typeof setTimeout> | null = null;
const complete = (result: AsyncCommandResult): void => {
if (settled) return;
settled = true;
clearTimeout(timeoutTimer);
if (forceKillTimer) clearTimeout(forceKillTimer);
if (confirmKillTimer) clearTimeout(confirmKillTimer);
options.signal?.removeEventListener("abort", terminate);
resume(Effect.succeed(result));
};
const terminate = (): void => {
if (closed) return;
child.kill("SIGTERM");
if (!forceKillTimer) {
forceKillTimer = setTimeout(() => {
child.kill("SIGKILL");
confirmKillTimer = setTimeout(
() =>
complete({
status: null,
stdout: stdout.toString("utf8").trim(),
stderr: new CommandTerminationError().message,
timedOut,
signal: null,
exitConfirmed: false,
}),
TERMINATION_CONFIRM_GRACE_MS,
);
}, TIMEOUT_KILL_GRACE_MS);
}
};
const timeoutTimer = setTimeout(() => {
timedOut = true;
terminate();
}, options.timeoutMs);
const settle = (result: AsyncCommandResult): void => {
complete(result);
};
child.stdout?.on("data", (data: Buffer) => {
const chunk = data.toString("utf-8");
stdout = boundedTail(stdout, data, maximumOutputBytes);
options.onOutput?.(chunk);
});
child.stderr?.on("data", (data: Buffer) => {
const chunk = data.toString("utf-8");
stderr = boundedTail(stderr, data, maximumOutputBytes);
options.onOutput?.(chunk);
});
child.on("error", (error) => {
settle({
status: null,
stdout: stdout.toString("utf8").trim(),
stderr: error.message,
timedOut,
signal: null,
});
});
child.on("close", (code, signal) => {
closed = true;
settle({
status: code,
stdout: stdout.toString("utf8").trim(),
stderr: stderr.toString("utf8").trim(),
timedOut,
signal,
});
});
options.signal?.addEventListener("abort", terminate, { once: true });
if (options.signal?.aborted) terminate();
return Effect.callback<void>((finish) => {
if (closed) {
finish(Effect.void);
return;
}
child.once("close", () => finish(Effect.void));
terminate();
}).pipe(
Effect.timeoutOrElse({
duration: TIMEOUT_KILL_GRACE_MS + TERMINATION_CONFIRM_GRACE_MS,
orElse: () => Effect.die(new CommandTerminationError()),
}),
);
});
export const runCommandAsync = (
command: string,
args: string[],
options: AsyncCommandOptions,
): Promise<AsyncCommandResult> => Effect.runPromise(runCommandAsyncEffect(command, args, options));
const runtimeBinDirectory = (): string | null =>
process.env["LOCAL_STUDIO_RUNTIME_BIN"] ??
(process.env["SNAP"] ? resolve(process.cwd(), "runtime", "bin") : null);
const homeBinDirectories = (): string[] => {
const directories: string[] = [];
const home = process.env["HOME"];
if (home) directories.push(join(home, ".local", "bin"), join(home, "bin"));
const user = process.env["USER"] ?? process.env["LOGNAME"];
if (user) directories.push(join("/home", user, ".local", "bin"), join("/home", user, "bin"));
return directories;
};
const sanitizePathEntry = (entry: string): string => entry.trim().replace(/^"|"$/g, "");
const binarySearchPath = (): string => {
const runtimeBin = runtimeBinDirectory();
const pathEntries = (process.env["PATH"] ?? "")
.split(delimiter)
.map(sanitizePathEntry)
.filter(Boolean);
return [...(runtimeBin ? [runtimeBin] : []), ...pathEntries, ...homeBinDirectories()].join(
delimiter,
);
};
const isExplicitPath = (binaryName: string): boolean =>
binaryName.includes("/") || binaryName.includes("\\");
export const resolveBinary = (binaryName: string): string | null => {
if (!binaryName) return null;
if (isExplicitPath(binaryName)) return Bun.which(resolve(binaryName));
return Bun.which(binaryName, { PATH: binarySearchPath() });
};