@nu-art/commando
Version:
Shell command execution framework with interactive sessions, CLI parameter resolution, and plugin system for building and executing shell scripts programmatically
239 lines (238 loc) • 8.97 kB
JavaScript
/*
* commando provides shell command execution framework with interactive sessions and plugin system
*
* Copyright (C) 2020 Adam van der Kruk aka TacB0sS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { addItemToArrayAtIndex, currentTimeMillis, generateHex, Logger, LogLevel, removeItemFromArray } from '@nu-art/ts-common';
import { spawn } from 'node:child_process';
/**
* Default log level filter: Error for stderr, Info for stdout.
*/
const defaultLogLevelFilter = (log, std) => std === 'err' ? LogLevel.Error : LogLevel.Info;
/**
* Interactive shell session manager using Node.js child_process spawn.
*
* Maintains a persistent bash session and provides:
* - Command execution in the same shell context
* - Log processing pipeline (multiple processors)
* - Subprocess management (PID tracking, killing)
* - Exit code extraction via echo commands
*
* **Key Features**:
* - Detached process (session leader) for proper signal handling
* - Log processors can consume or pass through messages
* - Automatic log level filtering (stderr=Error, stdout=Info)
* - PID tracking for background processes
* - Graceful shutdown with timeout
*
* **Process Management**:
* - Spawns `/bin/bash` as detached process
* - Removes NODE_OPTIONS to prevent debug flags from propagating
* - Tracks process lifecycle (alive state)
* - Supports killing main shell or subprocesses independently
*/
export class InteractiveShell extends Logger {
_debug = false;
logProcessors = [];
onCloseListeners = [];
shell;
alive;
logLevelFilter = defaultLogLevelFilter;
/**
* Constructs an InteractiveShell instance, initializes a detached shell session, and sets up log processors.
*/
constructor() {
super();
this.setTag(`${this.constructor['name']}(${generateHex(4)})`);
this.shell = spawn('/bin/bash', {
detached: true, // This is important to make the process a session leader
shell: true,
env: {
...process.env,
NODE_OPTIONS: '' // Remove any --inspect flags
}
});
this.alive = true;
const printer = (std) => async (data) => {
const messages = data.toString().trim().split('\n');
if (!messages.length)
return;
for (const message of messages) {
try {
let consumed = false;
for (const processor of this.logProcessors) {
const result = await processor(message, std);
if (!result) {
consumed = true;
break;
}
}
if (!consumed) {
const logLevel = this.logLevelFilter(message, std) ?? defaultLogLevelFilter(message, std);
this.log(logLevel, false, [message]);
}
}
catch (e) {
this.logError(e);
}
}
};
const stdoutPrinter = printer('out');
const stderrPrinter = printer('err');
this.shell.stdout?.on('data', stdoutPrinter);
this.shell.stderr?.on('data', stderrPrinter);
// Handle shell exit
this.shell.on('close', async (code) => {
this.alive = false;
code = code === null ? -1 : code;
this.onCloseListeners.forEach(listener => listener(code === null ? -1 : code));
const printer = code === 0 ? stdoutPrinter : stderrPrinter;
const string = `Process exited with code: ${code}`;
printer(Buffer.from(string));
});
}
/**
* Toggles or sets the debug mode.
* @param {boolean} [debug] - If provided, sets the debug mode to this value. Otherwise, toggles the current debug mode.
* @returns {boolean} - The current state of debug mode.
*/
debug(debug) {
this._debug = debug ?? !this._debug;
return this;
}
/**
* Executes a command in the interactive shell.
* @param {string} command - The command to execute.
*/
execute = (command) => {
this.logVerbose(`executing: `, `"""\n${command}\n"""`);
this.shell.stdin?.write(command + '\n', 'utf-8', (err) => {
if (err)
this.logError(`error`, err);
});
};
/**
* Awaits for the end of the interactive shell session.
*/
endInteractive = () => {
return new Promise(resolve => {
this.shell.stdin?.end(resolve);
});
};
// Check if a given PID is alive
isAlive = (pid) => {
try {
process.kill(pid, 0); // Non-intrusive check
return true;
}
catch {
return false;
}
};
// Poll until a PID is no longer alive or timeout is hit
waitForExit = (pid, timeout = 10000, sampleInterval = 100) => {
const startTime = currentTimeMillis();
return new Promise((resolve, reject) => {
const check = () => {
if (!this.isAlive(pid))
return resolve();
if (currentTimeMillis() - startTime > timeout) {
this.logWarning(`PID ${pid} did not exit in time. Sending 'SIGTERM'.`);
try {
process.kill(pid, 'SIGTERM');
}
catch (e) {
this.logError(`Failed to send 'SIGTERM' to PID ${pid}`, e);
}
return reject(new Error(`PID ${pid} did not exit after ${timeout}ms`));
}
setTimeout(check, sampleInterval);
};
check();
});
};
// Kill the main shell process (interactive shell)
kill = async (signal = 'SIGINT', timeout = 10000) => {
if (!this.alive)
return;
this.logWarning(`Sending ${signal} to shell PID: ${this.shell.pid}`);
try {
this.shell.kill(signal);
}
catch (e) {
this.logError(`Failed to send ${signal} to shell`, e);
throw e;
}
await this.waitForExit(this.shell.pid, timeout);
this.logWarning(`Shell process PID ${this.shell.pid} terminated`);
};
// Kill a background subprocess PID without affecting the shell
killSubprocess = async (pid, signal = 'SIGINT', timeout = 10000) => {
if (!this.isAlive(pid)) {
this.logDebug(`Subprocess PID ${pid} already exited`);
return;
}
this.logWarning(`Sending ${signal} to subprocess PID: ${pid}`);
try {
process.kill(pid, signal);
}
catch (e) {
this.logError(`Failed to send ${signal} to subprocess`, e);
throw e;
}
await this.waitForExit(pid, timeout);
this.logWarning(`Subprocess PID ${pid} terminated`);
};
/**
* Adds a log processor to handle log messages.
* @param {(log: string, std: LogTypes) => boolean} processor - The log processor function.
* @param index -
* @returns {this} - The InteractiveShell instance for method chaining.
*/
addLogProcessor(processor, index = this.logProcessors.length) {
addItemToArrayAtIndex(this.logProcessors, processor, index);
return this;
}
setLogLevelFilter(logLevelFilter) {
this.logLevelFilter = logLevelFilter;
return this;
}
addOnCloseListener(listener) {
this.onCloseListeners.push(listener);
return this;
}
removeOnCloseListener(listener) {
removeItemFromArray(this.onCloseListeners, listener);
return this;
}
/**
* Removes a log processor from handling log messages.
* @param {(log: string, std: LogTypes) => boolean} processor - The log processor function to remove.
* @returns {this} - The InteractiveShell instance for method chaining.
*/
removeLogProcessor(processor) {
removeItemFromArray(this.logProcessors, processor);
return this;
}
/**
* Sets a unique identifier for the shell session.
* @param {string} uid - The unique identifier.
* @returns {this} - The InteractiveShell instance for method chaining.
*/
setUID(uid) {
this.setTag(uid);
return this;
}
}