Skip to content
Open
Show file tree
Hide file tree
Changes from 24 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
16 changes: 14 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
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-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",
Expand Down
60 changes: 52 additions & 8 deletions 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 @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<string> {
return createIsolator(config)();
}
2 changes: 0 additions & 2 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { inspectValue, readTypedJsonSync } from "./utils";
export type IsolateConfigResolved = {
buildDirName?: string;
includeDevDependencies: boolean;
includePatchedDependencies: boolean;
isolateDirName: string;
logLevel: LogLevel;
targetPackagePath?: string;
Expand All @@ -25,7 +24,6 @@ export type IsolateConfig = Partial<IsolateConfigResolved>;
const configDefaults: IsolateConfigResolved = {
buildDirName: undefined,
includeDevDependencies: false,
includePatchedDependencies: false,
isolateDirName: "isolate",
logLevel: "info",
targetPackagePath: undefined,
Expand Down
22 changes: 8 additions & 14 deletions src/lib/lockfile/helpers/generate-pnpm-lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -27,7 +27,7 @@ export async function generatePnpmLockfile({
targetPackageManifest,
majorVersion,
includeDevDependencies,
includePatchedDependencies,
patchedDependencies,
}: {
workspaceRootDir: string;
targetPackageDir: string;
Expand All @@ -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<string, PatchFile>;
}) {
/**
* For now we will assume that the lockfile format might not change in the
Expand Down Expand Up @@ -132,7 +133,6 @@ export async function generatePnpmLockfile({
".",
pnpmMapImporter(".", importer!, {
includeDevDependencies,
includePatchedDependencies,
directoryByPackageName,
}),
];
Expand All @@ -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,
}),
];
Expand All @@ -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 (flattened to patches/ with collision avoidance).
*/
const patchedDependencies = includePatchedDependencies
? lockfile.patchedDependencies
: undefined;

if (useVersion9) {
await writeWantedLockfile_v9(isolateDir, {
...prunedLockfile,
Expand Down
3 changes: 1 addition & 2 deletions src/lib/lockfile/helpers/pnpm-map-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export function pnpmMapImporter(
directoryByPackageName,
}: {
includeDevDependencies: boolean;
includePatchedDependencies: boolean;
directoryByPackageName: { [packageName: string]: string };
}
): ProjectSnapshot {
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/lib/lockfile/process-lockfile.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +22,7 @@ export async function processLockfile({
internalDepPackageNames,
targetPackageDir,
targetPackageManifest,
patchedDependencies,
config,
}: {
workspaceRootDir: string;
Expand All @@ -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<string, PatchFile>;
config: IsolateConfigResolved;
}) {
const log = useLogger();
Expand Down Expand Up @@ -89,7 +92,7 @@ export async function processLockfile({
targetPackageManifest,
majorVersion,
includeDevDependencies: config.includeDevDependencies,
includePatchedDependencies: config.includePatchedDependencies,
patchedDependencies,
});
break;
}
Expand Down
Loading