hosted-git-info
Advanced tools
Comparing version 5.1.0 to 6.0.0
326
lib/index.js
'use strict' | ||
const url = require('url') | ||
const gitHosts = require('./git-host-info.js') | ||
const GitHost = module.exports = require('./git-host.js') | ||
const LRU = require('lru-cache') | ||
const hosts = require('./hosts.js') | ||
const fromUrl = require('./from-url.js') | ||
const cache = new LRU({ max: 1000 }) | ||
const protocolToRepresentationMap = { | ||
'git+ssh:': 'sshurl', | ||
'git+https:': 'https', | ||
'ssh:': 'sshurl', | ||
'git:': 'git', | ||
} | ||
class GitHost { | ||
constructor (type, user, auth, project, committish, defaultRepresentation, opts = {}) { | ||
Object.assign(this, GitHost.#gitHosts[type], { | ||
type, | ||
user, | ||
auth, | ||
project, | ||
committish, | ||
default: defaultRepresentation, | ||
opts, | ||
}) | ||
} | ||
function protocolToRepresentation (protocol) { | ||
return protocolToRepresentationMap[protocol] || protocol.slice(0, -1) | ||
} | ||
static #gitHosts = { byShortcut: {}, byDomain: {} } | ||
static #protocols = { | ||
'git+ssh:': { name: 'sshurl' }, | ||
'ssh:': { name: 'sshurl' }, | ||
'git+https:': { name: 'https', auth: true }, | ||
'git:': { auth: true }, | ||
'http:': { auth: true }, | ||
'https:': { auth: true }, | ||
'git+http:': { auth: true }, | ||
} | ||
const authProtocols = { | ||
'git:': true, | ||
'https:': true, | ||
'git+https:': true, | ||
'http:': true, | ||
'git+http:': true, | ||
} | ||
const knownProtocols = Object.keys(gitHosts.byShortcut) | ||
.concat(['http:', 'https:', 'git:', 'git+ssh:', 'git+https:', 'ssh:']) | ||
module.exports.fromUrl = function (giturl, opts) { | ||
if (typeof giturl !== 'string') { | ||
return | ||
static addHost (name, host) { | ||
GitHost.#gitHosts[name] = host | ||
GitHost.#gitHosts.byDomain[host.domain] = name | ||
GitHost.#gitHosts.byShortcut[`${name}:`] = name | ||
GitHost.#protocols[`${name}:`] = { name } | ||
} | ||
const key = giturl + JSON.stringify(opts || {}) | ||
static fromUrl (giturl, opts) { | ||
if (typeof giturl !== 'string') { | ||
return | ||
} | ||
if (!cache.has(key)) { | ||
cache.set(key, fromUrl(giturl, opts)) | ||
} | ||
const key = giturl + JSON.stringify(opts || {}) | ||
return cache.get(key) | ||
} | ||
if (!cache.has(key)) { | ||
const hostArgs = fromUrl(giturl, opts, { | ||
gitHosts: GitHost.#gitHosts, | ||
protocols: GitHost.#protocols, | ||
}) | ||
cache.set(key, hostArgs ? new GitHost(...hostArgs) : undefined) | ||
} | ||
function fromUrl (giturl, opts) { | ||
if (!giturl) { | ||
return | ||
return cache.get(key) | ||
} | ||
const correctedUrl = isGitHubShorthand(giturl) ? 'github:' + giturl : correctProtocol(giturl) | ||
const parsed = parseGitUrl(correctedUrl) | ||
if (!parsed) { | ||
return parsed | ||
} | ||
#fill (template, opts) { | ||
if (typeof template !== 'function') { | ||
return null | ||
} | ||
const gitHostShortcut = gitHosts.byShortcut[parsed.protocol] | ||
const gitHostDomain = | ||
gitHosts.byDomain[parsed.hostname.startsWith('www.') ? | ||
parsed.hostname.slice(4) : | ||
parsed.hostname] | ||
const gitHostName = gitHostShortcut || gitHostDomain | ||
if (!gitHostName) { | ||
return | ||
} | ||
const options = { ...this, ...this.opts, ...opts } | ||
const gitHostInfo = gitHosts[gitHostShortcut || gitHostDomain] | ||
let auth = null | ||
if (authProtocols[parsed.protocol] && (parsed.username || parsed.password)) { | ||
auth = `${parsed.username}${parsed.password ? ':' + parsed.password : ''}` | ||
} | ||
// the path should always be set so we don't end up with 'undefined' in urls | ||
if (!options.path) { | ||
options.path = '' | ||
} | ||
let committish = null | ||
let user = null | ||
let project = null | ||
let defaultRepresentation = null | ||
// template functions will insert the leading slash themselves | ||
if (options.path.startsWith('/')) { | ||
options.path = options.path.slice(1) | ||
} | ||
try { | ||
if (gitHostShortcut) { | ||
let pathname = parsed.pathname.startsWith('/') ? parsed.pathname.slice(1) : parsed.pathname | ||
const firstAt = pathname.indexOf('@') | ||
// we ignore auth for shortcuts, so just trim it out | ||
if (firstAt > -1) { | ||
pathname = pathname.slice(firstAt + 1) | ||
} | ||
if (options.noCommittish) { | ||
options.committish = null | ||
} | ||
const lastSlash = pathname.lastIndexOf('/') | ||
if (lastSlash > -1) { | ||
user = decodeURIComponent(pathname.slice(0, lastSlash)) | ||
// we want nulls only, never empty strings | ||
if (!user) { | ||
user = null | ||
} | ||
project = decodeURIComponent(pathname.slice(lastSlash + 1)) | ||
} else { | ||
project = decodeURIComponent(pathname) | ||
} | ||
const result = template(options) | ||
return options.noGitPlus && result.startsWith('git+') ? result.slice(4) : result | ||
} | ||
if (project.endsWith('.git')) { | ||
project = project.slice(0, -4) | ||
} | ||
hash () { | ||
return this.committish ? `#${this.committish}` : '' | ||
} | ||
if (parsed.hash) { | ||
committish = decodeURIComponent(parsed.hash.slice(1)) | ||
} | ||
ssh (opts) { | ||
return this.#fill(this.sshtemplate, opts) | ||
} | ||
defaultRepresentation = 'shortcut' | ||
} else { | ||
if (!gitHostInfo.protocols.includes(parsed.protocol)) { | ||
return | ||
} | ||
sshurl (opts) { | ||
return this.#fill(this.sshurltemplate, opts) | ||
} | ||
const segments = gitHostInfo.extract(parsed) | ||
if (!segments) { | ||
return | ||
} | ||
browse (path, ...args) { | ||
// not a string, treat path as opts | ||
if (typeof path !== 'string') { | ||
return this.#fill(this.browsetemplate, path) | ||
} | ||
user = segments.user && decodeURIComponent(segments.user) | ||
project = decodeURIComponent(segments.project) | ||
committish = decodeURIComponent(segments.committish) | ||
defaultRepresentation = protocolToRepresentation(parsed.protocol) | ||
if (typeof args[0] !== 'string') { | ||
return this.#fill(this.browsetreetemplate, { ...args[0], path }) | ||
} | ||
} catch (err) { | ||
/* istanbul ignore else */ | ||
if (err instanceof URIError) { | ||
return | ||
} else { | ||
throw err | ||
} | ||
return this.#fill(this.browsetreetemplate, { ...args[1], fragment: args[0], path }) | ||
} | ||
return new GitHost(gitHostName, user, auth, project, committish, defaultRepresentation, opts) | ||
} | ||
// If the path is known to be a file, then browseFile should be used. For some hosts | ||
// the url is the same as browse, but for others like GitHub a file can use both `/tree/` | ||
// and `/blob/` in the path. When using a default committish of `HEAD` then the `/tree/` | ||
// path will redirect to a specific commit. Using the `/blob/` path avoids this and | ||
// does not redirect to a different commit. | ||
browseFile (path, ...args) { | ||
if (typeof args[0] !== 'string') { | ||
return this.#fill(this.browseblobtemplate, { ...args[0], path }) | ||
} | ||
// accepts input like git:github.com:user/repo and inserts the // after the first : | ||
const correctProtocol = (arg) => { | ||
const firstColon = arg.indexOf(':') | ||
const proto = arg.slice(0, firstColon + 1) | ||
if (knownProtocols.includes(proto)) { | ||
return arg | ||
return this.#fill(this.browseblobtemplate, { ...args[1], fragment: args[0], path }) | ||
} | ||
const firstAt = arg.indexOf('@') | ||
if (firstAt > -1) { | ||
if (firstAt > firstColon) { | ||
return `git+ssh://${arg}` | ||
} else { | ||
return arg | ||
} | ||
docs (opts) { | ||
return this.#fill(this.docstemplate, opts) | ||
} | ||
const doubleSlash = arg.indexOf('//') | ||
if (doubleSlash === firstColon + 1) { | ||
return arg | ||
bugs (opts) { | ||
return this.#fill(this.bugstemplate, opts) | ||
} | ||
return arg.slice(0, firstColon + 1) + '//' + arg.slice(firstColon + 1) | ||
} | ||
https (opts) { | ||
return this.#fill(this.httpstemplate, opts) | ||
} | ||
// look for github shorthand inputs, such as npm/cli | ||
const isGitHubShorthand = (arg) => { | ||
// it cannot contain whitespace before the first # | ||
// it cannot start with a / because that's probably an absolute file path | ||
// but it must include a slash since repos are username/repository | ||
// it cannot start with a . because that's probably a relative file path | ||
// it cannot start with an @ because that's a scoped package if it passes the other tests | ||
// it cannot contain a : before a # because that tells us that there's a protocol | ||
// a second / may not exist before a # | ||
const firstHash = arg.indexOf('#') | ||
const firstSlash = arg.indexOf('/') | ||
const secondSlash = arg.indexOf('/', firstSlash + 1) | ||
const firstColon = arg.indexOf(':') | ||
const firstSpace = /\s/.exec(arg) | ||
const firstAt = arg.indexOf('@') | ||
git (opts) { | ||
return this.#fill(this.gittemplate, opts) | ||
} | ||
const spaceOnlyAfterHash = !firstSpace || (firstHash > -1 && firstSpace.index > firstHash) | ||
const atOnlyAfterHash = firstAt === -1 || (firstHash > -1 && firstAt > firstHash) | ||
const colonOnlyAfterHash = firstColon === -1 || (firstHash > -1 && firstColon > firstHash) | ||
const secondSlashOnlyAfterHash = secondSlash === -1 || (firstHash > -1 && secondSlash > firstHash) | ||
const hasSlash = firstSlash > 0 | ||
// if a # is found, what we really want to know is that the character | ||
// immediately before # is not a / | ||
const doesNotEndWithSlash = firstHash > -1 ? arg[firstHash - 1] !== '/' : !arg.endsWith('/') | ||
const doesNotStartWithDot = !arg.startsWith('.') | ||
shortcut (opts) { | ||
return this.#fill(this.shortcuttemplate, opts) | ||
} | ||
return spaceOnlyAfterHash && hasSlash && doesNotEndWithSlash && | ||
doesNotStartWithDot && atOnlyAfterHash && colonOnlyAfterHash && | ||
secondSlashOnlyAfterHash | ||
} | ||
path (opts) { | ||
return this.#fill(this.pathtemplate, opts) | ||
} | ||
// attempt to correct an scp style url so that it will parse with `new URL()` | ||
const correctUrl = (giturl) => { | ||
const firstAt = giturl.indexOf('@') | ||
const lastHash = giturl.lastIndexOf('#') | ||
let firstColon = giturl.indexOf(':') | ||
let lastColon = giturl.lastIndexOf(':', lastHash > -1 ? lastHash : Infinity) | ||
let corrected | ||
if (lastColon > firstAt) { | ||
// the last : comes after the first @ (or there is no @) | ||
// like it would in: | ||
// proto://hostname.com:user/repo | ||
// username@hostname.com:user/repo | ||
// :password@hostname.com:user/repo | ||
// username:password@hostname.com:user/repo | ||
// proto://username@hostname.com:user/repo | ||
// proto://:password@hostname.com:user/repo | ||
// proto://username:password@hostname.com:user/repo | ||
// then we replace the last : with a / to create a valid path | ||
corrected = giturl.slice(0, lastColon) + '/' + giturl.slice(lastColon + 1) | ||
// // and we find our new : positions | ||
firstColon = corrected.indexOf(':') | ||
lastColon = corrected.lastIndexOf(':') | ||
tarball (opts) { | ||
return this.#fill(this.tarballtemplate, { ...opts, noCommittish: false }) | ||
} | ||
if (firstColon === -1 && giturl.indexOf('//') === -1) { | ||
// we have no : at all | ||
// as it would be in: | ||
// username@hostname.com/user/repo | ||
// then we prepend a protocol | ||
corrected = `git+ssh://${corrected}` | ||
file (path, opts) { | ||
return this.#fill(this.filetemplate, { ...opts, path }) | ||
} | ||
return corrected | ||
} | ||
// try to parse the url as its given to us, if that throws | ||
// then we try to clean the url and parse that result instead | ||
// THIS FUNCTION SHOULD NEVER THROW | ||
const parseGitUrl = (giturl) => { | ||
let result | ||
try { | ||
result = new url.URL(giturl) | ||
} catch { | ||
// this fn should never throw | ||
edit (path, opts) { | ||
return this.#fill(this.edittemplate, { ...opts, path }) | ||
} | ||
if (result) { | ||
return result | ||
getDefaultRepresentation () { | ||
return this.default | ||
} | ||
const correctedUrl = correctUrl(giturl) | ||
try { | ||
result = new url.URL(correctedUrl) | ||
} catch { | ||
// this fn should never throw | ||
toString (opts) { | ||
if (this.default && typeof this[this.default] === 'function') { | ||
return this[this.default](opts) | ||
} | ||
return this.sshurl(opts) | ||
} | ||
} | ||
return result | ||
for (const [name, host] of Object.entries(hosts)) { | ||
GitHost.addHost(name, host) | ||
} | ||
module.exports = GitHost |
{ | ||
"name": "hosted-git-info", | ||
"version": "5.1.0", | ||
"version": "6.0.0", | ||
"description": "Provides metadata and conversions from repository urls for GitHub, Bitbucket and GitLab", | ||
@@ -24,5 +24,2 @@ "main": "./lib/index.js", | ||
"posttest": "npm run lint", | ||
"postversion": "npm publish", | ||
"prepublishOnly": "git push origin --follow-tags", | ||
"preversion": "npm test", | ||
"snap": "tap", | ||
@@ -41,3 +38,3 @@ "test": "tap", | ||
"@npmcli/eslint-config": "^3.0.1", | ||
"@npmcli/template-oss": "3.5.0", | ||
"@npmcli/template-oss": "4.5.1", | ||
"tap": "^16.0.1" | ||
@@ -50,12 +47,16 @@ }, | ||
"engines": { | ||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" | ||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" | ||
}, | ||
"tap": { | ||
"color": 1, | ||
"coverage": true | ||
"coverage": true, | ||
"nyc-arg": [ | ||
"--exclude", | ||
"tap-snapshots/**" | ||
] | ||
}, | ||
"templateOSS": { | ||
"//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", | ||
"version": "3.5.0" | ||
"version": "4.5.1" | ||
} | ||
} |
@@ -10,4 +10,4 @@ # hosted-git-info | ||
```javascript | ||
var hostedGitInfo = require("hosted-git-info") | ||
var info = hostedGitInfo.fromUrl("git@github.com:npm/hosted-git-info.git", opts) | ||
const hostedGitInfo = require("hosted-git-info") | ||
const info = hostedGitInfo.fromUrl("git@github.com:npm/hosted-git-info.git", opts) | ||
/* info looks like: | ||
@@ -56,3 +56,3 @@ { | ||
### var info = hostedGitInfo.fromUrl(gitSpecifier[, options]) | ||
### const info = hostedGitInfo.fromUrl(gitSpecifier[, options]) | ||
@@ -73,3 +73,3 @@ * *gitSpecifer* is a URL of a git repository or a SCP-style specifier of one. | ||
directly fetching it from the githost. If no committish was set then | ||
`master` will be used as the default. | ||
`HEAD` will be used as the default. | ||
@@ -135,3 +135,3 @@ For example `hostedGitInfo.fromUrl("git@github.com:npm/hosted-git-info.git#v1.0.0").file("package.json")` | ||
Currently this supports GitHub, Bitbucket and GitLab. Pull requests for | ||
additional hosts welcome. | ||
Currently this supports GitHub (including Gists), Bitbucket, GitLab and Sourcehut. | ||
Pull requests for additional hosts welcome. |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
26320
506