diff --git a/package.json b/package.json index 9d97ca5..9844153 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@esbuild-plugins/tsconfig-paths": "^0.1.2", "@types/fs-extra": "^11.0.4", "@types/node": "^24.4.0", "@types/npmcli__config": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 151ce88..9f5f3c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,9 @@ importers: specifier: ^2.8.1 version: 2.8.1 devDependencies: + '@esbuild-plugins/tsconfig-paths': + specifier: ^0.1.2 + version: 0.1.2(esbuild@0.25.9)(typescript@5.9.2) '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -106,6 +109,12 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@esbuild-plugins/tsconfig-paths@0.1.2': + resolution: {integrity: sha512-TusFR26Y+Ze+Zm+NdfqZTSG4XyrXKxIaAfYCL3jASEI/gHjSdoCujATjzNWaaXs6Sk6Bv2D7NLr4Jdz1gysy/Q==} + peerDependencies: + esbuild: '*' + typescript: '*' + '@esbuild/aix-ppc64@0.25.9': resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} engines: {node: '>=18'} @@ -1055,15 +1064,6 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2454,6 +2454,16 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@esbuild-plugins/tsconfig-paths@0.1.2(esbuild@0.25.9)(typescript@5.9.2)': + dependencies: + debug: 4.4.3 + esbuild: 0.25.9 + find-up: 5.0.0 + strip-json-comments: 3.1.1 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@esbuild/aix-ppc64@0.25.9': optional: true @@ -3090,7 +3100,7 @@ snapshots: '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.3 eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.1 @@ -3108,7 +3118,7 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.3 eslint: 8.57.1 optionalDependencies: typescript: 5.9.2 @@ -3124,7 +3134,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.2) '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.2) - debug: 4.3.7 + debug: 4.4.3 eslint: 8.57.1 ts-api-utils: 1.2.1(typescript@5.9.2) optionalDependencies: @@ -3138,7 +3148,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -3444,10 +3454,6 @@ snapshots: dependencies: ms: 2.1.2 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 diff --git a/src/isolate.ts b/src/isolate.ts index 3233930..76f9af8 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -21,6 +21,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 { @@ -210,6 +211,28 @@ export function createIsolator(config?: IsolateConfig) { config, }); + /** Copy patch files if includePatchedDependencies is enabled */ + const copiedPatches = await copyPatches({ + workspaceRootDir, + targetPackageManifest, + isolateDir, + includePatchedDependencies: config.includePatchedDependencies, + includeDevDependencies: config.includeDevDependencies, + }); + + /** Add copied patches to the isolated package.json */ + if (Object.keys(copiedPatches).length > 0) { + const manifest = await readManifest(isolateDir); + if (!manifest.pnpm) { + manifest.pnpm = {}; + } + manifest.pnpm.patchedDependencies = copiedPatches; + await writeManifest(isolateDir, manifest); + 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 diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 9926225..e10e198 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -13,11 +13,83 @@ import { 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 type { Logger } from "~/lib/logger"; import { useLogger } from "~/lib/logger"; import type { PackageManifest, PackagesRegistry } from "~/lib/types"; import { getErrorMessage, isRushWorkspace } from "~/lib/utils"; import { pnpmMapImporter } from "./pnpm-map-importer"; +function filterPatchedDependencies( + originalPatchedDependencies: any, + targetPackageManifest: PackageManifest, + includeDevDependencies: boolean, + log: Logger +): any { + if ( + !originalPatchedDependencies || + typeof originalPatchedDependencies !== "object" + ) { + return undefined; + } + + const getPackageName = (packageSpec: string): string => { + // Handle scoped packages: @scope/package@version -> @scope/package + if (packageSpec.startsWith("@")) { + const parts = packageSpec.split("@"); + return `@${parts[1]}`; + } + // Handle regular packages: package@version -> package + return packageSpec.split("@")[0]; + }; + + const filteredPatches: any = {}; + let includedCount = 0; + let excludedCount = 0; + + for (const [packageSpec, patchInfo] of Object.entries( + originalPatchedDependencies + )) { + const packageName = getPackageName(packageSpec); + + // Check if it's a regular dependency + if (targetPackageManifest.dependencies?.[packageName]) { + filteredPatches[packageSpec] = patchInfo; + includedCount++; + log.debug( + `Including production dependency patch in lockfile: ${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 in lockfile: ${packageSpec}`); + } else { + excludedCount++; + log.debug( + `Excluding dev dependency patch from lockfile: ${packageSpec}` + ); + } + continue; + } + + // Package not found in dependencies or devDependencies + log.debug( + `Excluding patch from lockfile: ${packageSpec} (package "${packageName}" not found in target dependencies)` + ); + excludedCount++; + } + + log.debug( + `Filtered patched dependencies: ${includedCount} included, ${excludedCount} excluded` + ); + + return Object.keys(filteredPatches).length > 0 ? filteredPatches : undefined; +} + export async function generatePnpmLockfile({ workspaceRootDir, targetPackageDir, @@ -40,14 +112,22 @@ export async function generatePnpmLockfile({ includePatchedDependencies: boolean; }) { /** - * For now we will assume that the lockfile format might not change in the - * versions after 9, because we might get lucky. If it does change, things - * would break either way. + * PNPM 10+ uses the same lockfile format as version 9, but with + * lockfileVersion: '10.0' Since @pnpm/lockfile-file v10 packages don't exist + * yet, we use v9 packages for PNPM 10+. This should work because PNPM + * maintains backward compatibility, but we log a warning to alert users of + * potential edge cases. */ const useVersion9 = majorVersion >= 9; const log = useLogger(); + if (majorVersion >= 10) { + log.debug( + `Using PNPM v${majorVersion} with v9 lockfile packages - this should work but may have limitations` + ); + } + log.debug("Generating PNPM lockfile..."); try { @@ -163,13 +243,18 @@ 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. + * Filter patched dependencies to only include patches for packages that + * will actually be present in the isolated lockfile based on dependency + * type. We read patchedDependencies from workspace root, but filter based + * on target package dependencies. */ const patchedDependencies = includePatchedDependencies - ? lockfile.patchedDependencies + ? filterPatchedDependencies( + lockfile.patchedDependencies, + targetPackageManifest, + includeDevDependencies, + log + ) : undefined; useVersion9 diff --git a/src/lib/patches/copy-patches.ts b/src/lib/patches/copy-patches.ts new file mode 100644 index 0000000..d807edc --- /dev/null +++ b/src/lib/patches/copy-patches.ts @@ -0,0 +1,124 @@ +import fs from "fs-extra"; +import path from "node:path"; +import { useLogger } from "../logger"; +import type { PackageManifest } from "../types"; +import { getRootRelativeLogPath } from "../utils"; + +export async function copyPatches({ + workspaceRootDir, + targetPackageManifest, + isolateDir, + includePatchedDependencies, + includeDevDependencies, +}: { + workspaceRootDir: string; + targetPackageManifest: PackageManifest; + isolateDir: string; + includePatchedDependencies: boolean; + includeDevDependencies: boolean; +}): Promise> { + const log = useLogger(); + + if (!includePatchedDependencies) { + log.debug("Skipping patch copying (includePatchedDependencies is false)"); + return {}; + } + + // Read patchedDependencies from workspace root package.json, not target package + let workspaceRootManifest: PackageManifest; + try { + const { readTypedJson } = await import("../utils"); + workspaceRootManifest = await readTypedJson( + path.join(workspaceRootDir, "package.json") + ); + } catch (error) { + log.warn(`Could not read workspace root package.json: ${error}`); + return {}; + } + + const patchedDependencies = workspaceRootManifest.pnpm?.patchedDependencies; + + if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) { + log.debug("No patched dependencies found in package.json"); + return {}; + } + + const patchesDir = path.join(isolateDir, "patches"); + await fs.ensureDir(patchesDir); + + log.debug( + `Found ${Object.keys(patchedDependencies).length} patched dependencies` + ); + + // Get the package name from the package spec (e.g., "chalk@5.3.0" -> "chalk", "@firebase/app@1.2.3" -> "@firebase/app") + const getPackageName = (packageSpec: string): string => { + // Handle scoped packages: @scope/package@version -> @scope/package + if (packageSpec.startsWith("@")) { + const parts = packageSpec.split("@"); + return `@${parts[1]}`; + } + // Handle regular packages: package@version -> package + return packageSpec.split("@")[0]; + }; + + // Filter patches based on dependency type + const filteredPatches = Object.entries(patchedDependencies).filter( + ([packageSpec]) => { + const packageName = getPackageName(packageSpec); + + // Check if it's a regular dependency + if (targetPackageManifest.dependencies?.[packageName]) { + log.debug(`Including production dependency patch: ${packageSpec}`); + return true; + } + + // Check if it's a dev dependency and we should include dev dependencies + if (targetPackageManifest.devDependencies?.[packageName]) { + if (includeDevDependencies) { + log.debug(`Including dev dependency patch: ${packageSpec}`); + return true; + } else { + log.debug( + `Excluding dev dependency patch: ${packageSpec} (includeDevDependencies=false)` + ); + return false; + } + } + + // Package not found in dependencies or devDependencies + log.debug( + `Excluding patch ${packageSpec}: package "${packageName}" not found in target dependencies` + ); + return false; + } + ); + + log.debug( + `Copying ${filteredPatches.length} patches (filtered from ${Object.keys(patchedDependencies).length})` + ); + + const copiedPatches: Record = {}; + + for (const [packageSpec, patchPath] of filteredPatches) { + const sourcePatchPath = path.resolve(workspaceRootDir, patchPath); + const targetPatchPath = path.join(patchesDir, path.basename(patchPath)); + + if (!fs.existsSync(sourcePatchPath)) { + log.warn( + `Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}` + ); + continue; + } + + await fs.copy(sourcePatchPath, targetPatchPath); + log.debug(`Copied patch for ${packageSpec}: ${path.basename(patchPath)}`); + + // Store the relative path for the isolated package.json + copiedPatches[packageSpec] = `patches/${path.basename(patchPath)}`; + } + + log.debug( + `Patches copied to ${getRootRelativeLogPath(patchesDir, isolateDir)}` + ); + return copiedPatches; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 7a75b32..d6b558b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2,6 +2,10 @@ import type { PackageManifest as PnpmPackageManifest } from "@pnpm/types"; export type PackageManifest = PnpmPackageManifest & { packageManager?: string; + pnpm?: { + patchedDependencies?: Record; + [key: string]: unknown; + }; }; export type WorkspacePackageInfo = { diff --git a/tsup.config.ts b/tsup.config.ts index 7fe3ae7..7bd1b2f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,4 +1,8 @@ import { defineConfig } from "tsup"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { default: tsconfigPathsPlugin } = require("@esbuild-plugins/tsconfig-paths"); export default defineConfig({ entry: { @@ -11,6 +15,7 @@ export default defineConfig({ splitting: false, dts: true, clean: true, + esbuildPlugins: [tsconfigPathsPlugin({})], // shims: true, // replaces use of import.meta /** * The `isolate` binary is an ES module. The file is required to have the