• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const { resolve, relative, sep } = require('path')
2const relativePrefix = `.${sep}`
3const { EOL } = require('os')
4
5const archy = require('archy')
6const { breadth } = require('treeverse')
7const npa = require('npm-package-arg')
8
9const _depth = Symbol('depth')
10const _dedupe = Symbol('dedupe')
11const _filteredBy = Symbol('filteredBy')
12const _include = Symbol('include')
13const _invalid = Symbol('invalid')
14const _name = Symbol('name')
15const _missing = Symbol('missing')
16const _parent = Symbol('parent')
17const _problems = Symbol('problems')
18const _required = Symbol('required')
19const _type = Symbol('type')
20const ArboristWorkspaceCmd = require('../arborist-cmd.js')
21const localeCompare = require('@isaacs/string-locale-compare')('en')
22
23class LS extends ArboristWorkspaceCmd {
24  static description = 'List installed packages'
25  static name = 'ls'
26  static usage = ['<package-spec>']
27  static params = [
28    'all',
29    'json',
30    'long',
31    'parseable',
32    'global',
33    'depth',
34    'omit',
35    'link',
36    'package-lock-only',
37    'unicode',
38    ...super.params,
39  ]
40
41  // TODO
42  /* istanbul ignore next */
43  static async completion (opts, npm) {
44    const completion = require('../utils/completion/installed-deep.js')
45    return completion(npm, opts)
46  }
47
48  async exec (args) {
49    const all = this.npm.config.get('all')
50    const chalk = this.npm.chalk
51    const depth = this.npm.config.get('depth')
52    const global = this.npm.global
53    const json = this.npm.config.get('json')
54    const link = this.npm.config.get('link')
55    const long = this.npm.config.get('long')
56    const omit = this.npm.flatOptions.omit
57    const parseable = this.npm.config.get('parseable')
58    const unicode = this.npm.config.get('unicode')
59    const packageLockOnly = this.npm.config.get('package-lock-only')
60    const workspacesEnabled = this.npm.flatOptions.workspacesEnabled
61
62    const path = global ? resolve(this.npm.globalDir, '..') : this.npm.prefix
63
64    const Arborist = require('@npmcli/arborist')
65
66    const arb = new Arborist({
67      global,
68      ...this.npm.flatOptions,
69      legacyPeerDeps: false,
70      path,
71    })
72    const tree = await this.initTree({ arb, args, packageLockOnly })
73
74    // filters by workspaces nodes when using -w <workspace-name>
75    // We only have to filter the first layer of edges, so we don't
76    // explore anything that isn't part of the selected workspace set.
77    let wsNodes
78    if (this.workspaceNames && this.workspaceNames.length) {
79      wsNodes = arb.workspaceNodes(tree, this.workspaceNames)
80    }
81    const filterBySelectedWorkspaces = edge => {
82      if (!workspacesEnabled
83        && edge.from.isProjectRoot
84        && edge.to.isWorkspace
85      ) {
86        return false
87      }
88
89      if (!wsNodes || !wsNodes.length) {
90        return true
91      }
92
93      if (this.npm.flatOptions.includeWorkspaceRoot
94          && edge.to && !edge.to.isWorkspace) {
95        return true
96      }
97
98      if (edge.from.isProjectRoot) {
99        return (edge.to
100          && edge.to.isWorkspace
101          && wsNodes.includes(edge.to.target))
102      }
103
104      return true
105    }
106
107    const seenItems = new Set()
108    const seenNodes = new Map()
109    const problems = new Set()
110
111    // defines special handling of printed depth when filtering with args
112    const filterDefaultDepth = depth === null ? Infinity : depth
113    const depthToPrint = (all || args.length)
114      ? filterDefaultDepth
115      : (depth || 0)
116
117    // add root node of tree to list of seenNodes
118    seenNodes.set(tree.path, tree)
119
120    // tree traversal happens here, using treeverse.breadth
121    const result = await breadth({
122      tree,
123      // recursive method, `node` is going to be the current elem (starting from
124      // the `tree` obj) that was just visited in the `visit` method below
125      // `nodeResult` is going to be the returned `item` from `visit`
126      getChildren (node, nodeResult) {
127        const seenPaths = new Set()
128        const workspace = node.isWorkspace
129        const currentDepth = workspace ? 0 : node[_depth]
130        const shouldSkipChildren =
131          !(node instanceof Arborist.Node) || (currentDepth > depthToPrint)
132        return (shouldSkipChildren)
133          ? []
134          : [...(node.target).edgesOut.values()]
135            .filter(filterBySelectedWorkspaces)
136            .filter(currentDepth === 0 ? filterByEdgesTypes({
137              link,
138              omit,
139            }) : () => true)
140            .map(mapEdgesToNodes({ seenPaths }))
141            .concat(appendExtraneousChildren({ node, seenPaths }))
142            .sort(sortAlphabetically)
143            .map(augmentNodesWithMetadata({
144              args,
145              currentDepth,
146              nodeResult,
147              seenNodes,
148            }))
149      },
150      // visit each `node` of the `tree`, returning an `item` - these are
151      // the elements that will be used to build the final output
152      visit (node) {
153        node[_problems] = getProblems(node, { global })
154
155        const item = json
156          ? getJsonOutputItem(node, { global, long })
157          : parseable
158            ? null
159            : getHumanOutputItem(node, { args, chalk, global, long })
160
161        // loop through list of node problems to add them to global list
162        if (node[_include]) {
163          for (const problem of node[_problems]) {
164            problems.add(problem)
165          }
166        }
167
168        seenItems.add(item)
169
170        // return a promise so we don't blow the stack
171        return Promise.resolve(item)
172      },
173    })
174
175    // handle the special case of a broken package.json in the root folder
176    const [rootError] = tree.errors.filter(e =>
177      e.code === 'EJSONPARSE' && e.path === resolve(path, 'package.json'))
178
179    this.npm.outputBuffer(
180      json ? jsonOutput({ path, problems, result, rootError, seenItems }) :
181      parseable ? parseableOutput({ seenNodes, global, long }) :
182      humanOutput({ chalk, result, seenItems, unicode })
183    )
184
185    // if filtering items, should exit with error code on no results
186    if (result && !result[_include] && args.length) {
187      process.exitCode = 1
188    }
189
190    if (rootError) {
191      throw Object.assign(
192        new Error('Failed to parse root package.json'),
193        { code: 'EJSONPARSE' }
194      )
195    }
196
197    const shouldThrow = problems.size &&
198      ![...problems].every(problem => problem.startsWith('extraneous:'))
199
200    if (shouldThrow) {
201      throw Object.assign(
202        new Error([...problems].join(EOL)),
203        { code: 'ELSPROBLEMS' }
204      )
205    }
206  }
207
208  async initTree ({ arb, args, packageLockOnly }) {
209    const tree = await (
210      packageLockOnly
211        ? arb.loadVirtual()
212        : arb.loadActual()
213    )
214
215    tree[_include] = args.length === 0
216    tree[_depth] = 0
217
218    return tree
219  }
220}
221module.exports = LS
222
223const isGitNode = (node) => {
224  if (!node.resolved) {
225    return
226  }
227
228  try {
229    const { type } = npa(node.resolved)
230    return type === 'git' || type === 'hosted'
231  } catch (err) {
232    return false
233  }
234}
235
236const isOptional = (node) =>
237  node[_type] === 'optional' || node[_type] === 'peerOptional'
238
239const isExtraneous = (node, { global }) =>
240  node.extraneous && !global
241
242const getProblems = (node, { global }) => {
243  const problems = new Set()
244
245  if (node[_missing] && !isOptional(node)) {
246    problems.add(`missing: ${node.pkgid}, required by ${node[_missing]}`)
247  }
248
249  if (node[_invalid]) {
250    problems.add(`invalid: ${node.pkgid} ${node.path}`)
251  }
252
253  if (isExtraneous(node, { global })) {
254    problems.add(`extraneous: ${node.pkgid} ${node.path}`)
255  }
256
257  return problems
258}
259
260// annotates _parent and _include metadata into the resulting
261// item obj allowing for filtering out results during output
262const augmentItemWithIncludeMetadata = (node, item) => {
263  item[_parent] = node[_parent]
264  item[_include] = node[_include]
265
266  // append current item to its parent.nodes which is the
267  // structure expected by archy in order to print tree
268  if (node[_include]) {
269    // includes all ancestors of included node
270    let p = node[_parent]
271    while (p) {
272      p[_include] = true
273      p = p[_parent]
274    }
275  }
276
277  return item
278}
279
280const getHumanOutputItem = (node, { args, chalk, global, long }) => {
281  const { pkgid, path } = node
282  const workspacePkgId = chalk.green(pkgid)
283  let printable = node.isWorkspace ? workspacePkgId : pkgid
284
285  // special formatting for top-level package name
286  if (node.isRoot) {
287    const hasNoPackageJson = !Object.keys(node.package).length
288    if (hasNoPackageJson || global) {
289      printable = path
290    } else {
291      printable += `${long ? EOL : ' '}${path}`
292    }
293  }
294
295  const highlightDepName = args.length && node[_filteredBy]
296  const missingColor = isOptional(node)
297    ? chalk.yellow.bgBlack
298    : chalk.red.bgBlack
299  const missingMsg = `UNMET ${isOptional(node) ? 'OPTIONAL ' : ''}DEPENDENCY`
300  const targetLocation = node.root
301    ? relative(node.root.realpath, node.realpath)
302    : node.targetLocation
303  const invalid = node[_invalid]
304    ? `invalid: ${node[_invalid]}`
305    : ''
306  const label =
307    (
308      node[_missing]
309        ? missingColor(missingMsg) + ' '
310        : ''
311    ) +
312    `${highlightDepName ? chalk.yellow.bgBlack(printable) : printable}` +
313    (
314      node[_dedupe]
315        ? ' ' + chalk.gray('deduped')
316        : ''
317    ) +
318    (
319      invalid
320        ? ' ' + chalk.red.bgBlack(invalid)
321        : ''
322    ) +
323    (
324      isExtraneous(node, { global })
325        ? ' ' + chalk.green.bgBlack('extraneous')
326        : ''
327    ) +
328    (
329      node.overridden
330        ? ' ' + chalk.gray('overridden')
331        : ''
332    ) +
333    (isGitNode(node) ? ` (${node.resolved})` : '') +
334    (node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
335    (long ? `${EOL}${node.package.description || ''}` : '')
336
337  return augmentItemWithIncludeMetadata(node, { label, nodes: [] })
338}
339
340const getJsonOutputItem = (node, { global, long }) => {
341  const item = {}
342
343  if (node.version) {
344    item.version = node.version
345  }
346
347  if (node.resolved) {
348    item.resolved = node.resolved
349  }
350
351  // if the node is the project root, do not add the overridden flag. the project root can't be
352  // overridden anyway, and if we add the flag it causes undesirable behavior when `npm ls --json`
353  // is ran in an empty directory since we end up printing an object with only an overridden prop
354  if (!node.isProjectRoot) {
355    item.overridden = node.overridden
356  }
357
358  item[_name] = node.name
359
360  // special formatting for top-level package name
361  const hasPackageJson =
362    node && node.package && Object.keys(node.package).length
363  if (node.isRoot && hasPackageJson) {
364    item.name = node.package.name || node.name
365  }
366
367  if (long && !node[_missing]) {
368    item.name = item[_name]
369    const { dependencies, ...packageInfo } = node.package
370    Object.assign(item, packageInfo)
371    item.extraneous = false
372    item.path = node.path
373    item._dependencies = {
374      ...node.package.dependencies,
375      ...node.package.optionalDependencies,
376    }
377    item.devDependencies = node.package.devDependencies || {}
378    item.peerDependencies = node.package.peerDependencies || {}
379  }
380
381  // augment json output items with extra metadata
382  if (isExtraneous(node, { global })) {
383    item.extraneous = true
384  }
385
386  if (node[_invalid]) {
387    item.invalid = node[_invalid]
388  }
389
390  if (node[_missing] && !isOptional(node)) {
391    item.required = node[_required]
392    item.missing = true
393  }
394  if (node[_include] && node[_problems] && node[_problems].size) {
395    item.problems = [...node[_problems]]
396  }
397
398  return augmentItemWithIncludeMetadata(node, item)
399}
400
401const filterByEdgesTypes = ({ link, omit }) => (edge) => {
402  for (const omitType of omit) {
403    if (edge[omitType]) {
404      return false
405    }
406  }
407  return link ? edge.to && edge.to.isLink : true
408}
409
410const appendExtraneousChildren = ({ node, seenPaths }) =>
411  // extraneous children are not represented
412  // in edges out, so here we add them to the list:
413  [...node.children.values()]
414    .filter(i => !seenPaths.has(i.path) && i.extraneous)
415
416const mapEdgesToNodes = ({ seenPaths }) => (edge) => {
417  let node = edge.to
418
419  // if the edge is linking to a missing node, we go ahead
420  // and create a new obj that will represent the missing node
421  if (edge.missing || (edge.optional && !node)) {
422    const { name, spec } = edge
423    const pkgid = `${name}@${spec}`
424    node = { name, pkgid, [_missing]: edge.from.pkgid }
425  }
426
427  // keeps track of a set of seen paths to avoid the edge case in which a tree
428  // item would appear twice given that it's a children of an extraneous item,
429  // so it's marked extraneous but it will ALSO show up in edgesOuts of
430  // its parent so it ends up as two diff nodes if we don't track it
431  if (node.path) {
432    seenPaths.add(node.path)
433  }
434
435  node[_required] = edge.spec || '*'
436  node[_type] = edge.type
437
438  if (edge.invalid) {
439    const spec = JSON.stringify(node[_required])
440    const from = edge.from.location || 'the root project'
441    node[_invalid] = (node[_invalid] ? node[_invalid] + ', ' : '') +
442      (`${spec} from ${from}`)
443  }
444
445  return node
446}
447
448const filterByPositionalArgs = (args, { node }) =>
449  args.length > 0 ? args.some(
450    (spec) => (node.satisfies && node.satisfies(spec))
451  ) : true
452
453const augmentNodesWithMetadata = ({
454  args,
455  currentDepth,
456  nodeResult,
457  seenNodes,
458}) => (node) => {
459  // if the original edge was a deduped dep, treeverse will fail to
460  // revisit that node in tree traversal logic, so we make it so that
461  // we have a diff obj for deduped nodes:
462  if (seenNodes.has(node.path)) {
463    const { realpath, root } = node
464    const targetLocation = root ? relative(root.realpath, realpath)
465      : node.targetLocation
466    node = {
467      name: node.name,
468      version: node.version,
469      pkgid: node.pkgid,
470      package: node.package,
471      path: node.path,
472      isLink: node.isLink,
473      realpath: node.realpath,
474      targetLocation,
475      [_type]: node[_type],
476      [_invalid]: node[_invalid],
477      [_missing]: node[_missing],
478      // if it's missing, it's not deduped, it's just missing
479      [_dedupe]: !node[_missing],
480    }
481  } else {
482    // keeps track of already seen nodes in order to check for dedupes
483    seenNodes.set(node.path, node)
484  }
485
486  // _parent is going to be a ref to a treeverse-visited node (returned from
487  // getHumanOutputItem, getJsonOutputItem, etc) so that we have an easy
488  // shortcut to place new nodes in their right place during tree traversal
489  node[_parent] = nodeResult
490  // _include is the property that allow us to filter based on position args
491  // e.g: `npm ls foo`, `npm ls simple-output@2`
492  // _filteredBy is used to apply extra color info to the item that
493  // was used in args in order to filter
494  node[_filteredBy] = node[_include] =
495    filterByPositionalArgs(args, { node: seenNodes.get(node.path) })
496  // _depth keeps track of how many levels deep tree traversal currently is
497  // so that we can `npm ls --depth=1`
498  node[_depth] = currentDepth + 1
499
500  return node
501}
502
503const sortAlphabetically = ({ pkgid: a }, { pkgid: b }) => localeCompare(a, b)
504
505const humanOutput = ({ chalk, result, seenItems, unicode }) => {
506  // we need to traverse the entire tree in order to determine which items
507  // should be included (since a nested transitive included dep will make it
508  // so that all its ancestors should be displayed)
509  // here is where we put items in their expected place for archy output
510  for (const item of seenItems) {
511    if (item[_include] && item[_parent]) {
512      item[_parent].nodes.push(item)
513    }
514  }
515
516  if (!result.nodes.length) {
517    result.nodes = ['(empty)']
518  }
519
520  const archyOutput = archy(result, '', { unicode })
521  return chalk.reset(archyOutput)
522}
523
524const jsonOutput = ({ path, problems, result, rootError, seenItems }) => {
525  if (problems.size) {
526    result.problems = [...problems]
527  }
528
529  if (rootError) {
530    result.problems = [
531      ...(result.problems || []),
532      ...[`error in ${path}: Failed to parse root package.json`],
533    ]
534    result.invalid = true
535  }
536
537  // we need to traverse the entire tree in order to determine which items
538  // should be included (since a nested transitive included dep will make it
539  // so that all its ancestors should be displayed)
540  // here is where we put items in their expected place for json output
541  for (const item of seenItems) {
542    // append current item to its parent item.dependencies obj in order
543    // to provide a json object structure that represents the installed tree
544    if (item[_include] && item[_parent]) {
545      if (!item[_parent].dependencies) {
546        item[_parent].dependencies = {}
547      }
548
549      item[_parent].dependencies[item[_name]] = item
550    }
551  }
552
553  return JSON.stringify(result, null, 2)
554}
555
556const parseableOutput = ({ global, long, seenNodes }) => {
557  let out = ''
558  for (const node of seenNodes.values()) {
559    if (node.path && node[_include]) {
560      out += node.path
561      if (long) {
562        out += `:${node.pkgid}`
563        out += node.path !== node.realpath ? `:${node.realpath}` : ''
564        out += isExtraneous(node, { global }) ? ':EXTRANEOUS' : ''
565        out += node[_invalid] ? ':INVALID' : ''
566        out += node.overridden ? ':OVERRIDDEN' : ''
567      }
568      out += EOL
569    }
570  }
571  return out.trim()
572}
573