diff --git a/lib/index.js b/lib/index.js index fc3357fd..f0e1e0e0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,4 @@ 'use strict'; -const util = require('util'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -56,687 +55,668 @@ const EMPTY = '@@_YEOMAN_EMPTY_MARKER_@@'; * } * }; */ +class Generator extends EventEmitter { + constructor(args, options) { + super(); -const Generator = function (args, options) { - EventEmitter.call(this); + if (!Array.isArray(args)) { + options = args; + args = []; + } - if (!Array.isArray(args)) { - options = args; - args = []; - } + this.options = options || {}; + this._initOptions = _.clone(options); + this._args = args || []; + this._options = {}; + this._arguments = []; + this._composedWith = []; + this._transformStreams = []; + + this.option('help', { + type: Boolean, + alias: 'h', + description: 'Print the generator\'s options and usage' + }); - this.options = options || {}; - this._initOptions = _.clone(options); - this._args = args || []; - this._options = {}; - this._arguments = []; - this._composedWith = []; - this._transformStreams = []; - - this.option('help', { - type: Boolean, - alias: 'h', - description: 'Print the generator\'s options and usage' - }); - - this.option('skip-cache', { - type: Boolean, - description: 'Do not remember prompt answers', - default: false - }); - - this.option('skip-install', { - type: Boolean, - description: 'Do not automatically install dependencies', - default: false - }); - - // Checks required paramaters - assert(this.options.env, 'You must provide the environment object. Use env#create() to create a new generator.'); - assert(this.options.resolved, 'You must provide the resolved path value. Use env#create() to create a new generator.'); - this.env = this.options.env; - this.resolved = this.options.resolved; - - // Ensure the environment support features this yeoman-generator version require. - require('yeoman-environment').enforceUpdate(this.env); - - this.description = this.description || ''; - - this.async = function () { - return function () {}; - }; - - this.fs = FileEditor.create(this.env.sharedFs); - this.conflicter = new Conflicter(this.env.adapter, this.options.force); - - // Mirror the adapter log method on the generator. - // - // example: - // this.log('foo'); - // this.log.error('bar'); - this.log = this.env.adapter.log; - - // Determine the app root - this.contextRoot = this.env.cwd; - - let rootPath = findUp.sync('.yo-rc.json', { - cwd: this.env.cwd - }); - rootPath = rootPath ? path.dirname(rootPath) : this.env.cwd; - - if (rootPath !== this.env.cwd) { - this.log([ - '', - 'Just found a `.yo-rc.json` in a parent directory.', - 'Setting the project root at: ' + rootPath - ].join('\n')); - this.destinationRoot(rootPath); - } + this.option('skip-cache', { + type: Boolean, + description: 'Do not remember prompt answers', + default: false + }); - this.appname = this.determineAppname(); - this.config = this._getStorage(); - this._globalConfig = this._getGlobalStorage(); + this.option('skip-install', { + type: Boolean, + description: 'Do not automatically install dependencies', + default: false + }); - // Ensure source/destination path, can be configured from subclasses - this.sourceRoot(path.join(path.dirname(this.resolved), 'templates')); -}; + // Checks required paramaters + assert(this.options.env, 'You must provide the environment object. Use env#create() to create a new generator.'); + assert(this.options.resolved, 'You must provide the resolved path value. Use env#create() to create a new generator.'); + this.env = this.options.env; + this.resolved = this.options.resolved; -util.inherits(Generator, EventEmitter); + // Ensure the environment support features this yeoman-generator version require. + require('yeoman-environment').enforceUpdate(this.env); -// Mixin the actions modules -_.extend(Generator.prototype, require('./actions/install')); -_.extend(Generator.prototype, require('./actions/help')); -_.extend(Generator.prototype, require('./actions/spawn-command')); -Generator.prototype.user = require('./actions/user'); + this.description = this.description || ''; -/* - * Prompt user to answer questions. The signature of this method is the same as {@link https://github.com/SBoudrias/Inquirer.js Inquirer.js} - * - * On top of the Inquirer.js API, you can provide a `{cache: true}` property for - * every question descriptor. When set to true, Yeoman will store/fetch the - * user's answers as defaults. - * - * @param {array} questions Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation} - * @return {Promise} - */ + this.async = () => () => {}; -Generator.prototype.prompt = function (questions) { - questions = promptSuggestion.prefillQuestions(this._globalConfig, questions); - questions = promptSuggestion.prefillQuestions(this.config, questions); + this.fs = FileEditor.create(this.env.sharedFs); + this.conflicter = new Conflicter(this.env.adapter, this.options.force); - return this.env.adapter.prompt(questions).then(answers => { - if (!this.options['skip-cache']) { - promptSuggestion.storeAnswers(this._globalConfig, questions, answers, false); - promptSuggestion.storeAnswers(this.config, questions, answers, true); - } + // Mirror the adapter log method on the generator. + // + // example: + // this.log('foo'); + // this.log.error('bar'); + this.log = this.env.adapter.log; - return answers; - }); -}; + // Determine the app root + this.contextRoot = this.env.cwd; -/** - * Adds an option to the set of generator expected options, only used to - * generate generator usage. By default, generators get all the cli options - * parsed by nopt as a `this.options` hash object. - * - * ### Options: - * - * - `description` Description for the option - * - `type` Either Boolean, String or Number - * - `alias` Option name alias (example `-h` and --help`) - * - `default` Default value - * - `hide` Boolean whether to hide from help - * - * @param {String} name - * @param {Object} config - */ - -Generator.prototype.option = function (name, config) { - config = config || {}; + let rootPath = findUp.sync('.yo-rc.json', { + cwd: this.env.cwd + }); + rootPath = rootPath ? path.dirname(rootPath) : this.env.cwd; + + if (rootPath !== this.env.cwd) { + this.log([ + '', + 'Just found a `.yo-rc.json` in a parent directory.', + 'Setting the project root at: ' + rootPath + ].join('\n')); + this.destinationRoot(rootPath); + } - // Alias default to defaults for backward compatibility. - if ('defaults' in config) { - config.default = config.defaults; - } - config.description = config.description || config.desc; - - _.defaults(config, { - name, - description: 'Description for ' + name, - type: Boolean, - hide: false - }); - - // Check whether boolean option is invalid (starts with no-) - const boolOptionRegex = /^no-/; - if (config.type === Boolean && name.match(boolOptionRegex)) { - const simpleName = name.replace(boolOptionRegex, ''); - return this.emit('error', new Error([ - `Option name ${chalk.yellow(name)} cannot start with ${chalk.red('no-')}\n`, - `Option name prefixed by ${chalk.yellow('--no')} are parsed as implicit`, - ` boolean. To use ${chalk.yellow('--' + name)} as an option, use\n`, - chalk.cyan(` this.option('${simpleName}', {type: Boolean})`) - ].join(''))); - } + this.appname = this.determineAppname(); + this.config = this._getStorage(); + this._globalConfig = this._getGlobalStorage(); - if (this._options[name] === null || this._options[name] === undefined) { - this._options[name] = config; + // Ensure source/destination path, can be configured from subclasses + this.sourceRoot(path.join(path.dirname(this.resolved), 'templates')); } - this.parseOptions(); - return this; -}; - -/** - * Adds an argument to the class and creates an attribute getter for it. - * - * Arguments are different from options in several aspects. The first one - * is how they are parsed from the command line, arguments are retrieved - * based on their position. - * - * Besides, arguments are used inside your code as a property (`this.argument`), - * while options are all kept in a hash (`this.options`). - * - * ### Options: - * - * - `description` Description for the argument - * - `required` Boolean whether it is required - * - `optional` Boolean whether it is optional - * - `type` String, Number, Array, or Object - * - `default` Default value for this argument - * - * @param {String} name - * @param {Object} config - */ - -Generator.prototype.argument = function (name, config) { - config = config || {}; + /* + * Prompt user to answer questions. The signature of this method is the same as {@link https://github.com/SBoudrias/Inquirer.js Inquirer.js} + * + * On top of the Inquirer.js API, you can provide a `{cache: true}` property for + * every question descriptor. When set to true, Yeoman will store/fetch the + * user's answers as defaults. + * + * @param {array} questions Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation} + * @return {Promise} + */ + prompt(questions) { + questions = promptSuggestion.prefillQuestions(this._globalConfig, questions); + questions = promptSuggestion.prefillQuestions(this.config, questions); + + return this.env.adapter.prompt(questions).then(answers => { + if (!this.options['skip-cache']) { + promptSuggestion.storeAnswers(this._globalConfig, questions, answers, false); + promptSuggestion.storeAnswers(this.config, questions, answers, true); + } - // Alias default to defaults for backward compatibility. - if ('defaults' in config) { - config.default = config.defaults; + return answers; + }); } - config.description = config.description || config.desc; - - _.defaults(config, { - name, - required: config.default === null || config.default === undefined, - type: String - }); - - this._arguments.push(config); - - this.parseOptions(); - return this; -}; - -Generator.prototype.parseOptions = function () { - const minimistDef = { - string: [], - boolean: [], - alias: {}, - default: {} - }; - - _.each(this._options, option => { - if (option.type === Boolean) { - minimistDef.boolean.push(option.name); - if (!('default' in option) && !option.required) { - minimistDef.default[option.name] = EMPTY; - } - } else { - minimistDef.string.push(option.name); + + /** + * Adds an option to the set of generator expected options, only used to + * generate generator usage. By default, generators get all the cli options + * parsed by nopt as a `this.options` hash object. + * + * ### Options: + * + * - `description` Description for the option + * - `type` Either Boolean, String or Number + * - `alias` Option name alias (example `-h` and --help`) + * - `default` Default value + * - `hide` Boolean whether to hide from help + * + * @param {String} name + * @param {Object} config + */ + option(name, config) { + config = config || {}; + + // Alias default to defaults for backward compatibility. + if ('defaults' in config) { + config.default = config.defaults; } + config.description = config.description || config.desc; - if (option.alias) { - minimistDef.alias[option.alias] = option.name; + _.defaults(config, { + name, + description: 'Description for ' + name, + type: Boolean, + hide: false + }); + + // Check whether boolean option is invalid (starts with no-) + const boolOptionRegex = /^no-/; + if (config.type === Boolean && name.match(boolOptionRegex)) { + const simpleName = name.replace(boolOptionRegex, ''); + return this.emit('error', new Error([ + `Option name ${chalk.yellow(name)} cannot start with ${chalk.red('no-')}\n`, + `Option name prefixed by ${chalk.yellow('--no')} are parsed as implicit`, + ` boolean. To use ${chalk.yellow('--' + name)} as an option, use\n`, + chalk.cyan(` this.option('${simpleName}', {type: Boolean})`) + ].join(''))); } - // Only apply default values if we don't already have a value injected from - // the runner - if (option.name in this._initOptions) { - minimistDef.default[option.name] = this._initOptions[option.name]; - } else if (option.alias && option.alias in this._initOptions) { - minimistDef.default[option.name] = this._initOptions[option.alias]; - } else if ('default' in option) { - minimistDef.default[option.name] = option.default; + if (this._options[name] === null || this._options[name] === undefined) { + this._options[name] = config; } - }); - const parsedOpts = minimist(this._args, minimistDef); + this.parseOptions(); + return this; + } - // Parse options to the desired type - _.each(parsedOpts, (option, name) => { - // Manually set value as undefined if it should be. - if (option === EMPTY) { - parsedOpts[name] = undefined; - return; - } - if (this._options[name] && option !== undefined) { - parsedOpts[name] = this._options[name].type(option); + /** + * Adds an argument to the class and creates an attribute getter for it. + * + * Arguments are different from options in several aspects. The first one + * is how they are parsed from the command line, arguments are retrieved + * based on their position. + * + * Besides, arguments are used inside your code as a property (`this.argument`), + * while options are all kept in a hash (`this.options`). + * + * ### Options: + * + * - `description` Description for the argument + * - `required` Boolean whether it is required + * - `optional` Boolean whether it is optional + * - `type` String, Number, Array, or Object + * - `default` Default value for this argument + * + * @param {String} name + * @param {Object} config + */ + argument(name, config) { + config = config || {}; + + // Alias default to defaults for backward compatibility. + if ('defaults' in config) { + config.default = config.defaults; } - }); - - // Parse positional arguments to valid options - this._arguments.forEach((config, index) => { - let value; - if (index >= parsedOpts._.length) { - if (config.name in this._initOptions) { - value = this._initOptions[config.name]; - } else if ('default' in config) { - value = config.default; - } else { - return; - } - } else if (config.type === Array) { - value = parsedOpts._.slice(index, parsedOpts._.length); - } else { - value = config.type(parsedOpts._[index]); - } - - parsedOpts[config.name] = value; - }); + config.description = config.description || config.desc; - // Make the parsed options available to the instance - _.extend(this.options, parsedOpts); - this.args = parsedOpts._; - this.arguments = parsedOpts._; + _.defaults(config, { + name, + required: config.default === null || config.default === undefined, + type: String + }); - // Make sure required args are all present - this.checkRequiredArgs(); -}; + this._arguments.push(config); -Generator.prototype.checkRequiredArgs = function () { - // If the help option was provided, we don't want to check for required - // arguments, since we're only going to print the help message anyway. - if (this.options.help) { - return; + this.parseOptions(); + return this; } - // Bail early if it's not possible to have a missing required arg - if (this.args.length > this._arguments.length) { - return; - } + parseOptions() { + const minimistDef = { + string: [], + boolean: [], + alias: {}, + default: {} + }; + + _.each(this._options, option => { + if (option.type === Boolean) { + minimistDef.boolean.push(option.name); + if (!('default' in option) && !option.required) { + minimistDef.default[option.name] = EMPTY; + } + } else { + minimistDef.string.push(option.name); + } - this._arguments.forEach(function (config, position) { - // If the help option was not provided, check whether the argument was - // required, and whether a value was provided. - if (config.required && position >= this.args.length) { - return this.emit('error', new Error('Did not provide required argument ' + chalk.bold(config.name) + '!')); - } - }, this); -}; + if (option.alias) { + minimistDef.alias[option.alias] = option.name; + } -/** - * Runs the generator, scheduling prototype methods on a run queue. Method names - * will determine the order each method is run. Methods without special names - * will run in the default queue. - * - * Any method named `constructor` and any methods prefixed by a `_` won't be scheduled. - * - * You can also supply the arguments for the method to be invoked. If none are - * provided, the same values used to initialize the invoker are used to - * initialize the invoked. - * - * @param {Function} [cb] - */ + // Only apply default values if we don't already have a value injected from + // the runner + if (option.name in this._initOptions) { + minimistDef.default[option.name] = this._initOptions[option.name]; + } else if (option.alias && option.alias in this._initOptions) { + minimistDef.default[option.name] = this._initOptions[option.alias]; + } else if ('default' in option) { + minimistDef.default[option.name] = option.default; + } + }); -Generator.prototype.run = function (cb) { - cb = cb || function () {}; + const parsedOpts = minimist(this._args, minimistDef); - const self = this; - this._running = true; - this.emit('run'); + // Parse options to the desired type + _.each(parsedOpts, (option, name) => { + // Manually set value as undefined if it should be. + if (option === EMPTY) { + parsedOpts[name] = undefined; + return; + } + if (this._options[name] && option !== undefined) { + parsedOpts[name] = this._options[name].type(option); + } + }); - const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); - const validMethods = methods.filter(methodIsValid); - assert(validMethods.length, 'This Generator is empty. Add at least one method for it to run.'); + // Parse positional arguments to valid options + this._arguments.forEach((config, index) => { + let value; + if (index >= parsedOpts._.length) { + if (config.name in this._initOptions) { + value = this._initOptions[config.name]; + } else if ('default' in config) { + value = config.default; + } else { + return; + } + } else if (config.type === Array) { + value = parsedOpts._.slice(index, parsedOpts._.length); + } else { + value = config.type(parsedOpts._[index]); + } - this.env.runLoop.once('end', () => { - this.emit('end'); - cb(); - }); + parsedOpts[config.name] = value; + }); - // Ensure a prototype method is a candidate run by default - function methodIsValid(name) { - return name.charAt(0) !== '_' && name !== 'constructor'; - } + // Make the parsed options available to the instance + Object.assign(this.options, parsedOpts); + this.args = parsedOpts._; + this.arguments = parsedOpts._; - function addMethod(method, methodName, queueName) { - queueName = queueName || 'default'; - debug('Queueing ' + methodName + ' in ' + queueName); - self.env.runLoop.add(queueName, completed => { - debug('Running ' + methodName); - self.emit('method:' + methodName); - - runAsync(function () { - self.async = function () { - return this.async(); - }.bind(this); - - return method.apply(self, self.args); - })().then(completed).catch(err => { - debug('An error occured while running ' + methodName, err); - - // Ensure we emit the error event outside the promise context so it won't be - // swallowed when there's no listeners. - setImmediate(() => { - self.emit('error', err); - cb(err); - }); - }); - }); + // Make sure required args are all present + this.checkRequiredArgs(); } - function addInQueue(name) { - const item = Object.getPrototypeOf(self)[name]; - const queueName = self.env.runLoop.queueNames.indexOf(name) === -1 ? null : name; - - // Name points to a function; run it! - if (_.isFunction(item)) { - return addMethod(item, name, queueName); + checkRequiredArgs() { + // If the help option was provided, we don't want to check for required + // arguments, since we're only going to print the help message anyway. + if (this.options.help) { + return; } - // Not a queue hash; stop - if (!queueName) { + // Bail early if it's not possible to have a missing required arg + if (this.args.length > this._arguments.length) { return; } - // Run each queue items - _.each(item, (method, methodName) => { - if (!_.isFunction(method) || !methodIsValid(methodName)) { - return; + this._arguments.forEach((config, position) => { + // If the help option was not provided, check whether the argument was + // required, and whether a value was provided. + if (config.required && position >= this.args.length) { + return this.emit('error', new Error(`Did not provide required argument ${chalk.bold(config.name)}!`)); } - - addMethod(method, methodName, queueName); }); } - validMethods.forEach(addInQueue); - - const writeFiles = function () { - this.env.runLoop.add('conflicts', this._writeFiles.bind(this), { - once: 'write memory fs to disk' - }); - }.bind(this); - - this.env.sharedFs.on('change', writeFiles); - writeFiles(); - - // Add the default conflicts handling - this.env.runLoop.add('conflicts', done => { - this.conflicter.resolve(err => { - if (err) { - this.emit('error', err); - } - - done(); + /** + * Runs the generator, scheduling prototype methods on a run queue. Method names + * will determine the order each method is run. Methods without special names + * will run in the default queue. + * + * Any method named `constructor` and any methods prefixed by a `_` won't be scheduled. + * + * You can also supply the arguments for the method to be invoked. If none are + * provided, the same values used to initialize the invoker are used to + * initialize the invoked. + * + * @param {Function} [cb] + */ + run(cb) { + cb = cb || (() => {}); + + const self = this; + this._running = true; + this.emit('run'); + + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); + const validMethods = methods.filter(methodIsValid); + assert(validMethods.length, 'This Generator is empty. Add at least one method for it to run.'); + + this.env.runLoop.once('end', () => { + this.emit('end'); + cb(); }); - }); - _.invokeMap(this._composedWith, 'run'); - return this; -}; - -/** - * Compose this generator with another one. - * @param {String} namespace The generator namespace to compose with - * @param {Object} options The options passed to the Generator - * @param {Object} [settings] Settings hash on the composition relation - * @param {string} [settings.local] Path to a locally stored generator - * @param {String} [settings.link="weak"] If "strong", the composition will occured - * even when the composition is initialized by - * the end user - * @return {this} - * - * @example Using a peerDependency generator - * this.composeWith('bootstrap', { sass: true }); - * - * @example Using a direct dependency generator - * this.composeWith(require.resolve('generator-bootstrap/app/main.js'), { sass: true }); - */ + // Ensure a prototype method is a candidate run by default + function methodIsValid(name) { + return name.charAt(0) !== '_' && name !== 'constructor'; + } -Generator.prototype.composeWith = function (modulePath, options) { - let generator; - options = options || {}; - - // Pass down the default options so they're correclty mirrored down the chain. - options = _.extend({ - skipInstall: this.options.skipInstall, - 'skip-install': this.options.skipInstall, - skipCache: this.options.skipCache, - 'skip-cache': this.options.skipCache - }, options); - - try { - const Generator = require(modulePath); // eslint-disable-line import/no-dynamic-require - Generator.resolved = require.resolve(modulePath); - Generator.namespace = this.env.namespace(modulePath); - generator = this.env.instantiate(Generator, { - options, - arguments: options.arguments - }); - } catch (err) { - if (err.code === 'MODULE_NOT_FOUND') { - generator = this.env.create(modulePath, { - options, - arguments: options.arguments + function addMethod(method, methodName, queueName) { + queueName = queueName || 'default'; + debug(`Queueing ${methodName} in ${queueName}`); + self.env.runLoop.add(queueName, completed => { + debug(`Running ${methodName}`); + self.emit(`method:${methodName}`); + + runAsync(function () { + self.async = () => this.async(); + return method.apply(self, self.args); + })().then(completed).catch(err => { + debug(`An error occured while running ${methodName}`, err); + + // Ensure we emit the error event outside the promise context so it won't be + // swallowed when there's no listeners. + setImmediate(() => { + self.emit('error', err); + cb(err); + }); + }); }); - } else { - throw err; } - } - if (this._running) { - generator.run(); - } else { - this._composedWith.push(generator); - } + function addInQueue(name) { + const item = Object.getPrototypeOf(self)[name]; + const queueName = self.env.runLoop.queueNames.indexOf(name) === -1 ? null : name; - return this; -}; + // Name points to a function; run it! + if (typeof item === 'function') { + return addMethod(item, name, queueName); + } -/** - * Determine the root generator name (the one who's extending Generator). - * @return {String} The name of the root generator - */ + // Not a queue hash; stop + if (!queueName) { + return; + } -Generator.prototype.rootGeneratorName = function () { - const pkg = readPkgUp.sync({cwd: this.resolved}).pkg; - return pkg ? pkg.name : '*'; -}; + // Run each queue items + _.each(item, (method, methodName) => { + if (!_.isFunction(method) || !methodIsValid(methodName)) { + return; + } -/** - * Determine the root generator version (the one who's extending Generator). - * @return {String} The version of the root generator - */ + addMethod(method, methodName, queueName); + }); + } -Generator.prototype.rootGeneratorVersion = function () { - const pkg = readPkgUp.sync({cwd: this.resolved}).pkg; - return pkg ? pkg.version : '0.0.0'; -}; + validMethods.forEach(addInQueue); -/** - * Return a storage instance. - * @return {Storage} Generator storage - * @private - */ + const writeFiles = () => { + this.env.runLoop.add('conflicts', this._writeFiles.bind(this), { + once: 'write memory fs to disk' + }); + }; -Generator.prototype._getStorage = function () { - const storePath = path.join(this.destinationRoot(), '.yo-rc.json'); - return new Storage(this.rootGeneratorName(), this.fs, storePath); -}; + this.env.sharedFs.on('change', writeFiles); + writeFiles(); -/** - * Setup a globalConfig storage instance. - * @return {Storage} Global config storage - * @private - */ + // Add the default conflicts handling + this.env.runLoop.add('conflicts', done => { + this.conflicter.resolve(err => { + if (err) { + this.emit('error', err); + } -Generator.prototype._getGlobalStorage = function () { - const storePath = path.join(os.homedir(), '.yo-rc-global.json'); - const storeName = util.format('%s:%s', this.rootGeneratorName(), this.rootGeneratorVersion()); - return new Storage(storeName, this.fs, storePath); -}; + done(); + }); + }); -/** - * Change the generator destination root directory. - * This path is used to find storage, when using a file system helper method (like - * `this.write` and `this.copy`) - * @param {String} rootPath new destination root path - * @return {String} destination root path - */ + _.invokeMap(this._composedWith, 'run'); + return this; + } -Generator.prototype.destinationRoot = function (rootPath) { - if (_.isString(rootPath)) { - this._destinationRoot = path.resolve(rootPath); + /** + * Compose this generator with another one. + * @param {String} namespace The generator namespace to compose with + * @param {Object} options The options passed to the Generator + * @param {Object} [settings] Settings hash on the composition relation + * @param {string} [settings.local] Path to a locally stored generator + * @param {String} [settings.link="weak"] If "strong", the composition will occured + * even when the composition is initialized by + * the end user + * @return {this} + * + * @example Using a peerDependency generator + * this.composeWith('bootstrap', { sass: true }); + * + * @example Using a direct dependency generator + * this.composeWith(require.resolve('generator-bootstrap/app/main.js'), { sass: true }); + */ + composeWith(modulePath, options) { + let generator; + options = options || {}; + + // Pass down the default options so they're correclty mirrored down the chain. + options = _.extend({ + skipInstall: this.options.skipInstall, + 'skip-install': this.options.skipInstall, + skipCache: this.options.skipCache, + 'skip-cache': this.options.skipCache + }, options); + + try { + const Generator = require(modulePath); // eslint-disable-line import/no-dynamic-require + Generator.resolved = require.resolve(modulePath); + Generator.namespace = this.env.namespace(modulePath); + generator = this.env.instantiate(Generator, { + options, + arguments: options.arguments + }); + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + generator = this.env.create(modulePath, { + options, + arguments: options.arguments + }); + } else { + throw err; + } + } - if (!fs.existsSync(rootPath)) { - mkdirp.sync(rootPath); + if (this._running) { + generator.run(); + } else { + this._composedWith.push(generator); } - process.chdir(rootPath); - this.env.cwd = rootPath; + return this; + } - // Reset the storage - this.config = this._getStorage(); + /** + * Determine the root generator name (the one who's extending Generator). + * @return {String} The name of the root generator + */ + rootGeneratorName() { + const pkg = readPkgUp.sync({cwd: this.resolved}).pkg; + return pkg ? pkg.name : '*'; } - return this._destinationRoot || this.env.cwd; -}; + /** + * Determine the root generator version (the one who's extending Generator). + * @return {String} The version of the root generator + */ + rootGeneratorVersion() { + const pkg = readPkgUp.sync({cwd: this.resolved}).pkg; + return pkg ? pkg.version : '0.0.0'; + } -/** - * Change the generator source root directory. - * This path is used by multiples file system methods like (`this.read` and `this.copy`) - * @param {String} rootPath new source root path - * @return {String} source root path - */ + /** + * Return a storage instance. + * @return {Storage} Generator storage + * @private + */ + _getStorage() { + const storePath = path.join(this.destinationRoot(), '.yo-rc.json'); + return new Storage(this.rootGeneratorName(), this.fs, storePath); + } -Generator.prototype.sourceRoot = function (rootPath) { - if (_.isString(rootPath)) { - this._sourceRoot = path.resolve(rootPath); + /** + * Setup a globalConfig storage instance. + * @return {Storage} Global config storage + * @private + */ + _getGlobalStorage() { + const storePath = path.join(os.homedir(), '.yo-rc-global.json'); + const storeName = `${this.rootGeneratorName()}:${this.rootGeneratorVersion()}`; + return new Storage(storeName, this.fs, storePath); } - return this._sourceRoot; -}; + /** + * Change the generator destination root directory. + * This path is used to find storage, when using a file system helper method (like + * `this.write` and `this.copy`) + * @param {String} rootPath new destination root path + * @return {String} destination root path + */ + destinationRoot(rootPath) { + if (typeof rootPath === 'string') { + this._destinationRoot = path.resolve(rootPath); + + if (!fs.existsSync(rootPath)) { + mkdirp.sync(rootPath); + } -/** - * Join a path to the source root. - * @param {...String} path - * @return {String} joined path - */ + process.chdir(rootPath); + this.env.cwd = rootPath; -Generator.prototype.templatePath = function () { - let filepath = path.join.apply(path, arguments); + // Reset the storage + this.config = this._getStorage(); + } - if (!path.isAbsolute(filepath)) { - filepath = path.join(this.sourceRoot(), filepath); + return this._destinationRoot || this.env.cwd; } - return filepath; -}; - -/** - * Join a path to the destination root. - * @param {...String} path - * @return {String} joined path - */ - -Generator.prototype.destinationPath = function () { - let filepath = path.join.apply(path, arguments); + /** + * Change the generator source root directory. + * This path is used by multiples file system methods like (`this.read` and `this.copy`) + * @param {String} rootPath new source root path + * @return {String} source root path + */ + sourceRoot(rootPath) { + if (typeof rootPath === 'string') { + this._sourceRoot = path.resolve(rootPath); + } - if (!path.isAbsolute(filepath)) { - filepath = path.join(this.destinationRoot(), filepath); + return this._sourceRoot; } - return filepath; -}; + /** + * Join a path to the source root. + * @param {...String} path + * @return {String} joined path + */ + templatePath() { + let filepath = path.join.apply(path, arguments); -/** - * Determines the name of the application. - * - * First checks for name in bower.json. - * Then checks for name in package.json. - * Finally defaults to the name of the current directory. - * @return {String} The name of the application - */ -Generator.prototype.determineAppname = function () { - let appname = this.fs.readJSON(this.destinationPath('bower.json'), {}).name; + if (!path.isAbsolute(filepath)) { + filepath = path.join(this.sourceRoot(), filepath); + } - if (!appname) { - appname = this.fs.readJSON(this.destinationPath('package.json'), {}).name; + return filepath; } - if (!appname) { - appname = path.basename(this.destinationRoot()); - } + /** + * Join a path to the destination root. + * @param {...String} path + * @return {String} joined path + */ + destinationPath() { + let filepath = path.join.apply(path, arguments); - return appname.replace(/[^\w\s]+?/g, ' '); -}; + if (!path.isAbsolute(filepath)) { + filepath = path.join(this.destinationRoot(), filepath); + } -/** - * Add a transform stream to the commit stream. - * - * Most usually, these transform stream will be Gulp plugins. - * - * @param {stream.Transform|stream.Transform[]} stream An array of Transform stream - * or a single one. - * @return {this} - */ -Generator.prototype.registerTransformStream = function (streams) { - assert(streams, 'expected to receive a transform stream as parameter'); - if (!_.isArray(streams)) { - streams = [streams]; + return filepath; } - this._transformStreams = this._transformStreams.concat(streams); - return this; -}; -/** - * Write memory fs file to disk and logging results - * @param {Function} done - callback once files are written - * @private - */ -Generator.prototype._writeFiles = function (done) { - const self = this; - - const conflictChecker = through.obj(function (file, enc, cb) { - const stream = this; + /** + * Determines the name of the application. + * + * First checks for name in bower.json. + * Then checks for name in package.json. + * Finally defaults to the name of the current directory. + * @return {String} The name of the application + */ + determineAppname() { + let appname = this.fs.readJSON(this.destinationPath('bower.json'), {}).name; + + if (!appname) { + appname = this.fs.readJSON(this.destinationPath('package.json'), {}).name; + } - // If the file has no state requiring action, move on - if (file.state === null) { - return cb(); + if (!appname) { + appname = path.basename(this.destinationRoot()); } - // Config file should not be processed by the conflicter. Just pass through - const filename = path.basename(file.path); + return appname.replace(/[^\w\s]+?/g, ' '); + } - if (filename === '.yo-rc.json' || filename === '.yo-rc-global.json') { - this.push(file); - return cb(); + /** + * Add a transform stream to the commit stream. + * + * Most usually, these transform stream will be Gulp plugins. + * + * @param {stream.Transform|stream.Transform[]} stream An array of Transform stream + * or a single one. + * @return {this} + */ + registerTransformStream(streams) { + assert(streams, 'expected to receive a transform stream as parameter'); + if (!Array.isArray(streams)) { + streams = [streams]; } + this._transformStreams = this._transformStreams.concat(streams); + return this; + } - self.conflicter.checkForCollision(file.path, file.contents, (err, status) => { - if (err) { - cb(err); - return; + /** + * Write memory fs file to disk and logging results + * @param {Function} done - callback once files are written + * @private + */ + _writeFiles(done) { + const self = this; + + const conflictChecker = through.obj(function (file, enc, cb) { + const stream = this; + + // If the file has no state requiring action, move on + if (file.state === null) { + return cb(); } - if (status === 'skip') { - delete file.state; - } else { - stream.push(file); + // Config file should not be processed by the conflicter. Just pass through + const filename = path.basename(file.path); + + if (filename === '.yo-rc.json' || filename === '.yo-rc-global.json') { + this.push(file); + return cb(); } - cb(); + self.conflicter.checkForCollision(file.path, file.contents, (err, status) => { + if (err) { + cb(err); + return; + } + + if (status === 'skip') { + delete file.state; + } else { + stream.push(file); + } + + cb(); + }); + self.conflicter.resolve(); + }); + + const transformStreams = this._transformStreams.concat([conflictChecker]); + this.fs.commit(transformStreams, () => { + done(); }); - self.conflicter.resolve(); - }); - - const transformStreams = this._transformStreams.concat([conflictChecker]); - this.fs.commit(transformStreams, () => { - done(); - }); -}; + } +} + +// Mixin the actions modules +_.extend(Generator.prototype, require('./actions/install')); +_.extend(Generator.prototype, require('./actions/help')); +_.extend(Generator.prototype, require('./actions/spawn-command')); +Generator.prototype.user = require('./actions/user'); module.exports = Generator; diff --git a/test/base.js b/test/base.js index f3cabd99..0eeeef1f 100644 --- a/test/base.js +++ b/test/base.js @@ -3,7 +3,6 @@ const fs = require('fs'); const os = require('os'); const LF = require('os').EOL; const path = require('path'); -const util = require('util'); const _ = require('lodash'); const sinon = require('sinon'); const mkdirp = require('mkdirp'); @@ -356,11 +355,8 @@ describe('Base', () => { }); it('will run non-enumerable methods', function (done) { - const Generator = function () { - Base.apply(this, arguments); - }; + class Generator extends Base {} - Generator.prototype = Object.create(Base.prototype); Object.defineProperty(Generator.prototype, 'nonenumerable', { value: sinon.spy(), configurable: true, @@ -1004,13 +1000,9 @@ describe('Base', () => { describe('Events', () => { before(function () { - const Generator = this.Generator = function () { - Base.apply(this, arguments); - }; - + class Generator extends Base {} + this.Generator = Generator; Generator.namespace = 'angular:app'; - util.inherits(Generator, Base); - Generator.prototype.createSomething = function () {}; Generator.prototype.createSomethingElse = function () {}; }); @@ -1047,24 +1039,24 @@ describe('Base', () => { }); it('only call the end event once (bug #402)', done => { - function GeneratorOnce() { - Base.apply(this, arguments); - this.sourceRoot(path.join(__dirname, 'fixtures')); - this.destinationRoot(path.join(os.tmpdir(), 'yeoman-base-once')); - } - - util.inherits(GeneratorOnce, Base); + class GeneratorOnce extends Base { + constructor(args, opts) { + super(args, opts); + this.sourceRoot(path.join(__dirname, 'fixtures')); + this.destinationRoot(path.join(os.tmpdir(), 'yeoman-base-once')); + } - GeneratorOnce.prototype.createDuplicate = function () { - this.fs.copy( - this.templatePath('foo-copy.js'), - this.destinationPath('foo-copy.js') - ); - this.fs.copy( - this.templatePath('foo-copy.js'), - this.destinationPath('foo-copy.js') - ); - }; + createDuplicate() { + this.fs.copy( + this.templatePath('foo-copy.js'), + this.destinationPath('foo-copy.js') + ); + this.fs.copy( + this.templatePath('foo-copy.js'), + this.destinationPath('foo-copy.js') + ); + } + } let isFirstEndEvent = true; const generatorOnce = new GeneratorOnce([], { diff --git a/test/install.js b/test/install.js index 87d71028..8d882c43 100644 --- a/test/install.js +++ b/test/install.js @@ -40,7 +40,7 @@ describe('Base (actions/install mixin)', () => { }; // Args: installer, paths, options, cb - var promise = this.dummy + const promise = this.dummy .runInstall('nestedScript', ['path1', 'path2'], options, spawnEnv) .then(() => { sinon.assert.calledWithExactly( @@ -80,7 +80,7 @@ describe('Base (actions/install mixin)', () => { }); it('resolve Promise if skipInstall', function () { - var promise = this.dummy.runInstall('npm', ['install']); + const promise = this.dummy.runInstall('npm', ['install']); this.dummy.run(); return promise; }); @@ -99,7 +99,7 @@ describe('Base (actions/install mixin)', () => { }); it('spawn a bower process with formatted options', function () { - var promise = this.dummy.bowerInstall('jquery', {saveDev: true}).then(() => { + const promise = this.dummy.bowerInstall('jquery', {saveDev: true}).then(() => { sinon.assert.calledOnce(this.spawnCommandStub); sinon.assert.calledWithExactly( this.spawnCommandStub, @@ -133,7 +133,7 @@ describe('Base (actions/install mixin)', () => { }); it('resolve Promise on success', function () { - var promise = this.dummy.npmInstall('yo').then(() => { + const promise = this.dummy.npmInstall('yo').then(() => { sinon.assert.calledOnce(this.spawnCommandStub); }); this.dummy.run(); @@ -162,7 +162,7 @@ describe('Base (actions/install mixin)', () => { }); it('resolve promise on success', function () { - var promise = this.dummy.yarnInstall('yo').then(() => { + const promise = this.dummy.yarnInstall('yo').then(() => { sinon.assert.calledOnce(this.spawnCommandStub); sinon.assert.calledWithExactly(this.spawnCommandStub, 'yarn', ['add', 'yo'], {}); }); @@ -173,7 +173,7 @@ describe('Base (actions/install mixin)', () => { describe('#installDependencies()', () => { it('spawn npm and bower', function () { - var promise = this.dummy.installDependencies().then(() => { + const promise = this.dummy.installDependencies().then(() => { sinon.assert.calledTwice(this.spawnCommandStub); sinon.assert.calledWithExactly(this.spawnCommandStub, 'bower', ['install'], {}); sinon.assert.calledWithExactly(this.spawnCommandStub, 'npm', ['install', '--cache-min', 86400], {}); @@ -183,7 +183,7 @@ describe('Base (actions/install mixin)', () => { }); it('spawn yarn', function () { - var promise = this.dummy.installDependencies({yarn: true, npm: false}).then(() => { + const promise = this.dummy.installDependencies({yarn: true, npm: false}).then(() => { sinon.assert.calledTwice(this.spawnCommandStub); sinon.assert.calledWithExactly(this.spawnCommandStub, 'bower', ['install'], {}); sinon.assert.calledWithExactly(this.spawnCommandStub, 'yarn', ['install'], {});