@hapi/bossy
Version:
Command line options parser
482 lines (371 loc) • 14 kB
JavaScript
'use strict';
const Tty = require('tty');
const Boom = require('@hapi/boom');
const Hoek = require('@hapi/hoek');
const Bounce = require('@hapi/bounce');
const Bourne = require('@hapi/bourne');
const Validate = require('@hapi/validate');
const Schemas = require('./schemas');
const internals = {};
exports.parse = function (definition, options) {
Validate.assert(definition, Schemas.definition, 'Invalid definition:');
Validate.assert(options, Schemas.parseOptions, 'Invalid options argument:');
const flags = {};
const keys = {};
definition = Schemas.definition.validate(definition).value;
options = options || {};
const names = Object.keys(definition);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
const def = Hoek.clone(definition[name]);
def.name = name;
keys[name] = def;
if (def.alias) {
for (let j = 0; j < def.alias.length; ++j) {
keys[def.alias[j]] = def;
}
}
if ((def.type === 'boolean' || def.type === 'json') && def.default !== undefined) {
flags[name] = def.default;
}
else if (def.type === 'boolean') {
flags[name] = false;
}
}
const args = options.argv ?? process.argv.slice(2);
let last = null;
const errors = [];
let help = false;
for (let i = 0; i < args.length; ++i) {
const arg = args[i];
if (arg[0] === '-') {
// Key
const char = arg[1];
if (!char) {
errors.push(internals.formatError('Invalid empty \'-\' option'));
continue;
}
if (char === '-' && arg.length <= 2) {
errors.push(internals.formatError('Invalid empty \'--\' option'));
continue;
}
const opts = (char === '-' ? [arg.slice(2)] : arg.slice(1).split(''));
for (let j = 0; j < opts.length; ++j) {
if (last) {
errors.push(internals.formatError('Invalid option:', last.def.name, 'missing value'));
continue;
}
const opt = opts[j];
let booleanNegationDef;
if (opt.startsWith('no-')) {
const maybeDef = keys[opt.replace('no-', '')];
if (maybeDef?.type === 'boolean') {
booleanNegationDef = maybeDef;
}
}
let jsonDef;
if (opt.includes('.')) {
const maybeDef = keys[opt.split('.')[0]];
if (maybeDef?.type === 'json') {
jsonDef = maybeDef;
}
}
const def = keys[opt] ?? booleanNegationDef ?? jsonDef;
if (!def) {
errors.push(internals.formatError('Unknown option:', opt));
continue;
}
if (def.type === 'help') {
flags[def.name] = true;
help = true;
}
else if (def.type === 'boolean') {
flags[def.name] = def !== booleanNegationDef;
}
else if (def.type === 'number' &&
opts.length > 1) {
args.splice(i + 1, 0, arg.split(char)[1]);
last = { def, opt };
break;
}
else {
last = { def, opt };
}
}
}
else {
// Value
let value = arg;
if (last) {
if (last.def.type === 'number') {
value = parseInt(arg, 10);
if (!Number.isSafeInteger(value)) {
errors.push(internals.formatError('Invalid value (non-number) for option:', last.def.name));
continue;
}
}
if (last.def.valid &&
!last.def.valid.includes(value)) {
const validValues = [];
for (let j = 0; j < last.def.valid.length; ++j) {
const valid = last.def.valid[j];
validValues.push(`'${valid}'`);
}
errors.push(internals.formatError('Invalid value for option:', last.def.name, '(valid: ' + validValues.join(',') + ')'));
continue;
}
if (last.def.type === 'json') {
const stringValue = value;
try {
value = Bourne.parse(value, { // E.g. { x }
protoAction: 'remove'
});
}
catch (err) {
// If the input doesn't look like JSON, we'll ignore that
// and treat it as a string, unless parsePrimitives is 'strict'
Bounce.ignore(err, SyntaxError);
if (last.def.parsePrimitives === 'strict') {
errors.push(internals.formatError('Invalid value for option:', last.opt, '(invalid JSON)'));
continue;
}
}
const isPrimitive = !value || typeof value !== 'object';
if (last.def.parsePrimitives === 'strict' && !isPrimitive) {
errors.push(internals.formatError('Invalid value for option:', last.opt, '(non-primitive JSON value)'));
continue;
}
if (!last.def.parsePrimitives && isPrimitive) {
// When receiving JSON that parses to a non-object, treat it as a string, i.e. numbers, true, false, null.
value = stringValue;
}
value = internals.valueAtArgPath(last.opt, value);
if (!value || typeof value !== 'object') {
errors.push(internals.formatError('Invalid value for option:', last.def.name, '(must be an object or array)'));
continue;
}
// Necessary to protect from prototype poisoning in dot-separated path e.g. `--x.__proto__.y 1`
Bourne.scan(value, { protoAction: 'remove' });
}
}
const name = last ? last.def.name : '_';
if (flags.hasOwnProperty(name)) {
if (!last ||
last.def.multiple) {
flags[name].push(value);
}
else if (last.def.type === 'json') {
Hoek.merge(flags[name], value);
}
else {
errors.push(internals.formatError('Multiple values are not allowed for option:', name));
continue;
}
}
else {
if (!last ||
last.def.multiple) {
flags[name] = [].concat(value);
}
else {
flags[name] = value;
}
}
last = null;
}
}
for (let i = 0; i < names.length; ++i) {
const def = keys[names[i]];
if (def.type === 'range') {
internals.parseRange(def, flags);
}
if (flags[def.name] === undefined) {
flags[def.name] = def.default;
}
if (def.require && flags[def.name] === undefined) {
errors.push(internals.formatError(definition));
}
if (def.alias) {
for (let j = 0; j < def.alias.length; ++j) {
const alias = def.alias[j];
flags[alias] = flags[def.name];
}
}
}
if (errors.length && !help) {
return errors[0];
}
return flags;
};
exports.usage = function (definition, usage, options) {
if ((arguments.length === 2) && (typeof usage === 'object')) {
options = usage;
usage = '';
}
Validate.assert(definition, Schemas.definition, 'Invalid definition:');
Validate.assert(options, Schemas.usageOptions, 'Invalid options argument:');
definition = Schemas.definition.validate(definition).value;
options = Schemas.usageOptions.validate(options || { colors: null }).value;
const color = internals.colors(options.colors);
const output = usage ? 'Usage: ' + usage + '\n\n' : '\n';
const col1 = ['Options:'];
const col2 = ['\n'];
const names = Object.keys(definition);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
const def = definition[name];
let shortName = internals.getShortName(name, def.alias);
let longName = (shortName === name) ? def.alias : name;
if (!longName &&
shortName.length > 1) {
longName = shortName;
shortName = '';
}
let formattedName = shortName ? ' -' + shortName : '';
if (longName) {
const aliases = [].concat(longName);
for (let j = 0; j < aliases.length; ++j) {
formattedName += shortName ? ', ' : ' ';
formattedName += '--' + aliases[j];
}
}
let formattedDesc = def.description ? color.gray(def.description) : '';
if (def.default || def.default === 0) {
formattedDesc += formattedDesc.length ? ' ' : '';
formattedDesc += def.type === 'json' ?
color.gray('(' + JSON.stringify(def.default) + ')') :
color.gray('(' + def.default + ')');
}
if (def.require) {
formattedDesc += formattedDesc.length ? ' ' : '';
formattedDesc += color.yellow('(required)');
}
col1.push(color.green(formattedName));
col2.push(formattedDesc);
}
return output + internals.formatColumns(col1, col2);
};
exports.object = (opt, parsed) => {
Hoek.assert(!opt.includes('.'), `Cannot build an object at a deep path: ${opt} (contains a dot)`);
const initial = parsed.hasOwnProperty(opt) ? parsed[opt] : {};
const depth = (path) => path.split('.').length;
return Object.entries(parsed)
.filter(([key]) => key.startsWith(`${opt}.`))
.sort(([keyA], [keyB]) => depth(keyA) - depth(keyB)) // Shallow to deep
.reduce((collect, [key, val]) => {
return Hoek.applyToDefaults(collect, internals.valueAtArgPath(key, val));
}, initial);
};
internals.formatError = function (...args) {
let msg = '';
if (args.length > 1) {
msg = args.join(' ');
}
else if (typeof args[0] === 'string') {
msg = args[0];
}
else {
msg = exports.usage(args[0]);
}
return new Boom.Boom(msg);
};
internals.getShortName = function (shortName, aliases) {
if (!aliases) {
return shortName;
}
for (let i = 0; i < aliases.length; ++i) {
if (aliases[i] && aliases[i].length < shortName.length) {
shortName = aliases[i];
}
}
return shortName;
};
internals.formatColumns = function (col1, col2) {
const rows = [];
let col1Width = 0;
col1.forEach((text) => {
if (text.length > col1Width) {
col1Width = text.length;
}
});
for (let i = 0; i < col1.length; ++i) {
let row = col1[i];
const padding = new Array((col1Width - row.length) + 5).join(' ');
row += padding + col2[i];
rows.push(row);
}
return rows.join('\n');
};
internals.parseRange = function (def, flags) {
const value = flags[def.name];
if (!value) {
return;
}
const values = [];
const nums = [].concat(value).join(',');
const ranges = nums.match(/(?:\d+\-\d+)|(?:\d+)/g);
for (let i = 0; i < ranges.length; ++i) {
let range = ranges[i];
range = range.split('-');
const from = parseInt(range[0], 10);
if (range.length === 2) {
const to = parseInt(range[1], 10);
if (from > to) {
for (let j = from; j >= to; --j) {
values.push(j);
}
}
else {
for (let j = from; j <= to; ++j) {
values.push(j);
}
}
}
else {
values.push(from);
}
}
flags[def.name] = values;
};
internals.colors = function (enabled) {
if (enabled === null) {
enabled = Tty.isatty(1) && Tty.isatty(2);
}
const codes = {
'black': 0,
'gray': 90,
'red': 31,
'green': 32,
'yellow': 33,
'magenta': 35,
'redBg': 41,
'greenBg': 42
};
const colors = {};
const names = Object.keys(codes);
for (let i = 0; i < names.length; ++i) {
const name = names[i];
colors[name] = internals.color(name, codes[name], enabled);
}
return colors;
};
internals.color = function (name, code, enabled) {
if (enabled) {
const color = '\u001b[' + code + 'm';
return function colorFormat(text) {
return color + text + '\u001b[0m';
};
}
return function plainFormat(text) {
return text;
};
};
internals.valueAtArgPath = (path, value) => {
path.split('.') // E.g. [name, lvl1, lvl2]
.slice(1) // E.g. [lvl1, lvl2]
.reverse() // E.g. [lvl2, lvl1]
.forEach((key) => { // E.g. { x } --> { lvl2: { x } } --> { lvl1: { lvl2: { x } } }
value = { [key]: value };
});
return value;
};