874 lines
30 KiB
TypeScript
874 lines
30 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { ShellEnv } from './classes.shellenv.js';
|
|
import type { IShellEnvContructorOptions, TExecutor } from './classes.shellenv.js';
|
|
import { ShellLog } from './classes.shelllog.js';
|
|
import * as cp from 'child_process';
|
|
|
|
// -- interfaces --
|
|
export interface IExecResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
signal?: NodeJS.Signals;
|
|
stderr?: string;
|
|
}
|
|
|
|
export interface IExecResultInteractive extends IExecResult {
|
|
sendInput: (input: string) => Promise<void>;
|
|
sendLine: (line: string) => Promise<void>;
|
|
endInput: () => void;
|
|
finalPromise: Promise<IExecResult>;
|
|
}
|
|
|
|
export interface IExecResultStreaming {
|
|
childProcess: cp.ChildProcess;
|
|
finalPromise: Promise<IExecResult>;
|
|
kill: () => Promise<void>;
|
|
terminate: () => Promise<void>;
|
|
keyboardInterrupt: () => Promise<void>;
|
|
customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise<void>;
|
|
sendInput: (input: string) => Promise<void>;
|
|
sendLine: (line: string) => Promise<void>;
|
|
endInput: () => void;
|
|
}
|
|
|
|
interface IExecOptions {
|
|
commandString: string;
|
|
silent?: boolean;
|
|
strict?: boolean;
|
|
streaming?: boolean;
|
|
interactive?: boolean;
|
|
passthrough?: boolean;
|
|
interactiveControl?: boolean;
|
|
usePty?: boolean;
|
|
ptyCols?: number;
|
|
ptyRows?: number;
|
|
ptyTerm?: string;
|
|
ptyShell?: string;
|
|
maxBuffer?: number;
|
|
onData?: (chunk: Buffer | string) => void;
|
|
timeout?: number;
|
|
debug?: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
|
|
command: string;
|
|
args?: string[];
|
|
}
|
|
|
|
export class Smartshell {
|
|
public shellEnv: ShellEnv;
|
|
public smartexit = new plugins.smartexit.SmartExit();
|
|
|
|
constructor(optionsArg: IShellEnvContructorOptions) {
|
|
this.shellEnv = new ShellEnv(optionsArg);
|
|
}
|
|
|
|
/**
|
|
* Executes a given command asynchronously.
|
|
*/
|
|
private async _exec(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | void> {
|
|
if (options.interactive) {
|
|
return await this._execInteractive({ commandString: options.commandString });
|
|
}
|
|
return await this._execCommand(options);
|
|
}
|
|
|
|
/**
|
|
* Executes an interactive command.
|
|
*/
|
|
private async _execInteractive(options: Pick<IExecOptions, 'commandString'>): Promise<void> {
|
|
// Skip interactive execution in CI environments.
|
|
if (process.env.CI) {
|
|
return;
|
|
}
|
|
|
|
return new Promise<void>((resolve) => {
|
|
const shell = cp.spawn(options.commandString, {
|
|
stdio: 'inherit',
|
|
shell: true,
|
|
detached: true,
|
|
});
|
|
|
|
this.smartexit.addProcess(shell);
|
|
|
|
shell.on('close', (code) => {
|
|
console.log(`Interactive shell terminated with code ${code}`);
|
|
this.smartexit.removeProcess(shell);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Executes a command with args array (shell:false) for security
|
|
*/
|
|
private async _execSpawn(options: ISpawnOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
|
const shellLogInstance = new ShellLog();
|
|
let stderrBuffer = '';
|
|
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
|
|
let bufferExceeded = false;
|
|
|
|
// Handle PTY mode if requested
|
|
if (options.usePty) {
|
|
throw new Error('PTY mode is not yet supported with execSpawn. Use exec methods with shell:true for PTY.');
|
|
}
|
|
|
|
const execChildProcess = cp.spawn(options.command, options.args || [], {
|
|
shell: false, // SECURITY: Never use shell with untrusted input
|
|
cwd: process.cwd(),
|
|
env: options.env || process.env,
|
|
detached: false,
|
|
signal: options.signal,
|
|
});
|
|
|
|
this.smartexit.addProcess(execChildProcess);
|
|
|
|
// Handle timeout
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
if (options.timeout) {
|
|
timeoutHandle = setTimeout(() => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
|
}
|
|
execChildProcess.kill('SIGTERM');
|
|
}, options.timeout);
|
|
}
|
|
|
|
// Connect stdin if passthrough is enabled (but not for interactive control)
|
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
|
process.stdin.pipe(execChildProcess.stdin);
|
|
}
|
|
|
|
// Create input methods for interactive control
|
|
const sendInput = async (input: string): Promise<void> => {
|
|
if (!execChildProcess.stdin) {
|
|
throw new Error('stdin is not available for this process');
|
|
}
|
|
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
|
|
throw new Error('stdin has been destroyed or is not writable');
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
execChildProcess.stdin.write(input, 'utf8', (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const sendLine = async (line: string): Promise<void> => {
|
|
return sendInput(line + '\n');
|
|
};
|
|
|
|
const endInput = (): void => {
|
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
|
execChildProcess.stdin.end();
|
|
}
|
|
};
|
|
|
|
// Capture stdout and stderr output
|
|
execChildProcess.stdout.on('data', (data) => {
|
|
if (!options.silent) {
|
|
shellLogInstance.writeToConsole(data);
|
|
}
|
|
|
|
if (options.onData) {
|
|
options.onData(data);
|
|
}
|
|
|
|
if (!bufferExceeded) {
|
|
shellLogInstance.addToBuffer(data);
|
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
|
bufferExceeded = true;
|
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
|
}
|
|
}
|
|
});
|
|
|
|
execChildProcess.stderr.on('data', (data) => {
|
|
if (!options.silent) {
|
|
shellLogInstance.writeToConsole(data);
|
|
}
|
|
|
|
const dataStr = data.toString();
|
|
stderrBuffer += dataStr;
|
|
|
|
if (options.onData) {
|
|
options.onData(data);
|
|
}
|
|
|
|
if (!bufferExceeded) {
|
|
shellLogInstance.addToBuffer(data);
|
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
|
bufferExceeded = true;
|
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wrap child process termination into a Promise
|
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
this.smartexit.removeProcess(execChildProcess);
|
|
|
|
// Safely unpipe stdin when process ends if passthrough was enabled
|
|
if (options.passthrough && !options.interactiveControl) {
|
|
try {
|
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
|
process.stdin.unpipe(execChildProcess.stdin);
|
|
}
|
|
} catch (err) {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Error unpiping stdin: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
|
const execResult: IExecResult = {
|
|
exitCode,
|
|
stdout: shellLogInstance.logStore.toString(),
|
|
signal: signal || undefined,
|
|
stderr: stderrBuffer,
|
|
};
|
|
|
|
if (options.strict && exitCode !== 0) {
|
|
const errorMsg = signal
|
|
? `Command "${options.command}" terminated by signal ${signal}`
|
|
: `Command "${options.command}" exited with code ${exitCode}`;
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve(execResult);
|
|
}
|
|
};
|
|
|
|
execChildProcess.once('exit', handleExit);
|
|
execChildProcess.once('error', (error) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
this.smartexit.removeProcess(execChildProcess);
|
|
|
|
// Safely unpipe stdin when process errors if passthrough was enabled
|
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
|
try {
|
|
if (!execChildProcess.stdin.destroyed) {
|
|
process.stdin.unpipe(execChildProcess.stdin);
|
|
}
|
|
} catch (err) {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
// If interactive control is enabled but not streaming, return interactive interface
|
|
if (options.interactiveControl && !options.streaming) {
|
|
return {
|
|
exitCode: 0, // Will be updated when process ends
|
|
stdout: '', // Will be updated when process ends
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
finalPromise: childProcessEnded,
|
|
} as IExecResultInteractive;
|
|
}
|
|
|
|
// If streaming mode is enabled, return a streaming interface
|
|
if (options.streaming) {
|
|
return {
|
|
childProcess: execChildProcess,
|
|
finalPromise: childProcessEnded,
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
kill: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
|
},
|
|
terminate: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
|
},
|
|
keyboardInterrupt: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
|
},
|
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
|
},
|
|
} as IExecResultStreaming;
|
|
}
|
|
|
|
// For non-streaming mode, wait for the process to complete
|
|
return await childProcessEnded;
|
|
}
|
|
|
|
/**
|
|
* Executes a command and returns either a non-streaming result or a streaming interface.
|
|
*/
|
|
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
|
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
|
|
const shellLogInstance = new ShellLog();
|
|
let stderrBuffer = '';
|
|
const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB
|
|
let bufferExceeded = false;
|
|
|
|
// Handle PTY mode if requested
|
|
if (options.usePty) {
|
|
return await this._execCommandPty(options, commandToExecute, shellLogInstance);
|
|
}
|
|
|
|
const execChildProcess = cp.spawn(commandToExecute, [], {
|
|
shell: true,
|
|
cwd: process.cwd(),
|
|
env: options.env || process.env,
|
|
detached: false,
|
|
signal: options.signal,
|
|
});
|
|
|
|
this.smartexit.addProcess(execChildProcess);
|
|
|
|
// Handle timeout
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
if (options.timeout) {
|
|
timeoutHandle = setTimeout(() => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
|
|
}
|
|
execChildProcess.kill('SIGTERM');
|
|
}, options.timeout);
|
|
}
|
|
|
|
// Connect stdin if passthrough is enabled (but not for interactive control)
|
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
|
process.stdin.pipe(execChildProcess.stdin);
|
|
}
|
|
|
|
// Create input methods for interactive control
|
|
const sendInput = async (input: string): Promise<void> => {
|
|
if (!execChildProcess.stdin) {
|
|
throw new Error('stdin is not available for this process');
|
|
}
|
|
if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) {
|
|
throw new Error('stdin has been destroyed or is not writable');
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
execChildProcess.stdin.write(input, 'utf8', (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const sendLine = async (line: string): Promise<void> => {
|
|
return sendInput(line + '\n');
|
|
};
|
|
|
|
const endInput = (): void => {
|
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
|
execChildProcess.stdin.end();
|
|
}
|
|
};
|
|
|
|
// Capture stdout and stderr output
|
|
execChildProcess.stdout.on('data', (data) => {
|
|
if (!options.silent) {
|
|
shellLogInstance.writeToConsole(data);
|
|
}
|
|
|
|
if (options.onData) {
|
|
options.onData(data);
|
|
}
|
|
|
|
if (!bufferExceeded) {
|
|
shellLogInstance.addToBuffer(data);
|
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
|
bufferExceeded = true;
|
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
|
}
|
|
}
|
|
});
|
|
|
|
execChildProcess.stderr.on('data', (data) => {
|
|
if (!options.silent) {
|
|
shellLogInstance.writeToConsole(data);
|
|
}
|
|
|
|
const dataStr = data.toString();
|
|
stderrBuffer += dataStr;
|
|
|
|
if (options.onData) {
|
|
options.onData(data);
|
|
}
|
|
|
|
if (!bufferExceeded) {
|
|
shellLogInstance.addToBuffer(data);
|
|
if (shellLogInstance.logStore.length > maxBuffer) {
|
|
bufferExceeded = true;
|
|
shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wrap child process termination into a Promise
|
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
|
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
this.smartexit.removeProcess(execChildProcess);
|
|
|
|
// Safely unpipe stdin when process ends if passthrough was enabled
|
|
if (options.passthrough && !options.interactiveControl) {
|
|
try {
|
|
if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) {
|
|
process.stdin.unpipe(execChildProcess.stdin);
|
|
}
|
|
} catch (err) {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Error unpiping stdin: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
|
|
const execResult: IExecResult = {
|
|
exitCode,
|
|
stdout: shellLogInstance.logStore.toString(),
|
|
signal: signal || undefined,
|
|
stderr: stderrBuffer,
|
|
};
|
|
|
|
if (options.strict && exitCode !== 0) {
|
|
const errorMsg = signal
|
|
? `Command "${options.commandString}" terminated by signal ${signal}`
|
|
: `Command "${options.commandString}" exited with code ${exitCode}`;
|
|
reject(new Error(errorMsg));
|
|
} else {
|
|
resolve(execResult);
|
|
}
|
|
};
|
|
|
|
execChildProcess.once('exit', handleExit);
|
|
execChildProcess.once('error', (error) => {
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
this.smartexit.removeProcess(execChildProcess);
|
|
|
|
// Safely unpipe stdin when process errors if passthrough was enabled
|
|
if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) {
|
|
try {
|
|
if (!execChildProcess.stdin.destroyed) {
|
|
process.stdin.unpipe(execChildProcess.stdin);
|
|
}
|
|
} catch (err) {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Error unpiping stdin on error: ${err}`);
|
|
}
|
|
}
|
|
}
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
// If interactive control is enabled but not streaming, return interactive interface
|
|
if (options.interactiveControl && !options.streaming) {
|
|
return {
|
|
exitCode: 0, // Will be updated when process ends
|
|
stdout: '', // Will be updated when process ends
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
finalPromise: childProcessEnded,
|
|
} as IExecResultInteractive;
|
|
}
|
|
|
|
// If streaming mode is enabled, return a streaming interface
|
|
if (options.streaming) {
|
|
return {
|
|
childProcess: execChildProcess,
|
|
finalPromise: childProcessEnded,
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
kill: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
|
|
},
|
|
terminate: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM');
|
|
},
|
|
keyboardInterrupt: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT');
|
|
},
|
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
|
}
|
|
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal);
|
|
},
|
|
} as IExecResultStreaming;
|
|
}
|
|
|
|
// For non-streaming mode, wait for the process to complete.
|
|
return await childProcessEnded;
|
|
}
|
|
|
|
public async exec(commandString: string): Promise<IExecResult> {
|
|
const result = await this._exec({ commandString });
|
|
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
|
return result as IExecResult;
|
|
}
|
|
|
|
public async execSilent(commandString: string): Promise<IExecResult> {
|
|
return (await this._exec({ commandString, silent: true })) as IExecResult;
|
|
}
|
|
|
|
public async execStrict(commandString: string): Promise<IExecResult> {
|
|
return (await this._exec({ commandString, strict: true })) as IExecResult;
|
|
}
|
|
|
|
public async execStrictSilent(commandString: string): Promise<IExecResult> {
|
|
return (await this._exec({ commandString, silent: true, strict: true })) as IExecResult;
|
|
}
|
|
|
|
public async execStreaming(commandString: string, silent: boolean = false): Promise<IExecResultStreaming> {
|
|
return (await this._exec({ commandString, silent, streaming: true })) as IExecResultStreaming;
|
|
}
|
|
|
|
public async execStreamingSilent(commandString: string): Promise<IExecResultStreaming> {
|
|
return (await this._exec({ commandString, silent: true, streaming: true })) as IExecResultStreaming;
|
|
}
|
|
|
|
public async execInteractive(commandString: string): Promise<void> {
|
|
await this._exec({ commandString, interactive: true });
|
|
}
|
|
|
|
public async execPassthrough(commandString: string): Promise<IExecResult> {
|
|
return await this._exec({ commandString, passthrough: true }) as IExecResult;
|
|
}
|
|
|
|
public async execStreamingPassthrough(commandString: string): Promise<IExecResultStreaming> {
|
|
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
|
|
}
|
|
|
|
public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
|
|
return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
|
|
}
|
|
|
|
public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
|
|
return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
|
|
}
|
|
|
|
public async execInteractiveControlPty(commandString: string): Promise<IExecResultInteractive> {
|
|
return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive;
|
|
}
|
|
|
|
public async execStreamingInteractiveControlPty(commandString: string): Promise<IExecResultStreaming> {
|
|
return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
|
|
}
|
|
|
|
/**
|
|
* Executes a command with args array (shell:false) for security
|
|
* This is the recommended API for untrusted input
|
|
*/
|
|
public async execSpawn(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args'> = {}): Promise<IExecResult> {
|
|
const result = await this._execSpawn({ command, args, ...options });
|
|
// Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult
|
|
return result as IExecResult;
|
|
}
|
|
|
|
/**
|
|
* Executes a command with args array in streaming mode
|
|
*/
|
|
public async execSpawnStreaming(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'streaming'> = {}): Promise<IExecResultStreaming> {
|
|
return await this._execSpawn({ command, args, streaming: true, ...options }) as IExecResultStreaming;
|
|
}
|
|
|
|
/**
|
|
* Executes a command with args array with interactive control
|
|
*/
|
|
public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit<ISpawnOptions, 'command' | 'args' | 'interactiveControl'> = {}): Promise<IExecResultInteractive> {
|
|
return await this._execSpawn({ command, args, interactiveControl: true, ...options }) as IExecResultInteractive;
|
|
}
|
|
|
|
public async execAndWaitForLine(
|
|
commandString: string,
|
|
regex: RegExp,
|
|
silent: boolean = false,
|
|
options: { timeout?: number; terminateOnMatch?: boolean } = {}
|
|
): Promise<void> {
|
|
const execStreamingResult = await this.execStreaming(commandString, silent);
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
let matched = false;
|
|
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
|
|
// Set up timeout if specified
|
|
if (options.timeout) {
|
|
timeoutHandle = setTimeout(async () => {
|
|
if (!matched) {
|
|
matched = true;
|
|
// Remove listener to prevent memory leak
|
|
execStreamingResult.childProcess.stdout.removeAllListeners('data');
|
|
await execStreamingResult.terminate();
|
|
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
|
|
}
|
|
}, options.timeout);
|
|
}
|
|
|
|
const dataHandler = async (chunk: Buffer | string) => {
|
|
const data = typeof chunk === 'string' ? chunk : chunk.toString();
|
|
if (!matched && regex.test(data)) {
|
|
matched = true;
|
|
|
|
// Clear timeout if set
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
|
|
// Remove listener to prevent memory leak
|
|
execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
|
|
|
|
// Terminate process if requested
|
|
if (options.terminateOnMatch) {
|
|
await execStreamingResult.terminate();
|
|
await execStreamingResult.finalPromise;
|
|
}
|
|
|
|
resolve();
|
|
}
|
|
};
|
|
|
|
execStreamingResult.childProcess.stdout.on('data', dataHandler);
|
|
|
|
// Also resolve/reject when process ends
|
|
execStreamingResult.finalPromise.then(() => {
|
|
if (!matched) {
|
|
matched = true;
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
reject(new Error('Process ended without matching pattern'));
|
|
}
|
|
}).catch((err) => {
|
|
if (!matched) {
|
|
matched = true;
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
}
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
|
|
return this.execAndWaitForLine(commandString, regex, true, options);
|
|
}
|
|
|
|
private nodePty: any = null;
|
|
|
|
private async lazyLoadNodePty(): Promise<any> {
|
|
if (this.nodePty) {
|
|
return this.nodePty;
|
|
}
|
|
|
|
try {
|
|
// Try to load node-pty if available
|
|
// @ts-ignore - node-pty is optional
|
|
this.nodePty = await import('node-pty');
|
|
return this.nodePty;
|
|
} catch (error) {
|
|
throw new Error(
|
|
'node-pty is required for PTY support but is not installed.\n' +
|
|
'Please install it as an optional dependency:\n' +
|
|
' pnpm add --save-optional node-pty\n' +
|
|
'Note: node-pty requires compilation and may have platform-specific requirements.'
|
|
);
|
|
}
|
|
}
|
|
|
|
private async _execCommandPty(
|
|
options: IExecOptions,
|
|
commandToExecute: string,
|
|
shellLogInstance: ShellLog
|
|
): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive> {
|
|
const pty = await this.lazyLoadNodePty();
|
|
|
|
// Platform-aware shell selection
|
|
let shell: string;
|
|
let shellArgs: string[];
|
|
|
|
if (options.ptyShell) {
|
|
// User-provided shell override
|
|
shell = options.ptyShell;
|
|
shellArgs = ['-c', commandToExecute];
|
|
} else if (process.platform === 'win32') {
|
|
// Windows: Use PowerShell by default, or cmd as fallback
|
|
const powershell = process.env.PROGRAMFILES
|
|
? `${process.env.PROGRAMFILES}\\PowerShell\\7\\pwsh.exe`
|
|
: 'powershell.exe';
|
|
|
|
// Check if PowerShell Core exists, otherwise use Windows PowerShell
|
|
const fs = await import('fs');
|
|
if (fs.existsSync(powershell)) {
|
|
shell = powershell;
|
|
shellArgs = ['-NoProfile', '-NonInteractive', '-Command', commandToExecute];
|
|
} else if (process.env.COMSPEC) {
|
|
shell = process.env.COMSPEC;
|
|
shellArgs = ['/d', '/s', '/c', commandToExecute];
|
|
} else {
|
|
shell = 'cmd.exe';
|
|
shellArgs = ['/d', '/s', '/c', commandToExecute];
|
|
}
|
|
} else {
|
|
// POSIX: Use SHELL env var or bash as default
|
|
shell = process.env.SHELL || '/bin/bash';
|
|
shellArgs = ['-c', commandToExecute];
|
|
}
|
|
|
|
// Create PTY process
|
|
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
name: options.ptyTerm || 'xterm-256color',
|
|
cols: options.ptyCols || 120,
|
|
rows: options.ptyRows || 30,
|
|
cwd: process.cwd(),
|
|
env: options.env || process.env,
|
|
});
|
|
|
|
// Add to smartexit (wrap in a minimal object with pid)
|
|
this.smartexit.addProcess({ pid: ptyProcess.pid } as any);
|
|
|
|
// Handle output (stdout and stderr are combined in PTY)
|
|
ptyProcess.onData((data: string) => {
|
|
if (!options.silent) {
|
|
shellLogInstance.writeToConsole(data);
|
|
}
|
|
shellLogInstance.addToBuffer(data);
|
|
});
|
|
|
|
// Wrap PTY termination into a Promise
|
|
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
|
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
|
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
|
|
|
|
const execResult: IExecResult = {
|
|
exitCode: exitCode ?? (signal ? 1 : 0),
|
|
stdout: shellLogInstance.logStore.toString(),
|
|
};
|
|
|
|
if (options.strict && exitCode !== 0) {
|
|
reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
|
|
} else {
|
|
resolve(execResult);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Create input methods for PTY
|
|
const sendInput = async (input: string): Promise<void> => {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
ptyProcess.write(input);
|
|
resolve();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
};
|
|
|
|
const sendLine = async (line: string): Promise<void> => {
|
|
// Use \r for PTY (carriage return is typical for terminal line discipline)
|
|
return sendInput(line + '\r');
|
|
};
|
|
|
|
const endInput = (): void => {
|
|
// Send EOF (Ctrl+D) to PTY
|
|
ptyProcess.write('\x04');
|
|
};
|
|
|
|
// If interactive control is enabled but not streaming, return interactive interface
|
|
if (options.interactiveControl && !options.streaming) {
|
|
return {
|
|
exitCode: 0, // Will be updated when process ends
|
|
stdout: '', // Will be updated when process ends
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
finalPromise: childProcessEnded,
|
|
} as IExecResultInteractive;
|
|
}
|
|
|
|
// If streaming mode is enabled, return a streaming interface
|
|
if (options.streaming) {
|
|
return {
|
|
childProcess: { pid: ptyProcess.pid } as any, // Minimal compatibility object
|
|
finalPromise: childProcessEnded,
|
|
sendInput,
|
|
sendLine,
|
|
endInput,
|
|
kill: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Killing PTY process ${ptyProcess.pid}`);
|
|
}
|
|
ptyProcess.kill();
|
|
},
|
|
terminate: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Terminating PTY process ${ptyProcess.pid}`);
|
|
}
|
|
ptyProcess.kill('SIGTERM');
|
|
},
|
|
keyboardInterrupt: async () => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Sending SIGINT to PTY process ${ptyProcess.pid}`);
|
|
}
|
|
ptyProcess.kill('SIGINT');
|
|
},
|
|
customSignal: async (signal: plugins.smartexit.TProcessSignal) => {
|
|
if (options.debug) {
|
|
console.log(`[smartshell] Sending ${signal} to PTY process ${ptyProcess.pid}`);
|
|
}
|
|
ptyProcess.kill(signal as any);
|
|
},
|
|
} as IExecResultStreaming;
|
|
}
|
|
|
|
// For non-streaming mode, wait for the process to complete
|
|
return await childProcessEnded;
|
|
}
|
|
}
|