UNPKG

yeoman-generator

Version:

Rails-inspired generator system that provides scaffolding for your apps

806 lines (679 loc) 23.7 kB
'use strict'; var fs = require('fs'); var util = require('util'); var path = require('path'); var events = require('events'); var assert = require('assert'); var _ = require('lodash'); var _s = require('underscore.string'); var async = require('async'); var findup = require('findup-sync'); var chalk = require('chalk'); var mkdirp = require('mkdirp'); var nopt = require('nopt'); var through = require('through2'); var userHome = require('user-home'); var GruntfileEditor = require('gruntfile-editor'); var FileEditor = require('mem-fs-editor'); var wiring = require('html-wiring'); var Conflicter = require('./util/conflicter'); var Storage = require('./util/storage'); var deprecate = require('./util/deprecate'); var promptSuggestion = require('./util/prompt-suggestion'); var debug = require('debug')('yeoman:generator'); /** * The `Base` class provides the common API shared by all generators. * It define options, arguments, hooks, file, prompt, log, API, etc. * * It mixes into its prototype all the methods found in the `actions/` mixins. * * Every generator should extend this base class. * * @constructor * @mixes actions/actions * @mixes actions/fetch * @mixes actions/file * @mixes actions/install * @mixes actions/invoke * @mixes actions/spawn_command * @mixes actions/string * @mixes actions/remote * @mixes actions/user * @mixes actions/help * @mixes nodejs/EventEmitter * * @param {String|Array} args * @param {Object} options * * @property {Object} env - the current Environment being run * @property {Object} args - Provide arguments at initialization * @property {String} resolved - the path to the current generator * @property {String} description - Used in `--help` output * @property {String} appname - The application name * @property {Storage} config - `.yo-rc` config file manager * @property {Object} fs - An instance of {@link https://github.com/SBoudrias/mem-fs-editor Mem-fs-editor} * @property {Function} log - Output content through Interface Adapter * * @example * var generator = require('yeoman-generator'); * var MyGenerator = generator.Base.extend({ * writing: function() { * this.fs.write('var foo = 1;', this.destinationPath('index.js')); * } * }); */ var Base = module.exports = function Base(args, options) { events.EventEmitter.call(this); if (!Array.isArray(args)) { options = args; args = []; } this.options = options || {}; this._args = args || []; this._options = {}; this._arguments = []; this._hooks = []; this._composedWith = []; this._transformStreams = []; this.option('help', { alias: 'h', desc: 'Print the generator\'s options and usage' }); this.option('skip-cache', { type: Boolean, desc: 'Do not remember prompt answers', defaults: 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.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 var rootPath = findup('.yo-rc.json'); rootPath = rootPath ? path.dirname(rootPath) : process.cwd(); if (rootPath !== process.cwd()) { this.log([ '', 'Just found a `.yo-rc.json` in a parent directory.', 'Setting the project root at: ' + rootPath ].join('\n')); process.chdir(rootPath); } var deprecatedFileUtils = deprecate.log.bind(null, [ '#src() and #dest() are deprecated. Please read the documentation to learn about', 'the new ways of handling files. http://yeoman.io/authoring/file-system.html' ].join('\n')); Object.defineProperty(this, 'src', { get: deprecatedFileUtils }); Object.defineProperty(this, 'dest', { get: deprecatedFileUtils }); Object.defineProperty(this, '_', { get: deprecate.log.bind(null, [ '#_ is deprecated. Require your own version of', chalk.cyan('Lodash'), 'or', chalk.cyan('underscore.string') ].join(' ')) }); this.fs = FileEditor.create(this.env.sharedFs); this.appname = this.determineAppname(); this.config = this._getStorage(); this._globalConfig = this._getGlobalStorage(); // ensure source/destination path, can be configured from subclasses this.sourceRoot(path.join(path.dirname(this.resolved), 'templates')); // Only instantiate the Gruntfile API when requested Object.defineProperty(this, 'gruntfile', { get: function () { if (!this.env.gruntfile) { var gruntfile = ''; var gruntPath = this.destinationPath('Gruntfile.js'); if (this.fs.exists(gruntPath)) { gruntfile = this.fs.read(gruntPath); } this.env.gruntfile = new GruntfileEditor(gruntfile); } // Schedule the creation/update of the Gruntfile this.env.runLoop.add('writing', function (done) { this.fs.write( this.destinationPath('Gruntfile.js'), this.env.gruntfile.toString() ); done(); }.bind(this), { once: 'gruntfile:write' }); return this.env.gruntfile; } }); }; util.inherits(Base, events.EventEmitter); // Mixin the actions modules _.extend(Base.prototype, require('./actions/actions')); _.extend(Base.prototype, require('./actions/fetch')); _.extend(Base.prototype, require('./actions/file')); _.extend(Base.prototype, require('./actions/install')); _.extend(Base.prototype, require('./actions/remote')); _.extend(Base.prototype, deprecate.object('this.<%= name %>() is deprecated. Use require("html-wiring").<%= name %>() instead.', wiring)); _.extend(Base.prototype, require('./actions/help')); Base.prototype.spawnCommand = require('./actions/spawn_command'); Base.prototype.user = require('./actions/user'); Base.prototype.invoke = deprecate( 'generator#invoke() is deprecated. Use generator#composeWith() - see http://yeoman.io/authoring/composability.html', require('./actions/invoke') ); // TODO: Remove before 1.0.0 // DEPRECATED: Use the module directly Base.prototype.welcome = deprecate( 'Generator#welcome() is deprecated. Instead, `require("yeoman-welcome")` directly.', function () { console.log(require('yeoman-welcome')); } ); /** * 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} * @param {Function} callback Receive a question object * @return {this} */ Base.prototype.prompt = function (questions, callback) { questions = promptSuggestion.prefillQuestions(this._globalConfig, questions); this.env.adapter.prompt(questions, function (answers) { if (!this.options['skip-cache']) { promptSuggestion.storeAnswers(this._globalConfig, questions, answers); } if (_.isFunction(callback)) { callback(answers); } }.bind(this)); return this; }; /** * 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: * * - `desc` Description for the option * - `type` Either Boolean, String or Number * - `defaults` Default value * - `hide` Boolean whether to hide from help * * @param {String} name * @param {Object} config */ Base.prototype.option = function option(name, config) { config = config || {}; _.defaults(config, { name: name, desc: 'Description for ' + name, type: Boolean, defaults: undefined, hide: false }); if (this._options[name] == null) { this._options[name] = config; } if (this.options[name] == null) { this.options[name] = config.defaults; } 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: * * - `desc` Description for the argument * - `required` Boolean whether it is required * - `optional` Boolean whether it is optional * - `type` String, Number, Array, or Object * - `defaults` Default value for this argument * * @param {String} name * @param {Object} config */ Base.prototype.argument = function argument(name, config) { config = config || {}; _.defaults(config, { name: name, required: config.defaults == null, type: String }); var position = this._arguments.length; this._arguments.push({ name: name, config: config }); Object.defineProperty(this, name, { configurable: true, enumerable: true, get: function () { // a bit of coercion and type handling, to be improved // just dealing with Array/String, default is assumed to be String var value = config.type === Array ? this.args.slice(position) : this.args[position]; return position >= this.args.length ? config.defaults : value; }, set: function (value) { this.args[position] = value; } }); this.checkRequiredArgs(); return this; }; Base.prototype.parseOptions = function () { var opts = {}; var shortOpts = {}; _.each(this._options, function (option) { opts[option.name] = option.type; if (option.alias) { shortOpts[option.alias] = '--' + option.name; } }); opts = nopt(opts, shortOpts, this._args, 0); _.extend(this.options, opts); this.args = this.arguments = opts.argv.remain; this.checkRequiredArgs(); }; Base.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; } // Bail early if it's not possible to have a missing required arg if (this.args.length > this._arguments.length) { return; } this._arguments.forEach(function (arg, position) { // If the help option was not provided, check whether the argument was // required, and whether a value was provided. if (arg.config.required && position >= this.args.length) { return this.emit('error', new Error('Did not provide required argument ' + chalk.bold(arg.name) + '!')); } }, this); }; /** * 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] */ Base.prototype.run = function run(cb) { cb = cb || function () {}; var self = this; this._running = true; this.emit('run'); var methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this)); var 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', function () { this.emit('end'); cb(); }.bind(this)); // Ensure a prototype method is a candidate run by default function methodIsValid(name) { return name.charAt(0) !== '_' && name !== 'constructor'; } function addMethod(method, methodName, queueName) { queueName = queueName || 'default'; debug('Queueing ' + methodName + ' in ' + queueName); self.env.runLoop.add(queueName, function (completed) { debug('Running ' + methodName); var done = function (err) { if (err) { self.emit('error', err); } completed(); }; var running = false; self.async = function () { running = true; return done; }; self.emit('method:' + methodName); try { method.apply(self, self.args); if (!running) { done(); return; } } catch (err) { debug('An error occured while running ' + methodName, err); self.emit('error', err); } }); } function addInQueue(name) { var item = Object.getPrototypeOf(self)[name]; var queueName = self.env.runLoop.queueNames.indexOf(name) >= 0 ? name : null; // Name points to a function; run it! if (_.isFunction(item)) { return addMethod(item, name, queueName); } // Not a queue hash; stop if (!queueName) { return; } // Run each queue items _.each(item, function (method, methodName) { if (!_.isFunction(method) || !methodIsValid(methodName)) { return; } addMethod(method, methodName, queueName); }); } validMethods.forEach(addInQueue); var 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', function (done) { this.conflicter.resolve(function (err) { if (err) { this.emit('error', err); } done(); }.bind(this)); }.bind(this)); this.on('end', function () { debug('Running the hooked generators'); this.runHooks(); }); _.invoke(this._composedWith, 'run'); return this; }; /** * Goes through all registered hooks, invoking them in series. * * @param {Function} [cb] */ Base.prototype.runHooks = function runHooks(cb) { cb = _.isFunction(cb) ? cb : function () {}; var setupInvoke = function (hook) { var resolved = this.defaultFor(hook.name); var options = _.clone(hook.options || this.options); options.args = _.clone(hook.args || this.args); return function (next) { this.invoke(resolved + (hook.as ? ':' + hook.as : ''), options, next); }.bind(this); }.bind(this); async.series(this._hooks.map(setupInvoke), cb); return this; }; /** * Registers a hook to invoke when this generator runs. * * A generator with a namespace based on the value supplied by the user * to the given option named `name`. An option is created when this method is * invoked and you can set a hash to customize it. * * Must be called prior to running the generator (shouldn't be called within * a generator "step" - top-level methods). * * ### Options: * * - `as` The context value to use when runing the hooked generator * - `args` The array of positional arguments to init and run the generator with * - `options` An object containing a nested `options` property with the hash of options * to use to init and run the generator with * * ### Examples: * * // $ yo webapp --test-framework jasmine * this.hookFor('test-framework'); * // => registers the `jasmine` hook * * // $ yo mygen:subgen --myargument * this.hookFor('mygen', { * as: 'subgen', * options: { * options: { * 'myargument': true * } * } * } * * @deprecated use `#composeWith()` instead. * @param {String} name * @param {Object} config */ Base.prototype.hookFor = deprecate( 'generator#hookFor() is deprecated. Use generator#composeWith() - see http://yeoman.io/authoring/composability.html', function (name, config) { config = config || {}; // enforce use of hookFor during instantiation assert(!this._running, 'hookFor can only be used inside the constructor function'); // add the corresponding option to this class, so that we output these hooks // in help this.option(name, { desc: _s.humanize(name) + ' to be invoked', defaults: this.options[name] || '' }); this._hooks.push(_.defaults(config, { name: name })); return this; } ); /** * Return the default value for the option name. * * @deprecated * @param {String} name */ Base.prototype.defaultFor = function defaultFor(name) { return this.options[name] || name; }; /** * 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 <caption>Using a peerDependency generator</caption> * this.composeWith('bootstrap', { options: { sass: true } }); * * @example <caption>Using a direct dependency generator</caption> * this.composeWith('bootstrap', { options: { sass: true } }, { * local: require.resolve('generator-bootstrap/app/main.js'); * }); */ Base.prototype.composeWith = function composeWith(namespace, options, settings) { settings = settings || {}; var generator; if (settings.local) { var Generator = require(settings.local); Generator.resolved = require.resolve(settings.local); Generator.namespace = namespace; generator = this.env.instantiate(Generator, options); } else { generator = this.env.create(namespace, options); } if (this._running) { generator.run(); } else { this._composedWith.push(generator); } return this; }; /** * Determine the root generator name (the one who's extending Base). * @return {String} The name of the root generator */ Base.prototype.rootGeneratorName = function () { var filepath = findup('package.json', { cwd: this.resolved }); return filepath ? this.fs.readJSON(filepath).name : '*'; }; /** * Determine the root generator version (the one who's extending Base). * @return {String} The version of the root generator */ Base.prototype.rootGeneratorVersion = function () { var filepath = findup('package.json', { cwd: this.resolved }); return filepath ? this.fs.readJSON(filepath).version : '0.0.0'; }; /** * Return a storage instance. * @return {Storage} Generator storage * @private */ Base.prototype._getStorage = function () { var storePath = path.join(this.destinationRoot(), '.yo-rc.json'); return new Storage(this.rootGeneratorName(), this.fs, storePath); }; /** * Setup a globalConfig storage instance. * @return {Storage} Global config storage * @private */ Base.prototype._getGlobalStorage = function () { var storePath = path.join(userHome, '.yo-rc-global.json'); var storeName = util.format('%s:%s', this.rootGeneratorName(), this.rootGeneratorVersion()); return new Storage(storeName, this.fs, storePath); }; /** * 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 */ Base.prototype.destinationRoot = function (rootPath) { if (_.isString(rootPath)) { this._destinationRoot = path.resolve(rootPath); if (!fs.existsSync(rootPath)) { mkdirp.sync(rootPath); } process.chdir(rootPath); // Reset the storage this.config = this._getStorage(); } return this._destinationRoot || process.cwd(); }; /** * 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 */ Base.prototype.sourceRoot = function (rootPath) { if (_.isString(rootPath)) { this._sourceRoot = path.resolve(rootPath); } return this._sourceRoot; }; /** * Join a path to the source root. * @param {...String} path * @return {String} joined path */ Base.prototype.templatePath = function () { var args = [this.sourceRoot()].concat(_.toArray(arguments)); return path.join.apply(path, args); }; /** * Join a path to the destination root. * @param {...String} path * @return {String} joined path */ Base.prototype.destinationPath = function () { var args = [this.destinationRoot()].concat(_.toArray(arguments)); return path.join.apply(path, args); }; /** * 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 */ Base.prototype.determineAppname = function () { var appname = this.fs.readJSON(this.destinationPath('bower.json'), {}).name; if (!appname) { appname = this.fs.readJSON(this.destinationPath('package.json'), {}).name; } if (!appname) { appname = path.basename(this.destinationRoot()); } return appname.replace(/[^\w\s]+?/g, ' '); }; Base.prototype.registerTransformStream = function (stream) { assert(stream, 'expected to receive a transform stream as parameter'); this._transformStreams.push(stream); return this; }; /** * Write memory fs file to disk and logging results * @param {Function} done - callback once files are written */ Base.prototype._writeFiles = function (done) { var self = this; var conflictChecker = through.obj(function (file, enc, cb) { var stream = this; // Config file should not be processed by the conflicter. Just pass through var filename = path.basename(file.path); if (filename === '.yo-rc.json' || filename === '.yo-rc-global.json') { this.push(file); return cb(); } self.conflicter.checkForCollision(file.path, file.contents, function (err, status) { if (err) { cb(err); return; } if (status !== 'skip') { stream.push(file); } cb(); }); self.conflicter.resolve(); }); var transformStreams = this._transformStreams.concat([conflictChecker]); this.fs.commit(transformStreams, function () { done(); }); }; /** * Extend this Class to create a new one inherithing this one. * Also add a helper \_\_super__ object pointing to the parent prototypes methods * @param {Object} protoProps Prototype properties (available on the instances) * @param {Object} staticProps Static properties (available on the contructor) * @return {Object} New sub class */ Base.extend = require('class-extend').extend;