mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-22 15:02:11 +08:00
652 lines
18 KiB
JavaScript
652 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const nopt = require('nopt');
|
|
const chalk = require('chalk');
|
|
const path = require('path');
|
|
const camelize = require('ember-cli-string-utils').camelize;
|
|
const getCallerFile = require('get-caller-file');
|
|
const printCommand = require('../utilities/print-command');
|
|
const Promise = require('rsvp').Promise;
|
|
const _ = require('lodash');
|
|
const EOL = require('os').EOL;
|
|
const CoreObject = require('core-object');
|
|
let logger = require('heimdalljs-logger')('ember-cli:command');
|
|
const SilentError = require('silent-error');
|
|
const fs = require('fs');
|
|
|
|
let cache = {};
|
|
|
|
let allowedWorkOptions = {
|
|
insideProject: true,
|
|
outsideProject: true,
|
|
everywhere: true,
|
|
};
|
|
|
|
path.name = 'Path';
|
|
|
|
/**
|
|
* The base class for all CLI commands.
|
|
*
|
|
* @module ember-cli
|
|
* @class Command
|
|
* @constructor
|
|
* @extends CoreObject
|
|
*/
|
|
let Command = CoreObject.extend({
|
|
/**
|
|
* The description of what this command does.
|
|
*
|
|
* @final
|
|
* @property description
|
|
* @type String
|
|
*/
|
|
description: null,
|
|
|
|
/**
|
|
* Does this command work everywhere or just inside or outside of projects.
|
|
*
|
|
* Possible values:
|
|
*
|
|
* - `insideProject`
|
|
* - `outsideProject`
|
|
* - `everywhere`
|
|
*
|
|
* @final
|
|
* @property works
|
|
* @type String
|
|
* @default `insideProject`
|
|
*/
|
|
works: 'insideProject',
|
|
|
|
_printableProperties: [
|
|
'name',
|
|
'description',
|
|
'aliases',
|
|
'works',
|
|
'availableOptions',
|
|
'anonymousOptions',
|
|
],
|
|
|
|
init() {
|
|
this._super.apply(this, arguments);
|
|
|
|
/**
|
|
* @final
|
|
* @property isWithinProject
|
|
* @type Boolean
|
|
*/
|
|
this.isWithinProject = this.project.isEmberCLIProject();
|
|
|
|
/**
|
|
* The name of the command.
|
|
*
|
|
* @final
|
|
* @property name
|
|
* @type String
|
|
* @example `new` or `generate`
|
|
*/
|
|
this.name = this.name || path.basename(getCallerFile(), '.js');
|
|
|
|
logger.info('initialize: name: %s, name: %s', this.name);
|
|
|
|
/**
|
|
* An array of aliases for the command
|
|
*
|
|
* @final
|
|
* @property aliases
|
|
* @type Array
|
|
* @example `['g']` for the `generate` command
|
|
*/
|
|
this.aliases = this.aliases || [];
|
|
|
|
// Works Property
|
|
if (!allowedWorkOptions[this.works]) {
|
|
throw new Error(`The "${this.name}" command's works field has to be either "everywhere", "insideProject" or "outsideProject".`);
|
|
}
|
|
|
|
/**
|
|
* An array of available options for the command
|
|
*
|
|
* @final
|
|
* @property availableOptions
|
|
* @type Array
|
|
* @example
|
|
* ```js
|
|
* availableOptions: [
|
|
* { name: 'dry-run', type: Boolean, default: false, aliases: ['d'] },
|
|
* { name: 'verbose', type: Boolean, default: false, aliases: ['v'] },
|
|
* { name: 'blueprint', type: String, default: 'app', aliases: ['b'] },
|
|
* { name: 'skip-npm', type: Boolean, default: false, aliases: ['sn'] },
|
|
* { name: 'skip-bower', type: Boolean, default: false, aliases: ['sb'] },
|
|
* { name: 'skip-git', type: Boolean, default: false, aliases: ['sg'] },
|
|
* { name: 'directory', type: String , aliases: ['dir'] }
|
|
* ],
|
|
* ```
|
|
*/
|
|
this.availableOptions = this.availableOptions || [];
|
|
|
|
/**
|
|
* An array of anonymous options for the command
|
|
*
|
|
* @final
|
|
* @property anonymousOptions
|
|
* @type Array
|
|
* @example
|
|
* ```js
|
|
* anonymousOptions: [
|
|
* '<blueprint>'
|
|
* ],
|
|
* ```
|
|
*/
|
|
this.anonymousOptions = this.anonymousOptions || [];
|
|
|
|
this.registerOptions();
|
|
},
|
|
|
|
/**
|
|
Registers options with command. This method provides the ability to extend or override command options.
|
|
Expects an object containing anonymousOptions or availableOptions, which it will then merge with
|
|
existing availableOptions before building the optionsAliases which are used to define shorthands.
|
|
@method registerOptions
|
|
@param {Object} options
|
|
*/
|
|
registerOptions(options) {
|
|
let extendedAvailableOptions = (options && options.availableOptions) || [];
|
|
let extendedAnonymousOptions = (options && options.anonymousOptions) || [];
|
|
|
|
this.anonymousOptions = _.union(this.anonymousOptions.slice(0), extendedAnonymousOptions);
|
|
|
|
// merge any availableOptions
|
|
this.availableOptions = _.union(this.availableOptions.slice(0), extendedAvailableOptions);
|
|
|
|
let optionKeys = _.uniq(_.map(this.availableOptions, 'name'));
|
|
|
|
optionKeys.map(this.mergeDuplicateOption.bind(this));
|
|
|
|
this.optionsAliases = this.optionsAliases || {};
|
|
|
|
this.availableOptions.map(this.validateOption.bind(this));
|
|
},
|
|
|
|
/**
|
|
* Called when command is interrupted from outside, e.g. ctrl+C or process kill
|
|
* Can be used to cleanup artifacts produced by command and control process exit code
|
|
*
|
|
* @method onInterrupt
|
|
* @return {Promise|undefined} if rejected promise then result of promise will be used as an exit code
|
|
*/
|
|
onInterrupt() {
|
|
if (this._currentTask) {
|
|
return this._currentTask.onInterrupt();
|
|
} else {
|
|
// interrupt all external commands which don't use `runTask()` with an error
|
|
process.exit(1);
|
|
}
|
|
},
|
|
|
|
_env(/* name */) {
|
|
return {
|
|
ui: this.ui,
|
|
analytics: this.analytics,
|
|
project: this.project,
|
|
testing: this.testing,
|
|
settings: this.settings,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Looks up for the task and runs
|
|
* It also keeps the reference for the current active task
|
|
* Keeping reference for the current task allows to cleanup task on interruption
|
|
*
|
|
* @private
|
|
* @method runTask
|
|
* @throws {Error} when no task found
|
|
* @throws {Error} on attempt to run concurrent task
|
|
* @param {string} name Task name from the tasks registry. Should be capitalized
|
|
* @param {object} options
|
|
* @return {Promise} Task run
|
|
*/
|
|
runTask(name, options) {
|
|
if (this._currentTask) {
|
|
throw new Error(`Concurrent tasks are not supported`);
|
|
}
|
|
|
|
logger.info(`\`${this.name}\` command running \`${name}\` task`);
|
|
|
|
let Task = this.tasks[name];
|
|
if (!Task) {
|
|
throw new Error(`Unknown task "${name}"`);
|
|
}
|
|
|
|
let task = new Task(this._env(name));
|
|
|
|
this._currentTask = task;
|
|
|
|
return Promise.resolve().then(() => task.run(options))
|
|
.catch(error => {
|
|
logger.info(`An error occurred running \`${name}\` from the \`${this.name}\` command.`, error.stack);
|
|
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
delete this._currentTask;
|
|
});
|
|
},
|
|
|
|
/**
|
|
Hook for extending a command before it is run in the cli.run command.
|
|
Most common use case would be to extend availableOptions.
|
|
@method beforeRun
|
|
@return {Promise|null}
|
|
*/
|
|
beforeRun() {
|
|
|
|
},
|
|
|
|
/**
|
|
@method validateAndRun
|
|
@return {Promise}
|
|
*/
|
|
validateAndRun(args) {
|
|
return new Promise(resolve => {
|
|
let commandOptions = this.parseArgs(args);
|
|
// if the help option was passed, resolve with 'callHelp' to call help command
|
|
if (commandOptions && (commandOptions.options.help || commandOptions.options.h)) {
|
|
logger.info(`${this.name} called with help option`);
|
|
return resolve('callHelp');
|
|
}
|
|
|
|
if (commandOptions === null) {
|
|
return resolve();
|
|
}
|
|
|
|
if (this.works === 'outsideProject' && this.isWithinProject) {
|
|
throw new SilentError(`You cannot use the ${chalk.green(this.name)} command inside an Angular CLI project.`);
|
|
}
|
|
|
|
if (this.works === 'insideProject') {
|
|
if (!this.project.hasDependencies()) {
|
|
throw new SilentError('node_modules appears empty, you may need to run `npm install`');
|
|
}
|
|
}
|
|
|
|
resolve(this.run(commandOptions.options, commandOptions.args));
|
|
});
|
|
},
|
|
|
|
/**
|
|
Reports if the given command has a command line option by a given name
|
|
@method hasOption
|
|
@param {String} name
|
|
@return {Boolean}
|
|
*/
|
|
hasOption(name) {
|
|
for (let i = 0; i < this.availableOptions.length; i++) {
|
|
if (this.availableOptions[i].name === name) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
Merges any options with duplicate keys in the availableOptions array.
|
|
Used primarily by registerOptions.
|
|
@method mergeDuplicateOption
|
|
@param {String} key
|
|
@return {Object}
|
|
*/
|
|
mergeDuplicateOption(key) {
|
|
let duplicateOptions, mergedOption, mergedAliases;
|
|
// get duplicates to merge
|
|
duplicateOptions = _.filter(this.availableOptions, { 'name': key });
|
|
|
|
if (duplicateOptions.length > 1) {
|
|
// TODO: warn on duplicates and overwriting
|
|
mergedAliases = [];
|
|
|
|
_.map(duplicateOptions, 'aliases').map(alias => {
|
|
alias.map(a => {
|
|
mergedAliases.push(a);
|
|
});
|
|
});
|
|
|
|
// merge duplicate options
|
|
mergedOption = _.assign.apply(null, duplicateOptions);
|
|
|
|
// replace aliases with unique aliases
|
|
mergedOption.aliases = _.uniqBy(mergedAliases, alias => {
|
|
if (typeof alias === 'object') {
|
|
return alias[Object.keys(alias)[0]];
|
|
}
|
|
return alias;
|
|
});
|
|
|
|
// remove duplicates from options
|
|
this.availableOptions = _.reject(this.availableOptions, { 'name': key });
|
|
this.availableOptions.push(mergedOption);
|
|
}
|
|
return this.availableOptions;
|
|
},
|
|
|
|
/**
|
|
Normalizes option, filling in implicit values
|
|
@method normalizeOption
|
|
@param {Object} option
|
|
@return {Object}
|
|
*/
|
|
normalizeOption(option) {
|
|
option.key = camelize(option.name);
|
|
option.aliases = (option.aliases || []);
|
|
if (option.aliases.indexOf(camelize(option.name)) === -1) {
|
|
option.aliases = option.aliases.concat(camelize(option.name));
|
|
}
|
|
option.required = option.required || false;
|
|
return option;
|
|
},
|
|
|
|
/**
|
|
Assigns option
|
|
@method assignOption
|
|
@param {Object} option
|
|
@param {Object} parsedOptions
|
|
@param {Object} commandOptions
|
|
@return {Boolean}
|
|
*/
|
|
assignOption(option, parsedOptions, commandOptions) {
|
|
let isValid = isValidParsedOption(option, parsedOptions[option.name]);
|
|
if (isValid) {
|
|
if (parsedOptions[option.name] === undefined) {
|
|
if (option.default !== undefined) {
|
|
commandOptions[option.key] = option.default;
|
|
}
|
|
|
|
if (this.settings[option.name] !== undefined) {
|
|
commandOptions[option.key] = this.settings[option.name];
|
|
} else if (this.settings[option.key] !== undefined) {
|
|
commandOptions[option.key] = this.settings[option.key];
|
|
}
|
|
} else {
|
|
commandOptions[option.key] = parsedOptions[option.name];
|
|
delete parsedOptions[option.name];
|
|
}
|
|
} else {
|
|
this.ui.writeLine(`The specified command ${chalk.green(this.name)} requires the option ${chalk.green(option.name)}.`);
|
|
}
|
|
return isValid;
|
|
},
|
|
|
|
/**
|
|
Validates option
|
|
@method validateOption
|
|
@param {Object} option
|
|
@return {Boolean}
|
|
*/
|
|
validateOption(option) {
|
|
let parsedAliases;
|
|
|
|
if (!option.name || !option.type) {
|
|
throw new Error(`The command "${this.name}" has an option without the required type and name fields.`);
|
|
}
|
|
|
|
if (option.name !== option.name.toLowerCase()) {
|
|
throw new Error(`The "${option.name}" option's name of the "${this.name}" command contains a capital letter.`);
|
|
}
|
|
|
|
this.normalizeOption(option);
|
|
|
|
if (option.aliases) {
|
|
parsedAliases = option.aliases.map(this.parseAlias.bind(this, option));
|
|
return parsedAliases.map(this.assignAlias.bind(this, option)).indexOf(false) === -1;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
Parses alias for an option and adds it to optionsAliases
|
|
@method parseAlias
|
|
@param {Object} option
|
|
@param {Object|String} alias
|
|
@return {Object}
|
|
*/
|
|
parseAlias(option, alias) {
|
|
let aliasType = typeof alias;
|
|
let key, value, aliasValue;
|
|
|
|
if (isValidAlias(alias, option.type)) {
|
|
if (aliasType === 'string') {
|
|
key = alias;
|
|
value = [`--${option.name}`];
|
|
} else if (aliasType === 'object') {
|
|
key = Object.keys(alias)[0];
|
|
value = [`--${option.name}`, alias[key]];
|
|
}
|
|
} else {
|
|
if (Array.isArray(alias)) {
|
|
aliasType = 'array';
|
|
aliasValue = alias.join(',');
|
|
} else {
|
|
aliasValue = alias;
|
|
try {
|
|
aliasValue = JSON.parse(alias);
|
|
} catch (e) {
|
|
let logger = require('heimdalljs-logger')('@angular/cli/ember-cli/models/command');
|
|
logger.error(e);
|
|
}
|
|
}
|
|
throw new Error(`The "${aliasValue}" [type:${aliasType}] alias is not an acceptable value. ` +
|
|
`It must be a string or single key object with a string value (for example, "value" or { "key" : "value" }).`);
|
|
}
|
|
|
|
return {
|
|
key,
|
|
value,
|
|
original: alias,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* @method assignAlias
|
|
* @param option
|
|
* @param alias
|
|
* @return {Boolean}
|
|
*/
|
|
assignAlias(option, alias) {
|
|
let isValid = this.validateAlias(option, alias);
|
|
|
|
if (isValid) {
|
|
this.optionsAliases[alias.key] = alias.value;
|
|
}
|
|
return isValid;
|
|
},
|
|
|
|
/**
|
|
Validates alias value
|
|
@method validateAlias
|
|
@param {Object} alias
|
|
@return {Boolean}
|
|
*/
|
|
validateAlias(option, alias) {
|
|
let key = alias.key;
|
|
let value = alias.value;
|
|
|
|
if (!this.optionsAliases[key]) {
|
|
return true;
|
|
} else {
|
|
if (value[0] !== this.optionsAliases[key][0]) {
|
|
throw new SilentError(`The "${key}" alias is already in use by the "${this.optionsAliases[key][0]}" option ` +
|
|
`and cannot be used by the "${value[0]}" option. Please use a different alias.`);
|
|
|
|
} else if (value[1] !== this.optionsAliases[key][1]) {
|
|
this.ui.writeLine(chalk.yellow(`The "${key}" alias cannot be overridden. Please use a different alias.`));
|
|
// delete offending alias from options
|
|
let index = this.availableOptions.indexOf(option);
|
|
let aliasIndex = this.availableOptions[index].aliases.indexOf(alias.original);
|
|
if (this.availableOptions[index].aliases[aliasIndex]) {
|
|
delete this.availableOptions[index].aliases[aliasIndex];
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
Parses command arguments and processes
|
|
@method parseArgs
|
|
@param {Object} commandArgs
|
|
@return {Object|null}
|
|
*/
|
|
parseArgs(commandArgs) {
|
|
let knownOpts = {}; // Parse options
|
|
let commandOptions = {};
|
|
let parsedOptions;
|
|
|
|
let assembleAndValidateOption = function(option) {
|
|
return this.assignOption(option, parsedOptions, commandOptions);
|
|
};
|
|
|
|
let validateParsed = function(key) {
|
|
// ignore 'argv', 'h', and 'help'
|
|
if (!commandOptions.hasOwnProperty(key) && key !== 'argv' && key !== 'h' && key !== 'help') {
|
|
this.ui.writeLine(chalk.yellow(`The option '--${key}' is not registered with the ${this.name} command. ` +
|
|
`Run \`ng ${this.name} --help\` for a list of supported options.`));
|
|
}
|
|
if (typeof parsedOptions[key] !== 'object') {
|
|
commandOptions[camelize(key)] = parsedOptions[key];
|
|
}
|
|
};
|
|
|
|
this.availableOptions.forEach(option => {
|
|
if (typeof option.type !== 'string') {
|
|
knownOpts[option.name] = option.type;
|
|
} else if (option.type === 'Path') {
|
|
knownOpts[option.name] = path;
|
|
} else {
|
|
knownOpts[option.name] = String;
|
|
}
|
|
});
|
|
|
|
parsedOptions = nopt(knownOpts, this.optionsAliases, commandArgs, 0);
|
|
|
|
if (!this.availableOptions.every(assembleAndValidateOption.bind(this))) {
|
|
return null;
|
|
}
|
|
|
|
_.keys(parsedOptions).map(validateParsed.bind(this));
|
|
|
|
return {
|
|
options: _.defaults(commandOptions, this.settings),
|
|
args: parsedOptions.argv.remain,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* @method run
|
|
* @param commandArgs
|
|
*/
|
|
run(commandArgs) {
|
|
throw new Error(`command must implement run${commandArgs.toString()}`);
|
|
},
|
|
|
|
_printCommand: printCommand,
|
|
|
|
/**
|
|
Prints basic help for the command.
|
|
Basic help looks like this:
|
|
ng generate <blueprint> <options...>
|
|
Generates new code from blueprints
|
|
aliases: g
|
|
--dry-run (Default: false)
|
|
--verbose (Default: false)
|
|
The default implementation is designed to cover all bases
|
|
but may be overriden if necessary.
|
|
@method printBasicHelp
|
|
*/
|
|
printBasicHelp() {
|
|
// ng command-name
|
|
let output;
|
|
if (this.isRoot) {
|
|
output = `Usage: ${this.name}`;
|
|
} else {
|
|
output = `ng ${this.name}`;
|
|
}
|
|
|
|
output += this._printCommand();
|
|
output += EOL;
|
|
|
|
return output;
|
|
},
|
|
|
|
/**
|
|
Prints detailed help for the command.
|
|
The default implementation is no-op and should be overridden
|
|
for each command where further help text is required.
|
|
@method printDetailedHelp
|
|
*/
|
|
printDetailedHelp() {},
|
|
|
|
/**
|
|
* @method getJson
|
|
* @param {Object} options
|
|
* @return {Object}
|
|
*/
|
|
getJson(options) {
|
|
let json = {};
|
|
this._printableProperties.forEach(key => (json[key] = this[key]));
|
|
|
|
if (this.addAdditionalJsonForHelp) {
|
|
this.addAdditionalJsonForHelp(json, options);
|
|
}
|
|
|
|
return json;
|
|
},
|
|
});
|
|
|
|
/*
|
|
Validates options parsed by nopt
|
|
*/
|
|
function isValidParsedOption(option, parsedOption) {
|
|
// option.name didn't parse
|
|
if (parsedOption === undefined) {
|
|
// no default
|
|
if (option.default === undefined) {
|
|
if (option.required) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
Validates alias. Must be a string or single key object
|
|
*/
|
|
function isValidAlias(alias, expectedType) {
|
|
let type = typeof alias;
|
|
let value, valueType;
|
|
if (type === 'string') {
|
|
return true;
|
|
} else if (type === 'object') {
|
|
|
|
// no arrays, no multi-key objects
|
|
if (!Array.isArray(alias) && Object.keys(alias).length === 1) {
|
|
value = alias[Object.keys(alias)[0]];
|
|
valueType = typeof value;
|
|
if (!Array.isArray(expectedType)) {
|
|
if (valueType === expectedType.name.toLowerCase()) {
|
|
return true;
|
|
}
|
|
|
|
} else if (expectedType.indexOf(value) > -1) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
module.exports = Command;
|