@nu-art/commando
Version:
Shell command execution framework with interactive sessions, CLI parameter resolution, and plugin system for building and executing shell scripts programmatically
276 lines (275 loc) • 10.2 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 { generateHex } from '@nu-art/ts-common';
import { InteractiveShell } from './InteractiveShell.js';
import { BaseCommando } from '../core/BaseCommando.js';
/**
* Interactive shell command executor extending BaseCommando.
*
* Maintains a persistent bash session and executes commands in sequence,
* allowing state to persist between commands. Provides advanced features:
* - Log processing and filtering
* - Exit code extraction
* - Background process management
* - PID tracking for subprocesses
*
* **Key Differences from Commando**:
* - Uses InteractiveShell (persistent session) vs SimpleShell (one-shot)
* - Commands execute in the same shell context (variables persist)
* - Supports log processors for reactive command execution
* - Can run background processes and track their PIDs
*
* **Exit Code Extraction**:
* Uses a unique key pattern (`echo ${key}=$?`) to extract exit codes
* from both stdout and stderr, ensuring accurate exit code detection.
*/
export class CommandoInteractive extends BaseCommando {
/** Interactive shell instance managing the persistent session */
shell;
/**
* Creates a CommandoInteractive instance with plugins.
*
* Initializes the InteractiveShell after merging plugins.
*
* @template T - Array of plugin constructor types
* @param plugins - Plugin classes to merge
* @returns Merged CommandoInteractive instance with plugins
*/
static create(...plugins) {
const _commando = BaseCommando._create(CommandoInteractive, ...plugins);
const commando = _commando;
commando.shell = new InteractiveShell();
return commando;
}
/**
* Constructs a CommandoInteractive instance.
*/
constructor() {
super();
this.shell = new InteractiveShell();
}
/**
* 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.shell.debug(debug);
return super.debug(debug);
}
/**
* Sets the UID for the shell.
* @param {string} uid - The UID to set.
* @returns {this} - The CommandoInteractive instance for method chaining.
*/
setUID(uid) {
this.shell.setUID(uid);
return this;
}
/**
* Closes the shell, optionally with a callback function.
* @returns {this} - The CommandoInteractive instance for method chaining.
*/
close() {
return this.shell.endInteractive();
}
/**
* Kills the shell process with a given signal.
* @param {NodeJS.Signals | number} [signal] - The signal to send to the process.
* @returns {boolean} - Whether the kill signal was successfully sent.
*/
kill(signal) {
return this.shell.kill(signal);
}
/**
* Gracefully kills a process by its PID.
* @param {number} [pid] - The PID of the process to kill.
* @param signal
*/
async killSubprocess(pid, signal) {
return this.shell.killSubprocess(pid, signal);
}
/**
* Waits for a log entry that matches a specified pattern, then executes a callback.
* @param {string | RegExp} filter - The pattern to match in log entries.
* @param {(match: RegExpMatchArray) => any} callback - The callback to execute when a match is found.
*/
onLog(filter, callback) {
const regexp = typeof filter === 'string' ? new RegExp(filter) : filter;
const logFilter = (log) => {
const match = log.match(regexp);
if (!match)
return true;
callback(match);
return true;
};
this.addLogProcessor(logFilter);
return this;
}
/**
* Executes accumulated commands and extracts exit code from shell output.
*
* **Exit Code Extraction Strategy**:
* - Appends `echo ${uniqueKey}=$?` to both stdout and stderr
* - Uses log processors to detect the unique key pattern
* - Waits for both outputs (stdout and stderr) to capture exit code
* - Falls back to shell close event if pattern not detected
*
* **Log Processing**:
* - Adds temporary log processors to capture stdout/stderr
* - Accumulates all output until exit code is detected
* - Removes processors after execution (in finally block)
*
* **Behavior**:
* - Resolves when exit code is detected or shell closes
* - Calls callback with accumulated stdout, stderr, and exit code
* - Always cleans up log processors (even on error)
*
* @template T - Return type of callback
* @param callback - Function to process command output
* @returns Promise resolving to callback result
*/
async execute(callback) {
let logProcessor;
let stdLogProcessor;
let onCloseListener;
try {
return await new Promise((resolve, reject) => {
const uniqueKey = generateHex(16);
const regexp = new RegExp(`${uniqueKey}=(\\d+)`);
let _stderr = '';
let _stdout = '';
stdLogProcessor = (log, type) => {
if (type === 'err')
_stderr += `${log}\n`;
else
_stdout += `${log}\n`;
return true;
};
let uidCounter = 0;
let exitCode = -1;
logProcessor = (log, type) => {
if (!log.includes(uniqueKey))
return true;
const match = log.match(regexp);
if (!match)
return false;
uidCounter++;
if (type === 'out')
exitCode = +(match[1]);
if (uidCounter !== 2)
return false;
try {
resolve(callback?.(_stdout, _stderr, +exitCode));
}
catch (err) {
reject(err);
}
return false;
};
onCloseListener = (exitCode) => {
resolve(callback?.(_stdout, _stderr, exitCode));
};
this.builder.append(`echo ${uniqueKey}=$? && echo ${uniqueKey}=$? 1>&2`);
const command = this.builder.reset();
this.shell.addLogProcessor(logProcessor, 0);
this.shell.addLogProcessor(stdLogProcessor);
this.shell.addOnCloseListener(onCloseListener);
this.shell.execute(command);
});
}
finally {
this.shell.removeLogProcessor(logProcessor);
this.shell.removeLogProcessor(stdLogProcessor);
this.shell.removeOnCloseListener(onCloseListener);
}
}
/**
* Adds a log processor to the shell.
* @param {Function} processor - The log processor function to add.
* @param index
* @returns {this} - The CommandoInteractive instance for method chaining.
*/
addLogProcessor(processor, index) {
this.shell.addLogProcessor(processor, index);
return this;
}
setLogLevelFilter(processor) {
this.shell.setLogLevelFilter(processor);
return this;
}
/**
* Removes a log processor from the shell.
* @param {Function} processor - The log processor function to remove.
* @returns {this} - The CommandoInteractive instance for method chaining.
*/
removeLogProcessor(processor) {
this.shell.removeLogProcessor(processor);
return this;
}
/**
* Appends a command to the command list.
* @param {string} command - The command to append.
* @returns {this} - The CommandoInteractive instance for method chaining.
*/
append(command) {
this.builder.append(command);
return this;
}
mark() {
this.builder.setMark();
return this;
}
/**
* Appends a command to run in the background and tracks its PID.
*
* **Behavior**:
* - Runs command with `&` (background)
* - Captures PID using `pid=$!` and echoes it with unique key
* - Waits for the process to complete
* - Calls pidListener when PID is detected
*
* **Use Case**: Running long-running processes while continuing
* to execute other commands in the same shell session.
*
* @param command - Command to run in background
* @param pidListener - Optional callback when PID is detected
* @returns This instance for method chaining
*/
appendAsync(command, pidListener) {
const pidUniqueKey = generateHex(16);
const regexp = new RegExp(`${pidUniqueKey}=(\\d+)`);
const pidLogProcessor = async (log) => {
const match = log.match(regexp);
if (!match)
return true;
const pid = +match[1];
await pidListener?.(pid);
return false;
};
this.append(`${command} &`)
.append('pid=$!')
.append(`echo "${pidUniqueKey}=\${pid}"`)
.append(`wait \$pid`)
.addLogProcessor(pidLogProcessor, 0);
return this;
}
getCommand() {
return this.builder.getCommand();
}
}