'use strict';
const arrify = require('arrify');
const path = require('path');
const fs = require('fs');
const _ = require('lodash');
const globby = require('globby');
const debug = require('debug')('yeoman:environment');
const slash = require('slash');
const {execaOutput} = require('./util/util');
const win32 = process.platform === 'win32';
const nvm = process.env.NVM_HOME;
const PROJECT_ROOT = path.join(__dirname, '..');
const PACKAGE_NAME_PATTERN = [require(path.join(PROJECT_ROOT, 'package.json')).name];
const packageLookup = {};
/**
* @mixin
* @alias env/resolver
*/
const resolver = module.exports;
/**
* @private
*/
resolver.packageLookup = packageLookup;
/**
* Search for generators and their sub generators.
*
* A generator is a `:lookup/:name/index.js` file placed inside an npm package.
*
* Defaults lookups are:
* - ./
* - generators/
* - lib/generators/
*
* So this index file `node_modules/generator-dummy/lib/generators/yo/index.js` would be
* registered as `dummy:yo` generator.
*
* @param {boolean|Object} [options]
* @param {boolean} [options.localOnly = false] - Set true to skip lookups of
* globally-installed generators.
* @param {string|Array} [options.packagePaths] - Paths to look for generators.
* @param {string|Array} [options.npmPaths] - Repository paths to look for generators packages.
* @param {string|Array} [options.filePatterns='*\/index.js'] - File pattern to look for.
* @param {string|Array} [options.packagePatterns='generator-*'] - Package pattern to look for.
* @param {boolean} [options.singleResult=false] - Set true to stop lookup on the first match.
* @param {Number} [options.globbyDeep] - Deep option to be passed to globby.
* @return {Object[]} List of generators
*/
resolver.lookup = function (options) {
// Resolve signature where options is omitted.
if (typeof options === 'function') {
throw new TypeError('Callback support have been removed.');
// Resolve signature where options is boolean.
} else if (typeof options === 'boolean') {
options = {localOnly: options};
} else {
options = options || {localOnly: false};
}
const {registerToScope, lookups = this.lookups} = options;
options = {
// Js generators should be after, last will override registered one.
filePatterns: lookups.flatMap(prefix => [`${prefix}/*/index.ts`, `${prefix}/*/index.js`, `${prefix}/*/index.cjs`, `${prefix}/*/index.mjs`]),
filterPaths: false,
packagePatterns: ['generator-*'],
reverse: !options.singleResult,
...options
};
const generators = [];
this.packageLookup.sync(options, module => {
const {packagePath, filePath} = module;
let repositoryPath = path.join(packagePath, '..');
if (path.basename(repositoryPath).startsWith('@')) {
// Scoped package
repositoryPath = path.join(repositoryPath, '..');
}
let namespace = this.namespace(path.relative(repositoryPath, filePath), lookups);
if (registerToScope && !namespace.startsWith('@')) {
namespace = `@${registerToScope}/${namespace}`;
}
const registered = this._tryRegistering(filePath, packagePath, namespace);
generators.push({generatorPath: filePath, packagePath, namespace, registered});
return options.singleResult && registered;
});
return generators;
};
/**
* Search for npm packages.
*
* @private
* @method
*
* @param {boolean|Object} [options]
* @param {boolean} [options.localOnly = false] - Set true to skip lookups of
* globally-installed generators.
* @param {string|Array} [options.packagePaths] - Paths to look for generators.
* @param {string|Array} [options.npmPaths] - Repository paths to look for generators packages.
* @param {string|Array} [options.filePatterns='*\/index.js'] - File pattern to look for.
* @param {string|Array} [options.packagePatterns='lookup'] - Package pattern to look for.
* @param {boolean} [options.reverse = false] - Set true reverse npmPaths/packagePaths order
* @param {function} [find] Executed for each match, return true to stop lookup.
*/
packageLookup.sync = function (options, find = module => module) {
debug('Running lookup with options: %o', options);
options = {...options};
options.filePatterns = arrify(options.filePatterns || 'package.json').map(filePattern => slash(filePattern));
if (options.packagePaths) {
options.packagePaths = arrify(options.packagePaths);
if (options.reverse) {
options.packagePaths = options.packagePaths.reverse();
}
} else {
options.npmPaths = options.npmPaths || this.getNpmPaths(options);
if (options.reverse && Array.isArray(options.npmPaths)) {
options.npmPaths = options.npmPaths.reverse();
}
options.packagePatterns = arrify(options.packagePatterns || PACKAGE_NAME_PATTERN).map(packagePattern => slash(packagePattern));
options.packagePaths = this.findPackagesIn(options.npmPaths, options.packagePatterns);
}
debug('Lookup calculated options: %o', options);
const modules = [];
for (const packagePath of options.packagePaths) {
if (!fs.existsSync(packagePath) || (!fs.lstatSync(packagePath).isDirectory() && !fs.lstatSync(packagePath).isSymbolicLink())) {
continue;
}
for (const filePath of globby.sync(options.filePatterns, {cwd: packagePath, absolute: true, ...options.globbyOptions})) {
const module = {filePath, packagePath};
if (find(module)) {
return [module];
}
modules.push(module);
}
}
return modules;
};
/**
* Search npm for every available generators.
* Generators are npm packages who's name start with `generator-` and who're placed in the
* top level `node_module` path. They can be installed globally or locally.
*
* @method
* @private
*
* @param {String[]} searchPaths List of search paths
* @param {String[]} packagePatterns Pattern of the packages
* @param {Object} [globbyOptions]
* @return {Array} List of the generator modules path
*/
packageLookup.findPackagesIn = function (searchPaths, packagePatterns, globbyOptions) {
searchPaths = arrify(searchPaths).filter(npmPath => npmPath).map(npmPath => path.resolve(npmPath));
let modules = [];
for (const root of searchPaths) {
if (!fs.existsSync(root) || (!fs.lstatSync(root).isDirectory() && !fs.lstatSync(root).isSymbolicLink())) {
continue;
}
// Some folders might not be readable to the current user. For those, we add a try
// catch to handle the error gracefully as globby doesn't have an option to skip
// restricted folders.
try {
modules = modules.concat(globby.sync(
packagePatterns,
{cwd: root, onlyDirectories: true, expandDirectories: false, absolute: true, deep: 0, ...globbyOptions}
));
// To limit recursive lookups into non-namespace folders within globby,
// fetch all namespaces in root, then search each namespace separately
// for generator modules
const scopes = globby.sync(
['@*'],
{cwd: root, onlyDirectories: true, expandDirectories: false, absolute: true, deep: 0, ...globbyOptions}
);
for (const scope of scopes) {
modules = modules.concat(globby.sync(
packagePatterns,
{cwd: scope, onlyDirectories: true, expandDirectories: false, absolute: true, deep: 0, ...globbyOptions}
));
}
} catch (error) {
debug('Could not access %s (%s)', root, error);
}
}
return modules;
};
/**
* Try registering a Generator to this environment.
*
* @private
*
* @param {String} generatorReference A generator reference, usually a file path.
* @param {String} [packagePath] - Generator's package path.
* @param {String} [namespace] - namespace of the generator.
* @return {boolean} true if the generator have been registered.
*/
resolver._tryRegistering = function (generatorReference, packagePath, namespace) {
const realPath = fs.realpathSync(generatorReference);
try {
debug('found %s, trying to register', generatorReference);
if (!namespace && realPath !== generatorReference) {
namespace = this.namespace(generatorReference);
}
this.register(realPath, namespace, packagePath);
return true;
} catch (error) {
console.error('Unable to register %s (Error: %s)', generatorReference, error.message);
return false;
}
};
/**
* Get the npm lookup directories (`node_modules/`)
*
* @deprecated
*
* @param {boolean|Object} [options]
* @param {boolean} [options.localOnly = false] - Set true to skip lookups of
* globally-installed generators.
* @param {boolean} [options.filterPaths = false] - Remove paths that don't ends
* with a supported path (don't touch at NODE_PATH paths).
* @return {Array} lookup paths
*/
resolver.getNpmPaths = function (options = {}) {
// Resolve signature where options is boolean (localOnly).
if (typeof options === 'boolean') {
options = {localOnly: options};
}
// Backward compatibility
options.filterPaths = options.filterPaths === undefined ? false : options.filterPaths;
return this.packageLookup.getNpmPaths(options);
};
/**
* Get the npm lookup directories (`node_modules/`)
*
* @method
* @private
*
* @param {boolean|Object} [options]
* @param {boolean} [options.localOnly = false] - Set true to skip lookups of
* globally-installed generators.
* @param {boolean} [options.filterPaths = false] - Remove paths that don't ends
* with a supported path (don't touch at NODE_PATH paths).
* @return {Array} lookup paths
*/
packageLookup.getNpmPaths = function (options = {}) {
// Resolve signature where options is boolean (localOnly).
if (typeof options === 'boolean') {
options = {localOnly: options};
}
// Start with the local paths.
let paths = this._getLocalNpmPaths();
// Append global paths, unless they should be excluded.
if (!options.localOnly) {
paths = paths.concat(this._getGlobalNpmPaths(options.filterPaths));
}
return _.uniq(paths);
};
/**
* Get the local npm lookup directories
* @private
* @return {Array} lookup paths
*/
packageLookup._getLocalNpmPaths = function () {
const paths = [];
// Walk up the CWD and add `node_modules/` folder lookup on each level
process.cwd().split(path.sep).forEach((part, i, parts) => {
let lookup = path.join(...parts.slice(0, i + 1), 'node_modules');
if (!win32) {
lookup = `/${lookup}`;
}
paths.push(lookup);
});
return _.uniq(paths.reverse());
};
/**
* Get the global npm lookup directories
* Reference: https://nodejs.org/api/modules.html
* @private
* @return {Array} lookup paths
*/
packageLookup._getGlobalNpmPaths = function (filterPaths = true) {
let paths = [];
// Node.js will search in the following list of GLOBAL_FOLDERS:
// 1: $HOME/.node_modules
// 2: $HOME/.node_libraries
// 3: $PREFIX/lib/node
const filterValidNpmPath = function (path, ignore = false) {
return ignore ? path : (['/node_modules', '/.node_modules', '/.node_libraries', '/node'].some(dir => path.endsWith(dir)) ? path : undefined);
};
// Default paths for each system
if (nvm) {
paths.push(path.join(process.env.NVM_HOME, process.version, 'node_modules'));
} else if (win32) {
paths.push(path.join(process.env.APPDATA, 'npm/node_modules'));
} else {
paths.push('/usr/lib/node_modules');
paths.push('/usr/local/lib/node_modules');
}
// Add NVM prefix directory
if (process.env.NVM_PATH) {
paths.push(path.join(path.dirname(process.env.NVM_PATH), 'node_modules'));
}
// Adding global npm directories
// We tried using npm to get the global modules path, but it haven't work out
// because of bugs in the parseable implementation of `ls` command and mostly
// performance issues. So, we go with our best bet for now.
if (process.env.NODE_PATH) {
paths = _.compact(process.env.NODE_PATH.split(path.delimiter)).concat(paths);
}
// Global node_modules should be 4 or 2 directory up this one (most of the time)
// Ex: /usr/another_global/node_modules/yeoman-denerator/node_modules/yeoman-environment/lib (1 level dependency)
paths.push(filterValidNpmPath(path.join(PROJECT_ROOT, '../../..'), !filterPaths));
// Ex: /usr/another_global/node_modules/yeoman-environment/lib (installed directly)
paths.push(path.join(PROJECT_ROOT, '..'));
// Get yarn global directory and infer the module paths from there
const yarnBase = execaOutput('yarn', ['global', 'dir'], {encoding: 'utf8'});
if (yarnBase) {
paths.push(path.resolve(yarnBase, 'node_modules'));
paths.push(path.resolve(yarnBase, '../link/'));
}
// Get npm global prefix and infer the module paths from there
const globalInstall = execaOutput('npm', ['root', '-g'], {encoding: 'utf8'});
if (globalInstall) {
paths.push(path.resolve(globalInstall));
}
// Adds support for generator resolving when yeoman-generator has been linked
if (process.argv[1]) {
paths.push(filterValidNpmPath(path.join(path.dirname(process.argv[1]), '../..'), !filterPaths));
}
return _.uniq(paths.filter(path => path).reverse());
};
/**
* Get or create an alias.
*
* Alias allows the `get()` and `lookup()` methods to search in alternate
* filepath for a given namespaces. It's used for example to map `generator-*`
* npm package to their namespace equivalent (without the generator- prefix),
* or to default a single namespace like `angular` to `angular:app` or
* `angular:all`.
*
* Given a single argument, this method acts as a getter. When both name and
* value are provided, acts as a setter and registers that new alias.
*
* If multiple alias are defined, then the replacement is recursive, replacing
* each alias in reverse order.
*
* An alias can be a single String or a Regular Expression. The finding is done
* based on .match().
*
* @param {String|RegExp} match
* @param {String} value
*
* @example
*
* env.alias(/^([a-zA-Z0-9:\*]+)$/, 'generator-$1');
* env.alias(/^([^:]+)$/, '$1:app');
* env.alias(/^([^:]+)$/, '$1:all');
* env.alias('foo');
* // => generator-foo:all
*/
resolver.alias = function (match, value) {
if (match && value) {
this.aliases.push({
match: match instanceof RegExp ? match : new RegExp(`^${match}$`),
value
});
return this;
}
const aliases = this.aliases.slice(0).reverse();
return aliases.reduce((resolved, alias) => {
if (!alias.match.test(resolved)) {
return resolved;
}
return resolved.replace(alias.match, alias.value);
}, match);
};