CAC.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import { EventEmitter } from "https://deno.land/std@0.80.0/node/events.ts";
  2. import mri from "https://cdn.skypack.dev/mri";
  3. import Command, { GlobalCommand, CommandConfig, HelpCallback, CommandExample } from "./Command.ts";
  4. import { OptionConfig } from "./Option.ts";
  5. import { getMriOptions, setDotProp, setByType, getFileName, camelcaseOptionName } from "./utils.ts";
  6. import { processArgs } from "./deno.ts";
  7. interface ParsedArgv {
  8. args: ReadonlyArray<string>;
  9. options: {
  10. [k: string]: any;
  11. };
  12. }
  13. class CAC extends EventEmitter {
  14. /** The program name to display in help and version message */
  15. name: string;
  16. commands: Command[];
  17. globalCommand: GlobalCommand;
  18. matchedCommand?: Command;
  19. matchedCommandName?: string;
  20. /**
  21. * Raw CLI arguments
  22. */
  23. rawArgs: string[];
  24. /**
  25. * Parsed CLI arguments
  26. */
  27. args: ParsedArgv['args'];
  28. /**
  29. * Parsed CLI options, camelCased
  30. */
  31. options: ParsedArgv['options'];
  32. showHelpOnExit?: boolean;
  33. showVersionOnExit?: boolean;
  34. /**
  35. * @param name The program name to display in help and version message
  36. */
  37. constructor(name = '') {
  38. super();
  39. this.name = name;
  40. this.commands = [];
  41. this.rawArgs = [];
  42. this.args = [];
  43. this.options = {};
  44. this.globalCommand = new GlobalCommand(this);
  45. this.globalCommand.usage('<command> [options]');
  46. }
  47. /**
  48. * Add a global usage text.
  49. *
  50. * This is not used by sub-commands.
  51. */
  52. usage(text: string) {
  53. this.globalCommand.usage(text);
  54. return this;
  55. }
  56. /**
  57. * Add a sub-command
  58. */
  59. command(rawName: string, description?: string, config?: CommandConfig) {
  60. const command = new Command(rawName, description || '', config, this);
  61. command.globalCommand = this.globalCommand;
  62. this.commands.push(command);
  63. return command;
  64. }
  65. /**
  66. * Add a global CLI option.
  67. *
  68. * Which is also applied to sub-commands.
  69. */
  70. option(rawName: string, description: string, config?: OptionConfig) {
  71. this.globalCommand.option(rawName, description, config);
  72. return this;
  73. }
  74. /**
  75. * Show help message when `-h, --help` flags appear.
  76. *
  77. */
  78. help(callback?: HelpCallback) {
  79. this.globalCommand.option('-h, --help', 'Display this message');
  80. this.globalCommand.helpCallback = callback;
  81. this.showHelpOnExit = true;
  82. return this;
  83. }
  84. /**
  85. * Show version number when `-v, --version` flags appear.
  86. *
  87. */
  88. version(version: string, customFlags = '-v, --version') {
  89. this.globalCommand.version(version, customFlags);
  90. this.showVersionOnExit = true;
  91. return this;
  92. }
  93. /**
  94. * Add a global example.
  95. *
  96. * This example added here will not be used by sub-commands.
  97. */
  98. example(example: CommandExample) {
  99. this.globalCommand.example(example);
  100. return this;
  101. }
  102. /**
  103. * Output the corresponding help message
  104. * When a sub-command is matched, output the help message for the command
  105. * Otherwise output the global one.
  106. *
  107. */
  108. outputHelp() {
  109. if (this.matchedCommand) {
  110. this.matchedCommand.outputHelp();
  111. } else {
  112. this.globalCommand.outputHelp();
  113. }
  114. }
  115. /**
  116. * Output the version number.
  117. *
  118. */
  119. outputVersion() {
  120. this.globalCommand.outputVersion();
  121. }
  122. private setParsedInfo({
  123. args,
  124. options
  125. }: ParsedArgv, matchedCommand?: Command, matchedCommandName?: string) {
  126. this.args = args;
  127. this.options = options;
  128. if (matchedCommand) {
  129. this.matchedCommand = matchedCommand;
  130. }
  131. if (matchedCommandName) {
  132. this.matchedCommandName = matchedCommandName;
  133. }
  134. return this;
  135. }
  136. unsetMatchedCommand() {
  137. this.matchedCommand = undefined;
  138. this.matchedCommandName = undefined;
  139. }
  140. /**
  141. * Parse argv
  142. */
  143. parse(argv = processArgs, {
  144. /** Whether to run the action for matched command */
  145. run = true
  146. } = {}): ParsedArgv {
  147. this.rawArgs = argv;
  148. if (!this.name) {
  149. this.name = argv[1] ? getFileName(argv[1]) : 'cli';
  150. }
  151. let shouldParse = true; // Search sub-commands
  152. for (const command of this.commands) {
  153. const parsed = this.mri(argv.slice(2), command);
  154. const commandName = parsed.args[0];
  155. if (command.isMatched(commandName)) {
  156. shouldParse = false;
  157. const parsedInfo = { ...parsed,
  158. args: parsed.args.slice(1)
  159. };
  160. this.setParsedInfo(parsedInfo, command, commandName);
  161. this.emit(`command:${commandName}`, command);
  162. }
  163. }
  164. if (shouldParse) {
  165. // Search the default command
  166. for (const command of this.commands) {
  167. if (command.name === '') {
  168. shouldParse = false;
  169. const parsed = this.mri(argv.slice(2), command);
  170. this.setParsedInfo(parsed, command);
  171. this.emit(`command:!`, command);
  172. }
  173. }
  174. }
  175. if (shouldParse) {
  176. const parsed = this.mri(argv.slice(2));
  177. this.setParsedInfo(parsed);
  178. }
  179. if (this.options.help && this.showHelpOnExit) {
  180. this.outputHelp();
  181. run = false;
  182. this.unsetMatchedCommand();
  183. }
  184. if (this.options.version && this.showVersionOnExit && this.matchedCommandName == null) {
  185. this.outputVersion();
  186. run = false;
  187. this.unsetMatchedCommand();
  188. }
  189. const parsedArgv = {
  190. args: this.args,
  191. options: this.options
  192. };
  193. if (run) {
  194. this.runMatchedCommand();
  195. }
  196. if (!this.matchedCommand && this.args[0]) {
  197. this.emit('command:*');
  198. }
  199. return parsedArgv;
  200. }
  201. private mri(argv: string[],
  202. /** Matched command */
  203. command?: Command): ParsedArgv {
  204. // All added options
  205. const cliOptions = [...this.globalCommand.options, ...(command ? command.options : [])];
  206. const mriOptions = getMriOptions(cliOptions); // Extract everything after `--` since mri doesn't support it
  207. let argsAfterDoubleDashes: string[] = [];
  208. const doubleDashesIndex = argv.indexOf('--');
  209. if (doubleDashesIndex > -1) {
  210. argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1);
  211. argv = argv.slice(0, doubleDashesIndex);
  212. }
  213. let parsed = mri(argv, mriOptions);
  214. parsed = Object.keys(parsed).reduce((res, name) => {
  215. return { ...res,
  216. [camelcaseOptionName(name)]: parsed[name]
  217. };
  218. }, {
  219. _: []
  220. });
  221. const args = parsed._;
  222. const options: {
  223. [k: string]: any;
  224. } = {
  225. '--': argsAfterDoubleDashes
  226. }; // Set option default value
  227. const ignoreDefault = command && command.config.ignoreOptionDefaultValue ? command.config.ignoreOptionDefaultValue : this.globalCommand.config.ignoreOptionDefaultValue;
  228. let transforms = Object.create(null);
  229. for (const cliOption of cliOptions) {
  230. if (!ignoreDefault && cliOption.config.default !== undefined) {
  231. for (const name of cliOption.names) {
  232. options[name] = cliOption.config.default;
  233. }
  234. } // If options type is defined
  235. if (Array.isArray(cliOption.config.type)) {
  236. if (transforms[cliOption.name] === undefined) {
  237. transforms[cliOption.name] = Object.create(null);
  238. transforms[cliOption.name]['shouldTransform'] = true;
  239. transforms[cliOption.name]['transformFunction'] = cliOption.config.type[0];
  240. }
  241. }
  242. } // Set option values (support dot-nested property name)
  243. for (const key of Object.keys(parsed)) {
  244. if (key !== '_') {
  245. const keys = key.split('.');
  246. setDotProp(options, keys, parsed[key]);
  247. setByType(options, transforms);
  248. }
  249. }
  250. return {
  251. args,
  252. options
  253. };
  254. }
  255. runMatchedCommand() {
  256. const {
  257. args,
  258. options,
  259. matchedCommand: command
  260. } = this;
  261. if (!command || !command.commandAction) return;
  262. command.checkUnknownOptions();
  263. command.checkOptionValue();
  264. command.checkRequiredArgs();
  265. const actionArgs: any[] = [];
  266. command.args.forEach((arg, index) => {
  267. if (arg.variadic) {
  268. actionArgs.push(args.slice(index));
  269. } else {
  270. actionArgs.push(args[index]);
  271. }
  272. });
  273. actionArgs.push(options);
  274. return command.commandAction.apply(this, actionArgs);
  275. }
  276. }
  277. export default CAC;