Bluesky app fork with some witchin' additions 馃挮
1#!/usr/bin/env -S node
2/* eslint-disable import-x/no-nodejs-modules */
3// @ts-check
4
5// https://github.com/karlhorky/pnpm-tricks#convert-patch-package-patches-to-pnpm-patches
6
7/**
8 * Convert patch-package patches to pnpm-native patches by:
9 *
10 * 1. Group all patch-package patches by package name
11 * 2. For non-overlapping patches: strip prefixes and
12 * concatenate
13 * 3. For overlapping patches (same file in multiple patches):
14 * download the original package, apply patches sequentially,
15 * and generate a squashed diff
16 * 4. Write a patch file: patches/<@scope__name>.patch
17 * 5. Update pnpm.patchedDependencies in package.json with a
18 * version-qualified key
19 *
20 * Original patch files not deleted, to allow for comparison.
21 */
22
23import { execSync } from 'node:child_process'
24import { readFile, writeFile, cp, rm, mkdtemp, mkdir } from 'node:fs/promises'
25import os from 'node:os'
26import path from 'node:path'
27import util from 'node:util'
28
29import glob from 'glob'
30
31const globAsync = util.promisify(glob)
32
33const rootDir = process.cwd()
34const patchesDir = path.join(rootDir, 'patches')
35const packageJsonPath = path.join(rootDir, 'package.json')
36
37const patchPackagePatchPaths = (await globAsync('*+*.patch', { cwd: patchesDir }))
38 .map(
39 (file) => path.join(patchesDir, file),
40 )
41patchPackagePatchPaths.sort()
42
43if (patchPackagePatchPaths.length === 0) {
44 console.log(`No patch-package patches found in ${patchesDir}.`)
45 process.exit(1)
46}
47
48const packageJsonContent = /** @type {{ pnpm?: { patchedDependencies?: Record<string, string> } }} */ (JSON.parse(
49 await readFile(packageJsonPath, 'utf8'),
50))
51packageJsonContent.pnpm ??= {}
52packageJsonContent.pnpm.patchedDependencies ??= {}
53
54/**
55 * Parse a patch-package filename to extract package info.
56 *
57 * Handles formats like:
58 * react-native+0.81.5.patch
59 * react-native+0.81.5+001+initial.patch
60 * @atproto+api+0.14.21.patch
61 * parent++child+1.0.0.patch
62 *
63 * @param {string} filePath
64 */
65function parsePatchFilename(filePath) {
66 const base = path.basename(filePath, '.patch')
67
68 // Handle parent++leaf separation
69 let parentEncoded
70 let leafPart = base
71 const parentSepIndex = base.indexOf('++')
72 if (parentSepIndex !== -1) {
73 parentEncoded = base.slice(0, parentSepIndex)
74 leafPart = base.slice(parentSepIndex + 2)
75 }
76
77 // Split by '+' and find the version (first segment starting with a digit)
78 const segments = leafPart.split('+')
79 let versionIndex = -1
80 for (let i = 1; i < segments.length; i++) {
81 if (/^\d/.test(segments[i])) {
82 versionIndex = i
83 break
84 }
85 }
86
87 if (versionIndex < 1) {
88 return null
89 }
90
91 const encodedLeafName = segments.slice(0, versionIndex).join('+')
92 // @scope+name -> @scope/name
93 const leafPackageName = encodedLeafName.replaceAll('+', '/')
94
95 const parentPackageName = parentEncoded
96 ? parentEncoded.replaceAll('+', '/')
97 : undefined
98
99 const nodeModulesPathPrefix = (
100 parentPackageName ? [parentPackageName, leafPackageName] : [leafPackageName]
101 )
102 .map((segment) => `node_modules/${segment}`)
103 .join('/')
104
105 return {
106 leafPackageName,
107 encodedLeafName,
108 nodeModulesPathPrefix,
109 }
110}
111
112// Group patches by leaf package name
113/** @type {Map<string, { encodedLeafName: string, nodeModulesPathPrefix: string, paths: string[] }>} */
114const patchGroups = new Map()
115
116for (const patchPath of patchPackagePatchPaths) {
117 const parsed = parsePatchFilename(patchPath)
118 if (!parsed) {
119 console.error(
120 `Skipping ${patchPath}: cannot parse filename`,
121 )
122 continue
123 }
124
125 if (!patchGroups.has(parsed.leafPackageName)) {
126 patchGroups.set(parsed.leafPackageName, { ...parsed, paths: [] })
127 }
128 patchGroups.get(parsed.leafPackageName).paths.push(patchPath)
129}
130
131/**
132 * Extract the set of files modified by a patch.
133 * @param {string} patchContent
134 * @returns {Set<string>}
135 */
136function getModifiedFiles(patchContent) {
137 const files = new Set()
138 for (const match of patchContent.matchAll(/^diff --git a\/(.+?) b\//gm)) {
139 files.add(match[1])
140 }
141 return files
142}
143
144/**
145 * Check if multiple patches modify any of the same files.
146 * @param {string[]} patchPaths
147 * @param {string} nodeModulesPathPrefix
148 * @returns {Promise<boolean>}
149 */
150async function hasOverlappingFiles(patchPaths, nodeModulesPathPrefix) {
151 const allFiles = new Set()
152 for (const patchPath of patchPaths) {
153 const content = await readFile(patchPath, 'utf8')
154 const stripped = content.replaceAll(`a/${nodeModulesPathPrefix}/`, 'a/')
155 for (const file of getModifiedFiles(stripped)) {
156 if (allFiles.has(file)) return true
157 allFiles.add(file)
158 }
159 }
160 return false
161}
162
163for (const [leafPackageName, group] of patchGroups) {
164 const packageDir = path.join(rootDir, 'node_modules', ...leafPackageName.split('/'))
165
166 // Read installed version for the version-qualified key
167 let installedVersion
168 try {
169 const installedPkgJson = JSON.parse(
170 await readFile(path.join(packageDir, 'package.json'), 'utf8'),
171 )
172 installedVersion = installedPkgJson.version
173 } catch {
174 console.warn(
175 `Could not resolve installed version for ${leafPackageName}`,
176 )
177 }
178
179 const overlapping = group.paths.length > 1 &&
180 await hasOverlappingFiles(group.paths, group.nodeModulesPathPrefix)
181
182 let pnpmPatchContent
183
184 if (!overlapping) {
185 // Simple path: strip node_modules prefix and concatenate
186 const convertedParts = []
187 for (const patchPath of group.paths) {
188 const content = await readFile(patchPath, 'utf8')
189 const converted = content
190 .replaceAll(`a/${group.nodeModulesPathPrefix}/`, 'a/')
191 .replaceAll(`b/${group.nodeModulesPathPrefix}/`, 'b/')
192 convertedParts.push(converted)
193 }
194 pnpmPatchContent = convertedParts.join('')
195 } else {
196 // Overlapping patches: download original, apply sequentially, squash
197 if (!installedVersion) {
198 console.error(
199 `Cannot squash overlapping patches for ${leafPackageName}: unknown installed version`,
200 )
201 continue
202 }
203
204 const pkgSegments = leafPackageName.split('/')
205 const stripLevel = 2 + pkgSegments.length
206
207 const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'pnpm-patch-'))
208 const originalDir = path.join(tmpDir, 'original')
209 const patchedDir = path.join(tmpDir, 'patched')
210
211 try {
212 // Download original package tarball
213 console.log(`Downloading ${leafPackageName}@${installedVersion}...`)
214 execSync(
215 `npm pack "${leafPackageName}@${installedVersion}" --pack-destination "${tmpDir.replaceAll('\\', '/')}"`,
216 { stdio: 'pipe' },
217 )
218
219 // Find the tarball (npm creates <scope-less-name>-<version>.tgz)
220 const tarballs = (await util.promisify(glob)('*.tgz', { cwd: tmpDir }))
221 if (tarballs.length === 0) {
222 throw new Error(`No tarball found after npm pack for ${leafPackageName}`)
223 }
224 const tarballPath = path.join(tmpDir, tarballs[0]).replaceAll('\\', '/')
225
226 // Extract to both original and patched dirs
227 await mkdir(originalDir, { recursive: true })
228 await mkdir(patchedDir, { recursive: true })
229 execSync(
230 `tar xzf "${tarballPath}" -C "${originalDir.replaceAll('\\', '/')}" --strip-components=1`,
231 { stdio: 'pipe' },
232 )
233 execSync(
234 `tar xzf "${tarballPath}" -C "${patchedDir.replaceAll('\\', '/')}" --strip-components=1`,
235 { stdio: 'pipe' },
236 )
237
238 // Apply each patch in sequence to the patched copy
239 for (const patchPath of group.paths) {
240 const gitPatchPath = patchPath.replaceAll('\\', '/')
241 try {
242 execSync(
243 `git apply -p${stripLevel} --ignore-whitespace "${gitPatchPath}"`,
244 { cwd: patchedDir, stdio: 'pipe' },
245 )
246 } catch (/** @type {any} */ e) {
247 const stderr = e.stderr?.toString() || ''
248 console.error(`Warning: patch may not have applied cleanly: ${path.basename(patchPath)}`)
249 console.error(stderr)
250 // Try with more lenient settings
251 execSync(
252 `git apply -p${stripLevel} --ignore-whitespace -C0 "${gitPatchPath}"`,
253 { cwd: patchedDir, stdio: 'pipe' },
254 )
255 }
256 }
257
258 // Generate a single squashed diff
259 const gitOriginalDir = originalDir.replaceAll('\\', '/')
260 const gitPatchedDir = patchedDir.replaceAll('\\', '/')
261 let diff
262 try {
263 execSync(
264 `git diff --no-ext-diff --no-index -- "${gitOriginalDir}" "${gitPatchedDir}"`,
265 { encoding: 'utf8', stdio: 'pipe', maxBuffer: 50 * 1024 * 1024 },
266 )
267 console.log(`No differences found for ${leafPackageName}, skipping`)
268 continue
269 } catch (/** @type {any} */ e) {
270 if (e.status === 1) {
271 diff = /** @type {string} */ (e.stdout)
272 } else {
273 throw e
274 }
275 }
276
277 // Replace temp dir paths with standard a/ b/ prefixes
278 pnpmPatchContent = diff
279 .replaceAll(`a/${gitOriginalDir}/`, 'a/')
280 .replaceAll(`b/${gitPatchedDir}/`, 'b/')
281 .replaceAll(`${gitOriginalDir}/`, '')
282 .replaceAll(`${gitPatchedDir}/`, '')
283 } finally {
284 await rm(tmpDir, { recursive: true, force: true })
285 }
286 }
287
288 // @scope+name -> @scope__name
289 const pnpmPatchPath = path.join(
290 patchesDir,
291 `${group.encodedLeafName.replaceAll('+', '__')}.patch`,
292 )
293 await writeFile(pnpmPatchPath, pnpmPatchContent)
294
295 const patchedDepKey = installedVersion
296 ? `${leafPackageName}@${installedVersion}`
297 : leafPackageName
298
299 packageJsonContent.pnpm.patchedDependencies[patchedDepKey] = path
300 .relative(rootDir, pnpmPatchPath)
301 .replaceAll('\\', '/')
302
303 const patchNames = group.paths.map((p) => path.relative(rootDir, p))
304 console.log(
305 `${overlapping ? 'Squashed' : 'Converted'} ${group.paths.length} patch(es) for ${leafPackageName}: ${patchNames.join(', ')}`,
306 )
307}
308
309await writeFile(
310 packageJsonPath,
311 JSON.stringify(packageJsonContent, null, 2) + '\n',
312)
313console.log(
314 "All patches squashed to pnpm patches. Run 'pnpm install' to apply.",
315)