UNPKG

@j-o-r/sh

Version:

Execute shell commands on Linux-based systems from javascript

467 lines (438 loc) 12.8 kB
// Copyright 2021 Google LLC // // 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 // // https://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. // // // Original Source: zx // Link to Original Source: https://github.com/google/zx // Reason for Using This Code: // The core functionality of this code is highly beneficial. However, certain parts of the original code // were overwriting the global namespace with core libraries and variables. This was causing conflicts with // other packages (for instance, fetch) and introducing unexpected elements into my code base. // The main $/SH method is all there is left, with barebone Promises and readable code. // Changes Made: // - The code has been or is being reformatted to comply with ES2020 standards. // - Some methods were added and existing ones were modified or deleted to enhance usability. // - The namespace has been changed from '$' to 'SH'. // Modified by: jorrit.duin+sh[AT]gmail.com import assert from 'node:assert'; import readline from 'node:readline/promises'; import SHDispatch from './SHDispatch.js'; import Test from './Test.js' import AsyncTracker from './AsyncTracker.js' /** * @typedef {Object.<string, string>} ArgsObject * @property {string[]} _ - Array of strings, unnamed parameters * @property {string} [key: string] - Any string key maps to a string value * @description Parsed parameters result. */ /** * @typedef {Function} RejectCallback * @param {Error} error - The error object passed to the callback. */ /** * @typedef {Function} ResolveCallback * @param {any} [param] - Optional callback any value */ /** * @typedef {import('./SHDispatch.js').SHOptions} SHOptions */ /** * @typedef {Object} AbortableInput * @property {Promise<string|void>} input - Promise that resolves to user input or void if aborted. * @property {() => void} abort - Function to abort input collection. */ /** * @typedef {Object} ExpBackoffGenerator * @generator * @yields {number} Backoff time in ms. */ /** * Utility to determine the JavaScript type of a value. * * @param {any} fn - Any value to inspect. * @returns {string} The "real" object type name (e.g., 'Array', 'Promise') or primitive typeof. * @example * jsType([]); // 'Array' * jsType(Promise.resolve()); // 'Promise' */ const jsType = (fn) => { if (fn === undefined) return 'undefined'; const type = Object.prototype.toString.call(fn).slice(8, -1); return type === 'Object' ? fn.constructor.name : type; }; /** * Checks if an object has a specific own property (code-safe). * * @param {any} o - Object to examine. * @param {string} p - Property name to check. * @returns {boolean} True if the object has the own property. */ const hasProp = (o, p) => { if (o == null) return false; return Object.prototype.hasOwnProperty.call(o, p); }; /** * Parses a human-readable duration string or number into milliseconds. * * Supports: '5s' (5000ms), '100ms' (100ms), plain numbers (ms). * * @param {number|string} d - Duration as number (ms) or string ('5s', '100ms'). * @returns {number} Duration in milliseconds. * @throws {Error} If invalid duration format. */ const parseDuration = (d) => { if (typeof d == 'number') { if (isNaN(d) || d < 0) throw new Error(`Invalid duration: "${d}".`); return d; } else if (/\d+s/.test(d)) { return +d.slice(0, -1) * 1000; } else if (/\d+ms/.test(d)) { return +d.slice(0, -2); } throw new Error(`Unknown duration: "${d}".`); } /** * Escapes a string for safe use as a bash command-line argument. * * Handles quotes, backticks, $, newlines, etc. * * @param {string} x - Input string to escape. * @returns {string} Bash-escaped string. */ const bashEscape = (x) => { let str = String(x).trim(); // the trick is to double escape escape vars first str = str.replace(/\\/g, '\\\\'); // then add escaping for oddities // Replace literal escape sequences like \n, \t, \r in quoted strings str = str.replace(/(['"])\\(.)\1/g, '$1\\\\$2$1'); // escape $ (is a BASH var) str = str.replace(/\$/g, '\\$'); // Escape backticks str = str.replace(/`/g, '\\`'); // Escape quotes str = str.replace(/"/g, '\\"'); return str; } /** Default options applied to all SH commands unless overridden. */ const defaultOptions = { cwd: process.cwd(), env: process.env, shell: 'bash', stdio: ['inherit', 'pipe', 'pipe'], timeout: 0, // when 0 there is no timeout }; /** Proxy set trap for dynamically updating defaultOptions via SH.prop = value */ const setHandler = { set(target, prop, value) { defaultOptions[prop] = value; return true; } } /** * Template tag for building and executing shell commands. * * Returns an {@link SHDispatch} instance for configuration (.options()) and execution (.run(), .runSync()). * * Interpolation rules: * - Arrays: Elements trimmed, newlines/tabs escaped, shell-special chars single-quoted with '\'' escape. * - Other values: String(value) inserted as-is (NOT auto-escaped; use {@link bashEscape} for safety). * * SH acts as both template tag and options setter: `SH.timeout = 5000; SH`cmd`` * * @param {TemplateStringsArray} pieces - String literals from template. * @param {...unknown[]} args - Values to interpolate. * @returns {SHDispatch} Command dispatcher. * @throws {Error} If pieces contain undefined. * @example * const cmd = SH`echo ${'Hello'}`; * await cmd.run(); // Executes: echo Hello * * // Array interpolation * SH`ls ${['-la', '/']}`.run(); // ls '-la' '/' */ const SH = new Proxy(function(pieces, ...args) { if (pieces.some((p) => p == undefined)) { throw new Error(`Malformed command ${pieces}`); } let cmd = pieces[0], i = 0; while (i < args.length) { let s; if (Array.isArray(args[i])) { // @ts-ignore s = args[i].map(/** @param {string} x */(x) => { // Trim every element let str = String(x).trim(); str = str.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); // If the string contains special characters, wrap in single quotes if (str.match(/[ "'$`(){}[\]]/)) { // Escape single quotes within the string return `'${str.replace(/'/g, `'\\''`)}'`; } return str; }).join(' '); } else { s = String(args[i]); } cmd += s + pieces[++i]; } return new SHDispatch(cmd, defaultOptions); }, setHandler); /** * Executes a callback in a new async context (fresh callstack). * * Useful for parallel operations without nesting. * * @param {() => Promise<any>} callback - Async function to execute. * @returns {Promise<any>} Result of callback. * @example * const results = await within(async () => { * return Promise.all([SH`sleep 1; echo 1`.run(), sleep(2)]); * }); */ const within = async (callback) => { return await callback(); } /** * Reads entire stdin as UTF-8 string. Resolves to empty string if TTY (no pipe). * * @returns {Promise<string|undefined>} Stdin content or undefined if TTY. * @example * const input = await readIn(); // Use in piped scripts */ const readIn = async () => { if (process.stdin.isTTY) return; let buf = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) { buf += chunk; } return buf; } /** * Prompts user for input on stdin with custom prompt. * * Supports aborting input collection. * * @param {string} prompt - Prompt text to display. * @returns {AbortableInput} Object with input Promise and abort function. * @example * const {input, abort} = userIn('Enter name: '); * const name = await input; * // abort(); // Cancel anytime */ const userIn = (prompt) => { let resolvePromise; const input = new Promise((resolve) => { resolvePromise = resolve; }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); let buffer = ''; let timer; /** @param {string} chunk */ const onData = (chunk) => { if (chunk.length > 4 && chunk.includes('\n')) { clearTimeout(timer); timer = setTimeout(resolveFunc, 50); } }; const resolveFunc = () => { let fullText = buffer; if (rl.line) fullText += rl.line; cleanup(); resolvePromise(fullText); }; const cleanup = () => { process.stdin.removeListener('data', onData); // @ts-ignore rl.removeAllListeners('line'); clearTimeout(timer); rl.close(); }; // @ts-ignore rl.on('line', (line) => { buffer += line + '\n'; clearTimeout(timer); timer = setTimeout(resolveFunc, 50); }); process.stdin.on('data', onData); rl.setPrompt(prompt); rl.prompt(); return { input, abort: () => { cleanup(); resolvePromise(); } }; }; /** * Retries an async function up to N times with optional delays. * * Delay can be fixed ('1s'), generator (expBackoff()), or none. * * @param {number} count - Number of attempts. * @param {string|number|ExpBackoffGenerator|Function} delayOrFn - Delay or callback if no separate fn. * @param {Function} [fn] - Callback to retry (if delayOrFn not function). * @returns {Promise<any>} Successful result. * @throws {Error} Last error if all attempts fail. * @example * await retry(3, '1s', () => SH`curl http://unreliable`.run()); * await retry(3, expBackoff(), () => flakyOp()); */ const retry = async (count, a, b) => { let callback; let delayStatic = 0; let delayGen; // @ts-ignore if (typeof a == 'function') { callback = a; } else { if (typeof a == 'object') { delayGen = a; } else { delayStatic = parseDuration(a); } assert(b); callback = b; } let lastErr; let attempt = 0; while (count-- > 0) { attempt++; try { return await callback(); } catch (err) { let delay = 0; if (delayStatic > 0) delay = delayStatic; // @ts-ignore if (delayGen) delay = delayGen.next().value; lastErr = err; if (count == 0) break; if (delay) await sleep(delay); } } throw lastErr; } /** * Sleeps for a specified duration. * * @param {string|number} duration - Duration as '5s', '100ms', or ms number. * @returns {Promise<void>} * @example * await sleep('2s'); */ const sleep = (duration) => { return new Promise((resolve) => { setTimeout(resolve, parseDuration(duration)); }); } /** * Changes the current working directory. * * @param {string} dir - Path to new directory. * @example * cd('/tmp'); */ const cd = (dir) => { // @ts-ignore process.chdir(dir); } /** * Generator for exponential backoff delays with jitter. * * @generator * @param {string} [max='60s'] - Max backoff duration. * @param {string} [rand='100ms'] - Max jitter. * @yields {number} Next backoff ms. * @example * const backoff = expBackoff(); * await sleep(backoff.next().value); */ function* expBackoff(max = '60s', rand = '100ms') { const maxMs = parseDuration(max); const randMs = parseDuration(rand); let n = 1; while (true) { const ms = Math.floor(Math.random() * randMs); yield Math.min(2 ** n++, maxMs) + ms; } } /** * Parses CLI arguments into an object. * * Supports --key value, -k value (no shorts grouped). * Bares go to _[]. Duplicates error. No = syntax. * * Defaults to process.argv.slice(2). * * @param {string[]} [args=process.argv.slice(2)] - Args array. * @returns {ArgsObject} * @throws {Error} On invalid/dupe args. * @example * parseArgs(['--port', '8080', 'file.txt']); // { port: '8080', _: ['file.txt'] } */ const parseArgs = (args) => { if (!args) args = process.argv.slice(2); const result = { _: [] }; const seenKeys = new Set(); for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--') || args[i].startsWith('-')) { if (args[i].startsWith('-') && !args[i].startsWith('--') && args[i].length > 2) { throw new Error(`Invalid argument: ${args[i]}. Use '--' for long options.`); } const key = args[i].startsWith('--') ? args[i].substring(2) : args[i].substring(1); if (seenKeys.has(key)) { throw new Error(`Duplicate argument: ${args[i]}`); } seenKeys.add(key); let value; if (args[i + 1] && !args[i + 1].startsWith('-')) { value = args[i + 1]; i++; // Skip next element as it is a value } else { value = true; } result[key] = value; } else { result._.push(args[i]); } } // @ts-ignore return result; }; export { SH, cd, sleep, retry, readIn, userIn, within, expBackoff, parseArgs, jsType, hasProp, Test, assert, AsyncTracker, bashEscape }