UNPKG

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