395 lines
9.1 KiB
JavaScript
395 lines
9.1 KiB
JavaScript
|
/*
|
||
|
MIT License http://www.opensource.org/licenses/mit-license.php
|
||
|
Author Tobias Koppers @sokra
|
||
|
*/
|
||
|
|
||
|
"use strict";
|
||
|
|
||
|
const { join, dirname, readJson } = require("../util/fs");
|
||
|
|
||
|
/** @typedef {import("../util/fs").InputFileSystem} InputFileSystem */
|
||
|
/** @typedef {import("../util/fs").JsonObject} JsonObject */
|
||
|
/** @typedef {import("../util/fs").JsonPrimitive} JsonPrimitive */
|
||
|
|
||
|
// Extreme shorthand only for github. eg: foo/bar
|
||
|
const RE_URL_GITHUB_EXTREME_SHORT = /^[^/@:.\s][^/@:\s]*\/[^@:\s]*[^/@:\s]#\S+/;
|
||
|
|
||
|
// Short url with specific protocol. eg: github:foo/bar
|
||
|
const RE_GIT_URL_SHORT = /^(github|gitlab|bitbucket|gist):\/?[^/.]+\/?/i;
|
||
|
|
||
|
// Currently supported protocols
|
||
|
const RE_PROTOCOL =
|
||
|
/^((git\+)?(ssh|https?|file)|git|github|gitlab|bitbucket|gist):$/i;
|
||
|
|
||
|
// Has custom protocol
|
||
|
const RE_CUSTOM_PROTOCOL = /^((git\+)?(ssh|https?|file)|git):\/\//i;
|
||
|
|
||
|
// Valid hash format for npm / yarn ...
|
||
|
const RE_URL_HASH_VERSION = /#(?:semver:)?(.+)/;
|
||
|
|
||
|
// Simple hostname validate
|
||
|
const RE_HOSTNAME = /^(?:[^/.]+(\.[^/]+)+|localhost)$/;
|
||
|
|
||
|
// For hostname with colon. eg: ssh://user@github.com:foo/bar
|
||
|
const RE_HOSTNAME_WITH_COLON =
|
||
|
/([^/@#:.]+(?:\.[^/@#:.]+)+|localhost):([^#/0-9]+)/;
|
||
|
|
||
|
// Reg for url without protocol
|
||
|
const RE_NO_PROTOCOL = /^([^/@#:.]+(?:\.[^/@#:.]+)+)/;
|
||
|
|
||
|
// RegExp for version string
|
||
|
const VERSION_PATTERN_REGEXP = /^([\d^=v<>~]|[*xX]$)/;
|
||
|
|
||
|
// Specific protocol for short url without normal hostname
|
||
|
const PROTOCOLS_FOR_SHORT = [
|
||
|
"github:",
|
||
|
"gitlab:",
|
||
|
"bitbucket:",
|
||
|
"gist:",
|
||
|
"file:"
|
||
|
];
|
||
|
|
||
|
// Default protocol for git url
|
||
|
const DEF_GIT_PROTOCOL = "git+ssh://";
|
||
|
|
||
|
// thanks to https://github.com/npm/hosted-git-info/blob/latest/git-host-info.js
|
||
|
const extractCommithashByDomain = {
|
||
|
/**
|
||
|
* @param {string} pathname pathname
|
||
|
* @param {string} hash hash
|
||
|
* @returns {string | undefined} hash
|
||
|
*/
|
||
|
"github.com": (pathname, hash) => {
|
||
|
let [, user, project, type, commithash] = pathname.split("/", 5);
|
||
|
if (type && type !== "tree") {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
commithash = !type ? hash : `#${commithash}`;
|
||
|
|
||
|
if (project && project.endsWith(".git")) {
|
||
|
project = project.slice(0, -4);
|
||
|
}
|
||
|
|
||
|
if (!user || !project) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
return commithash;
|
||
|
},
|
||
|
/**
|
||
|
* @param {string} pathname pathname
|
||
|
* @param {string} hash hash
|
||
|
* @returns {string | undefined} hash
|
||
|
*/
|
||
|
"gitlab.com": (pathname, hash) => {
|
||
|
const path = pathname.slice(1);
|
||
|
if (path.includes("/-/") || path.includes("/archive.tar.gz")) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const segments = path.split("/");
|
||
|
let project = /** @type {string} */ (segments.pop());
|
||
|
if (project.endsWith(".git")) {
|
||
|
project = project.slice(0, -4);
|
||
|
}
|
||
|
|
||
|
const user = segments.join("/");
|
||
|
if (!user || !project) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
return hash;
|
||
|
},
|
||
|
/**
|
||
|
* @param {string} pathname pathname
|
||
|
* @param {string} hash hash
|
||
|
* @returns {string | undefined} hash
|
||
|
*/
|
||
|
"bitbucket.org": (pathname, hash) => {
|
||
|
let [, user, project, aux] = pathname.split("/", 4);
|
||
|
if (["get"].includes(aux)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (project && project.endsWith(".git")) {
|
||
|
project = project.slice(0, -4);
|
||
|
}
|
||
|
|
||
|
if (!user || !project) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
return hash;
|
||
|
},
|
||
|
/**
|
||
|
* @param {string} pathname pathname
|
||
|
* @param {string} hash hash
|
||
|
* @returns {string | undefined} hash
|
||
|
*/
|
||
|
"gist.github.com": (pathname, hash) => {
|
||
|
let [, user, project, aux] = pathname.split("/", 4);
|
||
|
if (aux === "raw") {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (!project) {
|
||
|
if (!user) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
project = user;
|
||
|
}
|
||
|
|
||
|
if (project.endsWith(".git")) {
|
||
|
project = project.slice(0, -4);
|
||
|
}
|
||
|
|
||
|
return hash;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* extract commit hash from parsed url
|
||
|
* @inner
|
||
|
* @param {URL} urlParsed parsed url
|
||
|
* @returns {string} commithash
|
||
|
*/
|
||
|
function getCommithash(urlParsed) {
|
||
|
let { hostname, pathname, hash } = urlParsed;
|
||
|
hostname = hostname.replace(/^www\./, "");
|
||
|
|
||
|
try {
|
||
|
hash = decodeURIComponent(hash);
|
||
|
// eslint-disable-next-line no-empty
|
||
|
} catch (_err) {}
|
||
|
|
||
|
if (
|
||
|
extractCommithashByDomain[
|
||
|
/** @type {keyof extractCommithashByDomain} */ (hostname)
|
||
|
]
|
||
|
) {
|
||
|
return (
|
||
|
extractCommithashByDomain[
|
||
|
/** @type {keyof extractCommithashByDomain} */ (hostname)
|
||
|
](pathname, hash) || ""
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return hash;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* make url right for URL parse
|
||
|
* @inner
|
||
|
* @param {string} gitUrl git url
|
||
|
* @returns {string} fixed url
|
||
|
*/
|
||
|
function correctUrl(gitUrl) {
|
||
|
// like:
|
||
|
// proto://hostname.com:user/repo -> proto://hostname.com/user/repo
|
||
|
return gitUrl.replace(RE_HOSTNAME_WITH_COLON, "$1/$2");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* make url protocol right for URL parse
|
||
|
* @inner
|
||
|
* @param {string} gitUrl git url
|
||
|
* @returns {string} fixed url
|
||
|
*/
|
||
|
function correctProtocol(gitUrl) {
|
||
|
// eg: github:foo/bar#v1.0. Should not add double slash, in case of error parsed `pathname`
|
||
|
if (RE_GIT_URL_SHORT.test(gitUrl)) {
|
||
|
return gitUrl;
|
||
|
}
|
||
|
|
||
|
// eg: user@github.com:foo/bar
|
||
|
if (!RE_CUSTOM_PROTOCOL.test(gitUrl)) {
|
||
|
return `${DEF_GIT_PROTOCOL}${gitUrl}`;
|
||
|
}
|
||
|
|
||
|
return gitUrl;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* extract git dep version from hash
|
||
|
* @inner
|
||
|
* @param {string} hash hash
|
||
|
* @returns {string} git dep version
|
||
|
*/
|
||
|
function getVersionFromHash(hash) {
|
||
|
const matched = hash.match(RE_URL_HASH_VERSION);
|
||
|
|
||
|
return (matched && matched[1]) || "";
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* if string can be decoded
|
||
|
* @inner
|
||
|
* @param {string} str str to be checked
|
||
|
* @returns {boolean} if can be decoded
|
||
|
*/
|
||
|
function canBeDecoded(str) {
|
||
|
try {
|
||
|
decodeURIComponent(str);
|
||
|
} catch (_err) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* get right dep version from git url
|
||
|
* @inner
|
||
|
* @param {string} gitUrl git url
|
||
|
* @returns {string} dep version
|
||
|
*/
|
||
|
function getGitUrlVersion(gitUrl) {
|
||
|
const oriGitUrl = gitUrl;
|
||
|
// github extreme shorthand
|
||
|
gitUrl = RE_URL_GITHUB_EXTREME_SHORT.test(gitUrl)
|
||
|
? `github:${gitUrl}`
|
||
|
: correctProtocol(gitUrl);
|
||
|
|
||
|
gitUrl = correctUrl(gitUrl);
|
||
|
|
||
|
let parsed;
|
||
|
try {
|
||
|
parsed = new URL(gitUrl);
|
||
|
// eslint-disable-next-line no-empty
|
||
|
} catch (_err) {}
|
||
|
|
||
|
if (!parsed) {
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
const { protocol, hostname, pathname, username, password } = parsed;
|
||
|
if (!RE_PROTOCOL.test(protocol)) {
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
// pathname shouldn't be empty or URL malformed
|
||
|
if (!pathname || !canBeDecoded(pathname)) {
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
// without protocol, there should have auth info
|
||
|
if (RE_NO_PROTOCOL.test(oriGitUrl) && !username && !password) {
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
if (!PROTOCOLS_FOR_SHORT.includes(protocol.toLowerCase())) {
|
||
|
if (!RE_HOSTNAME.test(hostname)) {
|
||
|
return "";
|
||
|
}
|
||
|
|
||
|
const commithash = getCommithash(parsed);
|
||
|
return getVersionFromHash(commithash) || commithash;
|
||
|
}
|
||
|
|
||
|
// for protocol short
|
||
|
return getVersionFromHash(gitUrl);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param {string} str maybe required version
|
||
|
* @returns {boolean} true, if it looks like a version
|
||
|
*/
|
||
|
function isRequiredVersion(str) {
|
||
|
return VERSION_PATTERN_REGEXP.test(str);
|
||
|
}
|
||
|
|
||
|
module.exports.isRequiredVersion = isRequiredVersion;
|
||
|
|
||
|
/**
|
||
|
* @see https://docs.npmjs.com/cli/v7/configuring-npm/package-json#urls-as-dependencies
|
||
|
* @param {string} versionDesc version to be normalized
|
||
|
* @returns {string} normalized version
|
||
|
*/
|
||
|
function normalizeVersion(versionDesc) {
|
||
|
versionDesc = (versionDesc && versionDesc.trim()) || "";
|
||
|
|
||
|
if (isRequiredVersion(versionDesc)) {
|
||
|
return versionDesc;
|
||
|
}
|
||
|
|
||
|
// add handle for URL Dependencies
|
||
|
return getGitUrlVersion(versionDesc.toLowerCase());
|
||
|
}
|
||
|
|
||
|
module.exports.normalizeVersion = normalizeVersion;
|
||
|
|
||
|
/** @typedef {{ data: JsonObject, path: string }} DescriptionFile */
|
||
|
|
||
|
/**
|
||
|
* @param {InputFileSystem} fs file system
|
||
|
* @param {string} directory directory to start looking into
|
||
|
* @param {string[]} descriptionFiles possible description filenames
|
||
|
* @param {function((Error | null)=, DescriptionFile=): void} callback callback
|
||
|
*/
|
||
|
const getDescriptionFile = (fs, directory, descriptionFiles, callback) => {
|
||
|
let i = 0;
|
||
|
const tryLoadCurrent = () => {
|
||
|
if (i >= descriptionFiles.length) {
|
||
|
const parentDirectory = dirname(fs, directory);
|
||
|
if (!parentDirectory || parentDirectory === directory) return callback();
|
||
|
return getDescriptionFile(
|
||
|
fs,
|
||
|
parentDirectory,
|
||
|
descriptionFiles,
|
||
|
callback
|
||
|
);
|
||
|
}
|
||
|
const filePath = join(fs, directory, descriptionFiles[i]);
|
||
|
readJson(fs, filePath, (err, data) => {
|
||
|
if (err) {
|
||
|
if ("code" in err && err.code === "ENOENT") {
|
||
|
i++;
|
||
|
return tryLoadCurrent();
|
||
|
}
|
||
|
return callback(err);
|
||
|
}
|
||
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||
|
return callback(
|
||
|
new Error(`Description file ${filePath} is not an object`)
|
||
|
);
|
||
|
}
|
||
|
callback(null, { data, path: filePath });
|
||
|
});
|
||
|
};
|
||
|
tryLoadCurrent();
|
||
|
};
|
||
|
module.exports.getDescriptionFile = getDescriptionFile;
|
||
|
|
||
|
/**
|
||
|
* @param {JsonObject} data description file data i.e.: package.json
|
||
|
* @param {string} packageName name of the dependency
|
||
|
* @returns {string | undefined} normalized version
|
||
|
*/
|
||
|
const getRequiredVersionFromDescriptionFile = (data, packageName) => {
|
||
|
const dependencyTypes = [
|
||
|
"optionalDependencies",
|
||
|
"dependencies",
|
||
|
"peerDependencies",
|
||
|
"devDependencies"
|
||
|
];
|
||
|
|
||
|
for (const dependencyType of dependencyTypes) {
|
||
|
const dependency = /** @type {JsonObject} */ (data[dependencyType]);
|
||
|
if (
|
||
|
dependency &&
|
||
|
typeof dependency === "object" &&
|
||
|
packageName in dependency
|
||
|
) {
|
||
|
return normalizeVersion(
|
||
|
/** @type {Exclude<JsonPrimitive, null | boolean| number>} */ (
|
||
|
dependency[packageName]
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
module.exports.getRequiredVersionFromDescriptionFile =
|
||
|
getRequiredVersionFromDescriptionFile;
|