diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 1a50d094..7c68c32f 100644 --- a/src/applyPatches.ts +++ b/src/applyPatches.ts @@ -8,10 +8,12 @@ import { logPatchSequenceError } from "./makePatch" import { PackageDetails, PatchedPackageDetails } from "./PackageDetails" import { packageIsDevDependency } from "./packageIsDevDependency" import { executeEffects } from "./patch/apply" +import { PatchFilePart } from "./patch/parse" import { readPatch } from "./patch/read" import { reversePatch } from "./patch/reverse" import { getGroupedPatches } from "./patchFs" import { join, relative } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" import { clearPatchApplicationState, getPatchApplicationState, @@ -28,17 +30,19 @@ class PatchApplicationError extends Error { function getInstalledPackageVersion({ appPath, path, + resolvedPath, pathSpecifier, isDevOnly, patchFilename, }: { appPath: string path: string + resolvedPath: string pathSpecifier: string isDevOnly: boolean patchFilename: string }): null | string { - const packageDir = join(appPath, path) + const packageDir = join(appPath, resolvedPath) if (!existsSync(packageDir)) { if (process.env.NODE_ENV === "production" && isDevOnly) { return null @@ -47,7 +51,7 @@ function getInstalledPackageVersion({ let err = `${chalk.red("Error:")} Patch file found for package ${posix.basename( pathSpecifier, - )}` + ` which is not present at ${relative(".", packageDir)}` + )}` + ` which is not present at ${relative(".", join(appPath, path))}` if (!isDevOnly && process.env.NODE_ENV === "production") { err += ` @@ -180,7 +184,16 @@ export function applyPatchesForPackage({ bestEffort: boolean }) { const pathSpecifier = patches[0].pathSpecifier - const state = patches.length > 1 ? getPatchApplicationState(patches[0]) : null + const resolvedPackagePath = resolvePackagePath({ + appPath, + packageDetails: patches[0], + version: patches[0].version, + }) + + const state = + patches.length > 1 + ? getPatchApplicationState(patches[0], resolvedPackagePath) + : null const unappliedPatches = patches.slice(0) const appliedPatches: PatchedPackageDetails[] = [] // if there are multiple patches to apply, we can't rely on the reverse-patch-dry-run behavior to make this operation @@ -235,6 +248,7 @@ export function applyPatchesForPackage({ const installedPackageVersion = getInstalledPackageVersion({ appPath, path, + resolvedPath: resolvedPackagePath, pathSpecifier, isDevOnly: isDevOnly || @@ -264,6 +278,7 @@ export function applyPatchesForPackage({ patchDir, cwd: process.cwd(), bestEffort, + resolvedPath: resolvedPackagePath, }) ) { appliedPatches.push(patchDetails) @@ -282,7 +297,10 @@ export function applyPatchesForPackage({ } logPatchApplication(patchDetails) } else if (patches.length > 1) { - logPatchSequenceError({ patchDetails }) + logPatchSequenceError({ + patchDetails, + resolvedPath: resolvedPackagePath, + }) // in case the package has multiple patches, we need to break out of this inner loop // because we don't want to apply more patches on top of the broken state failedPatch = patchDetails @@ -338,7 +356,7 @@ export function applyPatchesForPackage({ } // if we removed all the patches that were previously applied we can delete the state file if (appliedPatches.length === patches.length) { - clearPatchApplicationState(patches[0]) + clearPatchApplicationState(patches[0], resolvedPackagePath) } else { // We failed while reversing patches and some are still in the applied state. // We need to update the state file to reflect that. @@ -364,6 +382,7 @@ export function applyPatchesForPackage({ patchFilename: patch.patchFilename, })), isRebasing: false, + resolvedPath: resolvedPackagePath, }) } } else { @@ -390,6 +409,7 @@ export function applyPatchesForPackage({ packageDetails: patches[0], patches: nextState, isRebasing: !!failedPatch, + resolvedPath: resolvedPackagePath, }) } if (failedPatch) { @@ -405,6 +425,7 @@ export function applyPatch({ patchDir, cwd, bestEffort, + resolvedPath, }: { patchFilePath: string reverse: boolean @@ -412,6 +433,7 @@ export function applyPatch({ patchDir: string cwd: string bestEffort: boolean + resolvedPath?: string }): boolean { const patch = readPatch({ patchFilePath, @@ -419,6 +441,11 @@ export function applyPatch({ patchDir, }) + // Remap paths if package resolved to a different location (e.g. .store) + if (resolvedPath && resolvedPath !== patchDetails.path) { + remapPatchPaths(patch, patchDetails.path, resolvedPath) + } + const forward = reverse ? reversePatch(patch) : patch try { if (!bestEffort) { @@ -450,6 +477,36 @@ export function applyPatch({ return true } +/** + * Remaps paths in parsed patch effects when the package resolved to a + * different location than expected (e.g. npm install-strategy=linked .store). + */ +function remapPatchPaths( + patch: PatchFilePart[], + originalPrefix: string, + newPrefix: string, +): void { + const remap = (path: string) => + path.startsWith(`${originalPrefix}/`) + ? newPrefix + path.slice(originalPrefix.length) + : path + + for (const part of patch) { + switch (part.type) { + case "patch": + case "file deletion": + case "file creation": + case "mode change": + part.path = remap(part.path) + break + case "rename": + part.fromPath = remap(part.fromPath) + part.toPath = remap(part.toPath) + break + } + } +} + function createVersionMismatchWarning({ packageName, actualVersion, diff --git a/src/createIssue.ts b/src/createIssue.ts index 1522e563..d8163169 100644 --- a/src/createIssue.ts +++ b/src/createIssue.ts @@ -36,9 +36,13 @@ function parseRepoString(repository: string): VCS { return { org, repo, provider: "GitHub" } } -export function getPackageVCSDetails(packageDetails: PackageDetails): VCS { - const repository = require(resolve(join(packageDetails.path, "package.json"))) - .repository as undefined | string | { url: string } +export function getPackageVCSDetails( + packageDetails: PackageDetails, + resolvedPath?: string, +): VCS { + const repository = require(resolve( + join(resolvedPath || packageDetails.path, "package.json"), + )).repository as undefined | string | { url: string } if (!repository) { return null @@ -121,13 +125,15 @@ export function openIssueCreationLink({ patchFileContents, packageVersion, patchPath, + resolvedPath, }: { packageDetails: PackageDetails patchFileContents: string packageVersion: string patchPath: string + resolvedPath?: string }) { - const vcs = getPackageVCSDetails(packageDetails) + const vcs = getPackageVCSDetails(packageDetails, resolvedPath) if (!vcs) { console.log( diff --git a/src/makePatch.ts b/src/makePatch.ts index 7e008eb3..c41ff4fa 100644 --- a/src/makePatch.ts +++ b/src/makePatch.ts @@ -32,6 +32,7 @@ import { import { parsePatchFile } from "./patch/parse" import { getGroupedPatches } from "./patchFs" import { dirname, join, resolve } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" import { resolveRelativeFileDependencies } from "./resolveRelativeFileDependencies" import { spawnSafeSync } from "./spawnSafe" import { @@ -80,7 +81,19 @@ export function makePatch({ return } - const state = getPatchApplicationState(packageDetails) + const resolvedPackagePath = resolvePackagePath({ appPath, packageDetails }) + const packagePath = join(appPath, resolvedPackagePath) + const packageJsonPath = join(packagePath, "package.json") + + if (!existsSync(packageJsonPath)) { + printNoPackageFoundError( + packagePathSpecifier, + join(appPath, packageDetails.path, "package.json"), + ) + process.exit(1) + } + + const state = getPatchApplicationState(packageDetails, resolvedPackagePath) const isRebasing = state?.isRebasing ?? false // If we are rebasing and no patches have been applied, --append is the only valid option because @@ -137,7 +150,7 @@ export function makePatch({ mode.type === "append" || existingPatches.length === 0 ? existingPatches.length + 1 : existingPatches.length - const vcs = getPackageVCSDetails(packageDetails) + const vcs = getPackageVCSDetails(packageDetails, resolvedPackagePath) const canCreateIssue = !isRebasing && shouldRecommendIssue(vcs) && @@ -145,13 +158,6 @@ export function makePatch({ mode.type !== "append" const appPackageJson = require(join(appPath, "package.json")) - const packagePath = join(appPath, packageDetails.path) - const packageJsonPath = join(packagePath, "package.json") - - if (!existsSync(packageJsonPath)) { - printNoPackageFoundError(packagePathSpecifier, packageJsonPath) - process.exit(1) - } const tmpRepo = dirSync({ unsafeCleanup: true }) const tmpRepoPackagePath = join(tmpRepo.name, packageDetails.path) @@ -187,7 +193,7 @@ export function makePatch({ ) const packageVersion = getPackageVersion( - join(resolve(packageDetails.path), "package.json"), + join(resolve(resolvedPackagePath), "package.json"), ) // copy .npmrc/.yarnrc in case packages are hosted in private registry @@ -228,11 +234,14 @@ export function makePatch({ chalk.grey("•"), `Installing ${packageDetails.name}@${packageVersion} with npm`, ) + // a copied .npmrc with install-strategy=linked would make the package a symlink git can't diff + const npmEnv = { ...process.env, npm_config_install_strategy: "hoisted" } try { // try first without ignoring scripts in case they are required // this works in 99.99% of cases spawnSafeSync(`npm`, ["i", "--force"], { cwd: tmpRepoNpmRoot, + env: npmEnv, logStdErrOnError: false, stdio: "ignore", }) @@ -241,6 +250,7 @@ export function makePatch({ // an implicit context which we haven't reproduced spawnSafeSync(`npm`, ["i", "--ignore-scripts", "--force"], { cwd: tmpRepoNpmRoot, + env: npmEnv, stdio: "ignore", }) } @@ -500,10 +510,14 @@ export function makePatch({ reverse: false, cwd: process.cwd(), bestEffort: false, + resolvedPath: resolvedPackagePath, }) ) { didFailWhileFinishingRebase = true - logPatchSequenceError({ patchDetails: patch }) + logPatchSequenceError({ + patchDetails: patch, + resolvedPath: resolvedPackagePath, + }) nextState.push({ patchFilename: patch.patchFilename, didApply: false, @@ -527,9 +541,10 @@ export function makePatch({ packageDetails, patches: nextState, isRebasing: didFailWhileFinishingRebase, + resolvedPath: resolvedPackagePath, }) } else { - clearPatchApplicationState(packageDetails) + clearPatchApplicationState(packageDetails, resolvedPackagePath) } if (canCreateIssue) { @@ -539,6 +554,7 @@ export function makePatch({ patchFileContents: diffResult.stdout.toString(), packageVersion, patchPath, + resolvedPath: resolvedPackagePath, }) } else { maybePrintIssueCreationPrompt(vcs, packageDetails, packageManager) @@ -579,8 +595,10 @@ function createPatchFileName({ export function logPatchSequenceError({ patchDetails, + resolvedPath = patchDetails.path, }: { patchDetails: PatchedPackageDetails + resolvedPath?: string }) { console.log(` ${chalk.red.bold("⛔ ERROR")} @@ -595,9 +613,7 @@ To partially apply the patch (if possible) and output a log of errors to fix, ru ${chalk.bold(`patch-package --partial`)} -After which you should make any required changes inside ${ - patchDetails.path - }, and finally run +After which you should make any required changes inside ${resolvedPath}, and finally run ${chalk.bold(`patch-package ${patchDetails.pathSpecifier}`)} diff --git a/src/rebase.ts b/src/rebase.ts index d8f0ba9c..4d45e489 100644 --- a/src/rebase.ts +++ b/src/rebase.ts @@ -4,6 +4,7 @@ import { applyPatch } from "./applyPatches" import { hashFile } from "./hash" import { PatchedPackageDetails } from "./PackageDetails" import { getGroupedPatches } from "./patchFs" +import { resolvePackagePath } from "./resolvePackagePath" import { getPatchApplicationState, savePatchApplicationState, @@ -39,7 +40,12 @@ export function rebase({ process.exit(1) } - const state = getPatchApplicationState(packagePatches[0]) + const resolvedPackagePath = resolvePackagePath({ + appPath, + packageDetails: packagePatches[0], + version: packagePatches[0].version, + }) + const state = getPatchApplicationState(packagePatches[0], resolvedPackagePath) if (!state) { console.log( @@ -54,7 +60,7 @@ export function rebase({ console.log( chalk.blueBright("Already rebasing"), "Make changes to the files in", - chalk.bold(packagePatches[0].path), + chalk.bold(resolvedPackagePath), "and then run `patch-package", packagePathSpecifier, "--continue` to", @@ -84,14 +90,16 @@ export function rebase({ patches: packagePatches, appPath, patchDir, + resolvedPath: resolvedPackagePath, }) savePatchApplicationState({ packageDetails: packagePatches[0], isRebasing: true, + resolvedPath: resolvedPackagePath, patches: [], }) console.log(` -Make any changes you need inside ${chalk.bold(packagePatches[0].path)} +Make any changes you need inside ${chalk.bold(resolvedPackagePath)} When you are done, run @@ -161,10 +169,12 @@ to insert a new patch file. patches: packagePatches.slice(targetIdx + 1), appPath, patchDir, + resolvedPath: resolvedPackagePath, }) savePatchApplicationState({ packageDetails: packagePatches[0], isRebasing: true, + resolvedPath: resolvedPackagePath, patches: packagePatches.slice(0, targetIdx + 1).map((p) => ({ patchFilename: p.patchFilename, patchContentHash: hashFile(join(patchesDirectory, p.patchFilename)), @@ -173,7 +183,7 @@ to insert a new patch file. }) console.log(` -Make any changes you need inside ${chalk.bold(packagePatches[0].path)} +Make any changes you need inside ${chalk.bold(resolvedPackagePath)} When you are done, do one of the following: @@ -196,10 +206,12 @@ function unApplyPatches({ patches, appPath, patchDir, + resolvedPath, }: { patches: PatchedPackageDetails[] appPath: string patchDir: string + resolvedPath: string }) { for (const patch of patches.slice().reverse()) { if ( @@ -210,6 +222,7 @@ function unApplyPatches({ patchDir, cwd: process.cwd(), bestEffort: false, + resolvedPath, }) ) { console.log( diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts new file mode 100644 index 00000000..6bff7d64 --- /dev/null +++ b/src/resolvePackagePath.test.ts @@ -0,0 +1,285 @@ +import { mkdirpSync, realpathSync, symlinkSync, writeFileSync } from "fs-extra" +import { dirname, relative } from "path" +import { dirSync } from "tmp" +import { getPatchDetailsFromCliString } from "./PackageDetails" +import { join } from "./path" +import { resolvePackagePath } from "./resolvePackagePath" + +const HASH = "hXErYF6ZrdpweGIFH-nM4Q" +const OTHER_HASH = "4cO0ZloNu8RvnBPVrlAb_Q" + +describe("resolvePackagePath", () => { + let rootPath: string + let appPath: string + let cleanup: () => void + + beforeEach(() => { + const tmp = dirSync({ unsafeCleanup: true }) + rootPath = realpathSync(tmp.name) + appPath = rootPath + cleanup = tmp.removeCallback + }) + + afterEach(() => { + cleanup() + }) + + function addStoreEntry(name: string, version: string, hash = HASH) { + const storePath = join( + "node_modules/.store", + `${name}@${version}-${hash}`, + "node_modules", + name, + ) + mkdirpSync(join(appPath, storePath)) + writeFileSync( + join(appPath, storePath, "package.json"), + JSON.stringify({ name, version }), + ) + return storePath + } + + function link(from: string, to: string) { + const fromPath = join(appPath, from) + mkdirpSync(dirname(fromPath)) + symlinkSync(relative(dirname(fromPath), join(appPath, to)), fromPath) + } + + function writeLockfile(packages: Record) { + writeFileSync( + join(appPath, "package-lock.json"), + JSON.stringify({ lockfileVersion: 3, packages }), + ) + } + + function resolve(pathSpecifier: string, version?: string) { + return resolvePackagePath({ + appPath, + packageDetails: getPatchDetailsFromCliString(pathSpecifier)!, + version, + }) + } + + it("returns the original path when the package exists", () => { + mkdirpSync(join(appPath, "node_modules/some-package")) + addStoreEntry("some-package", "1.0.0") + + expect(resolve("some-package")).toBe("node_modules/some-package") + }) + + it("returns the original path when there is no .store directory", () => { + expect(resolve("some-package")).toBe("node_modules/some-package") + }) + + it("returns the original path when the package is not in .store", () => { + addStoreEntry("other", "1.0.0") + + expect(resolve("some-package")).toBe("node_modules/some-package") + }) + + it("resolves a transitive package from .store", () => { + const storePath = addStoreEntry("ms", "2.1.2") + + expect(resolve("ms")).toBe(storePath) + }) + + it("does not match packages sharing a name prefix", () => { + addStoreEntry("ms-extra", "1.0.0") + + expect(resolve("ms")).toBe("node_modules/ms") + }) + + it("resolves a scoped transitive package from .store", () => { + const storePath = addStoreEntry("@babel/highlight", "7.25.9") + + expect(resolve("@babel/highlight")).toBe(storePath) + }) + + it("resolves prerelease versions from the store entry name", () => { + const storePath = addStoreEntry("ms", "3.0.0-canary.1") + + expect(resolve("ms", "3.0.0-canary.1")).toBe(storePath) + }) + + it("picks the lockfile version when .store has several versions", () => { + addStoreEntry("ms", "2.0.0", OTHER_HASH) + const storePath = addStoreEntry("ms", "2.1.2") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms")).toBe(storePath) + expect(resolve("ms", "2.0.0")).toBe(storePath) + }) + + it("falls back to the given version without a lockfile entry", () => { + addStoreEntry("ms", "2.0.0", OTHER_HASH) + const storePath = addStoreEntry("ms", "2.1.2") + + expect(resolve("ms", "2.1.2")).toBe(storePath) + }) + + it("does not guess between several versions", () => { + addStoreEntry("ms", "2.0.0", OTHER_HASH) + addStoreEntry("ms", "2.1.2") + + expect(resolve("ms")).toBe("node_modules/ms") + expect(resolve("ms", "3.0.0")).toBe("node_modules/ms") + }) + + it("does not substitute another version for the lockfile version", () => { + addStoreEntry("ms", "2.0.0") + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms", "2.0.0")).toBe("node_modules/ms") + }) + + it("does not guess between same-version entries", () => { + addStoreEntry("ms", "2.1.2") + addStoreEntry("ms", "2.1.2", OTHER_HASH) + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms")).toBe("node_modules/ms") + }) + + it("keeps resolving after a patch changes the package.json version", () => { + const storePath = addStoreEntry("ms", "2.1.2") + addStoreEntry("ms", "2.0.0", OTHER_HASH) + writeFileSync( + join(appPath, storePath, "package.json"), + JSON.stringify({ version: "2.1.2-patched" }), + ) + writeLockfile({ "node_modules/ms": { version: "2.1.2" } }) + + expect(resolve("ms")).toBe(storePath) + }) + + it("resolves a nested package through its parent's store entry", () => { + const parentPath = addStoreEntry("finalhandler", "1.2.0") + addStoreEntry("ms", "2.1.2") + const nestedPath = addStoreEntry("ms", "2.0.0", OTHER_HASH) + link("node_modules/finalhandler", parentPath) + link( + `node_modules/.store/finalhandler@1.2.0-${HASH}/node_modules/ms`, + nestedPath, + ) + + expect(resolve("finalhandler/ms")).toBe(nestedPath) + }) + + it("resolves a nested package whose lockfile parent is not its dependent", () => { + const parentPath = addStoreEntry("finalhandler", "1.2.0") + const directPath = addStoreEntry("ms", "2.1.2") + const nestedPath = addStoreEntry("ms", "2.0.0", OTHER_HASH) + link("node_modules/finalhandler", parentPath) + link("node_modules/ms", directPath) + writeLockfile({ + "node_modules/ms": { version: "2.1.2" }, + "node_modules/finalhandler/node_modules/ms": { version: "2.0.0" }, + }) + + expect(resolve("finalhandler/ms")).toBe(nestedPath) + }) + + it("resolves a scoped nested package through a scoped parent", () => { + const parentPath = addStoreEntry("@babel/code-frame", "7.24.7") + const nestedPath = addStoreEntry("@babel/highlight", "7.25.9") + link("node_modules/@babel/code-frame", parentPath) + link( + `node_modules/.store/@babel/code-frame@7.24.7-${HASH}/node_modules/@babel/highlight`, + nestedPath, + ) + + expect(resolve("@babel/code-frame/@babel/highlight")).toBe(nestedPath) + }) + + it("resolves a nested package in a workspace linked to the root .store", () => { + const parentPath = addStoreEntry("parent", "1.0.0") + const childPath = addStoreEntry("child", "1.0.0") + link( + `node_modules/.store/parent@1.0.0-${HASH}/node_modules/child`, + childPath, + ) + link("packages/a/node_modules/parent", parentPath) + appPath = join(rootPath, "packages/a") + + expect(resolve("parent/child")).toBe(join("../..", childPath)) + }) + + it("resolves an aliased transitive package by its lockfile name", () => { + addStoreEntry("string-width", "5.1.2", OTHER_HASH) + const storePath = addStoreEntry("string-width", "4.2.3") + writeFileSync( + join(appPath, "package-lock.json"), + JSON.stringify({ + packages: { + "node_modules/string-width-cjs": { + name: "string-width", + version: "4.2.3", + }, + }, + }), + ) + + expect(resolve("string-width-cjs")).toBe(storePath) + }) + + it("resolves an aliased nested package through its parent's store entry", () => { + const parentPath = addStoreEntry("@isaacs/cliui", "8.0.2") + const aliasedPath = addStoreEntry("string-width", "4.2.3") + link("node_modules/@isaacs/cliui", parentPath) + link( + `node_modules/.store/@isaacs/cliui@8.0.2-${HASH}/node_modules/string-width-cjs`, + aliasedPath, + ) + + expect(resolve("@isaacs/cliui/string-width-cjs")).toBe(aliasedPath) + }) + + it("does not resolve a nested path the lockfile hoists elsewhere", () => { + const parentPath = addStoreEntry("esbuild", "0.20.0") + const childPath = addStoreEntry("@esbuild/darwin-arm64", "0.20.0") + link("node_modules/esbuild", parentPath) + link( + `node_modules/.store/esbuild@0.20.0-${HASH}/node_modules/@esbuild/darwin-arm64`, + childPath, + ) + writeLockfile({ + "node_modules/esbuild": { version: "0.20.0" }, + "node_modules/@esbuild/darwin-arm64": { version: "0.20.0" }, + }) + + expect(resolve("esbuild/@esbuild/darwin-arm64")).toBe( + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64", + ) + expect(resolve("@esbuild/darwin-arm64")).toBe(childPath) + }) + + it("returns the original path when a nested package is missing", () => { + const parentPath = addStoreEntry("parent", "1.0.0") + link("node_modules/parent", parentPath) + + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", + ) + }) + + it("leaves regular installs untouched", () => { + mkdirpSync(join(appPath, "node_modules/parent")) + mkdirpSync(join(appPath, "node_modules/child")) + addStoreEntry("other", "1.0.0") + + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", + ) + }) + + it("does not resolve nested packages outside appPath", () => { + appPath = join(rootPath, "app") + const parentPath = addStoreEntry("parent", "1.0.0") + link("node_modules/parent", parentPath) + mkdirpSync(join(rootPath, "node_modules/child")) + + expect(resolve("parent/child")).toBe( + "node_modules/parent/node_modules/child", + ) + }) +}) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts new file mode 100644 index 00000000..02590011 --- /dev/null +++ b/src/resolvePackagePath.ts @@ -0,0 +1,218 @@ +import { existsSync, readdirSync, readFileSync, realpathSync } from "fs-extra" +import { basename, dirname } from "path" +import { PackageDetails } from "./PackageDetails" +import { join, relative } from "./path" + +/** + * npm install-strategy=linked keeps transitive and nested packages only in + * node_modules/.store. Returns packageDetails.path when it exists or can't be resolved. + */ +export function resolvePackagePath({ + appPath, + packageDetails, + version, +}: { + appPath: string + packageDetails: PackageDetails + version?: string +}): string { + const { path, packageNames, name } = packageDetails + + if (existsSync(join(appPath, path))) { + return path + } + + const appRealPath = realpathSync(appPath) + const storeDir = join(appPath, "node_modules", ".store") + const lockfilePackages = readLockfilePackages(appPath) + // makePatch reads the lockfile entry for this path, so it must exist + if (Object.keys(lockfilePackages).length && !lockfilePackages[path]) { + return path + } + const lockfileVersion = lockfilePackages[path]?.version + // npm aliases record the real package name, which is what .store uses + const storeName = lockfilePackages[path]?.name || name + + const packageDir = + resolveThroughParents({ + appPath, + appRealPath, + storeDir, + packageNames, + lockfilePackages, + lockfileVersion, + }) || + findInStore({ + storeDir, + name: storeName, + lockfileVersion, + fallbackVersion: version, + }) + + return packageDir ? relative(appRealPath, packageDir) : path +} + +/** + * The lockfile nests packages as a hoisted install would, which may not match + * the real dependent, so the result only counts if its version agrees. + */ +function resolveThroughParents({ + appPath, + appRealPath, + storeDir, + packageNames, + lockfilePackages, + lockfileVersion, +}: { + appPath: string + appRealPath: string + storeDir: string + packageNames: string[] + lockfilePackages: LockfilePackages + lockfileVersion?: string +}): string | null { + const [rootName, ...nestedNames] = packageNames + if (!nestedNames.length) { + return null + } + + const rootPath = join("node_modules", rootName) + let packageDir = existsSync(join(appPath, rootPath)) + ? realpathSync(join(appPath, rootPath)) + : findInStore({ + storeDir, + name: lockfilePackages[rootPath]?.name || rootName, + lockfileVersion: lockfilePackages[rootPath]?.version, + }) + + for (const name of nestedNames) { + if (!packageDir) { + return null + } + packageDir = resolveDependency({ fromDir: packageDir, name, appRealPath }) + } + + if (!packageDir) { + return null + } + + // anything outside .store is a regular install, which is left untouched + const storeEntry = parseStoreEntry(packageDir) + if ( + !storeEntry || + (lockfileVersion && storeEntry.version !== lockfileVersion) + ) { + return null + } + return packageDir +} + +function findInStore({ + storeDir, + name, + lockfileVersion, + fallbackVersion, +}: { + storeDir: string + name: string + lockfileVersion?: string + fallbackVersion?: string +}): string | null { + const entriesDir = name.startsWith("@") + ? join(storeDir, name.split("/")[0]) + : storeDir + if (!existsSync(entriesDir)) { + return null + } + + const candidates = readdirSync(entriesDir) + .map((entry) => join(entriesDir, entry, "node_modules", name)) + .filter((candidate) => parseStoreEntry(candidate)?.name === name) + .filter((candidate) => existsSync(join(candidate, "package.json"))) + + // makePatch diffs against the lockfile version, so never substitute another + const wantedVersion = lockfileVersion || fallbackVersion + const matches = wantedVersion + ? candidates.filter( + (candidate) => parseStoreEntry(candidate)?.version === wantedVersion, + ) + : [] + + if (!matches.length && !lockfileVersion && candidates.length === 1) { + return realpathSync(candidates[0]) + } + // same-version entries differ by dependency graph, and we can't tell which is used + return matches.length === 1 ? realpathSync(matches[0]) : null +} + +/** + * Store entries are node_modules/.store/@-/node_modules/. + * Reading the version from the path keeps patches to package.json resolvable. + */ +function parseStoreEntry( + packageDir: string, +): { name: string; version: string } | undefined { + const match = join(packageDir).match( + /\/node_modules\/\.store\/((?:@[^/]+\/)?[^/@]+)@([^/]+)-[\w-]{22}\/node_modules\/(.+)$/, + ) + if (!match || match[1] !== match[3]) { + return undefined + } + return { name: match[1], version: match[2] } +} + +/** + * Mirrors Node's module lookup from the parent's real location, which finds + * the sibling symlinks npm creates inside each .store entry. + */ +function resolveDependency({ + fromDir, + name, + appRealPath, +}: { + fromDir: string + name: string + appRealPath: string +}): string | null { + let currentDir = fromDir + + while (true) { + if (basename(currentDir) !== "node_modules") { + const candidate = join(currentDir, "node_modules", name) + if (existsSync(candidate)) { + return realpathSync(candidate) + } + } + + // workspace links point into the root .store, outside appPath + const parentDir = dirname(currentDir) + if ( + parentDir === currentDir || + basename(parentDir) === ".store" || + (!join(parentDir).includes("/node_modules/.store/") && + relative(appRealPath, parentDir).startsWith("..")) + ) { + return null + } + currentDir = parentDir + } +} + +type LockfilePackages = Record< + string, + { name?: string; version?: string } | undefined +> + +function readLockfilePackages(appPath: string): LockfilePackages { + for (const lockfileName of ["npm-shrinkwrap.json", "package-lock.json"]) { + try { + const lockfile = JSON.parse( + readFileSync(join(appPath, lockfileName), "utf8"), + ) + return lockfile.packages || {} + } catch (e) { + // noop + } + } + return {} +} diff --git a/src/stateFile.ts b/src/stateFile.ts index 7aea576d..5037a718 100644 --- a/src/stateFile.ts +++ b/src/stateFile.ts @@ -21,8 +21,9 @@ export const STATE_FILE_NAME = ".patch-package.json" export function getPatchApplicationState( packageDetails: PackageDetails, + resolvedPath?: string, ): PatchApplicationState | null { - const fileName = join(packageDetails.path, STATE_FILE_NAME) + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) let state: null | PatchApplicationState = null try { @@ -46,12 +47,14 @@ export function savePatchApplicationState({ packageDetails, patches, isRebasing, + resolvedPath, }: { packageDetails: PackageDetails patches: PatchState[] isRebasing: boolean + resolvedPath?: string }) { - const fileName = join(packageDetails.path, STATE_FILE_NAME) + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) const state: PatchApplicationState = { patches, @@ -62,8 +65,11 @@ export function savePatchApplicationState({ writeFileSync(fileName, stringify(state, { space: 4 }), "utf8") } -export function clearPatchApplicationState(packageDetails: PackageDetails) { - const fileName = join(packageDetails.path, STATE_FILE_NAME) +export function clearPatchApplicationState( + packageDetails: PackageDetails, + resolvedPath?: string, +) { + const fileName = join(resolvedPath || packageDetails.path, STATE_FILE_NAME) try { unlinkSync(fileName)