diff --git a/src/applyPatches.ts b/src/applyPatches.ts index 1a50d094..67f0e4bb 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,13 +51,13 @@ 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 += ` If this package is a dev dependency, rename the patch file to - + ${chalk.bold(patchFilename.replace(".patch", ".dev.patch"))} ` } @@ -180,7 +184,20 @@ export function applyPatchesForPackage({ bestEffort: boolean }) { const pathSpecifier = patches[0].pathSpecifier - const state = patches.length > 1 ? getPatchApplicationState(patches[0]) : null + + // Resolve actual package path, handling npm install-strategy=linked (.store) + const firstPatch = patches[0] + const resolvedPackagePath = resolvePackagePath({ + appPath, + packagePath: firstPatch.path, + packageName: firstPatch.name, + version: firstPatch.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 @@ -232,9 +249,18 @@ export function applyPatchesForPackage({ try { const { name, version, path, isDevOnly, patchFilename } = patchDetails + // Resolve the actual package path, handling npm install-strategy=linked + const resolvedPath = resolvePackagePath({ + appPath, + packagePath: path, + packageName: name, + version, + }) + const installedPackageVersion = getInstalledPackageVersion({ appPath, path, + resolvedPath, pathSpecifier, isDevOnly: isDevOnly || @@ -264,6 +290,7 @@ export function applyPatchesForPackage({ patchDir, cwd: process.cwd(), bestEffort, + resolvedPath, }) ) { appliedPatches.push(patchDetails) @@ -338,7 +365,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 +391,7 @@ export function applyPatchesForPackage({ patchFilename: patch.patchFilename, })), isRebasing: false, + resolvedPath: resolvedPackagePath, }) } } else { @@ -390,6 +418,7 @@ export function applyPatchesForPackage({ packageDetails: patches[0], patches: nextState, isRebasing: !!failedPatch, + resolvedPath: resolvedPackagePath, }) } if (failedPatch) { @@ -405,6 +434,7 @@ export function applyPatch({ patchDir, cwd, bestEffort, + resolvedPath, }: { patchFilePath: string reverse: boolean @@ -412,6 +442,7 @@ export function applyPatch({ patchDir: string cwd: string bestEffort: boolean + resolvedPath?: string }): boolean { const patch = readPatch({ patchFilePath, @@ -419,6 +450,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 +486,31 @@ 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 { + for (const part of patch) { + switch (part.type) { + case "patch": + case "file deletion": + case "file creation": + case "mode change": + part.path = part.path.replace(originalPrefix, newPrefix) + break + case "rename": + part.fromPath = part.fromPath.replace(originalPrefix, newPrefix) + part.toPath = part.toPath.replace(originalPrefix, newPrefix) + break + } + } +} + function createVersionMismatchWarning({ packageName, actualVersion, diff --git a/src/makePatch.ts b/src/makePatch.ts index 7e008eb3..4df4a348 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 { @@ -145,11 +146,21 @@ export function makePatch({ mode.type !== "append" const appPackageJson = require(join(appPath, "package.json")) - const packagePath = join(appPath, packageDetails.path) + + // Resolve actual package path, handling npm install-strategy=linked (.store) + const resolvedPackageDetails = resolvePackagePath({ + appPath, + packagePath: packageDetails.path, + packageName: packageDetails.name, + }) + const packagePath = join(appPath, resolvedPackageDetails) const packageJsonPath = join(packagePath, "package.json") if (!existsSync(packageJsonPath)) { - printNoPackageFoundError(packagePathSpecifier, packageJsonPath) + printNoPackageFoundError( + packagePathSpecifier, + join(appPath, packageDetails.path, "package.json"), + ) process.exit(1) } @@ -187,7 +198,7 @@ export function makePatch({ ) const packageVersion = getPackageVersion( - join(resolve(packageDetails.path), "package.json"), + join(resolve(resolvedPackageDetails), "package.json"), ) // copy .npmrc/.yarnrc in case packages are hosted in private registry diff --git a/src/resolvePackagePath.test.ts b/src/resolvePackagePath.test.ts new file mode 100644 index 00000000..12936d22 --- /dev/null +++ b/src/resolvePackagePath.test.ts @@ -0,0 +1,230 @@ +import { resolvePackagePath } from "./resolvePackagePath" +import { mkdirpSync, writeFileSync } from "fs-extra" +import { join } from "./path" +import { dirSync } from "tmp" + +describe("resolvePackagePath", () => { + let tmpDir: string + let cleanup: () => void + + beforeEach(() => { + const tmp = dirSync({ unsafeCleanup: true }) + tmpDir = tmp.name + cleanup = tmp.removeCallback + }) + + afterEach(() => { + cleanup() + }) + + it("returns the original path when the package exists at the expected location", () => { + const pkgDir = join(tmpDir, "node_modules", "some-package") + mkdirpSync(pkgDir) + writeFileSync(join(pkgDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe("node_modules/some-package") + }) + + it("resolves from .store when the expected path does not exist", () => { + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "some-package@1.0.0-abc123", + "node_modules", + "some-package", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe( + "node_modules/.store/some-package@1.0.0-abc123/node_modules/some-package", + ) + }) + + it("prefers version-specific match in .store", () => { + // Create two versions in .store + const storeDir1 = join( + tmpDir, + "node_modules", + ".store", + "pkg@1.0.0-hash1", + "node_modules", + "pkg", + ) + const storeDir2 = join( + tmpDir, + "node_modules", + ".store", + "pkg@2.0.0-hash2", + "node_modules", + "pkg", + ) + mkdirpSync(storeDir1) + mkdirpSync(storeDir2) + writeFileSync(join(storeDir1, "package.json"), '{"version":"1.0.0"}') + writeFileSync(join(storeDir2, "package.json"), '{"version":"2.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/pkg", + packageName: "pkg", + version: "2.0.0", + }) + + expect(result).toBe("node_modules/.store/pkg@2.0.0-hash2/node_modules/pkg") + }) + + it("handles scoped packages in .store", () => { + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "@scope+pkg@1.0.0-hash", + "node_modules", + "@scope", + "pkg", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/@scope/pkg", + packageName: "@scope/pkg", + version: "1.0.0", + }) + + expect(result).toBe( + "node_modules/.store/@scope+pkg@1.0.0-hash/node_modules/@scope/pkg", + ) + }) + + it("returns original path when package is not found anywhere", () => { + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/nonexistent", + packageName: "nonexistent", + }) + + expect(result).toBe("node_modules/nonexistent") + }) + + it("returns original path when .store directory does not exist", () => { + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/some-package", + packageName: "some-package", + }) + + expect(result).toBe("node_modules/some-package") + }) + + it("resolves nested package from .store", () => { + // Simulate: parent exists but nested dep doesn't at expected path + const parentDir = join(tmpDir, "node_modules", "parent") + mkdirpSync(parentDir) + + // The nested dep is in .store + const storeDir = join( + tmpDir, + "node_modules", + ".store", + "child@1.0.0-hash", + "node_modules", + "child", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: tmpDir, + packagePath: "node_modules/parent/node_modules/child", + packageName: "child", + version: "1.0.0", + }) + + expect(result).toBe( + "node_modules/.store/child@1.0.0-hash/node_modules/child", + ) + }) + + it("resolves from ancestor node_modules (monorepo hoisting)", () => { + // Simulate monorepo: appPath is packages/a, package is hoisted to root + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + // Package is hoisted to root node_modules + const hoistedDir = join(tmpDir, "node_modules", "foo") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/foo", + packageName: "foo", + }) + + expect(result).toBe("../../node_modules/foo") + }) + + it("resolves scoped package from ancestor node_modules", () => { + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + const hoistedDir = join(tmpDir, "node_modules", "@scope", "bar") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"2.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/@scope/bar", + packageName: "@scope/bar", + }) + + expect(result).toBe("../../node_modules/@scope/bar") + }) + + it("prefers .store over ancestor node_modules", () => { + const workspaceDir = join(tmpDir, "packages", "a") + mkdirpSync(workspaceDir) + + // Package in .store under workspace + const storeDir = join( + workspaceDir, + "node_modules", + ".store", + "foo@1.0.0-hash", + "node_modules", + "foo", + ) + mkdirpSync(storeDir) + writeFileSync(join(storeDir, "package.json"), '{"version":"1.0.0"}') + + // Also in ancestor node_modules + const hoistedDir = join(tmpDir, "node_modules", "foo") + mkdirpSync(hoistedDir) + writeFileSync(join(hoistedDir, "package.json"), '{"version":"1.0.0"}') + + const result = resolvePackagePath({ + appPath: workspaceDir, + packagePath: "node_modules/foo", + packageName: "foo", + version: "1.0.0", + }) + + expect(result).toBe("node_modules/.store/foo@1.0.0-hash/node_modules/foo") + }) +}) diff --git a/src/resolvePackagePath.ts b/src/resolvePackagePath.ts new file mode 100644 index 00000000..9e5459a7 --- /dev/null +++ b/src/resolvePackagePath.ts @@ -0,0 +1,140 @@ +import { existsSync, readdirSync } from "fs-extra" +import { join, relative, resolve } from "./path" + +/** + * Resolves the real filesystem path for a package that may not be at the + * expected node_modules location. Handles: + * + * 1. npm install-strategy=linked (.store directory layout) + * 2. Monorepo hoisting (package in ancestor node_modules) + * + * Returns a path relative to appPath (like packageDetails.path). + * Falls back to the original packagePath if the package cannot be found elsewhere. + */ +export function resolvePackagePath({ + appPath, + packagePath, + packageName, + version, +}: { + appPath: string + packagePath: string + packageName: string + version?: string +}): string { + const fullPath = join(appPath, packagePath) + + // Direct path exists (handles regular installs and working symlinks) + if (existsSync(fullPath)) { + return packagePath + } + + // Try to find the package in .store directory (npm install-strategy=linked) + const storePath = resolveFromStore({ appPath, packageName, version }) + if (storePath) { + return storePath + } + + // Try to find the package in ancestor node_modules (monorepo hoisting) + const ancestorPath = resolveFromAncestors({ appPath, packageName }) + if (ancestorPath) { + return ancestorPath + } + + // Couldn't resolve, return original (caller will handle the error) + return packagePath +} + +function resolveFromStore({ + appPath, + packageName, + version, +}: { + appPath: string + packageName: string + version?: string +}): string | null { + const storePath = join(appPath, "node_modules", ".store") + + if (!existsSync(storePath)) { + return null + } + + try { + const storeEntries = readdirSync(storePath) + // For scoped packages like @scope/name, .store uses + as separator + const normalizedName = packageName.replace("/", "+") + + // Try version-specific match first + if (version) { + const versionPrefix = `${normalizedName}@${version}` + for (const entry of storeEntries) { + if (entry.startsWith(versionPrefix)) { + const candidatePath = join( + "node_modules", + ".store", + entry, + "node_modules", + packageName, + ) + if (existsSync(join(appPath, candidatePath))) { + return candidatePath + } + } + } + } + + // Try any version match + const namePrefix = normalizedName + "@" + for (const entry of storeEntries) { + if (entry.startsWith(namePrefix)) { + const candidatePath = join( + "node_modules", + ".store", + entry, + "node_modules", + packageName, + ) + if (existsSync(join(appPath, candidatePath))) { + return candidatePath + } + } + } + } catch (e) { + // noop + } + + return null +} + +/** + * Walks up parent directories looking for the package in ancestor + * node_modules directories. This handles monorepo hoisting where + * dependencies are installed in the workspace root node_modules + * rather than the package's own node_modules. + */ +function resolveFromAncestors({ + appPath, + packageName, +}: { + appPath: string + packageName: string +}): string | null { + let currentDir = resolve(appPath, "..") + + while (true) { + const candidateFullPath = join(currentDir, "node_modules", packageName) + if (existsSync(candidateFullPath)) { + return relative(appPath, candidateFullPath) + } + + const parentDir = resolve(currentDir, "..") + if (parentDir === currentDir) { + // Reached filesystem root + break + } + currentDir = parentDir + } + + return null +} 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)