Comparing version 2.0.1 to 3.0.0-beta.1
@@ -0,1 +1,35 @@ | ||
## 3.0.0-beta.1 | ||
- Restored wrongly removed `Command#extend()` | ||
- Changed `Command`'s constructor and `Command#command(method)` to take `usage` only (i.e. `command('name [param]')` instead `command('name', '[param]')`) | ||
- Added `Command#clone()` method | ||
- Added `Command#getCommand(name)` and `Command#getCommands()` methods | ||
- Added `Command#getOption(name)` and `Command#getOptions()` methods | ||
- Added `Command#messageRef()` and `Option#messageRef()` methods | ||
- Added `Command#createOptionValues(values)` method | ||
- Added `Command#help()` method similar to `Command#version()`, use `Command#help(false)` to disable default help action option | ||
- Fixed `Command#showHelp()`, it's now logs help message in console instead of returning it | ||
- Renamed `Command#showHelp()` into `Command#outputHelp()` | ||
- Changed `Command` to store params info (as `Command#params`) even if no params | ||
- Removed `Command#infoOption()` method, use `action` in option's config instead, i.e. `option(usage, description, { action: ... })` | ||
- Removed `Command#infoOptionAction` and `infoOptionAction` option for `Command` constructor as well | ||
- Removed `Command#shortcut()` method, use `shortcut` in option's config instead, i.e. `option(usage, description, { shortcut: ... })` | ||
- Changed `Command#command()` to raise an exception when subcommand name already in use | ||
- Removed `Command#setOptions()` method | ||
- Removed `Command#setOption()` method | ||
- Removed `Command#hasOptions()` method | ||
- Removed `Command#hasOption()` method | ||
- Removed `Command#hasCommands()` method | ||
- Removed `Command#normalize()` method (use `createOptionValues()` instead) | ||
- Changed `Option` to store params info as `Option#params`, it always an object even if no params | ||
- Added `Option#names()` method | ||
- Removed name validation for subcommands | ||
- Allowed a number for options's short name | ||
- Changed argv parse handlers to [`init()` -> `applyConfig()` -> `prepareContext()`]+ -> `action()` | ||
- Changed exports | ||
- Added `getCommandHelp()` function | ||
- Added `Params` class | ||
- Removed `Argument` class | ||
- Removed `color` option | ||
## 2.0.1 (December 16, 2019) | ||
@@ -2,0 +36,0 @@ |
@@ -1,364 +0,228 @@ | ||
const processArgv = require('./parse-argv'); | ||
const parseParams = require('./parse-params'); | ||
const showCommandHelp = require('./help'); | ||
const getCommandHelp = require('./help'); | ||
const parseArgv = require('./parse-argv'); | ||
const Params = require('./params'); | ||
const Option = require('./option'); | ||
function noop() { | ||
// nothing todo | ||
} | ||
const noop = () => {}; // nothing todo | ||
const self = value => value; | ||
const defaultHelpAction = (instance, _, { commandPath }) => instance.outputHelp(commandPath); | ||
const defaultVersionAction = instance => console.log(instance.meta.version); | ||
const lastCommandHost = new WeakMap(); | ||
const lastAddedOption = new WeakMap(); | ||
function addOptionToCommand(command, option) { | ||
var commandOption; | ||
const handlers = ['init', 'applyConfig', 'finishContext', 'action'].reduce((res, name) => { | ||
res.initial[name] = name === 'action' ? self : noop; | ||
res.setters[name] = function(fn) { | ||
this.handlers[name] = fn.bind(null); | ||
// short | ||
if (option.short) { | ||
commandOption = command.short[option.short]; | ||
return this; | ||
}; | ||
return res; | ||
}, { initial: {}, setters: {} }); | ||
if (commandOption) { | ||
throw new Error('Short option name -' + option.short + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); | ||
} | ||
module.exports = class Command { | ||
constructor(usage = '') { | ||
const [name, params] = usage.trim().split(/(\s+.*)$/); | ||
command.short[option.short] = option; | ||
} | ||
this.name = name; | ||
this.params = new Params(params, `"${name}" command definition`); | ||
this.options = new Map(); | ||
this.commands = new Map(); | ||
this.meta = { | ||
description: '', | ||
version: '', | ||
help: null | ||
}; | ||
// long | ||
commandOption = command.long[option.long]; | ||
this.handlers = { ...handlers.initial }; | ||
Object.assign(this, handlers.setters); | ||
if (commandOption) { | ||
throw new Error('Long option --' + option.long + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); | ||
this.help(); | ||
} | ||
command.long[option.long] = option; | ||
// definition chaining | ||
extend(fn, ...args) { | ||
fn(this, ...args); | ||
return this; | ||
} | ||
description(description) { | ||
this.meta.description = description; | ||
// camel | ||
commandOption = command.options[option.camelName]; | ||
if (commandOption) { | ||
throw new Error('Name option ' + option.camelName + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); | ||
return this; | ||
} | ||
version(version, usage, description, action) { | ||
this.meta.version = version; | ||
this.option( | ||
usage || '-v, --version', | ||
description || 'Output version', | ||
{ action: action || defaultVersionAction } | ||
); | ||
command.options[option.camelName] = option; | ||
// set default value | ||
if (typeof option.defValue !== 'undefined') { | ||
command.setOption(option.camelName, option.defValue, true); | ||
return this; | ||
} | ||
return option; | ||
} | ||
function setFunctionFactory(name) { | ||
return function(fn) { | ||
var property = name + '_'; | ||
if (this[property] !== noop) { | ||
throw new Error('Method `' + name + '` could be invoked only once'); | ||
help(usage, description, action) { | ||
if (this.meta.help) { | ||
this.meta.help.names().forEach(name => this.options.delete(name)); | ||
this.meta.help = null; | ||
} | ||
if (typeof fn !== 'function') { | ||
throw new Error('Value for `' + name + '` method should be a function'); | ||
if (usage !== false) { | ||
this.option( | ||
usage || '-h, --help', | ||
description || 'Output usage information', | ||
{ action: action || defaultHelpAction } | ||
); | ||
this.meta.help = lastAddedOption.get(this); | ||
} | ||
this[property] = fn; | ||
return this; | ||
}; | ||
} | ||
} | ||
option(usage, description, ...optionOpts) { | ||
const option = new Option(usage, description, ...optionOpts); | ||
const nameType = ['Long option', 'Short option', 'Option']; | ||
const names = option.names(); | ||
/** | ||
* @class | ||
*/ | ||
var Command = function(name, params, config) { | ||
config = config || {}; | ||
names.forEach((name, idx) => { | ||
if (this.options.has(name)) { | ||
throw new Error( | ||
`${nameType[names.length === 2 ? idx * 2 : idx]} name "${name}" already in use by ${this.getOption(name).messageRef()}` | ||
); | ||
} | ||
}); | ||
this.name = name; | ||
this.params = false; | ||
try { | ||
if (params) { | ||
this.params = parseParams(params); | ||
for (const name of names) { | ||
this.options.set(name, option); | ||
} | ||
} catch (e) { | ||
throw new Error('Bad paramenter description in command definition: ' + this.name + ' ' + params); | ||
} | ||
this.commands = {}; | ||
this.options = {}; | ||
this.short = {}; | ||
this.long = {}; | ||
this.values = {}; | ||
this.defaults_ = {}; | ||
lastAddedOption.set(this, option); | ||
if ('defaultHelp' in config === false || config.defaultHelp) { | ||
this.infoOption('-h, --help', 'Output usage information', chunks => | ||
showCommandHelp(this, chunks.slice(0, chunks.length - 1).map(chunk => chunk.command.name)) | ||
); | ||
return this; | ||
} | ||
command(usageOrCommand) { | ||
const subcommand = typeof usageOrCommand === 'string' | ||
? new Command(usageOrCommand) | ||
: usageOrCommand; | ||
const name = subcommand.name; | ||
if (typeof config.infoOptionAction === 'function') { | ||
this.infoOptionAction = config.infoOptionAction; | ||
} | ||
}; | ||
Command.prototype = { | ||
params: null, | ||
commands: null, | ||
options: null, | ||
short: null, | ||
long: null, | ||
values: null, | ||
defaults_: null, | ||
description_: '', | ||
version_: '', | ||
initContext_: noop, | ||
init_: noop, | ||
delegate_: noop, | ||
action_: noop, | ||
args_: noop, | ||
end_: null, | ||
infoOptionAction: function(info) { | ||
console.log(info); | ||
process.exit(0); | ||
}, | ||
option: function(usage, description, opt1, opt2) { | ||
addOptionToCommand(this, new Option(usage, description, opt1, opt2)); | ||
return this; | ||
}, | ||
infoOption: function(usage, description, getInfo) { | ||
this.option( | ||
usage, | ||
description, | ||
commands => this.infoOptionAction(getInfo(commands)), | ||
Option.info | ||
); | ||
}, | ||
shortcut: function(usage, description, fn, opt1, opt2) { | ||
if (typeof fn !== 'function') { | ||
throw new Error('fn should be a function'); | ||
// search for existing one | ||
if (this.commands.has(name)) { | ||
throw new Error( | ||
`Subcommand name "${name}" already in use by ${this.getCommand(name).messageRef()}` | ||
); | ||
} | ||
var command = this; | ||
var option = addOptionToCommand(this, new Option(usage, description, opt1, opt2)); | ||
var normalize = option.normalize; | ||
// attach subcommand | ||
this.commands.set(name, subcommand); | ||
lastCommandHost.set(subcommand, this); | ||
option.normalize = function(value) { | ||
var values; | ||
return subcommand; | ||
} | ||
end() { | ||
const host = lastCommandHost.get(this) || null; | ||
lastCommandHost.delete(this); | ||
return host; | ||
} | ||
value = normalize.call(command, value); | ||
values = fn(value); | ||
for (var name in values) { | ||
if (hasOwnProperty.call(values, name)) { | ||
if (hasOwnProperty.call(command.options, name)) { | ||
command.setOption(name, values[name]); | ||
} else { | ||
command.values[name] = values[name]; | ||
} | ||
} | ||
// parse & run | ||
parse(argv, suggest) { | ||
let chunk = { | ||
context: { | ||
commandPath: [], | ||
options: null, | ||
args: null, | ||
literalArgs: null | ||
}, | ||
next: { | ||
command: this, | ||
argv: argv || process.argv.slice(2) | ||
} | ||
command.values[option.name] = value; | ||
return value; | ||
}; | ||
return this; | ||
}, | ||
hasOption: function(name) { | ||
return hasOwnProperty.call(this.options, name); | ||
}, | ||
hasOptions: function() { | ||
return Object.keys(this.options).length > 0; | ||
}, | ||
setOption: function(name, value, isDefault) { | ||
if (!this.hasOption(name)) { | ||
throw new Error('Option `' + name + '` is not defined'); | ||
} | ||
do { | ||
chunk = parseArgv( | ||
chunk.next.command, | ||
chunk.next.argv, | ||
chunk.context, | ||
suggest | ||
); | ||
} while (chunk.next); | ||
var option = this.options[name]; | ||
var oldValue = this.values[name]; | ||
var newValue = option.normalize.call(this, value, oldValue); | ||
return chunk; | ||
} | ||
run(argv) { | ||
const chunk = this.parse(argv); | ||
this.values[name] = option.maxArgsCount ? newValue : value; | ||
if (isDefault && !hasOwnProperty.call(this.defaults_, name)) { | ||
this.defaults_[name] = this.values[name]; | ||
if (typeof chunk.action === 'function') { | ||
return chunk.action.call(null, chunk.context); | ||
} | ||
}, | ||
setOptions: function(values) { | ||
for (var name in values) { | ||
if (hasOwnProperty.call(values, name) && this.hasOption(name)) { | ||
this.setOption(name, values[name]); | ||
} | ||
} | ||
}, | ||
reset: function() { | ||
this.values = {}; | ||
} | ||
Object.assign(this.values, this.defaults_); | ||
}, | ||
clone(deep) { | ||
const clone = Object.create(Object.getPrototypeOf(this)); | ||
command: function(nameOrCommand, params, config) { | ||
var name; | ||
var command; | ||
if (nameOrCommand instanceof Command) { | ||
command = nameOrCommand; | ||
name = command.name; | ||
} else { | ||
name = nameOrCommand; | ||
command = new Command(name, params, config); | ||
for (const [key, value] of Object.entries(this)) { | ||
clone[key] = value && typeof value === 'object' | ||
? (value instanceof Map | ||
? new Map(value) | ||
: Object.assign(Object.create(Object.getPrototypeOf(value)), value)) | ||
: value; | ||
} | ||
if (!/^[a-zA-Z][a-zA-Z0-9\-\_]*$/.test(name)) { | ||
throw new Error('Bad subcommand name: ' + name); | ||
} | ||
// search for existing one | ||
var subcommand = this.commands[name]; | ||
if (!subcommand) { | ||
// create new one if not exists | ||
subcommand = command; | ||
subcommand.end_ = this; | ||
this.commands[name] = subcommand; | ||
} | ||
return subcommand; | ||
}, | ||
end: function() { | ||
return this.end_; | ||
}, | ||
hasCommands: function() { | ||
return Object.keys(this.commands).length > 0; | ||
}, | ||
version: function(version, usage, description) { | ||
if (this.version_) { | ||
throw new Error('Version for command could be set only once'); | ||
} | ||
this.version_ = version; | ||
this.infoOption( | ||
usage || '-v, --version', | ||
description || 'Output version', | ||
function() { | ||
return version; | ||
if (deep) { | ||
for (const [name, subcommand] of clone.commands.entries()) { | ||
clone.commands.set(name, subcommand.clone(deep)); | ||
} | ||
); | ||
return this; | ||
}, | ||
description: function(description) { | ||
if (this.description_) { | ||
throw new Error('Description for command could be set only once'); | ||
} | ||
this.description_ = description; | ||
return clone; | ||
} | ||
createOptionValues(values) { | ||
const storage = Object.create(null); | ||
return this; | ||
}, | ||
init: setFunctionFactory('init'), | ||
initContext: setFunctionFactory('initContext'), | ||
args: setFunctionFactory('args'), | ||
delegate: setFunctionFactory('delegate'), | ||
action: setFunctionFactory('action'), | ||
parse: function(args, suggest) { | ||
if (!args) { | ||
args = process.argv.slice(2); | ||
} | ||
return processArgv(this, args, suggest); | ||
}, | ||
run: function(args, context) { | ||
var commands = this.parse(args); | ||
for (var i = 0; i < commands.length; i++) { | ||
var item = commands[i]; | ||
if (item.infoOptions.length) { | ||
item.infoOptions[0].info(commands.slice(0, i + 1)); | ||
return; | ||
for (const { name, normalize, default: value } of this.getOptions()) { | ||
if (typeof value !== 'undefined') { | ||
storage[name] = normalize(value); | ||
} | ||
} | ||
var prevCommand; | ||
var context = Object.assign({}, context || this.initContext_()); | ||
for (var i = 0; i < commands.length; i++) { | ||
var item = commands[i]; | ||
var command = item.command; | ||
return Object.assign(new Proxy(storage, { | ||
set: (obj, key, value, reciever) => { | ||
const option = this.getOption(key); | ||
// reset command values | ||
command.reset(); | ||
command.context = context; | ||
command.root = this; | ||
if (prevCommand) { | ||
prevCommand.delegate_(command); | ||
} | ||
// apply beforeInit options | ||
item.options.forEach(function(entry) { | ||
if (entry.option.beforeInit) { | ||
command.setOption(entry.option.camelName, entry.value); | ||
if (!option) { | ||
return true; // throw new Error(`Unknown option: "${key}"`); | ||
} | ||
}); | ||
command.init_(item.args.slice()); // use slice to avoid args mutation in handler | ||
const oldValue = obj[option.name]; | ||
const newValue = option.normalize(value, oldValue); | ||
const retValue = Reflect.set(obj, option.name, newValue); | ||
if (item.args.length) { | ||
command.args_(item.args.slice()); // use slice to avoid args mutation in handler | ||
} | ||
// apply regular options | ||
item.options.forEach(function(entry) { | ||
if (!entry.option.beforeInit) { | ||
command.setOption(entry.option.camelName, entry.value); | ||
if (option.shortcut) { | ||
Object.assign(reciever, option.shortcut.call(null, newValue, oldValue)); | ||
} | ||
}); | ||
prevCommand = command; | ||
} | ||
// return last command action result | ||
if (command) { | ||
return command.action_(item.args, item.literalArgs); | ||
} | ||
}, | ||
normalize: function(values) { | ||
var result = {}; | ||
if (!values) { | ||
values = {}; | ||
} | ||
for (var name in this.values) { | ||
if (hasOwnProperty.call(this.values, name)) { | ||
result[name] = hasOwnProperty.call(values, name) && hasOwnProperty.call(this.options, name) | ||
? this.options[name].normalize.call(this, values[name]) | ||
: this.values[name]; | ||
return retValue; | ||
} | ||
} | ||
}), values); | ||
} | ||
for (var name in values) { | ||
if (hasOwnProperty.call(values, name) && !hasOwnProperty.call(result, name)) { | ||
result[name] = values[name]; | ||
} | ||
} | ||
return result; | ||
}, | ||
showHelp: function(commandPath) { | ||
return showCommandHelp(this, commandPath); | ||
// misc | ||
messageRef() { | ||
return `${this.usage}${this.params.args.map(arg => ` ${arg.name}`)}`; | ||
} | ||
getOption(name) { | ||
return this.options.get(name) || null; | ||
} | ||
getOptions() { | ||
return [...new Set(this.options.values())]; | ||
} | ||
getCommand(name) { | ||
return this.commands.get(name) || null; | ||
} | ||
getCommands() { | ||
return [...this.commands.values()]; | ||
} | ||
outputHelp(commandPath) { | ||
console.log(getCommandHelp(this, Array.isArray(commandPath) ? commandPath.slice(0, -1) : null)); | ||
} | ||
}; | ||
module.exports = Command; |
const MAX_LINE_WIDTH = process.stdout.columns || 200; | ||
const MIN_OFFSET = 25; | ||
const MIN_OFFSET = 20; | ||
const reAstral = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; | ||
const ansiRegex = /\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]/g; | ||
const byName = (a, b) => a.name > b.name || -(a.name < b.name); | ||
let chalk; | ||
@@ -12,3 +13,3 @@ | ||
chalk = new ChalkInstance({ | ||
level: Number(module.exports.color && process.stdout.isTTY) | ||
level: Number(process.stdout.isTTY) | ||
}); | ||
@@ -57,29 +58,32 @@ } | ||
function args(command) { | ||
return command.params.args | ||
.map(({ name, required }) => required ? '<' + name + '>' : '[' + name + ']') | ||
.join(' '); | ||
function args(params, fn = s => s) { | ||
if (params.args.length === 0) { | ||
return ''; | ||
} | ||
return ' ' + fn( | ||
params.args | ||
.map(({ name, required }) => required ? '<' + name + '>' : '[' + name + ']') | ||
.join(' ') | ||
); | ||
} | ||
function valuesSortedByKey(dict) { | ||
return Object.keys(dict) | ||
.sort() | ||
.map(key => dict[key]); | ||
function formatLines(lines) { | ||
const maxNameLength = Math.max(MIN_OFFSET, ...lines.map(line => stringLength(line.name))); | ||
return lines.map(line => ( | ||
' ' + pad(maxNameLength, line.name) + | ||
' ' + breakByLines(line.description, maxNameLength + 8) | ||
)); | ||
} | ||
function commandsHelp(command) { | ||
if (!command.hasCommands()) { | ||
if (command.commands.size === 0) { | ||
return ''; | ||
} | ||
const lines = valuesSortedByKey(command.commands).map(subcommand => ({ | ||
name: chalk.green(subcommand.name) + chalk.gray( | ||
(subcommand.params ? ' ' + args(subcommand) : '') | ||
), | ||
description: subcommand.description_ || '' | ||
const lines = command.getCommands().sort(byName).map(({ name, meta, params }) => ({ | ||
description: meta.description, | ||
name: chalk.green(name) + args(params, chalk.gray) | ||
})); | ||
const maxNameLength = lines.reduce( | ||
(max, line) => Math.max(max, stringLength(line.name)), | ||
MIN_OFFSET - 2 | ||
); | ||
@@ -90,6 +94,3 @@ return [ | ||
'', | ||
...lines.map(line => ( | ||
' ' + pad(maxNameLength, line.name) + | ||
' ' + breakByLines(line.description, maxNameLength + 8) | ||
)), | ||
...formatLines(lines), | ||
'' | ||
@@ -100,21 +101,16 @@ ].join('\n'); | ||
function optionsHelp(command) { | ||
if (!command.hasOptions()) { | ||
if (command.options.size === 0) { | ||
return ''; | ||
} | ||
const hasShortOptions = Object.keys(command.short).length > 0; | ||
const lines = valuesSortedByKey(command.long).map(option => ({ | ||
name: option.usage | ||
.replace(/^(?:-., |)/, (m) => | ||
m || (hasShortOptions ? ' ' : '') | ||
) | ||
.replace(/(^|\s)(-[^\s,]+)/ig, (m, p, flag) => | ||
p + chalk.yellow(flag) | ||
), | ||
description: option.description | ||
const options = command.getOptions().sort(byName); | ||
const shortPlaceholder = options.some(option => option.short) ? ' ' : ''; | ||
const lines = options.map(({ short, long, params, description }) => ({ | ||
description, | ||
name: [ | ||
short ? chalk.yellow(short) + ', ' : shortPlaceholder, | ||
chalk.yellow(long), | ||
args(params) | ||
].join('') | ||
})); | ||
const maxNameLength = lines.reduce( | ||
(max, line) => Math.max(max, stringLength(line.name)), | ||
MIN_OFFSET - 2 | ||
); | ||
@@ -126,6 +122,3 @@ // Prepend the help information | ||
'', | ||
...lines.map(line => ( | ||
' ' + pad(maxNameLength, line.name) + | ||
' ' + breakByLines(line.description, maxNameLength + 8) | ||
)), | ||
...formatLines(lines), | ||
'' | ||
@@ -141,3 +134,3 @@ ].join('\n'); | ||
*/ | ||
module.exports = function showCommandHelp(command, commandPath) { | ||
module.exports = function getCommandHelp(command, commandPath) { | ||
initChalk(); | ||
@@ -150,8 +143,8 @@ | ||
return [ | ||
(command.description_ ? command.description_ + '\n\n' : '') + | ||
(command.meta.description ? command.meta.description + '\n\n' : '') + | ||
'Usage:\n\n' + | ||
' ' + chalk.cyan(commandPath) + | ||
(command.params ? ' ' + chalk.magenta(args(command)) : '') + | ||
(command.hasOptions() ? ' [' + chalk.yellow('options') + ']' : '') + | ||
(command.hasCommands() ? ' [' + chalk.green('command') + ']' : ''), | ||
args(command.params, chalk.magenta) + | ||
(command.options.size !== 0 ? ' [' + chalk.yellow('options') + ']' : '') + | ||
(command.commands.size !== 0 ? ' [' + chalk.green('command') + ']' : ''), | ||
commandsHelp(command) + | ||
@@ -161,3 +154,1 @@ optionsHelp(command) | ||
}; | ||
module.exports.color = true; |
@@ -1,22 +0,21 @@ | ||
var Option = require('./option'); | ||
var Argument = require('./argument'); | ||
var Command = require('./command'); | ||
var Error = require('./parse-argv').CliError; | ||
var showCommandHelp = require('./help'); | ||
const path = require('path'); | ||
const Params = require('./params'); | ||
const Option = require('./option'); | ||
const Command = require('./command'); | ||
const Error = require('./parse-argv-error'); | ||
const getCommandHelp = require('./help'); | ||
function nameFromProcessArgv() { | ||
return path.basename(process.argv[1], path.extname(process.argv[1])); | ||
} | ||
module.exports = { | ||
Error, | ||
Argument, | ||
Params, | ||
Command, | ||
Option, | ||
set color(value) { | ||
return showCommandHelp.color = Boolean(value); | ||
}, | ||
get color() { | ||
return showCommandHelp.color; | ||
}, | ||
getCommandHelp, | ||
command: function(name, params, config) { | ||
name = name || require('path').basename(process.argv[1]) || 'command'; | ||
name = name || nameFromProcessArgv() || 'command'; | ||
@@ -23,0 +22,0 @@ return new Command(name, params, config); |
@@ -1,119 +0,71 @@ | ||
var parseParams = require('./parse-params'); | ||
const Params = require('./params'); | ||
const camelcase = name => name.replace(/-(.)/g, (m, ch) => ch.toUpperCase()); | ||
const ensureFunction = (fn, fallback) => typeof fn === 'function' ? fn : fallback; | ||
const self = value => value; | ||
function camelize(name) { | ||
return name.replace(/-(.)/g, function(m, ch) { | ||
return ch.toUpperCase(); | ||
}); | ||
} | ||
module.exports = class Option { | ||
static normalizeOptions(opt1, opt2) { | ||
const raw = typeof opt1 === 'function' | ||
? { normalize: opt1, default: opt2 } | ||
: opt1 && typeof opt1 === 'object' | ||
? opt1 | ||
: { default: opt1 }; | ||
/** | ||
* @class | ||
* @param {string} usage | ||
* @param {string} description | ||
*/ | ||
var Option = function(usage, description, opt1, opt2) { | ||
var self = this; | ||
var params; | ||
var left = usage.trim() | ||
// short usage | ||
// -x | ||
.replace(/^-([a-zA-Z])(?:\s*,\s*|\s+)/, function(m, name) { | ||
self.short = name; | ||
return { | ||
default: raw.default, | ||
normalize: ensureFunction(raw.normalize, self), | ||
shortcut: ensureFunction(raw.shortcut), | ||
action: ensureFunction(raw.action), | ||
config: Boolean(raw.config) | ||
}; | ||
} | ||
return ''; | ||
}) | ||
// long usage | ||
// --flag | ||
// --no-flag - invert value if flag is boolean | ||
.replace(/^--([a-zA-Z][a-zA-Z0-9\-\_]+)\s*/, function(m, name) { | ||
self.long = name; | ||
self.name = name.replace(/(^|-)no-/, '$1'); | ||
self.defValue = self.name !== self.long; | ||
static parseUsage(usage) { | ||
const [m, short, long = ''] = usage.trim() | ||
.match(/^(?:(-[a-z\d])(?:\s*,\s*|\s+))?(--[a-z][a-z\d\-\_]*)\s*/i) || []; | ||
return ''; | ||
}); | ||
if (!m) { | ||
throw new Error(`Usage has no long name: ${usage}`); | ||
} | ||
if (!this.long) { | ||
throw new Error('Usage has no long name: ' + usage); | ||
} | ||
let params = new Params(usage.slice(m.length), `option usage: ${usage}`); | ||
try { | ||
params = parseParams(left); | ||
} catch (e) { | ||
throw new Error('Bad paramenter in option usage: ' + usage, e); | ||
return { short, long, params }; | ||
} | ||
if (params) { | ||
left = ''; | ||
this.name = this.long; | ||
this.defValue = undefined; | ||
constructor(usage, description, ...rawOptions) { | ||
const { short, long, params } = Option.parseUsage(usage); | ||
const options = Option.normalizeOptions(...rawOptions); | ||
Object.assign(this, params); | ||
} | ||
const isBool = params.maxCount === 0 && !options.action; | ||
let name = camelcase(long.replace(isBool ? /^--(no-)?/ : /^--/, '')); // --no-flag - invert value if flag is boolean | ||
if (left) { | ||
throw new Error('Bad usage for option: ' + usage); | ||
} | ||
if (options.action) { | ||
options.default = undefined; | ||
} else if (isBool) { | ||
options.normalize = Boolean; | ||
options.default = long.startsWith('--no-'); | ||
} | ||
if (!this.name) { | ||
this.name = this.long; | ||
} | ||
// names | ||
this.short = short; | ||
this.long = long; | ||
this.name = name; | ||
this.description = description || ''; | ||
this.usage = usage.trim(); | ||
this.camelName = camelize(this.name); | ||
// meta | ||
this.usage = usage.trim(); | ||
this.description = description || ''; | ||
if (typeof opt1 !== 'undefined') { | ||
if (opt2 === Option.info) { | ||
this.info = opt1; | ||
this.defValue = undefined; | ||
} else if (typeof opt2 !== 'undefined') { | ||
if (typeof opt1 === 'function') { | ||
this.normalize = opt1; | ||
} | ||
// attributes | ||
this.params = params; | ||
Object.assign(this, options); | ||
} | ||
this.defValue = opt2; | ||
} else { | ||
if (opt1 && opt1.constructor === Object) { | ||
for (var key in opt1) { | ||
if (key === 'normalize' || | ||
key === 'defValue' || | ||
key === 'beforeInit') { | ||
this[key] = opt1[key]; | ||
} | ||
} | ||
messageRef() { | ||
return `${this.usage} ${this.description}`; | ||
} | ||
// old name for `beforeInit` setting is `hot` | ||
if (opt1.hot) { | ||
this.beforeInit = true; | ||
} | ||
} else { | ||
if (typeof opt1 === 'function') { | ||
this.normalize = opt1; | ||
} else { | ||
this.defValue = opt1; | ||
} | ||
} | ||
} | ||
names() { | ||
return [this.long, this.short, this.name].filter(Boolean); | ||
} | ||
}; | ||
Option.info = {}; | ||
Option.prototype = { | ||
name: '', | ||
description: '', | ||
short: '', | ||
long: '', | ||
beforeInit: false, | ||
required: false, | ||
minArgsCount: 0, | ||
maxArgsCount: 0, | ||
args: null, | ||
info: undefined, | ||
defValue: undefined, | ||
normalize: value => value | ||
}; | ||
module.exports = Option; |
@@ -0,92 +1,80 @@ | ||
const CliError = require('./parse-argv-error'); | ||
function findVariants(command, entry) { | ||
return [ | ||
...Object.keys(command.long).map(name => '--' + name), | ||
...Object.keys(command.commands) | ||
...command.getOptions().map(option => option.long), | ||
...command.commands.keys() | ||
].filter(item => item.startsWith(entry)).sort(); | ||
} | ||
function createResultChunk(command) { | ||
return { | ||
command, | ||
args: [], | ||
literalArgs: [], | ||
options: [], | ||
infoOptions: [] | ||
}; | ||
} | ||
function consumeOptionParams(option, rawOptions, argv, index, suggestPoint) { | ||
const tokens = []; | ||
let value; | ||
/** | ||
* @class | ||
*/ | ||
if (option.params.maxCount) { | ||
for (let j = 0; j < option.params.maxCount; j++) { | ||
const token = argv[index + j]; | ||
var CliError = function(message) { | ||
this.message = message; | ||
}; | ||
CliError.prototype = Object.create(Error.prototype); | ||
CliError.prototype.name = 'CliError'; | ||
CliError.prototype.clap = true; | ||
// TODO: suggestions for option params | ||
if (index + j === suggestPoint) { | ||
return suggestPoint; | ||
} | ||
module.exports = function processArgv(command, args, suggest) { | ||
function processOption(option) { | ||
var params = []; | ||
if (!token || token[0] === '-') { | ||
break; | ||
} | ||
if (typeof option.info === 'function') { | ||
resultChunk.infoOptions.push(option); | ||
return; | ||
tokens.push(token); | ||
} | ||
if (option.maxArgsCount) { | ||
for (var j = 0; j < option.maxArgsCount; j++) { | ||
var suggestPoint = suggest && i + 1 + j >= args.length - 1; | ||
var nextToken = args[i + 1]; | ||
if (tokens.length < option.params.minCount) { | ||
throw new CliError( | ||
`Option ${argv[index - 1]} should be used with at least ${option.params.minCount} argument(s)\n` + | ||
`Usage: ${option.usage}` | ||
); | ||
} | ||
// TODO: suggestions for options | ||
if (suggestPoint) { | ||
// search for suggest | ||
noSuggestions = true; | ||
i = args.length; | ||
return; | ||
} | ||
value = option.params.maxCount === 1 ? tokens[0] : tokens; | ||
} else { | ||
value = !option.default; | ||
} | ||
if (!nextToken || nextToken[0] === '-') { | ||
break; | ||
} | ||
rawOptions.push({ | ||
option, | ||
value | ||
}); | ||
params.push(args[++i]); | ||
} | ||
return index + tokens.length - 1; | ||
} | ||
if (params.length < option.minArgsCount) { | ||
throw new CliError('Option ' + token + ' should be used with at least ' + option.minArgsCount + ' argument(s)\nUsage: ' + option.usage); | ||
} | ||
module.exports = function parseArgv(command, argv, context, suggestMode) { | ||
const suggestPoint = suggestMode ? argv.length - 1 : -1; | ||
const rawOptions = []; | ||
const result = { | ||
context, | ||
action: null, | ||
next: null | ||
}; | ||
if (option.maxArgsCount === 1) { | ||
params = params[0]; | ||
} | ||
} else { | ||
params = !option.defValue; | ||
} | ||
command = command.clone(); | ||
context.commandPath.push(command.name); | ||
context.options = Object.freeze(command.createOptionValues()); | ||
context.args = []; | ||
context.literalArgs = null; | ||
// command.values[option.camelName] = newValue; | ||
resultChunk.options.push({ | ||
option: option, | ||
value: params | ||
}); | ||
} | ||
command.handlers.init(command, context); | ||
var suggestStartsWith = ''; | ||
var noSuggestions = false; | ||
var resultChunk = createResultChunk(command); | ||
var result = [resultChunk]; | ||
for (var i = 0; i < argv.length; i++) { | ||
const token = argv[i]; | ||
for (var i = 0; i < args.length; i++) { | ||
var suggestPoint = suggest && i === args.length - 1; | ||
var token = args[i]; | ||
if (suggestPoint && (token === '--' || token === '-' || token[0] !== '-')) { | ||
suggestStartsWith = token; | ||
break; // returns long option & command list outside the loop | ||
if (i === suggestPoint) { | ||
return findVariants(command, token); // returns long option & command list | ||
} | ||
if (token === '--') { | ||
resultChunk.literalArgs.push(...args.slice(i + 1)); | ||
if (suggestPoint > i) { | ||
return []; | ||
} | ||
context.literalArgs = argv.slice(i + 1); | ||
break; | ||
@@ -96,57 +84,58 @@ } | ||
if (token[0] === '-') { | ||
if (token[1] === '-') { | ||
if (token[1] === '-' || token.length === 2) { | ||
// long option | ||
var option = command.long[token.substr(2)]; | ||
const option = command.getOption(token); | ||
if (!option) { | ||
// option doesn't exist | ||
if (suggestPoint) { | ||
return findVariants(command, token); | ||
} | ||
if (option === null) { | ||
throw new CliError(`Unknown option: ${token}`); | ||
} | ||
throw new CliError('Unknown option: ' + token); | ||
// process option params | ||
i = consumeOptionParams(option, rawOptions, argv, i + 1, suggestPoint); | ||
if (i === suggestPoint) { | ||
return []; | ||
} | ||
// process option | ||
processOption(option, command); | ||
} else { | ||
// short options sequence | ||
if (!/^-[a-zA-Z]+$/.test(token)) { | ||
throw new CliError('Wrong short option sequence: ' + token); | ||
if (!/^-[a-zA-Z0-9]+$/.test(token)) { | ||
throw new CliError(`Bad short option sequence: ${token}`); | ||
} | ||
for (var j = 1; j < token.length; j++) { | ||
var option = command.short[token[j]]; | ||
for (let j = 1; j < token.length; j++) { | ||
const option = command.getOption(`-${token[j]}`); | ||
if (!option) { | ||
throw new CliError('Unknown short option: -' + token[j]); | ||
if (option === null) { | ||
throw new CliError(`Unknown option "${token[j]}" in short option sequence: ${token}`); | ||
} | ||
if (option.maxArgsCount > 0 && token.length > 2) { | ||
throw new CliError('Non-boolean option -' + token[j] + ' can\'t be used in short option sequence: ' + token); | ||
if (option.params.maxCount > 0) { | ||
throw new CliError( | ||
`Non-boolean option "-${token[j]}" can\'t be used in short option sequence: ${token}` | ||
); | ||
} | ||
processOption(option, command); | ||
rawOptions.push({ | ||
option, | ||
value: !option.default | ||
}); | ||
} | ||
} | ||
} else { | ||
if (command.commands[token] && | ||
(!command.params || resultChunk.args.length >= command.params.minArgsCount)) { | ||
// switch control to another command | ||
command = command.commands[token]; | ||
const subcommand = command.getCommand(token); | ||
resultChunk = createResultChunk(command); | ||
result.push(resultChunk); | ||
if (subcommand !== null && | ||
context.args.length >= command.params.minCount) { | ||
// set next command and rest argv | ||
result.next = { | ||
command: subcommand, | ||
argv: argv.slice(i + 1) | ||
}; | ||
break; | ||
} else { | ||
if (resultChunk.options.length === 0 && | ||
(command.params && resultChunk.args.length < command.params.maxArgsCount)) { | ||
resultChunk.args.push(token); | ||
continue; | ||
if (rawOptions.length !== 0 || | ||
context.args.length >= command.params.maxCount) { | ||
throw new CliError(`Unknown command: ${token}`); | ||
} | ||
if (suggestPoint) { | ||
return findVariants(command, token); | ||
} | ||
throw new CliError('Unknown command: ' + token); | ||
context.args.push(token); | ||
} | ||
@@ -156,14 +145,47 @@ } | ||
if (suggest) { | ||
if (resultChunk.literalArgs.length || noSuggestions) { | ||
return []; | ||
// final checks | ||
if (suggestMode && !result.next) { | ||
return findVariants(command, ''); | ||
} else if (context.args.length < command.params.minCount) { | ||
throw new CliError(`Missed required argument(s) for command "${command.name}"`); | ||
} | ||
// create new option values storage | ||
context.options = command.createOptionValues(); | ||
// process action option | ||
const actionOption = rawOptions.find(({ option }) => option.action); | ||
if (actionOption) { | ||
const { option, value } = actionOption; | ||
result.action = () => option.action(command, value, context); | ||
result.next = null; | ||
return result; | ||
} | ||
// apply config options | ||
for (const { option, value } of rawOptions) { | ||
if (option.config) { | ||
context.options[option.name] = value; | ||
} | ||
} | ||
return findVariants(command, suggestStartsWith); | ||
} else if (command.params && resultChunk.args.length < command.params.minArgsCount) { | ||
throw new CliError('Missed required argument(s) for command `' + command.name + '`'); | ||
// run apply config handler | ||
command.handlers.applyConfig(context); | ||
// apply regular options | ||
for (const { option, value } of rawOptions) { | ||
if (!option.config) { | ||
context.options[option.name] = value; | ||
} | ||
} | ||
// run context finish handler | ||
command.handlers.finishContext(context); | ||
// set action if no rest argv | ||
if (!result.next) { | ||
result.action = command.handlers.action; | ||
} | ||
return result; | ||
}; | ||
module.exports.CliError = CliError; |
@@ -8,3 +8,3 @@ { | ||
"license": "MIT", | ||
"version": "2.0.1", | ||
"version": "3.0.0-beta.1", | ||
"keywords": [ | ||
@@ -31,3 +31,4 @@ "cli", | ||
"mocha": "^6.2.2", | ||
"nyc": "^14.1.0" | ||
"nyc": "^14.1.0", | ||
"test-console": "^1.1.0" | ||
}, | ||
@@ -34,0 +35,0 @@ "scripts": { |
102
README.md
@@ -7,4 +7,10 @@ [![NPM version](https://img.shields.io/npm/v/clap.svg)](https://www.npmjs.com/package/clap) | ||
Argument parser for command-line interfaces. It primary target to large tool sets that provides a lot of subcommands. Support for argument coercion and completion makes task run much easer, even if you doesn't use CLI. | ||
A library for node.js to build command-line interfaces (CLI). With its help, making a simple CLI application is a trivial task. It equally excels in complex tools with a lot of subcommands and specific features. This library supports argument coercion and completion suggestion — typing the commands is much easier. | ||
Inspired by [commander.js](https://github.com/tj/commander.js) | ||
Features: | ||
- TBD | ||
## Usage | ||
@@ -19,3 +25,3 @@ | ||
const myCommand = cli.command('my-command', '[optional-arg]') | ||
const myCommand = cli.command('my-command [optional-arg]') | ||
.description('Optional description') | ||
@@ -26,13 +32,13 @@ .version('1.2.3') | ||
.option('--bar [bar]', 'Option with optional argument') | ||
.option('--baz [value]', 'Option with optional argument and normalize function', function(value) { | ||
// calls on init and for any value set | ||
return Number(value); | ||
}, 123) // 123 is default | ||
.action(function(args, literalArgs) { | ||
.option('--baz [value]', 'Option with optional argument and normalize function', | ||
value => Number(value), | ||
123 // 123 is default | ||
) | ||
.action(function({ options, args, literalArgs }) { | ||
// options is an object with collected values | ||
// args goes before options | ||
// literal args goes after -- | ||
// this.values is an object with collected values | ||
// literal args goes after "--" | ||
}); | ||
myCommand.run(); // runs with process.argv.slice(2) | ||
myCommand.run(); // the same as "myCommnad.run(process.argv.slice(2))" | ||
myCommand.run(['--foo', '123', '-b']) | ||
@@ -52,4 +58,80 @@ | ||
## API | ||
### Command | ||
``` | ||
.command() | ||
// definition | ||
.description(value) | ||
.version(value, usage, description, action) | ||
.help(usage, description, action) | ||
.option(usage, description, ...options) | ||
.command(usageOrCommand) | ||
.extend(fn, ...options) | ||
.end() | ||
// argv processing pipeline handler setters | ||
.init(command, context) | ||
.applyConfig(context) | ||
.prerareContenxt(context) | ||
.action(context) | ||
// main methods | ||
.parse(argv, suggest) | ||
.run(argv) | ||
// misc | ||
.clone(deep) | ||
.createOptionValues() | ||
.getCommand(name) | ||
.getCommands() | ||
.getOption(name) | ||
.getOptions() | ||
.outputHelp() | ||
``` | ||
### .option(usage, description, ...options) | ||
There are two usage: | ||
``` | ||
.option(usage, description, normalize, value) | ||
.option(usage, description, options) | ||
``` | ||
Where `options`: | ||
``` | ||
{ | ||
default: any, // default value | ||
normalize: (value, oldValue) => { ... }, // any value for option is passing through this function and its result stores as option value | ||
shortcut: (value, oldValue) => { ... }, // for shortcut options, the handler is executed after the value is set, and its result (an object) is used as a source of values for other options | ||
action: () => { ... }, // for an action option, which breaks regular args processing and preform and action (e.g. show help or version) | ||
config: boolean // mark option is about config and should be applied before `applyConfig()` | ||
} | ||
``` | ||
### Argv processing | ||
- `init(command, context)` // before arguments parsing | ||
- invoke action option and exit if any | ||
- apply **config** options | ||
- `applyConfig(context)` | ||
- apply all the rest options | ||
- `prepareContext(context)` // after arguments parsing | ||
- switch to next command -> command is prescending | ||
- `init(command, context)` | ||
- invoke action option and exit if any | ||
- apply **config** options | ||
- `applyConfig(context)` | ||
- apply all the rest options | ||
- `prepareContext(context)` // after arguments parsing | ||
- switch to next command | ||
- ... | ||
- `action(context)` -> command is target | ||
- `action(context)` -> command is target | ||
## License | ||
MIT |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
29769
134
5
578
2
1