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