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 { 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>; 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( (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; 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 { 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 { // 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 { 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 { 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); } } }