src / executor.ts
src / executor.ts
import { spawn, SpawnOptions } from "child_process";
import { DEFAULT_CONFIG, resolveConfigPath, TerminalConfig } from "./config";
import {
ExecParamValidation,
ExecResult,
ExecOptions,
Platform,
ProcessInfo,
RunProcessResult,
RetryOptions,
ShellInfo,
} from "./types";
import { expandPath, sanitizeCommand, validateExecutionParams } from "./securityEnhanced";
import { logger } from "./logger";
import * as os from "os";
import * as path from "path";
import * as fs from "fs";
// ── Constants ──
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 500;
const RETRYABLE_CODES = ["ETIMEDOUT", "ECONNRESET", "EPIPE"];
const NON_RETRYABLE_CODES = ["ENOENT", "EACCES", "EEXIST", "EPERM", "EISDIR", "ELOOP", "E2BIG", "EINVAL", "EILSEQ"];
// ── Platform Detection ──
export function detectPlatform(): Platform {
const p = os.platform();
return p === "win32" ? "windows" : p === "darwin" ? "macos" : "linux";
}
export function resolvePwshPath(): string {
const platform = detectPlatform();
if (platform === "windows") {
const candidates = [
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
"C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe",
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
];
for (const c of candidates) {
try {
const resolved = path.resolve(expandPath(c));
if (fs.existsSync(resolved)) return resolved;
} catch { /* skip */ }
}
return "powershell";
}
const candidates = ["/usr/bin/pwsh", "/usr/bin/powershell", "/opt/microsoft/powershell/7/pwsh"];
for (const c of candidates) {
try {
const resolved = path.resolve(expandPath(c));
fs.accessSync(resolved, fs.constants.X_OK);
return resolved;
} catch { /* skip */ }
}
return "pwsh";
}
// ── Shell Resolution ──
function resolveShell(shellPath?: string, windowsShell?: "powershell" | "cmd"): ShellInfo {
const platform = detectPlatform();
if (shellPath) {
const expanded = expandPath(shellPath);
return { name: path.basename(expanded), path: expanded, platform };
}
if (platform === "windows") {
const shell = windowsShell || (process.env.WINDOWS_SHELL as "powershell" | "cmd" | undefined) || "powershell";
const resolved = shell === "cmd" ? "cmd.exe" : resolvePwshPath();
return { name: path.basename(resolved), path: resolved, platform };
}
return { name: "bash", path: "/bin/bash", platform };
}
// ── Working Directory Resolution ──
export function resolveCwd(cwd?: string): string {
if (!cwd) return process.cwd();
const expanded = expandPath(cwd);
const resolved = path.resolve(expanded);
try {
if (!fs.statSync(resolved).isDirectory()) throw new Error();
return resolved;
} catch {
throw new Error(`Invalid working directory: ${resolved}`);
}
}
// ── Result Builders ──
function createExecResult(
stdout: string,
stderr: string,
exitCode: number | null,
timedOut: boolean,
shell: string,
platform: Platform,
command: string,
durationMs: number,
): ExecResult {
return { stdout, stderr, exitCode, timedOut, shell, platform, command, durationMs };
}
function createRunResult(
stdout: string,
stderr: string,
exitCode: number | null,
timedOut: boolean,
durationMs: number,
signal?: string,
): RunProcessResult {
return { stdout, stderr, exitCode, timedOut, durationMs, signal, platform: detectPlatform() };
}
// ── Buffer Collector — DRY helper for stdout/stderr accumulation ──
function createBufferCollector(maxBytes: number): {
onStdoutData: (d: Buffer) => void;
onStderrData: (d: Buffer) => void;
getStdout: () => string;
getStderr: () => string;
} {
let stdoutBuf: Buffer = Buffer.alloc(0);
let stderrBuf: Buffer = Buffer.alloc(0);
const append = (current: Buffer, data: Buffer): Buffer => {
if (current.length >= maxBytes) return current;
const remaining = maxBytes - current.length;
return Buffer.concat([current, data.subarray(0, Math.max(0, remaining))]);
};
return {
onStdoutData: (d: Buffer) => {
stdoutBuf = append(stdoutBuf, d);
},
onStderrData: (d: Buffer) => {
stderrBuf = append(stderrBuf, d);
},
getStdout: () => stdoutBuf.toString("utf8"),
getStderr: () => stderrBuf.toString("utf8"),
};
}
/**
* Detect if a command uses cmd.exe-style syntax that PowerShell's -Command
* would misinterpret or fail to execute correctly.
*/
function isCmdStyleCommand(command: string): boolean {
const trimmed = command.trim();
// Known cmd.exe builtins
const cmdBuiltins = /^(dir|copy|xcopy|del|erase|ren|rename|move|cls|type|more|find|findstr|assoc|ftype|fc|format|tree|chkdsk|diskpart|attrib|subst|path|set|if|for|call|shift|goto|start|pause|break|ver|vol|title|prompt|color|help|exit|rem|echo|pushd|popd|cd|chdir|md|mkdir|rd|rmdir|sort|comp|compact|cipher|cacls|icacls|takeown|robocopy|where|endlocal|defined|exist)\s/i;
if (cmdBuiltins.test(trimmed)) return true;
// Bare / flags (cmd switches, not PowerShell --style)
if (/\b\/[a-zA-Z]+\b/.test(trimmed)) return true;
// cmd-style chaining: ||, &&, | (pipe), &
if (/\|\||&&|\|[^|]|&[^&]/.test(trimmed)) return true;
// cmd-style redirection: >, >>, <
if (/>[^>]|<[^>]/.test(trimmed)) return true;
return false;
}
/**
* Detect if a command starts with an absolute file path (executable path).
* PowerShell's -Command parser mangles paths with spaces and special chars,
* so we route these to cmd /c for reliable execution.
*/
function isAbsolutePathCommand(command: string): boolean {
const trimmed = command.trim();
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return true;
if (/^\//.test(trimmed)) return true;
return false;
}
function buildExecInvocation(
command: string,
options: ExecOptions,
shellInfo: ShellInfo,
): { command: string; args: string[] } {
if (options.args && options.args.length > 0) {
return { command, args: options.args };
}
if (shellInfo.platform === "windows") {
const forceCmd = options.windowsShell === "cmd" || isCmdStyleCommand(command) || isAbsolutePathCommand(command);
if (forceCmd) {
return { command: "cmd.exe", args: ["/d", "/s", "/c", command] };
}
return { command: shellInfo.path, args: ["-NoLogo", "-NoProfile", "-Command", command] };
}
return { command: shellInfo.path, args: ["-lc", command] };
}
// ── Exec Command (with shell, retry-capable) ──
export async function execCommand(
command: string,
options: ExecOptions = {},
): Promise<ExecResult> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const sanitized = sanitizeCommand(command);
if (!sanitized.valid) {
throw new Error(`Command rejected: ${sanitized.error}`);
}
const shellInfo = resolveShell(options.shellPath, options.windowsShell);
const cwd = resolveCwd(validated.cwd);
const timeoutMs = validated.timeoutMs ?? config.defaultTimeoutMs;
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
let lastError: Error | undefined;
const startTime = Date.now();
const maxRetries = options.retryCount ?? config.maxRetries ?? MAX_RETRIES;
const runAttempt = (): Promise<ExecResult> => {
return new Promise<ExecResult>((resolve, reject) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let timedOut = false;
controller.signal.addEventListener("abort", () => { timedOut = true; }, { once: true });
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
signal: controller.signal,
};
const invocation = buildExecInvocation(command, options, shellInfo);
const child = spawn(invocation.command, invocation.args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => {
clearTimeout(timeoutId);
reject(err);
});
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
clearTimeout(timeoutId);
resolve(createExecResult(
collector.getStdout(),
collector.getStderr(),
exitCode,
timedOut,
shellInfo.name,
shellInfo.platform,
command,
Date.now() - startTime,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code) => {
exitCode = code;
});
if (validated.stdin) {
child.stdin?.write(validated.stdin);
child.stdin?.end();
}
});
};
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
await sleep(options.retryDelayMs ?? config.retryDelayMs ?? RETRY_DELAY_MS);
}
try {
return await runAttempt();
} catch (err) {
lastError = err as Error;
const code = (err as NodeJS.ErrnoException).code;
if (NON_RETRYABLE_CODES.includes(code || "")) throw err;
if (!RETRYABLE_CODES.includes(code || "")) throw err;
}
}
throw lastError ?? new Error("execCommand failed with unknown error");
}
// ── Run Process (direct executable, no shell) ──
export async function runProcess(
command: string,
args: string[] = [],
options: ExecOptions = {},
): Promise<RunProcessResult> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const cwd = resolveCwd(validated.cwd);
const timeoutMs = validated.timeoutMs ?? config.defaultTimeoutMs;
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
const startTime = Date.now();
const controller = new AbortController();
let timedOut = false;
return new Promise<RunProcessResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
signal: controller.signal,
};
const child = spawn(command, args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => {
clearTimeout(timeoutId);
reject(err);
});
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
let signal: string | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
clearTimeout(timeoutId);
const normalizedExitCode = exitCode !== null ? exitCode : (signal !== null ? -1 : 0);
resolve(createRunResult(
collector.getStdout(),
collector.getStderr(),
normalizedExitCode,
timedOut,
Date.now() - startTime,
signal ?? undefined,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code, sig) => {
exitCode = code;
signal = sig;
});
if (validated.stdin) {
child.stdin?.write(validated.stdin);
child.stdin?.end();
}
});
}
// ── Exec Command Stream ──
export async function execCommandStream(
command: string,
options: ExecOptions = {},
): Promise<Readonly<ExecResult>> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const shellInfo = resolveShell(options.shellPath, options.windowsShell);
const cwd = resolveCwd(validated.cwd);
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
const startTime = Date.now();
return new Promise<ExecResult>((resolve, reject) => {
const invocation = buildExecInvocation(command, options, shellInfo);
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
};
const child = spawn(invocation.command, invocation.args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => reject(err));
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
resolve(createExecResult(
collector.getStdout(),
collector.getStderr(),
exitCode,
false,
shellInfo.name,
shellInfo.platform,
command,
Date.now() - startTime,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code) => {
exitCode = code;
});
});
}
// ── Retry Helper ──
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const maxRetries = options.maxRetries ?? MAX_RETRIES;
const retryDelay = options.retryDelayMs ?? RETRY_DELAY_MS;
const retryOnCodes = options.retryOnCodes ?? RETRYABLE_CODES;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
const code = (err as NodeJS.ErrnoException).code;
if (!code || !retryOnCodes.includes(code)) throw err;
if (attempt < maxRetries) await sleep(retryDelay);
}
}
throw lastError;
}
// ── Helpers ──
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let _cachedConfig: TerminalConfig | null = null;
function loadConfig(): TerminalConfig {
if (!_cachedConfig) {
const configPath = resolveConfigPath();
try {
const raw = fs.readFileSync(configPath, "utf8");
_cachedConfig = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
_cachedConfig = DEFAULT_CONFIG;
}
}
return _cachedConfig!;
}
// ── Normalize Line Endings ──
export function normalizeLineEndings(input: string): string {
return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
// ── Format Bytes ──
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
import { spawn, SpawnOptions } from "child_process";
import { DEFAULT_CONFIG, resolveConfigPath, TerminalConfig } from "./config";
import {
ExecParamValidation,
ExecResult,
ExecOptions,
Platform,
ProcessInfo,
RunProcessResult,
RetryOptions,
ShellInfo,
} from "./types";
import { expandPath, sanitizeCommand, validateExecutionParams } from "./securityEnhanced";
import { logger } from "./logger";
import * as os from "os";
import * as path from "path";
import * as fs from "fs";
// ── Constants ──
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 500;
const RETRYABLE_CODES = ["ETIMEDOUT", "ECONNRESET", "EPIPE"];
const NON_RETRYABLE_CODES = ["ENOENT", "EACCES", "EEXIST", "EPERM", "EISDIR", "ELOOP", "E2BIG", "EINVAL", "EILSEQ"];
// ── Platform Detection ──
export function detectPlatform(): Platform {
const p = os.platform();
return p === "win32" ? "windows" : p === "darwin" ? "macos" : "linux";
}
export function resolvePwshPath(): string {
const platform = detectPlatform();
if (platform === "windows") {
const candidates = [
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
"C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe",
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
];
for (const c of candidates) {
try {
const resolved = path.resolve(expandPath(c));
if (fs.existsSync(resolved)) return resolved;
} catch { /* skip */ }
}
return "powershell";
}
const candidates = ["/usr/bin/pwsh", "/usr/bin/powershell", "/opt/microsoft/powershell/7/pwsh"];
for (const c of candidates) {
try {
const resolved = path.resolve(expandPath(c));
fs.accessSync(resolved, fs.constants.X_OK);
return resolved;
} catch { /* skip */ }
}
return "pwsh";
}
// ── Shell Resolution ──
function resolveShell(shellPath?: string, windowsShell?: "powershell" | "cmd"): ShellInfo {
const platform = detectPlatform();
if (shellPath) {
const expanded = expandPath(shellPath);
return { name: path.basename(expanded), path: expanded, platform };
}
if (platform === "windows") {
const shell = windowsShell || (process.env.WINDOWS_SHELL as "powershell" | "cmd" | undefined) || "powershell";
const resolved = shell === "cmd" ? "cmd.exe" : resolvePwshPath();
return { name: path.basename(resolved), path: resolved, platform };
}
return { name: "bash", path: "/bin/bash", platform };
}
// ── Working Directory Resolution ──
export function resolveCwd(cwd?: string): string {
if (!cwd) return process.cwd();
const expanded = expandPath(cwd);
const resolved = path.resolve(expanded);
try {
if (!fs.statSync(resolved).isDirectory()) throw new Error();
return resolved;
} catch {
throw new Error(`Invalid working directory: ${resolved}`);
}
}
// ── Result Builders ──
function createExecResult(
stdout: string,
stderr: string,
exitCode: number | null,
timedOut: boolean,
shell: string,
platform: Platform,
command: string,
durationMs: number,
): ExecResult {
return { stdout, stderr, exitCode, timedOut, shell, platform, command, durationMs };
}
function createRunResult(
stdout: string,
stderr: string,
exitCode: number | null,
timedOut: boolean,
durationMs: number,
signal?: string,
): RunProcessResult {
return { stdout, stderr, exitCode, timedOut, durationMs, signal, platform: detectPlatform() };
}
// ── Buffer Collector — DRY helper for stdout/stderr accumulation ──
function createBufferCollector(maxBytes: number): {
onStdoutData: (d: Buffer) => void;
onStderrData: (d: Buffer) => void;
getStdout: () => string;
getStderr: () => string;
} {
let stdoutBuf: Buffer = Buffer.alloc(0);
let stderrBuf: Buffer = Buffer.alloc(0);
const append = (current: Buffer, data: Buffer): Buffer => {
if (current.length >= maxBytes) return current;
const remaining = maxBytes - current.length;
return Buffer.concat([current, data.subarray(0, Math.max(0, remaining))]);
};
return {
onStdoutData: (d: Buffer) => {
stdoutBuf = append(stdoutBuf, d);
},
onStderrData: (d: Buffer) => {
stderrBuf = append(stderrBuf, d);
},
getStdout: () => stdoutBuf.toString("utf8"),
getStderr: () => stderrBuf.toString("utf8"),
};
}
/**
* Detect if a command uses cmd.exe-style syntax that PowerShell's -Command
* would misinterpret or fail to execute correctly.
*/
function isCmdStyleCommand(command: string): boolean {
const trimmed = command.trim();
// Known cmd.exe builtins
const cmdBuiltins = /^(dir|copy|xcopy|del|erase|ren|rename|move|cls|type|more|find|findstr|assoc|ftype|fc|format|tree|chkdsk|diskpart|attrib|subst|path|set|if|for|call|shift|goto|start|pause|break|ver|vol|title|prompt|color|help|exit|rem|echo|pushd|popd|cd|chdir|md|mkdir|rd|rmdir|sort|comp|compact|cipher|cacls|icacls|takeown|robocopy|where|endlocal|defined|exist)\s/i;
if (cmdBuiltins.test(trimmed)) return true;
// Bare / flags (cmd switches, not PowerShell --style)
if (/\b\/[a-zA-Z]+\b/.test(trimmed)) return true;
// cmd-style chaining: ||, &&, | (pipe), &
if (/\|\||&&|\|[^|]|&[^&]/.test(trimmed)) return true;
// cmd-style redirection: >, >>, <
if (/>[^>]|<[^>]/.test(trimmed)) return true;
return false;
}
/**
* Detect if a command starts with an absolute file path (executable path).
* PowerShell's -Command parser mangles paths with spaces and special chars,
* so we route these to cmd /c for reliable execution.
*/
function isAbsolutePathCommand(command: string): boolean {
const trimmed = command.trim();
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return true;
if (/^\//.test(trimmed)) return true;
return false;
}
function buildExecInvocation(
command: string,
options: ExecOptions,
shellInfo: ShellInfo,
): { command: string; args: string[] } {
if (options.args && options.args.length > 0) {
return { command, args: options.args };
}
if (shellInfo.platform === "windows") {
const forceCmd = options.windowsShell === "cmd" || isCmdStyleCommand(command) || isAbsolutePathCommand(command);
if (forceCmd) {
return { command: "cmd.exe", args: ["/d", "/s", "/c", command] };
}
return { command: shellInfo.path, args: ["-NoLogo", "-NoProfile", "-Command", command] };
}
return { command: shellInfo.path, args: ["-lc", command] };
}
// ── Exec Command (with shell, retry-capable) ──
export async function execCommand(
command: string,
options: ExecOptions = {},
): Promise<ExecResult> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const sanitized = sanitizeCommand(command);
if (!sanitized.valid) {
throw new Error(`Command rejected: ${sanitized.error}`);
}
const shellInfo = resolveShell(options.shellPath, options.windowsShell);
const cwd = resolveCwd(validated.cwd);
const timeoutMs = validated.timeoutMs ?? config.defaultTimeoutMs;
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
let lastError: Error | undefined;
const startTime = Date.now();
const maxRetries = options.retryCount ?? config.maxRetries ?? MAX_RETRIES;
const runAttempt = (): Promise<ExecResult> => {
return new Promise<ExecResult>((resolve, reject) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
let timedOut = false;
controller.signal.addEventListener("abort", () => { timedOut = true; }, { once: true });
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
signal: controller.signal,
};
const invocation = buildExecInvocation(command, options, shellInfo);
const child = spawn(invocation.command, invocation.args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => {
clearTimeout(timeoutId);
reject(err);
});
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
clearTimeout(timeoutId);
resolve(createExecResult(
collector.getStdout(),
collector.getStderr(),
exitCode,
timedOut,
shellInfo.name,
shellInfo.platform,
command,
Date.now() - startTime,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code) => {
exitCode = code;
});
if (validated.stdin) {
child.stdin?.write(validated.stdin);
child.stdin?.end();
}
});
};
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
await sleep(options.retryDelayMs ?? config.retryDelayMs ?? RETRY_DELAY_MS);
}
try {
return await runAttempt();
} catch (err) {
lastError = err as Error;
const code = (err as NodeJS.ErrnoException).code;
if (NON_RETRYABLE_CODES.includes(code || "")) throw err;
if (!RETRYABLE_CODES.includes(code || "")) throw err;
}
}
throw lastError ?? new Error("execCommand failed with unknown error");
}
// ── Run Process (direct executable, no shell) ──
export async function runProcess(
command: string,
args: string[] = [],
options: ExecOptions = {},
): Promise<RunProcessResult> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const cwd = resolveCwd(validated.cwd);
const timeoutMs = validated.timeoutMs ?? config.defaultTimeoutMs;
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
const startTime = Date.now();
const controller = new AbortController();
let timedOut = false;
return new Promise<RunProcessResult>((resolve, reject) => {
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
signal: controller.signal,
};
const child = spawn(command, args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => {
clearTimeout(timeoutId);
reject(err);
});
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
let signal: string | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
clearTimeout(timeoutId);
const normalizedExitCode = exitCode !== null ? exitCode : (signal !== null ? -1 : 0);
resolve(createRunResult(
collector.getStdout(),
collector.getStderr(),
normalizedExitCode,
timedOut,
Date.now() - startTime,
signal ?? undefined,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code, sig) => {
exitCode = code;
signal = sig;
});
if (validated.stdin) {
child.stdin?.write(validated.stdin);
child.stdin?.end();
}
});
}
// ── Exec Command Stream ──
export async function execCommandStream(
command: string,
options: ExecOptions = {},
): Promise<Readonly<ExecResult>> {
const config = loadConfig();
const validation = validateExecutionParams(options);
if (!validation.valid) throw new Error(`Invalid execution params: ${validation.error}`);
const validated = validation.validated!;
const shellInfo = resolveShell(options.shellPath, options.windowsShell);
const cwd = resolveCwd(validated.cwd);
const maxOutput = validated.maxOutputBytes ?? config.maxOutputBytes;
const startTime = Date.now();
return new Promise<ExecResult>((resolve, reject) => {
const invocation = buildExecInvocation(command, options, shellInfo);
const spawnOpts: SpawnOptions = {
cwd,
shell: false,
env: validated.env,
stdio: ["pipe", "pipe", "pipe"],
};
const child = spawn(invocation.command, invocation.args, spawnOpts);
const collector = createBufferCollector(maxOutput);
child.stdout?.on("data", collector.onStdoutData);
child.stderr?.on("data", collector.onStderrData);
child.on("error", (err: Error) => reject(err));
let stdoutEnded = false;
let stderrEnded = false;
let exitCode: number | null = null;
const tryResolve = () => {
if (stdoutEnded && stderrEnded) {
resolve(createExecResult(
collector.getStdout(),
collector.getStderr(),
exitCode,
false,
shellInfo.name,
shellInfo.platform,
command,
Date.now() - startTime,
));
}
};
child.stdout?.on("end", () => { stdoutEnded = true; tryResolve(); });
child.stderr?.on("end", () => { stderrEnded = true; tryResolve(); });
child.on("close", (code) => {
exitCode = code;
});
});
}
// ── Retry Helper ──
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const maxRetries = options.maxRetries ?? MAX_RETRIES;
const retryDelay = options.retryDelayMs ?? RETRY_DELAY_MS;
const retryOnCodes = options.retryOnCodes ?? RETRYABLE_CODES;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err as Error;
const code = (err as NodeJS.ErrnoException).code;
if (!code || !retryOnCodes.includes(code)) throw err;
if (attempt < maxRetries) await sleep(retryDelay);
}
}
throw lastError;
}
// ── Helpers ──
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let _cachedConfig: TerminalConfig | null = null;
function loadConfig(): TerminalConfig {
if (!_cachedConfig) {
const configPath = resolveConfigPath();
try {
const raw = fs.readFileSync(configPath, "utf8");
_cachedConfig = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
} catch {
_cachedConfig = DEFAULT_CONFIG;
}
}
return _cachedConfig!;
}
// ── Normalize Line Endings ──
export function normalizeLineEndings(input: string): string {
return input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
// ── Format Bytes ──
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}