You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
535 lines
17 KiB
535 lines
17 KiB
'use strict'; |
|
const debug = require('debug')('yeoman:environment:compose'); |
|
const EventEmitter = require('events'); |
|
const _ = require('lodash'); |
|
const NpmApi = require('npm-api'); |
|
const path = require('path'); |
|
const semver = require('semver'); |
|
|
|
const npm = new NpmApi(); |
|
|
|
/** |
|
* @mixin |
|
* @alias env/namespace-composability |
|
*/ |
|
const composability = module.exports; |
|
|
|
/** |
|
* Get a generator only by namespace. |
|
* @private |
|
* @param {YeomanNamespace|String} namespace |
|
* @return {Generator|null} - the generator found at the location |
|
*/ |
|
composability.getByNamespace = function (namespace) { |
|
const ns = this.requireNamespace(namespace).namespace; |
|
const Generator = this.store.get(ns) || this.store.get(this.alias(ns)); |
|
return this._findGeneratorClass(Generator); |
|
}; |
|
|
|
/** |
|
* Lookup and register generators from the custom local repository. |
|
* |
|
* @private |
|
* @param {YeomanNamespace[]} namespacesToLookup - namespaces to lookup. |
|
* @return {Object[]} List of generators |
|
*/ |
|
composability.lookupLocalNamespaces = function (namespacesToLookup) { |
|
if (!namespacesToLookup) { |
|
return []; |
|
} |
|
namespacesToLookup = Array.isArray(namespacesToLookup) ? namespacesToLookup : [namespacesToLookup]; |
|
namespacesToLookup = namespacesToLookup.map(ns => this.requireNamespace(ns)); |
|
// Keep only those packages that has a compatible version. |
|
namespacesToLookup = namespacesToLookup.filter(ns => { |
|
return this.repository.verifyInstalledVersion(ns.generatorHint, ns.semver) !== undefined; |
|
}); |
|
return this.lookupLocalPackages(namespacesToLookup.map(ns => ns.generatorHint)); |
|
}; |
|
|
|
/** |
|
* Search for generators or sub generators by namespace. |
|
* |
|
* @private |
|
* @param {boolean|Object} [options] options passed to lookup. Options singleResult, |
|
* filePatterns and packagePatterns can be overridden |
|
* @return {Array|Object} List of generators |
|
*/ |
|
composability.lookupNamespaces = function (namespaces, options = {}) { |
|
if (!namespaces) { |
|
return []; |
|
} |
|
namespaces = Array.isArray(namespaces) ? namespaces : [namespaces]; |
|
namespaces = namespaces.map(ns => this.requireNamespace(ns)); |
|
const opts = namespaces.map(ns => { |
|
const nsOpts = {packagePatterns: ns.generatorHint}; |
|
if (ns.generator) { |
|
// Build filePatterns to look specifically for the namespace. |
|
const genPath = ns.generator.split(':').join('/'); |
|
let filePatterns = [`${genPath}/index.?s`, `${genPath}.?s`]; |
|
const lookups = options.lookups || this.lookups; |
|
filePatterns = lookups.map(prefix => { |
|
return filePatterns.map(pattern => path.join(prefix, pattern)); |
|
}).reduce( |
|
(accumulator, currentValue) => accumulator.concat(currentValue), |
|
[] |
|
); |
|
nsOpts.filePatterns = filePatterns; |
|
nsOpts.singleResult = true; |
|
} |
|
return nsOpts; |
|
}); |
|
return opts.map(opt => this.lookup({...opt, ...options})).reduce((acc, cur) => acc.concat(cur), []); |
|
}; |
|
|
|
/** |
|
* Load or install namespaces based on the namespace flag |
|
* |
|
* @private |
|
* @param {String|Array} - namespaces |
|
* @return {boolean} - true if every required namespace was found. |
|
*/ |
|
composability.prepareEnvironment = async function (namespaces) { |
|
debug('prepareEnvironment %o', namespaces); |
|
namespaces = Array.isArray(namespaces) ? namespaces : [namespaces]; |
|
let missing = namespaces.map(ns => this.requireNamespace(ns)); |
|
|
|
const updateMissing = () => { |
|
// Remove already loaded namespaces |
|
missing = missing.filter(ns => !this.getByNamespace(ns)); |
|
return missing; |
|
}; |
|
|
|
const assertMissing = missing => { |
|
if (missing.length !== 0) { |
|
throw new Error(`Error preparing environment for ${missing.map(ns => ns.complete).join()}`); |
|
} |
|
}; |
|
|
|
updateMissing(); |
|
|
|
// Install missing |
|
const toInstall = {}; |
|
|
|
const addPeerGenerators = async (packageName, packageRange = '*') => { |
|
const npmRepo = npm.repo(packageName); |
|
let packages; |
|
try { |
|
packages = await npmRepo.package('all'); |
|
if (packages.error) { |
|
throw new Error(packages.error); |
|
} |
|
} catch (error) { |
|
debug(`Could not find npm package for ${packageName}`, error); |
|
return false; |
|
} |
|
|
|
let version = semver.maxSatisfying(Object.keys(packages.versions), packageRange); |
|
if (version === null) { |
|
version = semver.maxSatisfying(Object.keys(packages.versions), '*'); |
|
console.error(`Error looking for ${packageName} version ${packageRange}, using ${version}`); |
|
} |
|
toInstall[packageName] = version; |
|
|
|
const peerDependencies = packages.versions[version].peerDependencies; |
|
if (peerDependencies) { |
|
for (const peerPackageName in peerDependencies) { |
|
if (!Object.prototype.hasOwnProperty.call(peerDependencies, peerPackageName)) { |
|
continue; |
|
} |
|
const peerNS = this.parseNamespace(peerPackageName); |
|
if (!peerNS) { |
|
continue; |
|
} |
|
if (peerNS.unscoped.startsWith('generator-') && toInstall[peerPackageName] === undefined) { |
|
const packageRange = peerDependencies[peerPackageName]; |
|
if (this.repository.verifyInstalledVersion(peerPackageName, packageRange)) { |
|
continue; |
|
} |
|
// eslint-disable-next-line no-await-in-loop |
|
await addPeerGenerators(peerPackageName, packageRange); |
|
} |
|
} |
|
} |
|
return true; |
|
}; |
|
|
|
debug('Looking for peer dependecies %o', namespaces); |
|
const toLookup = []; |
|
// eslint-disable-next-line guard-for-in |
|
for (const i in missing) { |
|
const ns = missing[i]; |
|
const packageName = ns.generatorHint; |
|
const packageRange = ns.semver; |
|
if (packageRange && !semver.validRange(packageRange)) { |
|
continue; |
|
} |
|
if (this.repository.verifyInstalledVersion(packageName, packageRange)) { |
|
continue; |
|
} |
|
// eslint-disable-next-line no-await-in-loop |
|
if (!await addPeerGenerators(packageName, packageRange)) { |
|
toLookup.push(ns); |
|
} |
|
} |
|
|
|
debug('Installing %o', toInstall); |
|
this.installLocalGenerators(toInstall); |
|
debug('done %o', toInstall); |
|
if (updateMissing().length === 0) { |
|
return true; |
|
} |
|
// At last, try to lookup if install failed. |
|
this.lookupLocalNamespaces(missing.concat(toLookup)); |
|
|
|
assertMissing(updateMissing()); |
|
return true; |
|
}; |
|
|
|
class YeomanGeneratorApi { |
|
constructor(generator) { |
|
this._generator = generator; |
|
|
|
const propertyNames = Object.getOwnPropertyNames(Object.getPrototypeOf(generator)); |
|
this.methods = propertyNames.map(property => { |
|
if (!property.startsWith('#')) { |
|
return undefined; |
|
} |
|
return property.slice(1); |
|
}).filter(property => property); |
|
|
|
this.methods.forEach(property => { |
|
const propertyValue = generator[`#${property}`]; |
|
this[property] = propertyValue.bind(generator); |
|
}); |
|
} |
|
|
|
get config() { |
|
return this._generator.config; |
|
} |
|
|
|
get generatorConfig() { |
|
return this._generator.generatorConfig; |
|
} |
|
|
|
get instanceConfig() { |
|
return this._generator.instanceConfig; |
|
} |
|
} |
|
|
|
class YeomanWith { |
|
constructor(generatorApis, compose) { |
|
this.generatorApis = Array.isArray(generatorApis) ? generatorApis : [generatorApis]; |
|
this.compose = compose; |
|
|
|
this.generatorApis.forEach(generatorApi => { |
|
generatorApi.methods.forEach(method => { |
|
if (!this[method]) { |
|
this[method] = this.call.bind(this, method); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
call(methods, ...methodArgs) { |
|
methods = Array.isArray(methods) ? methods : [methods]; |
|
const runMethods = generatorApi => { |
|
const promises = methods.map(methodName => { |
|
return generatorApi[methodName](...methodArgs); |
|
}); |
|
return promises.length === 1 ? promises[0] : Promise.all(promises); |
|
}; |
|
|
|
this.generatorApis.forEach(runMethods); |
|
return this; |
|
} |
|
} |
|
|
|
class YeomanCompose { |
|
constructor(env, options, sharedOptions) { |
|
if (typeof options === 'string') { |
|
options = {destinationRoot: options}; |
|
} |
|
this.env = env; |
|
// Destination root for this context. |
|
this._destinationRoot = options.destinationRoot; |
|
// Parent YeomanCompose if exists. |
|
this._parent = options.parent; |
|
// Store compose childs. |
|
this._childs = {}; |
|
|
|
// Store the generators by namespaceId. |
|
this._generators = {}; |
|
// Store the generators apis by namespaceId. |
|
this._generatorsApi = []; |
|
// Options by namespace. |
|
this._namespaceOptions = {}; |
|
// Default options to be passed to all generators. |
|
this._sharedOptions = {...sharedOptions, compose: this}; |
|
|
|
// Store generators apis hierarchically . |
|
this.api = {}; |
|
|
|
this.events = new EventEmitter(); |
|
|
|
// Shared options between contexts. |
|
this.shared = this._parent ? this._parent.shared : {}; |
|
|
|
// Load rootGenerator if passes. |
|
if (options.rootGenerator) { |
|
this._loadGenerator(options.rootGenerator); |
|
} |
|
} |
|
|
|
/** |
|
* @private |
|
* Get parent context |
|
*/ |
|
atParent() { |
|
return this._parent; |
|
} |
|
|
|
/** |
|
* @private |
|
* Get YeomanCompose child. |
|
* |
|
* @param {String} id - Identification to register into. |
|
* @param {String} destinationRoot - The relative path for the context. |
|
* @param {Object} sharedOptions - Configuration to be passed to every generator. |
|
* @return {YeomanCompose} Child YeomanCompose |
|
*/ |
|
createChild(id, destinationRoot, sharedOptions) { |
|
const namespace = this.env.requireNamespace(id); |
|
if (!this._childs[namespace.id]) { |
|
this._childs[namespace.id] = new YeomanCompose(this.env, {destinationRoot, parent: this}, sharedOptions); |
|
} |
|
return this._childs[namespace.id]; |
|
} |
|
|
|
/** |
|
* @private |
|
* Get config from a namespace. |
|
* |
|
* @param {String} namespace - Namespace the get the configuration. |
|
* @param {Boolean} [generatorConfig] - Set true to get the generator config |
|
* instead of package config |
|
* @return {Object} Config |
|
*/ |
|
getConfig(namespace, generatorConfig = false) { |
|
namespace = this.env.requireNamespace(namespace); |
|
const yoRc = path.join(this._destinationRoot, '.yo-rc.json'); |
|
let configToReturn = this.env.fs.readJSON(yoRc, {})[namespace.generatorHint] || {}; |
|
if (generatorConfig || namespace.instanceId) { |
|
configToReturn = configToReturn[namespace.generatorName] || {}; |
|
} |
|
if (namespace.instanceId) { |
|
configToReturn = configToReturn[namespace.instanceName] || {}; |
|
} |
|
return configToReturn; |
|
} |
|
|
|
/** |
|
* @private |
|
* Register the callback to be execute once the generator is instantiated. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Function} callback - Function to be executed once the generator is instantiated. |
|
* @return {Promise|undefined} Promise the generator api or undefined. |
|
*/ |
|
async once(namespace, callback) { |
|
namespace = this.env.requireNamespace(namespace); |
|
if (namespace.instanceId === '*') { |
|
throw new Error('Wildcard not supported'); |
|
} |
|
return this.if(namespace, callback, () => { |
|
this.events.once(`load_${namespace.id}`, callback); |
|
}); |
|
} |
|
|
|
/** |
|
* @private |
|
* If namespace is loaded then execute the callback else throws an error. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @return {Promise} Promise the generator api. |
|
*/ |
|
async get(namespace) { |
|
namespace = this.env.requireNamespace(namespace); |
|
if (namespace.instanceId === '*') { |
|
throw new Error(`Namespace must not be globby: ${namespace.complete}`); |
|
} |
|
if (namespace.complete !== namespace.id) { |
|
throw new Error(`Namespace ${namespace.complete} should be ${namespace.id}`); |
|
} |
|
const generatorApi = this._generatorsApi[namespace.id]; |
|
if (generatorApi) { |
|
return generatorApi; |
|
} |
|
throw new Error(`Generator ${namespace.complete} isn't loaded`); |
|
} |
|
|
|
/** |
|
* @private |
|
* Loads the generator if it isn't loaded. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Object} generatorOptions - Options to be passed to the generator |
|
* @return {Promise} Promise the generator api. |
|
*/ |
|
async require(namespace, generatorOptions) { |
|
return this.get(namespace).catch(() => this._queue(namespace, generatorOptions)); |
|
} |
|
|
|
/** |
|
* @private |
|
* If namespace is loaded then execute the callback. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Function} callback - Callback executed when the generator exists |
|
* @param {Function} [elseCallback] - Callback executed when the generator don't exists |
|
* @return {Promise|undefined} Promise the generator api. |
|
*/ |
|
async if(namespace, callback, elseCallback = () => {}) { |
|
return this.get(namespace).then(callback, () => elseCallback()); |
|
} |
|
|
|
/** |
|
* @private |
|
* Parse the namespace and route to the corresponding method. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Object} [generatorOptions] - Options to be passed to the generator |
|
* @param {Object} [callArgs] - Arguments to be passed to the methods. |
|
* @return {Promise} Promise YeomanWith |
|
*/ |
|
async with(namespace, generatorOptions, ...methodArgs) { |
|
debug(`Compose with generator ${namespace} at ${this._destinationRoot}`); |
|
namespace = this.env.requireNamespace(namespace); |
|
if (!namespace.generator) { |
|
throw new Error(`Namespace with generator is required: ${namespace.id}`); |
|
} |
|
let namespacesId; |
|
if (namespace.instanceId && namespace.instanceId === '*') { |
|
namespacesId = this._getInstanceNames(namespace.namespace).map(instanceId => { |
|
return namespace.with({instanceId}).id; |
|
}); |
|
} else { |
|
namespacesId = [namespace.id]; |
|
} |
|
|
|
const withPromise = Promise.all(namespacesId.map(nsId => this.require(nsId, generatorOptions))).then(generatorsApi => new YeomanWith(generatorsApi, this)); |
|
if (namespace.methods && namespace.methods.length > 0) { |
|
return withPromise.then(withInstance => { |
|
withInstance.call(namespace.methods, ...methodArgs); |
|
return withInstance; |
|
}); |
|
} |
|
return withPromise; |
|
} |
|
|
|
/** |
|
* @private |
|
* Get the generator instances from config. |
|
* |
|
* @param {String} generatorNamespace - Namespace of the generator |
|
* @return {String[]} instances names. |
|
*/ |
|
_getInstanceNames(generatorNamespace) { |
|
const generatorConfig = this.getConfig(generatorNamespace, true); |
|
return Object.keys(generatorConfig) |
|
.filter(instanceName => instanceName.startsWith('#')) |
|
.map(instanceName => instanceName.slice(1)); |
|
} |
|
|
|
/** |
|
* @private |
|
* Load the the generator into the YeomanCompose. |
|
* |
|
* @param {YeomanNamespace} namespace - Namespace object |
|
* @param {Object} generatorOptions - Options to be passed to the generator. |
|
* @return {Object} Generator api |
|
*/ |
|
_load(namespace, generatorOptions) { |
|
if (!namespace.generator) { |
|
throw new Error(`Namespace with generator is required: ${namespace.id}`); |
|
} |
|
if (namespace.complete !== namespace.id) { |
|
throw new Error(`Namespace ${namespace.complete} should be ${namespace.id}`); |
|
} |
|
const generatorApi = this._generatorsApi[namespace.id]; |
|
if (generatorApi) { |
|
return generatorApi; |
|
} |
|
|
|
debug(`Creating generator ${namespace} at ${this._destinationRoot}`); |
|
const generator = this._createGenerator(namespace, {...generatorOptions}); |
|
return this._loadGenerator(generator); |
|
} |
|
|
|
/** |
|
* @private |
|
* Instantiate the generator |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Object} generatorOptions - Options to be passed to the generator |
|
* @return {Generator} the instance of the generator. |
|
*/ |
|
_createGenerator(namespace, generatorOptions) { |
|
return this.env.create(namespace, { |
|
arguments: [namespace.instanceId], |
|
options: { |
|
destinationRoot: this._destinationRoot, |
|
...this._sharedOptions, |
|
...this._namespaceOptions[namespace.id], |
|
...generatorOptions |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* @private |
|
* Prepare the composed api for the generator. |
|
* |
|
* @param {Generator} generator - Generator |
|
* @return {Object} Generator composed api |
|
*/ |
|
_loadGenerator(generator) { |
|
const generatorApi = new YeomanGeneratorApi(generator); |
|
|
|
generator.options.generatorApi = generatorApi; |
|
const namespace = generator.options.namespaceId; |
|
const generatorObjectName = `${_.camelCase(namespace.unscoped)}`; |
|
if (namespace.instanceId) { |
|
this.api[generatorObjectName] = this[generatorObjectName] || {}; |
|
this.api[generatorObjectName][namespace.instanceId] = generatorApi; |
|
} else { |
|
this.api[generatorObjectName] = generatorApi; |
|
} |
|
|
|
this._generatorsApi[namespace.id] = generatorApi; |
|
this._generators[namespace.id] = generator; |
|
this.events.emit(`load_${namespace.id}`, generatorApi); |
|
|
|
return generatorApi; |
|
} |
|
|
|
/** |
|
* @private |
|
* Instantiate the generator and queue it's methods. |
|
* |
|
* @param {String|YeomanNamespace} namespace - Namespace |
|
* @param {Object} generatorOptions - Options to be passed to the generator. |
|
* @return {Object} generator composed api. |
|
*/ |
|
_queue(namespace, generatorOptions) { |
|
debug(`Queueing generator ${namespace} at ${this._destinationRoot}`); |
|
namespace = this.env.requireNamespace(namespace); |
|
const generatorApi = this._load(namespace, generatorOptions); |
|
generatorApi._generator.queueOwnTasks(); |
|
return generatorApi; |
|
} |
|
} |
|
|
|
composability.createCompose = function (destinationRoot, options = {}) { |
|
const rootGenerator = this._rootGenerator; |
|
return new YeomanCompose(this, {destinationRoot, rootGenerator}, options); |
|
};
|
|
|