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