@oclif/core
Version:
base library for oclif CLIs
484 lines (483 loc) • 21.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Parser = exports.readStdin = void 0;
/* eslint-disable no-await-in-loop */
const node_os_1 = require("node:os");
const node_readline_1 = require("node:readline");
const cache_1 = __importDefault(require("../cache"));
const logger_1 = require("../logger");
const util_1 = require("../util/util");
const errors_1 = require("./errors");
let debug;
try {
debug =
process.env.CLI_FLAGS_DEBUG === '1'
? (0, logger_1.makeDebug)('parser')
: () => {
// noop
};
}
catch {
debug = () => {
// noop
};
}
const readStdin = async () => {
const { stdin, stdout } = process;
// process.stdin.isTTY is true whenever it's running in a terminal.
// process.stdin.isTTY is undefined when it's running in a pipe, e.g. echo 'foo' | my-cli command
// process.stdin.isTTY is undefined when it's running in a spawned process, even if there's no pipe.
// This means that reading from stdin could hang indefinitely while waiting for a non-existent pipe.
// Because of this, we have to set a timeout to prevent the process from hanging.
if (stdin.isTTY)
return null;
if (globalThis.oclif?.stdinCache) {
debug('resolved stdin from global cache', globalThis.oclif.stdinCache);
return globalThis.oclif.stdinCache;
}
return new Promise((resolve) => {
const lines = [];
const ac = new AbortController();
const { signal } = ac;
const timeout = setTimeout(() => ac.abort(), 10);
const rl = (0, node_readline_1.createInterface)({
input: stdin,
output: stdout,
terminal: false,
});
rl.on('line', (line) => {
lines.push(line);
});
rl.once('close', () => {
const result = lines.join(node_os_1.EOL);
clearTimeout(timeout);
debug('resolved from stdin', result);
globalThis.oclif = { ...globalThis.oclif, stdinCache: result };
resolve(result);
});
signal.addEventListener('abort', () => {
debug('stdin aborted');
clearTimeout(timeout);
rl.close();
resolve(null);
}, { once: true });
});
};
exports.readStdin = readStdin;
function isNegativeNumber(input) {
return /^-\d/g.test(input);
}
const validateOptions = (flag, input) => {
if (flag.options && !flag.options.includes(input))
throw new errors_1.FlagInvalidOptionError(flag, input);
return input;
};
class Parser {
input;
argv;
booleanFlags;
context;
currentFlag;
flagAliases;
raw = [];
constructor(input) {
this.input = input;
this.context = input.context ?? {};
this.argv = [...input.argv];
this._setNames();
this.booleanFlags = (0, util_1.pickBy)(input.flags, (f) => f.type === 'boolean');
this.flagAliases = Object.fromEntries(Object.values(input.flags).flatMap((flag) => [...(flag.aliases ?? []), ...(flag.charAliases ?? [])].map((a) => [a, flag])));
}
get _argTokens() {
return this.raw.filter((o) => o.type === 'arg');
}
async parse() {
this._debugInput();
// eslint-disable-next-line complexity
const parseFlag = async (arg) => {
const { isLong, name } = this.findFlag(arg);
if (!name) {
const i = arg.indexOf('=');
if (i !== -1) {
const sliced = arg.slice(i + 1);
this.argv.unshift(sliced);
const equalsParsed = await parseFlag(arg.slice(0, i));
if (!equalsParsed) {
this.argv.shift();
}
return equalsParsed;
}
return false;
}
const flag = this.input.flags[name];
if (flag.type === 'option') {
if (!flag.multiple && this.raw.some((o) => o.type === 'flag' && o.flag === name)) {
throw new errors_1.CLIError(`Flag --${name} can only be specified once`);
}
this.currentFlag = flag;
let input = isLong || arg.length < 3 ? this.argv.shift() : arg.slice(arg[2] === '=' ? 3 : 2);
if (flag.allowStdin === 'only' && input !== '-' && input !== undefined && !this.findFlag(input).name) {
throw new errors_1.CLIError(`Flag --${name} can only be read from stdin. The value must be "-" or not provided at all.`);
}
if ((flag.allowStdin && input === '-') || flag.allowStdin === 'only') {
const stdin = await (0, exports.readStdin)();
if (stdin) {
input = stdin.trim();
}
}
// if the value ends up being one of the command's flags, the user didn't provide an input
if (typeof input !== 'string' || this.findFlag(input).name) {
if (flag.options) {
throw new errors_1.CLIError(`Flag --${name} expects one of these values: ${flag.options.join(', ')}`);
}
throw new errors_1.CLIError(`Flag --${name} expects a value`);
}
this.raw.push({ flag: flag.name, input, type: 'flag' });
}
else {
this.raw.push({ flag: flag.name, input: arg, type: 'flag' });
// push the rest of the short characters back on the stack
if (!isLong && arg.length > 2) {
this.argv.unshift(`-${arg.slice(2)}`);
}
}
return true;
};
let parsingFlags = true;
const nonExistentFlags = [];
let dashdash = false;
const originalArgv = [...this.argv];
while (this.argv.length > 0) {
const input = this.argv.shift();
if (parsingFlags && input.startsWith('-') && input !== '-') {
// attempt to parse as arg
if (this.input['--'] !== false && input === '--') {
parsingFlags = false;
continue;
}
if (await parseFlag(input)) {
continue;
}
if (input === '--') {
dashdash = true;
continue;
}
if (this.input['--'] !== false && !isNegativeNumber(input)) {
// At this point we have a value that begins with '-' or '--'
// but doesn't match up to a flag definition. So we assume that
// this is a misspelled flag or a non-existent flag,
// e.g. --hekp instead of --help
nonExistentFlags.push(input);
continue;
}
}
if (parsingFlags && this.currentFlag && this.currentFlag.multiple && !this.currentFlag.multipleNonGreedy) {
this.raw.push({ flag: this.currentFlag.name, input, type: 'flag' });
continue;
}
// not a flag, parse as arg
const arg = Object.keys(this.input.args)[this._argTokens.length];
this.raw.push({ arg, input, type: 'arg' });
}
const [{ args, argv }, { flags, metadata }] = await Promise.all([this._args(), this._flags()]);
this._debugOutput(argv, args, flags);
const unsortedArgv = (dashdash ? [...argv, ...nonExistentFlags, '--'] : [...argv, ...nonExistentFlags]);
return {
args: args,
argv: unsortedArgv.sort((a, b) => originalArgv.indexOf(a) - originalArgv.indexOf(b)),
flags,
metadata,
nonExistentFlags,
raw: this.raw,
};
}
async _args() {
const argv = [];
const args = {};
const tokens = this._argTokens;
let stdinRead = false;
const ctx = this.context;
for (const [name, arg] of Object.entries(this.input.args)) {
const token = tokens.find((t) => t.arg === name);
ctx.token = token;
if (token) {
if (arg.options && !arg.options.includes(token.input)) {
throw new errors_1.ArgInvalidOptionError(arg, token.input);
}
const parsed = await arg.parse(token.input, ctx, arg);
argv.push(parsed);
args[token.arg] = parsed;
}
else if (!arg.ignoreStdin && !stdinRead) {
let stdin = await (0, exports.readStdin)();
if (stdin) {
stdin = stdin.trim();
const parsed = await arg.parse(stdin, ctx, arg);
argv.push(parsed);
args[name] = parsed;
}
stdinRead = true;
}
if (!args[name] && (arg.default || arg.default === false)) {
if (typeof arg.default === 'function') {
const f = await arg.default();
argv.push(f);
args[name] = f;
}
else {
argv.push(arg.default);
args[name] = arg.default;
}
}
}
for (const token of tokens) {
if (args[token.arg] !== undefined)
continue;
argv.push(token.input);
}
return { args, argv };
}
_debugInput() {
debug('input: %s', this.argv.join(' '));
const args = Object.keys(this.input.args);
if (args.length > 0) {
debug('available args: %s', args.join(' '));
}
if (Object.keys(this.input.flags).length === 0)
return;
debug('available flags: %s', Object.keys(this.input.flags)
.map((f) => `--${f}`)
.join(' '));
}
_debugOutput(args, flags, argv) {
if (argv.length > 0) {
debug('argv: %o', argv);
}
if (Object.keys(args).length > 0) {
debug('args: %o', args);
}
if (Object.keys(flags).length > 0) {
debug('flags: %o', flags);
}
}
async _flags() {
const parseFlagOrThrowError = async (input, flag, context, token) => {
if (!flag.parse)
return input;
const ctx = {
...context,
error: context?.error,
exit: context?.exit,
jsonEnabled: context?.jsonEnabled,
log: context?.log,
logToStderr: context?.logToStderr,
token,
warn: context?.warn,
};
try {
if (flag.type === 'boolean') {
return await flag.parse(input, ctx, flag);
}
return await flag.parse(input, ctx, flag);
}
catch (error) {
error.message = `Parsing --${flag.name} \n\t${error.message}\nSee more help with --help`;
if (cache_1.default.getInstance().get('exitCodes')?.failedFlagParsing)
error.oclif = { exit: cache_1.default.getInstance().get('exitCodes')?.failedFlagParsing };
throw error;
}
};
/* Could add a valueFunction (if there is a value/env/default) and could metadata.
* Value function can be resolved later.
*/
const addValueFunction = (fws) => {
const tokenLength = fws.tokens?.length;
// user provided some input
if (tokenLength) {
// boolean
if (fws.inputFlag.flag.type === 'boolean' && (0, util_1.last)(fws.tokens)?.input) {
return {
...fws,
valueFunction: async (i) => parseFlagOrThrowError((0, util_1.last)(i.tokens)?.input !== `--no-${i.inputFlag.name}`, i.inputFlag.flag, this.context, (0, util_1.last)(i.tokens)),
};
}
// multiple with custom delimiter
if (fws.inputFlag.flag.type === 'option' && fws.inputFlag.flag.delimiter && fws.inputFlag.flag.multiple) {
// regex that will identify unescaped delimiters
const makeDelimiter = (delimiter) => new RegExp(`(?<!\\\\)${delimiter}`);
return {
...fws,
valueFunction: async (i) => (await Promise.all((i.tokens ?? [])
.flatMap((token) => token.input.split(makeDelimiter(i.inputFlag.flag.delimiter ?? ',')))
// trim, and remove surrounding doubleQuotes (which would hav been needed if the elements contain spaces)
.map((v) => v
.trim()
// remove escaped characters from delimiter
// example: --opt="a\,b,c" -> ["a,b", "c"]
.replaceAll(new RegExp(`\\\\${i.inputFlag.flag.delimiter}`, 'g'), i.inputFlag.flag.delimiter ?? ',')
.replace(/^"(.*)"$/, '$1')
.replace(/^'(.*)'$/, '$1'))
.map(async (v) => parseFlagOrThrowError(v, i.inputFlag.flag, this.context, {
...(0, util_1.last)(i.tokens),
input: v,
})))).map((v) => validateOptions(i.inputFlag.flag, v)),
};
}
// multiple in the oclif-core style
if (fws.inputFlag.flag.type === 'option' && fws.inputFlag.flag.multiple) {
return {
...fws,
valueFunction: async (i) => Promise.all((fws.tokens ?? []).map((token) => parseFlagOrThrowError(validateOptions(i.inputFlag.flag, token.input), i.inputFlag.flag, this.context, token))),
};
}
// simple option flag
if (fws.inputFlag.flag.type === 'option') {
return {
...fws,
valueFunction: async (i) => parseFlagOrThrowError(validateOptions(i.inputFlag.flag, (0, util_1.last)(fws.tokens)?.input), i.inputFlag.flag, this.context, (0, util_1.last)(fws.tokens)),
};
}
}
// no input: env flags
if (fws.inputFlag.flag.env && process.env[fws.inputFlag.flag.env]) {
const valueFromEnv = process.env[fws.inputFlag.flag.env];
if (fws.inputFlag.flag.type === 'option' && valueFromEnv) {
return {
...fws,
valueFunction: async (i) => parseFlagOrThrowError(validateOptions(i.inputFlag.flag, valueFromEnv), i.inputFlag.flag, this.context),
};
}
if (fws.inputFlag.flag.type === 'boolean') {
return {
...fws,
valueFunction: async (i) => (0, util_1.isTruthy)(process.env[i.inputFlag.flag.env] ?? 'false'),
};
}
}
// no input, but flag has default value
// eslint-disable-next-line no-constant-binary-expression, valid-typeof
if (typeof fws.inputFlag.flag.default !== undefined) {
return {
...fws,
metadata: { setFromDefault: true },
valueFunction: typeof fws.inputFlag.flag.default === 'function'
? (i, allFlags = {}) => fws.inputFlag.flag.default({ flags: allFlags, options: i.inputFlag.flag })
: async () => fws.inputFlag.flag.default,
};
}
// base case (no value function)
return fws;
};
const addHelpFunction = (fws) => {
if (fws.inputFlag.flag.type === 'option' && fws.inputFlag.flag.defaultHelp) {
return {
...fws,
helpFunction: typeof fws.inputFlag.flag.defaultHelp === 'function'
? (i, flags, ...context) =>
// @ts-expect-error flag type isn't specific enough to know defaultHelp will definitely be there
i.inputFlag.flag.defaultHelp({ flags, options: i.inputFlag }, ...context)
: // @ts-expect-error flag type isn't specific enough to know defaultHelp will definitely be there
(i) => i.inputFlag.flag.defaultHelp,
};
}
return fws;
};
const addDefaultHelp = async (fwsArray) => {
const valueReferenceForHelp = fwsArrayToObject(flagsWithAllValues.filter((fws) => !fws.metadata?.setFromDefault));
return Promise.all(fwsArray.map(async (fws) => {
try {
if (fws.helpFunction) {
return {
...fws,
metadata: {
...fws.metadata,
defaultHelp: await fws.helpFunction?.(fws, valueReferenceForHelp, this.context),
},
};
}
}
catch {
// no-op
}
return fws;
}));
};
const fwsArrayToObject = (fwsArray) => Object.fromEntries(fwsArray.filter((fws) => fws.value !== undefined).map((fws) => [fws.inputFlag.name, fws.value]));
const flagTokenMap = this.mapAndValidateFlags();
const flagsWithValues = await Promise.all(Object.entries(this.input.flags)
// we check them if they have a token, or might have env, default, or defaultHelp. Also include booleans so they get their default value
.filter(([name, flag]) => flag.type === 'boolean' ||
flag.env ||
flag.default !== undefined ||
'defaultHelp' in flag ||
flagTokenMap.has(name))
// match each possible flag to its token, if there is one
.map(([name, flag]) => ({ inputFlag: { flag, name }, tokens: flagTokenMap.get(name) }))
.map((fws) => addValueFunction(fws))
.filter((fws) => fws.valueFunction !== undefined)
.map((fws) => addHelpFunction(fws))
// we can't apply the default values until all the other flags are resolved because `flag.default` can reference other flags
.map(async (fws) => (fws.metadata?.setFromDefault ? fws : { ...fws, value: await fws.valueFunction?.(fws) })));
const valueReference = fwsArrayToObject(flagsWithValues.filter((fws) => !fws.metadata?.setFromDefault));
const flagsWithAllValues = await Promise.all(flagsWithValues.map(async (fws) => fws.metadata?.setFromDefault ? { ...fws, value: await fws.valueFunction?.(fws, valueReference) } : fws));
const finalFlags = flagsWithAllValues.some((fws) => typeof fws.helpFunction === 'function')
? await addDefaultHelp(flagsWithAllValues)
: flagsWithAllValues;
return {
flags: fwsArrayToObject(finalFlags),
metadata: {
flags: Object.fromEntries(finalFlags.filter((fws) => fws.metadata).map((fws) => [fws.inputFlag.name, fws.metadata])),
},
};
}
_setNames() {
for (const k of Object.keys(this.input.flags)) {
this.input.flags[k].name = k;
}
for (const k of Object.keys(this.input.args)) {
this.input.args[k].name = k;
}
}
findFlag(arg) {
const isLong = arg.startsWith('--');
const short = isLong ? false : arg.startsWith('-');
const name = isLong ? this.findLongFlag(arg) : short ? this.findShortFlag(arg) : undefined;
return { isLong, name };
}
findLongFlag(arg) {
const name = arg.slice(2);
if (this.input.flags[name]) {
return name;
}
if (this.flagAliases[name]) {
return this.flagAliases[name].name;
}
if (arg.startsWith('--no-')) {
const flag = this.booleanFlags[arg.slice(5)];
if (flag && flag.allowNo)
return flag.name;
}
}
findShortFlag([_, char]) {
if (this.flagAliases[char]) {
return this.flagAliases[char].name;
}
return Object.keys(this.input.flags).find((k) => this.input.flags[k].char === char && char !== undefined && this.input.flags[k].char !== undefined);
}
mapAndValidateFlags() {
const flagTokenMap = new Map();
for (const token of this.raw.filter((o) => o.type === 'flag')) {
// fail fast if there are any invalid flags
if (!(token.flag in this.input.flags)) {
throw new errors_1.CLIError(`Unexpected flag ${token.flag}`);
}
const existing = flagTokenMap.get(token.flag) ?? [];
flagTokenMap.set(token.flag, [...existing, token]);
}
return flagTokenMap;
}
}
exports.Parser = Parser;