@gkalpak/cli-utils
Version:
A private collection of utilities for developing cli tools.
422 lines (358 loc) • 16.6 kB
text/typescript
import {spawn, SpawnOptions} from 'node:child_process';
import {PassThrough, Readable} from 'node:stream';
import {internalUtils} from './internal-utils';
import {processUtils} from './process-utils';
/**
* A configuration object, specifying the behavior of {@link CommandUtils#expandCmd expandCmd()},
* {@link CommandUtils#run run()}, {@link CommandUtils#spawnAsPromised spawnAsPromised()}.
*
* For cli commands that accept configuration options, names must be prefixed with `--gkcu-` (but letter casing should
* be preserved). Also, if you want to pass a value, you must use `=` (not space). E.g.: `--gkcu-sapVersion=2`
*/
export interface IRunConfig {
/**
* If true, produce verbose, debug-friendly output.
* (Default: false)
*/
debug?: boolean;
/**
* If true, print the command instead of actually running it.
* (Default: false)
*
* NOTE: This is still an experimental feature and not guaranteed to work as expected.
*/
dryrun?: boolean;
/**
* If true, return the output of the command instead of printing it to stdout. If a number (`n`), print the output to
* stdout, but also return the `n` last lines (ignoring trailing whitespace).
* (Default: false)
*/
returnOutput?: boolean | number;
/**
* The version of `spawnAsPromised` to use.
* - `1`: The original version. Stable, but does not support some complex command constructs (e.g. pipes inside
* parenthesized expressions).
* - `2`: A newer, experimental version. Supports more complex command constructs, but is less stable at the moment.
* (Default: 1)
*/
sapVersion?: number;
/**
* If true, suppress the "Terminate batch job (Y/N)?" confirmation on Windows.
* (Default: false)
*
* NOTE: This is still an experimental feature and not guaranteed to work as expected.
* It is known to not work with certain types of commands (e.g. `vim`).
*/
suppressTbj?: boolean;
}
interface ISubCommandInfo {
placeholder: string;
returnOutput: boolean | number;
transformValue: (value: string) => string;
}
export 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).
*/
public async expandCmd(cmd: string, runtimeArgs: string[], config: IRunConfig): Promise<string> {
// 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<string, ISubCommandInfo[]>();
let expandedCmd = cmd.replace(re, (_, g1, g2, g3, g4, g5, g6, g7, g8) => {
const transformValue = (val: string) => !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: boolean | number = 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: IRunConfig = 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}.
*/
public preprocessArgs(rawArgs: string[]): {args: string[], config: IRunConfig} {
const metaArgRe = /^--gkcu-(?=[a-z])/;
const quoteIfNecessary = (arg: string) => /\s/.test(arg) ? `"${arg}"` : arg;
const processMetaArg = (arg: string) => {
const [key, ...rest] = arg.split('=');
const value = rest.join('=');
config[key as string] = +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).
*/
public async run(cmd: string, runtimeArgs: string[] = [], config: IRunConfig = {}): Promise<string> {
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).
*/
public spawnAsPromised(
rawCmd: string,
{debug, dryrun, returnOutput, sapVersion = 1, suppressTbj}: IRunConfig = {},
): Promise<string> {
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.');
}
internalUtils.resetOutputStyle(process.stdout);
};
const cancelCleanUp = processUtils.doOnExit(process, cleanUp);
const unsuppressTbj = suppressTbj ?
processUtils.suppressTerminateBatchJobConfirmation(process) :
internalUtils.noop;
const onDone = () => {
unsuppressTbj();
cancelCleanUp();
cleanUp();
};
const promise = new Promise<string>((resolve, reject) => {
let data = '';
const getReturnData = !returnOutputSubset ?
() => data :
() => this.getLastLines(data.trim(), returnOutput as number);
const pipedCmdSpecs = this.parseRawCmd(rawCmd, sapVersion, dryrun);
const lastStdout = pipedCmdSpecs.reduce<Readable | null>((prevStdout, cmdSpec, idx, arr) => {
const isLast = (idx === arr.length - 1);
const pipeOutput = !isLast || returnOutput;
const executable = cmdSpec.executable;
const args = cmdSpec.args;
const options: SpawnOptions = {
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 as string[]).join(', ')})`);
}
const proc = 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 PassThrough();
outputStream.on('data', d => data += d);
lastStdout!.pipe(outputStream);
if (returnOutputSubset) {
outputStream.pipe(process.stdout);
}
}
});
return internalUtils.finallyAsPromised(promise, onDone);
}
// Methods - Private
private debugMessage(msg: string): void {
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);
}
private getLastLines(input: string, lineCount: number) {
return input.split('\n').slice(-lineCount).join('\n').trim();
}
private insertAfter(items: string[], newItem: string, afterItem: string): void {
for (let i = 0; i < items.length; ++i) {
if (items[i] === afterItem) {
this.insertAt(items, newItem, ++i);
}
}
}
private insertAt(items: string[], newItem: string, idx: number) {
if (items[idx] === '(') {
++idx;
}
items.splice(idx, 0, newItem);
}
private parseRawCmd(rawCmd: string, sapVersion: number, dryrun = false): {executable: string, args: string[]}[] {
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}).`);
}
}
private parseSingleCmd(cmd: string, dryrun = false): {executable: string, args: string[]} {
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);
}, [] as string[]).
filter(x => x).
reduce((arr, str) => {
if (str[0] === '(') {
arr.push('(', str.slice(1));
} else {
arr.push(str);
}
return arr;
}, [] as string[]);
if (dryrun) {
this.transformForDryrun(tokens);
}
return {
args: tokens,
executable: tokens.shift() || '',
};
}
private transformForDryrun(tokens: string[]): void {
this.insertAt(tokens, 'echo', 0);
this.insertAfter(tokens, 'echo', '&&');
this.insertAfter(tokens, 'echo', '||');
}
private cleanUpOutput(str: string): string {
return internalUtils.
stripOutputStyleResetSequences(str).
replace(internalUtils.escapeSeqRes.moveCursor, '').
trim();
}
}
export const commandUtils = new CommandUtils();