diff --git a/scripts/migrate-patches.mjs b/scripts/migrate-patches.mjs new file mode 100644 index 0000000000..6217eb77ec --- /dev/null +++ b/scripts/migrate-patches.mjs @@ -0,0 +1,180 @@ +#!/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 @ --edit-dir --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//" prefix. +// 5. pnpm patch-commit -- 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 +.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//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 +.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 +`)