feat(smartshell): Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartshell',
|
||||
version: '3.2.4',
|
||||
version: '3.3.0',
|
||||
description: 'A library for executing shell commands using promises.'
|
||||
}
|
||||
|
@@ -8,6 +8,15 @@ import * as cp from 'child_process';
|
||||
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 {
|
||||
@@ -17,6 +26,9 @@ export interface IExecResultStreaming {
|
||||
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 {
|
||||
@@ -26,6 +38,23 @@ interface IExecOptions {
|
||||
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 {
|
||||
@@ -73,92 +102,441 @@ export class Smartshell {
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a command and returns either a non-streaming result or a streaming interface.
|
||||
* Executes a command with args array (shell:false) for security
|
||||
*/
|
||||
private async _execCommand(options: IExecOptions): Promise<IExecResult | IExecResultStreaming> {
|
||||
const commandToExecute = this.shellEnv.createEnvExecString(options.commandString);
|
||||
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(commandToExecute, [], {
|
||||
shell: true,
|
||||
const execChildProcess = cp.spawn(options.command, options.args || [], {
|
||||
shell: false, // SECURITY: Never use shell with untrusted input
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
env: options.env || process.env,
|
||||
detached: false,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
this.smartexit.addProcess(execChildProcess);
|
||||
|
||||
// Connect stdin if passthrough is enabled
|
||||
if (options.passthrough && execChildProcess.stdin) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Capture stdout and stderr output.
|
||||
// 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);
|
||||
}
|
||||
shellLogInstance.addToBuffer(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);
|
||||
}
|
||||
shellLogInstance.addToBuffer(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.
|
||||
// Wrap child process termination into a Promise
|
||||
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
|
||||
execChildProcess.on('exit', (code, signal) => {
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
this.smartexit.removeProcess(execChildProcess);
|
||||
|
||||
// Unpipe stdin when process ends if passthrough was enabled
|
||||
if (options.passthrough) {
|
||||
process.stdin.unpipe(execChildProcess.stdin);
|
||||
// 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: typeof code === 'number' ? code : (signal ? 1 : 0),
|
||||
exitCode,
|
||||
stdout: shellLogInstance.logStore.toString(),
|
||||
signal: signal || undefined,
|
||||
stderr: stderrBuffer,
|
||||
};
|
||||
|
||||
if (options.strict && code !== 0) {
|
||||
reject(new Error(`Command "${options.commandString}" exited with code ${code}`));
|
||||
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.on('error', (error) => {
|
||||
execChildProcess.once('exit', handleExit);
|
||||
execChildProcess.once('error', (error) => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
this.smartexit.removeProcess(execChildProcess);
|
||||
// Unpipe stdin when process errors if passthrough was enabled
|
||||
if (options.passthrough && execChildProcess.stdin) {
|
||||
process.stdin.unpipe(execChildProcess.stdin);
|
||||
|
||||
// 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 streaming mode is enabled, return a streaming interface immediately.
|
||||
// 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 () => {
|
||||
console.log(`Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
|
||||
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 () => {
|
||||
console.log(`Running tree kill with SIGTERM on process ${execChildProcess.pid}`);
|
||||
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 () => {
|
||||
console.log(`Running tree kill with SIGINT on process ${execChildProcess.pid}`);
|
||||
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) => {
|
||||
console.log(`Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`);
|
||||
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;
|
||||
@@ -169,7 +547,9 @@ export class Smartshell {
|
||||
}
|
||||
|
||||
public async exec(commandString: string): Promise<IExecResult> {
|
||||
return (await this._exec({ commandString })) as 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> {
|
||||
@@ -204,23 +584,290 @@ export class Smartshell {
|
||||
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
|
||||
silent: boolean = false,
|
||||
options: { timeout?: number; terminateOnMatch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const execStreamingResult = await this.execStreaming(commandString, silent);
|
||||
return new Promise<void>((resolve) => {
|
||||
execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => {
|
||||
|
||||
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 (regex.test(data)) {
|
||||
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): Promise<void> {
|
||||
return this.execAndWaitForLine(commandString, regex, true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user