npa.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. 'use strict'
  2. module.exports = npa
  3. module.exports.resolve = resolve
  4. module.exports.Result = Result
  5. const url = require('url')
  6. const HostedGit = require('hosted-git-info')
  7. const semver = require('semver')
  8. const path = global.FAKE_WINDOWS ? require('path').win32 : require('path')
  9. const validatePackageName = require('validate-npm-package-name')
  10. const { homedir } = require('os')
  11. const isWindows = process.platform === 'win32' || global.FAKE_WINDOWS
  12. const hasSlashes = isWindows ? /\\|[/]/ : /[/]/
  13. const isURL = /^(?:git[+])?[a-z]+:/i
  14. const isGit = /^[^@]+@[^:.]+\.[^:]+:.+$/i
  15. const isFilename = /[.](?:tgz|tar.gz|tar)$/i
  16. function npa (arg, where) {
  17. let name
  18. let spec
  19. if (typeof arg === 'object') {
  20. if (arg instanceof Result && (!where || where === arg.where))
  21. return arg
  22. else if (arg.name && arg.rawSpec)
  23. return npa.resolve(arg.name, arg.rawSpec, where || arg.where)
  24. else
  25. return npa(arg.raw, where || arg.where)
  26. }
  27. const nameEndsAt = arg[0] === '@' ? arg.slice(1).indexOf('@') + 1 : arg.indexOf('@')
  28. const namePart = nameEndsAt > 0 ? arg.slice(0, nameEndsAt) : arg
  29. if (isURL.test(arg))
  30. spec = arg
  31. else if (isGit.test(arg))
  32. spec = `git+ssh://${arg}`
  33. else if (namePart[0] !== '@' && (hasSlashes.test(namePart) || isFilename.test(namePart)))
  34. spec = arg
  35. else if (nameEndsAt > 0) {
  36. name = namePart
  37. spec = arg.slice(nameEndsAt + 1)
  38. } else {
  39. const valid = validatePackageName(arg)
  40. if (valid.validForOldPackages)
  41. name = arg
  42. else
  43. spec = arg
  44. }
  45. return resolve(name, spec, where, arg)
  46. }
  47. const isFilespec = isWindows ? /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/ : /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
  48. function resolve (name, spec, where, arg) {
  49. const res = new Result({
  50. raw: arg,
  51. name: name,
  52. rawSpec: spec,
  53. fromArgument: arg != null,
  54. })
  55. if (name)
  56. res.setName(name)
  57. if (spec && (isFilespec.test(spec) || /^file:/i.test(spec)))
  58. return fromFile(res, where)
  59. else if (spec && /^npm:/i.test(spec))
  60. return fromAlias(res, where)
  61. const hosted = HostedGit.fromUrl(spec, {
  62. noGitPlus: true,
  63. noCommittish: true,
  64. })
  65. if (hosted)
  66. return fromHostedGit(res, hosted)
  67. else if (spec && isURL.test(spec))
  68. return fromURL(res)
  69. else if (spec && (hasSlashes.test(spec) || isFilename.test(spec)))
  70. return fromFile(res, where)
  71. else
  72. return fromRegistry(res)
  73. }
  74. function invalidPackageName (name, valid) {
  75. const err = new Error(`Invalid package name "${name}": ${valid.errors.join('; ')}`)
  76. err.code = 'EINVALIDPACKAGENAME'
  77. return err
  78. }
  79. function invalidTagName (name) {
  80. const err = new Error(`Invalid tag name "${name}": Tags may not have any characters that encodeURIComponent encodes.`)
  81. err.code = 'EINVALIDTAGNAME'
  82. return err
  83. }
  84. function Result (opts) {
  85. this.type = opts.type
  86. this.registry = opts.registry
  87. this.where = opts.where
  88. if (opts.raw == null)
  89. this.raw = opts.name ? opts.name + '@' + opts.rawSpec : opts.rawSpec
  90. else
  91. this.raw = opts.raw
  92. this.name = undefined
  93. this.escapedName = undefined
  94. this.scope = undefined
  95. this.rawSpec = opts.rawSpec == null ? '' : opts.rawSpec
  96. this.saveSpec = opts.saveSpec
  97. this.fetchSpec = opts.fetchSpec
  98. if (opts.name)
  99. this.setName(opts.name)
  100. this.gitRange = opts.gitRange
  101. this.gitCommittish = opts.gitCommittish
  102. this.hosted = opts.hosted
  103. }
  104. Result.prototype.setName = function (name) {
  105. const valid = validatePackageName(name)
  106. if (!valid.validForOldPackages)
  107. throw invalidPackageName(name, valid)
  108. this.name = name
  109. this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
  110. // scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
  111. this.escapedName = name.replace('/', '%2f')
  112. return this
  113. }
  114. Result.prototype.toString = function () {
  115. const full = []
  116. if (this.name != null && this.name !== '')
  117. full.push(this.name)
  118. const spec = this.saveSpec || this.fetchSpec || this.rawSpec
  119. if (spec != null && spec !== '')
  120. full.push(spec)
  121. return full.length ? full.join('@') : this.raw
  122. }
  123. Result.prototype.toJSON = function () {
  124. const result = Object.assign({}, this)
  125. delete result.hosted
  126. return result
  127. }
  128. function setGitCommittish (res, committish) {
  129. if (committish != null && committish.length >= 7 && committish.slice(0, 7) === 'semver:') {
  130. res.gitRange = decodeURIComponent(committish.slice(7))
  131. res.gitCommittish = null
  132. } else
  133. res.gitCommittish = committish === '' ? null : committish
  134. return res
  135. }
  136. function fromFile (res, where) {
  137. if (!where)
  138. where = process.cwd()
  139. res.type = isFilename.test(res.rawSpec) ? 'file' : 'directory'
  140. res.where = where
  141. // always put the '/' on where when resolving urls, or else
  142. // file:foo from /path/to/bar goes to /path/to/foo, when we want
  143. // it to be /path/to/foo/bar
  144. let specUrl
  145. let resolvedUrl
  146. const prefix = (!/^file:/.test(res.rawSpec) ? 'file:' : '')
  147. const rawWithPrefix = prefix + res.rawSpec
  148. let rawNoPrefix = rawWithPrefix.replace(/^file:/, '')
  149. try {
  150. resolvedUrl = new url.URL(rawWithPrefix, `file://${path.resolve(where)}/`)
  151. specUrl = new url.URL(rawWithPrefix)
  152. } catch (originalError) {
  153. const er = new Error('Invalid file: URL, must comply with RFC 8909')
  154. throw Object.assign(er, {
  155. raw: res.rawSpec,
  156. spec: res,
  157. where,
  158. originalError,
  159. })
  160. }
  161. // environment switch for testing
  162. if (process.env.NPM_PACKAGE_ARG_8909_STRICT !== '1') {
  163. // XXX backwards compatibility lack of compliance with 8909
  164. // Remove when we want a breaking change to come into RFC compliance.
  165. if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
  166. const rawSpec = res.rawSpec.replace(/^file:\/\//, 'file:///')
  167. resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
  168. specUrl = new url.URL(rawSpec)
  169. rawNoPrefix = rawSpec.replace(/^file:/, '')
  170. }
  171. // turn file:/../foo into file:../foo
  172. if (/^\/\.\.?(\/|$)/.test(rawNoPrefix)) {
  173. const rawSpec = res.rawSpec.replace(/^file:\//, 'file:')
  174. resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
  175. specUrl = new url.URL(rawSpec)
  176. rawNoPrefix = rawSpec.replace(/^file:/, '')
  177. }
  178. // XXX end 8909 violation backwards compatibility section
  179. }
  180. // file:foo - relative url to ./foo
  181. // file:/foo - absolute path /foo
  182. // file:///foo - absolute path to /foo, no authority host
  183. // file://localhost/foo - absolute path to /foo, on localhost
  184. // file://foo - absolute path to / on foo host (error!)
  185. if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
  186. const msg = `Invalid file: URL, must be absolute if // present`
  187. throw Object.assign(new Error(msg), {
  188. raw: res.rawSpec,
  189. parsed: resolvedUrl,
  190. })
  191. }
  192. // turn /C:/blah into just C:/blah on windows
  193. let specPath = decodeURIComponent(specUrl.pathname)
  194. let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
  195. if (isWindows) {
  196. specPath = specPath.replace(/^\/+([a-z]:\/)/i, '$1')
  197. resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, '$1')
  198. }
  199. // replace ~ with homedir, but keep the ~ in the saveSpec
  200. // otherwise, make it relative to where param
  201. if (/^\/~(\/|$)/.test(specPath)) {
  202. res.saveSpec = `file:${specPath.substr(1)}`
  203. resolvedPath = path.resolve(homedir(), specPath.substr(3))
  204. } else if (!path.isAbsolute(rawNoPrefix))
  205. res.saveSpec = `file:${path.relative(where, resolvedPath)}`
  206. else
  207. res.saveSpec = `file:${path.resolve(resolvedPath)}`
  208. res.fetchSpec = path.resolve(where, resolvedPath)
  209. return res
  210. }
  211. function fromHostedGit (res, hosted) {
  212. res.type = 'git'
  213. res.hosted = hosted
  214. res.saveSpec = hosted.toString({ noGitPlus: false, noCommittish: false })
  215. res.fetchSpec = hosted.getDefaultRepresentation() === 'shortcut' ? null : hosted.toString()
  216. return setGitCommittish(res, hosted.committish)
  217. }
  218. function unsupportedURLType (protocol, spec) {
  219. const err = new Error(`Unsupported URL Type "${protocol}": ${spec}`)
  220. err.code = 'EUNSUPPORTEDPROTOCOL'
  221. return err
  222. }
  223. function matchGitScp (spec) {
  224. // git ssh specifiers are overloaded to also use scp-style git
  225. // specifiers, so we have to parse those out and treat them special.
  226. // They are NOT true URIs, so we can't hand them to `url.parse`.
  227. //
  228. // This regex looks for things that look like:
  229. // git+ssh://git@my.custom.git.com:username/project.git#deadbeef
  230. //
  231. // ...and various combinations. The username in the beginning is *required*.
  232. const matched = spec.match(/^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#(.*))?$/i)
  233. return matched && !matched[1].match(/:[0-9]+\/?.*$/i) && {
  234. fetchSpec: matched[1],
  235. gitCommittish: matched[2] == null ? null : matched[2],
  236. }
  237. }
  238. function fromURL (res) {
  239. // eslint-disable-next-line node/no-deprecated-api
  240. const urlparse = url.parse(res.rawSpec)
  241. res.saveSpec = res.rawSpec
  242. // check the protocol, and then see if it's git or not
  243. switch (urlparse.protocol) {
  244. case 'git:':
  245. case 'git+http:':
  246. case 'git+https:':
  247. case 'git+rsync:':
  248. case 'git+ftp:':
  249. case 'git+file:':
  250. case 'git+ssh:': {
  251. res.type = 'git'
  252. const match = urlparse.protocol === 'git+ssh:' ? matchGitScp(res.rawSpec)
  253. : null
  254. if (match) {
  255. setGitCommittish(res, match.gitCommittish)
  256. res.fetchSpec = match.fetchSpec
  257. } else {
  258. setGitCommittish(res, urlparse.hash != null ? urlparse.hash.slice(1) : '')
  259. urlparse.protocol = urlparse.protocol.replace(/^git[+]/, '')
  260. if (urlparse.protocol === 'file:' && /^git\+file:\/\/[a-z]:/i.test(res.rawSpec)) {
  261. // keep the drive letter : on windows file paths
  262. urlparse.host += ':'
  263. urlparse.hostname += ':'
  264. }
  265. delete urlparse.hash
  266. res.fetchSpec = url.format(urlparse)
  267. }
  268. break
  269. }
  270. case 'http:':
  271. case 'https:':
  272. res.type = 'remote'
  273. res.fetchSpec = res.saveSpec
  274. break
  275. default:
  276. throw unsupportedURLType(urlparse.protocol, res.rawSpec)
  277. }
  278. return res
  279. }
  280. function fromAlias (res, where) {
  281. const subSpec = npa(res.rawSpec.substr(4), where)
  282. if (subSpec.type === 'alias')
  283. throw new Error('nested aliases not supported')
  284. if (!subSpec.registry)
  285. throw new Error('aliases only work for registry deps')
  286. res.subSpec = subSpec
  287. res.registry = true
  288. res.type = 'alias'
  289. res.saveSpec = null
  290. res.fetchSpec = null
  291. return res
  292. }
  293. function fromRegistry (res) {
  294. res.registry = true
  295. const spec = res.rawSpec === '' ? 'latest' : res.rawSpec.trim()
  296. // no save spec for registry components as we save based on the fetched
  297. // version, not on the argument so this can't compute that.
  298. res.saveSpec = null
  299. res.fetchSpec = spec
  300. const version = semver.valid(spec, true)
  301. const range = semver.validRange(spec, true)
  302. if (version)
  303. res.type = 'version'
  304. else if (range)
  305. res.type = 'range'
  306. else {
  307. if (encodeURIComponent(spec) !== spec)
  308. throw invalidTagName(spec)
  309. res.type = 'tag'
  310. }
  311. return res
  312. }