#!/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 `)