codekingpro/portable-devtools
114k
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 