Skip to content

Commit

Permalink
Add 'npm explain' command
Browse files Browse the repository at this point in the history
Pass a specifier or folder path, and it'll explain what that dependency
is doing there.

PR-URL: #1776
Credit: @isaacs
Close: #1776
Reviewed-by: @ruyadorno
  • Loading branch information
isaacs committed Sep 8, 2020
1 parent 7418970 commit 5e49bda
Show file tree
Hide file tree
Showing 8 changed files with 366 additions and 6 deletions.
77 changes: 77 additions & 0 deletions docs/content/cli-commands/npm-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
section: cli-commands
title: npm-explain
description: Explain installed packages
---

# npm-explain(1)

## Explain installed packages

### Synopsis

```bash
npm explain <folder | specifier>
```

### 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)
3 changes: 2 additions & 1 deletion docs/content/cli-commands/npm-ls.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
section: cli-commands
section: cli-commands
title: npm-ls
description: List installed packages
---
Expand Down Expand Up @@ -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)
Expand Down
100 changes: 100 additions & 0 deletions lib/explain.js
Original file line number Diff line number Diff line change
@@ -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 <folder | specifier>')

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 })
6 changes: 4 additions & 2 deletions lib/utils/cmd-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const shorthands = {
run: 'run-script',
'clean-install': 'ci',
'clean-install-test': 'cit',
x: 'exec'
x: 'exec',
why: 'explain'
}

const affordances = {
Expand Down Expand Up @@ -128,7 +129,8 @@ const cmdList = [
'run-script',
'completion',
'doctor',
'exec'
'exec',
'explain'
]

const plumbing = ['birthday', 'help-search']
Expand Down
2 changes: 1 addition & 1 deletion lib/utils/explain-dep.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions tap-snapshots/test-lib-utils-cmd-list.js-TAP.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Object {
"urn": "run-script",
"v": "view",
"verison": "version",
"why": "explain",
"x": "exec",
},
"cmdList": Array [
Expand Down Expand Up @@ -164,6 +165,7 @@ Object {
"completion",
"doctor",
"exec",
"explain",
],
"plumbing": Array [
"birthday",
Expand All @@ -190,6 +192,7 @@ Object {
"unstar": "star",
"up": "update",
"v": "view",
"why": "explain",
"x": "exec",
},
}
Expand Down
4 changes: 2 additions & 2 deletions tap-snapshots/test-lib-utils-explain-eresolve.js-TAP.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 5e49bda

Please sign in to comment.