diff.js 8.2 KB

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