Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c31efc2
Copy PNPM patches to isolated directory
0x80 Nov 27, 2025
a9e8943
Merge branch 'main' into thijs/1127-copy-patches-pnpm
0x80 Nov 27, 2025
1343678
1.27.0-0
0x80 Nov 27, 2025
3dd34f0
Use jsdoc comments consistently
0x80 Nov 27, 2025
1f8a8c2
Extract utility
0x80 Nov 27, 2025
979705d
Extract filtering logic
0x80 Nov 27, 2025
f704afe
Avoid patch naming collisions
0x80 Nov 27, 2025
0265999
Add tests for filter patched deps
0x80 Nov 27, 2025
b2ff870
Something
0x80 Nov 27, 2025
e72cae4
Use output manifest consistently
0x80 Nov 27, 2025
182a7cd
Add tests for copy patches
0x80 Nov 27, 2025
41a5251
Add tests for get package name
0x80 Nov 27, 2025
25fc4f2
Use correct log path function and fix formatting
0x80 Nov 27, 2025
f7896fe
Fix lockfile patch paths to match copied file locations
0x80 Nov 27, 2025
923da2e
1.27.0-1
0x80 Nov 27, 2025
52118ec
Format code
0x80 Nov 27, 2025
8b4702e
Rename misleading test for malformed scoped package
0x80 Nov 27, 2025
8ad26f5
Add explicit mocks for package manager and lockfile readers in tests
0x80 Nov 27, 2025
9b2beda
Refactor filterPatchedDependencies to use object parameter and intern…
0x80 Nov 27, 2025
6041e9b
Remove includePatchedDependencies config option
0x80 Nov 27, 2025
af08551
Simplify patch copying to preserve original folder structure
0x80 Nov 27, 2025
39a37aa
1.27.0-2
0x80 Nov 27, 2025
a94fde3
Only copy patches when output uses pnpm
0x80 Nov 27, 2025
c979b93
Format
0x80 Nov 27, 2025
ec48d5e
Update src/lib/lockfile/helpers/generate-pnpm-lockfile.ts
0x80 Nov 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,7 @@ 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "isolate-package",
"version": "1.26.1",
"version": "1.27.0-0",
"description": "Isolate a monorepo package with its shared dependencies to form a self-contained directory, compatible with Firebase deploy",
"author": "Thijs Koerselman",
"license": "MIT",
Expand Down
25 changes: 24 additions & 1 deletion src/isolate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -211,6 +212,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
Expand Down Expand Up @@ -286,7 +309,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<string> {
return createIsolator(config)();
}
82 changes: 76 additions & 6 deletions src/lib/lockfile/helpers/generate-pnpm-lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,76 @@ 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 {
getErrorMessage,
getPackageName,
isRushWorkspace,
} from "~/lib/utils";
import { pnpmMapImporter } from "./pnpm-map-importer";

/**
* Filters patched dependencies to only include patches for packages that will
* actually be present in the isolated lockfile based on dependency type.
*/
function filterPatchedDependencies<T>(
patchedDependencies: Record<string, T> | undefined,
targetPackageManifest: PackageManifest,
includeDevDependencies: boolean,
log: Logger
): Record<string, T> | undefined {
if (!patchedDependencies || typeof patchedDependencies !== "object") {
return undefined;
}

const filteredPatches: Record<string, T> = {};
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 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,
Expand Down Expand Up @@ -163,13 +228,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 lockfile, but
* filter based on target package dependencies.
*/
const patchedDependencies = includePatchedDependencies
? lockfile.patchedDependencies
? filterPatchedDependencies(
lockfile.patchedDependencies,
targetPackageManifest,
includeDevDependencies,
log
)
: undefined;

if (useVersion9) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/lockfile/helpers/pnpm-map-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,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);
Expand Down
116 changes: 116 additions & 0 deletions src/lib/patches/copy-patches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import fs from "fs-extra";
import path from "node:path";
import { useLogger } from "~/lib/logger";
import type { PackageManifest } from "~/lib/types";
import {
getPackageName,
getRootRelativeLogPath,
readTypedJson,
} from "~/lib/utils";

export async function copyPatches({
workspaceRootDir,
targetPackageManifest,
isolateDir,
includePatchedDependencies,
includeDevDependencies,
}: {
workspaceRootDir: string;
targetPackageManifest: PackageManifest;
isolateDir: string;
includePatchedDependencies: boolean;
includeDevDependencies: boolean;
}): Promise<Record<string, string>> {
const log = useLogger();

if (!includePatchedDependencies) {
log.debug("Skipping patch copying (includePatchedDependencies is false)");
return {};
}

let workspaceRootManifest: PackageManifest;
try {
workspaceRootManifest = await readTypedJson<PackageManifest>(
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 {};
}

const patchesDir = path.join(isolateDir, "patches");
await fs.ensureDir(patchesDir);

log.debug(
`Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace`
);

const filteredPatches = Object.entries(patchedDependencies).filter(
([packageSpec]) => {
const packageName = getPackageName(packageSpec);

/** Check if it's a production 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;
}
log.debug(
`Excluding dev dependency patch: ${packageSpec} (includeDevDependencies=false)`
);
return false;
}

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<string, string> = {};

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)}`);

copiedPatches[packageSpec] = `patches/${path.basename(patchPath)}`;
}

if (Object.keys(copiedPatches).length > 0) {
log.debug(
`Patches copied to ${getRootRelativeLogPath(patchesDir, isolateDir)}`
);
}

return copiedPatches;
}
4 changes: 4 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { PackageManifest as PnpmPackageManifest } from "@pnpm/types";

export type PackageManifest = PnpmPackageManifest & {
packageManager?: string;
pnpm?: {
patchedDependencies?: Record<string, string>;
[key: string]: unknown;
};
};

export type WorkspacePackageInfo = {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/utils/get-package-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Extracts the package name from a package spec like "[email protected]" or
* "@firebase/[email protected]"
*/
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] ?? "";
}
1 change: 1 addition & 0 deletions src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./filter-object-undefined";
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";
Expand Down
Loading