UNPKG

22.7 kBJavaScriptView Raw
1'use strict';
2const fs = require('fs');
3const path = require('path');
4const os = require('os');
5const EventEmitter = require('events');
6const assert = require('assert');
7const _ = require('lodash');
8const findUp = require('find-up');
9const readPkgUp = require('read-pkg-up');
10const chalk = require('chalk');
11const makeDir = require('make-dir');
12const minimist = require('minimist');
13const runAsync = require('run-async');
14const through = require('through2');
15const FileEditor = require('mem-fs-editor');
16const debug = require('debug')('yeoman:generator');
17const Conflicter = require('./util/conflicter');
18const Storage = require('./util/storage');
19const promptSuggestion = require('./util/prompt-suggestion');
20
21const EMPTY = '@@_YEOMAN_EMPTY_MARKER_@@';
22
23/**
24 * The `Generator` class provides the common API shared by all generators.
25 * It define options, arguments, file, prompt, log, API, etc.
26 *
27 * It mixes into its prototype all the methods found in the `actions/` mixins.
28 *
29 * Every generator should extend this base class.
30 *
31 * @constructor
32 * @mixes actions/help
33 * @mixes actions/install
34 * @mixes actions/spawn-command
35 * @mixes actions/user
36 * @mixes nodejs/EventEmitter
37 *
38 * @param {String|Array} args
39 * @param {Object} options
40 *
41 * @property {Object} env - the current Environment being run
42 * @property {Object} args - Provide arguments at initialization
43 * @property {String} resolved - the path to the current generator
44 * @property {String} description - Used in `--help` output
45 * @property {String} appname - The application name
46 * @property {Storage} config - `.yo-rc` config file manager
47 * @property {Object} fs - An instance of {@link https://github.com/SBoudrias/mem-fs-editor Mem-fs-editor}
48 * @property {Function} log - Output content through Interface Adapter
49 *
50 * @example
51 * const Generator = require('yeoman-generator');
52 * module.exports = class extends Generator {
53 * writing() {
54 * this.fs.write(this.destinationPath('index.js'), 'const foo = 1;');
55 * }
56 * };
57 */
58class Generator extends EventEmitter {
59 constructor(args, options) {
60 super();
61
62 if (!Array.isArray(args)) {
63 options = args;
64 args = [];
65 }
66
67 this.options = options || {};
68 this._initOptions = _.clone(options);
69 this._args = args || [];
70 this._options = {};
71 this._arguments = [];
72 this._composedWith = [];
73 this._transformStreams = [];
74
75 this.option('help', {
76 type: Boolean,
77 alias: 'h',
78 description: "Print the generator's options and usage"
79 });
80
81 this.option('skip-cache', {
82 type: Boolean,
83 description: 'Do not remember prompt answers',
84 default: false
85 });
86
87 this.option('skip-install', {
88 type: Boolean,
89 description: 'Do not automatically install dependencies',
90 default: false
91 });
92
93 // Checks required parameters
94 assert(
95 this.options.env,
96 'You must provide the environment object. Use env#create() to create a new generator.'
97 );
98 assert(
99 this.options.resolved,
100 'You must provide the resolved path value. Use env#create() to create a new generator.'
101 );
102 this.env = this.options.env;
103 this.resolved = this.options.resolved;
104
105 // Ensure the environment support features this yeoman-generator version require.
106 require('yeoman-environment').enforceUpdate(this.env);
107
108 this.description = this.description || '';
109
110 this.async = () => () => {};
111
112 this.fs = FileEditor.create(this.env.sharedFs);
113 this.conflicter = new Conflicter(this.env.adapter, this.options.force);
114
115 // Mirror the adapter log method on the generator.
116 //
117 // example:
118 // this.log('foo');
119 // this.log.error('bar');
120 this.log = this.env.adapter.log;
121
122 // Determine the app root
123 this.contextRoot = this.env.cwd;
124
125 let rootPath = findUp.sync('.yo-rc.json', {
126 cwd: this.env.cwd
127 });
128 rootPath = rootPath ? path.dirname(rootPath) : this.env.cwd;
129
130 if (rootPath !== this.env.cwd) {
131 this.log(
132 [
133 '',
134 'Just found a `.yo-rc.json` in a parent directory.',
135 'Setting the project root at: ' + rootPath
136 ].join('\n')
137 );
138 this.destinationRoot(rootPath);
139 }
140
141 this.appname = this.determineAppname();
142 this.config = this._getStorage();
143 this._globalConfig = this._getGlobalStorage();
144
145 // Ensure source/destination path, can be configured from subclasses
146 this.sourceRoot(path.join(path.dirname(this.resolved), 'templates'));
147 }
148
149 /*
150 * Prompt user to answer questions. The signature of this method is the same as {@link https://github.com/SBoudrias/Inquirer.js Inquirer.js}
151 *
152 * On top of the Inquirer.js API, you can provide a `{cache: true}` property for
153 * every question descriptor. When set to true, Yeoman will store/fetch the
154 * user's answers as defaults.
155 *
156 * @param {array} questions Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation}
157 * @return {Promise}
158 */
159 prompt(questions) {
160 questions = promptSuggestion.prefillQuestions(this._globalConfig, questions);
161 questions = promptSuggestion.prefillQuestions(this.config, questions);
162
163 return this.env.adapter.prompt(questions).then(answers => {
164 if (!this.options['skip-cache']) {
165 promptSuggestion.storeAnswers(this._globalConfig, questions, answers, false);
166 promptSuggestion.storeAnswers(this.config, questions, answers, true);
167 }
168
169 return answers;
170 });
171 }
172
173 /**
174 * Adds an option to the set of generator expected options, only used to
175 * generate generator usage. By default, generators get all the cli options
176 * parsed by nopt as a `this.options` hash object.
177 *
178 * ### Options:
179 *
180 * - `description` Description for the option
181 * - `type` Either Boolean, String or Number
182 * - `alias` Option name alias (example `-h` and --help`)
183 * - `default` Default value
184 * - `hide` Boolean whether to hide from help
185 *
186 * @param {String} name
187 * @param {Object} config
188 */
189 option(name, config) {
190 config = config || {};
191
192 // Alias default to defaults for backward compatibility.
193 if ('defaults' in config) {
194 config.default = config.defaults;
195 }
196 config.description = config.description || config.desc;
197
198 _.defaults(config, {
199 name,
200 description: 'Description for ' + name,
201 type: Boolean,
202 hide: false
203 });
204
205 // Check whether boolean option is invalid (starts with no-)
206 const boolOptionRegex = /^no-/;
207 if (config.type === Boolean && name.match(boolOptionRegex)) {
208 const simpleName = name.replace(boolOptionRegex, '');
209 return this.emit(
210 'error',
211 new Error(
212 [
213 `Option name ${chalk.yellow(name)} cannot start with ${chalk.red('no-')}\n`,
214 `Option name prefixed by ${chalk.yellow('--no')} are parsed as implicit`,
215 ` boolean. To use ${chalk.yellow('--' + name)} as an option, use\n`,
216 chalk.cyan(` this.option('${simpleName}', {type: Boolean})`)
217 ].join('')
218 )
219 );
220 }
221
222 if (this._options[name] === null || this._options[name] === undefined) {
223 this._options[name] = config;
224 }
225
226 this.parseOptions();
227 return this;
228 }
229
230 /**
231 * Adds an argument to the class and creates an attribute getter for it.
232 *
233 * Arguments are different from options in several aspects. The first one
234 * is how they are parsed from the command line, arguments are retrieved
235 * based on their position.
236 *
237 * Besides, arguments are used inside your code as a property (`this.argument`),
238 * while options are all kept in a hash (`this.options`).
239 *
240 * ### Options:
241 *
242 * - `description` Description for the argument
243 * - `required` Boolean whether it is required
244 * - `optional` Boolean whether it is optional
245 * - `type` String, Number, Array, or Object
246 * - `default` Default value for this argument
247 *
248 * @param {String} name
249 * @param {Object} config
250 */
251 argument(name, config) {
252 config = config || {};
253
254 // Alias default to defaults for backward compatibility.
255 if ('defaults' in config) {
256 config.default = config.defaults;
257 }
258 config.description = config.description || config.desc;
259
260 _.defaults(config, {
261 name,
262 required: config.default === null || config.default === undefined,
263 type: String
264 });
265
266 this._arguments.push(config);
267
268 this.parseOptions();
269 return this;
270 }
271
272 parseOptions() {
273 const minimistDef = {
274 string: [],
275 boolean: [],
276 alias: {},
277 default: {}
278 };
279
280 _.each(this._options, option => {
281 if (option.type === Boolean) {
282 minimistDef.boolean.push(option.name);
283 if (!('default' in option) && !option.required) {
284 minimistDef.default[option.name] = EMPTY;
285 }
286 } else {
287 minimistDef.string.push(option.name);
288 }
289
290 if (option.alias) {
291 minimistDef.alias[option.alias] = option.name;
292 }
293
294 // Only apply default values if we don't already have a value injected from
295 // the runner
296 if (option.name in this._initOptions) {
297 minimistDef.default[option.name] = this._initOptions[option.name];
298 } else if (option.alias && option.alias in this._initOptions) {
299 minimistDef.default[option.name] = this._initOptions[option.alias];
300 } else if ('default' in option) {
301 minimistDef.default[option.name] = option.default;
302 }
303 });
304
305 const parsedOpts = minimist(this._args, minimistDef);
306
307 // Parse options to the desired type
308 _.each(parsedOpts, (option, name) => {
309 // Manually set value as undefined if it should be.
310 if (option === EMPTY) {
311 parsedOpts[name] = undefined;
312 return;
313 }
314 if (this._options[name] && option !== undefined) {
315 parsedOpts[name] = this._options[name].type(option);
316 }
317 });
318
319 // Parse positional arguments to valid options
320 this._arguments.forEach((config, index) => {
321 let value;
322 if (index >= parsedOpts._.length) {
323 if (config.name in this._initOptions) {
324 value = this._initOptions[config.name];
325 } else if ('default' in config) {
326 value = config.default;
327 } else {
328 return;
329 }
330 } else if (config.type === Array) {
331 value = parsedOpts._.slice(index, parsedOpts._.length);
332 } else {
333 value = config.type(parsedOpts._[index]);
334 }
335
336 parsedOpts[config.name] = value;
337 });
338
339 // Make the parsed options available to the instance
340 Object.assign(this.options, parsedOpts);
341 this.args = parsedOpts._;
342 this.arguments = parsedOpts._;
343
344 // Make sure required args are all present
345 this.checkRequiredArgs();
346 }
347
348 checkRequiredArgs() {
349 // If the help option was provided, we don't want to check for required
350 // arguments, since we're only going to print the help message anyway.
351 if (this.options.help) {
352 return;
353 }
354
355 // Bail early if it's not possible to have a missing required arg
356 if (this.args.length > this._arguments.length) {
357 return;
358 }
359
360 this._arguments.forEach((config, position) => {
361 // If the help option was not provided, check whether the argument was
362 // required, and whether a value was provided.
363 if (config.required && position >= this.args.length) {
364 return this.emit(
365 'error',
366 new Error(`Did not provide required argument ${chalk.bold(config.name)}!`)
367 );
368 }
369 });
370 }
371
372 /**
373 * Runs the generator, scheduling prototype methods on a run queue. Method names
374 * will determine the order each method is run. Methods without special names
375 * will run in the default queue.
376 *
377 * Any method named `constructor` and any methods prefixed by a `_` won't be scheduled.
378 *
379 * You can also supply the arguments for the method to be invoked. If none are
380 * provided, the same values used to initialize the invoker are used to
381 * initialize the invoked.
382 *
383 * @param {Function} [cb] Deprecated: prefer to use the promise interface
384 * @return {Promise} Resolved once the process finish
385 */
386 run(cb) {
387 const promise = new Promise((resolve, reject) => {
388 const self = this;
389 this._running = true;
390 this.emit('run');
391
392 const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
393 const validMethods = methods.filter(methodIsValid);
394 assert(
395 validMethods.length,
396 'This Generator is empty. Add at least one method for it to run.'
397 );
398
399 this.env.runLoop.once('end', () => {
400 this.emit('end');
401 resolve();
402 });
403
404 // Ensure a prototype method is a candidate run by default
405 function methodIsValid(name) {
406 return name.charAt(0) !== '_' && name !== 'constructor';
407 }
408
409 function addMethod(method, methodName, queueName) {
410 queueName = queueName || 'default';
411 debug(`Queueing ${methodName} in ${queueName}`);
412 self.env.runLoop.add(queueName, completed => {
413 debug(`Running ${methodName}`);
414 self.emit(`method:${methodName}`);
415
416 runAsync(function() {
417 self.async = () => this.async();
418 return method.apply(self, self.args);
419 })()
420 .then(completed)
421 .catch(err => {
422 debug(`An error occured while running ${methodName}`, err);
423
424 // Ensure we emit the error event outside the promise context so it won't be
425 // swallowed when there's no listeners.
426 setImmediate(() => {
427 self.emit('error', err);
428 reject(err);
429 });
430 });
431 });
432 }
433
434 function addInQueue(name) {
435 const item = Object.getPrototypeOf(self)[name];
436 const queueName = self.env.runLoop.queueNames.indexOf(name) === -1 ? null : name;
437
438 // Name points to a function; run it!
439 if (typeof item === 'function') {
440 return addMethod(item, name, queueName);
441 }
442
443 // Not a queue hash; stop
444 if (!queueName) {
445 return;
446 }
447
448 // Run each queue items
449 _.each(item, (method, methodName) => {
450 if (!_.isFunction(method) || !methodIsValid(methodName)) {
451 return;
452 }
453
454 addMethod(method, methodName, queueName);
455 });
456 }
457
458 validMethods.forEach(addInQueue);
459
460 const writeFiles = () => {
461 this.env.runLoop.add('conflicts', this._writeFiles.bind(this), {
462 once: 'write memory fs to disk'
463 });
464 };
465
466 this.env.sharedFs.on('change', writeFiles);
467 writeFiles();
468
469 // Add the default conflicts handling
470 this.env.runLoop.add('conflicts', done => {
471 this.conflicter.resolve(err => {
472 if (err) {
473 this.emit('error', err);
474 }
475
476 done();
477 });
478 });
479
480 _.invokeMap(this._composedWith, 'run');
481 });
482
483 promise.then(cb, cb);
484 return promise;
485 }
486
487 /**
488 * Compose this generator with another one.
489 * @param {String} namespace The generator namespace to compose with
490 * @param {Object} options The options passed to the Generator
491 * @param {Object} [settings] Settings hash on the composition relation
492 * @param {string} [settings.local] Path to a locally stored generator
493 * @param {String} [settings.link="weak"] If "strong", the composition will occured
494 * even when the composition is initialized by
495 * the end user
496 * @return {this}
497 *
498 * @example <caption>Using a peerDependency generator</caption>
499 * this.composeWith('bootstrap', { sass: true });
500 *
501 * @example <caption>Using a direct dependency generator</caption>
502 * this.composeWith(require.resolve('generator-bootstrap/app/main.js'), { sass: true });
503 */
504 composeWith(modulePath, options) {
505 let generator;
506 options = options || {};
507
508 // Pass down the default options so they're correctly mirrored down the chain.
509 options = _.extend(
510 {
511 skipInstall: this.options.skipInstall,
512 'skip-install': this.options.skipInstall,
513 skipCache: this.options.skipCache,
514 'skip-cache': this.options.skipCache
515 },
516 options
517 );
518
519 try {
520 const Generator = require(modulePath); // eslint-disable-line import/no-dynamic-require
521 Generator.resolved = require.resolve(modulePath);
522 Generator.namespace = this.env.namespace(modulePath);
523 generator = this.env.instantiate(Generator, {
524 options,
525 arguments: options.arguments
526 });
527 } catch (err) {
528 if (err.code === 'MODULE_NOT_FOUND') {
529 generator = this.env.create(modulePath, {
530 options,
531 arguments: options.arguments
532 });
533 } else {
534 throw err;
535 }
536 }
537
538 if (this._running) {
539 generator.run();
540 } else {
541 this._composedWith.push(generator);
542 }
543
544 return this;
545 }
546
547 /**
548 * Determine the root generator name (the one who's extending Generator).
549 * @return {String} The name of the root generator
550 */
551 rootGeneratorName() {
552 const pkg = readPkgUp.sync({ cwd: this.resolved }).pkg;
553 return pkg ? pkg.name : '*';
554 }
555
556 /**
557 * Determine the root generator version (the one who's extending Generator).
558 * @return {String} The version of the root generator
559 */
560 rootGeneratorVersion() {
561 const pkg = readPkgUp.sync({ cwd: this.resolved }).pkg;
562 return pkg ? pkg.version : '0.0.0';
563 }
564
565 /**
566 * Return a storage instance.
567 * @return {Storage} Generator storage
568 * @private
569 */
570 _getStorage() {
571 const storePath = path.join(this.destinationRoot(), '.yo-rc.json');
572 return new Storage(this.rootGeneratorName(), this.fs, storePath);
573 }
574
575 /**
576 * Setup a globalConfig storage instance.
577 * @return {Storage} Global config storage
578 * @private
579 */
580 _getGlobalStorage() {
581 const storePath = path.join(os.homedir(), '.yo-rc-global.json');
582 const storeName = `${this.rootGeneratorName()}:${this.rootGeneratorVersion()}`;
583 return new Storage(storeName, this.fs, storePath);
584 }
585
586 /**
587 * Change the generator destination root directory.
588 * This path is used to find storage, when using a file system helper method (like
589 * `this.write` and `this.copy`)
590 * @param {String} rootPath new destination root path
591 * @return {String} destination root path
592 */
593 destinationRoot(rootPath) {
594 if (typeof rootPath === 'string') {
595 this._destinationRoot = path.resolve(rootPath);
596
597 if (!fs.existsSync(rootPath)) {
598 makeDir.sync(rootPath);
599 }
600
601 process.chdir(rootPath);
602 this.env.cwd = rootPath;
603
604 // Reset the storage
605 this.config = this._getStorage();
606 }
607
608 return this._destinationRoot || this.env.cwd;
609 }
610
611 /**
612 * Change the generator source root directory.
613 * This path is used by multiples file system methods like (`this.read` and `this.copy`)
614 * @param {String} rootPath new source root path
615 * @return {String} source root path
616 */
617 sourceRoot(rootPath) {
618 if (typeof rootPath === 'string') {
619 this._sourceRoot = path.resolve(rootPath);
620 }
621
622 return this._sourceRoot;
623 }
624
625 /**
626 * Join a path to the source root.
627 * @param {...String} path
628 * @return {String} joined path
629 */
630 templatePath() {
631 let filepath = path.join.apply(path, arguments);
632
633 if (!path.isAbsolute(filepath)) {
634 filepath = path.join(this.sourceRoot(), filepath);
635 }
636
637 return filepath;
638 }
639
640 /**
641 * Join a path to the destination root.
642 * @param {...String} path
643 * @return {String} joined path
644 */
645 destinationPath() {
646 let filepath = path.join.apply(path, arguments);
647
648 if (!path.isAbsolute(filepath)) {
649 filepath = path.join(this.destinationRoot(), filepath);
650 }
651
652 return filepath;
653 }
654
655 /**
656 * Determines the name of the application.
657 *
658 * First checks for name in bower.json.
659 * Then checks for name in package.json.
660 * Finally defaults to the name of the current directory.
661 * @return {String} The name of the application
662 */
663 determineAppname() {
664 let appname = this.fs.readJSON(this.destinationPath('bower.json'), {}).name;
665
666 if (!appname) {
667 appname = this.fs.readJSON(this.destinationPath('package.json'), {}).name;
668 }
669
670 if (!appname) {
671 appname = path.basename(this.destinationRoot());
672 }
673
674 return appname.replace(/[^\w\s]+?/g, ' ');
675 }
676
677 /**
678 * Add a transform stream to the commit stream.
679 *
680 * Most usually, these transform stream will be Gulp plugins.
681 *
682 * @param {stream.Transform|stream.Transform[]} stream An array of Transform stream
683 * or a single one.
684 * @return {this}
685 */
686 registerTransformStream(streams) {
687 assert(streams, 'expected to receive a transform stream as parameter');
688 if (!Array.isArray(streams)) {
689 streams = [streams];
690 }
691 this._transformStreams = this._transformStreams.concat(streams);
692 return this;
693 }
694
695 /**
696 * Write memory fs file to disk and logging results
697 * @param {Function} done - callback once files are written
698 * @private
699 */
700 _writeFiles(done) {
701 const self = this;
702
703 const conflictChecker = through.obj(function(file, enc, cb) {
704 const stream = this;
705
706 // If the file has no state requiring action, move on
707 if (file.state === null) {
708 return cb();
709 }
710
711 // Config file should not be processed by the conflicter. Just pass through
712 const filename = path.basename(file.path);
713
714 if (filename === '.yo-rc.json' || filename === '.yo-rc-global.json') {
715 this.push(file);
716 return cb();
717 }
718
719 self.conflicter.checkForCollision(file.path, file.contents, (err, status) => {
720 if (err) {
721 cb(err);
722 return;
723 }
724
725 if (status === 'skip') {
726 delete file.state;
727 } else {
728 stream.push(file);
729 }
730
731 cb();
732 });
733 self.conflicter.resolve();
734 });
735
736 const transformStreams = this._transformStreams.concat([conflictChecker]);
737 this.fs.commit(transformStreams, () => {
738 done();
739 });
740 }
741}
742
743// Mixin the actions modules
744_.extend(Generator.prototype, require('./actions/install'));
745_.extend(Generator.prototype, require('./actions/help'));
746_.extend(Generator.prototype, require('./actions/spawn-command'));
747Generator.prototype.user = require('./actions/user');
748
749module.exports = Generator;