src / executor_impl.ts
src / executor_impl.ts
import { spawn, SpawnOptions } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { DEFAULT_CONFIG, resolveConfigPath, TerminalConfig } from "./config";
import {
ExecResult,
ExecOptions,
Platform,
RunProcessResult,
} from "./types";
import { expandPath, sanitizeCommand, validateExecutionParams } from "./securityEnhanced";
import { detectPlatform } from "./platform";
import { resolveShell, buildExecInvocation } from "./shell";
// ── Constants ──
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"];
// ── 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 ──
export 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 };
}
export 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 ──
export 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"),
};
}
// ── 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();
}
});
}
// ── Helpers ──
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let _cachedConfig: TerminalConfig | null = null;
export 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 * as fs from "fs";
import * as path from "path";
import { DEFAULT_CONFIG, resolveConfigPath, TerminalConfig } from "./config";
import {
ExecResult,
ExecOptions,
Platform,
RunProcessResult,
} from "./types";
import { expandPath, sanitizeCommand, validateExecutionParams } from "./securityEnhanced";
import { detectPlatform } from "./platform";
import { resolveShell, buildExecInvocation } from "./shell";
// ── Constants ──
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"];
// ── 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 ──
export 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 };
}
export 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 ──
export 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"),
};
}
// ── 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();
}
});
}
// ── Helpers ──
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
let _cachedConfig: TerminalConfig | null = null;
export 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]}`;
}