Files
callstack__agent-device/src/utils/exec.ts
T

459 lines
13 KiB
TypeScript
Raw Normal View History

import { constants } from 'node:fs';
import { access, stat } from 'node:fs/promises';
import path from 'node:path';
import { spawn, spawnSync, type StdioOptions } from 'node:child_process';
2026-01-30 19:41:00 +01:00
import { AppError } from './errors.ts';
export type ExecResult = {
stdout: string;
stderr: string;
exitCode: number;
stdoutBuffer?: Buffer;
};
type ExecOptions = {
2026-01-30 19:41:00 +01:00
cwd?: string;
env?: NodeJS.ProcessEnv;
allowFailure?: boolean;
binaryStdout?: boolean;
2026-02-02 15:12:23 +01:00
stdin?: string | Buffer;
timeoutMs?: number;
detached?: boolean;
2026-01-30 19:41:00 +01:00
};
type ExecStreamOptions = ExecOptions & {
2026-01-30 19:41:00 +01:00
onStdoutChunk?: (chunk: string) => void;
onStderrChunk?: (chunk: string) => void;
onSpawn?: (child: ReturnType<typeof spawn>) => void;
2026-01-30 19:41:00 +01:00
};
2026-02-02 21:33:45 +01:00
export type ExecBackgroundResult = {
child: ReturnType<typeof spawn>;
wait: Promise<ExecResult>;
};
type ExecDetachedOptions = ExecOptions & {
stdio?: StdioOptions;
};
const BARE_COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
const WINDOWS_PATH_EXTENSIONS = ['.com', '.exe', '.bat', '.cmd'];
2026-01-30 19:41:00 +01:00
export async function runCmd(
cmd: string,
args: string[],
options: ExecOptions = {},
): Promise<ExecResult> {
const executable = normalizeExecutableCommand(cmd);
2026-01-30 19:41:00 +01:00
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
2026-01-30 19:41:00 +01:00
cwd: options.cwd,
env: options.env,
2026-02-02 15:12:23 +01:00
stdio: ['pipe', 'pipe', 'pipe'],
detached: options.detached,
shell: false,
2026-01-30 19:41:00 +01:00
});
let stdout = '';
let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
let stderr = '';
let didTimeout = false;
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
const timeoutHandle = timeoutMs
? setTimeout(() => {
2026-03-17 18:18:53 +01:00
didTimeout = true;
child.kill('SIGKILL');
}, timeoutMs)
: null;
2026-01-30 19:41:00 +01:00
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
2026-02-02 15:12:23 +01:00
if (options.stdin !== undefined) {
child.stdin.write(options.stdin);
}
child.stdin.end();
2026-01-30 19:41:00 +01:00
child.stdout.on('data', (chunk) => {
if (options.binaryStdout) {
stdoutBuffer = Buffer.concat([
stdoutBuffer ?? Buffer.alloc(0),
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
} else {
stdout += chunk;
}
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (err) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
2026-01-30 19:41:00 +01:00
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
reject(new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, err));
2026-01-30 19:41:00 +01:00
return;
}
reject(new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, err));
2026-01-30 19:41:00 +01:00
});
child.on('close', (code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
2026-01-30 19:41:00 +01:00
const exitCode = code ?? 1;
if (didTimeout && timeoutMs) {
reject(
new AppError('COMMAND_FAILED', `${executable} timed out after ${timeoutMs}ms`, {
cmd,
args,
stdout,
stderr,
exitCode,
timeoutMs,
}),
);
return;
}
2026-01-30 19:41:00 +01:00
if (exitCode !== 0 && !options.allowFailure) {
reject(
new AppError('COMMAND_FAILED', `${executable} exited with code ${exitCode}`, {
2026-01-30 19:41:00 +01:00
cmd,
args,
stdout,
stderr,
exitCode,
processExitError: true,
2026-01-30 19:41:00 +01:00
}),
);
return;
}
resolve({ stdout, stderr, exitCode, stdoutBuffer });
});
});
}
export async function whichCmd(cmd: string): Promise<boolean> {
const candidate = normalizeExecutableLookup(cmd, { allowRelativePath: false });
if (!candidate) return false;
if (path.isAbsolute(candidate)) {
return isExecutable(candidate);
}
const pathValue = process.env.PATH;
if (!pathValue) return false;
const pathExtensions = resolvePathExtensions();
for (const directory of pathValue.split(path.delimiter)) {
const trimmedDirectory = directory.trim();
if (!trimmedDirectory) continue;
for (const entry of resolveExecutableCandidates(candidate, pathExtensions)) {
if (await isExecutable(path.join(trimmedDirectory, entry))) {
return true;
}
}
2026-01-30 19:41:00 +01:00
}
return false;
2026-01-30 19:41:00 +01:00
}
export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {}): ExecResult {
const executable = normalizeExecutableCommand(cmd);
const result = spawnSync(executable, args, {
2026-01-30 19:41:00 +01:00
cwd: options.cwd,
env: options.env,
2026-02-02 15:12:23 +01:00
stdio: ['pipe', 'pipe', 'pipe'],
2026-01-30 19:41:00 +01:00
encoding: options.binaryStdout ? undefined : 'utf8',
2026-02-02 15:12:23 +01:00
input: options.stdin,
timeout: normalizeTimeoutMs(options.timeoutMs),
shell: false,
2026-01-30 19:41:00 +01:00
});
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
if (code === 'ETIMEDOUT') {
2026-03-17 18:18:53 +01:00
throw new AppError(
'COMMAND_FAILED',
`${executable} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`,
2026-03-17 18:18:53 +01:00
{
cmd,
args,
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
},
result.error,
);
}
2026-01-30 19:41:00 +01:00
if (code === 'ENOENT') {
throw new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, result.error);
2026-01-30 19:41:00 +01:00
}
throw new AppError(
'COMMAND_FAILED',
`Failed to run ${executable}`,
{ cmd, args },
result.error,
);
2026-01-30 19:41:00 +01:00
}
const stdoutBuffer = options.binaryStdout
? Buffer.isBuffer(result.stdout)
? result.stdout
: Buffer.from(result.stdout ?? '')
: undefined;
const stdout = options.binaryStdout
? ''
: typeof result.stdout === 'string'
? result.stdout
: (result.stdout ?? '').toString();
const stderr =
typeof result.stderr === 'string' ? result.stderr : (result.stderr ?? '').toString();
const exitCode = result.status ?? 1;
if (exitCode !== 0 && !options.allowFailure) {
throw new AppError('COMMAND_FAILED', `${executable} exited with code ${exitCode}`, {
2026-01-30 19:41:00 +01:00
cmd,
args,
stdout,
stderr,
exitCode,
processExitError: true,
2026-01-30 19:41:00 +01:00
});
}
return { stdout, stderr, exitCode, stdoutBuffer };
}
export function runCmdDetached(
cmd: string,
args: string[],
options: ExecDetachedOptions = {},
): number {
const executable = normalizeExecutableCommand(cmd);
const child = spawn(executable, args, {
2026-01-30 19:41:00 +01:00
cwd: options.cwd,
env: options.env,
stdio: options.stdio ?? 'ignore',
2026-01-30 19:41:00 +01:00
detached: true,
shell: false,
2026-01-30 19:41:00 +01:00
});
child.unref();
return child.pid ?? 0;
2026-01-30 19:41:00 +01:00
}
export async function runCmdStreaming(
cmd: string,
args: string[],
options: ExecStreamOptions = {},
): Promise<ExecResult> {
const executable = normalizeExecutableCommand(cmd);
2026-01-30 19:41:00 +01:00
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
2026-01-30 19:41:00 +01:00
cwd: options.cwd,
env: options.env,
2026-02-02 15:12:23 +01:00
stdio: ['pipe', 'pipe', 'pipe'],
detached: options.detached,
shell: false,
2026-01-30 19:41:00 +01:00
});
options.onSpawn?.(child);
2026-01-30 19:41:00 +01:00
let stdout = '';
let stderr = '';
let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
let didTimeout = false;
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
const timeoutHandle = timeoutMs
? setTimeout(() => {
didTimeout = true;
child.kill('SIGKILL');
}, timeoutMs)
: null;
2026-01-30 19:41:00 +01:00
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
2026-02-02 15:12:23 +01:00
if (options.stdin !== undefined) {
child.stdin.write(options.stdin);
}
child.stdin.end();
2026-01-30 19:41:00 +01:00
child.stdout.on('data', (chunk) => {
if (options.binaryStdout) {
stdoutBuffer = Buffer.concat([
stdoutBuffer ?? Buffer.alloc(0),
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
]);
return;
}
const text = String(chunk);
stdout += text;
options.onStdoutChunk?.(text);
});
child.stderr.on('data', (chunk) => {
const text = String(chunk);
stderr += text;
options.onStderrChunk?.(text);
});
child.on('error', (err) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
2026-01-30 19:41:00 +01:00
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
reject(new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, err));
2026-01-30 19:41:00 +01:00
return;
}
reject(new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, err));
2026-01-30 19:41:00 +01:00
});
child.on('close', (code) => {
if (timeoutHandle) clearTimeout(timeoutHandle);
2026-01-30 19:41:00 +01:00
const exitCode = code ?? 1;
if (didTimeout && timeoutMs) {
reject(
new AppError('COMMAND_FAILED', `${executable} timed out after ${timeoutMs}ms`, {
cmd,
args,
stdout,
stderr,
exitCode,
timeoutMs,
}),
);
return;
}
2026-01-30 19:41:00 +01:00
if (exitCode !== 0 && !options.allowFailure) {
reject(
new AppError('COMMAND_FAILED', `${executable} exited with code ${exitCode}`, {
2026-01-30 19:41:00 +01:00
cmd,
args,
stdout,
stderr,
exitCode,
processExitError: true,
2026-01-30 19:41:00 +01:00
}),
);
return;
}
resolve({ stdout, stderr, exitCode, stdoutBuffer });
});
});
}
2026-02-02 21:33:45 +01:00
export function runCmdBackground(
cmd: string,
args: string[],
options: ExecOptions = {},
): ExecBackgroundResult {
const executable = normalizeExecutableCommand(cmd);
const child = spawn(executable, args, {
2026-02-02 21:33:45 +01:00
cwd: options.cwd,
env: options.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: options.detached,
shell: false,
2026-02-02 21:33:45 +01:00
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
const wait = new Promise<ExecResult>((resolve, reject) => {
child.on('error', (err) => {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
reject(new AppError('TOOL_MISSING', `${executable} not found in PATH`, { cmd }, err));
2026-02-02 21:33:45 +01:00
return;
}
reject(new AppError('COMMAND_FAILED', `Failed to run ${executable}`, { cmd, args }, err));
2026-02-02 21:33:45 +01:00
});
child.on('close', (code) => {
const exitCode = code ?? 1;
if (exitCode !== 0 && !options.allowFailure) {
reject(
new AppError('COMMAND_FAILED', `${executable} exited with code ${exitCode}`, {
2026-02-02 21:33:45 +01:00
cmd,
args,
stdout,
stderr,
exitCode,
processExitError: true,
2026-02-02 21:33:45 +01:00
}),
);
return;
}
resolve({ stdout, stderr, exitCode });
});
});
return { child, wait };
}
function normalizeExecutableCommand(cmd: string): string {
const candidate = normalizeExecutableLookup(cmd, { allowRelativePath: true });
if (!candidate) {
throw new AppError('INVALID_ARGS', `Invalid executable command: ${JSON.stringify(cmd)}`, {
cmd,
});
}
return candidate;
}
function normalizeExecutableLookup(
cmd: string,
options: { allowRelativePath: boolean },
): string | null {
const candidate = cmd.trim();
if (!candidate || candidate.includes('\0')) return null;
if (path.isAbsolute(candidate)) return candidate;
if (candidate.includes('/') || candidate.includes('\\')) {
return options.allowRelativePath ? candidate : null;
}
return BARE_COMMAND_RE.test(candidate) ? candidate : null;
}
function resolvePathExtensions(): string[] {
if (process.platform !== 'win32') return [''];
const rawPathExt = process.env.PATHEXT;
if (!rawPathExt) return WINDOWS_PATH_EXTENSIONS;
const extensions = rawPathExt
.split(';')
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0);
return extensions.length > 0 ? extensions : WINDOWS_PATH_EXTENSIONS;
}
function resolveExecutableCandidates(cmd: string, pathExtensions: string[]): string[] {
if (process.platform !== 'win32') return [cmd];
const lowered = cmd.toLowerCase();
if (pathExtensions.some((extension) => lowered.endsWith(extension))) {
return [cmd];
}
return pathExtensions.map((extension) => `${cmd}${extension}`);
}
async function isExecutable(filePath: string): Promise<boolean> {
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile()) return false;
await access(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
return true;
} catch {
return false;
2026-01-30 19:41:00 +01:00
}
}
function normalizeTimeoutMs(value: number | undefined): number | undefined {
if (!Number.isFinite(value)) return undefined;
const timeout = Math.floor(value as number);
if (timeout <= 0) return undefined;
return timeout;
}