diff.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. const { resolve } = require('node:path')
  2. const semver = require('semver')
  3. const libnpmdiff = require('libnpmdiff')
  4. const npa = require('npm-package-arg')
  5. const pacote = require('pacote')
  6. const pickManifest = require('npm-pick-manifest')
  7. const { log, output } = require('proc-log')
  8. const pkgJson = require('@npmcli/package-json')
  9. const BaseCommand = require('../base-cmd.js')
  10. class Diff extends BaseCommand {
  11. static description = 'The registry diff command'
  12. static name = 'diff'
  13. static usage = [
  14. '[...<paths>]',
  15. ]
  16. static params = [
  17. 'diff',
  18. 'diff-name-only',
  19. 'diff-unified',
  20. 'diff-ignore-all-space',
  21. 'diff-no-prefix',
  22. 'diff-src-prefix',
  23. 'diff-dst-prefix',
  24. 'diff-text',
  25. 'global',
  26. 'tag',
  27. 'workspace',
  28. 'workspaces',
  29. 'include-workspace-root',
  30. ]
  31. static workspaces = true
  32. static ignoreImplicitWorkspace = false
  33. async exec (args) {
  34. const specs = this.npm.config.get('diff').filter(d => d)
  35. if (specs.length > 2) {
  36. throw this.usageError(`Can't use more than two --diff arguments.`)
  37. }
  38. // execWorkspaces may have set this already
  39. if (!this.prefix) {
  40. this.prefix = this.npm.prefix
  41. }
  42. // this is the "top" directory, one up from node_modules in global mode we have to walk one up from globalDir because our node_modules is sometimes under ./lib, and in global mode we're only ever walking through node_modules (because we will have been given a package name already)
  43. if (this.npm.global) {
  44. this.top = resolve(this.npm.globalDir, '..')
  45. } else {
  46. this.top = this.prefix
  47. }
  48. const [a, b] = await this.retrieveSpecs(specs)
  49. log.info('diff', { src: a, dst: b })
  50. const res = await libnpmdiff([a, b], {
  51. ...this.npm.flatOptions,
  52. diffFiles: args,
  53. where: this.top,
  54. })
  55. return output.standard(res)
  56. }
  57. async execWorkspaces (args) {
  58. await this.setWorkspaces()
  59. for (const workspacePath of this.workspacePaths) {
  60. this.top = workspacePath
  61. this.prefix = workspacePath
  62. await this.exec(args)
  63. }
  64. }
  65. // get the package name from the packument at `path`
  66. // throws if no packument is present OR if it does not have `name` attribute
  67. async packageName () {
  68. let name
  69. try {
  70. const { content: pkg } = await pkgJson.normalize(this.prefix)
  71. name = pkg.name
  72. } catch {
  73. log.verbose('diff', 'could not read project dir package.json')
  74. }
  75. if (!name) {
  76. throw this.usageError('Needs multiple arguments to compare or run from a project dir.')
  77. }
  78. return name
  79. }
  80. async retrieveSpecs ([a, b]) {
  81. if (a && b) {
  82. const specs = await this.convertVersionsToSpecs([a, b])
  83. return this.findVersionsByPackageName(specs)
  84. }
  85. // no arguments, defaults to comparing cwd to its latest published registry version
  86. if (!a) {
  87. const pkgName = await this.packageName()
  88. return [
  89. `${pkgName}@${this.npm.config.get('tag')}`,
  90. `file:${this.prefix}`,
  91. ]
  92. }
  93. // single argument, used to compare wanted versions of an installed dependency or to compare the cwd to a published version
  94. let noPackageJson
  95. let pkgName
  96. try {
  97. const { content: pkg } = await pkgJson.normalize(this.prefix)
  98. pkgName = pkg.name
  99. } catch {
  100. log.verbose('diff', 'could not read project dir package.json')
  101. noPackageJson = true
  102. }
  103. const missingPackageJson =
  104. this.usageError('Needs multiple arguments to compare or run from a project dir.')
  105. // using a valid semver range, that means it should just diff the cwd against a published version to the registry using the same project name and the provided semver range
  106. if (semver.validRange(a)) {
  107. if (!pkgName) {
  108. throw missingPackageJson
  109. }
  110. return [
  111. `${pkgName}@${a}`,
  112. `file:${this.prefix}`,
  113. ]
  114. }
  115. // when using a single package name as arg and it's part of the current install tree, then retrieve the current installed version and compare it against the same value `npm outdated` would suggest you to update to
  116. const spec = npa(a)
  117. if (spec.registry) {
  118. let actualTree
  119. let node
  120. const Arborist = require('@npmcli/arborist')
  121. try {
  122. const opts = {
  123. ...this.npm.flatOptions,
  124. path: this.top,
  125. }
  126. const arb = new Arborist(opts)
  127. actualTree = await arb.loadActual(opts)
  128. node = actualTree &&
  129. actualTree.inventory.query('name', spec.name)
  130. .values().next().value
  131. } catch {
  132. log.verbose('diff', 'failed to load actual install tree')
  133. }
  134. if (!node || !node.name || !node.package || !node.package.version) {
  135. if (noPackageJson) {
  136. throw missingPackageJson
  137. }
  138. return [
  139. `${spec.name}@${spec.fetchSpec}`,
  140. `file:${this.prefix}`,
  141. ]
  142. }
  143. const tryRootNodeSpec = () =>
  144. (actualTree && actualTree.edgesOut.get(spec.name) || {}).spec
  145. const tryAnySpec = () => {
  146. for (const edge of node.edgesIn) {
  147. return edge.spec
  148. }
  149. }
  150. const aSpec = `file:${node.realpath}`
  151. // finds what version of the package to compare against
  152. // if an exact version or tag was passed than it should use that
  153. // otherwise, work from the top of the arborist tree to find the original semver range declared in the package that depends on the package.
  154. let bSpec
  155. if (spec.rawSpec !== '*') {
  156. bSpec = spec.rawSpec
  157. } else {
  158. const bTargetVersion =
  159. tryRootNodeSpec()
  160. || tryAnySpec()
  161. // figure out what to compare against
  162. // follows same logic to npm outdated "Wanted" results
  163. const packument = await pacote.packument(spec, {
  164. ...this.npm.flatOptions,
  165. preferOnline: true,
  166. _isRoot: true,
  167. })
  168. bSpec = pickManifest(
  169. packument,
  170. bTargetVersion,
  171. { ...this.npm.flatOptions }
  172. ).version
  173. }
  174. return [
  175. `${spec.name}@${aSpec}`,
  176. `${spec.name}@${bSpec}`,
  177. ]
  178. } else if (spec.type === 'directory') {
  179. return [
  180. `file:${spec.fetchSpec}`,
  181. `file:${this.prefix}`,
  182. ]
  183. } else {
  184. throw this.usageError(`Spec type ${spec.type} not supported.`)
  185. }
  186. }
  187. async convertVersionsToSpecs ([a, b]) {
  188. const semverA = semver.validRange(a)
  189. const semverB = semver.validRange(b)
  190. // both specs are semver versions, assume current project dir name
  191. if (semverA && semverB) {
  192. let pkgName
  193. try {
  194. const { content: pkg } = await pkgJson.normalize(this.prefix)
  195. pkgName = pkg.name
  196. } catch {
  197. log.verbose('diff', 'could not read project dir package.json')
  198. }
  199. if (!pkgName) {
  200. throw this.usageError('Needs to be run from a project dir in order to diff two versions.')
  201. }
  202. return [`${pkgName}@${a}`, `${pkgName}@${b}`]
  203. }
  204. // otherwise uses the name from the other arg to figure out the spec.name of what to compare
  205. if (!semverA && semverB) {
  206. return [a, `${npa(a).name}@${b}`]
  207. }
  208. if (semverA && !semverB) {
  209. return [`${npa(b).name}@${a}`, b]
  210. }
  211. // no valid semver ranges used
  212. return [a, b]
  213. }
  214. async findVersionsByPackageName (specs) {
  215. let actualTree
  216. const Arborist = require('@npmcli/arborist')
  217. try {
  218. const opts = {
  219. ...this.npm.flatOptions,
  220. path: this.top,
  221. }
  222. const arb = new Arborist(opts)
  223. actualTree = await arb.loadActual(opts)
  224. } catch {
  225. log.verbose('diff', 'failed to load actual install tree')
  226. }
  227. return specs.map(i => {
  228. const spec = npa(i)
  229. if (spec.rawSpec !== '*') {
  230. return i
  231. }
  232. const node = actualTree
  233. && actualTree.inventory.query('name', spec.name)
  234. .values().next().value
  235. const res = !node || !node.package || !node.package.version
  236. ? spec.fetchSpec
  237. : `file:${node.realpath}`
  238. return `${spec.name}@${res}`
  239. })
  240. }
  241. }
  242. module.exports = Diff