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; } export interface IExecResultStreaming { childProcess: cp.ChildProcess; finalPromise: Promise; kill: () => Promise; terminate: () => Promise; keyboardInterrupt: () => Promise; customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise; } interface IExecOptions { commandString: string; silent?: boolean; strict?: boolean; streaming?: boolean; interactive?: boolean; } 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 { if (options.interactive) { return await this._execInteractive({ commandString: options.commandString }); } return await this._execCommand(options); } /** * Executes an interactive command. */ private async _execInteractive(options: Pick): Promise { // Skip interactive execution in CI environments. if (process.env.CI) { return; } return new Promise((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 and returns either a non-streaming result or a streaming interface. */ private async _execCommand(options: IExecOptions): Promise { const commandToExecute = this.shellEnv.createEnvExecString(options.commandString); const shellLogInstance = new ShellLog(); const execChildProcess = cp.spawn(commandToExecute, [], { shell: true, cwd: process.cwd(), env: process.env, detached: false, }); this.smartexit.addProcess(execChildProcess); // Capture stdout and stderr output. execChildProcess.stdout.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } shellLogInstance.addToBuffer(data); }); execChildProcess.stderr.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } shellLogInstance.addToBuffer(data); }); // Wrap child process termination into a Promise. const childProcessEnded: Promise = new Promise((resolve, reject) => { execChildProcess.on('exit', (code, signal) => { this.smartexit.removeProcess(execChildProcess); const execResult: IExecResult = { exitCode: typeof code === 'number' ? code : (signal ? 1 : 0), stdout: shellLogInstance.logStore.toString(), }; if (options.strict && code !== 0) { reject(new Error(`Command "${options.commandString}" exited with code ${code}`)); } else { resolve(execResult); } }); execChildProcess.on('error', (error) => { this.smartexit.removeProcess(execChildProcess); reject(error); }); }); // If streaming mode is enabled, return a streaming interface immediately. if (options.streaming) { return { childProcess: execChildProcess, finalPromise: childProcessEnded, kill: async () => { console.log(`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}`); await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM'); }, keyboardInterrupt: async () => { console.log(`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}`); 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 { return (await this._exec({ commandString })) as IExecResult; } public async execSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true })) as IExecResult; } public async execStrict(commandString: string): Promise { return (await this._exec({ commandString, strict: true })) as IExecResult; } public async execStrictSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true, strict: true })) as IExecResult; } public async execStreaming(commandString: string, silent: boolean = false): Promise { return (await this._exec({ commandString, silent, streaming: true })) as IExecResultStreaming; } public async execStreamingSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true, streaming: true })) as IExecResultStreaming; } public async execInteractive(commandString: string): Promise { await this._exec({ commandString, interactive: true }); } public async execAndWaitForLine( commandString: string, regex: RegExp, silent: boolean = false ): Promise { const execStreamingResult = await this.execStreaming(commandString, silent); return new Promise((resolve) => { execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => { const data = typeof chunk === 'string' ? chunk : chunk.toString(); if (regex.test(data)) { resolve(); } }); }); } public async execAndWaitForLineSilent(commandString: string, regex: RegExp): Promise { return this.execAndWaitForLine(commandString, regex, true); } }