UNPKG

@gkalpak/cli-utils

Version:

A private collection of utilities for developing cli tools.

317 lines 15.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.commandUtils = exports.CommandUtils = void 0; const node_child_process_1 = require("node:child_process"); const node_stream_1 = require("node:stream"); const internal_utils_1 = require("./internal-utils"); const process_utils_1 = require("./process-utils"); class CommandUtils { /** * Expand a command string, by substituting argument identifiers with the specified arguments. It also supports * default/fallback arguments (specified either as static values or as commands to execute and use the output). * * The following rules apply (independently of the underlying OS): * - `$*`, `${*}`: Substitute with all arguments (if any). * - `$n`, `${n}`: Substitute with the nth argument (if specified), where `n` is a positive integer. * - `$n*`, `${n*}`: Substitute with all arguments starting at the nth one (if any). * - `${*:value}`, `${n:value}`, `${n*:value}`: Substitute with all arguments (`*`) or the nth argument (`n`) or the * nth and all subsequent arguments (`n*`). If not specified, substitute with `value`. * - `${*:::command}`, `${n:::command}`, `${n*:::command}`: Substitute with all arguments (`*`) or the nth * argument (`n`) or the nth and all subsequent arguments (`n*`). If not specified, run `command` and substitute * with its trimmed output. * NOTE: Fallback commands inherit the {@link commandUtils#IRunConfig configuration} of the main command, with the * exception of `returnOutput`, which can be overwritten (by adding `--gkcu-returnOutput[=<x>]` at the end of * the command). * * In all rules above, `$`s can also be escaped with `\`, which will be removed when executing the command. This * allows avoiding variable expansion in non-Windows platforms, while still not affecting the output on Windows. * * Hint: `${0:::command}` will always be substituted with the output of `command`. This is useful when you want to * always use the output of `command` in an OS-independent way. * * You can use {@link CommandUtils#preprocessArgs preprocessArgs()} to obtain the basic `runtimeArgs` and `config` * values. For example: `const {args, config} = preprocessArgs(process.argv.slice(2))`. * * @param cmd - The command to expand. * @param runtimeArgs - The runtime arguments that will be used for substituting. * @param config - A configuration object. See {@link command-utils/IRunConfig} for more details. * * @return A promise that resolves with the expanded command, with arguments substituted (including running * default/fallback value sub-commands, as necessary). */ async expandCmd(cmd, runtimeArgs, config) { // 1: leading \s // 2, 5: $* // 3, 6: $\d+* // 4, 7: $\d+ // 8: default/fallback value (possibly with `returnOutput` limit) const re = /(\s{0,1})\\?\$(?:(\*)|([1-9]+)\*|(\d+)|{(?:(\*)|([1-9]+)\*|(\d+))(?::([^}]*))?})/g; const subCommands = new Map(); let expandedCmd = cmd.replace(re, (_, g1, g2, g3, g4, g5, g6, g7, g8) => { const transformValue = (val) => !val ? '' : `${g1}${val}`; // Value based on the supplied arguments. const startIdx = (g2 || g5) ? 0 : (g3 || g6 || g4 || g7) - 1; const endIdx = (g2 || g5 || g3 || g6) ? runtimeArgs.length : +(g4 || g7); let value = runtimeArgs.slice(startIdx, endIdx).join(' '); // No argument(s), fall back to default. if (!value && g8) { const match = /^::(.+)$/.exec(g8); if (!match) { // It is a plain ol' fallback value. value = g8; } else { // It is a command. let returnOutput = true; const subCmd = match[1].replace(/ --gkcu-returnOutput=(\d+)$/, (__, g) => { returnOutput = +g; return ''; }); const placeholder = `${Math.random()}`; if (!subCommands.has(subCmd)) { subCommands.set(subCmd, []); } subCommands.get(subCmd).push({ placeholder, returnOutput, transformValue }); return placeholder; } } return transformValue(value); }); const subCommandPromises = Array.from(subCommands.entries()).map(([subCmd, infoList]) => { const hasNumericReturnOutput = infoList.some(info => typeof info.returnOutput === 'number'); const returnOutput = hasNumericReturnOutput ? Infinity : true; const runConfig = Object.assign({}, config, { returnOutput }); const subCmdPromise = this.run(subCmd, runtimeArgs, runConfig).then(result => this.cleanUpOutput(result)); const replPromises = infoList.map(info => subCmdPromise.then(result => { // Retrieve the part of the output that this sub-command cares about. const value = (typeof info.returnOutput === 'number') ? this.getLastLines(result, info.returnOutput) : result; // Construct the replacement for this sub-command (e.g. leading whitespace may vary). const repl = info.transformValue(config.dryrun ? `{{${value.replace(/\s/g, '_')}}}` : value); // Replace in `expandedCmd`. expandedCmd = expandedCmd.replace(info.placeholder, repl); })); return Promise.all(replPromises); }); await Promise.all(subCommandPromises); return expandedCmd; } /** * Preprocess a list of input arguments (e.g. `process.argv.slice(2)`) into a list of arguments that can be used for * substituting into commands (i.e. filtering out `--gkcu-` arguments and wrapping the remaining argument in * double-quotes, if necessary). Also, derive a {@link command-utils/IRunConfig configuration object} (based on * `--gkcu-` arguments) to modify the behavior of {@link CommandUtils#run run()} (e.g. enable debug output). * * NOTE: If you want to pass a value to a `--gkcu-` argument, you need to use `=` (using a space will not work). * For example: `some-command --gkcu-returnOutput=1` * * @param rawArgs - The input arguments that will be preprocessed. * * @return An object contaning a list of arguments that can be used for substituting and a * {@link command-utils/IRunConfig configuration object}. */ preprocessArgs(rawArgs) { const metaArgRe = /^--gkcu-(?=[a-z])/; const quoteIfNecessary = (arg) => /\s/.test(arg) ? `"${arg}"` : arg; const processMetaArg = (arg) => { const [key, ...rest] = arg.split('='); const value = rest.join('='); config[key] = +value || value || true; }; const config = Object.create(null); const args = rawArgs. filter(arg => !metaArgRe.test(arg) || processMetaArg(arg.replace(metaArgRe, ''))). map(quoteIfNecessary); return { args, config }; } /** * Run a command. Could be a complex command with `|`, `&&` and `||` (but not guaranteed to work if too complex :P). * * It supports argument substitution with {@link CommandUtils#expandCmd expandCmd()} and uses * {@link CommandUtils#spawnAsPromised spawnAsPromised()} to run the resulting command (after substitution). * * You can use {@link CommandUtils#preprocessArgs preprocessArgs()} to obtain the basic `runtimeArgs` and `config` * values. For example: `const {args, config} = preprocessArgs(process.argv.slice(2))`. * * @param cmd - The command to run. Could be a complex command with `|`, `&&` and `||` (but not guaranteed to work if * too complex :P). * @param runtimeArgs? - The runtime arguments that will be used for substituting. * @param config? - A configuration object. See {@link command-utils/IRunConfig} for more details. * * @return A promise that resolves once the command has been executed. The resolved value is either an empty string or * (some part of) the output of the command (if `returnOutput` is set and not false). */ async run(cmd, runtimeArgs = [], config = {}) { const expandedCmd = await this.expandCmd(cmd, runtimeArgs, config); if (config.debug) { this.debugMessage(`Input command: '${cmd}'`); this.debugMessage(`Expanded command: '${expandedCmd}'`); } return this.spawnAsPromised(expandedCmd, config); } /** * Spawn a complex command (or series of piped commands) and return a promise that resolves or rejects based on the * command's outcome. It uses `child_process.spawn()` under the hood, but provides the following extras: * * - You do not have to separate the executable from the arguments. * - It supports complex command with `|`, correctly piping a sub-command's output to the next sub-command's input. * - Cleans up once finished, resetting the output style (e.g. bold) and cursor style (e.g. hidden). * _This is useful, when a sub-command errors and leaves the terminal in an unclean state._ * - Supports all {@link command-utils/IRunConfig} options. * * @param cmd - The command to run. Could be a complex command with `|`. * @param config? - A configuration object. See {@link command-utils/IRunConfig} for more details. * * @return A promise that resolves once the command has been executed. The resolved value is either an empty string or * (some part of) the output of the command (if `returnOutput` is set and not false). */ spawnAsPromised(rawCmd, { debug, dryrun, returnOutput, sapVersion = 1, suppressTbj } = {}) { const returnOutputSubset = (typeof returnOutput === 'number'); const cleanUp = () => { if (returnOutput && !returnOutputSubset) { // The output has not been written to stdout. No need to clean up. return; } if (debug) { this.debugMessage(' Reseting the output and cursor styles.'); } internal_utils_1.internalUtils.resetOutputStyle(process.stdout); }; const cancelCleanUp = process_utils_1.processUtils.doOnExit(process, cleanUp); const unsuppressTbj = suppressTbj ? process_utils_1.processUtils.suppressTerminateBatchJobConfirmation(process) : internal_utils_1.internalUtils.noop; const onDone = () => { unsuppressTbj(); cancelCleanUp(); cleanUp(); }; const promise = new Promise((resolve, reject) => { let data = ''; const getReturnData = !returnOutputSubset ? () => data : () => this.getLastLines(data.trim(), returnOutput); const pipedCmdSpecs = this.parseRawCmd(rawCmd, sapVersion, dryrun); const lastStdout = pipedCmdSpecs.reduce((prevStdout, cmdSpec, idx, arr) => { const isLast = (idx === arr.length - 1); const pipeOutput = !isLast || returnOutput; const executable = cmdSpec.executable; const args = cmdSpec.args; const options = { shell: true, stdio: [ prevStdout ? 'pipe' : 'inherit', pipeOutput ? 'pipe' : 'inherit', 'inherit', ], }; if (debug) { this.debugMessage(` Running ${idx + 1}/${arr.length}: '${executable}', '${args.join(', ')}'\n` + ` (sapVersion: ${sapVersion}, stdio: ${options.stdio.join(', ')})`); } const proc = (0, node_child_process_1.spawn)(executable, args, options). on('error', reject). on('exit', (code, signal) => { if (code !== 0) return reject(code || signal); if (isLast) return resolve(getReturnData()); }); if (prevStdout) prevStdout.pipe(proc.stdin); return proc.stdout; }, null); if (returnOutput) { const outputStream = new node_stream_1.PassThrough(); outputStream.on('data', d => data += d); lastStdout.pipe(outputStream); if (returnOutputSubset) { outputStream.pipe(process.stdout); } } }); return internal_utils_1.internalUtils.finallyAsPromised(promise, onDone); } // Methods - Private debugMessage(msg) { const { gray } = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires const formatted = msg. split('\n'). map(line => gray(`[debug] ${line}`)). join('\n'); console.debug(formatted); } getLastLines(input, lineCount) { return input.split('\n').slice(-lineCount).join('\n').trim(); } insertAfter(items, newItem, afterItem) { for (let i = 0; i < items.length; ++i) { if (items[i] === afterItem) { this.insertAt(items, newItem, ++i); } } } insertAt(items, newItem, idx) { if (items[idx] === '(') { ++idx; } items.splice(idx, 0, newItem); } parseRawCmd(rawCmd, sapVersion, dryrun = false) { switch (sapVersion) { case 1: // Traditional (v1) parsing. return rawCmd. split(/\s+\|\s+/). map(cmd => this.parseSingleCmd(cmd, dryrun)); case 2: { // Since it will be executed in a shell, there is no need to handle anything specially. (Or is it?) const executable = !dryrun ? rawCmd : `node --print '${JSON.stringify(rawCmd).replace(/'/g, '\\\'')}'`; return [{ args: [], executable }]; } default: throw new Error(`Unknown 'sapVersion' (${sapVersion}).`); } } parseSingleCmd(cmd, dryrun = false) { const tokens = cmd. split('"'). reduce((arr, str, idx) => { const newTokens = (idx % 2) ? [`"${str}"`] : str.split(' '); const lastToken = arr[arr.length - 1]; if (lastToken) arr[arr.length - 1] = lastToken + newTokens.shift(); return arr.concat(newTokens); }, []). filter(x => x). reduce((arr, str) => { if (str[0] === '(') { arr.push('(', str.slice(1)); } else { arr.push(str); } return arr; }, []); if (dryrun) { this.transformForDryrun(tokens); } return { args: tokens, executable: tokens.shift() || '', }; } transformForDryrun(tokens) { this.insertAt(tokens, 'echo', 0); this.insertAfter(tokens, 'echo', '&&'); this.insertAfter(tokens, 'echo', '||'); } cleanUpOutput(str) { return internal_utils_1.internalUtils. stripOutputStyleResetSequences(str). replace(internal_utils_1.internalUtils.escapeSeqRes.moveCursor, ''). trim(); } } exports.CommandUtils = CommandUtils; exports.commandUtils = new CommandUtils(); //# sourceMappingURL=command-utils.js.map