426 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			426 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*
 | |
| 	MIT License http://www.opensource.org/licenses/mit-license.php
 | |
| 	Author Tobias Koppers @sokra
 | |
| */
 | |
| 
 | |
| "use strict";
 | |
| 
 | |
| const { dirname, join, 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);
 | |
| }
 | |
| 
 | |
| /** @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 {(err?: Error | null, descriptionFile?: DescriptionFile, paths?: string[]) => void} callback callback
 | |
|  * @param {(descriptionFile?: DescriptionFile) => boolean} satisfiesDescriptionFileData file data compliance check
 | |
|  * @param {Set<string>} checkedFilePaths set of file paths that have been checked
 | |
|  */
 | |
| const getDescriptionFile = (
 | |
| 	fs,
 | |
| 	directory,
 | |
| 	descriptionFiles,
 | |
| 	callback,
 | |
| 	satisfiesDescriptionFileData,
 | |
| 	checkedFilePaths = new Set()
 | |
| ) => {
 | |
| 	let i = 0;
 | |
| 
 | |
| 	const satisfiesDescriptionFileDataInternal = {
 | |
| 		check: satisfiesDescriptionFileData,
 | |
| 		checkedFilePaths
 | |
| 	};
 | |
| 
 | |
| 	const tryLoadCurrent = () => {
 | |
| 		if (i >= descriptionFiles.length) {
 | |
| 			const parentDirectory = dirname(fs, directory);
 | |
| 			if (!parentDirectory || parentDirectory === directory) {
 | |
| 				return callback(null, undefined, [
 | |
| 					...satisfiesDescriptionFileDataInternal.checkedFilePaths
 | |
| 				]);
 | |
| 			}
 | |
| 			return getDescriptionFile(
 | |
| 				fs,
 | |
| 				parentDirectory,
 | |
| 				descriptionFiles,
 | |
| 				callback,
 | |
| 				satisfiesDescriptionFileDataInternal.check,
 | |
| 				satisfiesDescriptionFileDataInternal.checkedFilePaths
 | |
| 			);
 | |
| 		}
 | |
| 		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`)
 | |
| 				);
 | |
| 			}
 | |
| 			if (
 | |
| 				typeof satisfiesDescriptionFileDataInternal.check === "function" &&
 | |
| 				!satisfiesDescriptionFileDataInternal.check({ data, path: filePath })
 | |
| 			) {
 | |
| 				i++;
 | |
| 				satisfiesDescriptionFileDataInternal.checkedFilePaths.add(filePath);
 | |
| 				return tryLoadCurrent();
 | |
| 			}
 | |
| 			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;
 | |
| 
 | |
| /**
 | |
|  * @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;
 |