var MAX_LINE_WIDTH = process.stdout.columns || 200; var MIN_OFFSET = 25; var errorHandler; var commandsPath; var reAstral = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; var ansiRegex = /\x1B\[([0-9]{1,3}(;[0-9]{1,3})*)?[m|K]/g; var hasOwnProperty = Object.prototype.hasOwnProperty; function stringLength(str){ return str .replace(ansiRegex, '') .replace(reAstral, ' ') .length; } function camelize(name){ return name.replace(/-(.)/g, function(m, ch){ return ch.toUpperCase(); }); } function assign(dest, source){ for (var key in source) if (hasOwnProperty.call(source, key)) dest[key] = source[key]; return dest; } function returnFirstArg(value){ return value; } function pad(width, str){ return str + Array(Math.max(0, width - stringLength(str)) + 1).join(' '); } function noop(){ // nothing todo } function parseParams(str){ // params [..] [..[optional]] // - require // [foo] - optional var tmp; var left = str.trim(); var result = { minArgsCount: 0, maxArgsCount: 0, args: [] }; do { tmp = left; left = left.replace(/^<([a-zA-Z][a-zA-Z0-9\-\_]*)>\s*/, function(m, name){ result.args.push(new Argument(name, true)); result.minArgsCount++; result.maxArgsCount++; return ''; }); } while (tmp != left); do { tmp = left; left = left.replace(/^\[([a-zA-Z][a-zA-Z0-9\-\_]*)\]\s*/, function(m, name){ result.args.push(new Argument(name, false)); result.maxArgsCount++; return ''; }); } while (tmp != left); if (left) throw new SyntaxError('Bad parameter description: ' + str); return result.args.length ? result : false; } /** * @class */ var SyntaxError = function(message){ this.message = message; }; SyntaxError.prototype = Object.create(Error.prototype); SyntaxError.prototype.name = 'SyntaxError'; SyntaxError.prototype.clap = true; /** * @class */ var Argument = function(name, required){ this.name = name; this.required = required; }; Argument.prototype = { required: false, name: '', normalize: returnFirstArg, suggest: function(){ return []; } }; /** * @class * @param {string} usage * @param {string} description */ var Option = function(usage, description){ 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 ''; }) // 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; return ''; }); if (!this.long) throw new SyntaxError('Usage has no long name: ' + usage); try { params = parseParams(left); } catch(e) { throw new SyntaxError('Bad paramenter description in usage for option: ' + usage, e); } if (params) { left = ''; this.name = this.long; this.defValue = undefined; assign(this, params); } if (left) throw new SyntaxError('Bad usage description for option: ' + usage); if (!this.name) this.name = this.long; this.description = description || ''; this.usage = usage.trim(); this.camelName = camelize(this.name); }; Option.prototype = { name: '', description: '', short: '', long: '', beforeInit: false, required: false, minArgsCount: 0, maxArgsCount: 0, args: null, defValue: undefined, normalize: returnFirstArg }; // // Command // function createOption(usage, description, opt_1, opt_2){ var option = new Option(usage, description); // if (option.bool && arguments.length > 2) // throw new SyntaxError('bool flags can\'t has default value or validator'); if (arguments.length == 3) { if (opt_1 && opt_1.constructor === Object) { for (var key in opt_1) if (key == 'normalize' || key == 'defValue' || key == 'beforeInit') option[key] = opt_1[key]; // old name for `beforeInit` setting is `hot` if (opt_1.hot) option.beforeInit = true; } else { if (typeof opt_1 == 'function') option.normalize = opt_1; else option.defValue = opt_1; } } if (arguments.length == 4) { if (typeof opt_1 == 'function') option.normalize = opt_1; option.defValue = opt_2; } return option; } function addOptionToCommand(command, option){ var commandOption; // short if (option.short) { commandOption = command.short[option.short]; if (commandOption) throw new SyntaxError('Short option name -' + option.short + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); command.short[option.short] = option; } // long commandOption = command.long[option.long]; if (commandOption) throw new SyntaxError('Long option --' + option.long + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); command.long[option.long] = option; // camel commandOption = command.options[option.camelName]; if (commandOption) throw new SyntaxError('Name option ' + option.camelName + ' already in use by ' + commandOption.usage + ' ' + commandOption.description); command.options[option.camelName] = option; // set default value if (typeof option.defValue != 'undefined') command.setOption(option.camelName, option.defValue, true); // add to suggestions command.suggestions.push('--' + option.long); return option; } function findVariants(obj, entry){ return obj.suggestions.filter(function(item){ return item.substr(0, entry.length) == entry; }); } function processArgs(command, args, suggest){ function processOption(option, command){ var params = []; 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]; // TODO: suggestions for options if (suggestPoint) { // search for suggest noSuggestions = true; i = args.length; return; } if (!nextToken || nextToken[0] == '-') break; params.push(args[++i]); } if (params.length < option.minArgsCount) throw new SyntaxError('Option ' + token + ' should be used with at least ' + option.minArgsCount + ' argument(s)\nUsage: ' + option.usage); if (option.maxArgsCount == 1) params = params[0]; } else { params = !option.defValue; } //command.values[option.camelName] = newValue; resultToken.options.push({ option: option, value: params }); } var resultToken = { command: command, args: [], literalArgs: [], options: [] }; var result = [resultToken]; var suggestStartsWith = ''; var noSuggestions = false; var collectArgs = false; var commandArgs = []; var noOptionsYet = true; var option; commandsPath = [command.name]; for (var i = 0; i < args.length; i++) { var suggestPoint = suggest && i == args.length - 1; var token = args[i]; if (collectArgs) { commandArgs.push(token); continue; } if (suggestPoint && (token == '--' || token == '-' || token[0] != '-')) { suggestStartsWith = token; break; // returns long option & command list outside the loop } if (token == '--') { resultToken.args = commandArgs; commandArgs = []; noOptionsYet = false; collectArgs = true; continue; } if (token[0] == '-') { noOptionsYet = false; if (commandArgs.length) { //command.args_.apply(command, commandArgs); resultToken.args = commandArgs; commandArgs = []; } if (token[1] == '-') { // long option option = command.long[token.substr(2)]; if (!option) { // option doesn't exist if (suggestPoint) return findVariants(command, token); else throw new SyntaxError('Unknown option: ' + token); } // process option processOption(option, command); } else { // short flags sequence if (!/^-[a-zA-Z]+$/.test(token)) throw new SyntaxError('Wrong short option sequence: ' + token); if (token.length == 2) { option = command.short[token[1]]; if (!option) throw new SyntaxError('Unknown short option name: -' + token[1]); // single option processOption(option, command); } else { // short options sequence for (var j = 1; j < token.length; j++) { option = command.short[token[j]]; if (!option) throw new SyntaxError('Unknown short option name: -' + token[j]); if (option.maxArgsCount) throw new SyntaxError('Non-boolean option -' + token[j] + ' can\'t be used in short option sequence: ' + token); processOption(option, command); } } } } else { if (command.commands[token] && (!command.params || commandArgs.length >= command.params.minArgsCount)) { if (noOptionsYet) { resultToken.args = commandArgs; commandArgs = []; } if (command.params && resultToken.args.length < command.params.minArgsCount) throw new SyntaxError('Missed required argument(s) for command `' + command.name + '`'); // switch control to another command command = command.commands[token]; noOptionsYet = true; commandsPath.push(command.name); resultToken = { command: command, args: [], literalArgs: [], options: [] }; result.push(resultToken); } else { if (noOptionsYet && command.params && commandArgs.length < command.params.maxArgsCount) { commandArgs.push(token); continue; } if (suggestPoint) return findVariants(command, token); else throw new SyntaxError('Unknown command: ' + token); } } } if (suggest) { if (collectArgs || noSuggestions) return []; return findVariants(command, suggestStartsWith); } else { if (!noOptionsYet) resultToken.literalArgs = commandArgs; else resultToken.args = commandArgs; if (command.params && resultToken.args.length < command.params.minArgsCount) throw new SyntaxError('Missed required argument(s) for command `' + command.name + '`'); } return result; } function setFunctionFactory(name){ return function(fn){ var property = name + '_'; if (this[property] !== noop) throw new SyntaxError('Method `' + name + '` could be invoked only once'); if (typeof fn != 'function') throw new SyntaxError('Value for `' + name + '` method should be a function'); this[property] = fn; return this; } } /** * @class */ var Command = function(name, params){ this.name = name; this.params = false; try { if (params) this.params = parseParams(params); } catch(e) { throw new SyntaxError('Bad paramenter description in command definition: ' + this.name + ' ' + params); } this.commands = {}; this.options = {}; this.short = {}; this.long = {}; this.values = {}; this.defaults_ = {}; this.suggestions = []; this.option('-h, --help', 'Output usage information', function(){ this.showHelp(); process.exit(0); }, undefined); }; Command.prototype = { params: null, commands: null, options: null, short: null, long: null, values: null, defaults_: null, suggestions: null, description_: '', version_: '', initContext_: noop, init_: noop, delegate_: noop, action_: noop, args_: noop, end_: null, option: function(usage, description, opt_1, opt_2){ addOptionToCommand(this, createOption.apply(null, arguments)); return this; }, shortcut: function(usage, description, fn, opt_1, opt_2){ if (typeof fn != 'function') throw new SyntaxError('fn should be a function'); var command = this; var option = addOptionToCommand(this, createOption(usage, description, opt_1, opt_2)); var normalize = option.normalize; option.normalize = function(value){ var values; 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]; 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 SyntaxError('Option `' + name + '` is not defined'); var option = this.options[name]; var oldValue = this.values[name]; var newValue = option.normalize.call(this, value, oldValue); this.values[name] = option.maxArgsCount ? newValue : value; if (isDefault && !hasOwnProperty.call(this.defaults_, name)) this.defaults_[name] = this.values[name]; }, 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 = {}; assign(this.values, this.defaults_); }, command: function(nameOrCommand, params){ var name; var command; if (nameOrCommand instanceof Command) { command = nameOrCommand; name = command.name; } else { name = nameOrCommand; if (!/^[a-zA-Z][a-zA-Z0-9\-\_]*$/.test(name)) throw new SyntaxError('Wrong command name: ' + name); } // search for existing one var subcommand = this.commands[name]; if (!subcommand) { // create new one if not exists subcommand = command || new Command(name, params); subcommand.end_ = this; this.commands[name] = subcommand; this.suggestions.push(name); } 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 SyntaxError('Version for command could be set only once'); this.version_ = version; this.option( usage || '-v, --version', description || 'Output version', function(){ console.log(this.version_); process.exit(0); }, undefined ); return this; }, description: function(description){ if (this.description_) throw new SyntaxError('Description for command could be set only once'); this.description_ = description; return this; }, init: setFunctionFactory('init'), initContext: setFunctionFactory('initContext'), args: setFunctionFactory('args'), delegate: setFunctionFactory('delegate'), action: setFunctionFactory('action'), extend: function(fn){ fn.apply(null, [this].concat(Array.prototype.slice.call(arguments, 1))); return this; }, parse: function(args, suggest){ if (!args) args = process.argv.slice(2); if (!errorHandler) return processArgs(this, args, suggest); else try { return processArgs(this, args, suggest); } catch(e) { errorHandler(e.message || e); } }, run: function(args, context){ var commands = this.parse(args); if (!commands) return; var prevCommand; var context = assign({}, context || this.initContext_()); for (var i = 0; i < commands.length; i++) { var item = commands[i]; var command = item.command; // 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); }); command.init_(item.args.slice()); // use slice to avoid args mutation in handler 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); }); 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]; for (var name in values) if (hasOwnProperty.call(values, name) && !hasOwnProperty.call(result, name)) result[name] = values[name]; return result; }, showHelp: function(){ console.log(showCommandHelp(this)); } }; // // help // /** * Return program help documentation. * * @return {String} * @api private */ function showCommandHelp(command){ function breakByLines(str, offset){ var words = str.split(' '); var maxWidth = MAX_LINE_WIDTH - offset || 0; var lines = []; var line = ''; while (words.length) { var word = words.shift(); if (!line || (line.length + word.length + 1) < maxWidth) { line += (line ? ' ' : '') + word; } else { lines.push(line); words.unshift(word); line = ''; } } lines.push(line); return lines.map(function(line, idx){ return (idx && offset ? pad(offset, '') : '') + line; }).join('\n'); } function args(command){ return command.params.args.map(function(arg){ return arg.required ? '<' + arg.name + '>' : '[' + arg.name + ']'; }).join(' '); } function commandsHelp(){ if (!command.hasCommands()) return ''; var maxNameLength = MIN_OFFSET - 2; var lines = Object.keys(command.commands).sort().map(function(name){ var subcommand = command.commands[name]; var line = { name: chalk.green(name) + chalk.gray( (subcommand.params ? ' ' + args(subcommand) : '') // (subcommand.hasOptions() ? ' [options]' : '') ), description: subcommand.description_ || '' }; maxNameLength = Math.max(maxNameLength, stringLength(line.name)); return line; }); return [ '', 'Commands:', '', lines.map(function(line){ return ' ' + pad(maxNameLength, line.name) + ' ' + breakByLines(line.description, maxNameLength + 4); }).join('\n'), '' ].join('\n'); } function optionsHelp(){ if (!command.hasOptions()) return ''; var hasShortOptions = Object.keys(command.short).length > 0; var maxNameLength = MIN_OFFSET - 2; var lines = Object.keys(command.long).sort().map(function(name){ var option = command.long[name]; var line = { name: option.usage .replace(/^(?:-., |)/, function(m){ return m || (hasShortOptions ? ' ' : ''); }) .replace(/(^|\s)(-[^\s,]+)/ig, function(m, p, flag){ return p + chalk.yellow(flag); }), description: option.description }; maxNameLength = Math.max(maxNameLength, stringLength(line.name)); return line; }); // Prepend the help information return [ '', 'Options:', '', lines.map(function(line){ return ' ' + pad(maxNameLength, line.name) + ' ' + breakByLines(line.description, maxNameLength + 4); }).join('\n'), '' ].join('\n'); } var output = []; var chalk = require('chalk'); chalk.enabled = module.exports.color && process.stdout.isTTY; if (command.description_) output.push(command.description_ + '\n'); output.push( 'Usage:\n\n ' + chalk.cyan(commandsPath ? commandsPath.join(' ') : command.name) + (command.params ? ' ' + chalk.magenta(args(command)) : '') + (command.hasOptions() ? ' [' + chalk.yellow('options') + ']' : '') + (command.hasCommands() ? ' [' + chalk.green('command') + ']' : ''), commandsHelp() + optionsHelp() ); return output.join('\n'); }; // // export // module.exports = { color: true, Error: SyntaxError, Argument: Argument, Command: Command, Option: Option, error: function(fn){ if (errorHandler) throw new SyntaxError('Error handler should be set only once'); if (typeof fn != 'function') throw new SyntaxError('Error handler should be a function'); errorHandler = fn; return this; }, create: function(name, params){ return new Command(name || require('path').basename(process.argv[1]) || 'cli', params); }, confirm: function(message, fn){ process.stdout.write(message); process.stdin.setEncoding('utf8'); process.stdin.once('data', function(val){ process.stdin.pause(); fn(/^y|yes|ok|true$/i.test(val.trim())); }); process.stdin.resume(); } };