UNPKG

22.9 kBJavaScriptView Raw
1import { EventEmitter } from 'events';
2
3function toArr(any) {
4 return any == null ? [] : Array.isArray(any) ? any : [any];
5}
6
7function toVal(out, key, val, opts) {
8 var x, old=out[key], nxt=(
9 !!~opts.string.indexOf(key) ? (val == null || val === true ? '' : String(val))
10 : typeof val === 'boolean' ? val
11 : !!~opts.boolean.indexOf(key) ? (val === 'false' ? false : val === 'true' || (out._.push((x = +val,x * 0 === 0) ? x : val),!!val))
12 : (x = +val,x * 0 === 0) ? x : val
13 );
14 out[key] = old == null ? nxt : (Array.isArray(old) ? old.concat(nxt) : [old, nxt]);
15}
16
17var lib = function (args, opts) {
18 args = args || [];
19 opts = opts || {};
20
21 var k, arr, arg, name, val, out={ _:[] };
22 var i=0, j=0, idx=0, len=args.length;
23
24 const alibi = opts.alias !== void 0;
25 const strict = opts.unknown !== void 0;
26 const defaults = opts.default !== void 0;
27
28 opts.alias = opts.alias || {};
29 opts.string = toArr(opts.string);
30 opts.boolean = toArr(opts.boolean);
31
32 if (alibi) {
33 for (k in opts.alias) {
34 arr = opts.alias[k] = toArr(opts.alias[k]);
35 for (i=0; i < arr.length; i++) {
36 (opts.alias[arr[i]] = arr.concat(k)).splice(i, 1);
37 }
38 }
39 }
40
41 opts.boolean.forEach(key => {
42 opts.boolean = opts.boolean.concat(opts.alias[key] = opts.alias[key] || []);
43 });
44
45 opts.string.forEach(key => {
46 opts.string = opts.string.concat(opts.alias[key] = opts.alias[key] || []);
47 });
48
49 if (defaults) {
50 for (k in opts.default) {
51 opts.alias[k] = opts.alias[k] || [];
52 (opts[typeof opts.default[k]] || []).push(k);
53 }
54 }
55
56 const keys = strict ? Object.keys(opts.alias) : [];
57
58 for (i=0; i < len; i++) {
59 arg = args[i];
60
61 if (arg === '--') {
62 out._ = out._.concat(args.slice(++i));
63 break;
64 }
65
66 for (j=0; j < arg.length; j++) {
67 if (arg.charCodeAt(j) !== 45) break; // "-"
68 }
69
70 if (j === 0) {
71 out._.push(arg);
72 } else if (arg.substring(j, j + 3) === 'no-') {
73 name = arg.substring(j + 3);
74 if (strict && !~keys.indexOf(name)) {
75 return opts.unknown(arg);
76 }
77 out[name] = false;
78 } else {
79 for (idx=j+1; idx < arg.length; idx++) {
80 if (arg.charCodeAt(idx) === 61) break; // "="
81 }
82
83 name = arg.substring(j, idx);
84 val = arg.substring(++idx) || (i+1 === len || (''+args[i+1]).charCodeAt(0) === 45 || args[++i]);
85 arr = (j === 2 ? [name] : name);
86
87 for (idx=0; idx < arr.length; idx++) {
88 name = arr[idx];
89 if (strict && !~keys.indexOf(name)) return opts.unknown('-'.repeat(j) + name);
90 toVal(out, name, (idx + 1 < arr.length) || val, opts);
91 }
92 }
93 }
94
95 if (defaults) {
96 for (k in opts.default) {
97 if (out[k] === void 0) {
98 out[k] = opts.default[k];
99 }
100 }
101 }
102
103 if (alibi) {
104 for (k in out) {
105 arr = opts.alias[k] || [];
106 while (arr.length > 0) {
107 out[arr.shift()] = out[k];
108 }
109 }
110 }
111
112 return out;
113};
114
115const removeBrackets = (v) => v.replace(/[<[].+/, '').trim();
116const findAllBrackets = (v) => {
117 const ANGLED_BRACKET_RE_GLOBAL = /<([^>]+)>/g;
118 const SQUARE_BRACKET_RE_GLOBAL = /\[([^\]]+)\]/g;
119 const res = [];
120 const parse = (match) => {
121 let variadic = false;
122 let value = match[1];
123 if (value.startsWith('...')) {
124 value = value.slice(3);
125 variadic = true;
126 }
127 return {
128 required: match[0].startsWith('<'),
129 value,
130 variadic
131 };
132 };
133 let angledMatch;
134 while ((angledMatch = ANGLED_BRACKET_RE_GLOBAL.exec(v))) {
135 res.push(parse(angledMatch));
136 }
137 let squareMatch;
138 while ((squareMatch = SQUARE_BRACKET_RE_GLOBAL.exec(v))) {
139 res.push(parse(squareMatch));
140 }
141 return res;
142};
143const getMriOptions = (options) => {
144 const result = { alias: {}, boolean: [] };
145 for (const [index, option] of options.entries()) {
146 // We do not set default values in mri options
147 // Since its type (typeof) will be used to cast parsed arguments.
148 // Which mean `--foo foo` will be parsed as `{foo: true}` if we have `{default:{foo: true}}`
149 // Set alias
150 if (option.names.length > 1) {
151 result.alias[option.names[0]] = option.names.slice(1);
152 }
153 // Set boolean
154 if (option.isBoolean) {
155 if (option.negated) {
156 // For negated option
157 // We only set it to `boolean` type when there's no string-type option with the same name
158 const hasStringTypeOption = options.some((o, i) => {
159 return (i !== index &&
160 o.names.some(name => option.names.includes(name)) &&
161 typeof o.required === 'boolean');
162 });
163 if (!hasStringTypeOption) {
164 result.boolean.push(option.names[0]);
165 }
166 }
167 else {
168 result.boolean.push(option.names[0]);
169 }
170 }
171 }
172 return result;
173};
174const findLongest = (arr) => {
175 return arr.sort((a, b) => {
176 return a.length > b.length ? -1 : 1;
177 })[0];
178};
179const padRight = (str, length) => {
180 return str.length >= length ? str : `${str}${' '.repeat(length - str.length)}`;
181};
182const camelcase = (input) => {
183 return input.replace(/([a-z])-([a-z])/g, (_, p1, p2) => {
184 return p1 + p2.toUpperCase();
185 });
186};
187const setDotProp = (obj, keys, val) => {
188 let i = 0;
189 let length = keys.length;
190 let t = obj;
191 let x;
192 for (; i < length; ++i) {
193 x = t[keys[i]];
194 t = t[keys[i]] =
195 i === length - 1
196 ? val
197 : x != null
198 ? x
199 : !!~keys[i + 1].indexOf('.') || !(+keys[i + 1] > -1)
200 ? {}
201 : [];
202 }
203};
204const setByType = (obj, transforms) => {
205 for (const key of Object.keys(transforms)) {
206 const transform = transforms[key];
207 if (transform.shouldTransform) {
208 obj[key] = Array.prototype.concat.call([], obj[key]);
209 if (typeof transform.transformFunction === 'function') {
210 obj[key] = obj[key].map(transform.transformFunction);
211 }
212 }
213 }
214};
215const getFileName = (input) => {
216 const m = /([^\\\/]+)$/.exec(input);
217 return m ? m[1] : '';
218};
219const camelcaseOptionName = (name) => {
220 // Camelcase the option name
221 // Don't camelcase anything after the dot `.`
222 return name
223 .split('.')
224 .map((v, i) => {
225 return i === 0 ? camelcase(v) : v;
226 })
227 .join('.');
228};
229class CACError extends Error {
230 constructor(message) {
231 super(message);
232 this.name = this.constructor.name;
233 if (typeof Error.captureStackTrace === 'function') {
234 Error.captureStackTrace(this, this.constructor);
235 }
236 else {
237 this.stack = new Error(message).stack;
238 }
239 }
240}
241
242class Option {
243 constructor(rawName, description, config) {
244 this.rawName = rawName;
245 this.description = description;
246 this.config = Object.assign({}, config);
247 // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option
248 rawName = rawName.replace(/\.\*/g, '');
249 this.negated = false;
250 this.names = removeBrackets(rawName)
251 .split(',')
252 .map((v) => {
253 let name = v.trim().replace(/^-{1,2}/, '');
254 if (name.startsWith('no-')) {
255 this.negated = true;
256 name = name.replace(/^no-/, '');
257 }
258 return camelcaseOptionName(name);
259 })
260 .sort((a, b) => (a.length > b.length ? 1 : -1)); // Sort names
261 // Use the longest name (last one) as actual option name
262 this.name = this.names[this.names.length - 1];
263 if (this.negated) {
264 this.config.default = true;
265 }
266 if (rawName.includes('<')) {
267 this.required = true;
268 }
269 else if (rawName.includes('[')) {
270 this.required = false;
271 }
272 else {
273 // No arg needed, it's boolean flag
274 this.isBoolean = true;
275 }
276 }
277}
278
279const deno = typeof window !== 'undefined' && window.Deno;
280const processArgs = deno ? ['deno'].concat(Deno.args) : process.argv;
281const platformInfo = deno
282 ? `${Deno.build.os}-${Deno.build.arch} deno-${Deno.version.deno}`
283 : `${process.platform}-${process.arch} node-${process.version}`;
284
285class Command {
286 constructor(rawName, description, config = {}, cli) {
287 this.rawName = rawName;
288 this.description = description;
289 this.config = config;
290 this.cli = cli;
291 this.options = [];
292 this.aliasNames = [];
293 this.name = removeBrackets(rawName);
294 this.args = findAllBrackets(rawName);
295 this.examples = [];
296 }
297 usage(text) {
298 this.usageText = text;
299 return this;
300 }
301 allowUnknownOptions() {
302 this.config.allowUnknownOptions = true;
303 return this;
304 }
305 ignoreOptionDefaultValue() {
306 this.config.ignoreOptionDefaultValue = true;
307 return this;
308 }
309 version(version, customFlags = '-v, --version') {
310 this.versionNumber = version;
311 this.option(customFlags, 'Display version number');
312 return this;
313 }
314 example(example) {
315 this.examples.push(example);
316 return this;
317 }
318 /**
319 * Add a option for this command
320 * @param rawName Raw option name(s)
321 * @param description Option description
322 * @param config Option config
323 */
324 option(rawName, description, config) {
325 const option = new Option(rawName, description, config);
326 this.options.push(option);
327 return this;
328 }
329 alias(name) {
330 this.aliasNames.push(name);
331 return this;
332 }
333 action(callback) {
334 this.commandAction = callback;
335 return this;
336 }
337 /**
338 * Check if a command name is matched by this command
339 * @param name Command name
340 */
341 isMatched(name) {
342 return this.name === name || this.aliasNames.includes(name);
343 }
344 get isDefaultCommand() {
345 return this.name === '' || this.aliasNames.includes('!');
346 }
347 get isGlobalCommand() {
348 return this instanceof GlobalCommand;
349 }
350 /**
351 * Check if an option is registered in this command
352 * @param name Option name
353 */
354 hasOption(name) {
355 name = name.split('.')[0];
356 return this.options.find(option => {
357 return option.names.includes(name);
358 });
359 }
360 outputHelp() {
361 const { name, commands } = this.cli;
362 const { versionNumber, options: globalOptions, helpCallback } = this.cli.globalCommand;
363 const sections = [
364 {
365 body: `${name}${versionNumber ? ` v${versionNumber}` : ''}`
366 }
367 ];
368 sections.push({
369 title: 'Usage',
370 body: ` $ ${name} ${this.usageText || this.rawName}`
371 });
372 const showCommands = (this.isGlobalCommand || this.isDefaultCommand) && commands.length > 0;
373 if (showCommands) {
374 const longestCommandName = findLongest(commands.map(command => command.rawName));
375 sections.push({
376 title: 'Commands',
377 body: commands
378 .map(command => {
379 return ` ${padRight(command.rawName, longestCommandName.length)} ${command.description}`;
380 })
381 .join('\n')
382 });
383 sections.push({
384 title: `For more info, run any command with the \`--help\` flag`,
385 body: commands
386 .map(command => ` $ ${name}${command.name === '' ? '' : ` ${command.name}`} --help`)
387 .join('\n')
388 });
389 }
390 const options = this.isGlobalCommand
391 ? globalOptions
392 : [...this.options, ...(globalOptions || [])];
393 if (options.length > 0) {
394 const longestOptionName = findLongest(options.map(option => option.rawName));
395 sections.push({
396 title: 'Options',
397 body: options
398 .map(option => {
399 return ` ${padRight(option.rawName, longestOptionName.length)} ${option.description} ${option.config.default === undefined
400 ? ''
401 : `(default: ${option.config.default})`}`;
402 })
403 .join('\n')
404 });
405 }
406 if (this.examples.length > 0) {
407 sections.push({
408 title: 'Examples',
409 body: this.examples
410 .map(example => {
411 if (typeof example === 'function') {
412 return example(name);
413 }
414 return example;
415 })
416 .join('\n')
417 });
418 }
419 if (helpCallback) {
420 helpCallback(sections);
421 }
422 console.log(sections
423 .map(section => {
424 return section.title
425 ? `${section.title}:\n${section.body}`
426 : section.body;
427 })
428 .join('\n\n'));
429 }
430 outputVersion() {
431 const { name } = this.cli;
432 const { versionNumber } = this.cli.globalCommand;
433 if (versionNumber) {
434 console.log(`${name}/${versionNumber} ${platformInfo}`);
435 }
436 }
437 checkRequiredArgs() {
438 const minimalArgsCount = this.args.filter(arg => arg.required).length;
439 if (this.cli.args.length < minimalArgsCount) {
440 throw new CACError(`missing required args for command \`${this.rawName}\``);
441 }
442 }
443 /**
444 * Check if the parsed options contain any unknown options
445 *
446 * Exit and output error when true
447 */
448 checkUnknownOptions() {
449 const { options, globalCommand } = this.cli;
450 if (!this.config.allowUnknownOptions) {
451 for (const name of Object.keys(options)) {
452 if (name !== '--' &&
453 !this.hasOption(name) &&
454 !globalCommand.hasOption(name)) {
455 throw new CACError(`Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\``);
456 }
457 }
458 }
459 }
460 /**
461 * Check if the required string-type options exist
462 */
463 checkOptionValue() {
464 const { options: parsedOptions, globalCommand } = this.cli;
465 const options = [...globalCommand.options, ...this.options];
466 for (const option of options) {
467 const value = parsedOptions[option.name.split('.')[0]];
468 // Check required option value
469 if (option.required) {
470 const hasNegated = options.some(o => o.negated && o.names.includes(option.name));
471 if (value === true || (value === false && !hasNegated)) {
472 throw new CACError(`option \`${option.rawName}\` value is missing`);
473 }
474 }
475 }
476 }
477}
478class GlobalCommand extends Command {
479 constructor(cli) {
480 super('@@global@@', '', {}, cli);
481 }
482}
483
484class CAC extends EventEmitter {
485 /**
486 * @param name The program name to display in help and version message
487 */
488 constructor(name = '') {
489 super();
490 this.name = name;
491 this.commands = [];
492 this.globalCommand = new GlobalCommand(this);
493 this.globalCommand.usage('<command> [options]');
494 }
495 /**
496 * Add a global usage text.
497 *
498 * This is not used by sub-commands.
499 */
500 usage(text) {
501 this.globalCommand.usage(text);
502 return this;
503 }
504 /**
505 * Add a sub-command
506 */
507 command(rawName, description, config) {
508 const command = new Command(rawName, description || '', config, this);
509 command.globalCommand = this.globalCommand;
510 this.commands.push(command);
511 return command;
512 }
513 /**
514 * Add a global CLI option.
515 *
516 * Which is also applied to sub-commands.
517 */
518 option(rawName, description, config) {
519 this.globalCommand.option(rawName, description, config);
520 return this;
521 }
522 /**
523 * Show help message when `-h, --help` flags appear.
524 *
525 */
526 help(callback) {
527 this.globalCommand.option('-h, --help', 'Display this message');
528 this.globalCommand.helpCallback = callback;
529 this.showHelpOnExit = true;
530 return this;
531 }
532 /**
533 * Show version number when `-v, --version` flags appear.
534 *
535 */
536 version(version, customFlags = '-v, --version') {
537 this.globalCommand.version(version, customFlags);
538 this.showVersionOnExit = true;
539 return this;
540 }
541 /**
542 * Add a global example.
543 *
544 * This example added here will not be used by sub-commands.
545 */
546 example(example) {
547 this.globalCommand.example(example);
548 return this;
549 }
550 /**
551 * Output the corresponding help message
552 * When a sub-command is matched, output the help message for the command
553 * Otherwise output the global one.
554 *
555 */
556 outputHelp() {
557 if (this.matchedCommand) {
558 this.matchedCommand.outputHelp();
559 }
560 else {
561 this.globalCommand.outputHelp();
562 }
563 }
564 /**
565 * Output the version number.
566 *
567 */
568 outputVersion() {
569 this.globalCommand.outputVersion();
570 }
571 setParsedInfo({ args, options }, matchedCommand, matchedCommandName) {
572 this.args = args;
573 this.options = options;
574 if (matchedCommand) {
575 this.matchedCommand = matchedCommand;
576 }
577 if (matchedCommandName) {
578 this.matchedCommandName = matchedCommandName;
579 }
580 return this;
581 }
582 /**
583 * Parse argv
584 */
585 parse(argv = processArgs, {
586 /** Whether to run the action for matched command */
587 run = true } = {}) {
588 this.rawArgs = argv;
589 if (!this.name) {
590 this.name = argv[1] ? getFileName(argv[1]) : 'cli';
591 }
592 let shouldParse = true;
593 // Search sub-commands
594 for (const command of this.commands) {
595 const parsed = this.mri(argv.slice(2), command);
596 const commandName = parsed.args[0];
597 if (command.isMatched(commandName)) {
598 shouldParse = false;
599 const parsedInfo = Object.assign({}, parsed, { args: parsed.args.slice(1) });
600 this.setParsedInfo(parsedInfo, command, commandName);
601 this.emit(`command:${commandName}`, command);
602 }
603 }
604 if (shouldParse) {
605 // Search the default command
606 for (const command of this.commands) {
607 if (command.name === '') {
608 shouldParse = false;
609 const parsed = this.mri(argv.slice(2), command);
610 this.setParsedInfo(parsed, command);
611 this.emit(`command:!`, command);
612 }
613 }
614 }
615 if (shouldParse) {
616 const parsed = this.mri(argv.slice(2));
617 this.setParsedInfo(parsed);
618 }
619 if (this.options.help && this.showHelpOnExit) {
620 this.outputHelp();
621 run = false;
622 }
623 if (this.options.version && this.showVersionOnExit) {
624 this.outputVersion();
625 run = false;
626 }
627 const parsedArgv = { args: this.args, options: this.options };
628 if (run) {
629 this.runMatchedCommand();
630 }
631 if (!this.matchedCommand && this.args[0]) {
632 this.emit('command:*');
633 }
634 return parsedArgv;
635 }
636 mri(argv,
637 /** Matched command */ command) {
638 // All added options
639 const cliOptions = [
640 ...this.globalCommand.options,
641 ...(command ? command.options : [])
642 ];
643 const mriOptions = getMriOptions(cliOptions);
644 // Extract everything after `--` since mri doesn't support it
645 let argsAfterDoubleDashes = [];
646 const doubleDashesIndex = argv.indexOf('--');
647 if (doubleDashesIndex > -1) {
648 argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1);
649 argv = argv.slice(0, doubleDashesIndex);
650 }
651 let parsed = lib(argv, mriOptions);
652 parsed = Object.keys(parsed).reduce((res, name) => {
653 return Object.assign({}, res, { [camelcaseOptionName(name)]: parsed[name] });
654 }, { _: [] });
655 const args = parsed._;
656 delete parsed._;
657 const options = {
658 '--': argsAfterDoubleDashes
659 };
660 // Set option default value
661 const ignoreDefault = command && command.config.ignoreOptionDefaultValue
662 ? command.config.ignoreOptionDefaultValue
663 : this.globalCommand.config.ignoreOptionDefaultValue;
664 let transforms = Object.create(null);
665 for (const cliOption of cliOptions) {
666 if (!ignoreDefault && cliOption.config.default !== undefined) {
667 for (const name of cliOption.names) {
668 options[name] = cliOption.config.default;
669 }
670 }
671 // If options type is defined
672 if (Array.isArray(cliOption.config.type)) {
673 if (transforms[cliOption.name] === undefined) {
674 transforms[cliOption.name] = Object.create(null);
675 transforms[cliOption.name]['shouldTransform'] = true;
676 transforms[cliOption.name]['transformFunction'] =
677 cliOption.config.type[0];
678 }
679 }
680 }
681 // Set dot nested option values
682 for (const key of Object.keys(parsed)) {
683 const keys = key.split('.');
684 setDotProp(options, keys, parsed[key]);
685 setByType(options, transforms);
686 }
687 return {
688 args,
689 options
690 };
691 }
692 runMatchedCommand() {
693 const { args, options, matchedCommand: command } = this;
694 if (!command || !command.commandAction)
695 return;
696 command.checkUnknownOptions();
697 command.checkOptionValue();
698 command.checkRequiredArgs();
699 const actionArgs = [];
700 command.args.forEach((arg, index) => {
701 if (arg.variadic) {
702 actionArgs.push(args.slice(index));
703 }
704 else {
705 actionArgs.push(args[index]);
706 }
707 });
708 actionArgs.push(options);
709 return command.commandAction.apply(this, actionArgs);
710 }
711}
712
713/**
714 * @param name The program name to display in help and version message
715 */
716const cac = (name = '') => new CAC(name);
717if (typeof module !== 'undefined') {
718 module.exports = cac;
719 module.exports.default = cac;
720 module.exports.cac = cac;
721}
722
723export default cac;
724export { cac };