254 lines
8.2 KiB
TypeScript
254 lines
8.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { spawn } from 'child_process';
|
|
import { TextDecoder } from 'util';
|
|
import os from 'os';
|
|
import stripAnsi from 'strip-ansi';
|
|
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
|
import { isBinary } from '../utils/textUtils.js';
|
|
|
|
const SIGKILL_TIMEOUT_MS = 200;
|
|
|
|
/** A structured result from a shell command execution. */
|
|
export interface ShellExecutionResult {
|
|
/** The raw, unprocessed output buffer. */
|
|
rawOutput: Buffer;
|
|
/** The combined, decoded stdout and stderr as a string. */
|
|
output: string;
|
|
/** The decoded stdout as a string. */
|
|
stdout: string;
|
|
/** The decoded stderr as a string. */
|
|
stderr: string;
|
|
/** The process exit code, or null if terminated by a signal. */
|
|
exitCode: number | null;
|
|
/** The signal that terminated the process, if any. */
|
|
signal: NodeJS.Signals | null;
|
|
/** An error object if the process failed to spawn. */
|
|
error: Error | null;
|
|
/** A boolean indicating if the command was aborted by the user. */
|
|
aborted: boolean;
|
|
/** The process ID of the spawned shell. */
|
|
pid: number | undefined;
|
|
}
|
|
|
|
/** A handle for an ongoing shell execution. */
|
|
export interface ShellExecutionHandle {
|
|
/** The process ID of the spawned shell. */
|
|
pid: number | undefined;
|
|
/** A promise that resolves with the complete execution result. */
|
|
result: Promise<ShellExecutionResult>;
|
|
}
|
|
|
|
/**
|
|
* Describes a structured event emitted during shell command execution.
|
|
*/
|
|
export type ShellOutputEvent =
|
|
| {
|
|
/** The event contains a chunk of output data. */
|
|
type: 'data';
|
|
/** The stream from which the data originated. */
|
|
stream: 'stdout' | 'stderr';
|
|
/** The decoded string chunk. */
|
|
chunk: string;
|
|
}
|
|
| {
|
|
/** Signals that the output stream has been identified as binary. */
|
|
type: 'binary_detected';
|
|
}
|
|
| {
|
|
/** Provides progress updates for a binary stream. */
|
|
type: 'binary_progress';
|
|
/** The total number of bytes received so far. */
|
|
bytesReceived: number;
|
|
};
|
|
|
|
/**
|
|
* A centralized service for executing shell commands with robust process
|
|
* management, cross-platform compatibility, and streaming output capabilities.
|
|
*
|
|
*/
|
|
export class ShellExecutionService {
|
|
/**
|
|
* Executes a shell command using `spawn`, capturing all output and lifecycle events.
|
|
*
|
|
* @param commandToExecute The exact command string to run.
|
|
* @param cwd The working directory to execute the command in.
|
|
* @param onOutputEvent A callback for streaming structured events about the execution, including data chunks and status updates.
|
|
* @param abortSignal An AbortSignal to terminate the process and its children.
|
|
* @returns An object containing the process ID (pid) and a promise that
|
|
* resolves with the complete execution result.
|
|
*/
|
|
static execute(
|
|
commandToExecute: string,
|
|
cwd: string,
|
|
onOutputEvent: (event: ShellOutputEvent) => void,
|
|
abortSignal: AbortSignal,
|
|
): ShellExecutionHandle {
|
|
const isWindows = os.platform() === 'win32';
|
|
|
|
const child = spawn(commandToExecute, [], {
|
|
cwd,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
// Use bash unless in Windows (since it doesn't support bash).
|
|
// For windows, just use the default.
|
|
shell: isWindows ? true : 'bash',
|
|
// Use process groups on non-Windows for robust killing.
|
|
// Windows process termination is handled by `taskkill /t`.
|
|
detached: !isWindows,
|
|
env: {
|
|
...process.env,
|
|
GEMINI_CLI: '1',
|
|
},
|
|
});
|
|
|
|
const result = new Promise<ShellExecutionResult>((resolve) => {
|
|
// Use decoders to handle multi-byte characters safely (for streaming output).
|
|
let stdoutDecoder: TextDecoder | null = null;
|
|
let stderrDecoder: TextDecoder | null = null;
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const outputChunks: Buffer[] = [];
|
|
let error: Error | null = null;
|
|
let exited = false;
|
|
|
|
let isStreamingRawContent = true;
|
|
const MAX_SNIFF_SIZE = 4096;
|
|
let sniffedBytes = 0;
|
|
|
|
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
|
|
if (!stdoutDecoder || !stderrDecoder) {
|
|
const encoding = getCachedEncodingForBuffer(data);
|
|
try {
|
|
stdoutDecoder = new TextDecoder(encoding);
|
|
stderrDecoder = new TextDecoder(encoding);
|
|
} catch {
|
|
// If the encoding is not supported, fall back to utf-8.
|
|
// This can happen on some platforms for certain encodings like 'utf-32le'.
|
|
stdoutDecoder = new TextDecoder('utf-8');
|
|
stderrDecoder = new TextDecoder('utf-8');
|
|
}
|
|
}
|
|
|
|
outputChunks.push(data);
|
|
|
|
// Binary detection logic. This only runs until we've made a determination.
|
|
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
|
|
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
|
sniffedBytes = sniffBuffer.length;
|
|
|
|
if (isBinary(sniffBuffer)) {
|
|
// Change state to stop streaming raw content.
|
|
isStreamingRawContent = false;
|
|
onOutputEvent({ type: 'binary_detected' });
|
|
}
|
|
}
|
|
|
|
const decodedChunk =
|
|
stream === 'stdout'
|
|
? stdoutDecoder.decode(data, { stream: true })
|
|
: stderrDecoder.decode(data, { stream: true });
|
|
const strippedChunk = stripAnsi(decodedChunk);
|
|
|
|
if (stream === 'stdout') {
|
|
stdout += strippedChunk;
|
|
} else {
|
|
stderr += strippedChunk;
|
|
}
|
|
|
|
if (isStreamingRawContent) {
|
|
onOutputEvent({ type: 'data', stream, chunk: strippedChunk });
|
|
} else {
|
|
const totalBytes = outputChunks.reduce(
|
|
(sum, chunk) => sum + chunk.length,
|
|
0,
|
|
);
|
|
onOutputEvent({ type: 'binary_progress', bytesReceived: totalBytes });
|
|
}
|
|
};
|
|
|
|
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
|
|
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
|
|
child.on('error', (err) => {
|
|
const { stdout, stderr, finalBuffer } = cleanup();
|
|
error = err;
|
|
resolve({
|
|
error,
|
|
stdout,
|
|
stderr,
|
|
rawOutput: finalBuffer,
|
|
output: stdout + (stderr ? `\n${stderr}` : ''),
|
|
exitCode: 1,
|
|
signal: null,
|
|
aborted: false,
|
|
pid: child.pid,
|
|
});
|
|
});
|
|
|
|
const abortHandler = async () => {
|
|
if (child.pid && !exited) {
|
|
if (isWindows) {
|
|
spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
|
|
} else {
|
|
try {
|
|
// Kill the entire process group (negative PID).
|
|
// SIGTERM first, then SIGKILL if it doesn't die.
|
|
process.kill(-child.pid, 'SIGTERM');
|
|
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
|
if (!exited) {
|
|
process.kill(-child.pid, 'SIGKILL');
|
|
}
|
|
} catch (_e) {
|
|
// Fall back to killing just the main process if group kill fails.
|
|
if (!exited) child.kill('SIGKILL');
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
|
|
child.on('exit', (code: number, signal: NodeJS.Signals) => {
|
|
const { stdout, stderr, finalBuffer } = cleanup();
|
|
|
|
resolve({
|
|
rawOutput: finalBuffer,
|
|
output: stdout + (stderr ? `\n${stderr}` : ''),
|
|
stdout,
|
|
stderr,
|
|
exitCode: code,
|
|
signal,
|
|
error,
|
|
aborted: abortSignal.aborted,
|
|
pid: child.pid,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Cleans up a process (and it's accompanying state) that is exiting or
|
|
* erroring and returns output formatted output buffers and strings
|
|
*/
|
|
function cleanup() {
|
|
exited = true;
|
|
abortSignal.removeEventListener('abort', abortHandler);
|
|
if (stdoutDecoder) {
|
|
stdout += stripAnsi(stdoutDecoder.decode());
|
|
}
|
|
if (stderrDecoder) {
|
|
stderr += stripAnsi(stderrDecoder.decode());
|
|
}
|
|
|
|
const finalBuffer = Buffer.concat(outputChunks);
|
|
|
|
return { stdout, stderr, finalBuffer };
|
|
}
|
|
});
|
|
|
|
return { pid: child.pid, result };
|
|
}
|
|
}
|