@gkalpak/cli-utils
Version:
A private collection of utilities for developing cli tools.
317 lines • 15.9 kB
JavaScript
;
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