isolate-package 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -303,6 +303,87 @@ describe("generatePnpmLockfile", () => {
303
303
  expect(writtenLockfile.packageExtensionsChecksum).toBe("abc123");
304
304
  });
305
305
 
306
+ it("should restore the catalogs snapshot after pruning (#198)", async () => {
307
+ const catalogs = {
308
+ default: {
309
+ lodash: { specifier: "^4.17.21", version: "4.17.21" },
310
+ },
311
+ utils: {
312
+ ramda: { specifier: "^0.30.0", version: "0.30.0" },
313
+ },
314
+ };
315
+ const lockfile = {
316
+ ...createMockLockfile(),
317
+ catalogs,
318
+ };
319
+ readWantedLockfile_v9.mockResolvedValue(lockfile as never);
320
+ getLockfileImporterId_v9.mockReturnValue("apps/my-app");
321
+
322
+ /** Simulate prune dropping the catalogs snapshot */
323
+ pruneLockfile_v9.mockImplementation((lf) => {
324
+ const result = { ...(lf as unknown as Record<string, unknown>) };
325
+ delete result.catalogs;
326
+ return result as never;
327
+ });
328
+
329
+ await generatePnpmLockfile({
330
+ workspaceRootDir: "/workspace",
331
+ targetPackageDir: "/workspace/apps/my-app",
332
+ isolateDir: "/workspace/apps/my-app/isolate",
333
+ internalDepPackageNames: ["shared"],
334
+ packagesRegistry: {
335
+ shared: {
336
+ absoluteDir: "/workspace/packages/shared",
337
+ rootRelativeDir: "packages/shared",
338
+ manifest: { name: "shared", version: "1.0.0" },
339
+ },
340
+ },
341
+ targetPackageManifest: { name: "my-app", version: "1.0.0" },
342
+ majorVersion: 9,
343
+ includeDevDependencies: false,
344
+ });
345
+
346
+ const writeCall = writeWantedLockfile_v9.mock.calls[0]!;
347
+ const writtenLockfile = writeCall[1] as {
348
+ catalogs?: Record<string, Record<string, unknown>>;
349
+ };
350
+
351
+ /**
352
+ * The catalogs snapshot is restored verbatim (like overrides), so it stays
353
+ * in sync with the importer specifiers and the verbatim pnpm-workspace.yaml
354
+ * copy.
355
+ */
356
+ expect(writtenLockfile.catalogs).toEqual(catalogs);
357
+ });
358
+
359
+ it("should not set catalogs when the source lockfile has none", async () => {
360
+ const lockfile = createMockLockfile();
361
+ readWantedLockfile_v9.mockResolvedValue(lockfile as never);
362
+ getLockfileImporterId_v9.mockReturnValue("apps/my-app");
363
+ pruneLockfile_v9.mockImplementation((lf) => lf as never);
364
+
365
+ await generatePnpmLockfile({
366
+ workspaceRootDir: "/workspace",
367
+ targetPackageDir: "/workspace/apps/my-app",
368
+ isolateDir: "/workspace/apps/my-app/isolate",
369
+ internalDepPackageNames: ["shared"],
370
+ packagesRegistry: {
371
+ shared: {
372
+ absoluteDir: "/workspace/packages/shared",
373
+ rootRelativeDir: "packages/shared",
374
+ manifest: { name: "shared", version: "1.0.0" },
375
+ },
376
+ },
377
+ targetPackageManifest: { name: "my-app", version: "1.0.0" },
378
+ majorVersion: 9,
379
+ includeDevDependencies: false,
380
+ });
381
+
382
+ const writeCall = writeWantedLockfile_v9.mock.calls[0]!;
383
+ const writtenLockfile = writeCall[1] as { catalogs?: unknown };
384
+ expect(writtenLockfile.catalogs).toBeUndefined();
385
+ });
386
+
306
387
  it("should include patchedDependencies in written lockfile", async () => {
307
388
  const lockfile = createMockLockfile();
308
389
  readWantedLockfile_v9.mockResolvedValue(lockfile as never);
@@ -18,6 +18,17 @@ import type { PackageManifest, PackagesRegistry, PatchFile } from "#/lib/types";
18
18
  import { getErrorMessage, isRushWorkspace } from "#/lib/utils";
19
19
  import { pnpmMapImporter } from "./pnpm-map-importer";
20
20
 
21
+ /**
22
+ * A pnpm catalog snapshot as stored in the lockfile: a map of catalog name
23
+ * (e.g. "default") to a map of dependency name to its resolved entry. The
24
+ * pinned `@pnpm/lockfile-file` types predate catalogs, so we model the shape
25
+ * locally for the cast below.
26
+ */
27
+ type CatalogSnapshots = Record<
28
+ string,
29
+ Record<string, { specifier: string; version: string }>
30
+ >;
31
+
21
32
  export async function generatePnpmLockfile({
22
33
  workspaceRootDir,
23
34
  targetPackageDir,
@@ -167,6 +178,22 @@ export async function generatePnpmLockfile({
167
178
  lockfile.packageExtensionsChecksum;
168
179
  }
169
180
 
181
+ /**
182
+ * Pruning drops the catalogs snapshot, but the isolated importers keep
183
+ * their "catalog:" specifiers (for pnpm we don't resolve catalog deps in
184
+ * the manifest, since the output is itself a workspace). Restore it
185
+ * verbatim — like overrides above — so it stays in sync with the importer
186
+ * specifiers and the preserved pnpm-workspace.yaml catalog definitions,
187
+ * which are themselves copied verbatim (see issue #198). pnpm tolerates
188
+ * catalog entries that no retained importer references, so there is no need
189
+ * to narrow the snapshot.
190
+ */
191
+ const catalogs = (lockfile as { catalogs?: CatalogSnapshots }).catalogs;
192
+
193
+ if (catalogs) {
194
+ (prunedLockfile as { catalogs?: CatalogSnapshots }).catalogs = catalogs;
195
+ }
196
+
170
197
  /**
171
198
  * Use pre-computed patched dependencies with transformed paths. The paths
172
199
  * are already adapted by copyPatches to match the isolated directory
@@ -41,14 +41,26 @@ export async function adaptTargetPackageManifest({
41
41
  ? manifest
42
42
  : omit(manifest, ["devDependencies"]);
43
43
 
44
- /** Resolve catalog dependencies before adapting internal deps */
45
- const manifestWithResolvedCatalogs = {
46
- ...inputManifest,
47
- dependencies: await resolveCatalogDependencies(
48
- inputManifest.dependencies,
49
- workspaceRootDir,
50
- ),
51
- };
44
+ /**
45
+ * For PNPM (non-forceNpm) the isolated output is itself a pnpm workspace and
46
+ * the `pnpm-workspace.yaml` with its catalog definitions is preserved, so
47
+ * "catalog:" specifiers can be kept verbatim — just like "workspace:*". The
48
+ * lockfile importers also keep their "catalog:" specifiers, so resolving them
49
+ * here would create a mismatch and break `pnpm install --frozen-lockfile`
50
+ * (see issue #198). For other package managers (and forceNpm) the catalog is
51
+ * not available in the output, so we resolve the specifiers to versions.
52
+ */
53
+ const isPnpmWorkspaceOutput = packageManager.name === "pnpm" && !forceNpm;
54
+
55
+ const preparedManifest = isPnpmWorkspaceOutput
56
+ ? inputManifest
57
+ : {
58
+ ...inputManifest,
59
+ dependencies: await resolveCatalogDependencies(
60
+ inputManifest.dependencies,
61
+ workspaceRootDir,
62
+ ),
63
+ };
52
64
 
53
65
  const adaptedManifest =
54
66
  (packageManager.name === "pnpm" || packageManager.name === "bun") &&
@@ -59,13 +71,10 @@ export async function adaptTargetPackageManifest({
59
71
  * want to adopt workspace-level fields from the root package.json
60
72
  * (pnpm.overrides for PNPM, top-level overrides for Bun).
61
73
  */
62
- await adoptPnpmFieldsFromRoot(
63
- manifestWithResolvedCatalogs,
64
- workspaceRootDir,
65
- )
74
+ await adoptPnpmFieldsFromRoot(preparedManifest, workspaceRootDir)
66
75
  : /** For other package managers we replace the links to internal dependencies */
67
76
  adaptManifestInternalDeps({
68
- manifest: manifestWithResolvedCatalogs,
77
+ manifest: preparedManifest,
69
78
  packagesRegistry,
70
79
  });
71
80
 
@@ -20,6 +20,10 @@ vi.mock("./resolve-catalog-dependencies", () => ({
20
20
 
21
21
  const { usePackageManager } = vi.mocked(await import("#/lib/package-manager"));
22
22
 
23
+ const { resolveCatalogDependencies } = vi.mocked(
24
+ await import("./resolve-catalog-dependencies"),
25
+ );
26
+
23
27
  const { writeManifest } = vi.mocked(await import("../io"));
24
28
 
25
29
  const { adaptInternalPackageManifests } =
@@ -178,4 +182,69 @@ describe("adaptInternalPackageManifests", () => {
178
182
 
179
183
  expect(writtenManifest.devDependencies).toBeUndefined();
180
184
  });
185
+
186
+ it("should preserve catalog: specifiers for pnpm without resolving them (#198)", async () => {
187
+ const manifest: PackageManifest = {
188
+ name: "@repo/shared",
189
+ version: "1.0.0",
190
+ dependencies: {
191
+ "lodash.merge": "catalog:",
192
+ },
193
+ };
194
+
195
+ const packagesRegistry = createRegistry({
196
+ "@repo/shared": {
197
+ rootRelativeDir: "packages/shared",
198
+ manifest,
199
+ },
200
+ });
201
+
202
+ await adaptInternalPackageManifests({
203
+ internalPackageNames: ["@repo/shared"],
204
+ packagesRegistry,
205
+ isolateDir: "/output",
206
+ forceNpm: false,
207
+ workspaceRootDir: "/workspace",
208
+ });
209
+
210
+ /**
211
+ * For pnpm the isolated output is itself a workspace with its catalog
212
+ * definitions preserved, so the specifier is kept verbatim rather than
213
+ * resolved (which would desync it from the lockfile importers).
214
+ */
215
+ expect(resolveCatalogDependencies).not.toHaveBeenCalled();
216
+
217
+ const writtenManifest = writeManifest.mock.calls[0]![1];
218
+ expect(writtenManifest.dependencies).toEqual({
219
+ "lodash.merge": "catalog:",
220
+ });
221
+ });
222
+
223
+ it("should resolve catalog dependencies when forceNpm is enabled", async () => {
224
+ const manifest: PackageManifest = {
225
+ name: "@repo/shared",
226
+ version: "1.0.0",
227
+ dependencies: {
228
+ "lodash.merge": "catalog:",
229
+ },
230
+ };
231
+
232
+ const packagesRegistry = createRegistry({
233
+ "@repo/shared": {
234
+ rootRelativeDir: "packages/shared",
235
+ manifest,
236
+ },
237
+ });
238
+
239
+ await adaptInternalPackageManifests({
240
+ internalPackageNames: ["@repo/shared"],
241
+ packagesRegistry,
242
+ isolateDir: "/output",
243
+ forceNpm: true,
244
+ workspaceRootDir: "/workspace",
245
+ });
246
+
247
+ /** With forceNpm the catalog is not available in the output, so resolve. */
248
+ expect(resolveCatalogDependencies).toHaveBeenCalledOnce();
249
+ });
181
250
  });
@@ -27,6 +27,15 @@ export async function adaptInternalPackageManifests({
27
27
  }) {
28
28
  const packageManager = usePackageManager();
29
29
 
30
+ /**
31
+ * For PNPM (non-forceNpm) the isolated output is itself a pnpm workspace with
32
+ * its `pnpm-workspace.yaml` catalog definitions preserved, so "catalog:"
33
+ * specifiers are kept verbatim to stay in sync with the lockfile importers
34
+ * (see issue #198). For other package managers the catalog is not available
35
+ * in the output, so we resolve the specifiers to versions.
36
+ */
37
+ const isPnpmWorkspaceOutput = packageManager.name === "pnpm" && !forceNpm;
38
+
30
39
  await Promise.all(
31
40
  internalPackageNames.map(async (packageName) => {
32
41
  const { manifest, rootRelativeDir } = got(packagesRegistry, packageName);
@@ -45,14 +54,15 @@ export async function adaptInternalPackageManifests({
45
54
  strippedManifest.scripts = omit(strippedManifest.scripts, ["prepare"]);
46
55
  }
47
56
 
48
- /** Resolve catalog dependencies before adapting internal deps */
49
- const manifestWithResolvedCatalogs = {
50
- ...strippedManifest,
51
- dependencies: await resolveCatalogDependencies(
52
- strippedManifest.dependencies,
53
- workspaceRootDir,
54
- ),
55
- };
57
+ const preparedManifest = isPnpmWorkspaceOutput
58
+ ? strippedManifest
59
+ : {
60
+ ...strippedManifest,
61
+ dependencies: await resolveCatalogDependencies(
62
+ strippedManifest.dependencies,
63
+ workspaceRootDir,
64
+ ),
65
+ };
56
66
 
57
67
  const outputManifest =
58
68
  (packageManager.name === "pnpm" || packageManager.name === "bun") &&
@@ -61,10 +71,10 @@ export async function adaptInternalPackageManifests({
61
71
  * For PNPM and Bun the output itself is a workspace so we can preserve
62
72
  * the specifiers with "workspace:*" in the output manifest.
63
73
  */
64
- manifestWithResolvedCatalogs
74
+ preparedManifest
65
75
  : /** For other package managers we replace the links to internal dependencies */
66
76
  adaptManifestInternalDeps({
67
- manifest: manifestWithResolvedCatalogs,
77
+ manifest: preparedManifest,
68
78
  packagesRegistry,
69
79
  parentRootRelativeDir: rootRelativeDir,
70
80
  });