// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-await-in-loop */ import { join } from 'path'; import { readFile } from 'fs/promises'; import chalk from 'chalk'; import semver from 'semver'; import got from 'got'; import enquirer from 'enquirer'; import execa from 'execa'; const rootDir = join(__dirname, '..', '..'); function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function readJsonFile(path: string): Promise { return JSON.parse(await readFile(path, 'utf-8')); } function parseNumberField(value: string | number | null | void): number | null { if (value == null) { return null; } if (typeof value === 'number') { return value; } const trimmed = value.trim(); if (trimmed === '') { return null; } const parsed = Number(value); if (!Number.isFinite(parsed)) { return null; } return parsed; } const npm = got.extend({ prefixUrl: 'https://registry.npmjs.org/', responseType: 'json', retry: { calculateDelay: retry => { if ( retry.error instanceof got.HTTPError && retry.error.response.statusCode === 429 ) { const retryAfter = parseNumberField( retry.error.response.headers['retry-after'] ); if (retryAfter != null) { console.log( chalk.gray(`Rate limited, retrying after ${retryAfter} seconds`) ); return retryAfter * 1000; } } return retry.computedValue; }, }, }); const DependencyTypes = [ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', ] as const; type LocalDependency = Readonly<{ name: string; depType: (typeof DependencyTypes)[number]; requestedVersion: string; resolvedVersion: string; }>; type FetchedDependency = LocalDependency & Readonly<{ latestVersion: string; moduleType: 'commonjs' | 'esm'; diff: semver.ReleaseType | null; }>; async function main() { const packageJson = await readJsonFile(join(rootDir, 'package.json')); const packageLock = await readJsonFile(join(rootDir, 'package-lock.json')); const localDeps: ReadonlyArray = DependencyTypes.flatMap( depType => { return Object.keys(packageJson[depType] ?? {}).map(name => { const requestedVersion = packageJson[depType][name]; const resolvedVersion = packageLock.packages[`node_modules/${name}`]?.version; assert(resolvedVersion, `Could not find resolved version for ${name}`); return { name, depType, requestedVersion, resolvedVersion }; }); } ); console.log(chalk`Found {cyan ${localDeps.length}} local dependencies`); const fetchedDeps: ReadonlyArray = await Promise.all( localDeps.map(async dep => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const info: any = await npm(`${dep.name}/latest`).json(); const latestVersion = info.version; const moduleType = info.type ?? 'commonjs'; assert( moduleType === 'commonjs' || moduleType === 'module', `Unexpected module type for ${dep.name}: ${moduleType}` ); const diff = semver.lt(dep.resolvedVersion, latestVersion) ? semver.diff(dep.resolvedVersion, latestVersion) : null; return { ...dep, latestVersion, moduleType, diff }; }) ); const outdatedDeps = fetchedDeps.filter(dep => dep.diff != null); console.log(chalk`Found {cyan ${outdatedDeps.length}} outdated dependencies`); const upgradeableDeps = outdatedDeps.filter(dep => { return dep.moduleType === 'commonjs'; }); console.log( chalk`Found {cyan ${upgradeableDeps.length}} upgradeable dependencies` ); const upgradeableDepsByDiff = new Map>(); for (const dep of upgradeableDeps) { assert(dep.diff != null, 'Expected diff to be non-null'); let group = upgradeableDepsByDiff.get(dep.diff); if (group == null) { group = new Set(); upgradeableDepsByDiff.set(dep.diff, group); } group.add(dep.name); } for (const [diff, deps] of upgradeableDepsByDiff) { console.log(chalk` - ${diff}: {cyan ${deps.size}}`); } let longestNameLength = 0; for (const dep of upgradeableDeps) { longestNameLength = Math.max(longestNameLength, dep.name.length); } const { approvedDeps } = await enquirer.prompt<{ approvedDeps: ReadonlyArray; }>({ type: 'multiselect', name: 'approvedDeps', message: 'Select which dependencies to upgrade', choices: upgradeableDeps.map(deps => { let color = chalk.red; if (deps.diff === 'patch') { color = chalk.green; } else if (deps.diff === 'minor') { color = chalk.yellow; } return { name: deps.name, message: `${deps.name.padEnd(longestNameLength)}`, hint: `(${color(deps.diff)}: ${deps.resolvedVersion} -> ${color(deps.latestVersion)})`, }; }), }); console.log( chalk`Starting upgrade of {cyan ${approvedDeps.length}} dependencies` ); // eslint-disable no-await-in-loop for (const dep of upgradeableDeps) { try { if (!approvedDeps.includes(dep.name)) { console.log(chalk`Skipping ${dep.name}`); continue; } const gitStatusBefore = await execa('git', ['status', '--porcelain']); if (gitStatusBefore.stdout.trim() !== '') { console.error(chalk`{red Found uncommitted changes, exiting}`); console.error(chalk.red(gitStatusBefore.stdout)); process.exit(1); } console.log( chalk`Upgrading {cyan ${dep.name}} from {yellow ${dep.resolvedVersion}} to {magenta ${dep.latestVersion}}` ); await execa( 'npm', ['install', '--save-exact', `${dep.name}@${dep.latestVersion}`], { stdio: 'inherit' } ); // eslint-disable-next-line no-constant-condition while (true) { try { await execa( 'npx', ['patch-package', '--error-on-fail', '--error-on-warn'], { stdio: 'inherit' } ); break; } catch { const { retry } = await enquirer.prompt<{ retry: boolean }>({ type: 'confirm', name: 'retry', message: 'Retry patch-package?', initial: true, }); if (!retry) { throw new Error('Failed to apply patch-package'); } } } const { npmScriptsToRun } = await enquirer.prompt<{ npmScriptsToRun: Array; }>({ type: 'multiselect', name: 'npmScriptsToRun', message: 'Select which scripts to run', choices: [ // Fast and common { name: 'eslint' }, { name: 'test-node' }, { name: 'test-electron' }, // Long { name: 'test-mock' }, // Uncommon { name: 'test-eslint' }, { name: 'test-lint-intl' }, ], }); const allNpmScriptToRun = [ // Mandatory 'generate', 'check:types', 'lint-deps', // Optional ...npmScriptsToRun, ]; for (const script of allNpmScriptToRun) { console.log(chalk`Running {cyan npm run ${script}}`); // eslint-disable-next-line no-constant-condition while (true) { try { await execa('npm', ['run', script], { stdio: 'inherit' }); break; } catch (error) { console.log( chalk.red( `Failed to run ${script}, you could go make changes and try again` ) ); const { retry } = await enquirer.prompt<{ retry: boolean; }>({ type: 'confirm', name: 'retry', message: 'Retry running script?', initial: true, }); if (!retry) { throw error; } else { console.log(chalk`Retrying {cyan npm run ${script}}`); continue; } } } } console.log('Changes after upgrade:'); await execa('git', ['status', '--porcelain'], { stdio: 'inherit' }); const { commitChanges } = await enquirer.prompt<{ commitChanges: boolean; }>({ type: 'select', name: 'commitChanges', message: 'Commit these changes?', choices: [ { name: 'commit', message: 'Commit and continue', value: true }, { name: 'revert', message: 'Revert and skip', value: false }, ], }); if (!commitChanges) { console.log('Reverting changes, and skipping'); await execa('git', ['checkout', '.']); continue; } console.log('Committing changes'); await execa('git', ['add', '.']); await execa('git', [ 'commit', '-m', `Upgrade ${dep.name} ${dep.depType} from ${dep.requestedVersion} to ${dep.latestVersion}`, ]); } catch (error) { console.error(chalk.red(error)); console.log( chalk.red(`Failed to upgrade ${dep.name}, reverting and skipping`) ); await execa('git', ['checkout', '.']); } } } main().catch(error => { console.error(error); process.exit(1); });