// Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // @ts-check import { join } from 'node:path'; import { readFile } from 'node:fs/promises'; import { styleText } from 'node:util'; import semver from 'semver'; import { got, HTTPError } from 'got'; import enquirer from 'enquirer'; import { execa } from 'execa'; import { assert } from './utils/assert.mjs'; const rootDir = join(import.meta.dirname, '..'); /** * @param {string} path * @returns {Promise} */ async function readJsonFile(path) { return JSON.parse(await readFile(path, 'utf-8')); } /** * @param {string | number | null | undefined} value * @returns {number | null} */ function parseNumberField(value) { 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 HTTPError && retry.error.response.statusCode === 429 ) { const retryAfter = parseNumberField( retry.error.response.headers['retry-after'] ); if (retryAfter != null) { console.log( styleText( 'gray', `Rate limited, retrying after ${retryAfter} seconds` ) ); return retryAfter * 1000; } } return retry.computedValue; }, }, }); const DependencyTypes = /** @type {const} */ ([ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', ]); /** * @typedef {object} LocalDependency * @prop {string} name * @prop {(typeof DependencyTypes)[number]} depType * @prop {string} requestedVersion * @prop {string} resolvedVersion */ /** * @typedef {object} FetchedDependencyProps * @prop {string} latestVersion * @prop {'commonjs' | 'esm'} moduleType * @prop {semver.ReleaseType | null} diff * * @typedef {LocalDependency & FetchedDependencyProps} FetchedDependency */ const packageJson = await readJsonFile(join(rootDir, 'package.json')); const packageLock = await readJsonFile(join(rootDir, 'package-lock.json')); /** @type {ReadonlyArray} */ const localDeps = 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( `Found ${styleText('cyan', `${localDeps.length}`)} local dependencies` ); /** @type {ReadonlyArray} */ const fetchedDeps = await Promise.all( localDeps.map(async dep => { /** @type {any} */ const info = 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( `Found ${styleText('cyan', `${outdatedDeps.length}`)} outdated dependencies` ); const upgradeableDeps = outdatedDeps.filter(dep => { return dep.moduleType === 'commonjs'; }); console.log( `Found ${styleText('cyan', `${upgradeableDeps.length}`)} upgradeable dependencies` ); /** @type {Map>} */ 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(` - ${diff}: ${styleText('cyan', `${deps.size}`)}`); } let longestNameLength = 0; for (const dep of upgradeableDeps) { longestNameLength = Math.max(longestNameLength, dep.name.length); } /** @type {{ approvedDeps: ReadonlyArray; }} */ const { approvedDeps } = await enquirer.prompt({ type: 'multiselect', name: 'approvedDeps', message: 'Select which dependencies to upgrade', choices: upgradeableDeps.map(deps => { /** @type {'red' | 'green' | 'yellow'} */ let color = 'red'; if (deps.diff === 'patch') { color = 'green'; } else if (deps.diff === 'minor') { color = 'yellow'; } return { name: deps.name, message: deps.name.padEnd(longestNameLength), hint: `(${styleText(color, `${deps.diff}`)}: ${deps.resolvedVersion} -> ${styleText(color, deps.latestVersion)})`, }; }), }); console.log( `Starting upgrade of ${styleText('cyan', `${approvedDeps.length}`)} dependencies` ); for (const dep of upgradeableDeps) { try { if (!approvedDeps.includes(dep.name)) { console.log(`Skipping ${dep.name}`); continue; } // oxlint-disable-next-line no-await-in-loop const gitStatusBefore = await execa('git', ['status', '--porcelain']); if (gitStatusBefore.stdout.trim() !== '') { console.error(styleText('red', 'Found uncommitted changes, exiting')); console.error(styleText('red', gitStatusBefore.stdout)); process.exit(1); } console.log( `Upgrading ${styleText('cyan', dep.name)} ` + `from ${styleText('yellow', dep.resolvedVersion)} ` + `to ${styleText('magenta', dep.latestVersion)}` ); // oxlint-disable-next-line no-await-in-loop await execa( 'npm', ['install', '--save-exact', `${dep.name}@${dep.latestVersion}`], { stdio: 'inherit' } ); /** @type {{ npmScriptsToRun: Array }} */ // oxlint-disable-next-line no-await-in-loop const { npmScriptsToRun } = await enquirer.prompt({ type: 'multiselect', name: 'npmScriptsToRun', message: 'Select which scripts to run', choices: [ // Fast and common { name: 'oxlint' }, { name: 'test-node' }, { name: 'test-electron' }, // Long { name: 'test-mock' }, // Uncommon { name: 'test-oxlint' }, { name: 'test-lint-intl' }, ], }); const allNpmScriptToRun = [ // Mandatory 'generate', 'check:types', 'lint-deps', // Optional ...npmScriptsToRun, ]; for (const script of allNpmScriptToRun) { console.log(`Running ${styleText('cyan', `npm run ${script}`)}`); // oxlint-disable-next-line no-constant-condition while (true) { try { // oxlint-disable-next-line no-await-in-loop await execa('npm', ['run', script], { stdio: 'inherit' }); break; } catch (error) { console.log( styleText( 'red', `Failed to run ${script}, you could go make changes and try again` ) ); /** @type {{ retry: boolean }} */ // oxlint-disable-next-line no-await-in-loop const { retry } = await enquirer.prompt({ type: 'confirm', name: 'retry', message: 'Retry running script?', initial: true, }); if (!retry) { throw error; } else { console.log(`Retrying ${styleText('cyan', `npm run ${script}`)}`); continue; } } } } console.log('Changes after upgrade:'); // oxlint-disable-next-line no-await-in-loop await execa('git', ['status', '--porcelain'], { stdio: 'inherit' }); /** @type {{ commitChanges: boolean }} */ // oxlint-disable-next-line no-await-in-loop const { commitChanges } = await enquirer.prompt({ 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'); // oxlint-disable-next-line no-await-in-loop await execa('git', ['checkout', '.']); continue; } console.log('Committing changes'); // oxlint-disable-next-line no-await-in-loop await execa('git', ['add', '.']); // oxlint-disable-next-line no-await-in-loop await execa('git', [ 'commit', '-m', `Upgrade ${dep.name} ${dep.depType} from ${dep.requestedVersion} to ${dep.latestVersion}`, ]); } catch (error) { console.error(styleText('red', error)); console.log( styleText('red', `Failed to upgrade ${dep.name}, reverting and skipping`) ); // oxlint-disable-next-line no-await-in-loop await execa('git', ['checkout', '.']); } }