UNPKG

@j-o-r/sh

Version:

Execute shell commands on Linux-based systems from javascript

299 lines (292 loc) 8.93 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 /** @type {assert} */ import assert from 'node:assert'; import SHDispatch from './SHDispatch.js'; import Test from './Test.js' /** * @typedef {Object.<string, string>} ArgsObject * @property {string} [key: string] - Any string key maps to a string value * @property {string[]} _ - Array of strings, unnamed parameters * @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 */ /** * Creates a new SHDispatch object that represents a command to be executed. * * @typedef {Function} Shell * @property {function(Array, ...*): ProcessPromise} execute - The function to execute the command. * * @param {Array} pieces - An array of string literals from a template literal. * @param {...*} args - The values to be interpolated into the string literals. * @returns {SHDispatch} Trigger for the command. * @throws {Error} Throws an error if any of the string literals in `pieces` is undefined. * * @example * const command = await SH`echo 'Hello, world!'`.run(); */ /** * Determine a javascript type * * @param {any} fn - Any let type * @returns {string} The "real" object / typeof name */ 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; }; /** * 4ms, 5s || 5 * @param {number|string} d * @returns {number} */ 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}".`); } /** @type {Shell & { (pieces: TemplateStringsArray, ...args: *): SHDispatch }} */ 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])) { s = args[i].map((x) => x).join(' '); } else { s = args[i]; } cmd += s + pieces[++i]; } return new SHDispatch(cmd); }, {}); /** * Create a async/sync context in new execution callstack * @param {function} callback - async/sync function * @example * const p = within(async () => { * const res = await Promise.all([ * SH`sleep 1; echo 1`.run(), * SH`sleep 2; echo 2`.run(), * sleep(2), * SH`sleep 3; echo 3`.run() * ]); * return 'res'; * }); */ const within = async (callback) => { return await callback(); } /** * This function reads the standard input (stdin) from the current process. * @example * const content = await stdin(); */ const readIn = async () => { let buf = ''; process.stdin.setEncoding('utf8'); for await (const chunk of process.stdin) { buf += chunk; } return buf; } /** * Retries a given asynchronous function a specified number of times with optional delays between attempts. * * @param {number} count - The number of retry attempts. * @param {string|expBackoff|Function} a - Either a delay duration as a string, a delay generator object, or the callback function. * @param {Function} [b] - The callback function to retry, required if `a` is not a function. * @returns {Promise<*>} - The result of the callback function if it succeeds within the retry attempts. * @throws {Error} - The last error encountered if all retry attempts fail. * * @example * // Retry a command 3 times * const p = await retry(3, () => SH`curl -s https://flipwrsi`); * * // Retry a command 3 times with an interval of 1 second between each try * const p = await retry(3, '1s', () => SH`curl -s https://flipwrsi`); * * // Retry a command 3 times with irregular intervals using exponential backoff * const p = await retry(3, expBackoff(), () => SH`curl -s https://flipwrsi`); */ const retry = async (count, a, b) => { // const total = count; 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; } /** * This function pauses or "sleeps" code execution for a specified duration. * @param {string|number} duration - The duration to pause execution for, e.g., '100ms' or '3s'. * * @example * * await sleep('5s'); */ const sleep = (duration) => { return new Promise((resolve) => { setTimeout(resolve, parseDuration(duration)); }); } /** * Change working directory * @param {string} dir */ const cd = (dir) => { // @ts-ignore process.chdir(dir); } /** * Generates an exponential backoff time with a random jitter. * * @generator * @param {string} [max='60s'] - The maximum backoff time in a human-readable format (e.g., '60s' for 60 seconds). * @param {string} [rand='100ms'] - The maximum random jitter time in a human-readable format (e.g., '100ms' for 100 milliseconds). * @yields {number} The backoff time in milliseconds. */ 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 command-line arguments into an object. * * The function recognizes arguments that start with two dashes (`--`) or one dash (`-`) as keys, * and the subsequent value (if not another key) as the corresponding value. * If a key does not have a value, it defaults to `true`. * All unrecognized arguments are collected in an array under the `_` property. * * @param {string[]} [args] - An array of command-line arguments (process.argv.slice(2)). * @returns {ArgsObject} An object where: * - If args is not passed, process.argv.slice(2) will be the default * - Each key corresponds to an argument that starts with `--` or `-`, * - The value is either the next argument or `true` if no value is provided, * - The `_` property contains an array of unbound arguments. */ 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, within, expBackoff, parseArgs, jsType, Test, assert }