diff --git a/docs/content/cli-commands/npm-explain.md b/docs/content/cli-commands/npm-explain.md new file mode 100644 index 0000000000000..e4c73529b06f2 --- /dev/null +++ b/docs/content/cli-commands/npm-explain.md @@ -0,0 +1,77 @@ +--- +section: cli-commands +title: npm-explain +description: Explain installed packages +--- + +# npm-explain(1) + +## Explain installed packages + +### Synopsis + +```bash +npm explain +``` + +### Description + +This command will print the chain of dependencies causing a given package +to be installed in the current project. + +Positional arguments can be either folders within `node_modules`, or +`name@version-range` specifiers, which will select the dependency +relationships to explain. + +For example, running `npm explain glob` within npm's source tree will show: + +```bash +glob@7.1.6 +node_modules/glob + glob@"^7.1.4" from the root project + +glob@7.1.1 dev +node_modules/tacks/node_modules/glob + glob@"^7.0.5" from rimraf@2.6.2 + node_modules/tacks/node_modules/rimraf + rimraf@"^2.6.2" from tacks@1.3.0 + node_modules/tacks + dev tacks@"^1.3.0" from the root project +``` + +To explain just the package residing at a specific folder, pass that as the +argument to the command. This can be useful when trying to figure out +exactly why a given dependency is being duplicated to satisfy conflicting +version requirements within the project. + +```bash +$ npm explain node_modules/nyc/node_modules/find-up +find-up@3.0.0 dev +node_modules/nyc/node_modules/find-up + find-up@"^3.0.0" from nyc@14.1.1 + node_modules/nyc + nyc@"^14.1.1" from tap@14.10.8 + node_modules/tap + dev tap@"^14.10.8" from the root project +``` + +### Configuration + +#### json + +* Default: false +* Type: Bolean + +Show information in JSON format. + +### See Also + +* [npm config](/cli-commands/config) +* [npmrc](/configuring-npm/npmrc) +* [npm folders](/configuring-npm/folders) +* [npm ls](/cli-commands/ls) +* [npm install](/cli-commands/install) +* [npm link](/cli-commands/link) +* [npm prune](/cli-commands/prune) +* [npm outdated](/cli-commands/outdated) +* [npm update](/cli-commands/update) diff --git a/docs/content/cli-commands/npm-ls.md b/docs/content/cli-commands/npm-ls.md index 797f15a50d849..a3a0541a6c7c4 100644 --- a/docs/content/cli-commands/npm-ls.md +++ b/docs/content/cli-commands/npm-ls.md @@ -1,5 +1,5 @@ --- -section: cli-commands +section: cli-commands title: npm-ls description: List installed packages --- @@ -122,6 +122,7 @@ Set it to false in order to use all-ansi output. * [npm config](/cli-commands/config) * [npmrc](/configuring-npm/npmrc) * [npm folders](/configuring-npm/folders) +* [npm explain](/cli-commands/explain) * [npm install](/cli-commands/install) * [npm link](/cli-commands/link) * [npm prune](/cli-commands/prune) diff --git a/lib/explain.js b/lib/explain.js new file mode 100644 index 0000000000000..46f0d0c982995 --- /dev/null +++ b/lib/explain.js @@ -0,0 +1,100 @@ +const usageUtil = require('./utils/usage.js') +const npm = require('./npm.js') +const { explainNode } = require('./utils/explain-dep.js') +const completion = require('./utils/completion/installed-deep.js') +const output = require('./utils/output.js') +const Arborist = require('@npmcli/arborist') +const npa = require('npm-package-arg') +const semver = require('semver') +const { relative, resolve } = require('path') +const validName = require('validate-npm-package-name') + +const usage = usageUtil('explain', 'npm explain ') + +const cmd = (args, cb) => explain(args).then(() => cb()).catch(cb) + +const explain = async (args) => { + if (!args.length) { + throw usage + } + + const arb = new Arborist({ path: npm.prefix, ...npm.flatOptions }) + const tree = await arb.loadActual() + + const nodes = new Set() + for (const arg of args) { + for (const node of getNodes(tree, arg)) { + nodes.add(node) + } + } + if (nodes.size === 0) { + throw `No dependencies found matching ${args.join(', ')}` + } + + const expls = [] + for (const node of nodes) { + const { extraneous, dev, optional, devOptional, peer } = node + const expl = node.explain() + if (extraneous) { + expl.extraneous = true + } else { + expl.dev = dev + expl.optional = optional + expl.devOptional = devOptional + expl.peer = peer + } + expls.push(expl) + } + + if (npm.flatOptions.json) { + output(JSON.stringify(expls, null, 2)) + } else { + output(expls.map(expl => { + return explainNode(expl, Infinity, npm.color) + }).join('\n\n')) + } +} + +const getNodes = (tree, arg) => { + // if it's just a name, return packages by that name + const { validForOldPackages: valid } = validName(arg) + if (valid) { + return tree.inventory.query('name', arg) + } + + // if it's a location, get that node + const maybeLoc = arg.replace(/\\/g, '/').replace(/\/+$/, '') + const nodeByLoc = tree.inventory.get(maybeLoc) + if (nodeByLoc) { + return [nodeByLoc] + } + + // maybe a path to a node_modules folder + const maybePath = relative(npm.prefix, resolve(maybeLoc)) + .replace(/\\/g, '/').replace(/\/+$/, '') + const nodeByPath = tree.inventory.get(maybePath) + if (nodeByPath) { + return [nodeByPath] + } + + // otherwise, try to select all matching nodes + try { + return getNodesByVersion(tree, arg) + } catch (er) { + return [] + } +} + +const getNodesByVersion = (tree, arg) => { + const spec = npa(arg, npm.prefix) + if (spec.type !== 'version' && spec.type !== 'range') { + return [] + } + + return tree.inventory.filter(node => { + return node.package.name === spec.name && + semver.satisfies(node.package.version, spec.rawSpec) + }) +} + +module.exports = Object.assign(cmd, { usage, completion }) diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index 85e456a835805..6328d80d5c1a0 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -20,7 +20,8 @@ const shorthands = { run: 'run-script', 'clean-install': 'ci', 'clean-install-test': 'cit', - x: 'exec' + x: 'exec', + why: 'explain' } const affordances = { @@ -128,7 +129,8 @@ const cmdList = [ 'run-script', 'completion', 'doctor', - 'exec' + 'exec', + 'explain' ] const plumbing = ['birthday', 'help-search'] diff --git a/lib/utils/explain-dep.js b/lib/utils/explain-dep.js index 773cc59425b15..facab373baad6 100644 --- a/lib/utils/explain-dep.js +++ b/lib/utils/explain-dep.js @@ -63,7 +63,7 @@ const explainDependents = ({ name, dependents }, depth, color) => { // show just the names of the first 5 deps that overflowed the list if (dependents.length > max) { let len = 0 - const maxLen = 30 + const maxLen = 50 const showNames = [] for (let i = max; i < dependents.length; i++) { const { from: { name } } = dependents[i] diff --git a/tap-snapshots/test-lib-utils-cmd-list.js-TAP.test.js b/tap-snapshots/test-lib-utils-cmd-list.js-TAP.test.js index 1b0118d262d1e..c77da6b18317d 100644 --- a/tap-snapshots/test-lib-utils-cmd-list.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-cmd-list.js-TAP.test.js @@ -100,6 +100,7 @@ Object { "urn": "run-script", "v": "view", "verison": "version", + "why": "explain", "x": "exec", }, "cmdList": Array [ @@ -164,6 +165,7 @@ Object { "completion", "doctor", "exec", + "explain", ], "plumbing": Array [ "birthday", @@ -190,6 +192,7 @@ Object { "unstar": "star", "up": "update", "v": "view", + "why": "explain", "x": "exec", }, } diff --git a/tap-snapshots/test-lib-utils-explain-eresolve.js-TAP.test.js b/tap-snapshots/test-lib-utils-explain-eresolve.js-TAP.test.js index 9baadbe6e21a5..716d82ced382b 100644 --- a/tap-snapshots/test-lib-utils-explain-eresolve.js-TAP.test.js +++ b/tap-snapshots/test-lib-utils-explain-eresolve.js-TAP.test.js @@ -170,7 +170,7 @@ Found: react@16.13.1 peer react@"^16.4.2" from gatsby@2.24.53 node_modules/gatsby gatsby@"" from the root project - 26 more (react-dom, @reach/router, gatsby-cli, ...) + 26 more (react-dom, @reach/router, gatsby-cli, gatsby-link, ...) Could not add conflicting dependency: react@16.8.1 node_modules/react @@ -734,7 +734,7 @@ Found: react@16.13.1 peer react@"^16.4.2" from gatsby@2.24.53 node_modules/gatsby gatsby@"" from the root project - 26 more (react-dom, @reach/router, gatsby-cli, ...) + 26 more (react-dom, @reach/router, gatsby-cli, gatsby-link, ...) Could not add conflicting dependency: react@16.8.1 node_modules/react diff --git a/test/lib/explain.js b/test/lib/explain.js new file mode 100644 index 0000000000000..a9db344f8b20c --- /dev/null +++ b/test/lib/explain.js @@ -0,0 +1,177 @@ +const t = require('tap') +const requireInject = require('require-inject') +const npm = { + prefix: null, + color: true, + flatOptions: {} +} +const { resolve } = require('path') + +const OUTPUT = [] + +const explain = requireInject('../../lib/explain.js', { + '../../lib/npm.js': npm, + + '../../lib/utils/output.js': (...args) => { + OUTPUT.push(args) + }, + + // keep the snapshots pared down a bit, since this has its own tests. + '../../lib/utils/explain-dep.js': { + explainNode: (expl, depth, color) => { + return `${expl.name}@${expl.version} depth=${depth} color=${color}` + } + } +}) + +t.test('no args throws usage', async t => { + t.plan(1) + try { + await explain([], er => { + throw er + }) + } catch (er) { + t.equal(er, explain.usage) + } +}) + +t.test('no match throws not found', async t => { + npm.prefix = t.testdir() + t.plan(1) + try { + await explain(['foo@1.2.3', 'node_modules/baz'], er => { + throw er + }) + } catch (er) { + t.equal(er, 'No dependencies found matching foo@1.2.3, node_modules/baz') + } +}) + +t.test('invalid package name throws not found', async t => { + npm.prefix = t.testdir() + t.plan(1) + const badName = ' not a valid package name ' + try { + await explain([`${badName}@1.2.3`], er => { + throw er + }) + } catch (er) { + t.equal(er, `No dependencies found matching ${badName}@1.2.3`) + } +}) + +t.test('explain some nodes', async t => { + npm.prefix = t.testdir({ + node_modules: { + foo: { + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.2.3', + dependencies: { + bar: '*' + } + }) + }, + bar: { + 'package.json': JSON.stringify({ + name: 'bar', + version: '1.2.3' + }) + }, + baz: { + 'package.json': JSON.stringify({ + name: 'baz', + version: '1.2.3', + dependencies: { + foo: '*', + bar: '2' + } + }), + node_modules: { + bar: { + 'package.json': JSON.stringify({ + name: 'bar', + version: '2.3.4' + }) + }, + extra: { + 'package.json': JSON.stringify({ + name: 'extra', + version: '99.9999.999999', + description: 'extraneous package' + }) + } + } + } + }, + 'package.json': JSON.stringify({ + dependencies: { + baz: '1' + } + }) + }) + + // works with either a full actual path or the location + const p = 'node_modules/foo' + for (const path of [p, resolve(npm.prefix, p)]) { + await explain([path], er => { + if (er) { + throw er + } + }) + t.strictSame(OUTPUT, [['foo@1.2.3 depth=Infinity color=true']]) + OUTPUT.length = 0 + } + + // finds all nodes by name + await explain(['bar'], er => { + if (er) { + throw er + } + }) + t.strictSame(OUTPUT, [[ + 'bar@1.2.3 depth=Infinity color=true\n\n' + + 'bar@2.3.4 depth=Infinity color=true' + ]]) + OUTPUT.length = 0 + + // finds only nodes that match the spec + await explain(['bar@1'], er => { + if (er) { + throw er + } + }) + t.strictSame(OUTPUT, [['bar@1.2.3 depth=Infinity color=true']]) + OUTPUT.length = 0 + + // finds extraneous nodes + await explain(['extra'], er => { + if (er) { + throw er + } + }) + t.strictSame(OUTPUT, [['extra@99.9999.999999 depth=Infinity color=true']]) + OUTPUT.length = 0 + + npm.flatOptions.json = true + await explain(['node_modules/foo'], er => { + if (er) { + throw er + } + }) + t.match(JSON.parse(OUTPUT[0][0]), [{ + name: 'foo', + version: '1.2.3', + dependents: Array + }]) + OUTPUT.length = 0 + npm.flatOptions.json = false + + t.test('report if no nodes found', async t => { + t.plan(1) + await explain(['asdf/foo/bar', 'quux@1.x'], er => { + t.equal(er, 'No dependencies found matching asdf/foo/bar, quux@1.x') + }) + }) +}) +