4d5eb28cb4
Co-authored-by: Okinea Dev <hi@okinea.dev>
394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
import consola from 'consola';
|
|
import { glob } from 'tinyglobby';
|
|
import { dirname } from 'node:path';
|
|
import pMap from 'p-map';
|
|
import * as semver from 'semver';
|
|
import { styleText } from 'node:util';
|
|
|
|
const HELP_MESSAGE = `
|
|
Upgrades dependencies throughout WXT using custom rules.
|
|
|
|
Usage:
|
|
bun run scripts/upgrade-deps.ts [options]
|
|
|
|
Options:
|
|
--write, -w Write changes to package.json files
|
|
--major, -m Allow major version upgrades
|
|
--dev-only, -d Only check/upgrade dev dependencies
|
|
--prod-only, -p Only check/upgrade production dependencies
|
|
--help, -h Display this help message
|
|
`.slice(1, -1);
|
|
|
|
const IGNORED_PACKAGES = [
|
|
// Very touchy, don't change:
|
|
'typedoc',
|
|
'typedoc-plugin-markdown',
|
|
'typedoc-vitepress-theme',
|
|
// Manually manage version so a single version is used:
|
|
'esbuild',
|
|
// Maintained manually to match min-node version
|
|
'@types/node',
|
|
// License changed in newer versions
|
|
'ua-parser-js',
|
|
];
|
|
|
|
await main();
|
|
|
|
async function main(): Promise<never> {
|
|
const args = process.argv.slice(2);
|
|
const isHelp = ['-h', '--help'].some((arg) => args.includes(arg));
|
|
const isWrite = ['-w', '--write'].some((arg) => args.includes(arg));
|
|
const isDevOnly = ['-d', '--dev-only'].some((arg) => args.includes(arg));
|
|
const isProdOnly = ['-p', '--prod-only'].some((arg) => args.includes(arg));
|
|
|
|
if (isHelp) {
|
|
console.log(HELP_MESSAGE);
|
|
process.exit(0);
|
|
}
|
|
|
|
const { packageJsonFiles, dependencyVersionsMap } =
|
|
await getPackageJsonDependencies(isDevOnly, isProdOnly);
|
|
const dependencyVersionMap = validateNoMultipleVersions(
|
|
dependencyVersionsMap,
|
|
);
|
|
|
|
consola.start(
|
|
`Fetching ${Object.keys(dependencyVersionsMap).length} dependencies...`,
|
|
);
|
|
const dependencies = await fetchAllPackageInfos(dependencyVersionMap);
|
|
consola.success('Done!');
|
|
|
|
const isMajor = ['-m', '--major'].some((arg) => args.includes(arg));
|
|
const upgrades = await detectUpgrades(dependencies, isMajor);
|
|
|
|
if (!upgrades.length) {
|
|
consola.info("\nNo upgrades found, you're up to date!\n");
|
|
process.exit(0);
|
|
}
|
|
|
|
printUpgrades(upgrades);
|
|
|
|
if (!isWrite) {
|
|
consola.info('Run with `-w` to write changes to package.json files\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
consola.start('Writing new versions to package.json files...');
|
|
await writeUpgrades(packageJsonFiles, upgrades);
|
|
consola.success('Done!');
|
|
consola.info('\nRun `bun install` to install new dependencies\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
type DependencyVersionsMap = Record<string, Set<string>>;
|
|
type PackageJsonData = {
|
|
content: any;
|
|
path: string;
|
|
folder: string;
|
|
};
|
|
|
|
async function getPackageJsonDependencies(
|
|
isDevOnly: boolean,
|
|
isProdOnly: boolean,
|
|
): Promise<{
|
|
packageJsonFiles: string[];
|
|
dependencyVersionsMap: DependencyVersionsMap;
|
|
}> {
|
|
const packageJsonFiles = await glob(
|
|
['package.json', '*/*/package.json', '!**/node_modules'],
|
|
{ onlyFiles: true, expandDirectories: false },
|
|
);
|
|
const packageJsons: PackageJsonData[] = await Promise.all(
|
|
packageJsonFiles.map(async (path) => ({
|
|
content: await Bun.file(path).json(),
|
|
path,
|
|
folder: dirname(path),
|
|
})),
|
|
);
|
|
|
|
const dependencyVersionsMap = packageJsons.reduce<DependencyVersionsMap>(
|
|
(map, { content }) => {
|
|
const addToMap = ([name, version]: [string, any]) => {
|
|
if (
|
|
name === 'wxt' ||
|
|
version.startsWith('workspace:') ||
|
|
IGNORED_PACKAGES.includes(name)
|
|
)
|
|
return;
|
|
|
|
map[name] ||= new Set();
|
|
map[name].add(version as string);
|
|
};
|
|
if (!isDevOnly)
|
|
Object.entries(content.dependencies || {}).forEach(addToMap);
|
|
if (!isProdOnly)
|
|
Object.entries(content.devDependencies || {}).forEach(addToMap);
|
|
return map;
|
|
},
|
|
{},
|
|
);
|
|
|
|
return {
|
|
packageJsonFiles,
|
|
dependencyVersionsMap,
|
|
};
|
|
}
|
|
|
|
type DependencyVersionMap = Record<string, string>;
|
|
|
|
function validateNoMultipleVersions(
|
|
dependencyVersionsMap: DependencyVersionsMap,
|
|
): DependencyVersionMap {
|
|
const depsWithMultipleVersions = Object.entries(dependencyVersionsMap).filter(
|
|
([_, versions]) => versions.size > 1,
|
|
);
|
|
if (depsWithMultipleVersions.length === 0) {
|
|
return Object.fromEntries(
|
|
Object.entries(dependencyVersionsMap).map(([name, versions]) => [
|
|
name,
|
|
[...versions][0],
|
|
]),
|
|
);
|
|
}
|
|
|
|
const maxWidth = Math.max(
|
|
...depsWithMultipleVersions.map(([name]) => name.length),
|
|
);
|
|
|
|
consola.info('Found multiple versions of:');
|
|
for (const [name, versions] of depsWithMultipleVersions) {
|
|
console.log(
|
|
` ${styleText('magenta', name.padEnd(maxWidth))} ${Array.from(
|
|
versions,
|
|
)
|
|
.map((text) => styleText('cyan', text))
|
|
.join('\t')}`,
|
|
);
|
|
}
|
|
consola.error(`${depsWithMultipleVersions.length} problem(s) found`);
|
|
process.exit(1);
|
|
}
|
|
|
|
async function spawnJson(cmd: string[]): Promise<any> {
|
|
const child = Bun.spawn({ cmd });
|
|
const exitCode = await child.exited;
|
|
if (exitCode !== 0)
|
|
throw new Error(`Command failed with exit code ${exitCode}`);
|
|
|
|
return JSON.parse(
|
|
// @ts-ignore: text() is an untyped util Bun provides for ReadableStreams
|
|
await child.stdout.text(),
|
|
);
|
|
}
|
|
|
|
async function fetchPackageInfo(name: string): Promise<PackageInfo> {
|
|
// Use Bun instead of API in case dependencies don't come from NPM.
|
|
const partial = await spawnJson(['bun', 'info', name, '--json']);
|
|
|
|
// Bun doesn't return the dist-tags and time fields by default, have to fetch
|
|
// them separately.
|
|
const distTags = await spawnJson([
|
|
'bun',
|
|
'info',
|
|
name,
|
|
'dist-tags',
|
|
'--json',
|
|
]);
|
|
const time = await spawnJson(['bun', 'info', name, 'time', '--json']);
|
|
|
|
return {
|
|
...partial,
|
|
'dist-tags': distTags,
|
|
time,
|
|
};
|
|
}
|
|
|
|
type PackageInfo = {
|
|
name: string;
|
|
'dist-tags': {
|
|
latest: string;
|
|
[tag: string]: string;
|
|
};
|
|
versions: string[];
|
|
time: {
|
|
created: string;
|
|
modified: string;
|
|
[version: string]: string;
|
|
};
|
|
};
|
|
|
|
type DependencyInfo = {
|
|
name: string;
|
|
currentVersionRange: string;
|
|
info: PackageInfo;
|
|
};
|
|
|
|
async function fetchAllPackageInfos(
|
|
deps: DependencyVersionMap,
|
|
): Promise<DependencyInfo[]> {
|
|
const infos: DependencyInfo[] = await pMap(
|
|
Object.entries(deps),
|
|
async ([name, currentVersionRange]) => {
|
|
const info = await fetchPackageInfo(name);
|
|
return { name, currentVersionRange, info };
|
|
},
|
|
{ concurrency: 20 },
|
|
);
|
|
return infos.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
type UpgradeDetails = {
|
|
name: string;
|
|
rangePrefix: string;
|
|
currentVersion: string;
|
|
currentVersionReleasedAt: Date;
|
|
currentRange: string;
|
|
upgradeToVersion: string;
|
|
upgradeToVersionReleasedAt: Date;
|
|
upgradeToRange: string;
|
|
diff: semver.ReleaseType | null;
|
|
latestVersion: string;
|
|
latestVersionReleasedAt: Date;
|
|
};
|
|
|
|
async function detectUpgrades(
|
|
deps: DependencyInfo[],
|
|
isMajor: boolean,
|
|
): Promise<UpgradeDetails[]> {
|
|
const results: UpgradeDetails[] = [];
|
|
|
|
for (const dep of deps) {
|
|
const currentRange = dep.currentVersionRange;
|
|
if (currentRange === '*') continue;
|
|
|
|
const parts = currentRange.split(' || ').map((part) => part.trim());
|
|
const lastRange = parts[parts.length - 1];
|
|
const isUnion = parts.length > 1;
|
|
|
|
const rangePrefix = lastRange.match(/^(.?)(\d+\.\d+\.\d+)/)?.[1] ?? '';
|
|
|
|
const currentVersion = semver.minVersion(lastRange)?.version;
|
|
if (currentVersion == null)
|
|
throw Error(`Invalid version specifier: ${dep.name}@${currentRange}`);
|
|
|
|
const currentVersionReleasedAt = new Date(dep.info.time[currentVersion]);
|
|
const isPre1 = currentVersion.startsWith('0.');
|
|
|
|
const latestVersion = dep.info['dist-tags'].latest;
|
|
const latestVersionReleasedAt = new Date(dep.info.time[latestVersion]);
|
|
|
|
const upgradeToVersion = isMajor
|
|
? // Always use the latest version for major upgrades
|
|
latestVersion
|
|
: // Otherwise use the last stable version greater than the current version that is not a major release
|
|
(dep.info.versions.findLast(
|
|
(v) =>
|
|
semver.gt(v, currentVersion) &&
|
|
(isPre1 ? ['patch'] : ['patch', 'minor']).includes(
|
|
semver.diff(v, currentVersion)!,
|
|
) &&
|
|
semver.prerelease(v) == null,
|
|
) ?? currentVersion);
|
|
const upgradeToVersionReleasedAt = new Date(
|
|
dep.info.time[upgradeToVersion],
|
|
);
|
|
|
|
let upgradeToRange = `${rangePrefix}${upgradeToVersion}`;
|
|
upgradeToRange = isUnion
|
|
? semver.satisfies(upgradeToVersion, currentRange)
|
|
? currentRange
|
|
: `${currentRange} || ${upgradeToRange}`
|
|
: upgradeToRange;
|
|
|
|
let diff = semver.diff(currentVersion, upgradeToVersion);
|
|
if (isPre1) {
|
|
if (diff === 'minor') diff = 'major';
|
|
if (diff === 'patch') diff = 'minor';
|
|
}
|
|
|
|
if (upgradeToRange === currentRange) continue;
|
|
|
|
results.push({
|
|
name: dep.name,
|
|
rangePrefix,
|
|
diff,
|
|
currentVersion,
|
|
currentVersionReleasedAt,
|
|
currentRange,
|
|
upgradeToVersion,
|
|
upgradeToVersionReleasedAt,
|
|
upgradeToRange,
|
|
latestVersion,
|
|
latestVersionReleasedAt,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function printUpgrades(upgrades: UpgradeDetails[]): void {
|
|
const namePadding = Math.max(...upgrades.map((u) => u.name.length));
|
|
const currentVersionPadding = Math.max(
|
|
...upgrades.map((u) => u.currentRange.length),
|
|
);
|
|
const upgradeToVersionPadding = Math.max(
|
|
...upgrades.map((u) => u.upgradeToRange.length),
|
|
);
|
|
const numberPadding = String(upgrades.length + 1).length + 1;
|
|
|
|
consola.info(`Found ${upgrades.length} upgrades:\n`);
|
|
|
|
for (let i = 0; i < upgrades.length; i++) {
|
|
const upgrade = upgrades[i];
|
|
const num = styleText(
|
|
'bold',
|
|
(i + 1).toString().padStart(numberPadding) + '.',
|
|
);
|
|
const name = styleText('magenta', upgrade.name.padEnd(namePadding));
|
|
const color =
|
|
upgrade.diff == null
|
|
? 'bold'
|
|
: upgrade.diff === 'patch'
|
|
? 'green'
|
|
: upgrade.diff === 'minor'
|
|
? 'yellow'
|
|
: 'red';
|
|
const currentVersion = styleText(
|
|
'bold',
|
|
upgrade.currentRange.padEnd(currentVersionPadding),
|
|
);
|
|
const upgradeToVersion = styleText(
|
|
color,
|
|
upgrade.upgradeToRange.padEnd(upgradeToVersionPadding),
|
|
);
|
|
const latest =
|
|
upgrade.latestVersion !== upgrade.upgradeToVersion
|
|
? ' ' +
|
|
styleText(['bold', 'red'], `(${upgrade.latestVersion} available)`)
|
|
: '';
|
|
console.log(
|
|
` ${num} ${name} ${currentVersion} ${styleText('dim', '→')} ${upgradeToVersion}${latest}`,
|
|
);
|
|
}
|
|
console.log();
|
|
}
|
|
|
|
async function writeUpgrades(
|
|
packageJsonFiles: string[],
|
|
upgrades: UpgradeDetails[],
|
|
) {
|
|
for (const packageJsonFile of packageJsonFiles) {
|
|
const file = Bun.file(packageJsonFile);
|
|
const oldText = await file.text();
|
|
let newText = oldText;
|
|
for (const upgrade of upgrades) {
|
|
const search = `"${upgrade.name}": "${upgrade.currentRange}"`;
|
|
const replace = `"${upgrade.name}": "${upgrade.upgradeToRange}"`;
|
|
newText = newText.replaceAll(search, replace);
|
|
}
|
|
if (newText !== oldText) {
|
|
await file.write(newText);
|
|
}
|
|
}
|
|
}
|