feat(smartshell): add cwd-aware execution options, structured strict-mode errors, and safer process tree termination

This commit is contained in:
2026-05-09 13:48:16 +00:00
parent e61b352576
commit d65e1ed4f6
17 changed files with 3830 additions and 4812 deletions
+168 -72
View File
@@ -8,10 +8,37 @@ import * as cp from 'child_process';
export interface IExecResult {
exitCode: number;
stdout: string;
/** stdout currently preserves smartshell's legacy combined stdout/stderr buffer. */
combinedOutput?: string;
signal?: NodeJS.Signals;
stderr?: string;
}
export class SmartshellError extends Error {
public command: string;
public result: IExecResult;
public exitCode: number;
public stdout: string;
public combinedOutput?: string;
public stderr?: string;
public signal?: NodeJS.Signals;
constructor(command: string, result: IExecResult) {
const reason = result.signal
? `terminated by signal ${result.signal}`
: `exited with code ${result.exitCode}`;
super(`Command "${command}" ${reason}`);
this.name = 'SmartshellError';
this.command = command;
this.result = result;
this.exitCode = result.exitCode;
this.stdout = result.stdout;
this.combinedOutput = result.combinedOutput;
this.stderr = result.stderr;
this.signal = result.signal;
}
}
export interface IExecResultInteractive extends IExecResult {
sendInput: (input: string) => Promise<void>;
sendLine: (line: string) => Promise<void>;
@@ -31,15 +58,7 @@ export interface IExecResultStreaming {
endInput: () => void;
}
interface IExecOptions {
commandString: string;
silent?: boolean;
strict?: boolean;
streaming?: boolean;
interactive?: boolean;
passthrough?: boolean;
interactiveControl?: boolean;
usePty?: boolean;
export interface IExecRuntimeOptions {
ptyCols?: number;
ptyRows?: number;
ptyTerm?: string;
@@ -49,14 +68,35 @@ interface IExecOptions {
timeout?: number;
debug?: boolean;
env?: NodeJS.ProcessEnv;
cwd?: string;
signal?: AbortSignal;
}
export interface ISpawnOptions extends Omit<IExecOptions, 'commandString'> {
interface IExecOptions extends IExecRuntimeOptions {
commandString: string;
silent?: boolean;
strict?: boolean;
streaming?: boolean;
interactive?: boolean;
passthrough?: boolean;
interactiveControl?: boolean;
usePty?: boolean;
}
export interface ISpawnOptions extends IExecRuntimeOptions {
command: string;
args?: string[];
silent?: boolean;
strict?: boolean;
streaming?: boolean;
interactive?: boolean;
passthrough?: boolean;
interactiveControl?: boolean;
usePty?: boolean;
}
export type TExecCommandOptions = IExecRuntimeOptions;
export class Smartshell {
public shellEnv: ShellEnv;
public smartexit = new plugins.smartexit.SmartExit();
@@ -68,9 +108,13 @@ export class Smartshell {
/**
* Executes a given command asynchronously.
*/
private async _exec(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | void> {
private async _exec(options: IExecOptions): Promise<IExecResult | IExecResultStreaming | IExecResultInteractive | void> {
if (options.interactive) {
return await this._execInteractive({ commandString: options.commandString });
return await this._execInteractive({
commandString: options.commandString,
cwd: options.cwd,
env: options.env,
});
}
return await this._execCommand(options);
}
@@ -78,7 +122,7 @@ export class Smartshell {
/**
* Executes an interactive command.
*/
private async _execInteractive(options: Pick<IExecOptions, 'commandString'>): Promise<void> {
private async _execInteractive(options: Pick<IExecOptions, 'commandString' | 'cwd' | 'env'>): Promise<void> {
// Skip interactive execution in CI environments.
if (process.env.CI) {
return;
@@ -89,6 +133,8 @@ export class Smartshell {
stdio: 'inherit',
shell: true,
detached: true,
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
});
this.smartexit.addProcess(shell);
@@ -117,7 +163,7 @@ export class Smartshell {
const execChildProcess = cp.spawn(options.command, options.args || [], {
shell: false, // SECURITY: Never use shell with untrusted input
cwd: process.cwd(),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
signal: options.signal,
@@ -132,7 +178,7 @@ export class Smartshell {
if (options.debug) {
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
}
execChildProcess.kill('SIGTERM');
void this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
}, options.timeout);
}
@@ -232,18 +278,17 @@ export class Smartshell {
}
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
const combinedOutput = shellLogInstance.logStore.toString();
const execResult: IExecResult = {
exitCode,
stdout: shellLogInstance.logStore.toString(),
stdout: combinedOutput,
combinedOutput,
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));
reject(new SmartshellError(options.command, execResult));
} else {
resolve(execResult);
}
@@ -296,25 +341,25 @@ export class Smartshell {
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
await this.killProcessTree(execChildProcess.pid, 'SIGKILL', options.debug);
},
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');
await this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
},
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');
await this.killProcessTree(execChildProcess.pid, 'SIGINT', options.debug);
},
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);
await this.killProcessTree(execChildProcess.pid, signal, options.debug);
},
} as IExecResultStreaming;
}
@@ -344,7 +389,7 @@ export class Smartshell {
const shellBinary = this.shellEnv.executor === 'bash' ? '/bin/bash' : true;
const execChildProcess = cp.spawn(commandToExecute, [], {
shell: shellBinary,
cwd: process.cwd(),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit
signal: options.signal,
@@ -359,7 +404,7 @@ export class Smartshell {
if (options.debug) {
console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`);
}
execChildProcess.kill('SIGTERM');
void this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
}, options.timeout);
}
@@ -459,18 +504,17 @@ export class Smartshell {
}
const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0);
const combinedOutput = shellLogInstance.logStore.toString();
const execResult: IExecResult = {
exitCode,
stdout: shellLogInstance.logStore.toString(),
stdout: combinedOutput,
combinedOutput,
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));
reject(new SmartshellError(options.commandString, execResult));
} else {
resolve(execResult);
}
@@ -523,25 +567,25 @@ export class Smartshell {
if (options.debug) {
console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`);
}
await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL');
await this.killProcessTree(execChildProcess.pid, 'SIGKILL', options.debug);
},
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');
await this.killProcessTree(execChildProcess.pid, 'SIGTERM', options.debug);
},
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');
await this.killProcessTree(execChildProcess.pid, 'SIGINT', options.debug);
},
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);
await this.killProcessTree(execChildProcess.pid, signal, options.debug);
},
} as IExecResultStreaming;
}
@@ -550,58 +594,87 @@ export class Smartshell {
return await childProcessEnded;
}
public async exec(commandString: string): Promise<IExecResult> {
const result = await this._exec({ commandString });
private async killProcessTree(
pid: number | undefined,
signal: plugins.smartexit.TProcessSignal,
debug?: boolean,
): Promise<void> {
if (!pid) {
return;
}
if (debug) {
console.log(`[smartshell] Killing process tree ${pid} with ${signal}`);
}
try {
await plugins.smartexit.SmartExit.killTreeByPid(pid, signal);
} catch (error) {
if (debug) {
console.log(`[smartshell] Tree kill failed for ${pid}: ${error}`);
}
try {
process.kill(pid, signal as NodeJS.Signals);
} catch {
// Process already exited or is not accessible.
}
}
}
public async exec(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
const result = await this._exec({ commandString, ...options });
// 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 execSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
return (await this._exec({ commandString, ...options, silent: true })) as IExecResult;
}
public async execStrict(commandString: string): Promise<IExecResult> {
return (await this._exec({ commandString, strict: true })) as IExecResult;
public async execStrict(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
return (await this._exec({ commandString, ...options, strict: true })) as IExecResult;
}
public async execStrictSilent(commandString: string): Promise<IExecResult> {
return (await this._exec({ commandString, silent: true, strict: true })) as IExecResult;
public async execStrictSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
return (await this._exec({ commandString, ...options, 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 execStreaming(
commandString: string,
silent: boolean = false,
options: TExecCommandOptions = {},
): Promise<IExecResultStreaming> {
return (await this._exec({ commandString, ...options, 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 execStreamingSilent(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
return (await this._exec({ commandString, ...options, silent: true, streaming: true })) as IExecResultStreaming;
}
public async execInteractive(commandString: string): Promise<void> {
await this._exec({ commandString, interactive: true });
public async execInteractive(commandString: string, options: TExecCommandOptions = {}): Promise<void> {
await this._exec({ commandString, ...options, interactive: true });
}
public async execPassthrough(commandString: string): Promise<IExecResult> {
return await this._exec({ commandString, passthrough: true }) as IExecResult;
public async execPassthrough(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResult> {
return await this._exec({ commandString, ...options, passthrough: true }) as IExecResult;
}
public async execStreamingPassthrough(commandString: string): Promise<IExecResultStreaming> {
return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming;
public async execStreamingPassthrough(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
return await this._exec({ commandString, ...options, streaming: true, passthrough: true }) as IExecResultStreaming;
}
public async execInteractiveControl(commandString: string): Promise<IExecResultInteractive> {
return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive;
public async execInteractiveControl(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultInteractive> {
return await this._exec({ commandString, ...options, interactiveControl: true }) as IExecResultInteractive;
}
public async execStreamingInteractiveControl(commandString: string): Promise<IExecResultStreaming> {
return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming;
public async execStreamingInteractiveControl(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
return await this._exec({ commandString, ...options, 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 execInteractiveControlPty(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultInteractive> {
return await this._exec({ commandString, ...options, 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;
public async execStreamingInteractiveControlPty(commandString: string, options: TExecCommandOptions = {}): Promise<IExecResultStreaming> {
return await this._exec({ commandString, ...options, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming;
}
/**
@@ -618,23 +691,31 @@ export class Smartshell {
* 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;
return await this._execSpawn({ command, args, ...options, streaming: true }) 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;
return await this._execSpawn({ command, args, ...options, interactiveControl: true }) as IExecResultInteractive;
}
public async execAndWaitForLine(
commandString: string,
regex: RegExp,
silent: boolean = false,
options: { timeout?: number; terminateOnMatch?: boolean } = {}
options: { timeout?: number; terminateOnMatch?: boolean; cwd?: string; env?: NodeJS.ProcessEnv } = {}
): Promise<void> {
const execStreamingResult = await this.execStreaming(commandString, silent);
const execStreamingResult = await this.execStreaming(commandString, silent, {
cwd: options.cwd,
env: options.env,
});
const stdout = execStreamingResult.childProcess.stdout;
if (!stdout) {
await execStreamingResult.terminate();
throw new Error('stdout is not available for this process');
}
return new Promise<void>((resolve, reject) => {
let matched = false;
@@ -646,7 +727,7 @@ export class Smartshell {
if (!matched) {
matched = true;
// Remove listener to prevent memory leak
execStreamingResult.childProcess.stdout.removeAllListeners('data');
stdout.removeAllListeners('data');
await execStreamingResult.terminate();
reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`));
}
@@ -664,7 +745,7 @@ export class Smartshell {
}
// Remove listener to prevent memory leak
execStreamingResult.childProcess.stdout.removeListener('data', dataHandler);
stdout.removeListener('data', dataHandler);
// Terminate process if requested
if (options.terminateOnMatch) {
@@ -676,7 +757,7 @@ export class Smartshell {
}
};
execStreamingResult.childProcess.stdout.on('data', dataHandler);
stdout.on('data', dataHandler);
// Also resolve/reject when process ends
execStreamingResult.finalPromise.then(() => {
@@ -699,7 +780,7 @@ export class Smartshell {
});
}
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise<void> {
public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean; cwd?: string; env?: NodeJS.ProcessEnv }): Promise<void> {
return this.execAndWaitForLine(commandString, regex, true, options);
}
@@ -769,7 +850,7 @@ export class Smartshell {
name: options.ptyTerm || 'xterm-256color',
cols: options.ptyCols || 120,
rows: options.ptyRows || 30,
cwd: process.cwd(),
cwd: options.cwd || process.cwd(),
env: options.env || process.env,
});
@@ -784,18 +865,33 @@ export class Smartshell {
shellLogInstance.addToBuffer(data);
});
let timeoutHandle: NodeJS.Timeout | null = null;
if (options.timeout) {
timeoutHandle = setTimeout(() => {
if (options.debug) {
console.log(`[smartshell] Timeout reached for PTY process ${ptyProcess.pid}, terminating...`);
}
ptyProcess.kill('SIGTERM');
}, options.timeout);
}
// Wrap PTY termination into a Promise
const childProcessEnded: Promise<IExecResult> = new Promise((resolve, reject) => {
ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
this.smartexit.removeProcess({ pid: ptyProcess.pid } as any);
const combinedOutput = shellLogInstance.logStore.toString();
const execResult: IExecResult = {
exitCode: exitCode ?? (signal ? 1 : 0),
stdout: shellLogInstance.logStore.toString(),
stdout: combinedOutput,
combinedOutput,
};
if (options.strict && exitCode !== 0) {
reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`));
reject(new SmartshellError(options.commandString, execResult));
} else {
resolve(execResult);
}