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