'use strict'; const semver = require('semver'); const _ = require('lodash'); const cint = require('cint'); const semverutils = require('semver-utils'); const ProgressBar = require('progress'); const versionUtil = require('./version-util.js'); const packageManagers = require('./package-managers'); const prompts = require('prompts'); // keep order for setPrecision const DEFAULT_WILDCARD = '^'; /** * Returns 'v' if the string starts with a v, otherwise returns empty string. * @param {string} str * @returns {"v"|""} */ function v(str) { return str && (str[0] === 'v' || str[1] === 'v') ? 'v' : ''; } /** * Returns a new function that AND's the two functions over the provided arguments. * @param {function} f * @param {function} g * @returns {function} */ function and(f, g) { return function (...args) { return f.apply(this, args) && g.apply(this, args); }; } /** * Upgrade an existing dependency declaration to satisfy the latest version * @param declaration Current version declaration (e.g. "1.2.x") * @param latestVersion Latest version (e.g "1.3.2") * @param {Object} [options={}] * @returns {string} The upgraded dependency declaration (e.g. "1.3.x") */ function upgradeDependencyDeclaration(declaration, latestVersion, options = {}) { options.wildcard = options.wildcard || DEFAULT_WILDCARD; // parse the latestVersion // return original declaration if latestSemver is invalid const [latestSemver] = semverutils.parseRange(latestVersion); if (!latestSemver) { return declaration; } // return global versionUtil.wildcards immediately if (options.removeRange) { return latestVersion; } else if (versionUtil.isWildCard(declaration)) { return declaration; } // parse the declaration // if multiple ranges, use the semver with the least number of parts const parsedRange = _(semverutils.parseRange(declaration)) // semver-utils includes empty entries for the || and - operators. We can remove them completely .reject({operator: '||'}) .reject({operator: '-'}) .sortBy(_.ary(_.flow(versionUtil.stringify, versionUtil.numParts), 1)) .value(); const [declaredSemver] = parsedRange; /** * Chooses version parts between the declared version and the latest. * Base parts (major, minor, patch) are only included if they are in the original declaration. * Added parts (release, build) are always included. They are only present if we are checking --greatest versions * anyway. */ function chooseVersion(part) { return versionUtil.isWildPart(declaredSemver[part]) ? declaredSemver[part] : versionUtil.VERSION_BASE_PARTS.includes(part) && declaredSemver[part] ? latestSemver[part] : versionUtil.VERSION_ADDED_PARTS.includes(part) ? latestSemver[part] : undefined; } // create a new semver object with major, minor, patch, build, and release parts const newSemver = cint.toObject(versionUtil.VERSION_PARTS, part => cint.keyValue(part, chooseVersion(part)) ); const newSemverString = versionUtil.stringify(newSemver); const version = v(declaredSemver.semver) + newSemverString; // determine the operator // do not compact, because [undefined, '<'] must be differentiated from ['<'] const uniqueOperators = _(parsedRange) .map(range => range.operator) .uniq() .value(); const operator = uniqueOperators[0] || ''; const hasWildCard = versionUtil.WILDCARDS.some(_.partial(_.includes, newSemverString, _, 0)); const isLessThan = uniqueOperators[0] === '<' || uniqueOperators[0] === '<='; const isMixed = uniqueOperators.length > 1; // convert versions with { return value; }); // get the preferred wildcard and bind it to upgradeDependencyDeclaration const wildcard = getPreferredWildcard(currentDependencies) || DEFAULT_WILDCARD; const upgradeDep = _.partialRight(upgradeDependencyDeclaration, { wildcard, removeRange: options.removeRange }); return _(currentDependencies) // only include packages for which a latest version was fetched .pickBy((current, packageName) => { return packageName in latestVersions; }) // combine the current and latest dependency objects into a single object keyed by packageName and containing // both versions in an array: [current, latest] .mapValues((current, packageName) => { const latest = latestVersions[packageName]; return [current, latest]; }) // pick the packages that are upgradeable // we can use spread because isUpgradeable and upgradeDependencyDeclaration both take current and latest as // arguments .pickBy(_.spread(isUpgradeable)) .mapValues(_.spread(upgradeDep)) .value(); } // Determines if the given version (range) should be upgraded to the latest (i.e. it is valid, it does not currently /** * Return true if the version satisfies the range. * @type {function} * @param {string} version * @param {string} range * @returns {boolean} */ const isSatisfied = semver.satisfies; /** * Satisfy the latest, and it is not beyond the latest). * @param {string} current * @param {string} latest * @returns {boolean} */ function isUpgradeable(current, latest) { // do not upgrade non-npm version declarations (such as git tags) // do not upgrade versionUtil.wildcards if (!semver.validRange(current) || versionUtil.isWildCard(current)) { return false; } // remove the constraint (e.g. ^1.0.1 -> 1.0.1) to allow upgrades that satisfy the range, but are out of date const [range] = semverutils.parseRange(current); if (!range) { throw new Error(`"${current}" could not be parsed by semver-utils. This is probably a bug. Please file an issue at https://github.com/tjunnone/npm-check-updates.`); } const version = versionUtil.stringify(range); // make sure it is a valid range // not upgradeable if the latest version satisfies the current range // not upgradeable if the specified version is newer than the latest (indicating a prerelease version) return Boolean(semver.validRange(version)) && !isSatisfied(latest, version) && !semver.ltr(latest, version); } /** * @typedef {string|string[]|RegExp} FilterObject */ /** * Creates a filter function from a given filter string. Supports * strings, comma-or-space-delimited lists, and regexes. * @param {FilterObject} [filter] * @returns {function} */ function packageNameFilter(filter) { let filterPackages; // no filter if (!filter) { filterPackages = _.identity; } else if (typeof filter === 'string') { // RegExp filter if (filter[0] === '/' && cint.index(filter,-1) === '/') { const regexp = new RegExp(filter.slice(1, filter.length-1)); filterPackages = regexp.test.bind(regexp); } else { // string filter const packages = filter.split(/[\s,]+/); filterPackages = packages.includes.bind(packages); } } else if (Array.isArray(filter)) { // array filter filterPackages = filter.includes.bind(filter); } else if (filter instanceof RegExp) { // raw RegExp filterPackages = filter.test.bind(filter); } else { throw new Error('Invalid packages filter. Must be a RegExp, array, or comma-or-space-delimited list.'); } // (limit the arity to 1 to avoid passing the value) return cint.aritize(filterPackages, 1); } /** * Creates a single filter function from an optional filter and optional reject. * @param {FilterObject} [filter] * @param {FilterObject} [reject] * @returns {function} */ function filterAndReject(filter, reject) { return and( filter ? packageNameFilter(filter) : _.identity, reject ? _.negate(packageNameFilter(reject)) : _.identity ); } /** * Returns an 2-tuple of upgradedDependencies and their latest versions. * @param {Object} currentDependencies * @param {Object} options * @returns {Array} */ function upgradePackageDefinitions(currentDependencies, options) { const versionTarget = getVersionTarget(options); return queryVersions(currentDependencies, { versionTarget, registry: options.registry ? options.registry : null, pre: options.pre, packageManager: options.packageManager, json: options.json, loglevel: options.loglevel, enginesNode: options.enginesNode }).then(latestVersions => { const upgradedDependencies = upgradeDependencies(currentDependencies, latestVersions, { removeRange: options.removeRange }); const filteredUpgradedDependencies = _.pickBy(upgradedDependencies, (v, dep) => { return !options.jsonUpgraded || !options.minimal || !isSatisfied(latestVersions[dep], currentDependencies[dep]); }); return [filteredUpgradedDependencies, latestVersions]; }); } /** * Upgrade the dependency declarations in the package data. * @param {string} pkgData The package.json data, as utf8 text * @param {Object} oldDependencies Old dependencies {package: range} * @param {Object} newDependencies New dependencies {package: range} * @param {Object} newVersions New versions {package: version} * @param {Object} [options={}] * @returns {string} The updated package data, as utf8 text * @sideeffect prompts */ async function upgradePackageData(pkgData, oldDependencies, newDependencies, newVersions, options = {}) { // copy newDependencies for mutation via interactive mode const selectedNewDependencies = Object.assign({}, newDependencies); let newPkgData = pkgData; for (const dependency in newDependencies) { if (!options.minimal || !isSatisfied(newVersions[dependency], oldDependencies[dependency])) { if (options.interactive) { const to = versionUtil.colorizeDiff(oldDependencies[dependency], newDependencies[dependency] || ''); const response = await prompts({ type: 'confirm', name: 'value', message: `Do you want to upgrade: ${dependency} ${oldDependencies[dependency]} → ${to}?`, initial: true }); if (!response.value) { // continue loop to next dependency and skip updating newPkgData delete selectedNewDependencies[dependency]; continue; } } const expression = `"${dependency}"\\s*:\\s*"${escapeRegexp(`${oldDependencies[dependency]}"`)}`; const regExp = new RegExp(expression, 'g'); newPkgData = newPkgData.replace(regExp, `"${dependency}": "${newDependencies[dependency]}"`); } } return {newPkgData, selectedNewDependencies}; } /** * Get the current dependencies from the package file. * @param {Object} [pkgData={}] Object with dependencies, devDependencies, peerDependencies, optionalDependencies, and/or bundleDependencies properties * @param {Object} [options={}] * @param {string} options.dep * @param options.filter * @param options.reject * @returns {Promise} Promised {packageName: version} collection */ function getCurrentDependencies(pkgData = {}, options = {}) { if (options.dep) { const deps = (options.dep || '').split(','); options.prod = deps.includes('prod'); options.dev = deps.includes('dev'); options.peer = deps.includes('peer'); options.optional = deps.includes('optional'); options.bundle = deps.includes('bundle'); } else { options.prod = options.dev = options.peer = options.optional = options.bundle = true; } const allDependencies = cint.filterObject(_.merge({}, options.prod && pkgData.dependencies, options.dev && pkgData.devDependencies, options.peer && pkgData.peerDependencies, options.optional && pkgData.optionalDependencies, options.bundle && pkgData.bundleDependencies ), filterAndReject(options.filter, options.reject)); return allDependencies; } /** * @param [options] * @param options.cwd * @param options.filter * @param options.global * @param options.packageManager * @param options.prefix * @param options.reject */ function getInstalledPackages(options = {}) { return getPackageManager(options.packageManager).list({cwd: options.cwd, prefix: options.prefix, global: options.global}).then(pkgInfoObj => { if (!pkgInfoObj) { throw new Error('Unable to retrieve NPM package list'); } // filter out undefined packages or those with a wildcard const filterFunction = filterAndReject(options.filter, options.reject); return cint.filterObject(pkgInfoObj, (dep, version) => version && !versionUtil.isWildPart(version) && filterFunction(dep) ); }); } /** * Get the latest or greatest versions from the NPM repository based on the version target. * @param {Object} packageMap An object whose keys are package name and values are current versions * @param {Object} [options={}] Options. Default: { versionTarget: 'latest' }. You may also specify { versionTarget: 'greatest' } * @returns {Promise} Promised {packageName: version} collection */ function queryVersions(packageMap, options = {}) { let getPackageVersion; const packageList = Object.keys(packageMap); const packageManager = getPackageManager(options.packageManager); // validate options.versionTarget options.versionTarget = options.versionTarget || 'latest'; let bar; if (!options.json && options.loglevel !== 'silent' && packageList.length > 0) { bar = new ProgressBar('[:bar] :current/:total :percent', {total: packageList.length, width: 20}); bar.render(); } // determine the getPackageVersion function from options.versionTarget switch (options.versionTarget) { case 'latest': { getPackageVersion = packageManager.latest; break; } case 'greatest': { getPackageVersion = packageManager.greatest; break; } case 'newest': { getPackageVersion = packageManager.newest; break; } case 'major': { getPackageVersion = packageManager.greatestMajor; break; } case 'minor': { getPackageVersion = packageManager.greatestMinor; break; } default: { const supportedVersionTargets = ['latest', 'newest', 'greatest', 'major', 'minor']; return Promise.reject(new Error(`Unsupported versionTarget: ${options.versionTarget}. Supported version targets are: ${supportedVersionTargets.join(', ')}`)); } } /** * Ignore 404 errors from getPackageVersion by having them return `null` * instead of rejecting. * @param dep * @returns {Promise} */ function getPackageVersionProtected(dep) { return getPackageVersion(dep, packageMap[dep], Object.assign( {}, options, // upgrade prereleases to newer prereleases by default { pre: options.pre != null ? options.pre : versionUtil.isPre(packageMap[dep]) } )).catch(err => { if (err && (err.message || err).toString().match(/E404|ENOTFOUND|404 Not Found/i)) { return null; } else { throw err; } }).then(result => { if (bar) { bar.tick(); } return result; }); } /** * Zip up the array of versions into to a nicer object keyed by package name. * @param {Array} versionList * @returns {Object} */ function zipVersions(versionList) { return cint.toObject(versionList, (version, i) => { return cint.keyValue(packageList[i], version); }); } return Promise.all(packageList.map(getPackageVersionProtected)) .then(zipVersions) .then(_.partialRight(_.pickBy, _.identity)); } /** * * @param {Object} dependencies A dependencies collection * @returns {string|null} Returns whether the user prefers ^, ~, .*, or .x * (simply counts the greatest number of occurrences) or `null` if * given no dependencies. */ function getPreferredWildcard(dependencies) { // if there are no dependencies, return null. if (Object.keys(dependencies).length === 0) { return null; } // group the dependencies by wildcard const groups = _.groupBy(Object.values(dependencies), dep => versionUtil.WILDCARDS.find(wildcard => dep && dep.includes(wildcard) ) ); delete groups.undefined; // convert to an array of objects that can be sorted const arrOfGroups = cint.toArray(groups, (wildcard, instances) => ({ wildcard, instances })); // reverse sort the groups so that the wildcard with the most appearances is at the head, then return it. const sorted = _.sortBy(arrOfGroups, wildcardObject => -wildcardObject.instances.length); return sorted.length > 0 ? sorted[0].wildcard : null; } /** * * @param {Object} options * @returns {string} */ function getVersionTarget(options) { return options.semverLevel || (options.newest ? 'newest' : options.greatest ? 'greatest' : 'latest'); } /** * Initialize the version manager with the given package manager. * @param {string|Object} packageManagerNameOrObject * @param packageManagerNameOrObject.global * @param packageManagerNameOrObject.packageManager * @returns {Object} */ function getPackageManager(packageManagerNameOrObject) { /** Get one of the preset package managers or throw an error if there is no match. */ function getPresetPackageManager(packageManagerName) { if (!(packageManagerName in packageManagers)) { throw new Error(`Invalid package manager: ${packageManagerName}`); } return packageManagers[packageManagerName]; } return !packageManagerNameOrObject ? packageManagers.npm : // default to npm // use present package manager if name is specified typeof packageManagerNameOrObject === 'string' ? getPresetPackageManager(packageManagerNameOrObject) : // use provided package manager object otherwise packageManagerNameOrObject; } // // Helper functions // /** * @param {string} s * @returns {string} String safe for use in `new RegExp()` */ function escapeRegexp(s) { return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); // Thanks Stack Overflow! } // // API // module.exports = { // used directly by npm-check-updates.js getCurrentDependencies, getInstalledPackages, getVersionTarget, isSatisfied, upgradePackageData, upgradePackageDefinitions, // exposed for testing getPreferredWildcard, isUpgradeable, queryVersions, upgradeDependencies, upgradeDependencyDeclaration };