@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
299 lines (292 loc) • 8.93 kB
JavaScript
// 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
}