1const os = require('node:os') 2const { resolve } = require('node:path') 3const { stripVTControlCharacters } = require('node:util') 4const pacote = require('pacote') 5const table = require('text-table') 6const npa = require('npm-package-arg') 7const pickManifest = require('npm-pick-manifest') 8const localeCompare = require('@isaacs/string-locale-compare')('en') 9 10const ArboristWorkspaceCmd = require('../arborist-cmd.js') 11 12class Outdated extends ArboristWorkspaceCmd { 13 static description = 'Check for outdated packages' 14 static name = 'outdated' 15 static usage = ['[<package-spec> ...]'] 16 static params = [ 17 'all', 18 'json', 19 'long', 20 'parseable', 21 'global', 22 'workspace', 23 ] 24 25 async exec (args) { 26 const global = resolve(this.npm.globalDir, '..') 27 const where = this.npm.global 28 ? global 29 : this.npm.prefix 30 31 const Arborist = require('@npmcli/arborist') 32 const arb = new Arborist({ 33 ...this.npm.flatOptions, 34 path: where, 35 }) 36 37 this.edges = new Set() 38 this.list = [] 39 this.tree = await arb.loadActual() 40 41 if (this.workspaceNames && this.workspaceNames.length) { 42 this.filterSet = 43 arb.workspaceDependencySet( 44 this.tree, 45 this.workspaceNames, 46 this.npm.flatOptions.includeWorkspaceRoot 47 ) 48 } else if (!this.npm.flatOptions.workspacesEnabled) { 49 this.filterSet = 50 arb.excludeWorkspacesDependencySet(this.tree) 51 } 52 53 if (args.length !== 0) { 54 // specific deps 55 for (let i = 0; i < args.length; i++) { 56 const nodes = this.tree.inventory.query('name', args[i]) 57 this.getEdges(nodes, 'edgesIn') 58 } 59 } else { 60 if (this.npm.config.get('all')) { 61 // all deps in tree 62 const nodes = this.tree.inventory.values() 63 this.getEdges(nodes, 'edgesOut') 64 } 65 // top-level deps 66 this.getEdges() 67 } 68 69 await Promise.all(Array.from(this.edges).map((edge) => { 70 return this.getOutdatedInfo(edge) 71 })) 72 73 // sorts list alphabetically 74 const outdated = this.list.sort((a, b) => localeCompare(a.name, b.name)) 75 76 if (outdated.length > 0) { 77 process.exitCode = 1 78 } 79 80 // return if no outdated packages 81 if (outdated.length === 0 && !this.npm.config.get('json')) { 82 return 83 } 84 85 // display results 86 if (this.npm.config.get('json')) { 87 this.npm.output(this.makeJSON(outdated)) 88 } else if (this.npm.config.get('parseable')) { 89 this.npm.output(this.makeParseable(outdated)) 90 } else { 91 const outList = outdated.map(x => this.makePretty(x)) 92 const outHead = ['Package', 93 'Current', 94 'Wanted', 95 'Latest', 96 'Location', 97 'Depended by', 98 ] 99 100 if (this.npm.config.get('long')) { 101 outHead.push('Package Type', 'Homepage') 102 } 103 const outTable = [outHead].concat(outList) 104 105 outTable[0] = outTable[0].map(heading => this.npm.chalk.underline(heading)) 106 107 const tableOpts = { 108 align: ['l', 'r', 'r', 'r', 'l'], 109 stringLength: s => stripVTControlCharacters(s).length, 110 } 111 this.npm.output(table(outTable, tableOpts)) 112 } 113 } 114 115 getEdges (nodes, type) { 116 // when no nodes are provided then it should only read direct deps 117 // from the root node and its workspaces direct dependencies 118 if (!nodes) { 119 this.getEdgesOut(this.tree) 120 this.getWorkspacesEdges() 121 return 122 } 123 124 for (const node of nodes) { 125 type === 'edgesOut' 126 ? this.getEdgesOut(node) 127 : this.getEdgesIn(node) 128 } 129 } 130 131 getEdgesIn (node) { 132 for (const edge of node.edgesIn) { 133 this.trackEdge(edge) 134 } 135 } 136 137 getEdgesOut (node) { 138 // TODO: normalize usage of edges and avoid looping through nodes here 139 if (this.npm.global) { 140 for (const child of node.children.values()) { 141 this.trackEdge(child) 142 } 143 } else { 144 for (const edge of node.edgesOut.values()) { 145 this.trackEdge(edge) 146 } 147 } 148 } 149 150 trackEdge (edge) { 151 const filteredOut = 152 edge.from 153 && this.filterSet 154 && this.filterSet.size > 0 155 && !this.filterSet.has(edge.from.target) 156 157 if (filteredOut) { 158 return 159 } 160 161 this.edges.add(edge) 162 } 163 164 getWorkspacesEdges (node) { 165 if (this.npm.global) { 166 return 167 } 168 169 for (const edge of this.tree.edgesOut.values()) { 170 const workspace = edge 171 && edge.to 172 && edge.to.target 173 && edge.to.target.isWorkspace 174 175 if (workspace) { 176 this.getEdgesOut(edge.to.target) 177 } 178 } 179 } 180 181 async getPackument (spec) { 182 const packument = await pacote.packument(spec, { 183 ...this.npm.flatOptions, 184 fullMetadata: this.npm.config.get('long'), 185 preferOnline: true, 186 }) 187 return packument 188 } 189 190 async getOutdatedInfo (edge) { 191 let alias = false 192 try { 193 alias = npa(edge.spec).subSpec 194 } catch (err) { 195 // ignore errors, no alias 196 } 197 const spec = npa(alias ? alias.name : edge.name) 198 const node = edge.to || edge 199 const { path, location } = node 200 const { version: current } = node.package || {} 201 202 const type = edge.optional ? 'optionalDependencies' 203 : edge.peer ? 'peerDependencies' 204 : edge.dev ? 'devDependencies' 205 : 'dependencies' 206 207 for (const omitType of this.npm.flatOptions.omit) { 208 if (node[omitType]) { 209 return 210 } 211 } 212 213 // deps different from prod not currently 214 // on disk are not included in the output 215 if (edge.error === 'MISSING' && type !== 'dependencies') { 216 return 217 } 218 219 try { 220 const packument = await this.getPackument(spec) 221 const expected = alias ? alias.fetchSpec : edge.spec 222 // if it's not a range, version, or tag, skip it 223 try { 224 if (!npa(`${edge.name}@${edge.spec}`).registry) { 225 return null 226 } 227 } catch (err) { 228 return null 229 } 230 const wanted = pickManifest(packument, expected, this.npm.flatOptions) 231 const latest = pickManifest(packument, '*', this.npm.flatOptions) 232 233 if ( 234 !current || 235 current !== wanted.version || 236 wanted.version !== latest.version 237 ) { 238 const dependent = edge.from ? 239 this.maybeWorkspaceName(edge.from) 240 : 'global' 241 242 this.list.push({ 243 name: alias ? edge.spec.replace('npm', edge.name) : edge.name, 244 path, 245 type, 246 current, 247 location, 248 wanted: wanted.version, 249 latest: latest.version, 250 dependent, 251 homepage: packument.homepage, 252 }) 253 } 254 } catch (err) { 255 // silently catch and ignore ETARGET, E403 & 256 // E404 errors, deps are just skipped 257 if (!( 258 err.code === 'ETARGET' || 259 err.code === 'E403' || 260 err.code === 'E404') 261 ) { 262 throw err 263 } 264 } 265 } 266 267 maybeWorkspaceName (node) { 268 if (!node.isWorkspace) { 269 return node.name 270 } 271 272 const humanOutput = 273 !this.npm.config.get('json') && !this.npm.config.get('parseable') 274 275 const workspaceName = 276 humanOutput 277 ? node.pkgid 278 : node.name 279 280 return humanOutput 281 ? this.npm.chalk.green(workspaceName) 282 : workspaceName 283 } 284 285 // formatting functions 286 makePretty (dep) { 287 const { 288 current = 'MISSING', 289 location = '-', 290 homepage = '', 291 name, 292 wanted, 293 latest, 294 type, 295 dependent, 296 } = dep 297 298 const columns = [name, current, wanted, latest, location, dependent] 299 300 if (this.npm.config.get('long')) { 301 columns[6] = type 302 columns[7] = homepage 303 } 304 305 columns[0] = this.npm.chalk[current === wanted ? 'yellow' : 'red'](columns[0]) // current 306 columns[2] = this.npm.chalk.green(columns[2]) // wanted 307 columns[3] = this.npm.chalk.magenta(columns[3]) // latest 308 309 return columns 310 } 311 312 // --parseable creates output like this: 313 // <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby> 314 makeParseable (list) { 315 return list.map(dep => { 316 const { 317 name, 318 current, 319 wanted, 320 latest, 321 path, 322 dependent, 323 type, 324 homepage, 325 } = dep 326 const out = [ 327 path, 328 name + '@' + wanted, 329 current ? (name + '@' + current) : 'MISSING', 330 name + '@' + latest, 331 dependent, 332 ] 333 if (this.npm.config.get('long')) { 334 out.push(type, homepage) 335 } 336 337 return out.join(':') 338 }).join(os.EOL) 339 } 340 341 makeJSON (list) { 342 const out = {} 343 list.forEach(dep => { 344 const { 345 name, 346 current, 347 wanted, 348 latest, 349 path, 350 type, 351 dependent, 352 homepage, 353 } = dep 354 out[name] = { 355 current, 356 wanted, 357 latest, 358 dependent, 359 location: path, 360 } 361 if (this.npm.config.get('long')) { 362 out[name].type = type 363 out[name].homepage = homepage 364 } 365 }) 366 return JSON.stringify(out, null, 2) 367 } 368} 369module.exports = Outdated 370