Files
bsky-social-app/scripts/migrate-patches.mjs
T
2026-05-18 19:33:08 +03:00

181 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// One-shot migration from patch-package to pnpm-native patches.
//
// Strategy: delegate the heavy lifting to pnpm.
// 1. Wipe node_modules and reinstall with the postinstall patch-package
// step skipped, so we get pristine upstream sources. (`patch-package
// --reverse` is unreliable here -- pnpm caches the patched files in
// its store, so subsequent `pnpm patch` extractions still come back
// patched.)
// 2. Group ./patches/*.patch files by package (parse filename).
// 3. For each group:
// pnpm patch <pkg>@<version> --edit-dir <tmp> --ignore-existing
// to get a clean extraction.
// 4. Apply each patch-package diff in filename order with `git apply`,
// stripping the leading "a/node_modules/<pkg>/" prefix.
// 5. pnpm patch-commit <tmp> -- writes the squashed patch to ./patches
// and updates pnpm.patchedDependencies in package.json.
//
// The old patch-package files are left in place for review. Once you've
// confirmed the new patches look right:
// - delete the old <pkg>+<version>.patch files (and the .patch.md notes)
// - remove patch-package from devDependencies
// - drop `patch-package &&` from the postinstall script in package.json
// - run `pnpm install` to re-sync node_modules with the new patches
import {execFileSync} from 'node:child_process'
import {mkdtemp, readdir, readFile, rm} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import path from 'node:path'
const root = process.cwd()
const patchesDir = path.join(root, 'patches')
// Parse a patch-package filename. Returns the package name or null.
//
// react-native+0.81.5.patch -> react-native
// react-native+0.81.5+002+ScrollForwarder.patch -> react-native
// @discord+bottom-sheet+4.6.1.patch -> @discord/bottom-sheet
//
// patch-package also supports nested (parent++child) patches, but Bluesky
// doesn't use them, so we bail loudly rather than guessing.
function parsePackageName(filename) {
const base = filename.replace(/\.patch$/, '')
if (base.includes('++')) {
throw new Error(`nested patch not supported: ${filename}`)
}
const segments = base.split('+')
const versionIdx = segments.findIndex((s, i) => i > 0 && /^\d/.test(s))
if (versionIdx < 1) return null
const encoded = segments.slice(0, versionIdx).join('+')
// @scope+name -> @scope/name (only the first '+' is the scope separator)
return encoded.startsWith('@') ? encoded.replace('+', '/') : encoded
}
const patchFiles = (await readdir(patchesDir))
.filter(f => f.endsWith('.patch'))
.sort()
const groups = new Map()
for (const file of patchFiles) {
const pkg = parsePackageName(file)
if (!pkg) {
console.warn(`skip (cannot parse): ${file}`)
continue
}
if (!groups.has(pkg)) groups.set(pkg, [])
groups.get(pkg).push(file)
}
if (groups.size === 0) {
console.error(`No patch-package patches found in ${patchesDir}`)
process.exit(1)
}
// Wipe and reinstall without postinstall scripts so pnpm extracts clean
// upstream sources. --force re-fetches even if files are already in the
// store; --ignore-scripts skips the patch-package postinstall.
console.log('Removing node_modules...')
await rm(path.join(root, 'node_modules'), {recursive: true, force: true})
console.log('Reinstalling fresh (--force --ignore-scripts)...')
execFileSync('pnpm', ['install', '--force', '--ignore-scripts'], {
stdio: 'inherit',
})
for (const [pkg, files] of groups) {
const installedPkgJson = path.join(
root,
'node_modules',
...pkg.split('/'),
'package.json',
)
let version
try {
version = JSON.parse(await readFile(installedPkgJson, 'utf8')).version
} catch {
console.error(`skip ${pkg}: not installed (run pnpm install first)`)
continue
}
console.log(
`\n== ${pkg}@${version} (${files.length} patch${files.length === 1 ? '' : 'es'})`,
)
const tmp = await mkdtemp(path.join(tmpdir(), 'pnpm-patch-'))
try {
execFileSync(
'pnpm',
['patch', `${pkg}@${version}`, '--edit-dir', tmp, '--ignore-existing'],
{stdio: 'inherit'},
)
// Patch paths look like a/node_modules/<pkg>/path/to/file. We need to
// strip a/ + node_modules/ + every segment of the package name to land
// at the package root.
const stripLevel = 2 + pkg.split('/').length
let applied = 0
let stale = 0
for (const file of files) {
const patchPath = path.join(patchesDir, file)
const gitArgs = [
'apply',
`-p${stripLevel}`,
'--ignore-whitespace',
'--recount',
]
try {
execFileSync('git', [...gitArgs, patchPath], {cwd: tmp, stdio: 'pipe'})
console.log(` applied ${file}`)
applied++
continue
} catch {}
// git apply's --ignore-whitespace doesn't tolerate whitespace-only
// mismatches in *context* lines. GNU patch's -l does, so fall back.
try {
execFileSync(
'patch',
[`-p${stripLevel}`, '-l', '-N', '--no-backup-if-mismatch', '-i', patchPath],
{cwd: tmp, stdio: 'pipe'},
)
console.log(` applied ${file} (via GNU patch -l)`)
applied++
continue
} catch {}
// If reversing applies, the patch is already in upstream -- stale.
try {
execFileSync(
'git',
[...gitArgs, '--reverse', '--check', patchPath],
{cwd: tmp, stdio: 'pipe'},
)
console.log(` STALE ${file} (already in upstream, skipping)`)
stale++
} catch {
console.error(` FAILED ${file} (does not apply, not stale)`)
throw new Error(`patch ${file} does not apply against ${pkg}@${version}`)
}
}
if (applied === 0) {
console.log(` -> no live patches for ${pkg}, skipping pnpm patch-commit`)
continue
}
execFileSync('pnpm', ['patch-commit', tmp], {stdio: 'inherit'})
} finally {
await rm(tmp, {recursive: true, force: true})
}
}
console.log(`
Done. Review the generated patches in ./patches, then:
- delete the old <pkg>+<version>.patch files (and any .patch.md notes you
don't want to keep)
- remove patch-package from devDependencies
- drop "patch-package &&" from the postinstall script in package.json
- run pnpm install to confirm everything still applies
`)