UNPKG

22.6 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};
219
220class Option {
221 constructor(rawName, description, config) {
222 this.rawName = rawName;
223 this.description = description;
224 this.config = Object.assign({}, config);
225 // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option
226 rawName = rawName.replace(/\.\*/g, '');
227 this.negated = false;
228 this.names = removeBrackets(rawName)
229 .split(',')
230 .map((v) => {
231 let name = v.trim().replace(/^-{1,2}/, '');
232 if (name.startsWith('no-')) {
233 this.negated = true;
234 name = name.replace(/^no-/, '');
235 }
236 return name;
237 })
238 .sort((a, b) => (a.length > b.length ? 1 : -1)); // Sort names
239 // Use the longese name (last one) as actual option name
240 this.name = this.names[this.names.length - 1];
241 if (this.negated) {
242 this.config.default = true;
243 }
244 if (rawName.includes('<')) {
245 this.required = true;
246 }
247 else if (rawName.includes('[')) {
248 this.required = false;
249 }
250 else {
251 // No arg needed, it's boolean flag
252 this.isBoolean = true;
253 }
254 }
255}
256
257const deno = typeof window !== 'undefined' && window.Deno;
258const exit = (code) => {
259 return deno ? Deno.exit(code) : process.exit(code);
260};
261const processArgs = deno ? ['deno'].concat(Deno.args) : process.argv;
262const platformInfo = deno
263 ? `${Deno.platform.os}-${Deno.platform.arch} deno-${Deno.version.deno}`
264 : `${process.platform}-${process.arch} node-${process.version}`;
265
266class Command {
267 constructor(rawName, description, config = {}, cli) {
268 this.rawName = rawName;
269 this.description = description;
270 this.config = config;
271 this.cli = cli;
272 this.options = [];
273 this.aliasNames = [];
274 this.name = removeBrackets(rawName);
275 this.args = findAllBrackets(rawName);
276 this.examples = [];
277 }
278 usage(text) {
279 this.usageText = text;
280 return this;
281 }
282 allowUnknownOptions() {
283 this.config.allowUnknownOptions = true;
284 return this;
285 }
286 ignoreOptionDefaultValue() {
287 this.config.ignoreOptionDefaultValue = true;
288 return this;
289 }
290 version(version, customFlags = '-v, --version') {
291 this.versionNumber = version;
292 this.option(customFlags, 'Display version number');
293 return this;
294 }
295 example(example) {
296 this.examples.push(example);
297 return this;
298 }
299 /**
300 * Add a option for this command
301 * @param rawName Raw option name(s)
302 * @param description Option description
303 * @param config Option config
304 */
305 option(rawName, description, config) {
306 const option = new Option(rawName, description, config);
307 this.options.push(option);
308 return this;
309 }
310 alias(name) {
311 this.aliasNames.push(name);
312 return this;
313 }
314 action(callback) {
315 this.commandAction = callback;
316 return this;
317 }
318 /**
319 * Check if a command name is matched by this command
320 * @param name Command name
321 */
322 isMatched(name) {
323 return this.name === name || this.aliasNames.includes(name);
324 }
325 get isDefaultCommand() {
326 return this.name === '' || this.aliasNames.includes('!');
327 }
328 get isGlobalCommand() {
329 return this instanceof GlobalCommand;
330 }
331 /**
332 * Check if an option is registered in this command
333 * @param name Option name
334 */
335 hasOption(name) {
336 name = name.split('.')[0];
337 return this.options.find(option => {
338 return option.names.includes(name);
339 });
340 }
341 outputHelp() {
342 const { name, commands } = this.cli;
343 const { versionNumber, options: globalOptions, helpCallback } = this.cli.globalCommand;
344 const sections = [
345 {
346 body: `${name}${versionNumber ? ` v${versionNumber}` : ''}`
347 }
348 ];
349 sections.push({
350 title: 'Usage',
351 body: ` $ ${name} ${this.usageText || this.rawName}`
352 });
353 const showCommands = (this.isGlobalCommand || this.isDefaultCommand) && commands.length > 0;
354 if (showCommands) {
355 const longestCommandName = findLongest(commands.map(command => command.rawName));
356 sections.push({
357 title: 'Commands',
358 body: commands
359 .map(command => {
360 return ` ${padRight(command.rawName, longestCommandName.length)} ${command.description}`;
361 })
362 .join('\n')
363 });
364 sections.push({
365 title: `For more info, run any command with the \`--help\` flag`,
366 body: commands
367 .map(command => ` $ ${name}${command.name === '' ? '' : ` ${command.name}`} --help`)
368 .join('\n')
369 });
370 }
371 const options = this.isGlobalCommand
372 ? globalOptions
373 : [...this.options, ...(globalOptions || [])];
374 if (options.length > 0) {
375 const longestOptionName = findLongest(options.map(option => option.rawName));
376 sections.push({
377 title: 'Options',
378 body: options
379 .map(option => {
380 return ` ${padRight(option.rawName, longestOptionName.length)} ${option.description} ${option.config.default === undefined
381 ? ''
382 : `(default: ${option.config.default})`}`;
383 })
384 .join('\n')
385 });
386 }
387 if (this.examples.length > 0) {
388 sections.push({
389 title: 'Examples',
390 body: this.examples
391 .map(example => {
392 if (typeof example === 'function') {
393 return example(name);
394 }
395 return example;
396 })
397 .join('\n')
398 });
399 }
400 if (helpCallback) {
401 helpCallback(sections);
402 }
403 console.log(sections
404 .map(section => {
405 return section.title
406 ? `${section.title}:\n${section.body}`
407 : section.body;
408 })
409 .join('\n\n'));
410 exit(0);
411 }
412 outputVersion() {
413 const { name } = this.cli;
414 const { versionNumber } = this.cli.globalCommand;
415 if (versionNumber) {
416 console.log(`${name}/${versionNumber} ${platformInfo}`);
417 }
418 exit(0);
419 }
420 checkRequiredArgs() {
421 const minimalArgsCount = this.args.filter(arg => arg.required).length;
422 if (this.cli.args.length < minimalArgsCount) {
423 console.error(`error: missing required args for command \`${this.rawName}\``);
424 exit(1);
425 }
426 }
427 /**
428 * Check if the parsed options contain any unknown options
429 *
430 * Exit and output error when true
431 */
432 checkUnknownOptions() {
433 const { rawOptions, globalCommand } = this.cli;
434 if (!this.config.allowUnknownOptions) {
435 for (const name of Object.keys(rawOptions)) {
436 if (name !== '--' &&
437 !this.hasOption(name) &&
438 !globalCommand.hasOption(name)) {
439 console.error(`error: Unknown option \`${name.length > 1 ? `--${name}` : `-${name}`}\``);
440 exit(1);
441 }
442 }
443 }
444 }
445 /**
446 * Check if the required string-type options exist
447 */
448 checkOptionValue() {
449 const { rawOptions, globalCommand } = this.cli;
450 const options = [...globalCommand.options, ...this.options];
451 for (const option of options) {
452 const value = rawOptions[option.name.split('.')[0]];
453 // Check required option value
454 if (option.required) {
455 const hasNegated = options.some(o => o.negated && o.names.includes(option.name));
456 if (value === true || (value === false && !hasNegated)) {
457 console.error(`error: option \`${option.rawName}\` value is missing`);
458 exit(1);
459 }
460 }
461 }
462 }
463}
464class GlobalCommand extends Command {
465 constructor(cli) {
466 super('@@global@@', '', {}, cli);
467 }
468}
469
470class CAC extends EventEmitter {
471 /**
472 * @param name The program name to display in help and version message
473 */
474 constructor(name = '') {
475 super();
476 this.name = name;
477 this.commands = [];
478 this.globalCommand = new GlobalCommand(this);
479 this.globalCommand.usage('<command> [options]');
480 }
481 /**
482 * Add a global usage text.
483 *
484 * This is not used by sub-commands.
485 */
486 usage(text) {
487 this.globalCommand.usage(text);
488 return this;
489 }
490 /**
491 * Add a sub-command
492 */
493 command(rawName, description, config) {
494 const command = new Command(rawName, description || '', config, this);
495 command.globalCommand = this.globalCommand;
496 this.commands.push(command);
497 return command;
498 }
499 /**
500 * Add a global CLI option.
501 *
502 * Which is also applied to sub-commands.
503 */
504 option(rawName, description, config) {
505 this.globalCommand.option(rawName, description, config);
506 return this;
507 }
508 /**
509 * Show help message when `-h, --help` flags appear.
510 *
511 */
512 help(callback) {
513 this.globalCommand.option('-h, --help', 'Display this message');
514 this.globalCommand.helpCallback = callback;
515 this.showHelpOnExit = true;
516 return this;
517 }
518 /**
519 * Show version number when `-v, --version` flags appear.
520 *
521 */
522 version(version, customFlags = '-v, --version') {
523 this.globalCommand.version(version, customFlags);
524 this.showVersionOnExit = true;
525 return this;
526 }
527 /**
528 * Add a global example.
529 *
530 * This example added here will not be used by sub-commands.
531 */
532 example(example) {
533 this.globalCommand.example(example);
534 return this;
535 }
536 /**
537 * Output the corresponding help message
538 * When a sub-command is matched, output the help message for the command
539 * Otherwise output the global one.
540 *
541 * This will also call `process.exit(0)` to quit the process.
542 */
543 outputHelp() {
544 if (this.matchedCommand) {
545 this.matchedCommand.outputHelp();
546 }
547 else {
548 this.globalCommand.outputHelp();
549 }
550 }
551 /**
552 * Output the version number.
553 *
554 * This will also call `process.exit(0)` to quit the process.
555 */
556 outputVersion() {
557 this.globalCommand.outputVersion();
558 }
559 setParsedInfo({ args, options, rawOptions }, matchedCommand, matchedCommandName) {
560 this.args = args;
561 this.options = options;
562 this.rawOptions = rawOptions;
563 if (matchedCommand) {
564 this.matchedCommand = matchedCommand;
565 }
566 if (matchedCommandName) {
567 this.matchedCommandName = matchedCommandName;
568 }
569 return this;
570 }
571 /**
572 * Parse argv
573 */
574 parse(argv = processArgs, {
575 /** Whether to run the action for matched command */
576 run = true } = {}) {
577 this.rawArgs = argv;
578 if (!this.name) {
579 this.name = argv[1] ? getFileName(argv[1]) : 'cli';
580 }
581 let shouldParse = true;
582 // Search sub-commands
583 for (const command of this.commands) {
584 const mriResult = this.mri(argv.slice(2), command);
585 const commandName = mriResult.args[0];
586 if (command.isMatched(commandName)) {
587 shouldParse = false;
588 const parsedInfo = Object.assign({}, mriResult, { args: mriResult.args.slice(1) });
589 this.setParsedInfo(parsedInfo, command, commandName);
590 this.emit(`command:${commandName}`, command);
591 }
592 }
593 if (shouldParse) {
594 // Search the default command
595 for (const command of this.commands) {
596 if (command.name === '') {
597 shouldParse = false;
598 const mriResult = this.mri(argv.slice(2), command);
599 this.setParsedInfo(mriResult, command);
600 this.emit(`command:!`, command);
601 }
602 }
603 }
604 if (shouldParse) {
605 const mriResult = this.mri(argv.slice(2));
606 this.setParsedInfo(mriResult);
607 }
608 if (this.options.help && this.showHelpOnExit) {
609 this.outputHelp();
610 }
611 if (this.options.version && this.showVersionOnExit) {
612 this.outputVersion();
613 }
614 const parsedArgv = { args: this.args, options: this.options };
615 if (run) {
616 this.runMatchedCommand();
617 }
618 if (!this.matchedCommand && this.args[0]) {
619 this.emit('command:*');
620 }
621 return parsedArgv;
622 }
623 mri(argv,
624 /** Matched command */ command) {
625 // All added options
626 const cliOptions = [
627 ...this.globalCommand.options,
628 ...(command ? command.options : [])
629 ];
630 const mriOptions = getMriOptions(cliOptions);
631 // Extract everything after `--` since mri doesn't support it
632 let argsAfterDoubleDashes = [];
633 const doubleDashesIndex = argv.indexOf('--');
634 if (doubleDashesIndex > -1) {
635 argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1);
636 argv = argv.slice(0, doubleDashesIndex);
637 }
638 const parsed = lib(argv, mriOptions);
639 const args = parsed._;
640 delete parsed._;
641 const options = {
642 '--': argsAfterDoubleDashes
643 };
644 // Set option default value
645 const ignoreDefault = command && command.config.ignoreOptionDefaultValue
646 ? command.config.ignoreOptionDefaultValue
647 : this.globalCommand.config.ignoreOptionDefaultValue;
648 let transforms = Object.create(null);
649 for (const cliOption of cliOptions) {
650 if (!ignoreDefault && cliOption.config.default !== undefined) {
651 for (const name of cliOption.names) {
652 options[name] = cliOption.config.default;
653 }
654 }
655 // If options type is defined
656 if (Array.isArray(cliOption.config.type)) {
657 if (transforms[cliOption.name] === undefined) {
658 transforms[cliOption.name] = Object.create(null);
659 transforms[cliOption.name]['shouldTransform'] = true;
660 transforms[cliOption.name]['transformFunction'] =
661 cliOption.config.type[0];
662 }
663 }
664 }
665 // Camelcase option names and set dot nested option values
666 for (const key of Object.keys(parsed)) {
667 const keys = key.split('.').map((v, i) => {
668 return i === 0 ? camelcase(v) : v;
669 });
670 setDotProp(options, keys, parsed[key]);
671 setByType(options, transforms);
672 }
673 return {
674 args,
675 options,
676 rawOptions: parsed
677 };
678 }
679 runMatchedCommand() {
680 const { args, options, matchedCommand: command } = this;
681 if (!command || !command.commandAction)
682 return;
683 command.checkUnknownOptions();
684 command.checkOptionValue();
685 command.checkRequiredArgs();
686 const actionArgs = [];
687 command.args.forEach((arg, index) => {
688 if (arg.variadic) {
689 actionArgs.push(args.slice(index));
690 }
691 else {
692 actionArgs.push(args[index]);
693 }
694 });
695 actionArgs.push(options);
696 return command.commandAction.apply(this, actionArgs);
697 }
698}
699
700/**
701 * @param name The program name to display in help and version message
702 */
703const cac = (name = '') => new CAC(name);
704if (typeof module !== 'undefined') {
705 module.exports = cac;
706 module.exports.default = cac;
707 module.exports.cac = cac;
708}
709
710export default cac;
711export { cac };