/**
* Terminal Controller
*
* Handles terminal command execution with proper error handling,
* timeout management, and cross-platform compatibility.
*/
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
const execAsync = promisify(exec);
export interface CommandResult {
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
executionTime: number;
command: string;
}
export interface CommandOptions {
cwd?: string;
timeout?: number;
env?: Record<string, string>;
shell?: boolean;
encoding?: BufferEncoding;
}
export class TerminalController {
private defaultTimeout: number = 30000; // 30 seconds
private defaultOptions: CommandOptions = {
shell: true,
encoding: 'utf8'
};
/**
* Execute command with promise-based interface
*/
async executeCommand(
command: string,
args: string[] = [],
options: CommandOptions = {}
): Promise<CommandResult> {
const startTime = Date.now();
const mergedOptions = {
...this.defaultOptions,
...options,
timeout: options.timeout || this.defaultTimeout
};
return new Promise<CommandResult>((resolve) => {
const child = spawn(command, args, {
cwd: mergedOptions.cwd,
env: { ...process.env, ...mergedOptions.env },
shell: false
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
let finished = false;
const finish = (code: number | null) => {
if (finished) return;
finished = true;
resolve({
success: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code === null ? 1 : code,
executionTime: Date.now() - startTime,
command: `${command} ${args.join(' ')}`
});
};
child.on('close', (code) => finish(code));
child.on('error', (err) => {
stderr += err.message;
finish(1);
});
// Handle timeout
if (mergedOptions.timeout) {
setTimeout(() => {
try { child.kill('SIGTERM'); } catch {}
finish(1);
}, mergedOptions.timeout);
}
});
}
/**
* Execute command in specific directory
*/
async executeInDirectory(
directory: string,
command: string,
args: string[] = [],
options: CommandOptions = {}
): Promise<CommandResult> {
const resolvedPath = path.resolve(directory);
return this.executeCommand(command, args, {
...options,
cwd: resolvedPath
});
}
/**
* Check if command exists in system PATH
*/
async commandExists(command: string): Promise<boolean> {
try {
const args = process.platform === 'win32' ? [command] : [command];
const cmd = process.platform === 'win32' ? 'where' : 'which';
const result = await this.executeCommand(cmd, args);
return result.success && result.stdout.length > 0;
} catch {
return false;
}
}
/**
* Get current working directory
*/
getCurrentDirectory(): string {
return process.cwd();
}
/**
* Change working directory for subsequent commands
*/
setWorkingDirectory(directory: string): void {
process.chdir(path.resolve(directory));
}
/**
* Execute multiple commands in sequence
*/
async executeSequence(
commands: Array<{ command: string; args?: string[]; options?: CommandOptions }>,
stopOnError: boolean = true
): Promise<CommandResult[]> {
const results: CommandResult[] = [];
for (const cmd of commands) {
const result = await this.executeCommand(
cmd.command,
cmd.args || [],
cmd.options || {}
);
results.push(result);
if (!result.success && stopOnError) {
break;
}
}
return results;
}
/**
* Execute command with real-time output streaming
*/
async executeWithStreaming(
command: string,
args: string[] = [],
options: CommandOptions & {
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
} = {}
): Promise<CommandResult> {
const startTime = Date.now();
const fullCommand = `${command} ${args.join(' ')}`;
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: { ...process.env, ...options.env },
shell: options.shell !== false
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
const text = data.toString();
stdout += text;
options.onStdout?.(text);
});
child.stderr?.on('data', (data) => {
const text = data.toString();
stderr += text;
options.onStderr?.(text);
});
child.on('close', (code) => {
resolve({
success: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code || 0,
executionTime: Date.now() - startTime,
command: fullCommand
});
});
child.on('error', (error) => {
resolve({
success: false,
stdout: stdout.trim(),
stderr: error.message,
exitCode: 1,
executionTime: Date.now() - startTime,
command: fullCommand
});
});
// Handle timeout
if (options.timeout) {
setTimeout(() => {
child.kill('SIGTERM');
}, options.timeout);
}
});
}
/**
* Search for text in files using grep or findstr
*/
async searchInFiles(
directory: string,
query: string,
options: {
caseSensitive?: boolean;
filePattern?: string;
maxResults?: number;
} = {}
): Promise<CommandResult> {
const isWindows = process.platform === 'win32';
if (isWindows) {
// Use findstr on Windows
const args: string[] = ['/S', '/N'];
if (!options.caseSensitive) args.push('/I');
const filePattern = options.filePattern && options.filePattern !== '*' ? options.filePattern : '*';
// findstr usage: findstr [options] "searchString" filespec
args.push(query);
args.push(filePattern);
const result = await this.executeInDirectory(directory, 'findstr', args);
// findstr returns exit code 1 when no matches are found — treat as success with empty output
if (result.exitCode === 1 && !result.stdout.trim()) {
return {
success: true,
stdout: '',
stderr: '',
exitCode: 0,
executionTime: result.executionTime,
command: result.command
};
}
return result;
} else {
// Use grep on Unix-like systems
const args = ['-r', '-n'];
if (!options.caseSensitive) args.push('-i');
if (options.filePattern && options.filePattern !== '*') {
args.push('--include', options.filePattern);
}
args.push(query, '.');
const result = await this.executeInDirectory(directory, 'grep', args);
// grep exit code 1 means no matches — normalize to success with empty output
if (result.exitCode === 1 && !result.stdout.trim()) {
return {
success: true,
stdout: '',
stderr: '',
exitCode: 0,
executionTime: result.executionTime,
command: result.command
};
}
return result;
}
}
/**
* Validate directory exists and is accessible
*/
async validateDirectory(directory: string): Promise<{
exists: boolean;
accessible: boolean;
isDirectory: boolean;
error?: string;
}> {
try {
const fs = await import('fs/promises');
const stats = await fs.stat(directory);
return {
exists: true,
accessible: true,
isDirectory: stats.isDirectory(),
};
} catch (error: any) {
return {
exists: false,
accessible: false,
isDirectory: false,
error: error.message
};
}
}
}
// Export singleton instance
export const terminalController = new TerminalController();