diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 80c3a48..065a2ed 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -64,12 +64,18 @@ by name. - `unpack-dependencies.ts` - Extracts packed tarballs to isolate directory - `process-build-output-files.ts` - Copies target package build output +**patches/** - Handles PNPM patched dependencies: + +- `copy-patches.ts` - Copies relevant patch files from workspace root to isolate + directory, filtering based on target package dependencies + ### Key Types (`src/lib/types.ts`) - `PackageManifest` - Extended pnpm package manifest type - `PackagesRegistry` - Maps package names to their paths and manifests - `WorkspacePackageInfo` - Package metadata (absoluteDir, rootRelativeDir, manifest) +- `PatchFile` - Represents a patch file entry with path and hash ### Process Flow @@ -79,8 +85,9 @@ by name. 4. Recursively find all internal dependencies 5. Pack and unpack internal dependencies to isolate directory 6. Adapt manifests to use `file:` references -7. Generate pruned lockfile for the isolated package -8. Copy workspace config files (.npmrc, pnpm-workspace.yaml) +7. Copy PNPM patched dependencies (if any exist) +8. Generate pruned lockfile for the isolated package +9. Copy workspace config files (.npmrc, pnpm-workspace.yaml) ## Path Alias @@ -89,3 +96,8 @@ The codebase uses `~/` as path alias for `src/` (configured in tsconfig.json). ## Testing Tests use Vitest and are co-located with source files (`*.test.ts`). + +## Code Style + +- Use JSDoc style comments (`/** ... */`) for all comments, including + single-line comments diff --git a/README.md b/README.md index fae8a89..5c572c6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [Troubleshooting](#troubleshooting) - [Prerequisites](#prerequisites) - [Configuration Options](#configuration-options) +- [PNPM Patched Dependencies](#pnpm-patched-dependencies) - [API](#api) - [The internal packages strategy](#the-internal-packages-strategy) - [Firebase](#firebase) @@ -34,6 +35,7 @@ integrated, check out [mono-ts](https://github.com/0x80/mono-ts) - Optionally force output to use NPM with matching versions - Optionally include devDependencies in the isolated output - Optionally pick or omit scripts from the manifest +- Automatically copies PNPM patched dependencies to the isolated output - Compatible with the Firebase tools CLI, including 1st and 2nd generation Firebase Functions. For more information see [the Firebase instructions](./docs/firebase.md). @@ -325,6 +327,23 @@ services When you use the `targetPackagePath` option, this setting will be ignored. +## PNPM Patched Dependencies + +If your workspace uses PNPM's [patched dependencies](https://pnpm.io/cli/patch) +feature, `isolate` will automatically copy the relevant patch files to the +isolated output. + +Patches are filtered based on the target package's dependencies: + +- Patches for production dependencies are always included +- Patches for dev dependencies are only included when `includeDevDependencies` + is enabled +- Patches for packages not in the target's dependency tree are excluded + +The patch files are copied to the isolated output, preserving their original +directory structure. Both the `package.json` and `pnpm-lock.yaml` are updated +with the correct paths. + ## API Alternatively, `isolate` can be integrated in other programs by importing it as diff --git a/package.json b/package.json index 4c96ee2..41348b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "isolate-package", - "version": "1.26.1", + "version": "1.27.0-2", "description": "Isolate a monorepo package with its shared dependencies to form a self-contained directory, compatible with Firebase deploy", "author": "Thijs Koerselman", "license": "MIT", diff --git a/src/isolate.ts b/src/isolate.ts index 6223d94..2001e53 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -22,6 +22,7 @@ import { } from "./lib/output"; import { detectPackageManager, shouldUsePnpmPack } from "./lib/package-manager"; import { getVersion } from "./lib/package-manager/helpers/infer-from-files"; +import { copyPatches } from "./lib/patches/copy-patches"; import { createPackagesRegistry, listInternalPackages } from "./lib/registry"; import type { PackageManifest } from "./lib/types"; import { @@ -199,6 +200,23 @@ export function createIsolator(config?: IsolateConfig) { await writeManifest(isolateDir, outputManifest); + /** + * Copy patch files before generating lockfile so the lockfile contains the + * correct paths. Only copy patches when output uses pnpm, since patched + * dependencies are a pnpm-specific feature. + */ + const shouldCopyPatches = + packageManager.name === "pnpm" && !config.forceNpm; + + const copiedPatches = shouldCopyPatches + ? await copyPatches({ + workspaceRootDir, + targetPackageManifest: outputManifest, + isolateDir, + includeDevDependencies: config.includeDevDependencies, + }) + : {}; + /** Generate an isolated lockfile based on the original one */ const usedFallbackToNpm = await processLockfile({ workspaceRootDir, @@ -208,18 +226,44 @@ export function createIsolator(config?: IsolateConfig) { targetPackageDir, targetPackageName: targetPackageManifest.name, targetPackageManifest: outputManifest, + patchedDependencies: + Object.keys(copiedPatches).length > 0 ? copiedPatches : undefined, config, }); - if (usedFallbackToNpm) { - /** - * When we fall back to NPM, we set the manifest package manager to the - * available NPM version. - */ + const hasCopiedPatches = Object.keys(copiedPatches).length > 0; + + /** Update manifest if patches were copied or npm fallback is needed */ + if (hasCopiedPatches || usedFallbackToNpm) { const manifest = await readManifest(isolateDir); - const npmVersion = getVersion("npm"); - manifest.packageManager = `npm@${npmVersion}`; + if (hasCopiedPatches) { + if (!manifest.pnpm) { + manifest.pnpm = {}; + } + /** + * Extract just the paths for the manifest (lockfile needs full + * PatchFile) + */ + manifest.pnpm.patchedDependencies = Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.path, + ]) + ); + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated package.json` + ); + } + + if (usedFallbackToNpm) { + /** + * When we fall back to NPM, we set the manifest package manager to the + * available NPM version. + */ + const npmVersion = getVersion("npm"); + manifest.packageManager = `npm@${npmVersion}`; + } await writeManifest(isolateDir, manifest); } @@ -286,7 +330,7 @@ export function createIsolator(config?: IsolateConfig) { }; } -// Keep the original function for backward compatibility +/** Keep the original function for backward compatibility */ export async function isolate(config?: IsolateConfig): Promise { return createIsolator(config)(); } diff --git a/src/lib/config.ts b/src/lib/config.ts index d8f5f07..2128ba2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -7,7 +7,6 @@ import { inspectValue, readTypedJsonSync } from "./utils"; export type IsolateConfigResolved = { buildDirName?: string; includeDevDependencies: boolean; - includePatchedDependencies: boolean; isolateDirName: string; logLevel: LogLevel; targetPackagePath?: string; @@ -25,7 +24,6 @@ export type IsolateConfig = Partial; const configDefaults: IsolateConfigResolved = { buildDirName: undefined, includeDevDependencies: false, - includePatchedDependencies: false, isolateDirName: "isolate", logLevel: "info", targetPackagePath: undefined, diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 6e317d8..0fcf906 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -14,7 +14,7 @@ import { pruneLockfile as pruneLockfile_v8 } from "pnpm_prune_lockfile_v8"; import { pruneLockfile as pruneLockfile_v9 } from "pnpm_prune_lockfile_v9"; import { pick } from "remeda"; import { useLogger } from "~/lib/logger"; -import type { PackageManifest, PackagesRegistry } from "~/lib/types"; +import type { PackageManifest, PackagesRegistry, PatchFile } from "~/lib/types"; import { getErrorMessage, isRushWorkspace } from "~/lib/utils"; import { pnpmMapImporter } from "./pnpm-map-importer"; @@ -27,7 +27,7 @@ export async function generatePnpmLockfile({ targetPackageManifest, majorVersion, includeDevDependencies, - includePatchedDependencies, + patchedDependencies, }: { workspaceRootDir: string; targetPackageDir: string; @@ -37,7 +37,8 @@ export async function generatePnpmLockfile({ targetPackageManifest: PackageManifest; majorVersion: number; includeDevDependencies: boolean; - includePatchedDependencies: boolean; + /** Pre-computed patched dependencies with transformed paths from copyPatches */ + patchedDependencies?: Record; }) { /** * For now we will assume that the lockfile format might not change in the @@ -132,7 +133,6 @@ export async function generatePnpmLockfile({ ".", pnpmMapImporter(".", importer!, { includeDevDependencies, - includePatchedDependencies, directoryByPackageName, }), ]; @@ -143,8 +143,7 @@ export async function generatePnpmLockfile({ return [ importerId, pnpmMapImporter(importerId, importer!, { - includeDevDependencies: false, // Only include dev deps for target package - includePatchedDependencies, + includeDevDependencies: false, directoryByPackageName, }), ]; @@ -163,15 +162,10 @@ export async function generatePnpmLockfile({ } /** - * Don't know how to map the patched dependencies yet, so we just include - * them but I don't think it would work like this. The important thing for - * now is that they are omitted by default, because that is the most common - * use case. + * Use pre-computed patched dependencies with transformed paths. The paths + * are already adapted by copyPatches to match the isolated directory + * structure, preserving the original folder structure (not flattened). */ - const patchedDependencies = includePatchedDependencies - ? lockfile.patchedDependencies - : undefined; - if (useVersion9) { await writeWantedLockfile_v9(isolateDir, { ...prunedLockfile, diff --git a/src/lib/lockfile/helpers/pnpm-map-importer.ts b/src/lib/lockfile/helpers/pnpm-map-importer.ts index 2b703ea..9fae56f 100644 --- a/src/lib/lockfile/helpers/pnpm-map-importer.ts +++ b/src/lib/lockfile/helpers/pnpm-map-importer.ts @@ -16,7 +16,6 @@ export function pnpmMapImporter( directoryByPackageName, }: { includeDevDependencies: boolean; - includePatchedDependencies: boolean; directoryByPackageName: { [packageName: string]: string }; } ): ProjectSnapshot { @@ -50,7 +49,7 @@ function pnpmMapDependenciesLinks( return value; } - // Replace backslashes with forward slashes to support Windows Git Bash + /** Replace backslashes with forward slashes to support Windows Git Bash */ const relativePath = path .relative(importerPath, got(directoryByPackageName, key)) .replace(path.sep, path.posix.sep); diff --git a/src/lib/lockfile/process-lockfile.ts b/src/lib/lockfile/process-lockfile.ts index bd057c0..445c5a4 100644 --- a/src/lib/lockfile/process-lockfile.ts +++ b/src/lib/lockfile/process-lockfile.ts @@ -1,7 +1,7 @@ import type { IsolateConfigResolved } from "../config"; import { useLogger } from "../logger"; import { usePackageManager } from "../package-manager"; -import type { PackageManifest, PackagesRegistry } from "../types"; +import type { PackageManifest, PackagesRegistry, PatchFile } from "../types"; import { generateNpmLockfile, generatePnpmLockfile, @@ -22,6 +22,7 @@ export async function processLockfile({ internalDepPackageNames, targetPackageDir, targetPackageManifest, + patchedDependencies, config, }: { workspaceRootDir: string; @@ -31,6 +32,8 @@ export async function processLockfile({ targetPackageDir: string; targetPackageName: string; targetPackageManifest: PackageManifest; + /** Pre-computed patched dependencies with transformed paths from copyPatches */ + patchedDependencies?: Record; config: IsolateConfigResolved; }) { const log = useLogger(); @@ -89,7 +92,7 @@ export async function processLockfile({ targetPackageManifest, majorVersion, includeDevDependencies: config.includeDevDependencies, - includePatchedDependencies: config.includePatchedDependencies, + patchedDependencies, }); break; } diff --git a/src/lib/patches/copy-patches.test.ts b/src/lib/patches/copy-patches.test.ts new file mode 100644 index 0000000..7e3e7e4 --- /dev/null +++ b/src/lib/patches/copy-patches.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { PackageManifest } from "~/lib/types"; +import { copyPatches } from "./copy-patches"; + +/** Mock fs-extra */ +vi.mock("fs-extra", () => ({ + default: { + ensureDir: vi.fn(), + existsSync: vi.fn(), + copy: vi.fn(), + }, +})); + +/** Mock the utils */ +vi.mock("~/lib/utils", () => ({ + filterPatchedDependencies: vi.fn(), + getIsolateRelativeLogPath: vi.fn((p: string) => p), + getRootRelativeLogPath: vi.fn((p: string) => p), + isRushWorkspace: vi.fn(() => false), + readTypedJson: vi.fn(), +})); + +/** Mock the package manager */ +vi.mock("~/lib/package-manager", () => ({ + usePackageManager: vi.fn(() => ({ majorVersion: 9 })), +})); + +/** Mock the pnpm lockfile readers */ +vi.mock("pnpm_lockfile_file_v8", () => ({ + readWantedLockfile: vi.fn(() => Promise.resolve(null)), +})); + +vi.mock("pnpm_lockfile_file_v9", () => ({ + readWantedLockfile: vi.fn(() => Promise.resolve(null)), +})); + +/** Mock the logger */ +vi.mock("~/lib/logger", () => ({ + useLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +const fs = vi.mocked((await import("fs-extra")).default); +const { filterPatchedDependencies, readTypedJson } = vi.mocked( + await import("~/lib/utils") +); + +describe("copyPatches", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return empty object when workspace root package.json cannot be read", async () => { + readTypedJson.mockRejectedValue(new Error("File not found")); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: { name: "test", version: "1.0.0" }, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({}); + }); + + it("should return empty object when no patchedDependencies in workspace root", async () => { + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + } as PackageManifest); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: { name: "test", version: "1.0.0" }, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({}); + }); + + it("should return empty object when all patches are filtered out", async () => { + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + }, + }, + } as PackageManifest); + filterPatchedDependencies.mockReturnValue(undefined); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: { name: "test", version: "1.0.0" }, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({}); + }); + + it("should copy patches for production dependencies", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "lodash@4.17.21": { path: "patches/lodash.patch", hash: "" }, + }); + /** Should preserve original folder structure */ + expect(fs.ensureDir).toHaveBeenCalledWith("/workspace/isolate/patches"); + expect(fs.copy).toHaveBeenCalledWith( + "/workspace/patches/lodash.patch", + "/workspace/isolate/patches/lodash.patch" + ); + }); + + it("should include dev dependency patches when includeDevDependencies is true", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + devDependencies: { vitest: "^1.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "vitest@1.0.0": "patches/vitest.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "vitest@1.0.0": "patches/vitest.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: true, + }); + + expect(result).toEqual({ + "vitest@1.0.0": { path: "patches/vitest.patch", hash: "" }, + }); + expect(filterPatchedDependencies).toHaveBeenCalledWith({ + patchedDependencies: { "vitest@1.0.0": "patches/vitest.patch" }, + targetPackageManifest: targetManifest, + includeDevDependencies: true, + }); + expect(fs.copy).toHaveBeenCalledWith( + "/workspace/patches/vitest.patch", + "/workspace/isolate/patches/vitest.patch" + ); + }); + + it("should skip missing patch files and log a warning", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + }); + + fs.existsSync.mockReturnValue(false); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({}); + expect(fs.copy).not.toHaveBeenCalled(); + }); + + it("should handle scoped package names correctly", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { "@firebase/app": "^1.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "@firebase/app@1.2.3": { path: "patches/firebase-app.patch", hash: "" }, + }); + }); + + it("should preserve nested folder structure when copying patches", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { "pkg-a": "^1.0.0", "pkg-b": "^1.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "pkg-a@1.0.0": "patches/v1/fix.patch", + "pkg-b@1.0.0": "patches/v2/fix.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "pkg-a@1.0.0": "patches/v1/fix.patch", + "pkg-b@1.0.0": "patches/v2/fix.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + /** Should preserve original paths without renaming */ + expect(result).toEqual({ + "pkg-a@1.0.0": { path: "patches/v1/fix.patch", hash: "" }, + "pkg-b@1.0.0": { path: "patches/v2/fix.patch", hash: "" }, + }); + expect(fs.copy).toHaveBeenCalledTimes(2); + expect(fs.copy).toHaveBeenCalledWith( + "/workspace/patches/v1/fix.patch", + "/workspace/isolate/patches/v1/fix.patch" + ); + expect(fs.copy).toHaveBeenCalledWith( + "/workspace/patches/v2/fix.patch", + "/workspace/isolate/patches/v2/fix.patch" + ); + }); + + it("should preserve deeply nested patch paths", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "lodash@4.17.21": "some/nested/path/lodash.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "some/nested/path/lodash.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + /** The path should preserve the original directory structure */ + expect(result).toEqual({ + "lodash@4.17.21": { path: "some/nested/path/lodash.patch", hash: "" }, + }); + expect(fs.ensureDir).toHaveBeenCalledWith( + "/workspace/isolate/some/nested/path" + ); + expect(fs.copy).toHaveBeenCalledWith( + "/workspace/some/nested/path/lodash.patch", + "/workspace/isolate/some/nested/path/lodash.patch" + ); + }); + + it("should copy multiple patches correctly", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { + lodash: "^4.0.0", + "@firebase/app": "^1.0.0", + }, + }; + + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + pnpm: { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }, + }, + } as PackageManifest); + + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }); + + fs.existsSync.mockReturnValue(true); + + const result = await copyPatches({ + workspaceRootDir: "/workspace", + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "lodash@4.17.21": { path: "patches/lodash.patch", hash: "" }, + "@firebase/app@1.2.3": { path: "patches/firebase-app.patch", hash: "" }, + }); + expect(fs.copy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/patches/copy-patches.ts b/src/lib/patches/copy-patches.ts new file mode 100644 index 0000000..2d819c5 --- /dev/null +++ b/src/lib/patches/copy-patches.ts @@ -0,0 +1,129 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { readWantedLockfile as readWantedLockfile_v8 } from "pnpm_lockfile_file_v8"; +import { readWantedLockfile as readWantedLockfile_v9 } from "pnpm_lockfile_file_v9"; +import { useLogger } from "~/lib/logger"; +import { usePackageManager } from "~/lib/package-manager"; +import type { PackageManifest, PatchFile } from "~/lib/types"; +import { + filterPatchedDependencies, + getRootRelativeLogPath, + isRushWorkspace, + readTypedJson, +} from "~/lib/utils"; + +export async function copyPatches({ + workspaceRootDir, + targetPackageManifest, + isolateDir, + includeDevDependencies, +}: { + workspaceRootDir: string; + targetPackageManifest: PackageManifest; + isolateDir: string; + includeDevDependencies: boolean; +}): Promise> { + const log = useLogger(); + + let workspaceRootManifest: PackageManifest; + try { + workspaceRootManifest = await readTypedJson( + path.join(workspaceRootDir, "package.json") + ); + } catch (error) { + log.warn( + `Could not read workspace root package.json: ${error instanceof Error ? error.message : String(error)}` + ); + return {}; + } + + const patchedDependencies = workspaceRootManifest.pnpm?.patchedDependencies; + + if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) { + log.debug("No patched dependencies found in workspace root package.json"); + return {}; + } + + log.debug( + `Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace` + ); + + const filteredPatches = filterPatchedDependencies({ + patchedDependencies, + targetPackageManifest, + includeDevDependencies, + }); + + if (!filteredPatches) { + return {}; + } + + /** Read the lockfile to get the hashes for each patch */ + const lockfilePatchedDependencies = + await readLockfilePatchedDependencies(workspaceRootDir); + + const copiedPatches: Record = {}; + + for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) { + const sourcePatchPath = path.resolve(workspaceRootDir, patchPath); + + if (!fs.existsSync(sourcePatchPath)) { + log.warn( + `Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}` + ); + continue; + } + + /** Preserve original folder structure */ + const targetPatchPath = path.join(isolateDir, patchPath); + await fs.ensureDir(path.dirname(targetPatchPath)); + await fs.copy(sourcePatchPath, targetPatchPath); + log.debug(`Copied patch for ${packageSpec}: ${patchPath}`); + + /** Get the hash from the original lockfile, or use empty string if not found */ + const originalPatchFile = lockfilePatchedDependencies?.[packageSpec]; + const hash = originalPatchFile?.hash ?? ""; + + if (!hash) { + log.warn(`No hash found for patch ${packageSpec} in lockfile`); + } + + copiedPatches[packageSpec] = { + path: patchPath, + hash, + }; + } + + if (Object.keys(copiedPatches).length > 0) { + log.debug(`Copied ${Object.keys(copiedPatches).length} patch files`); + } + + return copiedPatches; +} + +/** + * Read the patchedDependencies from the original lockfile to get the hashes. + * Since the file content is the same after copying, the hash remains valid. + */ +async function readLockfilePatchedDependencies( + workspaceRootDir: string +): Promise | undefined> { + try { + const { majorVersion } = usePackageManager(); + const useVersion9 = majorVersion >= 9; + const isRush = isRushWorkspace(workspaceRootDir); + + const lockfileDir = isRush + ? path.join(workspaceRootDir, "common/config/rush") + : workspaceRootDir; + + const lockfile = useVersion9 + ? await readWantedLockfile_v9(lockfileDir, { ignoreIncompatible: false }) + : await readWantedLockfile_v8(lockfileDir, { ignoreIncompatible: false }); + + return lockfile?.patchedDependencies; + } catch { + /** Package manager not detected or lockfile not readable */ + return undefined; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7a75b32..5ea3003 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,7 +1,20 @@ import type { PackageManifest as PnpmPackageManifest } from "@pnpm/types"; +/** + * Represents a patch file entry in the pnpm lockfile. Contains the path to the + * patch file and its content hash. + */ +export interface PatchFile { + path: string; + hash: string; +} + export type PackageManifest = PnpmPackageManifest & { packageManager?: string; + pnpm?: { + patchedDependencies?: Record; + [key: string]: unknown; + }; }; export type WorkspacePackageInfo = { diff --git a/src/lib/utils/filter-patched-dependencies.test.ts b/src/lib/utils/filter-patched-dependencies.test.ts new file mode 100644 index 0000000..f9a738b --- /dev/null +++ b/src/lib/utils/filter-patched-dependencies.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PackageManifest } from "~/lib/types"; +import { filterPatchedDependencies } from "./filter-patched-dependencies"; + +/** Mock the logger */ +vi.mock("~/lib/logger", () => ({ + useLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +describe("filterPatchedDependencies", () => { + it("should return undefined when patchedDependencies is undefined", () => { + const manifest: PackageManifest = { name: "test", version: "1.0.0" }; + + const result = filterPatchedDependencies({ + patchedDependencies: undefined, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toBeUndefined(); + }); + + it("should return undefined when patchedDependencies is empty", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: {}, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toBeUndefined(); + }); + + it("should include patches for production dependencies", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { "lodash@4.17.21": "patches/lodash.patch" }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toEqual({ "lodash@4.17.21": "patches/lodash.patch" }); + }); + + it("should include patches for dev dependencies when includeDevDependencies is true", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + devDependencies: { vitest: "^1.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { "vitest@1.0.0": "patches/vitest.patch" }, + targetPackageManifest: manifest, + includeDevDependencies: true, + }); + + expect(result).toEqual({ "vitest@1.0.0": "patches/vitest.patch" }); + }); + + it("should exclude patches for dev dependencies when includeDevDependencies is false", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + devDependencies: { vitest: "^1.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { "vitest@1.0.0": "patches/vitest.patch" }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toBeUndefined(); + }); + + it("should exclude patches for packages not in target dependencies", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { "other-package@1.0.0": "patches/other.patch" }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toBeUndefined(); + }); + + it("should handle scoped package names correctly", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { "@firebase/app": "^1.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }); + }); + + it("should filter mixed patches correctly", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0", "@firebase/app": "^1.0.0" }, + devDependencies: { vitest: "^1.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + "@firebase/app@1.2.3": "patches/firebase-app.patch", + "vitest@1.0.0": "patches/vitest.patch", + "unknown@1.0.0": "patches/unknown.patch", + }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "lodash@4.17.21": "patches/lodash.patch", + "@firebase/app@1.2.3": "patches/firebase-app.patch", + }); + }); + + it("should include dev dependency patches when includeDevDependencies is true in mixed scenario", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + devDependencies: { vitest: "^1.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + "vitest@1.0.0": "patches/vitest.patch", + }, + targetPackageManifest: manifest, + includeDevDependencies: true, + }); + + expect(result).toEqual({ + "lodash@4.17.21": "patches/lodash.patch", + "vitest@1.0.0": "patches/vitest.patch", + }); + }); + + it("should return undefined when all patches are filtered out", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { + "unknown-a@1.0.0": "patches/a.patch", + "unknown-b@2.0.0": "patches/b.patch", + }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toBeUndefined(); + }); + + it("should preserve patch value types", () => { + const manifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + const result = filterPatchedDependencies({ + patchedDependencies: { + "lodash@4.17.21": { path: "patches/lodash.patch", hash: "abc123" }, + }, + targetPackageManifest: manifest, + includeDevDependencies: false, + }); + + expect(result).toEqual({ + "lodash@4.17.21": { path: "patches/lodash.patch", hash: "abc123" }, + }); + }); +}); diff --git a/src/lib/utils/filter-patched-dependencies.ts b/src/lib/utils/filter-patched-dependencies.ts new file mode 100644 index 0000000..8ceb70c --- /dev/null +++ b/src/lib/utils/filter-patched-dependencies.ts @@ -0,0 +1,63 @@ +import { useLogger } from "~/lib/logger"; +import type { PackageManifest } from "~/lib/types"; +import { getPackageName } from "./get-package-name"; + +/** + * Filters patched dependencies to only include patches for packages that are + * present in the target package's dependencies based on dependency type. + */ +export function filterPatchedDependencies({ + patchedDependencies, + targetPackageManifest, + includeDevDependencies, +}: { + patchedDependencies: Record | undefined; + targetPackageManifest: PackageManifest; + includeDevDependencies: boolean; +}): Record | undefined { + const log = useLogger(); + if (!patchedDependencies || typeof patchedDependencies !== "object") { + return undefined; + } + + const filteredPatches: Record = {}; + let includedCount = 0; + let excludedCount = 0; + + for (const [packageSpec, patchInfo] of Object.entries(patchedDependencies)) { + const packageName = getPackageName(packageSpec); + + /** Check if it's a production dependency */ + if (targetPackageManifest.dependencies?.[packageName]) { + filteredPatches[packageSpec] = patchInfo; + includedCount++; + log.debug(`Including production dependency patch: ${packageSpec}`); + continue; + } + + /** Check if it's a dev dependency and we should include dev dependencies */ + if (targetPackageManifest.devDependencies?.[packageName]) { + if (includeDevDependencies) { + filteredPatches[packageSpec] = patchInfo; + includedCount++; + log.debug(`Including dev dependency patch: ${packageSpec}`); + } else { + excludedCount++; + log.debug(`Excluding dev dependency patch: ${packageSpec}`); + } + continue; + } + + /** Package not found in dependencies or devDependencies */ + log.debug( + `Excluding patch: ${packageSpec} (package "${packageName}" not in target dependencies)` + ); + excludedCount++; + } + + log.debug( + `Filtered patches: ${includedCount} included, ${excludedCount} excluded` + ); + + return Object.keys(filteredPatches).length > 0 ? filteredPatches : undefined; +} diff --git a/src/lib/utils/get-package-name.test.ts b/src/lib/utils/get-package-name.test.ts new file mode 100644 index 0000000..5d17afe --- /dev/null +++ b/src/lib/utils/get-package-name.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { getPackageName } from "./get-package-name"; + +describe("getPackageName", () => { + describe("scoped packages", () => { + it("should extract name from scoped package with version", () => { + expect(getPackageName("@firebase/app@1.2.3")).toBe("@firebase/app"); + }); + + it("should extract name from scoped package with complex version", () => { + expect(getPackageName("@types/node@20.10.0")).toBe("@types/node"); + }); + + it("should handle scoped package without version", () => { + expect(getPackageName("@firebase/app")).toBe("@firebase/app"); + }); + + it("should handle malformed scoped package with extra slashes", () => { + /** This is malformed input - real scoped packages only support @scope/name */ + expect(getPackageName("@org/sub/package@1.0.0")).toBe("@org/sub/package"); + }); + }); + + describe("regular packages", () => { + it("should extract name from regular package with version", () => { + expect(getPackageName("lodash@4.17.21")).toBe("lodash"); + }); + + it("should extract name from regular package with complex version", () => { + expect(getPackageName("typescript@5.3.0-beta")).toBe("typescript"); + }); + + it("should handle regular package without version", () => { + expect(getPackageName("lodash")).toBe("lodash"); + }); + + it("should handle package with hyphenated name", () => { + expect(getPackageName("fs-extra@11.0.0")).toBe("fs-extra"); + }); + + it("should handle package with underscores", () => { + expect(getPackageName("some_package@1.0.0")).toBe("some_package"); + }); + }); + + describe("edge cases", () => { + it("should return empty string for empty input", () => { + expect(getPackageName("")).toBe(""); + }); + + it("should handle @ symbol only", () => { + expect(getPackageName("@")).toBe("@"); + }); + + it("should handle scoped package with only scope", () => { + expect(getPackageName("@scope/")).toBe("@scope/"); + }); + + it("should handle multiple @ symbols in version (edge case)", () => { + /** This is a malformed input but should not throw */ + expect(getPackageName("package@1.0.0@extra")).toBe("package"); + }); + + it("should handle scoped package with multiple @ in version", () => { + /** Scoped packages split on @ so this tests the behavior */ + expect(getPackageName("@scope/pkg@1.0.0@extra")).toBe("@scope/pkg"); + }); + }); +}); diff --git a/src/lib/utils/get-package-name.ts b/src/lib/utils/get-package-name.ts new file mode 100644 index 0000000..1b93756 --- /dev/null +++ b/src/lib/utils/get-package-name.ts @@ -0,0 +1,13 @@ +/** + * Extracts the package name from a package spec like "chalk@5.3.0" or + * "@firebase/app@1.2.3" + */ +export function getPackageName(packageSpec: string): string { + if (packageSpec.startsWith("@")) { + /** Scoped packages: @scope/package@version -> @scope/package */ + const parts = packageSpec.split("@"); + return `@${parts[1] ?? ""}`; + } + /** Regular packages: package@version -> package */ + return packageSpec.split("@")[0] ?? ""; +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index f4e011a..433ea94 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,6 +1,8 @@ export * from "./filter-object-undefined"; +export * from "./filter-patched-dependencies"; export * from "./get-dirname"; export * from "./get-error-message"; +export * from "./get-package-name"; export * from "./inspect-value"; export * from "./is-present"; export * from "./is-rush-workspace";