UNPKG

yeoman-generator

Version:

Rails-inspired generator system that provides scaffolding for your apps

680 lines (679 loc) 27.4 kB
import fs, { readFileSync } from 'node:fs'; import path, { dirname, resolve as pathResolve, join as pathJoin } from 'node:path'; import os from 'node:os'; import { EventEmitter } from 'node:events'; import { fileURLToPath } from 'node:url'; import * as _ from 'lodash-es'; import semver from 'semver'; import { readPackageUpSync } from 'read-package-up'; import chalk from 'chalk'; import minimist from 'minimist'; import createDebug from 'debug'; import { create as createMemFsEditor } from 'mem-fs-editor'; import { requireNamespace, toNamespace } from '@yeoman/namespace'; import Storage from './util/storage.js'; import { prefillQuestions, storeAnswers } from './util/prompt-suggestion.js'; import { DESTINATION_ROOT_CHANGE_EVENT, requiredEnvironmentVersion } from './constants.js'; import { FsMixin } from './actions/fs.js'; import { HelpMixin } from './actions/help.js'; import { PackageJsonMixin } from './actions/package-json.js'; import { SpawnCommandMixin } from './actions/spawn-command.js'; import { GitMixin } from './actions/user.js'; import { TasksMixin } from './actions/lifecycle.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); // eslint-disable-next-line @typescript-eslint/naming-convention const EMPTY = '@@_YEOMAN_EMPTY_MARKER_@@'; // eslint-disable-next-line @typescript-eslint/naming-convention const ENV_VER_WITH_VER_API = '2.9.0'; const packageJson = JSON.parse(readFileSync(pathJoin(_dirname, '../package.json'), 'utf8')); // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class BaseGenerator // eslint-disable-next-line unicorn/prefer-event-target extends EventEmitter { options; _initOptions; _args; _options; _arguments; _prompts; _namespace; _namespaceId; _customPriorities; resolved; description; contextRoot; _debug; env; fs; log; _ = _; appname; args; /** @deprecated */ arguments; _destinationRoot; _sourceRoot; generatorConfig; instanceConfig; _config; _packageJson; _globalConfig; // If for some reason environment adds more queues, we should use or own for stability. static get queues() { return [ 'initializing', 'prompting', 'configuring', 'default', 'writing', 'transform', 'conflicts', 'install', 'end', ]; } _running = false; features; yoGeneratorVersion = packageJson.version; // eslint-disable-next-line complexity constructor(args, options, features) { super(); const actualArgs = Array.isArray(args) ? args : []; const actualOptions = Array.isArray(args) ? options : args; const actualFeatures = Array.isArray(args) ? features : options; const { env, ...generatorOptions } = actualOptions; // Load parameters this._args = actualArgs; this.options = generatorOptions; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions this.features = actualFeatures ?? {}; // Initialize properties this._options = {}; this._arguments = []; this._prompts = []; // Parse parameters this._initOptions = { ...actualOptions }; this._namespace = actualOptions.namespace; this._namespaceId = toNamespace(actualOptions.namespace); this._customPriorities = this.features?.customPriorities; this.features.skipParseOptions = this.features.skipParseOptions ?? this.options.skipParseOptions; this.features.customPriorities = this.features.customPriorities ?? this.options.customPriorities; 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, }); this.option('force-install', { type: Boolean, description: 'Fail on install dependencies error', default: false, }); this.option('ask-answered', { type: Boolean, description: 'Show prompts for already configured options', default: false, }); this.env = env; this.resolved = actualOptions.resolved; this.description = actualOptions.description ?? ''; if (this.env) { // Determine the app root this.contextRoot = this.env.cwd; this.destinationRoot(this.options.destinationRoot ?? this.env.cwd); // Clear destionationRoot, _destinationRoot will take priority when composing, but not override passed options. delete this.options.destinationRoot; this.fs = createMemFsEditor(this.env.sharedFs); } // Add convenience debug object this._debug = createDebug(this._namespace); // Ensure source/destination path, can be configured from subclasses // Used by help() this.sourceRoot(path.join(path.dirname(this.resolved), 'templates')); if (actualOptions.help) { return; } if (this.features.unique && !this.features.uniqueBy) { let uniqueBy; if (this.features.unique === true || this.features.unique === 'namespace') { uniqueBy = this._namespace; } else if (this.features.unique === 'argument' && this._args.length === 1) { const namespaceId = requireNamespace(this._namespace).with({ instanceId: this._args[0] }); uniqueBy = namespaceId.id; } else { throw new Error(`Error generating a uniqueBy value. Uniqueness '${this.features.unique}' not supported by '${this._namespace}'`); } this.features.uniqueBy = uniqueBy; } if (!this.env) { throw new Error('This generator requires an environment.'); } // Ensure the environment support features this yeoman-generator version require. if (!this.env.adapter || !this.env.sharedFs) { throw new Error("Current environment doesn't provides some necessary feature this generator needs."); } // Mirror the adapter log method on the generator. // // example: // this.log('foo'); // this.log.error('bar'); this.log = this.env.adapter.log; this.appname = this.determineAppname(); // Create config for the generator and instance if (this._namespaceId?.generator) { this.generatorConfig = this.config.createStorage(`:${this._namespaceId.generator}`); if (this._namespaceId.instanceId) { this.instanceConfig = this.generatorConfig.createStorage(`#${this._namespaceId.instanceId}`); } } this._globalConfig = this._getGlobalStorage(); this.checkEnvironmentVersion(requiredEnvironmentVersion, this.options.skipCheckEnv ?? false); } /** * Configure Generator behaviours. * * @param features * @param features.unique - Generates a uniqueBy id for the environment * Accepts 'namespace' or 'true' for one instance by namespace * Accepts 'argument' for one instance by namespace and 1 argument * */ setFeatures(features) { Object.assign(this.features, features); } /** * Specifications for Environment features. */ getFeatures() { return this.features; } checkEnvironmentVersion(packageDependency, version, warning = false) { let versionToCheck; if (typeof version === 'boolean' || version === undefined) { warning = version ?? false; versionToCheck = packageDependency ?? ENV_VER_WITH_VER_API; packageDependency = 'yeoman-environment'; } else { versionToCheck = version; } const returnError = (currentVersion) => { return new Error(`This generator (${this._namespace}) requires ${packageDependency} at least ${versionToCheck}, current version is ${currentVersion}, try reinstalling latest version of 'yo' or use '--ignore-version-check' option`); }; if (!this.env.getVersion) { if (!this.options.ignoreVersionCheck && !warning) { throw returnError(`less than ${ENV_VER_WITH_VER_API}`); } console.warn(`It's not possible to check version with running Environment less than ${ENV_VER_WITH_VER_API}`); console.warn('Some features may be missing'); if (semver.lte(versionToCheck, '2.8.1')) { return undefined; } return false; } const runningVersion = this.env.getVersion(packageDependency); if (runningVersion !== undefined && semver.lte(versionToCheck, runningVersion)) { return true; } // Version cannot be checked if (runningVersion === undefined) { return true; } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (this.options.ignoreVersionCheck || warning) { console.warn(`Current ${packageDependency} is not compatible with current generator, min required: ${versionToCheck} current version: ${runningVersion}. Some features may be missing, try updating reinstalling 'yo'.`); return false; } throw returnError(runningVersion); } /** * Convenience debug method * * @param parameters to be passed to debug */ debug(formater, ...args) { this._debug(formater, ...args); } /** * Register stored config prompts and optional option alternative. * * @param questions - Inquirer question or questions. * @param questions.exportOption - Additional data to export this question as an option. * @param question.storage - Storage to store the answers. */ registerConfigPrompts(questions) { questions = Array.isArray(questions) ? questions : [questions]; const getOptionTypeFromInquirerType = (type) => { if (type === 'number') { return Number; } if (type === 'confirm') { return Boolean; } if (type === 'checkbox') { return Array; } return String; }; for (const q of questions) { const question = { ...q }; if (q.exportOption) { const option = typeof q.exportOption === 'boolean' ? {} : q.exportOption; this.option({ name: q.name, type: getOptionTypeFromInquirerType(q.type), description: q.message, ...option, storage: q.storage ?? this.config, }); } this._prompts.push(question); } } /** * 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 `{store: true}` property for * every question descriptor. When set to true, Yeoman will store/fetch the * user's answers as defaults. * * @param questions Array of question descriptor objects. See {@link https://github.com/SBoudrias/Inquirer.js/blob/master/README.md Documentation} * @param questions.storage Storage object or name (generator property) to be used by the question to store/fetch the response. * @param storage Storage object or name (generator property) to be used by default to store/fetch responses. * @return prompt promise */ async prompt(questions, storage) { const storageForQuestion = {}; const arrayQuestions = Array.isArray(questions) ? questions : [questions]; const getAnswerFromStorage = (question) => { const questionRef = question.storage ?? storage; const questionStorage = typeof questionRef === 'string' ? this[questionRef] : questionRef; if (questionStorage) { const { name } = question; storageForQuestion[name] = questionStorage; const value = questionStorage.getPath(name); if (value !== undefined) { question.default = (answers) => answers[name]; return [name, value]; } } return undefined; }; // Shows the prompt even if the answer already exists. for (const question of arrayQuestions) { if (question.askAnswered === undefined) { question.askAnswered = this.options.askAnswered === true; } } questions = prefillQuestions(this._globalConfig, arrayQuestions); questions = prefillQuestions(this.config, arrayQuestions); const initialAnswers = Object.fromEntries(questions.map(question => getAnswerFromStorage(question)).filter(Boolean)); const answers = await this.env.adapter.prompt(questions, initialAnswers); for (const [name, questionStorage] of Object.entries(storageForQuestion)) { const answer = answers[name] === undefined ? null : answers[name]; questionStorage.setPath(name, answer); } if (!this.options.skipCache) { storeAnswers(this._globalConfig, questions, answers, false); if (!this.options.skipLocalCache) { storeAnswers(this.config, questions, answers, true); } } return answers; } /** * 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. * * @param name - Option name * @param config - Option options * @param config.type - Either Boolean, String or Number * @param config.description - Description for the option * @param config.default - Default value * @param config.alias - Option name alias (example `-h` and --help`) * @param config.hide - Boolean whether to hide from help * @param config.storage - Storage to persist the option * @return This generator */ option(name, config) { if (Array.isArray(name)) { for (const option of name) { this.option(option); } return; } const spec = typeof name === 'object' ? name : { hide: false, type: Boolean, description: 'Description for ' + name, ...config, name }; const specName = spec.name; // Check whether boolean option is invalid (starts with no-) const boolOptionRegex = /^no-/; if (spec.type === Boolean && specName.startsWith('no-')) { const simpleName = specName.replace(boolOptionRegex, ''); throw new Error([ `Option name ${chalk.yellow(specName)} cannot start with ${chalk.red('no-')}\n`, `Option name prefixed by ${chalk.yellow('--no')} are parsed as implicit`, ` boolean. To use ${chalk.yellow('--' + specName)} as an option, use\n`, chalk.cyan(` this.option('${simpleName}', {type: Boolean})`), ].join('')); } if (!this._options[specName]) { this._options[specName] = spec; } if (!this.features.skipParseOptions) { this.parseOptions(); } const { storage } = spec; if (storage && this.options[specName] !== undefined) { const storageObject = typeof storage === 'string' ? this[storage] : storage; storageObject.set(specName, this.options[specName]); } 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`). * * * @param name - Argument name * @param config - Argument options * @return This generator */ argument(name, config = {}) { // Alias default to defaults for backward compatibility. if ('defaults' in config) { config.default = config.defaults; } this._arguments.push({ name, required: config.default === null || config.default === undefined, type: String, ...config, }); if (!this.features.skipParseOptions) { this.parseOptions(); } return this; } parseOptions() { const booleans = []; const strings = []; const alias = {}; const defaults = {}; for (const option of Object.values(this._options)) { if (option.type === Boolean) { booleans.push(option.name); if (!('default' in option) && !option.required) { defaults[option.name] = EMPTY; } } else { strings.push(option.name); } if (option.alias) { alias[option.alias] = option.name; } // Only apply default values if we don't already have a value injected from // the runner if (option.name in this._initOptions) { defaults[option.name] = this._initOptions[option.name]; } else if (option.alias && option.alias in this._initOptions) { defaults[option.name] = this._initOptions[option.alias]; } else if ('default' in option) { defaults[option.name] = option.default; } } const parsedOptions = minimist(this._args, { boolean: booleans, string: strings, alias, default: defaults }); // Parse options to the desired type for (const [name, option] of Object.entries(parsedOptions)) { // Manually set value as undefined if it should be. if (option === EMPTY) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete parsedOptions[name]; } else if (this._options[name] && option !== undefined) { parsedOptions[name] = this._options[name].type(option); } } // Parse positional arguments to valid options for (const [index, config] of this._arguments.entries()) { let value; if (index >= parsedOptions._.length) { if (config.name in this._initOptions) { value = this._initOptions[config.name]; } else if ('default' in config) { value = config.default; } else { continue; } } else if (config.type === Array) { value = parsedOptions._.slice(index, parsedOptions._.length); } else { value = config.type(parsedOptions._[index]); } parsedOptions[config.name] = value; } // Make the parsed options available to the instance Object.assign(this.options, parsedOptions); this.args = parsedOptions._; this.arguments = parsedOptions._; // Make sure required args are all present this.checkRequiredArgs(); } 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; } // Bail early if it's not possible to have a missing required arg if (this.args.length > this._arguments.length) { return; } for (const [position, config] of this._arguments.entries()) { // 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) { throw new Error(`Did not provide required argument ${chalk.bold(config.name)}!`); } } } /** * Generator config Storage. */ get config() { if (!this._config) { this._config = this._getStorage(); } return this._config; } /** * Package.json Storage resolved to `this.destinationPath('package.json')`. * * Environment watches for package.json changes at `this.env.cwd`, and triggers an package manager install if it has been committed to disk. * If package.json is at a different folder, like a changed generator root, propagate it to the Environment like `this.env.cwd = this.destinationPath()`. * * @example * this.packageJson.merge({ * scripts: { * start: 'webpack --serve', * }, * dependencies: { * ... * }, * peerDependencies: { * ... * }, * }); */ get packageJson() { if (!this._packageJson) { this._packageJson = this.createStorage('package.json'); } return this._packageJson; } /** * 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. * * @return Resolved once the process finish */ async run() { return this.env.runGenerator(this); } /** * Determine the root generator name (the one who's extending Generator). * @return The name of the root generator */ rootGeneratorName() { return readPackageUpSync({ cwd: this.resolved })?.packageJson?.name ?? '*'; } /** * Determine the root generator version (the one who's extending Generator). * @return The version of the root generator */ rootGeneratorVersion() { return readPackageUpSync({ cwd: this.resolved })?.packageJson?.version ?? '0.0.0'; } /** * Return a storage instance. * @param storePath The path of the json file * @param options storage options or the storage name */ createStorage(storePath, options) { if (typeof options === 'string') { options = { name: options }; } storePath = this.destinationPath(storePath); return new Storage(this.fs, storePath, options); } /** * Return a storage instance. * @param rootName The rootName in which is stored inside .yo-rc.json * @param options Storage options * @return Generator storage */ _getStorage(rootName = this.rootGeneratorName(), options = {}) { if (typeof rootName === 'object') { options = rootName; rootName = this.rootGeneratorName(); } return this.createStorage('.yo-rc.json', { ...options, name: rootName }); } /** * Setup a globalConfig storage instance. * @return Global config storage */ _getGlobalStorage() { // When localConfigOnly === true simulate a globalConfig at local dir const globalStorageDir = this.options.localConfigOnly ? this.destinationRoot() : os.homedir(); const storePath = path.join(globalStorageDir, '.yo-rc-global.json'); const storeName = `${this.rootGeneratorName()}:${this.rootGeneratorVersion()}`; return this.createStorage(storePath, { name: storeName }); } /** * 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 rootPath new destination root path * @return destination root path */ destinationRoot(rootPath) { if (typeof rootPath === 'string') { this._destinationRoot = pathResolve(rootPath); if (!fs.existsSync(this._destinationRoot)) { fs.mkdirSync(this._destinationRoot, { recursive: true }); } this.emit(DESTINATION_ROOT_CHANGE_EVENT, this._destinationRoot); // Reset the storage this._config = undefined; // Reset packageJson this._packageJson = undefined; } return this._destinationRoot || this.env.cwd; } /** * Get or change the generator source root directory. * This path is used by multiples file system methods like (`this.read` and `this.copy`) * @param rootPath new source root path */ sourceRoot(rootPath) { if (typeof rootPath === 'string') { this._sourceRoot = pathResolve(rootPath); } return this._sourceRoot; } /** * Join a path to the source root. * @param dest - path parts * @return joined path */ templatePath(...dest) { let filepath = path.join.apply(path, dest); if (!path.isAbsolute(filepath)) { filepath = path.join(this.sourceRoot(), filepath); } return filepath; } /** * Join a path to the destination root. * @param dest - path parts * @return joined path */ destinationPath(...dest) { let filepath = path.join.apply(path, dest); if (!path.isAbsolute(filepath)) { filepath = path.join(this.destinationRoot(), filepath); } return filepath; } /** * 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 The name of the application */ determineAppname() { const appName = this.packageJson.get('name') ?? path.basename(this.destinationRoot()); return appName.replaceAll(/[^\w\s]+?/g, ' '); } } applyMixins(BaseGenerator, [FsMixin, HelpMixin, PackageJsonMixin, SpawnCommandMixin, GitMixin, TasksMixin]); function applyMixins(destCtor, constructors) { for (const sourceCtor of constructors) { for (const name of Object.getOwnPropertyNames(sourceCtor.prototype)) { Object.defineProperty(destCtor.prototype, name, Object.getOwnPropertyDescriptor(sourceCtor.prototype, name) ?? Object.create(null)); } } } export default BaseGenerator;