add sync-dependency-versions command to simplify upstream merges
This commit is contained in:
parent
83134775f0
commit
a0cf3a6fb6
3 changed files with 399 additions and 5 deletions
390
scripts/sync-deps.mjs
Normal file
390
scripts/sync-deps.mjs
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import nodePath from 'node:path';
|
||||
import nodeFs from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Root directory of the repository.
|
||||
* @type {string}
|
||||
*/
|
||||
const rootDir = nodePath.resolve(import.meta.dirname, '..');
|
||||
|
||||
/**
|
||||
* Filename patterns to exclude.
|
||||
* @type {RegExp[]}
|
||||
*/
|
||||
const excludedPaths = [
|
||||
/\/node_modules\//,
|
||||
/\/(js_)?built\//i,
|
||||
/\/temp\//i,
|
||||
];
|
||||
|
||||
/**
|
||||
* All packages located in the solution
|
||||
*/
|
||||
const packages = await loadPackages();
|
||||
|
||||
/**
|
||||
* All packages defined in the solution
|
||||
*/
|
||||
const dependencies = mapDependencies(packages);
|
||||
const allDependencies = Object.values(dependencies);
|
||||
const allDependenciesWithDifference = allDependencies.filter(d => d.hasDifference);
|
||||
|
||||
console.log(`Found ${allDependenciesWithDifference.length} mismatched dependencies (out of ${allDependencies.length} total) from ${packages.length} packages.`);
|
||||
|
||||
if (allDependenciesWithDifference.length > 0) {
|
||||
await syncDependencies(allDependenciesWithDifference);
|
||||
console.log(`package.json files have changed. Please run "pnpm i" to update the pnpm-lock.yaml, then verify that everything still works.`);
|
||||
}
|
||||
|
||||
async function loadPackages() {
|
||||
/**
|
||||
* @type {Package[]}
|
||||
*/
|
||||
let packages = [];
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadPackagesFrom = async (dir) => {
|
||||
const files = await nodeFs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of files) {
|
||||
const path = nodePath.join(dir, entry.name);
|
||||
|
||||
// Check for filtered paths
|
||||
let filterPath = path.replaceAll(nodePath.sep, '/');
|
||||
if (entry.isDirectory()) {
|
||||
filterPath += '/';
|
||||
}
|
||||
if (excludedPaths.some(p => p.test(filterPath))) {
|
||||
//console.debug(`Skipping excluded path ${path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await loadPackagesFrom(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === 'package.json') {
|
||||
try {
|
||||
const packageText = await nodeFs.readFile(path, { encoding: 'utf-8' });
|
||||
const packageJson = JSON.parse(packageText);
|
||||
|
||||
// Handle duplicate package names
|
||||
let packageName = packageJson.name || nodePath.basename(dir);
|
||||
if (packages.some(p => p.name === packageName)) {
|
||||
let i = 1;
|
||||
while (packages.some(p => p.name === `${packageName}:${i}`) ){
|
||||
i++;
|
||||
}
|
||||
packageName = `${packageName}:${i}`;
|
||||
}
|
||||
|
||||
// Parse and flatten all dependency sections
|
||||
const dependencies = mergeDependencies(packageName, {
|
||||
resolutions: parseDependencies(packageName, packageJson, 'resolutions'),
|
||||
peerDependencies: parseDependencies(packageName, packageJson, 'peerDependencies'),
|
||||
optionalDependencies: parseDependencies(packageName, packageJson, 'optionalDependencies'),
|
||||
devDependencies: parseDependencies(packageName, packageJson, 'devDependencies'),
|
||||
dependencies: parseDependencies(packageName, packageJson, 'dependencies'),
|
||||
});
|
||||
|
||||
packages.push({
|
||||
name: packageName,
|
||||
json: packageJson,
|
||||
path,
|
||||
dependencies,
|
||||
});
|
||||
|
||||
if (dependencies.length > 0) {
|
||||
console.info(`Loaded ${dependencies.length} dependencies from ${packageName}`);
|
||||
} else {
|
||||
// console.debug(`Loaded no dependencies from ${packageName}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Error reading package from ${path}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
await loadPackagesFrom(rootDir);
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} packageName
|
||||
* @param {Record<DepType, Dependency[]>} dependencyGroups
|
||||
* @returns {Dependency[]}
|
||||
*/
|
||||
function mergeDependencies(packageName, dependencyGroups) {
|
||||
/** @type {Dependency[]} */
|
||||
const dependencies = [];
|
||||
|
||||
for (const type of Object.keys(dependencyGroups)) {
|
||||
/** @type {Dependency[]} */
|
||||
const typeDependencies = dependencyGroups[type];
|
||||
for (const dependency of typeDependencies) {
|
||||
const existing = dependencies.find(d => d.name === dependency.name);
|
||||
if (existing) {
|
||||
console.warn(`[${packageName}/${type}/${dependency.name}] Skipping duplicate dependency (was already defined in ${existing.type})`);
|
||||
} else {
|
||||
dependencies.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} packageName
|
||||
* @param {Record<DepType, unknown>} packageJson
|
||||
* @param {DepType} type
|
||||
* @returns {Dependency[]}
|
||||
*/
|
||||
function parseDependencies(packageName, packageJson, type) {
|
||||
/** @type {Dependency[]} */
|
||||
const dependencies = [];
|
||||
|
||||
// Make sure we actually have this type
|
||||
if (typeof(packageJson[type]) === 'object') {
|
||||
for (const [name, npmVersion] of Object.entries(packageJson[type])) {
|
||||
const version = parseVersionString(packageName, type, name, npmVersion);
|
||||
if (version != null) {
|
||||
dependencies.push({
|
||||
name,
|
||||
type,
|
||||
version,
|
||||
npmVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} packageName
|
||||
* @param {DepType} depType
|
||||
* @param {string} depName
|
||||
* @param {unknown} versionString
|
||||
* @returns {DepVersion | null}
|
||||
*/
|
||||
function parseVersionString(packageName, depType, depName, versionString) {
|
||||
if (typeof(versionString) !== 'string') {
|
||||
console.warn(`[${packageName}/${depType}/${depName}] Skipping version string "${versionString}" - incorrect type ${typeof(versionString)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (versionString.startsWith('npm:') || versionString.startsWith('workspace:')) {
|
||||
//console.warn(`[${packageName}/${depType}/${depName}] Skipping version string "${versionString}" - package redirects are not supported`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (versionString.startsWith('github:') || versionString.startsWith('http:') || versionString.startsWith('https:')) {
|
||||
//console.warn(`[${packageName}/${depType}/${depName}] Skipping version string "${versionString}" - external packages are not supported`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (versionString === '') {
|
||||
versionString = '*';
|
||||
} else if (versionString === 'latest') {
|
||||
versionString = '*';
|
||||
} else if (versionString === 'next') {
|
||||
versionString = '*';
|
||||
} else {
|
||||
versionString = versionString.replaceAll(/(\b|^)[x*]+(\b|$)/g, '*');
|
||||
}
|
||||
|
||||
const versionMatch = versionString.match(/^([\^<>~=]*)((\d+|\*)(\.(\d+|\*)+)*)(\b|$|[-+])/);
|
||||
if (!versionMatch) {
|
||||
console.warn(`[${packageName}/${depType}/${depName}] Skipping version string "${versionString}" - not in a parseable format`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const upgradePrefix = versionMatch[1];
|
||||
const primaryVersion = versionMatch[2];
|
||||
|
||||
// Parse the primary version (x.y.z)
|
||||
/** @type {DepVersion} */
|
||||
const parsedVersion = primaryVersion
|
||||
.split('.')
|
||||
.map(p => p === '*' ? '*' : parseInt(p));
|
||||
|
||||
// Parse the upgrade prefix
|
||||
if (upgradePrefix === '>' || upgradePrefix === '>=') {
|
||||
parsedVersion.push('*');
|
||||
} else if (upgradePrefix === '~') {
|
||||
// "Allows patch-level changes if a minor version is specified on the comparator. Allows minor-level changes if not."
|
||||
// https://github.com/npm/node-semver#versions
|
||||
const start = parsedVersion.length > 2 ? 2 : 1;
|
||||
for (let i = start; i < parsedVersion.length; i++) {
|
||||
parsedVersion[i] = '*';
|
||||
}
|
||||
} else if (upgradePrefix === '^') {
|
||||
// "Allows changes that do not modify the left-most non-zero element in the [major, minor, patch] tuple."
|
||||
// https://github.com/npm/node-semver#versions
|
||||
for (let i = 1; i < parsedVersion.length; i++) {
|
||||
parsedVersion[i] = '*';
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse x.*.z to just x.*
|
||||
const firstStarIdx = parsedVersion.indexOf('*');
|
||||
if (firstStarIdx >= 0) {
|
||||
parsedVersion.length = firstStarIdx + 1;
|
||||
}
|
||||
|
||||
return parsedVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Package[]} packages
|
||||
* @returns {Partial<Record<string, MappedDependency>>}
|
||||
*/
|
||||
function mapDependencies(packages) {
|
||||
/** @type {Partial<Record<string, MappedDependency>>} */
|
||||
const mappedDependencies = {};
|
||||
|
||||
for (const pkg of packages) {
|
||||
for (const dependency of pkg.dependencies) {
|
||||
const packageDependency = { package: pkg, packageDependency: dependency };
|
||||
|
||||
/** @type {MappedDependency} */
|
||||
let mapping = mappedDependencies[dependency.name];
|
||||
if (!mapping) {
|
||||
mapping = {
|
||||
name: dependency.name,
|
||||
newestVersion: dependency.version,
|
||||
newestNpmVersion: dependency.npmVersion,
|
||||
hasDifference: false,
|
||||
packages: [packageDependency],
|
||||
};
|
||||
mappedDependencies[dependency.name] = mapping;
|
||||
} else {
|
||||
if (isNewer(dependency.version, mapping.newestVersion)) {
|
||||
mapping.newestVersion = dependency.version;
|
||||
mapping.newestNpmVersion = dependency.npmVersion;
|
||||
mapping.hasDifference = true;
|
||||
}
|
||||
mapping.packages.push(packageDependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mappedDependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DepVersion} a
|
||||
* @param {DepVersion} b
|
||||
* @return {boolean}
|
||||
*/
|
||||
function isNewer(a, b) {
|
||||
return compareVersions(a, b) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* -1 => a is older / b is newer
|
||||
* 0 => same age
|
||||
* 1 => a is newer / b is older
|
||||
* @param {DepVersion} a
|
||||
* @param {DepVersion} b
|
||||
* @return {-1 | 0 | 1}
|
||||
*/
|
||||
function compareVersions(a, b) {
|
||||
const limit = Math.max(a.length, b.length);
|
||||
|
||||
// Check each part (x.y.z and so on)
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const aPart = a[i] ?? '*';
|
||||
const bPart = b[i] ?? '*';
|
||||
|
||||
if (aPart === '*') {
|
||||
if (bPart !== '*') {
|
||||
// A matches any and B has a limit, therefore A is newer
|
||||
return 1;
|
||||
}
|
||||
} else if (bPart === '*') {
|
||||
// A has a limit and B matches any, therefore B is newer
|
||||
return -1;
|
||||
} else if (aPart !== bPart) {
|
||||
if (aPart > bPart) {
|
||||
// A has a limit and B has a lower limit, therefore A is newer
|
||||
return 1;
|
||||
} else {
|
||||
// A has a limit and B has a higher limit, therefore B is newer
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If parseable versions match, then consider them the same.
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MappedDependency[]} dependencies
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function syncDependencies(dependencies) {
|
||||
/** @type {Partial<Record<string, Package>>} */
|
||||
const modifiedPackages = {};
|
||||
|
||||
for (const dependency of dependencies) {
|
||||
for (const pkg of dependency.packages) {
|
||||
if (dependency.newestNpmVersion !== pkg.packageDependency.npmVersion) {
|
||||
console.log(`Updating ${dependency.name} from version ${pkg.packageDependency.npmVersion} to ${dependency.newestNpmVersion} in package ${pkg.package.name}`);
|
||||
pkg.package.json[pkg.packageDependency.type][dependency.name] = dependency.newestNpmVersion;
|
||||
modifiedPackages[pkg.package.name] = pkg.package;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of Object.values(modifiedPackages)) {
|
||||
const packageText = JSON.stringify(pkg.json, null, '\t');
|
||||
await nodeFs.writeFile(pkg.path, packageText, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef Package
|
||||
* @property {string} name
|
||||
* @property {string} path
|
||||
* @property {object} json
|
||||
* @property {Dependency[]} dependencies
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef Dependency
|
||||
* @property {string} name
|
||||
* @property {DepType} type
|
||||
* @property {DepVersion} version
|
||||
* @property {string} npmVersion
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies' | 'resolutions'} DepType
|
||||
* @typedef {[DepPart, ...DepPart[]][]} DepVersion
|
||||
* @typedef {number | '*'} DepPart
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef MappedDependency
|
||||
* @property {string} name
|
||||
* @property {DepVersion} newestVersion
|
||||
* @property {string} newestNpmVersion
|
||||
* @property {boolean} hasDifference
|
||||
* @property {{package: Package, packageDependency: Dependency}[]} packages
|
||||
*/
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue