@j-o-r/sh
Version:
Execute shell commands on Linux-based systems from javascript
467 lines (438 loc) • 12.8 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
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
}