runtime-adapter.ts•9.73 kB
/**
* @fileoverview Runtime adapter for cross-runtime process spawning
* @module services/git/providers/cli/utils/runtime-adapter
*
* This module provides a unified interface for spawning git processes
* that works in both Bun and Node.js runtimes. When running via bunx
* (Node.js), it uses child_process.spawn. When running in native Bun,
* it uses Bun.spawn for better performance and security.
*
* ## Why eslint-disable is necessary
*
* TypeScript cannot infer types for `globalThis.Bun` at compile time because:
* 1. The Bun types are only available when running in Bun runtime
* 2. This code must compile for both Node.js and Bun targets
* 3. We must dynamically access `globalThis.Bun` and cast it
*
* We minimize unsafe code by:
* - Creating a typed interface for only the Bun APIs we use
* - Isolating the single `any` cast to the initial access
* - Using TypeScript interfaces for all downstream usage
*/
import { spawn } from 'node:child_process';
/**
* Result of executing a git command.
*/
export interface GitCommandResult {
/** Standard output from the command */
stdout: string;
/** Standard error from the command */
stderr: string;
}
/**
* Minimal typed interface for Bun's spawn API.
*
* This interface includes only the subset of Bun's API that we actually use,
* providing type safety for the dynamic access to globalThis.Bun.
*
* @internal
*/
interface BunSpawnAPI {
spawn(
cmd: string[],
options: {
cwd: string;
env: Record<string, string>;
stdio: [string, string, string];
},
): BunSubprocess;
}
/**
* Bun extends ReadableStream with additional helper methods.
* This interface represents Bun's enhanced ReadableStream.
*
* @internal
*/
interface BunReadableStream extends ReadableStream<Uint8Array> {
/**
* Bun-specific extension: Read the entire stream as text.
* This is more efficient than using a TextDecoder manually.
*/
text(): Promise<string>;
}
/**
* Minimal typed interface for Bun's Subprocess.
*
* @internal
*/
interface BunSubprocess {
readonly stdout: BunReadableStream;
readonly stderr: BunReadableStream;
readonly exited: Promise<number>;
kill(): void;
}
/**
* Detects the current JavaScript runtime.
*
* @returns 'bun' if running in Bun, 'node' if running in Node.js
*
* @example
* ```typescript
* const runtime = detectRuntime();
* if (runtime === 'bun') {
* // Use Bun-specific APIs
* } else {
* // Use Node.js APIs
* }
* ```
*/
export function detectRuntime(): 'bun' | 'node' {
// Check for Bun global object (most reliable)
if (typeof globalThis.Bun !== 'undefined') {
return 'bun';
}
// Check process.versions.bun as fallback
if (process.versions?.bun) {
return 'bun';
}
return 'node';
}
/**
* Spawns a git command using Bun.spawn for optimal performance.
*
* This function is used when running in native Bun runtime. It uses Bun's
* native spawn API which provides better performance than Node's child_process.
*
* The function:
* 1. Spawns the git process with piped stdout/stderr
* 2. Races the process exit against a timeout and abort signal
* 3. Reads streams using the standard ReadableStream.text() method
* 4. Returns structured output or throws on error
*
* @param args - Git command arguments
* @param cwd - Working directory
* @param env - Environment variables
* @param timeout - Timeout in milliseconds
* @param signal - Optional AbortSignal for cancellation
* @returns Promise resolving to stdout and stderr
* @throws Error if the command fails, times out, or is aborted
*/
async function spawnWithBun(
args: string[],
cwd: string,
env: Record<string, string>,
timeout: number,
signal?: AbortSignal,
): Promise<GitCommandResult> {
// Cast globalThis.Bun to our typed interface
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bunApi = globalThis.Bun as any as BunSpawnAPI;
// Check if already aborted before starting
if (signal?.aborted) {
throw new Error(
`Git command cancelled before execution: git ${args.join(' ')}`,
);
}
// Spawn the process using typed interface - no more eslint-disable needed
const proc = bunApi.spawn(['git', ...args], {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Create abort signal listener that kills the process
const abortPromise = new Promise<never>((_, reject) => {
if (signal) {
signal.addEventListener(
'abort',
() => {
proc.kill();
reject(new Error(`Git command cancelled: git ${args.join(' ')}`));
},
{ once: true },
);
}
});
// Create a timeout promise that will kill the process if it exceeds the limit
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
proc.kill();
reject(
new Error(
`Git command timed out after ${timeout / 1000}s: git ${args.join(' ')}`,
),
);
}, timeout);
// Ensure timeout is cleared if process exits normally
void proc.exited.finally(() => clearTimeout(timeoutId));
});
// Wait for the process to exit, racing against timeout and abort signal
const exitCode = await Promise.race([
proc.exited,
timeoutPromise,
...(signal ? [abortPromise] : []),
]);
// Read the output streams using standard ReadableStream.text() method
// This is the modern Web Streams API approach
const [stdout, stderr] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
]);
// Check if the command succeeded (exit code 0)
if (exitCode !== 0) {
const combinedOutput = `Exit Code: ${exitCode}\nStderr: ${stderr}\nStdout: ${stdout}`;
throw new Error(combinedOutput);
}
return { stdout, stderr };
}
/**
* Spawns a git command using Node.js child_process.spawn.
*
* This function is used when running via bunx or in Node.js runtime.
*
* @param args - Git command arguments
* @param cwd - Working directory
* @param env - Environment variables
* @param timeout - Timeout in milliseconds
* @param signal - Optional AbortSignal for cancellation
* @returns Promise resolving to stdout and stderr
* @throws Error if the command fails, times out, or is aborted
*/
async function spawnWithNode(
args: string[],
cwd: string,
env: Record<string, string>,
timeout: number,
signal?: AbortSignal,
): Promise<GitCommandResult> {
return new Promise((resolve, reject) => {
// Check if already aborted before starting
if (signal?.aborted) {
reject(
new Error(`Git command cancelled before execution: ${args.join(' ')}`),
);
return;
}
const proc = spawn('git', args, {
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
proc.stdout.on('data', (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
proc.stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk);
});
// Setup abort signal handler
const abortHandler = () => {
proc.kill('SIGTERM');
reject(new Error(`Git command cancelled: ${args.join(' ')}`));
};
if (signal) {
signal.addEventListener('abort', abortHandler, { once: true });
}
// Setup timeout
const timeoutHandle = setTimeout(() => {
proc.kill('SIGTERM');
reject(
new Error(
`Git command timed out after ${timeout / 1000}s: ${args.join(' ')}`,
),
);
}, timeout);
proc.on('error', (error) => {
clearTimeout(timeoutHandle);
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
reject(error);
});
proc.on('close', (exitCode) => {
clearTimeout(timeoutHandle);
if (signal) {
signal.removeEventListener('abort', abortHandler);
}
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
if (exitCode !== 0) {
const combinedOutput = `Exit Code: ${exitCode}\nStderr: ${stderr}\nStdout: ${stdout}`;
reject(new Error(combinedOutput));
} else {
resolve({ stdout, stderr });
}
});
});
}
/**
* Spawns a git command using the appropriate runtime implementation.
*
* Automatically detects the runtime (Bun vs Node.js) and uses the
* optimal process spawning method for that runtime.
*
* Supports cancellation via AbortSignal (MCP Spec 2025-06-18):
* - Clients can cancel long-running operations by aborting the request
* - The git process will be killed and resources cleaned up
*
* @param args - Git command arguments (e.g., ['status', '--porcelain'])
* @param cwd - Working directory for command execution
* @param env - Environment variables
* @param timeout - Timeout in milliseconds (default: 60000)
* @param signal - Optional AbortSignal for cancellation support
* @returns Promise resolving to stdout and stderr
* @throws Error if the command fails, times out, or is cancelled
*
* @example
* ```typescript
* const result = await spawnGitCommand(
* ['status', '--porcelain'],
* '/path/to/repo',
* { GIT_TERMINAL_PROMPT: '0' },
* 60000,
* abortController.signal
* );
* console.log(result.stdout); // Git output
* ```
*/
export async function spawnGitCommand(
args: string[],
cwd: string,
env: Record<string, string>,
timeout = 60000,
signal?: AbortSignal,
): Promise<GitCommandResult> {
const runtime = detectRuntime();
if (runtime === 'bun') {
return spawnWithBun(args, cwd, env, timeout, signal);
} else {
return spawnWithNode(args, cwd, env, timeout, signal);
}
}