'use strict';
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const chalk = require('chalk');
const _ = require('lodash');
const GroupedQueue = require('grouped-queue');
const escapeStrRe = require('escape-string-regexp');
const untildify = require('untildify');
const memFs = require('mem-fs');
const FileEditor = require('mem-fs-editor');
const debug = require('debug')('yeoman:environment');
const isScoped = require('is-scoped');
const crypto = require('crypto');
const npmlog = require('npmlog');
const {TrackerGroup} = require('are-we-there-yet');
const {promisify} = require('util');
const {pipeline: _pipeline} = require('stream');
const pipeline = promisify(_pipeline);
const ENVIRONMENT_VERSION = require('../package.json').version;
const Store = require('./store');
const composability = require('./composability');
const resolver = require('./resolver');
const TerminalAdapter = require('./adapter');
const YeomanRepository = require('./util/repository');
const Conflicter = require('./util/conflicter');
const {YeomanCommand} = require('./util/command');
const {
createCommitTransform,
createConflicterCheckTransform,
createConflicterStatusTransform,
createEachFileTransform,
createModifiedTransform,
createYoRcTransform,
createYoResolveTransform
} = require('./util/transform');
/**
* Two-step argument splitting function that first splits arguments in quotes,
* and then splits up the remaining arguments if they are not part of a quote.
*/
function splitArgsFromString(argsString) {
let result = [];
if (!argsString) {
return result;
}
const quoteSeparatedArgs = argsString.split(/("[^"]*")/).filter(x => x);
quoteSeparatedArgs.forEach(arg => {
if (arg.match('\x22')) {
result.push(arg.replace(/"/g, ''));
} else {
result = result.concat(arg.trim().split(' '));
}
});
return result;
}
/**
* Hint of generator module name
*/
function getGeneratorHint(namespace) {
if (isScoped(namespace)) {
const splitName = namespace.split('/');
return `${splitName[0]}/generator-${splitName[1]}`;
}
return `generator-${namespace}`;
}
const mixins = [
require('./command.js'),
require('./generator-features.js'),
require('./namespace'),
require('./package-manager.js')
];
const Base = mixins.reduce((a, b) => b(a), EventEmitter);
class Environment extends Base {
static get UNKNOWN_NAMESPACE() {
return 'unknownnamespace';
}
static get UNKNOWN_RESOLVED() {
return 'unknown';
}
static get queues() {
return [
'environment:run',
'initializing',
'prompting',
'configuring',
'default',
'writing',
'transform',
'conflicts',
'environment:conflicts',
'install',
'end'
];
}
static get lookups() {
return ['.', 'generators', 'lib/generators', 'dist/generators'];
}
/**
* Make sure the Environment present expected methods if an old version is
* passed to a Generator.
* @param {Environment} env
* @return {Environment} The updated env
*/
static enforceUpdate(env) {
if (!env.adapter) {
env.adapter = new TerminalAdapter();
}
if (!env.runLoop) {
env.runLoop = new GroupedQueue(Environment.queues, false);
}
if (!env.sharedFs) {
env.sharedFs = memFs.create();
}
if (!env.fs) {
env.fs = FileEditor.create(env.sharedFs);
}
return env;
}
/**
* Prepare a commander instance for cli support.
*
* @param {Class} GeneratorClass - Generator to create Command
* @return {Command} Return a Command instance
*/
static prepareCommand(GeneratorClass, command = new YeomanCommand()) {
command = Base.addEnvironmentOptions(command);
return Environment.prepareGeneratorCommand(command, GeneratorClass);
}
/**
* Prepare a commander instance for cli support.
*
* @param {Command} command - Command to be prepared
* @param {Class} GeneratorClass - Generator to create Command
* @return {Command} return command
*/
static prepareGeneratorCommand(command, GeneratorClass) {
const generator = new GeneratorClass([], {help: true, env: {}});
Base.addGeneratorOptions(command, generator);
command.action(async function () {
command.env = Environment.createEnv(this.opts());
let rootCommand = this;
while (rootCommand.parent) {
rootCommand = rootCommand.parent;
}
rootCommand.emit('yeoman:environment', command.env);
const generator = command.env.instantiate(GeneratorClass, this.args, this.opts());
await command.env.queueGenerator(generator);
return command.env.start().then(() => command.env);
});
return command;
}
/**
* Factory method to create an environment instance. Take same parameters as the
* Environment constructor.
*
* @deprecated @param {string[]} [args] - arguments.
* @param {object} [options] - Environment options.
* @param {Adapter} [adapter] - Terminal adapter.
*
* @return {Environment} a new Environment instance
*/
static createEnv(args, options, adapter) {
if (args && !Array.isArray(args)) {
options = args;
}
options = options || {};
return new Environment(options, adapter);
}
/**
* Factory method to create an environment instance. Take same parameters as the
* Environment constructor.
*
* @param {String} version - Version of the Environment
* @param {...any} args - Same arguments as {@link Environment}#createEnv.
* @return {Environment} a new Environment instance
*/
static async createEnvWithVersion(version, ...args) {
const repository = new YeomanRepository();
const installedVersion = repository.verifyInstalledVersion('yeoman-environment', version);
if (!installedVersion) {
await repository.installPackage('yeoman-environment', version);
}
const VersionedEnvironment = repository.requireModule('yeoman-environment', version);
return VersionedEnvironment.createEnv(...args);
}
/**
* Convert a generators namespace to its name
*
* @param {String} namespace
* @return {String}
*/
static namespaceToName(namespace) {
return namespace.split(':')[0];
}
/**
* Lookup for a specific generator.
*
* @param {String} namespace
* @param {Object} [options]
* @param {Boolean} [options.localOnly=false] - Set true to skip lookups of
* globally-installed generators.
* @param {Boolean} [options.packagePath=false] - Set true to return the package
* path instead of generators file.
* @param {Boolean} [options.singleResult=true] - Set false to return multiple values.
* @return {String} generator
*/
static lookupGenerator(namespace, options) {
options = typeof options === 'boolean' ? {singleResult: true, localOnly: options} : {singleResult: !(options && options.multiple), ...options};
options.filePatterns = options.filePatterns || Environment.lookups.map(prefix => path.join(prefix, '*/index.{js,ts}'));
const name = Environment.namespaceToName(namespace);
options.packagePatterns = options.packagePatterns || getGeneratorHint(name);
const envProt = Environment.prototype;
options.npmPaths = options.npmPaths || envProt.getNpmPaths(options.localOnly).reverse();
options.packagePatterns = options.packagePatterns || 'generator-*';
options.packagePaths = options.packagePaths || resolver.packageLookup.findPackagesIn(options.npmPaths, options.packagePatterns);
let paths = options.singleResult ? undefined : [];
resolver.packageLookup.sync(options, module => {
const filename = module.filePath;
const fileNS = envProt.namespace(filename, Environment.lookups);
if (namespace === fileNS || (options.packagePath && namespace === Environment.namespaceToName(fileNS))) {
// Version 2.6.0 returned pattern instead of modulePath for options.packagePath
const returnPath = options.packagePath ? module.packagePath : (options.generatorPath ? path.posix.join(filename, '../../') : filename);
if (options.singleResult) {
paths = returnPath;
return true;
}
paths.push(returnPath);
}
return false;
});
return paths;
}
/**
* @classdesc `Environment` object is responsible of handling the lifecyle and bootstrap
* of generators in a specific environment (your app).
*
* It provides a high-level API to create and run generators, as well as further
* tuning where and how a generator is resolved.
*
* An environment is created using a list of `arguments` and a Hash of
* `options`. Usually, this is the list of arguments you get back from your CLI
* options parser.
*
* An optional adapter can be passed to provide interaction in non-CLI environment
* (e.g. IDE plugins), otherwise a `TerminalAdapter` is instantiated by default
*
* @constructor
* @mixes env/resolver
* @mixes env/composability
* @param {String|Array} args
* @param {Object} opts
* @param {Boolean} [opts.experimental]
* @param {Object} [opts.sharedOptions]
* @param {Console} [opts.console]
* @param {Stream} [opts.stdin]
* @param {Stream} [opts.stdout]
* @param {Stream} [opts.stderr]
* @param {TerminalAdapter} [adapter] - A TerminalAdapter instance or another object
* implementing this adapter interface. This is how
* you'd interface Yeoman with a GUI or an editor.
*/
constructor(options, adapter) {
super();
this.setMaxListeners(100);
this.options = options || {};
this.adapter = adapter || new TerminalAdapter({console: this.options.console, stdin: this.options.stdin, stderr: this.options.stderr});
this.cwd = this.options.cwd || process.cwd();
this.cwd = path.resolve(this.cwd);
this.logCwd = this.options.logCwd || this.cwd;
this.store = new Store();
this.command = this.options.command;
this.runLoop = new GroupedQueue(Environment.queues, false);
this.sharedFs = memFs.create();
// Each composed generator might set listeners on these shared resources. Let's make sure
// Node won't complain about event listeners leaks.
this.runLoop.setMaxListeners(0);
this.sharedFs.setMaxListeners(0);
// Create a shared mem-fs-editor instance.
this.fs = FileEditor.create(this.sharedFs);
this.lookups = Environment.lookups;
this.aliases = [];
this.alias(/^([^:]+)$/, '$1:app');
// Used sharedOptions from options if exists.
this.sharedOptions = this.options.sharedOptions || {};
// Remove Unecessary sharedOptions from options
delete this.options.sharedOptions;
// Create a default sharedData.
this.sharedOptions.sharedData = this.sharedOptions.sharedData || {};
// Pass forwardErrorToEnvironment to generators.
this.sharedOptions.forwardErrorToEnvironment = false;
this.repository = new YeomanRepository(this.options.yeomanRepository);
if (!this.options.experimental) {
process.argv.forEach(value => {
if (value === '--experimental') {
this.options.experimental = true;
debug('Set environment as experimental');
}
});
}
this.loadSharedOptions(this.options);
if (this.sharedOptions.skipLocalCache === undefined) {
this.sharedOptions.skipLocalCache = true;
}
// Store the generators by paths and uniqueBy feature.
this._generatorsForPath = {};
this._generators = {};
// Store the YeomanCompose by paths and uniqueBy feature.
this._composeStore = {};
}
/**
* Load options passed to the Generator that should be used by the Environment.
*
* @param {Object} options
*/
loadEnvironmentOptions(options) {
const environmentOptions = _.pick(options, [
'skipInstall',
'nodePackageManager'
]);
_.defaults(this.options, environmentOptions);
return environmentOptions;
}
/**
* Load options passed to the Environment that should be forwarded to the Generator.
*
* @param {Object} options
*/
loadSharedOptions(options) {
const optionsToShare = _.pick(options, [
'skipInstall',
'forceInstall',
'skipCache',
'skipLocalCache',
'skipParseOptions',
'localConfigOnly',
'askAnswered'
]);
Object.assign(this.sharedOptions, optionsToShare);
return optionsToShare;
}
/**
* @deprecated
* Error handler taking `err` instance of Error.
*
* The `error` event is emitted with the error object, if no `error` listener
* is registered, then we throw the error.
*
* @param {Object} err
* @return {Error} err
*/
error(error) {
throw error instanceof Error ? error : new Error(error);
}
/**
* Outputs the general help and usage. Optionally, if generators have been
* registered, the list of available generators is also displayed.
*
* @param {String} name
*/
help(name = 'init') {
const out = [
'Usage: :binary: GENERATOR [args] [options]',
'',
'General options:',
' --help # Print generator\'s options and usage',
' -f, --force # Overwrite files that already exist',
'',
'Please choose a generator below.',
''
];
const ns = this.namespaces();
const groups = {};
for (const namespace of ns) {
const base = namespace.split(':')[0];
if (!groups[base]) {
groups[base] = [];
}
groups[base].push(namespace);
}
for (const key of Object.keys(groups).sort()) {
const group = groups[key];
if (group.length > 0) {
out.push('', key.charAt(0).toUpperCase() + key.slice(1));
}
for (const ns of groups[key]) {
out.push(` ${ns}`);
}
}
return out.join('\n').replace(/:binary:/g, name);
}
/**
* Registers a specific `generator` to this environment. This generator is stored under
* provided namespace, or a default namespace format if none if available.
*
* @param {String} name - Filepath to the a generator or a npm package name
* @param {String} namespace - Namespace under which register the generator (optional)
* @param {String} packagePath - PackagePath to the generator npm package (optional)
* @return {Object} environment - This environment
*/
register(name, namespace, packagePath) {
if (typeof name !== 'string') {
throw new TypeError('You must provide a generator name to register.');
}
const modulePath = this.resolveModulePath(name);
namespace = namespace || this.namespace(modulePath);
if (!namespace) {
throw new Error('Unable to determine namespace.');
}
// Generator is already registered and matches the current namespace.
if (this.store._meta[namespace] && this.store._meta[namespace].resolved === modulePath) {
return this;
}
this.store.add(namespace, modulePath, modulePath, packagePath);
const packageNS = Environment.namespaceToName(namespace);
this.store.addPackageNS(packageNS);
if (packagePath) {
this.store.addPackage(packageNS, packagePath);
}
debug('Registered %s (%s) on package %s (%s)', namespace, modulePath, packageNS, packagePath);
return this;
}
/**
* Register a stubbed generator to this environment. This method allow to register raw
* functions under the provided namespace. `registerStub` will enforce the function passed
* to extend the Base generator automatically.
*
* @param {Function} Generator - A Generator constructor or a simple function
* @param {String} namespace - Namespace under which register the generator
* @param {String} [resolved] - The file path to the generator
* @param {String} [packagePath] - The generator's package path
* @return {this}
*/
registerStub(Generator, namespace, resolved = Environment.UNKNOWN_RESOLVED, packagePath = undefined) {
if (typeof Generator !== 'function' && typeof Generator.createGenerator !== 'function') {
throw new TypeError('You must provide a stub function to register.');
}
if (typeof namespace !== 'string') {
throw new TypeError('You must provide a namespace to register.');
}
this.store.add(namespace, Generator, resolved, packagePath);
const packageNS = Environment.namespaceToName(namespace);
this.store.addPackageNS(packageNS);
if (packagePath) {
this.store.addPackage(packageNS, packagePath);
}
debug('Registered %s (%s) on package (%s)', namespace, resolved, packagePath);
return this;
}
/**
* Returns the list of registered namespace.
* @return {Array}
*/
namespaces() {
return this.store.namespaces();
}
/**
* Returns the environment or dependency version.
* @param {String} packageName - Module to get version.
* @return {String} Environment version.
*/
getVersion(packageName) {
if (packageName && packageName !== 'yeoman-environment') {
try {
return require(`${packageName}/package.json`).version;
} catch {
return undefined;
}
}
return ENVIRONMENT_VERSION;
}
/**
* Returns stored generators meta
* @return {Object}
*/
getGeneratorsMeta() {
return this.store.getGeneratorsMeta();
}
/**
* Get registered generators names
*
* @return {Array}
*/
getGeneratorNames() {
return _.uniq(Object.keys(this.getGeneratorsMeta()).map(namespace => Environment.namespaceToName(namespace)));
}
/**
* Verify if a package namespace already have been registered.
*
* @param {String} [packageNS] - namespace of the package.
* @return {boolean} - true if any generator of the package has been registered
*/
isPackageRegistered(packageNS) {
return this.getRegisteredPackages().includes(packageNS);
}
/**
* Get all registered packages namespaces.
*
* @return {Array} - array of namespaces.
*/
getRegisteredPackages() {
return this.store.getPackagesNS();
}
/**
* Get last added path for a namespace
*
* @param {String} - namespace
* @return {String} - path of the package
*/
getPackagePath(namespace) {
if (namespace.includes(':')) {
const generator = this.get(namespace) || {};
return generator.packagePath;
}
const packagePaths = this.getPackagePaths(namespace) || [];
return packagePaths[0];
}
/**
* Get paths for a namespace
*
* @param {String} - namespace
* @return {Array} - array of paths.
*/
getPackagePaths(namespace) {
return this.store.getPackagesPaths()[namespace] ||
this.store.getPackagesPaths()[Environment.namespaceToName(this.alias(namespace))];
}
/**
* Get a single generator from the registered list of generators. The lookup is
* based on generator's namespace, "walking up" the namespaces until a matching
* is found. Eg. if an `angular:common` namespace is registered, and we try to
* get `angular:common:all` then we get `angular:common` as a fallback (unless
* an `angular:common:all` generator is registered).
*
* @param {String} namespaceOrPath
* @return {Generator|null} - the generator registered under the namespace
*/
get(namespaceOrPath) {
// Stop the recursive search if nothing is left
if (!namespaceOrPath) {
return;
}
const parsed = this.toNamespace ? this.toNamespace(namespaceOrPath) : undefined;
if (parsed && this.getByNamespace) {
return this.getByNamespace(parsed);
}
let namespace = namespaceOrPath;
// Legacy yeoman-generator `#hookFor()` function is passing the generator path as part
// of the namespace. If we find a path delimiter in the namespace, then ignore the
// last part of the namespace.
const parts = namespaceOrPath.split(':');
const maybePath = _.last(parts);
if (parts.length > 1 && /[/\\]/.test(maybePath)) {
parts.pop();
// We also want to remove the drive letter on windows
if (maybePath.includes('\\') && _.last(parts).length === 1) {
parts.pop();
}
namespace = parts.join(':');
}
const maybeGenerator = this.store.get(namespace) ||
this.store.get(this.alias(namespace)) ||
// Namespace is empty if namespaceOrPath contains a win32 absolute path of the form 'C:\path\to\generator'.
// for this reason we pass namespaceOrPath to the getByPath function.
this.getByPath(namespaceOrPath);
if (maybeGenerator && maybeGenerator.then) {
return maybeGenerator.then(Generator => this._findGeneratorClass(Generator));
}
return this._findGeneratorClass(maybeGenerator);
}
/**
* Get a generator by path instead of namespace.
* @param {String} path
* @return {Generator|null} - the generator found at the location
*/
getByPath(path) {
if (fs.existsSync(path)) {
const namespace = this.namespace(path);
this.register(path, namespace);
return this.get(namespace);
}
}
/**
* Find generator's class constructor.
* @private
* @param {Object} Generator - Object containing the class.
* @return {Function} Generator's constructor.
*/
_findGeneratorClass(Generator) {
if (!Generator) {
return Generator;
}
let meta = Generator;
if (Array.isArray(Generator)) {
meta = Generator[1];
Generator = Generator[0];
}
if (typeof Generator.default === 'function') {
Generator.default.resolved = meta.resolved;
Generator.default.namespace = meta.namespace;
return Generator.default;
}
if (typeof Generator.createGenerator === 'function') {
const maybeGenerator = Generator.createGenerator(this);
if (maybeGenerator.then) {
return maybeGenerator.then(Gen => {
Gen.resolved = meta.resolved;
Gen.namespace = meta.namespace;
return Gen;
});
}
maybeGenerator.resolved = meta.resolved;
maybeGenerator.namespace = meta.namespace;
return maybeGenerator;
}
if (typeof Generator !== 'function') {
throw new TypeError('The generator doesn\'t provides a constructor.');
}
return Generator;
}
/**
* Create is the Generator factory. It takes a namespace to lookup and optional
* hash of options, that lets you define `arguments` and `options` to
* instantiate the generator with.
*
* An error is raised on invalid namespace.
*
* @param {String} namespaceOrPath
* @param {Array} [args]
* @param {Object} [options]
* @return {Generator} The instantiated generator
*/
create(namespaceOrPath, args, options) {
if (!Array.isArray(args) && typeof args === 'object') {
options = args.options || args;
args = args.arguments || args.args || [];
} else {
args = Array.isArray(args) ? args : splitArgsFromString(args);
options = options || {};
}
const namespace = this.toNamespace ? this.toNamespace(namespaceOrPath) : undefined;
let maybeGenerator;
if (namespace && this.getByNamespace) {
maybeGenerator = this.getByNamespace(namespace);
if (!maybeGenerator) {
this.lookupLocalNamespaces(namespace);
maybeGenerator = this.getByNamespace(namespace);
}
}
const checkGenerator = Generator => {
if (namespace && Generator && Generator.namespace && Generator.namespace !== Environment.UNKNOWN_NAMESPACE) {
// Update namespace object in case of aliased namespace.
namespace.namespace = Generator.namespace;
}
if (typeof Generator !== 'function') {
const generatorHint = namespace ? namespace.generatorHint : getGeneratorHint(namespaceOrPath);
throw new Error(
chalk.red('You don\'t seem to have a generator with the name “' + namespaceOrPath + '” installed.') + '\n' +
'But help is on the way:\n\n' +
'You can see available generators via ' +
chalk.yellow('npm search yeoman-generator') + ' or via ' + chalk.yellow('http://yeoman.io/generators/') + '. \n' +
'Install them with ' + chalk.yellow(`npm install ${generatorHint}`) + '.\n\n' +
'To see all your installed generators run ' + chalk.yellow('yo') + ' without any arguments. ' +
'Adding the ' + chalk.yellow('--help') + ' option will also show subgenerators. \n\n' +
'If ' + chalk.yellow('yo') + ' cannot find the generator, run ' + chalk.yellow('yo doctor') + ' to troubleshoot your system.'
);
}
return Generator;
};
maybeGenerator = maybeGenerator || this.get(namespaceOrPath);
if (maybeGenerator && maybeGenerator.then) {
return maybeGenerator.then(Generator => checkGenerator(Generator)).then(Generator => this.instantiate(Generator, args, options));
}
return this.instantiate(checkGenerator(maybeGenerator), args, options);
}
/**
* Instantiate a Generator with metadatas
*
* @param {Class<Generator>} generator Generator class
* @param {Array} [args] Arguments to pass the instance
* @param {Object} [options] Options to pass the instance
* @return {Generator} The instantiated generator
*/
instantiate(Generator, args, options) {
if (!Array.isArray(args) && typeof args === 'object') {
options = args.options || args;
args = args.arguments || args.args || [];
} else {
args = Array.isArray(args) ? args : splitArgsFromString(args);
options = options || {};
}
const {namespace} = Generator;
const environmentOptions = {
env: this,
resolved: Generator.resolved || Environment.UNKNOWN_RESOLVED,
namespace
};
const generator = new Generator(args, {
...this.sharedOptions,
...options,
...environmentOptions
});
generator._environmentOptions = {
...this.options,
...this.sharedOptions,
...environmentOptions
};
return generator;
}
/**
* Compose with the generator.
*
* @param {String} namespaceOrPath
* @param {Array} [args]
* @param {Object} [options]
* @param {Boolean} [schedule]
* @return {Generator} The instantiated generator or the singleton instance.
*/
composeWith(generator, args, options, schedule = true) {
if (typeof args === 'boolean') {
schedule = args;
args = undefined;
options = undefined;
} else if (typeof options === 'boolean') {
schedule = options;
options = undefined;
}
const generatorInstance = this.create(generator, args, options);
if (generatorInstance.then) {
return generatorInstance.then(generatorInstance => this.queueGenerator(generatorInstance, schedule));
}
return this.queueGenerator(generatorInstance, schedule);
}
/**
* @private
*/
getGeneratorsForPath(generatorRoot = this.cwd) {
this._generatorsForPath[generatorRoot] = this._generatorsForPath[generatorRoot] || {};
return this._generatorsForPath[generatorRoot];
}
/**
* @private
*/
getGenerator(uniqueBy, generatorRoot = this.cwd) {
if (this._generators[uniqueBy]) {
return this._generators[uniqueBy];
}
return this.getGeneratorsForPath(generatorRoot)[uniqueBy];
}
/**
* @private
*/
getAllGenerators() {
return Object.fromEntries([
...Object.entries(this._generators),
...Object.entries(this._generatorsForPath).map(([root, generatorStore]) => {
return Object.entries(generatorStore).map(([namespace, generator]) => ([`${root}#${namespace}`, generator]));
}).flat()
]);
}
/**
* @private
*/
setGenerator(uniqueBy, generator) {
if (generator.features && generator.features.uniqueGlobally) {
this._generators[uniqueBy] = generator;
} else {
this.getGeneratorsForPath(generator.destinationRoot())[uniqueBy] = generator;
}
return generator;
}
/**
* Queue generator run (queue itself tasks).
*
* @param {Generator} generator Generator instance
* @param {boolean} [schedule=false] Whether to schedule the generator run.
* @return {Generator} The generator or singleton instance.
*/
queueGenerator(generator, schedule = false) {
const generatorFeatures = generator.getFeatures ? generator.getFeatures() : {};
let uniqueBy;
let rootUniqueBy;
let namespaceToEmit;
if (generatorFeatures) {
uniqueBy = generatorFeatures.uniqueBy;
namespaceToEmit = uniqueBy;
if (!generatorFeatures.uniqueGlobally) {
rootUniqueBy = generator.destinationRoot();
}
}
if (!uniqueBy) {
const {namespace} = generator.options;
const instanceId = crypto.randomBytes(20).toString('hex');
let namespaceDefinition = this.toNamespace(namespace);
if (namespaceDefinition) {
namespaceDefinition = namespaceDefinition.with({instanceId});
uniqueBy = namespaceDefinition.id;
namespaceToEmit = namespaceDefinition.namespace;
} else {
uniqueBy = `${namespace}#${instanceId}`;
namespaceToEmit = namespace;
}
}
const existing = this.getGenerator(uniqueBy, rootUniqueBy);
if (existing) {
debug(`Using existing generator for namespace ${uniqueBy}`);
return existing;
}
this.setGenerator(uniqueBy, generator);
this.emit('compose', namespaceToEmit, generator);
this.emit(`compose:${namespaceToEmit}`, generator);
const runGenerator = () => {
if (generator.queueTasks) {
// Generator > 5
this.once('run', () => generator.emit('run'));
this.once('end', () => generator.emit('end'));
return generator.queueTasks();
}
if (!generator.options.forwardErrorToEnvironment) {
generator.on('error', error => this.emit('error', error));
}
generator.promise = generator.run();
};
if (schedule) {
this.runLoop.add(
'environment:run',
async (done, stop) => {
try {
await runGenerator();
done();
} catch (error) {
stop(error);
}
}
);
} else {
const maybePromise = runGenerator();
if (maybePromise && maybePromise.then) {
return maybePromise.then(() => generator);
}
}
return generator;
}
/**
* Tries to locate and run a specific generator. The lookup is done depending
* on the provided arguments, options and the list of registered generators.
*
* When the environment was unable to resolve a generator, an error is raised.
*
* @param {String|Array} args
* @param {Object} [options]
*/
async run(args, options, done) {
if (done || typeof options === 'function' || typeof args === 'function') {
throw new Error('Callback support have been removed.');
}
args = Array.isArray(args) ? args : splitArgsFromString(args);
options = {...options};
const name = args.shift();
if (!name) {
throw new Error('Must provide at least one argument, the generator namespace to invoke.');
}
this.loadEnvironmentOptions(options);
const instantiateAndRun = async () => {
const generator = await this.create(name, args, {
...options,
initialGenerator: true
});
if (options.help) {
console.log(generator.help());
return undefined;
}
return this.runGenerator(generator);
};
if (this.options.experimental && !this.get(name)) {
debug(`Generator ${name} was not found, trying to install it`);
return this.prepareEnvironment(name).then(() => instantiateAndRun(), () => instantiateAndRun());
}
return instantiateAndRun();
}
/**
* Start Environment queue
* @param {Object} options - Conflicter options.
*/
start(options) {
return new Promise((resolve, reject) => {
if (this.conflicter === undefined) {
const conflicterOptions = _.pick(
_.defaults({}, this.options, options),
['force', 'bail', 'ignoreWhitespace', 'dryRun', 'skipYoResolve', 'logCwd']
);
conflicterOptions.cwd = conflicterOptions.logCwd;
this.conflicter = new Conflicter(this.adapter, conflicterOptions);
this.queueConflicter();
this.queuePackageManagerInstall();
}
/*
* Listen to errors and reject if emmited.
* Some cases the generator relied at the behavior that the running process
* would be killed if an error is thrown to environment.
* Make sure to not rely on that behavior.
*/
this.on('error', error => {
reject(error);
});
/*
* For backward compatibility
*/
this.on('generator:reject', error => {
reject(error);
});
this.on('generator:resolve', error => {
resolve(error);
});
this.runLoop.on('error', error => {
this.emit('error', error);
});
this.runLoop.on('paused', () => {
this.emit('paused');
});
this.once('end', () => {
resolve();
});
/* If runLoop has ended, the environment has ended too. */
this.runLoop.once('end', () => {
this.emit('end');
});
this.emit('run');
this.runLoop.start();
});
}
/**
* Convenience method to run the generator with callbackWrapper.
* See https://github.com/yeoman/environment/pull/101
*
* @param {Object} generator
*/
async runGenerator(generator) {
try {
generator = await generator;
generator = await this.queueGenerator(generator);
} catch (error) {
return Promise.reject(error);
}
this.compatibilityMode = generator.queueTasks ? false : 'v4';
this._rootGenerator = this._rootGenerator || generator;
return this.start(generator.options);
}
/**
* Get the first generator that was queued to run in this environment.
*
* @return {Generator} generator queued to run in this environment.
*/
rootGenerator() {
return this._rootGenerator;
}
/**
* Given a String `filepath`, tries to figure out the relative namespace.
*
* ### Examples:
*
* this.namespace('backbone/all/index.js');
* // => backbone:all
*
* this.namespace('generator-backbone/model');
* // => backbone:model
*
* this.namespace('backbone.js');
* // => backbone
*
* this.namespace('generator-mocha/backbone/model/index.js');
* // => mocha:backbone:model
*
* @param {String} filepath
* @param {Array} lookups paths
*/
namespace(filepath, lookups = this.lookups) {
if (!filepath) {
throw new Error('Missing namespace');
}
// Cleanup extension and normalize path for differents OS
let ns = path.normalize(filepath.replace(new RegExp(escapeStrRe(path.extname(filepath)) + '$'), ''));
// Sort lookups by length so biggest are removed first
const nsLookups = _(lookups.concat(['..'])).map(found => path.normalize(found)).sortBy('length').value().reverse();
// If `ns` contains a lookup dir in its path, remove it.
ns = nsLookups.reduce((ns, lookup) => {
// Only match full directory (begin with leading slash or start of input, end with trailing slash)
lookup = new RegExp(`(?:\\\\|/|^)${escapeStrRe(lookup)}(?=\\\\|/)`, 'g');
return ns.replace(lookup, '');
}, ns);
const folders = ns.split(path.sep);
const scope = _.findLast(folders, folder => folder.indexOf('@') === 0);
// Cleanup `ns` from unwanted parts and then normalize slashes to `:`
ns = ns
.replace(/(.*generator-)/, '') // Remove before `generator-`
.replace(/[/\\](index|main)$/, '') // Remove `/index` or `/main`
.replace(/^[/\\]+/, '') // Remove leading `/`
.replace(/[/\\]+/g, ':'); // Replace slashes by `:`
if (scope) {
ns = `${scope}/${ns}`;
}
debug('Resolve namespaces for %s: %s', filepath, ns);
return ns;
}
/**
* Resolve a module path
* @param {String} moduleId - Filepath or module name
* @return {String} - The resolved path leading to the module
*/
resolveModulePath(moduleId) {
if (moduleId[0] === '.') {
moduleId = path.resolve(moduleId);
}
moduleId = untildify(moduleId);
moduleId = path.normalize(moduleId);
if (path.extname(moduleId) === '') {
moduleId += path.sep;
}
let resolved;
// Win32: moduleId is resolving as moduleId.js or moduleId.json instead of moduleId/index.js, workaround it.
if (process.platform === 'win32' && path.extname(moduleId) === '') {
try {
resolved = require.resolve(path.join(moduleId, 'index'));
} catch {}
}
return resolved || require.resolve(moduleId);
}
/**
* Apply transform streams to file in MemFs.
* @param {Transform[]} transformStreams - transform streams to be applied.
* @param {Stream} [stream] - files stream, defaults to this.sharedFs.stream().
* @return {Promise}
*/
applyTransforms(transformStreams, options = {}) {
const {
stream = this.sharedFs.stream(),
name = 'Tranforming'
} = options;
let {log = true} = options;
if (log) {
npmlog.tracker = new TrackerGroup();
npmlog.enableProgress();
log = npmlog.newItem(name);
}
if (!Array.isArray(transformStreams)) {
transformStreams = [transformStreams];
}
return pipeline(
stream,
createModifiedTransform(),
...transformStreams,
createEachFileTransform(file => {
if (log) {
log.completeWork(10);
npmlog.info('Completed', path.relative(this.logCwd, file.path));
}
}, {autoForward: false, logName: 'environment:log'})
).then(() => {
if (log) {
log.finish();
npmlog.disableProgress();
}
});
}
/**
* Commits the MemFs to the disc.
* @param {Stream} [stream] - files stream, defaults to this.sharedFs.stream().
* @return {Promise}
*/
commitSharedFs(stream = this.sharedFs.stream()) {
return new Promise((resolve, reject) => {
debug('committing files');
this.fs.commit([
createYoResolveTransform(this.conflicter),
createYoRcTransform(),
createConflicterCheckTransform(this.conflicter),
createConflicterStatusTransform(),
// Use custom commit transform due to out of order transform.
createCommitTransform(this.fs)
],
stream,
(error, value) => {
debug('committing finished');
if (error) {
reject(error);
return;
}
// Force to empty Conflicter queue.
this.conflicter.queue.once('end', () => resolve(value));
this.conflicter.queue.run();
});
});
}
/**
* Queue environment's commit task.
*/
queueConflicter() {
const queueCommit = () => {
debug('Queueing conflicts task');
this.runLoop.add('environment:conflicts', (done, stop) => {
let customCommitTask = this.findGeneratorCustomCommitTask();
if (customCommitTask !== undefined && customCommitTask) {
if (typeof customCommitTask !== 'function') {
done();
return;
}
} else {
customCommitTask = this.commitSharedFs.bind(this);
}
const result = customCommitTask();
if (!result || !result.then) {
done();
return;
}
return result.then(() => {
debug('Adding queueCommit event listener');
this.sharedFs.once('change', queueCommit);
done();
}
, stop);
}
, {
once: 'write memory fs to disk'
});
};
queueCommit();
}
/**
* Queue environment's package manager install task.
*/
queuePackageManagerInstall() {
this.runLoop.add(
'install',
(done, stop) => this.packageManagerInstallTask().then(done, stop),
{once: 'package manager install'}
);
}
}
Object.assign(Environment.prototype, resolver);
Object.assign(Environment.prototype, composability);
Object.assign(Environment.prototype, require('./package-manager'));
Object.assign(Environment.prototype, require('./spawn-command'));
Object.assign(Environment.prototype, require('./namespace-composability'));
/**
* Expose the utilities on the module
* @see {@link env/util}
*/
Environment.util = require('./util/util');
module.exports = Environment;