diff --git a/README.md b/README.md index 62cd64f9..b0ed36c7 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ co(function* () { - [x] `save`, `save-dev`, `save-optional` - [x] support `ignore-scripts` - [x] uninstall +- [x] resolutions ## Different with NPM @@ -255,6 +256,10 @@ root/ both the same version: 1.1.2 ``` +## Resolutions + +support [selective version resolutions](https://yarnpkg.com/en/docs/selective-version-resolutions) like yarn. which lets you define custom package versions inside your dependencies through the resolutions field in your `package.json` file. + ## Benchmarks ### cnpmjs.org install diff --git a/lib/install.js b/lib/install.js index f4c2f2ea..986284cc 100644 --- a/lib/install.js +++ b/lib/install.js @@ -50,6 +50,8 @@ function* _install(parentDir, pkg, ancestors, options) { pkg.version = '*'; } + pkg = options.resolution(pkg, ancestors); + debug('[%s/%s] install %s@%s in %s', options.progresses.finishedInstallTasks, options.progresses.installTasks, @@ -58,7 +60,7 @@ function* _install(parentDir, pkg, ancestors, options) { options.spinner.text = `[${options.progresses.finishedInstallTasks}/${options.progresses.installTasks}] Installing ${pkg.name}@${pkg.version}`; } let p = npa(pkg.name ? `${pkg.name}@${pkg.version}` : pkg.version); - const displayName = p.displayName = getDisplayName(pkg, ancestors); + const displayName = p.displayName = utils.getDisplayName(pkg, ancestors); if (options.registryOnly && REMOTE_TYPES[p.type]) { throw new Error(`Only allow install package from registry, but "${displayName}" is ${p.type}`); @@ -325,13 +327,6 @@ function* bundleBin(name, parentDir, options) { yield bin(parentDir, pkg, pkgDir, options); } -function getDisplayName(pkg, ancestors) { - return ancestors - .map(ancestor => ancestor.displayName || ancestor) - .concat([ `${pkg.name}@${pkg.version}` ]) - .join(chalk.gray(' › ')); -} - function* matchAncestorDependencies(childPkg, ancestors, options) { // only need check npm types if (!fromNpm(childPkg.type)) return; diff --git a/lib/local_install.js b/lib/local_install.js index 828cac9d..ca80efc6 100644 --- a/lib/local_install.js +++ b/lib/local_install.js @@ -22,6 +22,7 @@ const preinstall = require('./preinstall'); const prepublish = require('./prepublish'); const install = require('./install'); const dependencies = require('./dependencies'); +const createResolution = require('./resolution'); const formatInstallOptions = require('./format_install_options'); const link = require('./link'); const bin = require('./bin'); @@ -109,6 +110,7 @@ function* _install(options) { let pkgs = options.pkgs; const rootPkgDependencies = dependencies(rootPkg, options); options.rootPkgDependencies = rootPkgDependencies; + options.resolution = createResolution(rootPkg, options); if (pkgs.length === 0) { if (options.production) { pkgs = rootPkgDependencies.prod; diff --git a/lib/resolution.js b/lib/resolution.js new file mode 100644 index 00000000..b3267274 --- /dev/null +++ b/lib/resolution.js @@ -0,0 +1,66 @@ +'use strict'; + +const minimatch = require('minimatch'); +const npa = require('npm-package-arg'); +const utils = require('./utils'); +const chalk = require('chalk'); + +module.exports = (pkg, options) => { + const resolutions = pkg && pkg.resolutions || {}; + const resolutionMap = new Map(); + + // parse resolutions, generate resolutionMap: + // { + // debug: [ + // "koa/accept", "1.0.0", + // "send": "2.0.0" + // ], + // less: [ + // "**", "^1" + // ], + // } + for (const path in resolutions) { + const sections = path.split('/'); + // debug => **/debug + if (sections.length === 1) sections.unshift('**'); + // check + for (const section of sections) { + if (section !== '**' && !npa(section).name) { + throw new Error(`[resolutions] resolution package ${path} format error`); + } + } + const endpoint = sections.pop(); + if (endpoint === '**') throw new Error(`[resolutions] resolution package ${path} format error`); + + const version = resolutions[path]; + + if (!resolutionMap.has(endpoint)) resolutionMap.set(endpoint, []); + resolutionMap.get(endpoint).push([ sections.join('/'), version ]); + } + + return (pkg, ancestors) => { + // only work for nested dependencies + if (!ancestors.length) return pkg; + // check pkg.name first to reduce calculate + const resolutions = resolutionMap.get(pkg.name); + if (!resolutions) return pkg; + + const ancestorPath = ancestors.map(ancestor => ancestor.name).join('/'); + for (const resolution of resolutions) { + const path = resolution[0]; + const version = resolution[1]; + if (minimatch(ancestorPath, path)) { + options.pendingMessages.push([ + 'warn', + '%s %s override by %s', + chalk.yellow('resolutions'), + chalk.gray(utils.getDisplayName(pkg, ancestors)), + chalk.magenta(`${path}/${pkg.name}@${version}`), + ]); + return Object.assign({}, pkg, { version }); + } + } + + return pkg; + }; +}; diff --git a/lib/utils.js b/lib/utils.js index 1c03d587..1d047ab9 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -552,3 +552,10 @@ exports.getGlobalInstallMeta = prefix => { }; exports.endsWithX = version => typeof version === 'string' && !!version.match(/^\d+\.(x|\d+\.x)$/); + +exports.getDisplayName = (pkg, ancestors) => { + return ancestors + .map(ancestor => ancestor.displayName || ancestor) + .concat([ `${pkg.name}@${pkg.version}` ]) + .join(' › '); +}; diff --git a/package.json b/package.json index e142eb08..11ba02a6 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "co-parallel": "^1.0.0", "debug": "^2.6.8", "destroy": "^1.0.4", + "minimatch": "^3.0.4", "minimist": "^1.2.0", "mkdirp": "^0.5.1", "moment": "^2.18.1", diff --git a/test/fixtures/resolutions-error-endpoint/package.json b/test/fixtures/resolutions-error-endpoint/package.json new file mode 100644 index 00000000..44538a46 --- /dev/null +++ b/test/fixtures/resolutions-error-endpoint/package.json @@ -0,0 +1,9 @@ +{ + "name": "resolution-error-endpoint", + "dependencies": { + "koa": "^1" + }, + "resolutions": { + "foo/bar-*": "1.0.0" + } +} diff --git a/test/fixtures/resolutions-error-format/package.json b/test/fixtures/resolutions-error-format/package.json new file mode 100644 index 00000000..d9b460a1 --- /dev/null +++ b/test/fixtures/resolutions-error-format/package.json @@ -0,0 +1,9 @@ +{ + "name": "resolution-error-endpoint", + "dependencies": { + "koa": "^1" + }, + "resolutions": { + "foo/**": "1.0.0" + } +} diff --git a/test/fixtures/resolutions/package.json b/test/fixtures/resolutions/package.json new file mode 100644 index 00000000..6c006c84 --- /dev/null +++ b/test/fixtures/resolutions/package.json @@ -0,0 +1,13 @@ +{ + "name": "resolutions", + "dependencies": { + "koa": "2", + "debug": "2.0.0", + "cookies": "0.7.1" + }, + "resolutions": { + "debug": "1.0.0", + "**/http-errors": "1.0.0", + "koa/cookies": "0.7.0" + } +} diff --git a/test/resolutions.test.js b/test/resolutions.test.js new file mode 100644 index 00000000..c15d98da --- /dev/null +++ b/test/resolutions.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const assert = require('assert'); +const rimraf = require('rimraf'); +const path = require('path'); +const readJSON = require('../lib/utils').readJSON; +const coffee = require('coffee'); + +const bin = path.join(__dirname, '../bin/install.js'); + +let root; +function cleanup() { + rimraf.sync(path.join(root, 'node_modules')); +} + +function* checkPkg(name, version) { + const pkg = yield readJSON(path.join(root, 'node_modules', name, 'package.json')); + assert.equal(pkg.version, version); +} + +describe('test/seperate-dependencies.test.js', () => { + describe('resolutions-error-format', () => { + before(() => { + root = path.join(__dirname, 'fixtures', 'resolutions-error-format'); + cleanup(); + }); + afterEach(cleanup); + + it('should install error', () => { + return coffee.fork(bin, { + cwd: root, + }) + // .debug() + .expect('code', 1) + .expect('stderr', /resolution package foo\/\*\* format error/) + .end(); + }); + }); + + describe('resolutions-error-endpoint', () => { + before(() => { + root = path.join(__dirname, 'fixtures', 'resolutions-error-endpoint'); + cleanup(); + }); + afterEach(cleanup); + + it('should install error', () => { + return coffee.fork(bin, { + cwd: root, + }) + // .debug() + .expect('code', 1) + .expect('stderr', /resolution package foo\/bar-\* format error/) + .end(); + }); + }); + + describe('resolutions', () => { + before(() => { + root = path.join(__dirname, 'fixtures', 'resolutions'); + cleanup(); + }); + afterEach(cleanup); + + it('should work', function* () { + yield coffee.fork(bin, { + cwd: root, + }) + .debug() + .expect('code', 0) + .end(); + + yield checkPkg('debug', '2.0.0'); + yield checkPkg('koa/node_modules/debug', '1.0.0'); + yield checkPkg('koa/node_modules/cookies', '0.7.0'); + yield checkPkg('cookies', '0.7.1'); + + }); + }); +});