From 7bb6d306e64ffff66e094063a0ab3c4ddfbb6ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 05:25:58 +0000 Subject: [PATCH 1/4] Remove 5 production dependencies in favor of Node.js builtins Replace @malept/cross-spawn-promise with child_process.execFile, debug with util.debuglog, minimatch with path.matchesGlob (Node 22), dir-compare with a simple recursive comparison, and plist with macOS plutil command. This eliminates ~22 transitive packages, leaving @electron/asar as the sole production dependency. https://claude.ai/code/session_0112RFfDPLMXemxFs6qcKf27 --- package.json | 10 +-- src/asar-utils.ts | 7 ++- src/debug.ts | 4 +- src/file-utils.ts | 9 ++- src/index.ts | 113 ++++++++++++++++++++++++++++------ test/util.ts | 19 +++--- yarn.lock | 154 +--------------------------------------------- 7 files changed, 118 insertions(+), 198 deletions(-) diff --git a/package.json b/package.json index 38543a2..baa859c 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,7 @@ "@electron/get": "^4.0.0", "@tsconfig/node22": "^22.0.1", "@types/cross-zip": "^4.0.1", - "@types/debug": "^4.1.10", - "@types/minimatch": "^5.1.2", "@types/node": "~22.10.7", - "@types/plist": "^3.0.4", "cross-zip": "^4.0.0", "husky": "^9.1.7", "lint-staged": "^16.1.0", @@ -53,12 +50,7 @@ "vitest": "^4.1.2" }, "dependencies": { - "@electron/asar": "^4.0.0", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "minimatch": "^9.0.3", - "plist": "^3.1.0" + "@electron/asar": "^4.0.0" }, "lint-staged": { "*.ts": [ diff --git a/src/asar-utils.ts b/src/asar-utils.ts index 94077b7..189ee07 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -5,8 +5,6 @@ import os from 'node:os'; import path from 'node:path'; import * as asar from '@electron/asar'; -import { minimatch } from 'minimatch'; - import { d } from './debug.js'; import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC } from './file-utils.js'; @@ -57,7 +55,10 @@ function isDirectory(a: string, file: string): boolean { } function checkSingleArch(archive: string, file: string, allowList?: string): void { - if (allowList === undefined || !minimatch(file, allowList, { matchBase: true })) { + if ( + allowList === undefined || + !path.matchesGlob(allowList.includes('/') ? file : path.basename(file), allowList) + ) { throw new Error( `Detected unique file "${file}" in "${archive}" not covered by ` + `allowList rule: "${allowList}"`, diff --git a/src/debug.ts b/src/debug.ts index e5de091..0ece9f5 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -1,3 +1,3 @@ -import debug from 'debug'; +import { debuglog } from 'node:util'; -export const d = debug('electron-universal'); +export const d = debuglog('electron-universal'); diff --git a/src/file-utils.ts b/src/file-utils.ts index 5d1e248..0bbb35c 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -2,8 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { promises as stream } from 'node:stream'; -import { minimatch } from 'minimatch'; - // See: https://github.com/apple-opensource-mirror/llvmCore/blob/0c60489d96c87140db9a6a14c6e82b15f5e5d252/include/llvm/Object/MachOFormat.h#L108-L112 export const MACHO_MAGIC = new Set([ // 32-bit Mach-O @@ -67,9 +65,10 @@ const isSingleArchFile = (relativePath: string, opts: GetAllAppFilesOpts): boole return false; } - return minimatch(unpackedPath, opts.singleArchFiles, { - matchBase: true, - }); + return path.matchesGlob( + opts.singleArchFiles.includes('/') ? unpackedPath : path.basename(unpackedPath), + opts.singleArchFiles, + ); }; /** diff --git a/src/index.ts b/src/index.ts index b80c6a8..9e7e5ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,10 @@ +import { execFile as execFileCb, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import * as asar from '@electron/asar'; -import { spawn } from '@malept/cross-spawn-promise'; -import * as dircompare from 'dir-compare'; -import { minimatch } from 'minimatch'; -import plist from 'plist'; import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js'; import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js'; @@ -14,6 +12,76 @@ import { sha } from './sha.js'; import { d } from './debug.js'; import { computeIntegrityData } from './integrity.js'; +const execFile = promisify(execFileCb); + +function parsePlist(filePath: string): Record { + return JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', filePath], { encoding: 'utf8' }), + ); +} + +function buildPlist(obj: Record): string { + const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'plist-')), 'data.json'); + try { + fs.writeFileSync(tmpFile, JSON.stringify(obj)); + return execFileSync('plutil', ['-convert', 'xml1', '-o', '-', tmpFile], { encoding: 'utf8' }); + } finally { + fs.rmSync(path.dirname(tmpFile), { recursive: true, force: true }); + } +} + +type DiffEntry = { + state: 'equal' | 'distinct' | 'left' | 'right'; + name1?: string; + relativePath: string; +}; + +async function compareDirectories(dir1: string, dir2: string): Promise { + async function getFiles(dir: string, rel = ''): Promise> { + const entries = new Map(); + for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) { + const relPath = rel ? path.join(rel, item.name) : item.name; + if (item.isDirectory()) { + for (const [k, v] of await getFiles(path.join(dir, item.name), relPath)) { + entries.set(k, v); + } + } else if (item.isFile() || item.isSymbolicLink()) { + entries.set(relPath, path.join(dir, item.name)); + } + } + return entries; + } + + const files1 = await getFiles(dir1); + const files2 = await getFiles(dir2); + const results: DiffEntry[] = []; + + for (const relFile of new Set([...files1.keys(), ...files2.keys()])) { + const name = path.basename(relFile); + const relDir = path.dirname(relFile); + const relativePath = relDir === '.' ? '' : relDir; + + if (!files1.has(relFile)) { + results.push({ state: 'right', relativePath }); + continue; + } + if (!files2.has(relFile)) { + results.push({ state: 'left', name1: name, relativePath }); + continue; + } + + const content1 = await fs.promises.readFile(files1.get(relFile)!); + const content2 = await fs.promises.readFile(files2.get(relFile)!); + results.push({ + state: content1.equals(content2) ? 'equal' : 'distinct', + name1: name, + relativePath, + }); + } + + return results; +} + /** * Options to pass into the {@link makeUniversalApp} function. * @@ -125,11 +193,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = // On APFS (standard on modern macOS), -c does a copy-on-write clone // that's near-instant even for multi-hundred-MB apps. d('copying x64 app as starter template via APFS clone (cp -cR)'); - await spawn('cp', ['-cR', opts.x64AppPath, tmpApp]); + await execFile('cp', ['-cR', opts.x64AppPath, tmpApp]); } catch { // -c fails on non-APFS volumes; fall back to a regular copy. d('APFS clone unsupported, falling back to regular cp -R'); - await spawn('cp', ['-R', opts.x64AppPath, tmpApp]); + await execFile('cp', ['-R', opts.x64AppPath, tmpApp]); } const uniqueToX64: string[] = []; @@ -194,7 +262,12 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = if (x64Sha === arm64Sha) { if ( opts.x64ArchFiles === undefined || - !minimatch(machOFile.relativePath, opts.x64ArchFiles, { matchBase: true }) + !path.matchesGlob( + opts.x64ArchFiles.includes('/') + ? machOFile.relativePath + : path.basename(machOFile.relativePath), + opts.x64ArchFiles, + ) ) { throw new Error( `Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` + @@ -214,7 +287,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = first, second, }); - await spawn('lipo', [ + await execFile('lipo', [ first, second, '-create', @@ -232,12 +305,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = */ if (x64AsarMode === AsarMode.NO_ASAR) { d('checking if the x64 and arm64 app folders are identical'); - const comparison = await dircompare.compare( + const diffSet = await compareDirectories( path.resolve(tmpApp, 'Contents', 'Resources', 'app'), path.resolve(opts.arm64AppPath, 'Contents', 'Resources', 'app'), - { compareSize: true, compareContent: true }, ); - const differences = comparison.diffSet!.filter((difference) => difference.state !== 'equal'); + const differences = diffSet.filter((difference) => difference.state !== 'equal'); d(`Found ${differences.length} difference(s) between the x64 and arm64 folders`); const nonMergedDifferences = differences.filter( (difference) => @@ -377,11 +449,9 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); - const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( - await fs.promises.readFile(x64PlistPath, 'utf8'), - ) as any; - const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( - await fs.promises.readFile(arm64PlistPath, 'utf8'), + const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = parsePlist(x64PlistPath) as any; + const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = parsePlist( + arm64PlistPath, ) as any; if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { throw new Error( @@ -391,14 +461,19 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = const injectAsarIntegrity = !opts.infoPlistsToIgnore || - minimatch(plistFile.relativePath, opts.infoPlistsToIgnore, { matchBase: true }); + path.matchesGlob( + opts.infoPlistsToIgnore.includes('/') + ? plistFile.relativePath + : path.basename(plistFile.relativePath), + opts.infoPlistsToIgnore, + ); const mergedPlist = injectAsarIntegrity ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } : { ...x64Plist }; await fs.promises.writeFile( path.resolve(tmpApp, plistFile.relativePath), - plist.build(mergedPlist), + buildPlist(mergedPlist), ); } @@ -412,7 +487,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = d('moving final universal app to target destination'); await fs.promises.mkdir(path.dirname(opts.outAppPath), { recursive: true }); - await spawn('mv', [tmpApp, opts.outAppPath]); + await fsMove(tmpApp, opts.outAppPath); } catch (err) { throw err; } finally { diff --git a/test/util.ts b/test/util.ts index 007af00..96fabfb 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,14 +1,16 @@ +import { execFile as execFileCb, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { promisify } from 'node:util'; import { createPackageWithOptions, getRawHeader } from '@electron/asar'; import { downloadArtifact } from '@electron/get'; -import { spawn } from '@malept/cross-spawn-promise'; import * as zip from 'cross-zip'; -import plist from 'plist'; import type { ExpectStatic } from 'vitest'; +const execFile = promisify(execFileCb); + import * as fileUtils from '../dist/file-utils.js'; // We do a LOT of verifications in `verifyApp` 😅 @@ -87,10 +89,11 @@ export const verifyApp = async ( expect(integrityMap).toMatchSnapshot(); }; -const extractAsarIntegrity = async (infoPlist: string) => { - const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse( - await fs.promises.readFile(infoPlist, 'utf-8'), - ) as any; +const extractAsarIntegrity = (infoPlist: string) => { + const parsed = JSON.parse( + execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlist], { encoding: 'utf8' }), + ); + const { ElectronAsarIntegrity: integrity, ...otherData } = parsed; return integrity; }; @@ -109,9 +112,9 @@ export const verifyFileTree = async (expect: ExpectStatic, dirPath: string) => { export const ensureUniversal = async (expect: ExpectStatic, app: string) => { const exe = path.resolve(app, 'Contents', 'MacOS', 'Electron'); - const result = await spawn(exe); + const { stdout: result } = await execFile(exe); expect(result).toContain('arm64'); - const result2 = await spawn('arch', ['-x86_64', exe]); + const { stdout: result2 } = await execFile('arch', ['-x86_64', exe]); expect(result2).toContain('x64'); }; diff --git a/yarn.lock b/yarn.lock index 64717b9..a3704ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43,20 +43,12 @@ __metadata: dependencies: "@electron/asar": "npm:^4.0.0" "@electron/get": "npm:^4.0.0" - "@malept/cross-spawn-promise": "npm:^2.0.0" "@tsconfig/node22": "npm:^22.0.1" "@types/cross-zip": "npm:^4.0.1" - "@types/debug": "npm:^4.1.10" - "@types/minimatch": "npm:^5.1.2" "@types/node": "npm:~22.10.7" - "@types/plist": "npm:^3.0.4" cross-zip: "npm:^4.0.0" - debug: "npm:^4.3.1" - dir-compare: "npm:^4.2.0" husky: "npm:^9.1.7" lint-staged: "npm:^16.1.0" - minimatch: "npm:^9.0.3" - plist: "npm:^3.1.0" prettier: "npm:^3.5.3" typedoc: "npm:~0.25.13" typescript: "npm:^5.8.3" @@ -92,15 +84,6 @@ __metadata: languageName: node linkType: hard -"@malept/cross-spawn-promise@npm:^2.0.0": - version: 2.0.0 - resolution: "@malept/cross-spawn-promise@npm:2.0.0" - dependencies: - cross-spawn: "npm:^7.0.1" - checksum: 10c0/84d60b8d467f4252114849f0a33c3763f07898335269eec5c94978ccac9d5680e1e268d993dd1a6d25a91476f9e0992759d7e1f385f9f3a090d862f9bb949603 - languageName: node - linkType: hard - "@napi-rs/wasm-runtime@npm:^1.1.1": version: 1.1.2 resolution: "@napi-rs/wasm-runtime@npm:1.1.2" @@ -297,15 +280,6 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.1.10": - version: 4.1.10 - resolution: "@types/debug@npm:4.1.10" - dependencies: - "@types/ms": "npm:*" - checksum: 10c0/b3479ffdfd141809b165944d3b3bf3b6a70f95064228a4fa0ff470a25c8ab3f3db7b9f5be0a7460dc9d6fe3595bdb4cbc088c9102bd7afa596dba754f0585ead - languageName: node - linkType: hard - "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -327,29 +301,6 @@ __metadata: languageName: node linkType: hard -"@types/minimatch@npm:^5.1.2": - version: 5.1.2 - resolution: "@types/minimatch@npm:5.1.2" - checksum: 10c0/83cf1c11748891b714e129de0585af4c55dd4c2cafb1f1d5233d79246e5e1e19d1b5ad9e8db449667b3ffa2b6c80125c429dbee1054e9efb45758dbc4e118562 - languageName: node - linkType: hard - -"@types/ms@npm:*": - version: 0.7.33 - resolution: "@types/ms@npm:0.7.33" - checksum: 10c0/ef610d94ebee838243af37800cb5d1a52b2ae0fb6880675fbb9276c0c4afcefda755f16889fa597ee4e5b377998a7e67b453614aae68d3225e5f7219984284df - languageName: node - linkType: hard - -"@types/node@npm:*": - version: 20.8.10 - resolution: "@types/node@npm:20.8.10" - dependencies: - undici-types: "npm:~5.26.4" - checksum: 10c0/caaa3ae9294f1bfdacb029a916c64af63cbcea613a52f53ea86f93c91779859af177b2b68113ef835194519f5e76cadda08559929b68297f1a8a568c207f9f66 - languageName: node - linkType: hard - "@types/node@npm:~22.10.7": version: 22.10.10 resolution: "@types/node@npm:22.10.10" @@ -359,16 +310,6 @@ __metadata: languageName: node linkType: hard -"@types/plist@npm:^3.0.4": - version: 3.0.4 - resolution: "@types/plist@npm:3.0.4" - dependencies: - "@types/node": "npm:*" - xmlbuilder: "npm:>=11.0.1" - checksum: 10c0/21f3f45e63a785e0acf55cc6a071936e5ad2dfbef67c9441de1e3a81e686d00b96934a5794822a3ff4d560bc781a455f0e88a3157bc8763cd6068e0614ef8bdc - languageName: node - linkType: hard - "@vitest/expect@npm:4.1.2": version: 4.1.2 resolution: "@vitest/expect@npm:4.1.2" @@ -451,13 +392,6 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:^0.8.8": - version: 0.8.12 - resolution: "@xmldom/xmldom@npm:0.8.12" - checksum: 10c0/b733c84292d1bee32ef21a05aba8f9063456b51a54068d0b4a1abf5545156ee0b9894b7ae23775b5881b11c35a8a03871d1b514fb7e1b11654cdbee57e1c2707 - languageName: node - linkType: hard - "ansi-escapes@npm:^7.0.0": version: 7.0.0 resolution: "ansi-escapes@npm:7.0.0" @@ -527,13 +461,6 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.5.1": - version: 1.5.1 - resolution: "base64-js@npm:1.5.1" - checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf - languageName: node - linkType: hard - "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -541,16 +468,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.13 - resolution: "brace-expansion@npm:1.1.13" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/384c61bb329b6adfdcc0cbbdd108dc19fb5f3e84ae15a02a74f94c6c791b5a9b035aae73b2a51929a8a478e2f0f212a771eb6a8b5b514cccfb8d0c9f2ce8cbd8 - languageName: node - linkType: hard - "brace-expansion@npm:^2.0.2": version: 2.0.3 resolution: "brace-expansion@npm:2.0.3" @@ -670,13 +587,6 @@ __metadata: languageName: node linkType: hard -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -684,7 +594,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -702,7 +612,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1": +"debug@npm:^4.1.0, debug@npm:^4.1.1": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -778,16 +688,6 @@ __metadata: languageName: node linkType: hard -"dir-compare@npm:^4.2.0": - version: 4.2.0 - resolution: "dir-compare@npm:4.2.0" - dependencies: - minimatch: "npm:^3.0.5" - p-limit: "npm:^3.1.0 " - checksum: 10c0/615c6f6804095f912d98d49f9b56798ceebbc83612d660b7faa6bdb4894d978c02cfa1a30853a7319a269141e4f2a2034d4988a1985b58382614a3942f94e5b2 - languageName: node - linkType: hard - "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -1458,15 +1358,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.5": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - "minimatch@npm:^9.0.3": version: 9.0.9 resolution: "minimatch@npm:9.0.9" @@ -1550,15 +1441,6 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.1.0 ": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a - languageName: node - linkType: hard - "package-json-from-dist@npm:^1.0.0": version: 1.0.1 resolution: "package-json-from-dist@npm:1.0.1" @@ -1620,17 +1502,6 @@ __metadata: languageName: node linkType: hard -"plist@npm:^3.1.0": - version: 3.1.0 - resolution: "plist@npm:3.1.0" - dependencies: - "@xmldom/xmldom": "npm:^0.8.8" - base64-js: "npm:^1.5.1" - xmlbuilder: "npm:^15.1.1" - checksum: 10c0/db19ba50faafc4103df8e79bcd6b08004a56db2a9dd30b3e5c8b0ef30398ef44344a674e594d012c8fc39e539a2b72cb58c60a76b4b4401cbbc7c8f6b028d93d - languageName: node - linkType: hard - "postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" @@ -2060,13 +1931,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~5.26.4": - version: 5.26.5 - resolution: "undici-types@npm:5.26.5" - checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501 - languageName: node - linkType: hard - "undici-types@npm:~6.20.0": version: 6.20.0 resolution: "undici-types@npm:6.20.0" @@ -2263,13 +2127,6 @@ __metadata: languageName: node linkType: hard -"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": - version: 15.1.1 - resolution: "xmlbuilder@npm:15.1.1" - checksum: 10c0/665266a8916498ff8d82b3d46d3993913477a254b98149ff7cff060d9b7cc0db7cf5a3dae99aed92355254a808c0e2e3ec74ad1b04aa1061bdb8dfbea26c18b8 - languageName: node - linkType: hard - "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -2285,10 +2142,3 @@ __metadata: checksum: 10c0/ddff0e11c1b467728d7eb4633db61c5f5de3d8e9373cf84d08fb0cdee03e1f58f02b9f1c51a4a8a865751695addbd465a77f73f1079be91fe5493b29c305fd77 languageName: node linkType: hard - -"yocto-queue@npm:^0.1.0": - version: 0.1.0 - resolution: "yocto-queue@npm:0.1.0" - checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f - languageName: node - linkType: hard From 3da787a006e5a44f77dc6b410c31f44e62bfa31b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 05:30:47 +0000 Subject: [PATCH 2/4] Keep debug and plist as production dependencies Restore debug and plist imports/usage per review feedback. The final set of removed dependencies is: @malept/cross-spawn-promise, dir-compare, and minimatch. https://claude.ai/code/session_0112RFfDPLMXemxFs6qcKf27 --- package.json | 6 +++- src/debug.ts | 4 +-- src/index.ts | 29 +++++------------ test/util.ts | 12 +++---- yarn.lock | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index baa859c..6defc4e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "@electron/get": "^4.0.0", "@tsconfig/node22": "^22.0.1", "@types/cross-zip": "^4.0.1", + "@types/debug": "^4.1.10", "@types/node": "~22.10.7", + "@types/plist": "^3.0.4", "cross-zip": "^4.0.0", "husky": "^9.1.7", "lint-staged": "^16.1.0", @@ -50,7 +52,9 @@ "vitest": "^4.1.2" }, "dependencies": { - "@electron/asar": "^4.0.0" + "@electron/asar": "^4.0.0", + "debug": "^4.3.1", + "plist": "^3.1.0" }, "lint-staged": { "*.ts": [ diff --git a/src/debug.ts b/src/debug.ts index 0ece9f5..e5de091 100644 --- a/src/debug.ts +++ b/src/debug.ts @@ -1,3 +1,3 @@ -import { debuglog } from 'node:util'; +import debug from 'debug'; -export const d = debuglog('electron-universal'); +export const d = debug('electron-universal'); diff --git a/src/index.ts b/src/index.ts index 9e7e5ef..f0de62b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ -import { execFile as execFileCb, execFileSync } from 'node:child_process'; +import { execFile as execFileCb } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import * as asar from '@electron/asar'; +import plist from 'plist'; import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js'; import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js'; @@ -14,22 +15,6 @@ import { computeIntegrityData } from './integrity.js'; const execFile = promisify(execFileCb); -function parsePlist(filePath: string): Record { - return JSON.parse( - execFileSync('plutil', ['-convert', 'json', '-o', '-', filePath], { encoding: 'utf8' }), - ); -} - -function buildPlist(obj: Record): string { - const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'plist-')), 'data.json'); - try { - fs.writeFileSync(tmpFile, JSON.stringify(obj)); - return execFileSync('plutil', ['-convert', 'xml1', '-o', '-', tmpFile], { encoding: 'utf8' }); - } finally { - fs.rmSync(path.dirname(tmpFile), { recursive: true, force: true }); - } -} - type DiffEntry = { state: 'equal' | 'distinct' | 'left' | 'right'; name1?: string; @@ -449,9 +434,11 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = const x64PlistPath = path.resolve(opts.x64AppPath, plistFile.relativePath); const arm64PlistPath = path.resolve(opts.arm64AppPath, plistFile.relativePath); - const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = parsePlist(x64PlistPath) as any; - const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = parsePlist( - arm64PlistPath, + const { ElectronAsarIntegrity: x64Integrity, ...x64Plist } = plist.parse( + await fs.promises.readFile(x64PlistPath, 'utf8'), + ) as any; + const { ElectronAsarIntegrity: arm64Integrity, ...arm64Plist } = plist.parse( + await fs.promises.readFile(arm64PlistPath, 'utf8'), ) as any; if (JSON.stringify(x64Plist) !== JSON.stringify(arm64Plist)) { throw new Error( @@ -473,7 +460,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = await fs.promises.writeFile( path.resolve(tmpApp, plistFile.relativePath), - buildPlist(mergedPlist), + plist.build(mergedPlist), ); } diff --git a/test/util.ts b/test/util.ts index 96fabfb..bedb1fa 100644 --- a/test/util.ts +++ b/test/util.ts @@ -1,4 +1,4 @@ -import { execFile as execFileCb, execFileSync } from 'node:child_process'; +import { execFile as execFileCb } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { createPackageWithOptions, getRawHeader } from '@electron/asar'; import { downloadArtifact } from '@electron/get'; import * as zip from 'cross-zip'; +import plist from 'plist'; import type { ExpectStatic } from 'vitest'; const execFile = promisify(execFileCb); @@ -89,11 +90,10 @@ export const verifyApp = async ( expect(integrityMap).toMatchSnapshot(); }; -const extractAsarIntegrity = (infoPlist: string) => { - const parsed = JSON.parse( - execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlist], { encoding: 'utf8' }), - ); - const { ElectronAsarIntegrity: integrity, ...otherData } = parsed; +const extractAsarIntegrity = async (infoPlist: string) => { + const { ElectronAsarIntegrity: integrity, ...otherData } = plist.parse( + await fs.promises.readFile(infoPlist, 'utf-8'), + ) as any; return integrity; }; diff --git a/yarn.lock b/yarn.lock index a3704ac..b81ce40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -45,10 +45,14 @@ __metadata: "@electron/get": "npm:^4.0.0" "@tsconfig/node22": "npm:^22.0.1" "@types/cross-zip": "npm:^4.0.1" + "@types/debug": "npm:^4.1.10" "@types/node": "npm:~22.10.7" + "@types/plist": "npm:^3.0.4" cross-zip: "npm:^4.0.0" + debug: "npm:^4.3.1" husky: "npm:^9.1.7" lint-staged: "npm:^16.1.0" + plist: "npm:^3.1.0" prettier: "npm:^3.5.3" typedoc: "npm:~0.25.13" typescript: "npm:^5.8.3" @@ -280,6 +284,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.10": + version: 4.1.13 + resolution: "@types/debug@npm:4.1.13" + dependencies: + "@types/ms": "npm:*" + checksum: 10c0/e5e124021bbdb23a82727eee0a726ae0fc8a3ae1f57253cbcc47497f259afb357de7f6941375e773e1abbfa1604c1555b901a409d762ec2bb4c1612131d4afb7 + languageName: node + linkType: hard + "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -301,6 +314,22 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 2.1.0 + resolution: "@types/ms@npm:2.1.0" + checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 25.6.0 + resolution: "@types/node@npm:25.6.0" + dependencies: + undici-types: "npm:~7.19.0" + checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + languageName: node + linkType: hard + "@types/node@npm:~22.10.7": version: 22.10.10 resolution: "@types/node@npm:22.10.10" @@ -310,6 +339,16 @@ __metadata: languageName: node linkType: hard +"@types/plist@npm:^3.0.4": + version: 3.0.5 + resolution: "@types/plist@npm:3.0.5" + dependencies: + "@types/node": "npm:*" + xmlbuilder: "npm:>=11.0.1" + checksum: 10c0/2a929f4482e3bea8c3288a46ae589a2ae2d01df5b7841ead7032d7baa79d79af6c875a5798c90705eea9306c2fb1544d7ed12ab3c905c5626d5dd5dc9f464b94 + languageName: node + linkType: hard + "@vitest/expect@npm:4.1.2": version: 4.1.2 resolution: "@vitest/expect@npm:4.1.2" @@ -392,6 +431,13 @@ __metadata: languageName: node linkType: hard +"@xmldom/xmldom@npm:^0.8.8": + version: 0.8.12 + resolution: "@xmldom/xmldom@npm:0.8.12" + checksum: 10c0/b733c84292d1bee32ef21a05aba8f9063456b51a54068d0b4a1abf5545156ee0b9894b7ae23775b5881b11c35a8a03871d1b514fb7e1b11654cdbee57e1c2707 + languageName: node + linkType: hard + "ansi-escapes@npm:^7.0.0": version: 7.0.0 resolution: "ansi-escapes@npm:7.0.0" @@ -461,6 +507,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.5.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "boolean@npm:^3.0.1": version: 3.2.0 resolution: "boolean@npm:3.2.0" @@ -624,6 +677,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + "debug@npm:^4.4.1": version: 4.4.1 resolution: "debug@npm:4.4.1" @@ -1502,6 +1567,17 @@ __metadata: languageName: node linkType: hard +"plist@npm:^3.1.0": + version: 3.1.0 + resolution: "plist@npm:3.1.0" + dependencies: + "@xmldom/xmldom": "npm:^0.8.8" + base64-js: "npm:^1.5.1" + xmlbuilder: "npm:^15.1.1" + checksum: 10c0/db19ba50faafc4103df8e79bcd6b08004a56db2a9dd30b3e5c8b0ef30398ef44344a674e594d012c8fc39e539a2b72cb58c60a76b4b4401cbbc7c8f6b028d93d + languageName: node + linkType: hard + "postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" @@ -1938,6 +2014,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.19.0": + version: 7.19.2 + resolution: "undici-types@npm:7.19.2" + checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e + languageName: node + linkType: hard + "vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": version: 8.0.5 resolution: "vite@npm:8.0.5" @@ -2127,6 +2210,13 @@ __metadata: languageName: node linkType: hard +"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1": + version: 15.1.1 + resolution: "xmlbuilder@npm:15.1.1" + checksum: 10c0/665266a8916498ff8d82b3d46d3993913477a254b98149ff7cff060d9b7cc0db7cf5a3dae99aed92355254a808c0e2e3ec74ad1b04aa1061bdb8dfbea26c18b8 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" From fad8bbe07b9a35e9fbd897d26a0eb26160e8138f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 05:37:52 +0000 Subject: [PATCH 3/4] Add unit tests for compareDirectories and matchGlob Extract compareDirectories and matchGlob into file-utils.ts as shared exports, deduplicating the matchBase logic from 3 files. Add tests covering: identical/distinct/left-only/right-only files, nested dirs, symlinks, empty dirs, and matchBase vs full-path glob patterns. https://claude.ai/code/session_0112RFfDPLMXemxFs6qcKf27 --- src/asar-utils.ts | 7 +- src/file-utils.ts | 65 +++++++++++++++- src/index.ts | 77 +++--------------- test/compare-directories.spec.ts | 129 +++++++++++++++++++++++++++++++ test/match-glob.spec.ts | 43 +++++++++++ 5 files changed, 246 insertions(+), 75 deletions(-) create mode 100644 test/compare-directories.spec.ts create mode 100644 test/match-glob.spec.ts diff --git a/src/asar-utils.ts b/src/asar-utils.ts index 189ee07..bbdcc14 100644 --- a/src/asar-utils.ts +++ b/src/asar-utils.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import * as asar from '@electron/asar'; import { d } from './debug.js'; -import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC } from './file-utils.js'; +import { MACHO_MAGIC, MACHO_UNIVERSAL_MAGIC, matchGlob } from './file-utils.js'; const LIPO = 'lipo'; @@ -55,10 +55,7 @@ function isDirectory(a: string, file: string): boolean { } function checkSingleArch(archive: string, file: string, allowList?: string): void { - if ( - allowList === undefined || - !path.matchesGlob(allowList.includes('/') ? file : path.basename(file), allowList) - ) { + if (allowList === undefined || !matchGlob(file, allowList)) { throw new Error( `Detected unique file "${file}" in "${archive}" not covered by ` + `allowList rule: "${allowList}"`, diff --git a/src/file-utils.ts b/src/file-utils.ts index 0bbb35c..74adb6c 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -33,6 +33,66 @@ export const isMachO = (header: Buffer): boolean => { return false; }; +/** + * Glob match with matchBase semantics: if the pattern contains no `/`, + * only the basename of `filePath` is tested against the pattern. + */ +export const matchGlob = (filePath: string, pattern: string): boolean => { + return path.matchesGlob(pattern.includes('/') ? filePath : path.basename(filePath), pattern); +}; + +export type DiffEntry = { + state: 'equal' | 'distinct' | 'left' | 'right'; + name1?: string; + relativePath: string; +}; + +export async function compareDirectories(dir1: string, dir2: string): Promise { + async function getFiles(dir: string, rel = ''): Promise> { + const entries = new Map(); + for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) { + const relPath = rel ? path.join(rel, item.name) : item.name; + if (item.isDirectory()) { + for (const [k, v] of await getFiles(path.join(dir, item.name), relPath)) { + entries.set(k, v); + } + } else if (item.isFile() || item.isSymbolicLink()) { + entries.set(relPath, path.join(dir, item.name)); + } + } + return entries; + } + + const files1 = await getFiles(dir1); + const files2 = await getFiles(dir2); + const results: DiffEntry[] = []; + + for (const relFile of new Set([...files1.keys(), ...files2.keys()])) { + const name = path.basename(relFile); + const relDir = path.dirname(relFile); + const relativePath = relDir === '.' ? '' : relDir; + + if (!files1.has(relFile)) { + results.push({ state: 'right', relativePath }); + continue; + } + if (!files2.has(relFile)) { + results.push({ state: 'left', name1: name, relativePath }); + continue; + } + + const content1 = await fs.promises.readFile(files1.get(relFile)!); + const content2 = await fs.promises.readFile(files2.get(relFile)!); + results.push({ + state: content1.equals(content2) ? 'equal' : 'distinct', + name1: name, + relativePath, + }); + } + + return results; +} + const UNPACKED_ASAR_PATH = path.join('Contents', 'Resources', 'app.asar.unpacked'); export enum AppFileType { @@ -65,10 +125,7 @@ const isSingleArchFile = (relativePath: string, opts: GetAllAppFilesOpts): boole return false; } - return path.matchesGlob( - opts.singleArchFiles.includes('/') ? unpackedPath : path.basename(unpackedPath), - opts.singleArchFiles, - ); + return matchGlob(unpackedPath, opts.singleArchFiles); }; /** diff --git a/src/index.ts b/src/index.ts index f0de62b..b4c8d3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,65 +8,21 @@ import * as asar from '@electron/asar'; import plist from 'plist'; import { AsarMode, detectAsarMode, isUniversalMachO, mergeASARs } from './asar-utils.js'; -import { AppFile, AppFileType, fsMove, getAllAppFiles, readMachOHeader } from './file-utils.js'; +import { + AppFile, + AppFileType, + compareDirectories, + fsMove, + getAllAppFiles, + matchGlob, + readMachOHeader, +} from './file-utils.js'; import { sha } from './sha.js'; import { d } from './debug.js'; import { computeIntegrityData } from './integrity.js'; const execFile = promisify(execFileCb); -type DiffEntry = { - state: 'equal' | 'distinct' | 'left' | 'right'; - name1?: string; - relativePath: string; -}; - -async function compareDirectories(dir1: string, dir2: string): Promise { - async function getFiles(dir: string, rel = ''): Promise> { - const entries = new Map(); - for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) { - const relPath = rel ? path.join(rel, item.name) : item.name; - if (item.isDirectory()) { - for (const [k, v] of await getFiles(path.join(dir, item.name), relPath)) { - entries.set(k, v); - } - } else if (item.isFile() || item.isSymbolicLink()) { - entries.set(relPath, path.join(dir, item.name)); - } - } - return entries; - } - - const files1 = await getFiles(dir1); - const files2 = await getFiles(dir2); - const results: DiffEntry[] = []; - - for (const relFile of new Set([...files1.keys(), ...files2.keys()])) { - const name = path.basename(relFile); - const relDir = path.dirname(relFile); - const relativePath = relDir === '.' ? '' : relDir; - - if (!files1.has(relFile)) { - results.push({ state: 'right', relativePath }); - continue; - } - if (!files2.has(relFile)) { - results.push({ state: 'left', name1: name, relativePath }); - continue; - } - - const content1 = await fs.promises.readFile(files1.get(relFile)!); - const content2 = await fs.promises.readFile(files2.get(relFile)!); - results.push({ - state: content1.equals(content2) ? 'equal' : 'distinct', - name1: name, - relativePath, - }); - } - - return results; -} - /** * Options to pass into the {@link makeUniversalApp} function. * @@ -247,12 +203,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = if (x64Sha === arm64Sha) { if ( opts.x64ArchFiles === undefined || - !path.matchesGlob( - opts.x64ArchFiles.includes('/') - ? machOFile.relativePath - : path.basename(machOFile.relativePath), - opts.x64ArchFiles, - ) + !matchGlob(machOFile.relativePath, opts.x64ArchFiles) ) { throw new Error( `Detected file "${machOFile.relativePath}" that's the same in both x64 and arm64 builds and not covered by the ` + @@ -447,13 +398,7 @@ export const makeUniversalApp = async (opts: MakeUniversalOpts): Promise = } const injectAsarIntegrity = - !opts.infoPlistsToIgnore || - path.matchesGlob( - opts.infoPlistsToIgnore.includes('/') - ? plistFile.relativePath - : path.basename(plistFile.relativePath), - opts.infoPlistsToIgnore, - ); + !opts.infoPlistsToIgnore || matchGlob(plistFile.relativePath, opts.infoPlistsToIgnore); const mergedPlist = injectAsarIntegrity ? { ...x64Plist, ElectronAsarIntegrity: generatedIntegrity } : { ...x64Plist }; diff --git a/test/compare-directories.spec.ts b/test/compare-directories.spec.ts new file mode 100644 index 0000000..45bb753 --- /dev/null +++ b/test/compare-directories.spec.ts @@ -0,0 +1,129 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { compareDirectories, DiffEntry } from '../src/file-utils.js'; + +const sortDiff = (entries: DiffEntry[]) => + [...entries].sort((a, b) => { + const pathA = a.relativePath + (a.name1 ?? ''); + const pathB = b.relativePath + (b.name1 ?? ''); + return pathA.localeCompare(pathB); + }); + +describe('compareDirectories', () => { + let tmpDir: string; + let dir1: string; + let dir2: string; + + const setup = async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'compare-test-')); + dir1 = path.join(tmpDir, 'dir1'); + dir2 = path.join(tmpDir, 'dir2'); + await fs.promises.mkdir(dir1, { recursive: true }); + await fs.promises.mkdir(dir2, { recursive: true }); + }; + + afterEach(async () => { + if (tmpDir) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should report identical directories as all equal', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir1, 'a.txt'), 'hello'); + await fs.promises.writeFile(path.join(dir2, 'a.txt'), 'hello'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([{ state: 'equal', name1: 'a.txt', relativePath: '' }]); + }); + + it('should detect files with different content as distinct', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir1, 'a.txt'), 'hello'); + await fs.promises.writeFile(path.join(dir2, 'a.txt'), 'world'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([{ state: 'distinct', name1: 'a.txt', relativePath: '' }]); + }); + + it('should detect left-only files', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir1, 'only-left.txt'), 'data'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([{ state: 'left', name1: 'only-left.txt', relativePath: '' }]); + }); + + it('should detect right-only files (name1 is undefined)', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir2, 'only-right.txt'), 'data'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([{ state: 'right', relativePath: '' }]); + }); + + it('should handle nested directories', async () => { + await setup(); + await fs.promises.mkdir(path.join(dir1, 'sub'), { recursive: true }); + await fs.promises.mkdir(path.join(dir2, 'sub'), { recursive: true }); + await fs.promises.writeFile(path.join(dir1, 'sub', 'nested.txt'), 'same'); + await fs.promises.writeFile(path.join(dir2, 'sub', 'nested.txt'), 'same'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([{ state: 'equal', name1: 'nested.txt', relativePath: 'sub' }]); + }); + + it('should handle deeply nested files with correct relativePath', async () => { + await setup(); + await fs.promises.mkdir(path.join(dir1, 'a', 'b'), { recursive: true }); + await fs.promises.mkdir(path.join(dir2, 'a', 'b'), { recursive: true }); + await fs.promises.writeFile(path.join(dir1, 'a', 'b', 'deep.txt'), 'x'); + await fs.promises.writeFile(path.join(dir2, 'a', 'b', 'deep.txt'), 'y'); + + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([ + { state: 'distinct', name1: 'deep.txt', relativePath: path.join('a', 'b') }, + ]); + }); + + it('should handle empty directories', async () => { + await setup(); + const results = await compareDirectories(dir1, dir2); + expect(results).toEqual([]); + }); + + it('should handle mixed states across multiple files', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir1, 'same.txt'), 'same'); + await fs.promises.writeFile(path.join(dir2, 'same.txt'), 'same'); + await fs.promises.writeFile(path.join(dir1, 'diff.txt'), 'v1'); + await fs.promises.writeFile(path.join(dir2, 'diff.txt'), 'v2'); + await fs.promises.writeFile(path.join(dir1, 'left-only.txt'), 'left'); + await fs.promises.writeFile(path.join(dir2, 'right-only.txt'), 'right'); + + const results = sortDiff(await compareDirectories(dir1, dir2)); + expect(results).toEqual( + sortDiff([ + { state: 'equal', name1: 'same.txt', relativePath: '' }, + { state: 'distinct', name1: 'diff.txt', relativePath: '' }, + { state: 'left', name1: 'left-only.txt', relativePath: '' }, + { state: 'right', relativePath: '' }, + ]), + ); + }); + + it('should follow symlinks and compare target content', async () => { + await setup(); + await fs.promises.writeFile(path.join(dir1, 'real.txt'), 'content'); + await fs.promises.symlink(path.join(dir1, 'real.txt'), path.join(dir1, 'link.txt')); + await fs.promises.writeFile(path.join(dir2, 'link.txt'), 'content'); + + const results = sortDiff(await compareDirectories(dir1, dir2)); + const linkEntry = results.find((r) => r.name1 === 'link.txt'); + expect(linkEntry?.state).toBe('equal'); + }); +}); diff --git a/test/match-glob.spec.ts b/test/match-glob.spec.ts new file mode 100644 index 0000000..f7b201d --- /dev/null +++ b/test/match-glob.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { matchGlob } from '../src/file-utils.js'; + +describe('matchGlob', () => { + describe('matchBase behavior (pattern without /)', () => { + it('should match against basename when pattern has no slash', () => { + expect(matchGlob('some/deep/path/hello-world', 'hello-world')).toBe(true); + }); + + it('should not match when basename differs', () => { + expect(matchGlob('some/deep/path/other-file', 'hello-world')).toBe(false); + }); + + it('should support wildcard patterns against basename', () => { + expect(matchGlob('path/to/hello-world-arm64', 'hello-world-*')).toBe(true); + expect(matchGlob('path/to/goodbye-world', 'hello-world-*')).toBe(false); + }); + + it('should support glob character classes', () => { + expect(matchGlob('path/to/file.txt', '*.txt')).toBe(true); + expect(matchGlob('path/to/file.bin', '*.txt')).toBe(false); + }); + }); + + describe('full path matching (pattern with /)', () => { + it('should match against full relative path when pattern contains /', () => { + expect(matchGlob('SubApp.app/Contents/Info.plist', 'SubApp.app/Contents/Info.plist')).toBe( + true, + ); + }); + + it('should not match when only basename matches but full path differs', () => { + expect(matchGlob('Other.app/Contents/Info.plist', 'SubApp.app/Contents/Info.plist')).toBe( + false, + ); + }); + + it('should support wildcards in path patterns', () => { + expect(matchGlob('SubApp.app/Contents/Info.plist', '*/Contents/Info.plist')).toBe(true); + }); + }); +}); From 132926e32c759a6d42ae20592c0cef9bd9ae3ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 05:42:22 +0000 Subject: [PATCH 4/4] Fix compareDirectories failing on symlinked directories When readdir returns a symlink entry, isDirectory() returns false even if the symlink target is a directory. Stat the resolved path to determine the actual type, so symlinked directories are traversed instead of read as files (EISDIR). https://claude.ai/code/session_0112RFfDPLMXemxFs6qcKf27 --- src/file-utils.ts | 11 ++++++++--- test/compare-directories.spec.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/file-utils.ts b/src/file-utils.ts index 74adb6c..40b004d 100644 --- a/src/file-utils.ts +++ b/src/file-utils.ts @@ -51,13 +51,18 @@ export async function compareDirectories(dir1: string, dir2: string): Promise> { const entries = new Map(); for (const item of await fs.promises.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, item.name); const relPath = rel ? path.join(rel, item.name) : item.name; - if (item.isDirectory()) { - for (const [k, v] of await getFiles(path.join(dir, item.name), relPath)) { + // For symlinks, stat the target to determine if it's a file or directory + const isDir = + item.isDirectory() || + (item.isSymbolicLink() && (await fs.promises.stat(fullPath)).isDirectory()); + if (isDir) { + for (const [k, v] of await getFiles(fullPath, relPath)) { entries.set(k, v); } } else if (item.isFile() || item.isSymbolicLink()) { - entries.set(relPath, path.join(dir, item.name)); + entries.set(relPath, fullPath); } } return entries; diff --git a/test/compare-directories.spec.ts b/test/compare-directories.spec.ts index 45bb753..2abf2b1 100644 --- a/test/compare-directories.spec.ts +++ b/test/compare-directories.spec.ts @@ -126,4 +126,22 @@ describe('compareDirectories', () => { const linkEntry = results.find((r) => r.name1 === 'link.txt'); expect(linkEntry?.state).toBe('equal'); }); + + it('should traverse symlinked directories', async () => { + await setup(); + // dir1: realdir/file.txt + linkdir -> realdir + await fs.promises.mkdir(path.join(dir1, 'realdir')); + await fs.promises.writeFile(path.join(dir1, 'realdir', 'file.txt'), 'data'); + await fs.promises.symlink(path.join(dir1, 'realdir'), path.join(dir1, 'linkdir')); + // dir2: same structure + await fs.promises.mkdir(path.join(dir2, 'realdir')); + await fs.promises.writeFile(path.join(dir2, 'realdir', 'file.txt'), 'data'); + await fs.promises.symlink(path.join(dir2, 'realdir'), path.join(dir2, 'linkdir')); + + const results = await compareDirectories(dir1, dir2); + const linkdirEntry = results.find((r) => r.relativePath === 'linkdir'); + expect(linkdirEntry).toBeDefined(); + expect(linkdirEntry!.state).toBe('equal'); + expect(linkdirEntry!.name1).toBe('file.txt'); + }); });