UNPKG

@j-o-r/sh

Version:

Execute shell commands on Linux-based systems from javascript

277 lines (255 loc) 9.45 kB
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;