summaryrefslogtreecommitdiff
path: root/packages/core/src/services/shellExecutionService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src/services/shellExecutionService.ts')
-rw-r--r--packages/core/src/services/shellExecutionService.ts229
1 files changed, 229 insertions, 0 deletions
diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts
new file mode 100644
index 00000000..0f0002cd
--- /dev/null
+++ b/packages/core/src/services/shellExecutionService.ts
@@ -0,0 +1,229 @@
+/**
+ * @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 shell = isWindows ? 'cmd.exe' : 'bash';
+ const shellArgs = [isWindows ? '/c' : '-c', commandToExecute];
+
+ const child = spawn(shell, shellArgs, {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ detached: !isWindows, // Use process groups on non-Windows for robust killing
+ 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) => {
+ error = err;
+ });
+
+ 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, signal) => {
+ exited = true;
+ abortSignal.removeEventListener('abort', abortHandler);
+
+ if (stdoutDecoder) {
+ stdout += stripAnsi(stdoutDecoder.decode());
+ }
+ if (stderrDecoder) {
+ stderr += stripAnsi(stderrDecoder.decode());
+ }
+
+ const finalBuffer = Buffer.concat(outputChunks);
+
+ resolve({
+ rawOutput: finalBuffer,
+ output: stdout + (stderr ? `\n${stderr}` : ''),
+ stdout,
+ stderr,
+ exitCode: code,
+ signal,
+ error,
+ aborted: abortSignal.aborted,
+ pid: child.pid,
+ });
+ });
+ });
+
+ return { pid: child.pid, result };
+ }
+}