Files
recflare/packages/tools/src/update-pnpm.ts
T
Devin Zuczek 8e644a5c20 Initial commit
2026-06-08 18:23:40 -04:00

222 lines
5.7 KiB
TypeScript

import { cliError } from '@jahands/cli-tools'
import Table from 'cli-table3'
import * as toml from 'smol-toml'
import { match } from 'ts-pattern'
import { z } from 'zod'
import { getRepoRoot } from './path'
import { PackageJson } from './pkg'
type UpdateResult = {
type: string
updated: number
failed: number
files: string[]
errors: string[]
}
export async function updatePnpm() {
const $$ = $({
verbose: false,
stdio: 'pipe',
})
const repoRoot = getRepoRoot()
cd(repoRoot)
const miseAvailable = await which('mise')
echo(chalk.white(`Checking for pnpm updates...`))
const res = await fetch('https://registry.npmjs.org/pnpm')
if (!res.ok) {
throw cliError(`Failed to fetch pnpm registry: ${res.status}`)
}
const body = await res.json()
const pnpm = NpmRegistryPnpmResponse.parse(body)
const latest = pnpm['dist-tags'].latest
echo(chalk.blue(`Latest pnpm version: ${latest}`))
const results: UpdateResult[] = []
if (miseAvailable) {
const miseResult = await updateMiseTomlFiles(repoRoot, latest)
results.push(miseResult)
}
const packageJsonResult = await updatePackageJsonFiles(repoRoot, latest)
results.push(packageJsonResult)
if (results.some((r) => r.updated > 0)) {
echo(chalk.blue(`Fixing formatting...`))
await $$`runx fix --format`
}
// Display summary table
const table = new Table({
head: [
chalk.whiteBright('File Type'),
chalk.whiteBright('Updated'),
chalk.whiteBright('Failed'),
chalk.whiteBright('Status'),
],
})
let totalUpdated = 0
let totalFailed = 0
for (const result of results) {
totalUpdated += result.updated
totalFailed += result.failed
const status = match(result)
.when(
(r) => r.failed > 0,
() => chalk.red('Failed')
)
.when(
(r) => r.updated > 0,
() => chalk.green('Updated')
)
.otherwise(() => chalk.gray('No changes'))
table.push([result.type, result.updated.toString(), result.failed.toString(), status])
}
// Show detailed errors if any
for (const result of results) {
if (result.errors.length > 0) {
echo(chalk.red(`\nErrors in ${result.type}:`))
for (const error of result.errors) {
echo(chalk.red(` ${error}`))
}
}
}
if (totalUpdated > 0) {
if (miseAvailable) {
echo(chalk.blue('\nRunning mise up...'))
await $$`mise up`
}
echo(chalk.blue(`Fixing formatting...`))
await $$`runx fix --format`
echo(chalk.greenBright(`\nSuccessfully updated pnpm to ${latest}`))
} else if (totalFailed > 0) {
echo(chalk.red(`\nFailed to update some files. See errors above.`))
process.exit(1)
} else {
echo(chalk.green(`\nNo pnpm updates needed, already on ${latest}`))
}
echo('\n' + table.toString())
}
const Semver = z.string().regex(/^\d+\.\d+\.\d+$/)
const NpmRegistryPnpmResponse = z.object({
_id: z.literal('pnpm'),
name: z.literal('pnpm'),
'dist-tags': z.object({
latest: Semver.describe('pnpm latest version, e.g. 9.5.0'),
}),
})
type MiseToml = z.infer<typeof MiseToml>
const MiseToml = z
.object({
tools: z
.object({
pnpm: Semver.describe('pnpm version, e.g. 9.5.0').optional(),
})
.catchall(z.string()),
alias: z.record(z.string(), z.string()).optional(),
})
.loose()
/**
* Update package.json files with the new pnpm version
* @param repoRoot - The root directory of the repository
* @param newVersion - The new pnpm version
* @returns UpdateResult with details about the operation
*/
async function updatePackageJsonFiles(repoRoot: string, newVersion: string): Promise<UpdateResult> {
const result: UpdateResult = {
type: 'package.json',
updated: 0,
failed: 0,
files: [],
errors: [],
}
const packageJsonFiles = await glob('**/package.json', {
cwd: repoRoot,
gitignore: true,
ignore: ['**/node_modules/**', 'turbo/generators/**'],
})
for (const file of packageJsonFiles) {
const filePath = `${repoRoot}/${file}`
try {
const packageJson = PackageJson.loose().parse(await Bun.file(filePath).json())
if (packageJson.packageManager?.startsWith('pnpm@')) {
const currentVersion = packageJson.packageManager.replace('pnpm@', '')
if (currentVersion !== newVersion) {
echo(chalk.blue(`Updating ${file} packageManager to pnpm@${newVersion}`))
packageJson.packageManager = `pnpm@${newVersion}`
await Bun.file(filePath).write(JSON.stringify(packageJson, null, 2) + '\n')
result.updated++
result.files.push(file)
}
}
} catch (e) {
result.failed++
result.errors.push(`Failed to update ${file}: ${e instanceof Error ? e.message : String(e)}`)
}
}
return result
}
/**
* Update mise.toml and .mise.toml files with the new pnpm version
* @param repoRoot - The root directory of the repository
* @param newVersion - The new pnpm version
* @returns UpdateResult with details about the operation
*/
async function updateMiseTomlFiles(repoRoot: string, newVersion: string): Promise<UpdateResult> {
const result: UpdateResult = {
type: 'mise.toml',
updated: 0,
failed: 0,
files: [],
errors: [],
}
const miseTomlFiles = await glob('**/{.mise.toml,mise.toml}', {
cwd: repoRoot,
gitignore: true,
ignore: ['**/node_modules/**'],
})
for (const file of miseTomlFiles) {
const filePath = `${repoRoot}/${file}`
try {
const miseToml = MiseToml.parse(toml.parse(await Bun.file(filePath).text()))
if (miseToml.tools.pnpm && miseToml.tools.pnpm !== newVersion) {
echo(chalk.blue(`Updating ${file} to pnpm@${newVersion}`))
miseToml.tools.pnpm = newVersion
const miseString = toml.stringify(miseToml) + '\n'
await Bun.file(filePath).write(miseString.replaceAll('"', "'"))
result.updated++
result.files.push(file)
}
} catch (e) {
result.failed++
result.errors.push(`Failed to update ${file}: ${e instanceof Error ? e.message : String(e)}`)
}
}
return result
}