@userfrosting/merge-package-dependencies
Version:
Merge NPM, Yarn or Bower package dependencies into one package, with semver rules respected.
585 lines • 25.6 kB
JavaScript
import semver from "semver";
import { intersect as semverIntersect } from "semver-intersect";
import { PJV } from "package-json-validator";
import fs from "fs";
import path from "path";
import chalk from "chalk";
import yarnLockParser from "@yarnpkg/lockfile";
import * as Exceptions from "./exceptions.js";
export { LogicalException, InvalidArgumentException, InvalidNodePackageException, InvalidBowerPackageException, } from "./exceptions.js";
const packageJsonValidator = PJV.validate;
// Known dependency keys
const npmDependencyTypes = [
"dependencies",
"devDependencies",
"peerDependencies",
];
const yarnDependencyTypes = [
"dependencies",
"devDependencies",
"peerDependencies",
"resolutions"
];
const bowerDependencyTypes = [
"dependencies",
"devDependencies",
"resolutions"
];
/**
* Merge specified npm packages together.
*
* @param template - Template that packages will be merged into. Is validated with [package-json-validator](https://www.npmjs.com/package/package-json-validator) with template.private == true overriding this.
* @param paths - Paths to package.json files. EG: "path/to/" (package.json is prepended) or "path/to/package.json" or "path/to/different.json".
* @param saveTo - If string, saves the generated package.json to the specified path. Like 'paths', has 'package.json' prepended if required.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
*
* @public
*/
export function npm(template, paths, saveTo = null, log = false) {
return packageJsonMerge(template, paths, saveTo, log, "npm");
}
;
/**
* Merge specified yarn packages together.
*
* @param template - Template that packages will be merged into. Is validated with [package-json-validator](https://www.npmjs.com/package/package-json-validator) with template.private == true overriding this.
* @param paths - Paths to package.json files. EG: "path/to/" (package.json is prepended) or "path/to/package.json" or "path/to/different.json".
* @param saveTo - If string, saves the generated package.json to the specified path. Like 'paths', has 'package.json' prepended if required.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
*
* @public
*/
export function yarn(template, paths, saveTo = null, log = false) {
return packageJsonMerge(template, paths, saveTo, log, "yarn");
}
/**
* Merge specified bower packages together.
*
* @param template - Template that packages will be merged into. Is validated with [bower-json](https://www.npmjs.com/package/bower-json).
* @param paths - Paths to bower.json files. EG: "path/to/" (bower.json is prepended) or "path/to/bower.json" or "path/to/different.json".
* @param saveTo - If string, saves the generated bower.json to the specified path. Like 'paths', has 'bower.json' prepended if required.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
*
* @public
*/
export function bower(template, paths, saveTo = null, log = false) {
return bowerMerge(template, paths, saveTo, log);
}
;
/**
* Uses `yarn.lock` to detect if multiple versions of a dependency have been installed.
*
* @param p - Directory of `yarn.lock`.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
*
* @public
*/
export function yarnIsFlat(p = process.cwd(), log = false) {
if (log === true) {
log = console.log;
}
else if (!log) {
log = () => { };
}
// Normalize provided directory
p = path.normalize(p + '/');
log(`Checking for duplicate dependencies with '${p + 'yarn.lock'}'`);
// Parse lockfile
let yarnLock = yarnLockParser.parse(fs.readFileSync(p + 'yarn.lock', 'utf8')).object;
// Collect dependencies, versions and ranges. Flip switch if duplicates identified.
let deps = {};
let dups = false;
for (let dep in yarnLock) {
// Extract dependency name and resolver
let name = dep.slice(0, dep.indexOf('@', 1));
let resolver = dep.slice(dep.indexOf('@', 1) + 1);
let entry = yarnLock[dep];
// Remember details
if (!(name in deps)) {
// Not in the list, just add it.
deps[name] = {
versions: [entry.version],
resolvers: [resolver]
};
}
else {
// We might have duplicates...
if (deps[name].versions.indexOf(entry.version)) {
// Definitely dups
dups = true;
deps[name].versions.push(entry.version);
}
if (deps[name].resolvers.indexOf(resolver))
deps[name].resolvers.push(resolver);
}
}
// Quit now if there aren't duplicates
if (!dups) {
log(chalk.green('No duplicates found.'));
return true;
}
// Announce duplicates if logging enabled
/**
* X versions of (name) are currently installed.
* Versions: ...
* Resolvers: ...
*/
if (log) {
log(chalk.red('Duplicate dependencies detected!'));
for (let name in deps) {
if (deps[name].versions.length > 1) {
log(`${chalk.bold(deps[name].versions.length)} versions of ${chalk.cyan(name)} installed.`);
log(` Versions: ${deps[name].versions.join(', ')}`);
log(` Resolvers: ${deps[name].resolvers.join(', ')}`);
}
}
}
return false;
}
/**
* Merge specified packages together. (supports npm and yarn)
*
* @param template - Template that packages will be merged into. Is validated with [package-json-validator](https://www.npmjs.com/package/package-json-validator).
* @param paths - Paths to package.json files. EG: "path/to/" (package.json is prepended) or "path/to/package.json" or "path/to/different.json".
* @param saveTo - If string, saves the generated package.json to the specified path. Like 'paths', has 'package.json' prepended if required.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
* @param packageSpec - Used to determine what dependency keys should be merged.
*/
function packageJsonMerge(template, paths, saveTo, log, packageSpec) {
if (log === true) {
log = console.log;
}
else if (!log) {
log = () => { };
}
// Inspect input template
log("Inspecting template package...");
// If template.private == true, we only inspect touched fields.
npmValidate(template, packageSpec, log);
// paths
if (!Array.isArray(paths)) {
throw new Exceptions.InvalidArgumentException("'paths' must be an array.");
}
paths.forEach(function (p, index, array) {
if (p.match(/\\$|\/$/)) {
p += "package.json";
array[index] = p;
}
});
//saveTo
if (typeof saveTo === 'string') { // Resolve to complete path now.
if (saveTo.match(/\\$|\/$/)) {
saveTo += "package.json";
}
saveTo = path.resolve(saveTo);
}
// Load and validate packages.
let packages = [];
for (let filePath of paths) {
log("Inspecting package at " + filePath);
let pkg = JSON.parse(fs.readFileSync(filePath).toString()); // We don't use require, as the extension could be different. Plus require aggressively caches.
npmValidate(pkg, packageSpec, log);
pkg.path = filePath;
packages.push(pkg);
}
// Grab required dependency types, and add if not yet existing on the template.
let depTypes = [];
if (packageSpec == "npm") {
depTypes = [...npmDependencyTypes];
}
else if (packageSpec == "yarn") {
depTypes = [...yarnDependencyTypes];
}
for (let depType of depTypes) {
if (!template[depType]) {
template[depType] = {};
}
}
// Perform merge.
template = mergePackageDependencies(template, packages, depTypes, log);
// Save if requested.
if (saveTo) {
log(`Saving generated package to '${saveTo}'`);
// Make directories if needed.
if (!fs.existsSync(path.dirname(saveTo))) {
fs.mkdirSync(path.dirname(saveTo));
}
fs.writeFileSync(saveTo, JSON.stringify(template, null, ' '));
}
log("All done!");
// Return package.
return template;
}
/**
* Validates npm package. When private == true, validation goes into minimal mode (name, license, etc are not required).
*
* @param pkg - Package object to validate
* @param pkgSpec - Used to determine what dependency keys should be merged.
* @param log
*/
function npmValidate(pkg, pkgSpec, log) {
let results = {
valid: true
};
// If package.private == true, we only inspect touched fields.
if (pkg.private == true) {
// Ensure existing dependency fields are valid.
let depTypes = [];
if (pkgSpec == "npm") {
depTypes = npmDependencyTypes;
}
else if (pkgSpec == "yarn") {
depTypes = yarnDependencyTypes;
}
for (let depType of depTypes) {
if (pkg[depType] && typeof pkg[depType] !== 'object') {
if (!results.errors)
results.errors = [];
results.valid = false;
results.errors.push(`"${depType}" must be an object.`);
}
}
}
else
results = packageJsonValidator(JSON.stringify(pkg));
// Handle errors
npmErrors(results, log);
}
/**
* Handles errors from npm package validation. When log = true, logs critical errors, errors, warnings, and recommendations from npm package validation to the console.
*
* @param results
* @param log
*/
function npmErrors(results, log) {
// Inspect input.
if (typeof results !== "object") {
throw new Exceptions.InvalidArgumentException("'results' must be of type 'object'.");
}
if (log === true) {
log = console.log;
}
else if (!log) {
log = () => { };
}
// Log if requested.
if (log) {
// Announce critical errors.
if (results.critical) {
log(chalk.bgRed("Critical Error: ") + chalk.red(results.critical));
}
// Announce errors.
if (results.errors) {
log(chalk.underline.red("Errors:"));
for (let error of results.errors) {
log(" " + chalk.red(error));
}
}
// Announce warnings.
if (results.warnings) {
log(chalk.underline.yellow("Warnings:"));
for (let warning of results.warnings) {
log(" " + chalk.yellow(warning));
}
}
// Announce recommendations.
if (results.recommendations) {
log(chalk.underline("Recommendations:"));
for (let recommendation of results.recommendations) {
log(" " + recommendation);
}
}
// Announce validity.
if (results.valid) {
log(chalk.green("Package is VALID."));
}
else {
log(chalk.red("Package is INVALID."));
}
}
if (!results.valid) {
throw new Exceptions.InvalidNodePackageException("Package is invalid. Inspection results:\n" + JSON.stringify(results, null, ' '));
}
}
/**
* Merge specified bower packages together.
*
* @param template - Template that packages will be merged into. Is validated with [bower-json](https://www.npmjs.com/package/bower-json).
* @param paths - Paths to bower.json files. EG: "path/to/" (bower.json is prepended) or "path/to/bower.json" or "path/to/different.json".
* @param saveTo - If string, saves the generated bower.json to the specified path. Like 'paths', has 'bower.json' prepended if required.
* @param log - If true, progress and errors will be logged. Has no affect on exceptions thrown.
*/
function bowerMerge(template, paths, saveTo = null, log = false) {
if (log === true) {
log = console.log;
}
else if (!log) {
log = () => { };
}
// Inspect input template
log("Inspecting template package...");
bowerValidate(JSON.stringify(template)); // Throws an exception on failure, so no need for error reporting.
// paths
if (!Array.isArray(paths)) {
throw new Exceptions.InvalidArgumentException("'paths' must be an array.");
}
paths.forEach(function (p, index, array) {
if (p.match(/\\$|\/$/)) {
p += "bower.json";
array[index] = p;
}
});
//saveTo
if (typeof saveTo === 'string') { // Resolve to complete path now.
if (saveTo.match(/\\$|\/$/)) {
saveTo += "bower.json";
}
saveTo = path.resolve(saveTo);
}
// Load and validate packages.
let packages = [];
for (let filePath of paths) {
log("Inspecting package at " + filePath);
let pkg = JSON.parse(fs.readFileSync(filePath).toString()); // We don't use require, as the extension could be different.
bowerValidate(JSON.stringify(pkg)); // As before, no need to log.
pkg.path = filePath;
packages.push(pkg);
}
// Add dependency keys if not yet existing.
let depTypes = [...bowerDependencyTypes];
for (let dependencyType of depTypes) {
if (!template[dependencyType]) {
template[dependencyType] = {};
}
}
// Perform merge.
template = mergePackageDependencies(template, packages, depTypes, log);
// Save if requested.
if (saveTo) {
log(`Saving generated package to '${saveTo}'`);
if (!fs.existsSync(path.dirname(saveTo))) {
fs.mkdirSync(path.dirname(saveTo));
}
fs.writeFileSync(saveTo, JSON.stringify(template, null, ' '));
}
log("All done!");
// Return package.
return template;
}
/**
* Performs very basic bower.json spec validation.
*
* @param pkgJson - JSON representation of a bower.json package.
*/
function bowerValidate(pkgJson) {
const pkg = JSON.parse(pkgJson);
if (typeof pkg !== 'object') {
throw new Exceptions.InvalidBowerPackageException("Root of package MUST be an object.");
}
if (typeof pkg.name !== 'string') {
throw new Exceptions.InvalidBowerPackageException("Package name MUST be a string.");
}
// Now we'll give the used dependency properties a check.
for (let dependencyType of bowerDependencyTypes) {
if (pkg.hasOwnProperty(dependencyType)) {
if (typeof pkg[dependencyType] !== 'object') {
throw new Exceptions.InvalidBowerPackageException(`Package's '${dependencyType}' property MUST be an object.`);
}
else {
for (let pkgDep in pkg[dependencyType]) {
if (typeof pkg[dependencyType][pkgDep] !== 'string') {
throw new Exceptions.InvalidBowerPackageException(`Invalid value for ${dependencyType}->${pkgDep} in package.`);
}
}
}
}
}
// And make sure resolutions match the expected form.
if ("resolutions" in pkg) {
// @ts-ignore
if (typeof pkg.resolutions !== "object") {
throw new Exceptions.InvalidBowerPackageException(`Package's 'resolutions' property MUST be an object.`);
}
else {
// @ts-ignore
for (let pkgRes in pkg.resolutions) {
if (typeof pkg.resolutions[pkgRes] !== 'string') {
throw new Exceptions.InvalidBowerPackageException(`Invalid value for resolutions->${pkgRes} in package.`);
}
}
}
}
}
function mergePackageDependencies(tml, pkgs, depTypes, log) {
var _a, _b, _c, _d, _e;
if (log === true) {
log = console.log;
}
else if (!log) {
log = () => { };
}
// Add resolutions first in case they resolve a dependency collision that cannot be merged.
if (depTypes.indexOf('resolutions') !== -1) {
log("Starting dependency resolution merge.");
// Merge resolutions for each package.
for (let pkg of pkgs) {
if (pkg.resolutions) {
log(`Starting merge of dependency resolutions from package '${(_a = pkg.name) !== null && _a !== void 0 ? _a : pkg.path}'`);
for (let dependency in pkg.resolutions) {
// Handle dependency resolution
if (tml.resolutions.hasOwnProperty(dependency)) {
log(`Merging dependency resolution '${chalk.cyan(dependency)}' with '${chalk.magenta(tml.resolutions[dependency])}' and '${chalk.magenta(pkg.resolutions[dependency])}'.`);
tml.resolutions[dependency] = handleDependencyCollision(tml.resolutions[dependency], pkg.resolutions[dependency]);
}
else {
log(`Adding dependency resolution '${chalk.cyan(dependency)}' with '${chalk.magenta(pkg.resolutions[dependency])}'.`);
tml.resolutions[dependency] = pkg.resolutions[dependency];
}
}
log(`Finished merge of dependency resolutions from package '${(_b = pkg.name) !== null && _b !== void 0 ? _b : pkg.path}'`);
}
}
// Remove 'resolutions' from depTypes.
depTypes = depTypes.filter(depType => depType !== "resolutions");
log("Finished dependency resolution merge.");
}
log("Starting dependency merge.");
// Iterate over pkgs
for (let pkg of pkgs) {
log(`Starting merge of dependencies from package '${(_c = pkg.name) !== null && _c !== void 0 ? _c : pkg.path}'`);
// And dependency types...
for (let dependencyType of depTypes) {
if (dependencyType in pkg) {
log(`Merging dependency type '${dependencyType}'`);
// ...for each package
for (let dependency in pkg[dependencyType]) {
// Handle dependency
if (tml[dependencyType].hasOwnProperty(dependency)) {
log(`Merging dependency '${chalk.cyan(dependency)}' with '${chalk.magenta(tml[dependencyType][dependency])}' and '${chalk.magenta(pkg[dependencyType][dependency])}'.`);
try {
// Try to resolve dependency collision.
tml[dependencyType][dependency] = handleDependencyCollision(tml[dependencyType][dependency], pkg[dependencyType][dependency]);
}
catch (e) {
// Dependency collision failed, do we have resolutions and if so, is this collision resolved by it?
if ((_d = tml.resolutions) === null || _d === void 0 ? void 0 : _d[dependency]) {
// It is!
log(chalk.bgGreen("Dependency conflict detected, resolved by resolution."));
tml[dependencyType][dependency] = tml.resolutions[dependency];
}
else {
// Nope!
log(chalk.bgRed("Dependency conflict detected!"));
throw e;
}
}
}
else {
log(`Adding dependency '${chalk.cyan(dependency)}' with '${chalk.magenta(pkg[dependencyType][dependency])}'.`);
tml[dependencyType][dependency] = pkg[dependencyType][dependency];
}
}
}
}
log(`Finished merge of dependencies from package '${(_e = pkg.name) !== null && _e !== void 0 ? _e : pkg.path}'`);
}
log("Finished dependency merge.");
return tml;
}
/**
* Returns the intersection of both semver ranges if both valid semver ranges. If incoming version isn't a valid semver range, it acts as a override and is returned instead.
*
* @param currentVersion - The existing package version value.
* @param incomingVersion - The incoming package version value to handle.
*/
function handleDependencyCollision(currentVersion, incomingVersion) {
// If packageVersion is a valid semver range, currentVersion must also be valid.
if (semver.valid(incomingVersion) || semver.validRange(incomingVersion)) {
if (!semver.valid(currentVersion) && !semver.validRange(currentVersion)) {
throw new Exceptions.LogicalException("An incoming package semver version range requires the current version to also be valid semver range.\nIncoming: " + incomingVersion + "\nCurrent: " + currentVersion);
}
else {
// Intersect semver ranges.
try {
if (currentVersion == incomingVersion) {
return currentVersion;
}
else {
// First try to make a clean merge with semverIntersect.
try {
return semverIntersect(currentVersion, incomingVersion);
}
catch (e) {
// If that fails, look for logical-or. If found, perform elaborate merge.
try {
let elaborateMerge = (semverRangeWithOr, semverRange) => {
let ranges = semverRangeWithOr.split("||");
let output = "";
for (let i = 0; i < ranges.length; i++) {
// Create new range (hopefully with semverIntersect)
let compoundRange;
try {
compoundRange = semverIntersect(ranges[i], semverRange);
}
catch (e) {
compoundRange = `${ranges[i]} ${semverRange}`;
}
// Middle and beginning.
if (i - 1 != ranges.length) {
output += `${compoundRange} || `;
}
// End
else {
output += compoundRange;
}
}
// Make sure generated output is a valid semver range.
if (!semver.validRange(output))
throw new Exceptions.LogicalException("Cannot produced valid semver range. Range generated: " + output);
return output;
};
if (currentVersion.includes("||")) {
// Just in case both have a logical-or. (but who would be THAT cruel to a package manager)
if (incomingVersion.includes("||")) {
let ranges = currentVersion.split("||");
let result = "";
for (let range of ranges) {
result += elaborateMerge(incomingVersion, range);
}
return result;
}
else {
return elaborateMerge(currentVersion, incomingVersion);
}
}
else if (incomingVersion.includes("||")) {
return elaborateMerge(incomingVersion, currentVersion);
}
else {
// If we get here, there looks to be a semver range contradiction.
throw new Exceptions.LogicalException('Semver ranges could not be intersected. Ranges may contradict each other.');
}
}
catch (e) {
throw new Exceptions.LogicalException(`Cannot merge semver ranges "${currentVersion}" and "${incomingVersion}".`);
}
// Otherwise just join, and inspect.
let intersection = currentVersion + " " + incomingVersion;
if (!semver.validRange(intersection)) {
throw new Exceptions.LogicalException(`'${currentVersion}' and '${incomingVersion}' cannot be combined to make a valid semver range.`);
}
return intersection;
}
}
}
catch (e) { // We wrap the thrown exception in a LogicalException, so that its easier to handle.
throw new Exceptions.LogicalException(e);
}
}
}
else {
console.log(chalk.bgRed("Non-semver dependency version value detected. Override triggered. Value: ") + chalk.bgMagenta(incomingVersion));
return incomingVersion;
}
}
//# sourceMappingURL=main.js.map