@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
277 lines (255 loc) • 9.45 kB
JavaScript
import { spawnSync, spawn } from 'node:child_process';
/**
* Validates if a number is a finite positive integer (including 0).
*
* @param {unknown} n - Value to check.
* @returns {boolean} True if finite non-negative integer.
*/
const isFinitePosInt = (n) => Number.isFinite(n) && n >= 0;
/**
* Parses a human-readable duration into milliseconds.
*
* Supports: numbers (ms), '250ms', '2s'. null/undefined → 0; invalid → throws.
*
* @param {number|string|null|undefined} d - Duration input.
* @returns {number} ms.
* @throws {Error} Invalid format/type.
*/
const parseDuration = (d) => {
if (typeof d === 'number') return isFinitePosInt(d) ? d : (() => { throw new Error('Invalid duration'); })();
if (typeof d === 'string') {
const m = d.match(/^(\d+)(ms|s)?$/);
if (!m) throw new Error('Invalid duration string');
const n = Number(m[1]);
return m[2] === 's' ? n * 1000 : n;
}
if (d == null) return 0;
throw new Error('Invalid duration type');
};
/**
* Retrieves child PIDs of a given parent PID using pgrep -P.
*
* @param {number} pid - Parent PID.
* @returns {Promise<number[]>} Array of child PIDs.
*/
const childrenOf = (pid) => new Promise((resolve, reject) => {
const p = spawn('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] });
const out = [];
p.stdout.on('data', (chunk) => out.push(chunk));
p.on('close', (code) => {
if (code === 0) {
const ids = Buffer.concat(out).toString('utf8').trim().split(/\s+/).map(Number).filter(Boolean);
resolve(ids);
} else if (code === 1) {
resolve([]); // no children or error
}
});
p.on('error', reject);
});
/**
* @typedef {import('../SH.js').SHOptions & { maxBuffer?: number }} SHExecuteOptions
* @description Extended options for SHExecute: adds `maxBuffer` (bytes per stream, default 1MB).
*/
/**
* Low-level process executor for shell commands.
*
* Used internally by {@link SHDispatch}.
*
* Key features:
* - **Shell mode**: If `options.shell` is string/true, runs `${prefix}; ${command}` via shell ('bash' default).
* - **No-shell mode**: Uses `/usr/bin/env -S ${command}` for direct exec (ignores prefix).
* - **Rolling timeout**: `options.timeout` (ms/'2s'); resets on stdout/stderr data. SIGTERM on expiry.
* - **Buffering**: Captures stdout/stderr up to `maxBuffer` (1MB default); appends truncation markers.
* - **Payload**: `run(payload)` writes string to stdin (forces pipe).
* - **Detached**: If `options.detached`, resolves early (~1s) and unrefs.
* - **Kill**: Terminates process + children via pgrep.
*
* @example
* const exec = new SHExecute('ls', 'set -euo pipefail', { timeout: '5s' });
* const out = await exec.run();
*/
class SHExecute {
#forcedKill = false;
/** @type {import('child_process').ChildProcess | null} */
#proc = null;
#prefix = '';
#command = '';
/** @type {import('child_process').SpawnOptions | import('child_process').SpawnSyncOptions} */
#options = {};
#stdoutChunks = [];
#stderrChunks = [];
#stdoutLen = 0;
#stderrLen = 0;
#maxBuffer = 500 * 1024; // 500KB per stream
#truncated = { stdout: false, stderr: false };
#timedOut = false;
/**
* @param {string} command - Command to execute.
* @param {string} prefix - Shell prelude (e.g., 'set -euo pipefail'); ignored in no-shell.
* @param {SHExecuteOptions} [options] - Spawn options + maxBuffer/timeout.
*/
constructor(command, prefix, options = {}) {
this.#prefix = prefix;
this.#command = command;
const { maxBuffer, ...spawnOpts } = options ?? {};
this.#maxBuffer = Number.isFinite(maxBuffer) && maxBuffer > 0 ? maxBuffer : this.#maxBuffer;
this.#options = spawnOpts;
}
/**
* Synchronous execution.
*
* @param {string} [payload] - Stdin data (forces pipe).
* @returns {import('child_process').SpawnSyncReturns<Buffer>}
* @throws {Error} Invalid payload type.
*/
runSync(payload) {
this.#forcedKill = false;
if (payload && typeof payload !== 'string') {
throw new Error('Argument is not a string');
}
/** @type {import('node:child_process').SpawnSyncOptions} */
const options = { ...this.#options, shell: false };
if (payload) {
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
stdio[0] = 'pipe';
// @ts-ignore
options.stdio = stdio;
options.input = payload;
}
const shellOpt = this.#options?.shell;
if (shellOpt) {
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
return spawnSync(sh, ['-c', cmd], options);
}
// no-shell mode
return spawnSync('/usr/bin/env', ['-S', this.#command], options);
}
/**
* Asynchronous execution with buffering/timeout/kill.
*
* Resolves stdout (trimmed) on success; rejects on error/timeout/kill.
*
* @param {string} [payload] - Stdin data (forces pipe).
* @returns {Promise<string>} Trimmed UTF-8 stdout (+ truncation marker if exceeded).
* @throws {Error} Command failure (incl. code, stderr), timeout, kill, spawn error.
*/
run(payload) {
this.#forcedKill = false;
if (payload && typeof payload !== 'string') {
throw new Error('Argument is not a string');
}
/** @type {import('child_process').SpawnOptions} */
const options = { ...this.#options, shell: false };
if (payload) {
const stdio = Array.isArray(options.stdio) ? [...options.stdio] : ['pipe', 'pipe', 'pipe'];
stdio[0] = 'pipe';
// @ts-ignore
options.stdio = stdio;
}
// reset buffers for each run
this.#stdoutChunks = [];
this.#stderrChunks = [];
this.#stdoutLen = 0;
this.#stderrLen = 0;
this.#truncated = { stdout: false, stderr: false };
this.#timedOut = false;
const shellOpt = this.#options?.shell;
if (shellOpt) {
const sh = typeof shellOpt === 'string' ? shellOpt : 'bash';
const cmd = this.#prefix ? `${this.#prefix}; ${this.#command}` : this.#command;
this.#proc = spawn(sh, ['-c', cmd], options);
} else {
this.#proc = spawn('/usr/bin/env', ['-S', this.#command], options);
}
if (payload) this.#proc.stdin?.end(payload);
const ms = parseDuration(this.#options?.timeout ?? 0);
let timeoutId = null;
const resetTimeout = () => {
if (timeoutId !== null) clearTimeout(timeoutId);
if (ms > 0) {
timeoutId = setTimeout(() => {
this.#timedOut = true;
this.kill('SIGTERM').catch(() => {});
}, ms);
}
};
if (ms > 0) resetTimeout();
this.#proc.stdout?.on('data', (chunk) => {
this.#stdoutLen += chunk.length;
// prevent TOKEN bombs
if (this.#stdoutLen <= this.#maxBuffer) {
this.#stdoutChunks.push(chunk);
} else {
this.#truncated.stdout = true;
}
resetTimeout();
});
this.#proc.stderr?.on('data', (chunk) => {
this.#stderrLen += chunk.length;
if (this.#stderrLen <= this.#maxBuffer) {
this.#stderrChunks.push(chunk);
} else {
this.#truncated.stderr = true;
}
resetTimeout();
});
return new Promise((resolve, reject) => {
if (options.detached) {
setTimeout(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
resolve('');
this.#proc.unref();
}, 1000);
}
this.#proc.on('close', (code, signal) => {
if (timeoutId !== null) clearTimeout(timeoutId);
if (this.#forcedKill) {
reject(new Error('Process killed (forced).'));
return;
}
if (this.#timedOut) {
reject(new Error(`Process timed out after ${ms}ms.`));
return;
}
if (code === 0) {
const stdoutBuf = this.#stdoutChunks.length ? Buffer.concat(this.#stdoutChunks) : Buffer.alloc(0);
let stdout = stdoutBuf.toString('utf8').trim();
if (this.#truncated.stdout) stdout += '\n[stdout truncated]\n';
resolve(stdout);
} else {
const stderrBuf = this.#stderrChunks.length ? Buffer.concat(this.#stderrChunks) : Buffer.alloc(0);
let stderr = stderrBuf.toString('utf8');
if (this.#truncated.stderr) stderr += '\n[stderr truncated]\n';
reject(new Error(`Command failed with code ${code}: ${stderr}`));
}
});
this.#proc.on('error', (err) => reject(err));
});
}
/**
* Terminates process and its children (via pgrep).
*
* @param {number | string} [signal='SIGTERM'] - Signal to send.
* @returns {Promise<number[]>} Killed PIDs.
* @throws {Error} No process/PID.
*/
async kill(signal = 'SIGTERM') {
if (!this.#proc) throw new Error('Trying to kill a process without creating one.');
if (!this.#proc.pid) throw new Error('The process pid is undefined.');
this.#forcedKill = true;
const pid = this.#proc.pid;
let killed = [];
try {
const kids = await childrenOf(pid).catch(() => []);
for (const k of kids) {
try { process.kill(k, signal); killed.push(k); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
}
try { process.kill(pid, signal); killed.push(pid); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
} finally {
this.#proc = null;
}
return killed;
}
}
export default SHExecute;