/*
runTool — the single subprocess seam that lets stack plugins run external binaries (RFC-1100). Owns the
shared prologue every external-binary call needs: dist/ preflight, required-env
presence check, env merge, timeout, and error mapping to ToolResult. Production
uses the default execFileSync adapter; tests inject a recording ToolExecutor so no
test mocks node:child_process.
- Does not validate tool-specific config — callers check their own config fields before calling runTool.
- Does not use shell interpolation — execFile with an argument array only (DNA-89).
- Does not run in the browser — server-only (Node child_process).
- RFC-1100: initial subprocess seam — runTool + ToolSpec/ToolResult/ToolExecutor; replaces per-call-site execFileSync prologues in stack plugins.
- RFC-1100: step 1 — shared tool/fs/check seams (walkFiles, runTool, defineStackChecks)
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
// Server-only. Do not import from browser/client scripts.
import { join } from "node:path";
import { createDefaultIO, type WorkspaceIO } from "../kernel/workspace-io.ts";
export interface ToolSpec {
/** Binary to execute, e.g. "npx". */
bin: string;
/** Argument array — never a shell string (DNA-89). */
args: string[];
/** Working directory for the subprocess. */
cwd: string;
/** Extra environment merged over process.env for the subprocess. */
env?: Record;
/** Subprocess timeout. Default 120_000 ms. */
timeoutMs?: number;
/** Preflight: require /dist to exist before executing. */
requireDist?: boolean;
/** Preflight: require these env vars to be non-empty after the env merge. */
requireEnv?: string[];
}
export interface ToolResult {
success: boolean;
stdout?: string;
errors?: string[];
/** Which phase produced the failure — lets callers keep preflight messages unwrapped. */
failedAt?: "preflight" | "exec";
}
/**
* Executes a ToolSpec. Injectable so tests record specs instead of spawning
* processes — pass a fake that captures the spec and returns a canned ToolResult.
*/
export type ToolExecutor = (spec: ToolSpec) => ToolResult | Promise;
const DEFAULT_TIMEOUT_MS = 120_000;
const execFileExecutor: ToolExecutor = async (spec) => {
try {
const env: Record = {};
for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;
Object.assign(env, spec.env);
const result = await createDefaultIO().io.exec(spec.bin, spec.args, {
cwd: spec.cwd,
timeoutMs: spec.timeoutMs ?? DEFAULT_TIMEOUT_MS,
env,
});
if (result.exitCode === 0) {
return { success: true, stdout: result.stdout };
}
return {
success: false,
errors: [result.stderr || `exit code ${result.exitCode}`],
failedAt: "exec",
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, errors: [message], failedAt: "exec" };
}
};
export async function runTool(
spec: ToolSpec,
executor: ToolExecutor = execFileExecutor,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (spec.requireDist) {
const distDir = join(spec.cwd, "dist");
if (!(await io.exists(distDir))) {
return {
success: false,
errors: [`dist/ directory not found at ${distDir} — run build first`],
failedAt: "preflight",
};
}
}
if (spec.requireEnv && spec.requireEnv.length > 0) {
const mergedEnv = { ...process.env, ...spec.env };
const missing = spec.requireEnv.filter((name) => !mergedEnv[name]);
if (missing.length > 0) {
return {
success: false,
errors: missing.map((name) => `Required environment variable ${name} is not set`),
failedAt: "preflight",
};
}
}
try {
return await executor(spec);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, errors: [message], failedAt: "exec" };
}
}