/** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ // tslint:disable:no-global-tslint-disable no-any import { logging, strings as coreStrings, tags } from '@angular-devkit/core'; import * as yargsParser from 'yargs-parser'; import { ArgumentStrategy, CommandConstructor, CommandContext, CommandScope, Option, } from '../models/command'; import { insideProject } from '../utilities/project'; export interface CommandMap { [key: string]: CommandConstructor; } // Based off https://en.wikipedia.org/wiki/Levenshtein_distance // No optimization, really. function levenshtein(a: string, b: string): number { /* base case: empty strings */ if (a.length == 0) { return b.length; } if (b.length == 0) { return a.length; } // Test if last characters of the strings match. const cost = a[a.length - 1] == b[b.length - 1] ? 0 : 1; /* return minimum of delete char from s, delete char from t, and delete char from both */ return Math.min( levenshtein(a.slice(0, -1), b) + 1, levenshtein(a, b.slice(0, -1)) + 1, levenshtein(a.slice(0, -1), b.slice(0, -1)) + cost, ); } /** * Run a command. * @param commandMap Map of available commands. * @param args Raw unparsed arguments. * @param logger The logger to use. * @param context Execution context. */ export async function runCommand(commandMap: CommandMap, args: string[], logger: logging.Logger, context: CommandContext): Promise { // if not args supplied, just run the help command. if (!args || args.length === 0) { args = ['help']; } const rawOptions = yargsParser(args, { alias: { help: ['h'] }, boolean: [ 'help' ] }); let commandName = rawOptions._[0]; // remove the command name rawOptions._ = rawOptions._.slice(1); const executionScope = insideProject() ? CommandScope.inProject : CommandScope.outsideProject; let Cmd: CommandConstructor | null; Cmd = findCommand(commandMap, commandName); if (!Cmd && !commandName && (rawOptions.v || rawOptions.version)) { commandName = 'version'; Cmd = findCommand(commandMap, commandName); } if (!Cmd && rawOptions.help) { commandName = 'help'; Cmd = findCommand(commandMap, commandName); } if (!Cmd) { const commandsDistance = {} as { [name: string]: number }; const allCommands = listAllCommandNames(commandMap).sort((a, b) => { if (!(a in commandsDistance)) { commandsDistance[a] = levenshtein(a, commandName); } if (!(b in commandsDistance)) { commandsDistance[b] = levenshtein(b, commandName); } return commandsDistance[a] - commandsDistance[b]; }); logger.error(tags.stripIndent` The specified command ("${commandName}") is invalid. For a list of available options, run "ng help". Did you mean "${allCommands[0]}"? `); return 1; } const command = new Cmd(context, logger); args = await command.initializeRaw(args); let options = parseOptions(args, command.options, command.arguments, command.argStrategy); await command.initialize(options); options = parseOptions(args, command.options, command.arguments, command.argStrategy); if (commandName === 'help') { options.commandMap = commandMap; } if (options.help) { command.printHelp(options); return; } else { if (command.scope !== undefined && command.scope !== CommandScope.everywhere) { if (command.scope !== executionScope) { let errorMessage; if (command.scope === CommandScope.inProject) { errorMessage = `This command can only be run inside of a CLI project.`; } else { errorMessage = `This command can not be run inside of a CLI project.`; } logger.fatal(errorMessage); return 1; } if (command.scope === CommandScope.inProject) { if (!context.project.configFile) { logger.fatal('Invalid project: missing workspace file.'); return 1; } if (['.angular-cli.json', 'angular-cli.json'].includes(context.project.configFile)) { // -------------------------------------------------------------------------------- // If changing this message, please update the same message in // `packages/@angular/cli/bin/ng-update-message.js` const message = tags.stripIndent` The Angular CLI configuration format has been changed, and your existing configuration can be updated automatically by running the following command: ng update @angular/cli `; logger.warn(message); return 1; } } } delete options.h; delete options.help; const isValid = await command.validate(options); if (!isValid) { logger.fatal(`Validation error. Invalid command`); return 1; } return await command.run(options); } } export function parseOptions( args: string[], cmdOpts: Option[], commandArguments: string[], argStrategy: ArgumentStrategy, ): T { const parser = yargsParser; const aliases = cmdOpts.concat() .filter(o => o.aliases && o.aliases.length > 0) .reduce((aliases: any, opt: Option) => { aliases[opt.name] = (opt.aliases || []) .filter(a => a.length === 1); return aliases; }, {}); const booleans = cmdOpts .filter(o => o.type && o.type === Boolean) .map(o => o.name); const defaults = cmdOpts .filter(o => o.default !== undefined || booleans.indexOf(o.name) !== -1) .reduce((defaults: any, opt: Option) => { defaults[opt.name] = opt.default; return defaults; }, {}); const strings = cmdOpts .filter(o => o.type === String) .map(o => o.name); const numbers = cmdOpts .filter(o => o.type === Number) .map(o => o.name); aliases.help = ['h']; booleans.push('help'); const yargsOptions = { alias: aliases, boolean: booleans, default: defaults, string: strings, number: numbers, }; const parsedOptions = parser(args, yargsOptions); // Remove aliases. cmdOpts .filter(o => o.aliases && o.aliases.length > 0) .map(o => o.aliases) .reduce((allAliases: any, aliases: string[]) => { return allAliases.concat([...aliases]); }, []) .forEach((alias: string) => { delete parsedOptions[alias]; }); // Remove undefined booleans booleans .filter(b => parsedOptions[b] === undefined) .map(b => coreStrings.camelize(b)) .forEach(b => delete parsedOptions[b]); // remove options with dashes. Object.keys(parsedOptions) .filter(key => key.indexOf('-') !== -1) .forEach(key => delete parsedOptions[key]); // remove the command name parsedOptions._ = parsedOptions._.slice(1); switch (argStrategy) { case ArgumentStrategy.MapToOptions: parsedOptions._.forEach((value: string, index: number) => { const arg = commandArguments[index]; if (arg) { parsedOptions[arg] = value; } }); delete parsedOptions._; break; } return parsedOptions; } // Find a command. function findCommand(map: CommandMap, name: string): CommandConstructor | null { let Cmd: CommandConstructor = map[name]; if (!Cmd) { // find command via aliases Cmd = Object.keys(map) .filter(key => { if (!map[key].aliases) { return false; } const foundAlias = map[key].aliases .filter((alias: string) => alias === name); return foundAlias.length > 0; }) .map((key) => { return map[key]; })[0]; } if (!Cmd) { return null; } return Cmd; } function listAllCommandNames(map: CommandMap): string[] { return Object.keys(map).concat( Object.keys(map) .reduce((acc, key) => { if (!map[key].aliases) { return acc; } return acc.concat(map[key].aliases); }, [] as string[]), ); }