outdated.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. const { resolve } = require('node:path')
  2. const { stripVTControlCharacters } = require('node:util')
  3. const pacote = require('pacote')
  4. const table = require('text-table')
  5. const npa = require('npm-package-arg')
  6. const pickManifest = require('npm-pick-manifest')
  7. const { output } = require('proc-log')
  8. const localeCompare = require('@isaacs/string-locale-compare')('en')
  9. const ArboristWorkspaceCmd = require('../arborist-cmd.js')
  10. const safeNpa = (spec) => {
  11. try {
  12. return npa(spec)
  13. } catch {
  14. return null
  15. }
  16. }
  17. // This string is load bearing and is shared with Arborist
  18. const MISSING = 'MISSING'
  19. class Outdated extends ArboristWorkspaceCmd {
  20. static description = 'Check for outdated packages'
  21. static name = 'outdated'
  22. static usage = ['[<package-spec> ...]']
  23. static params = [
  24. 'all',
  25. 'json',
  26. 'long',
  27. 'parseable',
  28. 'global',
  29. 'workspace',
  30. 'before',
  31. ]
  32. #tree
  33. #list = []
  34. #edges = new Set()
  35. #filterSet
  36. async exec (args) {
  37. const Arborist = require('@npmcli/arborist')
  38. const arb = new Arborist({
  39. ...this.npm.flatOptions,
  40. path: this.npm.global ? resolve(this.npm.globalDir, '..') : this.npm.prefix,
  41. })
  42. this.#tree = await arb.loadActual()
  43. if (this.workspaceNames?.length) {
  44. this.#filterSet = arb.workspaceDependencySet(
  45. this.#tree,
  46. this.workspaceNames,
  47. this.npm.flatOptions.includeWorkspaceRoot
  48. )
  49. } else if (!this.npm.flatOptions.workspacesEnabled) {
  50. this.#filterSet = arb.excludeWorkspacesDependencySet(this.#tree)
  51. }
  52. if (args.length) {
  53. for (const arg of args) {
  54. // specific deps
  55. this.#getEdges(this.#tree.inventory.query('name', arg), 'edgesIn')
  56. }
  57. } else {
  58. if (this.npm.config.get('all')) {
  59. // all deps in tree
  60. this.#getEdges(this.#tree.inventory.values(), 'edgesOut')
  61. }
  62. // top-level deps
  63. this.#getEdges()
  64. }
  65. await Promise.all([...this.#edges].map((e) => this.#getOutdatedInfo(e)))
  66. // sorts list alphabetically by name and then dependent
  67. const outdated = this.#list
  68. .sort((a, b) => localeCompare(a.name, b.name) || localeCompare(a.dependent, b.dependent))
  69. if (outdated.length) {
  70. process.exitCode = 1
  71. }
  72. if (this.npm.config.get('json')) {
  73. output.buffer(this.#json(outdated))
  74. return
  75. }
  76. const res = this.npm.config.get('parseable')
  77. ? this.#parseable(outdated)
  78. : this.#pretty(outdated)
  79. if (res) {
  80. output.standard(res)
  81. }
  82. }
  83. #getEdges (nodes, type) {
  84. // when no nodes are provided then it should only read direct deps from the root node and its workspaces direct dependencies
  85. if (!nodes) {
  86. this.#getEdgesOut(this.#tree)
  87. this.#getWorkspacesEdges()
  88. return
  89. }
  90. for (const node of nodes) {
  91. if (type === 'edgesOut') {
  92. this.#getEdgesOut(node)
  93. } else {
  94. this.#getEdgesIn(node)
  95. }
  96. }
  97. }
  98. #getEdgesIn (node) {
  99. for (const edge of node.edgesIn) {
  100. this.#trackEdge(edge)
  101. }
  102. }
  103. #getEdgesOut (node) {
  104. // TODO: normalize usage of edges and avoid looping through nodes here
  105. const edges = this.npm.global ? node.children.values() : node.edgesOut.values()
  106. for (const edge of edges) {
  107. this.#trackEdge(edge)
  108. }
  109. }
  110. #trackEdge (edge) {
  111. if (edge.from && this.#filterSet?.size > 0 && !this.#filterSet.has(edge.from.target)) {
  112. return
  113. }
  114. this.#edges.add(edge)
  115. }
  116. #getWorkspacesEdges () {
  117. if (this.npm.global) {
  118. return
  119. }
  120. for (const edge of this.#tree.edgesOut.values()) {
  121. if (edge?.to?.target?.isWorkspace) {
  122. this.#getEdgesOut(edge.to.target)
  123. }
  124. }
  125. }
  126. async #getPackument (spec) {
  127. return pacote.packument(spec, {
  128. ...this.npm.flatOptions,
  129. fullMetadata: this.npm.config.get('long'),
  130. preferOnline: true,
  131. })
  132. }
  133. async #getOutdatedInfo (edge) {
  134. const alias = safeNpa(edge.spec)?.subSpec
  135. const spec = npa(alias ? alias.name : edge.name)
  136. const node = edge.to || edge
  137. const { path, location, package: { version: current } = {} } = node
  138. const type = edge.optional ? 'optionalDependencies'
  139. : edge.peer ? 'peerDependencies'
  140. : edge.dev ? 'devDependencies'
  141. : 'dependencies'
  142. for (const omitType of this.npm.flatOptions.omit) {
  143. if (node[omitType]) {
  144. return
  145. }
  146. }
  147. // deps different from prod not currently on disk are not included in the output
  148. if (edge.error === MISSING && type !== 'dependencies') {
  149. return
  150. }
  151. // if it's not a range, version, or tag, skip it
  152. if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) {
  153. return null
  154. }
  155. try {
  156. const packument = await this.#getPackument(spec)
  157. const expected = alias ? alias.fetchSpec : edge.spec
  158. const wanted = pickManifest(packument, expected, this.npm.flatOptions)
  159. const latest = pickManifest(packument, '*', this.npm.flatOptions)
  160. if (!current || current !== wanted.version || wanted.version !== latest.version) {
  161. this.#list.push({
  162. name: alias ? edge.spec.replace('npm', edge.name) : edge.name,
  163. path,
  164. type,
  165. current,
  166. location,
  167. wanted: wanted.version,
  168. latest: latest.version,
  169. workspaceDependent: edge.from?.isWorkspace ? edge.from.pkgid : null,
  170. dependedByLocation: edge.from?.name
  171. ? edge.from?.location
  172. : 'global',
  173. dependent: edge.from?.name ?? 'global',
  174. homepage: packument.homepage,
  175. })
  176. }
  177. } catch (err) {
  178. // silently catch and ignore ETARGET, E403 & E404 errors
  179. // deps are just skipped
  180. if (!['ETARGET', 'E404', 'E404'].includes(err.code)) {
  181. throw err
  182. }
  183. }
  184. }
  185. // formatting functions
  186. #pretty (list) {
  187. if (!list.length) {
  188. return
  189. }
  190. const long = this.npm.config.get('long')
  191. const { bold, yellow, red, cyan, blue } = this.npm.chalk
  192. return table([
  193. [
  194. 'Package',
  195. 'Current',
  196. 'Wanted',
  197. 'Latest',
  198. 'Location',
  199. 'Depended by',
  200. ...long ? ['Package Type', 'Homepage', 'Depended By Location'] : [],
  201. ].map(h => bold.underline(h)),
  202. ...list.map((d) => [
  203. d.current === d.wanted ? yellow(d.name) : red(d.name),
  204. d.current ?? 'MISSING',
  205. cyan(d.wanted),
  206. blue(d.latest),
  207. d.location ?? '-',
  208. d.workspaceDependent ? blue(d.workspaceDependent) : d.dependent,
  209. ...long ? [d.type, blue(d.homepage ?? ''), d.dependedByLocation] : [],
  210. ]),
  211. ], {
  212. align: ['l', 'r', 'r', 'r', 'l'],
  213. stringLength: s => stripVTControlCharacters(s).length,
  214. })
  215. }
  216. // --parseable creates output like this:
  217. // <fullpath>:<name@wanted>:<name@installed>:<name@latest>:<dependedby>
  218. #parseable (list) {
  219. return list.map(d => [
  220. d.path,
  221. `${d.name}@${d.wanted}`,
  222. d.current ? `${d.name}@${d.current}` : 'MISSING',
  223. `${d.name}@${d.latest}`,
  224. d.dependent,
  225. ...this.npm.config.get('long') ? [d.type, d.homepage, d.dependedByLocation] : [],
  226. ].join(':')).join('\n')
  227. }
  228. #json (list) {
  229. // TODO(BREAKING_CHANGE): this should just return an array.
  230. // It's a list and turning it into an object with keys is lossy since multiple items in the list could have the same key. For now we hack that by only changing top level values into arrays if they have multiple outdated items
  231. return list.reduce((acc, d) => {
  232. const dep = {
  233. current: d.current,
  234. wanted: d.wanted,
  235. latest: d.latest,
  236. dependent: d.dependent,
  237. location: d.path,
  238. ...this.npm.config.get('long') ? {
  239. type: d.type,
  240. homepage: d.homepage,
  241. dependedByLocation: d.dependedByLocation } : {},
  242. }
  243. acc[d.name] = acc[d.name]
  244. // If this item already has an outdated dep then we turn it into an array
  245. ? (Array.isArray(acc[d.name]) ? acc[d.name] : [acc[d.name]]).concat(dep)
  246. : dep
  247. return acc
  248. }, {})
  249. }
  250. }
  251. module.exports = Outdated