Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
npa.js480 linesDownload Raw Back to lib
1'use strict'
2
3const isWindows = process.platform === 'win32'
4
5const { URL } = require('node:url')
6// We need to use path/win32 so that we get consistent results in tests, but this also means we need to manually convert backslashes to forward slashes when generating file: urls with paths.
7const path = isWindows ? require('node:path/win32') : require('node:path')
8const { homedir } = require('node:os')
9const HostedGit = require('hosted-git-info')
10const semver = require('semver')
11const validatePackageName = require('validate-npm-package-name')
12const { log } = require('proc-log')
13
14const hasSlashes = isWindows ? /\\|[/]/ : /[/]/
15const isURL = /^(?:git[+])?[a-z]+:/i
16const isGit = /^[^@]+@[^:.]+\.[^:]+:.+$/i
17const isFileType = /[.](?:tgz|tar\.gz|tar)$/i
18const isPortNumber = /:[0-9]+(\/|$)/i
19const isWindowsFile = /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/
20const isPosixFile = /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
21const defaultRegistry = 'https://registry.npmjs.org'
22
23function npa (arg, where) {
24  let name
25  let spec
26  if (typeof arg === 'object') {
27    if (arg instanceof Result && (!where || where === arg.where)) {
28      return arg
29    } else if (arg.name && arg.rawSpec) {
30      return npa.resolve(arg.name, arg.rawSpec, where || arg.where)
31    } else {
32      return npa(arg.raw, where || arg.where)
33    }
34  }
35  const nameEndsAt = arg.indexOf('@', 1) // Skip possible leading @
36  const namePart = nameEndsAt > 0 ? arg.slice(0, nameEndsAt) : arg
37  if (isURL.test(arg)) {
38    spec = arg
39  } else if (isGit.test(arg)) {
40    spec = `git+ssh://${arg}`
41  // eslint-disable-next-line max-len
42  } else if (!namePart.startsWith('@') && (hasSlashes.test(namePart) || isFileType.test(namePart))) {
43    spec = arg
44  } else if (nameEndsAt > 0) {
45    name = namePart
46    spec = arg.slice(nameEndsAt + 1) || '*'
47  } else {
48    const valid = validatePackageName(arg)
49    if (valid.validForOldPackages) {
50      name = arg
51      spec = '*'
52    } else {
53      spec = arg
54    }
55  }
56  return resolve(name, spec, where, arg)
57}
58
59function isFileSpec (spec) {
60  if (!spec) {
61    return false
62  }
63  if (spec.toLowerCase().startsWith('file:')) {
64    return true
65  }
66  if (isWindows) {
67    return isWindowsFile.test(spec)
68  }
69  return isPosixFile.test(spec)
70}
71
72function isAliasSpec (spec) {
73  if (!spec) {
74    return false
75  }
76  return spec.toLowerCase().startsWith('npm:')
77}
78
79function resolve (name, spec, where, arg) {
80  const res = new Result({
81    raw: arg,
82    name: name,
83    rawSpec: spec,
84    fromArgument: arg != null,
85  })
86
87  if (name) {
88    res.name = name
89  }
90
91  if (!where) {
92    where = process.cwd()
93  }
94
95  if (isFileSpec(spec)) {
96    return fromFile(res, where)
97  } else if (isAliasSpec(spec)) {
98    return fromAlias(res, where)
99  }
100
101  const hosted = HostedGit.fromUrl(spec, {
102    noGitPlus: true,
103    noCommittish: true,
104  })
105  if (hosted) {
106    return fromHostedGit(res, hosted)
107  } else if (spec && isURL.test(spec)) {
108    return fromURL(res)
109  } else if (spec && (hasSlashes.test(spec) || isFileType.test(spec))) {
110    return fromFile(res, where)
111  } else {
112    return fromRegistry(res)
113  }
114}
115
116function toPurl (arg, reg = defaultRegistry) {
117  const res = npa(arg)
118
119  if (res.type !== 'version') {
120    throw invalidPurlType(res.type, res.raw)
121  }
122
123  // URI-encode leading @ of scoped packages
124  let purl = 'pkg:npm/' + res.name.replace(/^@/, '%40') + '@' + res.rawSpec
125  if (reg !== defaultRegistry) {
126    purl += '?repository_url=' + reg
127  }
128
129  return purl
130}
131
132function invalidPackageName (name, valid, raw) {
133  // eslint-disable-next-line max-len
134  const err = new Error(`Invalid package name "${name}" of package "${raw}": ${valid.errors.join('; ')}.`)
135  err.code = 'EINVALIDPACKAGENAME'
136  return err
137}
138
139function invalidTagName (name, raw) {
140  // eslint-disable-next-line max-len
141  const err = new Error(`Invalid tag name "${name}" of package "${raw}": Tags may not have any characters that encodeURIComponent encodes.`)
142  err.code = 'EINVALIDTAGNAME'
143  return err
144}
145
146function invalidPurlType (type, raw) {
147  // eslint-disable-next-line max-len
148  const err = new Error(`Invalid type "${type}" of package "${raw}": Purl can only be generated for "version" types.`)
149  err.code = 'EINVALIDPURLTYPE'
150  return err
151}
152
153class Result {
154  constructor (opts) {
155    this.type = opts.type
156    this.registry = opts.registry
157    this.where = opts.where
158    if (opts.raw == null) {
159      this.raw = opts.name ? `${opts.name}@${opts.rawSpec}` : opts.rawSpec
160    } else {
161      this.raw = opts.raw
162    }
163    this.name = undefined
164    this.escapedName = undefined
165    this.scope = undefined
166    this.rawSpec = opts.rawSpec || ''
167    this.saveSpec = opts.saveSpec
168    this.fetchSpec = opts.fetchSpec
169    if (opts.name) {
170      this.setName(opts.name)
171    }
172    this.gitRange = opts.gitRange
173    this.gitCommittish = opts.gitCommittish
174    this.gitSubdir = opts.gitSubdir
175    this.hosted = opts.hosted
176  }
177
178  // TODO move this to a getter/setter in a semver major
179  setName (name) {
180    const valid = validatePackageName(name)
181    if (!valid.validForOldPackages) {
182      throw invalidPackageName(name, valid, this.raw)
183    }
184
185    this.name = name
186    this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
187    // scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
188    this.escapedName = name.replace('/', '%2f')
189    return this
190  }
191
192  toString () {
193    const full = []
194    if (this.name != null && this.name !== '') {
195      full.push(this.name)
196    }
197    const spec = this.saveSpec || this.fetchSpec || this.rawSpec
198    if (spec != null && spec !== '') {
199      full.push(spec)
200    }
201    return full.length ? full.join('@') : this.raw
202  }
203
204  toJSON () {
205    const result = Object.assign({}, this)
206    delete result.hosted
207    return result
208  }
209}
210
211// sets res.gitCommittish, res.gitRange, and res.gitSubdir
212function setGitAttrs (res, committish) {
213  if (!committish) {
214    res.gitCommittish = null
215    return
216  }
217
218  // for each :: separated item:
219  for (const part of committish.split('::')) {
220    // if the item has no : the n it is a commit-ish
221    if (!part.includes(':')) {
222      if (res.gitRange) {
223        throw new Error('cannot override existing semver range with a committish')
224      }
225      if (res.gitCommittish) {
226        throw new Error('cannot override existing committish with a second committish')
227      }
228      res.gitCommittish = part
229      continue
230    }
231    // split on name:value
232    const [name, value] = part.split(':')
233    // if name is semver do semver lookup of ref or tag
234    if (name === 'semver') {
235      if (res.gitCommittish) {
236        throw new Error('cannot override existing committish with a semver range')
237      }
238      if (res.gitRange) {
239        throw new Error('cannot override existing semver range with a second semver range')
240      }
241      res.gitRange = decodeURIComponent(value)
242      continue
243    }
244    if (name === 'path') {
245      if (res.gitSubdir) {
246        throw new Error('cannot override existing path with a second path')
247      }
248      res.gitSubdir = `/${value}`
249      continue
250    }
251    log.warn('npm-package-arg', `ignoring unknown key "${name}"`)
252  }
253}
254
255// Taken from: EncodePathChars and lookup_table in src/node_url.cc
256// url.pathToFileURL only returns absolute references.  We can't use it to encode paths.
257// encodeURI mangles windows paths. We can't use it to encode paths.
258// Under the hood, url.pathToFileURL does a limited set of encoding, with an extra windows step, and then calls path.resolve.
259// The encoding node does without path.resolve is not available outside of the source, so we are recreating it here.
260const encodedPathChars = new Map([
261  ['\0', '%00'],
262  ['\t', '%09'],
263  ['\n', '%0A'],
264  ['\r', '%0D'],
265  [' ', '%20'],
266  ['"', '%22'],
267  ['#', '%23'],
268  ['%', '%25'],
269  ['?', '%3F'],
270  ['[', '%5B'],
271  ['\\', isWindows ? '/' : '%5C'],
272  [']', '%5D'],
273  ['^', '%5E'],
274  ['|', '%7C'],
275  ['~', '%7E'],
276])
277
278function pathToFileURL (str) {
279  let result = ''
280  for (let i = 0; i < str.length; i++) {
281    result = `${result}${encodedPathChars.get(str[i]) ?? str[i]}`
282  }
283  if (result.startsWith('file:')) {
284    return result
285  }
286  return `file:${result}`
287}
288
289function fromFile (res, where) {
290  res.type = isFileType.test(res.rawSpec) ? 'file' : 'directory'
291  res.where = where
292
293  let rawSpec = pathToFileURL(res.rawSpec)
294
295  if (rawSpec.startsWith('file:/')) {
296    // XXX backwards compatibility lack of compliance with RFC 8089
297
298    // turn file://path into file:/path
299    if (/^file:\/\/[^/]/.test(rawSpec)) {
300      rawSpec = `file:/${rawSpec.slice(5)}`
301    }
302
303    // turn file:/../path into file:../path
304    // for 1 or 3 leading slashes (2 is already ruled out from handling file:// explicitly above)
305    if (/^\/{1,3}\.\.?(\/|$)/.test(rawSpec.slice(5))) {
306      rawSpec = rawSpec.replace(/^file:\/{1,3}/, 'file:')
307    }
308  }
309
310  let resolvedUrl
311  let specUrl
312  try {
313    // always put the '/' on "where", or else file:foo from /path/to/bar goes to /path/to/foo, when we want it to be /path/to/bar/foo
314    resolvedUrl = new URL(rawSpec, `${pathToFileURL(path.resolve(where))}/`)
315    specUrl = new URL(rawSpec)
316  } catch (originalError) {
317    const er = new Error('Invalid file: URL, must comply with RFC 8089')
318    throw Object.assign(er, {
319      raw: res.rawSpec,
320      spec: res,
321      where,
322      originalError,
323    })
324  }
325
326  // turn /C:/blah into just C:/blah on windows
327  let specPath = decodeURIComponent(specUrl.pathname)
328  let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
329  if (isWindows) {
330    specPath = specPath.replace(/^\/+([a-z]:\/)/i, '$1')
331    resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, '$1')
332  }
333
334  // replace ~ with homedir, but keep the ~ in the saveSpec
335  // otherwise, make it relative to where param
336  if (/^\/~(\/|$)/.test(specPath)) {
337    res.saveSpec = `file:${specPath.substr(1)}`
338    resolvedPath = path.resolve(homedir(), specPath.substr(3))
339  } else if (!path.isAbsolute(rawSpec.slice(5))) {
340    res.saveSpec = `file:${path.relative(where, resolvedPath)}`
341  } else {
342    res.saveSpec = `file:${path.resolve(resolvedPath)}`
343  }
344
345  res.fetchSpec = path.resolve(where, resolvedPath)
346  // re-normalize the slashes in saveSpec due to node:path/win32 behavior in windows
347  res.saveSpec = res.saveSpec.split('\\').join('/')
348  // Ignoring because this only happens in windows
349  /* istanbul ignore next */
350  if (res.saveSpec.startsWith('file://')) {
351    // normalization of \\win32\root paths can cause a double / which we don't want
352    res.saveSpec = `file:/${res.saveSpec.slice(7)}`
353  }
354  return res
355}
356
357function fromHostedGit (res, hosted) {
358  res.type = 'git'
359  res.hosted = hosted
360  res.saveSpec = hosted.toString({ noGitPlus: false, noCommittish: false })
361  res.fetchSpec = hosted.getDefaultRepresentation() === 'shortcut' ? null : hosted.toString()
362  setGitAttrs(res, hosted.committish)
363  return res
364}
365
366function unsupportedURLType (protocol, spec) {
367  const err = new Error(`Unsupported URL Type "${protocol}": ${spec}`)
368  err.code = 'EUNSUPPORTEDPROTOCOL'
369  return err
370}
371
372function fromURL (res) {
373  let rawSpec = res.rawSpec
374  res.saveSpec = rawSpec
375  if (rawSpec.startsWith('git+ssh:')) {
376    // git ssh specifiers are overloaded to also use scp-style git
377    // specifiers, so we have to parse those out and treat them special.
378    // They are NOT true URIs, so we can't hand them to URL.
379
380    // This regex looks for things that look like:
381    // git+ssh://git@my.custom.git.com:username/project.git#deadbeef
382    // ...and various combinations. The username in the beginning is *required*.
383    const matched = rawSpec.match(/^git\+ssh:\/\/([^:#]+:[^#]+(?:\.git)?)(?:#(.*))?$/i)
384    // Filter out all-number "usernames" which are really port numbers
385    // They can either be :1234 :1234/ or :1234/path but not :12abc
386    if (matched && !matched[1].match(isPortNumber)) {
387      res.type = 'git'
388      setGitAttrs(res, matched[2])
389      res.fetchSpec = matched[1]
390      return res
391    }
392  } else if (rawSpec.startsWith('git+file://')) {
393    // URL can't handle windows paths
394    rawSpec = rawSpec.replace(/\\/g, '/')
395  }
396  const parsedUrl = new URL(rawSpec)
397  // check the protocol, and then see if it's git or not
398  switch (parsedUrl.protocol) {
399    case 'git:':
400    case 'git+http:':
401    case 'git+https:':
402    case 'git+rsync:':
403    case 'git+ftp:':
404    case 'git+file:':
405    case 'git+ssh:':
406      res.type = 'git'
407      setGitAttrs(res, parsedUrl.hash.slice(1))
408      if (parsedUrl.protocol === 'git+file:' && /^git\+file:\/\/[a-z]:/i.test(rawSpec)) {
409        // URL can't handle drive letters on windows file paths, the host can't contain a :
410        res.fetchSpec = `git+file://${parsedUrl.host.toLowerCase()}:${parsedUrl.pathname}`
411      } else {
412        parsedUrl.hash = ''
413        res.fetchSpec = parsedUrl.toString()
414      }
415      if (res.fetchSpec.startsWith('git+')) {
416        res.fetchSpec = res.fetchSpec.slice(4)
417      }
418      break
419    case 'http:':
420    case 'https:':
421      res.type = 'remote'
422      res.fetchSpec = res.saveSpec
423      break
424
425    default:
426      throw unsupportedURLType(parsedUrl.protocol, rawSpec)
427  }
428
429  return res
430}
431
432function fromAlias (res, where) {
433  const subSpec = npa(res.rawSpec.substr(4), where)
434  if (subSpec.type === 'alias') {
435    throw new Error('nested aliases not supported')
436  }
437
438  if (!subSpec.registry) {
439    throw new Error('aliases only work for registry deps')
440  }
441
442  if (!subSpec.name) {
443    throw new Error('aliases must have a name')
444  }
445
446  res.subSpec = subSpec
447  res.registry = true
448  res.type = 'alias'
449  res.saveSpec = null
450  res.fetchSpec = null
451  return res
452}
453
454function fromRegistry (res) {
455  res.registry = true
456  const spec = res.rawSpec.trim()
457  // no save spec for registry components as we save based on the fetched
458  // version, not on the argument so this can't compute that.
459  res.saveSpec = null
460  res.fetchSpec = spec
461  const version = semver.valid(spec, true)
462  const range = semver.validRange(spec, true)
463  if (version) {
464    res.type = 'version'
465  } else if (range) {
466    res.type = 'range'
467  } else {
468    if (encodeURIComponent(spec) !== spec) {
469      throw invalidTagName(spec, res.raw)
470    }
471    res.type = 'tag'
472  }
473  return res
474}
475
476module.exports = npa
477module.exports.resolve = resolve
478module.exports.toPurl = toPurl
479module.exports.Result = Result
480 
codekingpro/portable-devtools · Team Ai