isolate-package 1.33.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.
Files changed (68) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/isolate-bin.mjs +5 -6
  4. package/dist/isolate-bin.mjs.map +1 -1
  5. package/dist/{isolate-DyRD5Zd_.mjs → isolate-ts-Igq7C.mjs} +888 -271
  6. package/dist/isolate-ts-Igq7C.mjs.map +1 -0
  7. package/package.json +23 -19
  8. package/src/get-internal-package-names.test.ts +1 -1
  9. package/src/get-internal-package-names.ts +2 -2
  10. package/src/isolate-bin.ts +5 -5
  11. package/src/isolate.ts +22 -17
  12. package/src/lib/config.test.ts +1 -1
  13. package/src/lib/config.ts +3 -3
  14. package/src/lib/lockfile/helpers/bun-lockfile.ts +153 -0
  15. package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
  16. package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +14 -146
  17. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +1 -5
  18. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +311 -16
  19. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +193 -22
  20. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +83 -2
  21. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +33 -6
  22. package/src/lib/lockfile/helpers/generate-yarn-lockfile.ts +5 -5
  23. package/src/lib/lockfile/process-lockfile.test.ts +2 -2
  24. package/src/lib/manifest/adapt-target-package-manifest.ts +22 -13
  25. package/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +72 -3
  26. package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +22 -12
  27. package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
  28. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
  29. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
  30. package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
  31. package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
  32. package/src/lib/manifest/io.ts +6 -2
  33. package/src/lib/manifest/validate-manifest.ts +2 -2
  34. package/src/lib/output/get-build-output-dir.ts +1 -1
  35. package/src/lib/output/pack-dependencies.ts +1 -1
  36. package/src/lib/output/process-build-output-files.ts +6 -17
  37. package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
  38. package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
  39. package/src/lib/package-manager/index.ts +1 -1
  40. package/src/lib/package-manager/names.ts +8 -10
  41. package/src/lib/patches/collect-installed-names-bun.test.ts +154 -0
  42. package/src/lib/patches/collect-installed-names-bun.ts +87 -0
  43. package/src/lib/patches/collect-installed-names-pnpm.test.ts +316 -0
  44. package/src/lib/patches/collect-installed-names-pnpm.ts +365 -0
  45. package/src/lib/patches/copy-patches.test.ts +130 -13
  46. package/src/lib/patches/copy-patches.ts +47 -10
  47. package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
  48. package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
  49. package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
  50. package/src/lib/registry/create-packages-registry.ts +34 -31
  51. package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
  52. package/src/lib/registry/list-internal-packages.test.ts +2 -2
  53. package/src/lib/types.ts +2 -2
  54. package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
  55. package/src/lib/utils/filter-patched-dependencies.ts +2 -2
  56. package/src/lib/utils/get-dirname.ts +1 -1
  57. package/src/lib/utils/index.ts +1 -1
  58. package/src/lib/utils/json.ts +12 -14
  59. package/src/lib/utils/pack.ts +32 -22
  60. package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
  61. package/src/lib/utils/reset-isolate-dir.ts +147 -0
  62. package/src/lib/utils/unpack.test.ts +76 -0
  63. package/src/lib/utils/unpack.ts +16 -10
  64. package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
  65. package/src/lib/utils/wait-for-complete-file.ts +44 -0
  66. package/src/lib/utils/yaml.ts +8 -9
  67. package/src/testing/setup.ts +1 -1
  68. package/dist/isolate-DyRD5Zd_.mjs.map +0 -1
@@ -5,14 +5,15 @@ import path, { join } from "node:path";
5
5
  import { isEmpty, omit, pick, unique } from "remeda";
6
6
  import { exec, execFileSync, execSync } from "node:child_process";
7
7
  import { detectMonorepo } from "detect-monorepo";
8
- import { pathToFileURL } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { createConsola } from "consola";
10
- import { fileURLToPath } from "url";
11
10
  import { inspect } from "node:util";
12
- import fs$1 from "node:fs";
11
+ import fs$1, { createReadStream } from "node:fs";
13
12
  import stripJsonComments from "strip-json-comments";
14
- import tar from "tar-fs";
15
- import { createGunzip } from "zlib";
13
+ import { randomBytes } from "node:crypto";
14
+ import { pipeline } from "node:stream/promises";
15
+ import { createGunzip } from "node:zlib";
16
+ import { extract } from "tar-fs";
16
17
  import yaml from "yaml";
17
18
  import Arborist from "@npmcli/arborist";
18
19
  import Config from "@npmcli/config";
@@ -21,7 +22,6 @@ import { getLockfileImporterId, readWantedLockfile, writeWantedLockfile } from "
21
22
  import { getLockfileImporterId as getLockfileImporterId$1, readWantedLockfile as readWantedLockfile$1, writeWantedLockfile as writeWantedLockfile$1 } from "pnpm_lockfile_file_v9";
22
23
  import { pruneLockfile } from "pnpm_prune_lockfile_v8";
23
24
  import { pruneLockfile as pruneLockfile$1 } from "pnpm_prune_lockfile_v9";
24
- import path$1 from "path";
25
25
  import { getTsconfig } from "get-tsconfig";
26
26
  import outdent$1 from "outdent";
27
27
  import { globSync } from "glob";
@@ -178,16 +178,16 @@ function readTypedJsonSync(filePath) {
178
178
  try {
179
179
  const rawContent = fs.readFileSync(filePath, "utf-8");
180
180
  return JSON.parse(stripJsonComments(rawContent, { trailingCommas: true }));
181
- } catch (err) {
182
- throw new Error(`Failed to read JSON from ${filePath}: ${getErrorMessage(err)}`, { cause: err });
181
+ } catch (error) {
182
+ throw new Error(`Failed to read JSON from ${filePath}: ${getErrorMessage(error)}`, { cause: error });
183
183
  }
184
184
  }
185
185
  async function readTypedJson(filePath) {
186
186
  try {
187
187
  const rawContent = await fs.readFile(filePath, "utf-8");
188
188
  return JSON.parse(stripJsonComments(rawContent, { trailingCommas: true }));
189
- } catch (err) {
190
- throw new Error(`Failed to read JSON from ${filePath}: ${getErrorMessage(err)}`, { cause: err });
189
+ } catch (error) {
190
+ throw new Error(`Failed to read JSON from ${filePath}: ${getErrorMessage(error)}`, { cause: error });
191
191
  }
192
192
  }
193
193
  //#endregion
@@ -199,162 +199,128 @@ function getIsolateRelativeLogPath(path, isolatePath) {
199
199
  return join("(isolate)", path.replace(isolatePath, ""));
200
200
  }
201
201
  //#endregion
202
- //#region src/lib/utils/get-major-version.ts
203
- function getMajorVersion(version) {
204
- return parseInt(version.split(".").at(0) ?? "0", 10);
205
- }
206
- //#endregion
207
- //#region src/lib/package-manager/names.ts
208
- const supportedPackageManagerNames = [
209
- "pnpm",
210
- "yarn",
211
- "npm",
212
- "bun"
213
- ];
214
- function getLockfileFileName(name) {
215
- switch (name) {
216
- case "bun": return "bun.lock";
217
- case "pnpm": return "pnpm-lock.yaml";
218
- case "yarn": return "yarn.lock";
219
- case "npm": return "package-lock.json";
220
- }
221
- }
222
- //#endregion
223
- //#region src/lib/package-manager/helpers/infer-from-files.ts
224
- function inferFromFiles(workspaceRoot) {
225
- for (const name of supportedPackageManagerNames) {
226
- const lockfileName = getLockfileFileName(name);
227
- if (fs.existsSync(path.join(workspaceRoot, lockfileName))) try {
228
- const version = getVersion(name);
229
- return {
230
- name,
231
- version,
232
- majorVersion: getMajorVersion(version)
233
- };
234
- } catch (err) {
235
- throw new Error(`Failed to find package manager version for ${name}: ${getErrorMessage(err)}`, { cause: err });
236
- }
237
- }
238
- /** If no lockfile was found, it could be that there is an npm shrinkwrap file. */
239
- if (fs.existsSync(path.join(workspaceRoot, "npm-shrinkwrap.json"))) {
240
- const version = getVersion("npm");
241
- return {
242
- name: "npm",
243
- version,
244
- majorVersion: getMajorVersion(version)
245
- };
246
- }
247
- throw new Error(`Failed to detect package manager`);
248
- }
249
- function getVersion(packageManagerName) {
250
- return execSync(`${packageManagerName} --version`).toString().trim();
251
- }
252
- //#endregion
253
- //#region src/lib/package-manager/helpers/infer-from-manifest.ts
254
- function inferFromManifest(workspaceRoot) {
202
+ //#region src/lib/utils/reset-isolate-dir.ts
203
+ /**
204
+ * Prefix used for trash directories created when resetting the isolate output
205
+ * directory. The leading dot keeps it hidden in file explorers and `ls`
206
+ * without `-a`, so users don't see a flash of two folders while the old
207
+ * contents are being reaped in the background.
208
+ */
209
+ const TRASH_PREFIX = ".";
210
+ const TRASH_INFIX = ".trash-";
211
+ /**
212
+ * Reset the isolate output directory to a fresh empty directory, avoiding the
213
+ * `ENOTEMPTY` race that occurs when another process (e.g. the Firebase
214
+ * functions emulator, a file watcher) writes into the directory while it is
215
+ * being recursively deleted.
216
+ *
217
+ * Strategy:
218
+ *
219
+ * 1. Sweep any leftover trash directories from previous runs that may have
220
+ * been killed mid-cleanup. Best-effort: ignore errors.
221
+ * 2. If the isolate directory exists, atomically `rename` it to a hidden
222
+ * sibling on the same filesystem. The rename is atomic, so the moment it
223
+ * returns the original path is free for a fresh empty directory and
224
+ * nothing a concurrent writer does inside the old tree can affect the
225
+ * new one.
226
+ * 3. Kick off the recursive delete of the trash directory in the background.
227
+ * We don't await it: it is the slowest part of an isolate run, and any
228
+ * failure (e.g. another process still holding files open) is harmless
229
+ * because the logical state is already correct. Stale trash dirs are
230
+ * reaped by the next run's sweep in step 1.
231
+ * 4. Ensure the (now-vacant) isolate directory exists.
232
+ */
233
+ async function resetIsolateDir(isolateDir, options = {}) {
255
234
  const log = useLogger();
256
- const { packageManager: packageManagerString } = readTypedJsonSync(path.join(workspaceRoot, "package.json"));
257
- if (!packageManagerString) {
258
- log.debug("No packageManager field found in root manifest");
259
- return;
235
+ const trashParentDir = options.trashParentDir ?? path.dirname(isolateDir);
236
+ const trashGlobPrefix = `${TRASH_PREFIX}${buildTrashStem(trashParentDir, isolateDir)}${TRASH_INFIX}`;
237
+ /** Best-effort sweep of leftover trash from previously killed runs. */
238
+ await sweepStaleTrash(trashParentDir, trashGlobPrefix);
239
+ if (fs.existsSync(isolateDir)) {
240
+ const trashDir = path.join(trashParentDir, `${trashGlobPrefix}${process.pid}-${randomBytes(4).toString("hex")}`);
241
+ try {
242
+ await fs.ensureDir(trashParentDir);
243
+ await fs.rename(isolateDir, trashDir);
244
+ log.debug("Moved existing isolate output directory to trash for cleanup");
245
+ /**
246
+ * Fire-and-forget. A concurrent writer can cause `ENOTEMPTY` or
247
+ * `EBUSY` here, but the logical state is already correct: the real
248
+ * `isolateDir` is gone. Any debris left behind will be swept on the
249
+ * next run.
250
+ */
251
+ fs.remove(trashDir).catch((error) => {
252
+ log.debug("Background cleanup of trashed isolate directory did not complete:", error instanceof Error ? error.message : String(error));
253
+ });
254
+ } catch (error) {
255
+ /**
256
+ * `rename` can fail with `EXDEV` if `trashParentDir` ends up on a
257
+ * different filesystem from `isolateDir`, or with `EPERM` on platforms
258
+ * that disallow renaming busy directories. Fall back to the original
259
+ * behaviour: a straight recursive delete. This preserves correctness
260
+ * at the cost of the race the rename was meant to avoid.
261
+ */
262
+ log.debug("Could not rename existing isolate output directory, falling back to recursive delete:", error instanceof Error ? error.message : String(error));
263
+ await fs.remove(isolateDir);
264
+ }
260
265
  }
261
- const [name, version = "*"] = packageManagerString.split("@");
262
- assert(supportedPackageManagerNames.includes(name), `Package manager "${name}" is not currently supported`);
263
- const lockfileName = getLockfileFileName(name);
264
- assert(fs.existsSync(path.join(workspaceRoot, lockfileName)), `Manifest declares ${name} to be the packageManager, but failed to find ${lockfileName} in workspace root`);
265
- return {
266
- name,
267
- version,
268
- majorVersion: getMajorVersion(version),
269
- packageManagerString
270
- };
271
- }
272
- //#endregion
273
- //#region src/lib/package-manager/index.ts
274
- let packageManager;
275
- function usePackageManager() {
276
- if (!packageManager) throw Error("No package manager detected. Make sure to call detectPackageManager() before usePackageManager()");
277
- return packageManager;
266
+ await fs.ensureDir(isolateDir);
278
267
  }
279
268
  /**
280
- * First we check if the package manager is declared in the manifest. If it is,
281
- * we get the name and version from there. Otherwise we'll search for the
282
- * different lockfiles and ask the OS to report the installed version.
269
+ * Build a stable name stem for the trash directory. When `trashParentDir` is
270
+ * the direct parent of `isolateDir` (the default), this is just the isolate
271
+ * dir's basename. When it isn't (e.g. callers placing trash outside the
272
+ * packed dir), include the relative path so multiple isolate dirs sharing
273
+ * the same trash parent don't collide in the sweep filter.
283
274
  */
284
- function detectPackageManager(workspaceRootDir) {
285
- if (isRushWorkspace(workspaceRootDir)) packageManager = inferFromFiles(path.join(workspaceRootDir, "common/config/rush"));
286
- else
287
- /**
288
- * Disable infer from manifest for now. I doubt it is useful after all but
289
- * I'll keep the code as a reminder.
290
- */
291
- packageManager = inferFromManifest(workspaceRootDir) ?? inferFromFiles(workspaceRootDir);
292
- return packageManager;
275
+ function buildTrashStem(trashParentDir, isolateDir) {
276
+ const relative = path.relative(trashParentDir, isolateDir);
277
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return path.basename(isolateDir);
278
+ return relative.split(path.sep).filter(Boolean).join("-");
293
279
  }
294
- function shouldUsePnpmPack() {
295
- const { name, majorVersion } = usePackageManager();
296
- return name === "pnpm" && majorVersion >= 8;
297
- }
298
- //#endregion
299
- //#region src/lib/utils/pack.ts
300
- async function pack(srcDir, dstDir) {
301
- const log = useLogger();
302
- const execOptions = { maxBuffer: 10 * 1024 * 1024 };
303
- const previousCwd = process.cwd();
304
- process.chdir(srcDir);
305
- /**
306
- * PNPM pack seems to be a lot faster than NPM pack, so when PNPM is detected
307
- * we use that instead.
308
- */
309
- const stdout = shouldUsePnpmPack() ? await new Promise((resolve, reject) => {
310
- exec(`pnpm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
311
- if (err) {
312
- log.error(getErrorMessage(err));
313
- return reject(err);
314
- }
315
- resolve(stdout);
316
- });
317
- }) : await new Promise((resolve, reject) => {
318
- exec(`npm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
319
- if (err) return reject(err);
320
- resolve(stdout);
280
+ /**
281
+ * Best-effort sweep of leftover trash directories matching this isolate dir's
282
+ * stem. Failures are swallowed: stale trash is debris, not state, and a
283
+ * subsequent run will get another chance to reap it.
284
+ */
285
+ async function sweepStaleTrash(parentDir, trashGlobPrefix) {
286
+ let entries;
287
+ try {
288
+ entries = await fs.readdir(parentDir);
289
+ } catch {
290
+ /** Parent doesn't exist yet; nothing to sweep. */
291
+ return;
292
+ }
293
+ await Promise.all(entries.filter((entry) => entry.startsWith(trashGlobPrefix)).map(async (entry) => {
294
+ await fs.remove(path.join(parentDir, entry)).catch(() => {
295
+ /** Best-effort. */
321
296
  });
322
- });
323
- const lastLine = stdout.trim().split("\n").at(-1);
324
- assert(lastLine, `Failed to parse last line from stdout: ${stdout.trim()}`);
325
- const fileName = path.basename(lastLine);
326
- assert(fileName, `Failed to parse file name from: ${lastLine}`);
327
- const filePath = path.join(dstDir, fileName);
328
- if (!fs$1.existsSync(filePath)) log.error(`The response from pack could not be resolved to an existing file: ${filePath}`);
329
- else log.debug(`Packed (temp)/${fileName}`);
330
- process.chdir(previousCwd);
331
- /**
332
- * Return the path anyway even if it doesn't validate. A later stage will wait
333
- * for the file to occur still. Not sure if this makes sense. Maybe we should
334
- * stop at the validation error...
335
- */
336
- return filePath;
297
+ }));
337
298
  }
338
299
  //#endregion
339
300
  //#region src/lib/utils/unpack.ts
301
+ /**
302
+ * Extract a gzipped tar archive into the given directory.
303
+ *
304
+ * Uses `stream/promises.pipeline` so that errors at any stage (file read,
305
+ * gunzip, tar extract) propagate as a rejected promise rather than crashing
306
+ * the process as unhandled stream errors.
307
+ */
340
308
  async function unpack(filePath, unpackDir) {
341
- await new Promise((resolve, reject) => {
342
- fs.createReadStream(filePath).pipe(createGunzip()).pipe(tar.extract(unpackDir)).on("finish", () => resolve()).on("error", (err) => reject(err));
343
- });
309
+ await pipeline(createReadStream(filePath), createGunzip(), extract(unpackDir));
344
310
  }
345
311
  //#endregion
346
312
  //#region src/lib/utils/yaml.ts
313
+ /** @todo Add some zod validation maybe */
347
314
  function readTypedYamlSync(filePath) {
348
315
  try {
349
316
  const rawContent = fs.readFileSync(filePath, "utf-8");
350
- /** @todo Add some zod validation maybe */
351
317
  return yaml.parse(rawContent);
352
- } catch (err) {
353
- throw new Error(`Failed to read YAML from ${filePath}: ${getErrorMessage(err)}`, { cause: err });
318
+ } catch (error) {
319
+ throw new Error(`Failed to read YAML from ${filePath}: ${getErrorMessage(error)}`, { cause: error });
354
320
  }
355
321
  }
322
+ /** @todo Add some zod validation maybe */
356
323
  function writeTypedYamlSync(filePath, content) {
357
- /** @todo Add some zod validation maybe */
358
324
  fs.writeFileSync(filePath, yaml.stringify(content), "utf-8");
359
325
  }
360
326
  //#endregion
@@ -486,30 +452,106 @@ function resolveConfig(initialConfig) {
486
452
  return config;
487
453
  }
488
454
  //#endregion
489
- //#region src/lib/lockfile/helpers/generate-bun-lockfile.ts
455
+ //#region src/lib/utils/get-major-version.ts
456
+ function getMajorVersion(version) {
457
+ return parseInt(version.split(".").at(0) ?? "0", 10);
458
+ }
459
+ //#endregion
460
+ //#region src/lib/package-manager/names.ts
461
+ const supportedPackageManagerNames = [
462
+ "pnpm",
463
+ "yarn",
464
+ "npm",
465
+ "bun"
466
+ ];
467
+ const lockfileFileNamesByPackageManager = {
468
+ bun: "bun.lock",
469
+ pnpm: "pnpm-lock.yaml",
470
+ yarn: "yarn.lock",
471
+ npm: "package-lock.json"
472
+ };
473
+ function getLockfileFileName(name) {
474
+ return lockfileFileNamesByPackageManager[name];
475
+ }
476
+ //#endregion
477
+ //#region src/lib/package-manager/helpers/infer-from-files.ts
478
+ function inferFromFiles(workspaceRoot) {
479
+ for (const name of supportedPackageManagerNames) {
480
+ const lockfileName = getLockfileFileName(name);
481
+ if (fs.existsSync(path.join(workspaceRoot, lockfileName))) try {
482
+ const version = getVersion(name);
483
+ return {
484
+ name,
485
+ version,
486
+ majorVersion: getMajorVersion(version)
487
+ };
488
+ } catch (error) {
489
+ throw new Error(`Failed to find package manager version for ${name}: ${getErrorMessage(error)}`, { cause: error });
490
+ }
491
+ }
492
+ /** If no lockfile was found, it could be that there is an npm shrinkwrap file. */
493
+ if (fs.existsSync(path.join(workspaceRoot, "npm-shrinkwrap.json"))) {
494
+ const version = getVersion("npm");
495
+ return {
496
+ name: "npm",
497
+ version,
498
+ majorVersion: getMajorVersion(version)
499
+ };
500
+ }
501
+ throw new Error(`Failed to detect package manager`);
502
+ }
503
+ function getVersion(packageManagerName) {
504
+ return execSync(`${packageManagerName} --version`).toString().trim();
505
+ }
506
+ //#endregion
507
+ //#region src/lib/package-manager/helpers/infer-from-manifest.ts
508
+ function inferFromManifest(workspaceRoot) {
509
+ const log = useLogger();
510
+ const { packageManager: packageManagerString } = readTypedJsonSync(path.join(workspaceRoot, "package.json"));
511
+ if (!packageManagerString) {
512
+ log.debug("No packageManager field found in root manifest");
513
+ return null;
514
+ }
515
+ const [name, version = "*"] = packageManagerString.split("@");
516
+ assert(supportedPackageManagerNames.includes(name), `Package manager "${name}" is not currently supported`);
517
+ const lockfileName = getLockfileFileName(name);
518
+ assert(fs.existsSync(path.join(workspaceRoot, lockfileName)), `Manifest declares ${name} to be the packageManager, but failed to find ${lockfileName} in workspace root`);
519
+ return {
520
+ name,
521
+ version,
522
+ majorVersion: getMajorVersion(version),
523
+ packageManagerString
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region src/lib/package-manager/index.ts
528
+ let packageManager;
529
+ function usePackageManager() {
530
+ if (!packageManager) throw new Error("No package manager detected. Make sure to call detectPackageManager() before usePackageManager()");
531
+ return packageManager;
532
+ }
490
533
  /**
491
- * Serialize a value to JSON with trailing commas after every array element and
492
- * object property, matching Bun's native bun.lock output format.
534
+ * First we check if the package manager is declared in the manifest. If it is,
535
+ * we get the name and version from there. Otherwise we'll search for the
536
+ * different lockfiles and ask the OS to report the installed version.
493
537
  */
494
- function serializeWithTrailingCommas(value, indent = 2) {
495
- /**
496
- * Add trailing commas after values that precede a closing bracket/brace.
497
- * Apply repeatedly because consecutive closing brackets (e.g. ]\n}) need
498
- * multiple passes the first pass adds a comma after the inner value, and
499
- * subsequent passes handle the outer brackets.
538
+ function detectPackageManager(workspaceRootDir) {
539
+ if (isRushWorkspace(workspaceRootDir)) packageManager = inferFromFiles(path.join(workspaceRootDir, "common/config/rush"));
540
+ else
541
+ /**
542
+ * Disable infer from manifest for now. I doubt it is useful after all but
543
+ * I'll keep the code as a reminder.
500
544
  */
501
- let result = JSON.stringify(value, null, indent);
502
- let previous;
503
- do {
504
- previous = result;
505
- result = result.replace(/(["\d\w\]}-])\n(\s*[\]}])/g, "$1,\n$2");
506
- } while (result !== previous);
507
- return result;
545
+ packageManager = inferFromManifest(workspaceRootDir) ?? inferFromFiles(workspaceRootDir);
546
+ return packageManager;
508
547
  }
509
- /**
510
- * Extract dependency names from a workspace entry, optionally including
511
- * devDependencies.
512
- */
548
+ function shouldUsePnpmPack() {
549
+ const { name, majorVersion } = usePackageManager();
550
+ return name === "pnpm" && majorVersion >= 8;
551
+ }
552
+ //#endregion
553
+ //#region src/lib/lockfile/helpers/bun-lockfile.ts
554
+ /** Extract dependency names from a workspace entry. */
513
555
  function collectDependencyNames(entry, includeDevDependencies) {
514
556
  const names = /* @__PURE__ */ new Set();
515
557
  for (const name of Object.keys(entry.dependencies ?? {})) names.add(name);
@@ -519,8 +561,8 @@ function collectDependencyNames(entry, includeDevDependencies) {
519
561
  return [...names];
520
562
  }
521
563
  /**
522
- * Check whether a package entry represents a workspace package by examining its
523
- * identifier string (first element in the entry array).
564
+ * Check whether a package entry represents a workspace package by examining
565
+ * its identifier string (first element in the entry array).
524
566
  */
525
567
  function isWorkspacePackageEntry(entry) {
526
568
  const ident = entry[0];
@@ -532,16 +574,16 @@ function isWorkspacePackageEntry(entry) {
532
574
  * - workspace packages: [ident, info] -> index 1
533
575
  * - git/github packages: [ident, info, checksum] -> index 1
534
576
  *
535
- * Detection: if the second element is a string (registry URL or checksum), the
536
- * info object is deeper. Workspace entries have only 2 elements.
577
+ * Detection: if the second element is a string (registry URL or checksum),
578
+ * the info object is deeper. Workspace entries have only 2 elements.
537
579
  */
538
580
  function getPackageInfoObject(entry) {
539
581
  if (entry.length <= 1) return void 0;
540
582
  /** Workspace entries: [ident, info] */
541
583
  if (isWorkspacePackageEntry(entry)) return typeof entry[1] === "object" ? entry[1] : void 0;
542
584
  /**
543
- * npm entries with registry URL: [ident, registryUrl, info, checksum].
544
- * The second element is a string (the registry URL).
585
+ * npm entries with registry URL: [ident, registryUrl, info, checksum]. The
586
+ * second element is a string (the registry URL).
545
587
  */
546
588
  if (typeof entry[1] === "string") return typeof entry[2] === "object" ? entry[2] : void 0;
547
589
  /** git/tarball entries: [ident, info, checksum] */
@@ -549,14 +591,14 @@ function getPackageInfoObject(entry) {
549
591
  }
550
592
  /**
551
593
  * Recursively collect all package keys that are required, starting from a set
552
- * of direct dependency names and walking through their transitive dependencies
553
- * in the packages section.
594
+ * of direct dependency names and walking through their transitive
595
+ * dependencies in the packages section.
554
596
  */
555
597
  function collectRequiredPackages(directDependencyNames, packages) {
556
598
  const required = /* @__PURE__ */ new Set();
557
599
  const queue = [...directDependencyNames];
558
- while (queue.length > 0) {
559
- const name = queue.pop();
600
+ let name;
601
+ while ((name = queue.pop()) !== void 0) {
560
602
  if (required.has(name)) continue;
561
603
  const entry = packages[name];
562
604
  if (!entry) continue;
@@ -568,15 +610,40 @@ function collectRequiredPackages(directDependencyNames, packages) {
568
610
  "dependencies",
569
611
  "optionalDependencies",
570
612
  "peerDependencies"
571
- ]) {
572
- const deps = info[depField];
573
- if (deps && typeof deps === "object") {
574
- for (const depName of Object.keys(deps)) if (!required.has(depName)) queue.push(depName);
575
- }
576
- }
613
+ ]) enqueueDeps(info[depField], required, queue);
577
614
  }
578
615
  return required;
579
616
  }
617
+ /**
618
+ * Push any names from a dependency map onto the BFS queue, skipping anything
619
+ * already marked required so we don't revisit it. `deps` is typed as `unknown`
620
+ * because it comes from a freshly-parsed lockfile entry with no schema.
621
+ */
622
+ function enqueueDeps(deps, required, queue) {
623
+ if (!deps || typeof deps !== "object") return;
624
+ for (const depName of Object.keys(deps)) if (!required.has(depName)) queue.push(depName);
625
+ }
626
+ //#endregion
627
+ //#region src/lib/lockfile/helpers/generate-bun-lockfile.ts
628
+ /**
629
+ * Serialize a value to JSON with trailing commas after every array element and
630
+ * object property, matching Bun's native bun.lock output format.
631
+ */
632
+ function serializeWithTrailingCommas(value, indent = 2) {
633
+ /**
634
+ * Add trailing commas after values that precede a closing bracket/brace.
635
+ * Apply repeatedly because consecutive closing brackets (e.g. ]\n}) need
636
+ * multiple passes — the first pass adds a comma after the inner value, and
637
+ * subsequent passes handle the outer brackets.
638
+ */
639
+ let result = JSON.stringify(value, null, indent);
640
+ let previous;
641
+ do {
642
+ previous = result;
643
+ result = result.replace(/(["\d\w\]}-])\n(\s*[\]}])/g, "$1,\n$2");
644
+ } while (result !== previous);
645
+ return result;
646
+ }
580
647
  async function generateBunLockfile({ workspaceRootDir, targetPackageDir, isolateDir, internalDepPackageNames, packagesRegistry, includeDevDependencies }) {
581
648
  const log = useLogger();
582
649
  log.debug("Generating Bun lockfile...");
@@ -660,9 +727,9 @@ async function generateBunLockfile({ workspaceRootDir, targetPackageDir, isolate
660
727
  /** Append trailing newline to match Bun's native output format */
661
728
  await fs.writeFile(outputPath, serializeWithTrailingCommas(outputLockfile) + "\n");
662
729
  log.debug("Created lockfile at", outputPath);
663
- } catch (err) {
664
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
665
- throw err;
730
+ } catch (error) {
731
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
732
+ throw error;
666
733
  }
667
734
  }
668
735
  //#endregion
@@ -710,9 +777,9 @@ async function generateNpmLockfile({ workspaceRootDir, isolateDir, targetPackage
710
777
  });
711
778
  }
712
779
  log.debug("Created lockfile at", path.join(isolateDir, "package-lock.json"));
713
- } catch (err) {
714
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
715
- throw err;
780
+ } catch (error) {
781
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
782
+ throw error;
716
783
  }
717
784
  }
718
785
  async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
@@ -728,7 +795,7 @@ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPa
728
795
  const rootTree = await arborist.loadVirtual();
729
796
  const targetImporterNode = arborist.workspaceNodes(rootTree, [targetPackageName])[0];
730
797
  if (!targetImporterNode) throw new Error(`Target workspace "${targetPackageName}" not found in root package-lock.json`);
731
- if (typeof targetImporterNode.location !== "string") throw new Error(`Target workspace "${targetPackageName}" resolved to a node without a location`);
798
+ if (typeof targetImporterNode.location !== "string") throw new TypeError(`Target workspace "${targetPackageName}" resolved to a node without a location`);
732
799
  /**
733
800
  * `workspaceDependencySet` walks `edgesOut` from each seed node. It does
734
801
  * not add the seed node itself to the result, so ensure the target
@@ -767,6 +834,12 @@ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPa
767
834
  srcData,
768
835
  reachable,
769
836
  targetImporterLoc: targetImporterNode.location,
837
+ /**
838
+ * npm's lockfile exposes each workspace as a Link at
839
+ * `node_modules/<name>`. This link is pointless in the isolate (the
840
+ * target becomes the root), so filter it out if it shows up in the
841
+ * reachable set.
842
+ */
770
843
  targetLinkLoc: `node_modules/${targetPackageName}`,
771
844
  targetPackageManifest
772
845
  });
@@ -796,10 +869,16 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
796
869
  const srcPackages = srcData.packages;
797
870
  if (!srcPackages[targetImporterLoc]) throw new Error(`Source lockfile has no entry for target importer "${targetImporterLoc}"`);
798
871
  const targetNestedNodeModulesPrefix = `${targetImporterLoc}/node_modules/`;
799
- /** Track the source location each output entry came from, so we can
800
- * produce a clear error if two source paths remap to the same target.
801
- */
872
+ const remapToOutputLoc = (origLoc) => {
873
+ if (origLoc === targetImporterLoc) return "";
874
+ if (origLoc.startsWith(targetNestedNodeModulesPrefix)) return origLoc.slice(targetImporterLoc.length + 1);
875
+ return origLoc;
876
+ };
877
+ const isTargetNested = (origLoc) => origLoc === targetImporterLoc || origLoc.startsWith(targetNestedNodeModulesPrefix);
878
+ /** Track the source location each output entry came from, used to identify
879
+ * the displaced entry when a collision occurs. */
802
880
  const origLocByNewLoc = /* @__PURE__ */ new Map();
881
+ const collisions = [];
803
882
  for (const node of reachable) {
804
883
  const origLoc = node.location;
805
884
  /** The target's self-link has no place in the isolate (root IS the target). */
@@ -815,20 +894,79 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
815
894
  * `packages/app/lib/core`) are preserved verbatim because their disk
816
895
  * location in the isolate is unchanged.
817
896
  */
818
- let newLoc;
819
- if (origLoc === targetImporterLoc) newLoc = "";
820
- else if (origLoc.startsWith(targetNestedNodeModulesPrefix)) newLoc = origLoc.slice(targetImporterLoc.length + 1);
821
- else newLoc = origLoc;
897
+ const newLoc = remapToOutputLoc(origLoc);
822
898
  const srcEntry = srcPackages[origLoc];
823
899
  if (!srcEntry) throw new Error(`Reachable node "${origLoc}" has no entry in source lockfile packages`);
824
900
  const existing = outPackages[newLoc];
825
901
  if (existing && !entriesAreEquivalent(existing, srcEntry)) {
826
902
  const previousOrigLoc = origLocByNewLoc.get(newLoc) ?? "<unknown>";
827
- throw new Error(`Path collision at "${newLoc}": source locations "${previousOrigLoc}" and "${origLoc}" both map there with conflicting entries. This happens when the target pins a nested version override that collides with a hoisted version still needed by another reachable dependency. Please report a reproduction at https://github.com/0x80/isolate-package/issues.`);
903
+ const incomingIsTargetNested = isTargetNested(origLoc);
904
+ const previousIsTargetNested = isTargetNested(previousOrigLoc);
905
+ if (incomingIsTargetNested && !previousIsTargetNested) {
906
+ /** The target-nested entry wins. The previously-stored hoisted entry
907
+ * is displaced and must be re-nested under its consumers. */
908
+ collisions.push({
909
+ loserOrigLoc: previousOrigLoc,
910
+ loserEntry: existing
911
+ });
912
+ outPackages[newLoc] = { ...srcEntry };
913
+ origLocByNewLoc.set(newLoc, origLoc);
914
+ continue;
915
+ }
916
+ if (!incomingIsTargetNested && previousIsTargetNested) {
917
+ /** The previously-stored target-nested entry wins; the incoming
918
+ * hoisted entry is the loser. */
919
+ collisions.push({
920
+ loserOrigLoc: origLoc,
921
+ loserEntry: { ...srcEntry }
922
+ });
923
+ continue;
924
+ }
925
+ /** Neither side is the target's nested version, or both are — we have
926
+ * no rule to pick a winner. Bail loudly. */
927
+ throw new Error(`Path collision at "${newLoc}": source locations "${previousOrigLoc}" and "${origLoc}" both map there with conflicting entries and no rule applies to pick a winner (neither is a target-nested override, or both are). Please report a reproduction at https://github.com/0x80/isolate-package/issues.`);
828
928
  }
829
929
  outPackages[newLoc] = { ...srcEntry };
830
930
  origLocByNewLoc.set(newLoc, origLoc);
831
931
  }
932
+ /** Re-nest each displaced entry under the reachable consumers that
933
+ * originally resolved to it via node_modules walk-up. */
934
+ for (const collision of collisions) {
935
+ const loserName = extractPackageNameFromLockfileLoc(collision.loserOrigLoc);
936
+ if (!loserName) continue;
937
+ for (const consumer of reachable) {
938
+ const consumerSrcLoc = consumer.location;
939
+ if (consumerSrcLoc === collision.loserOrigLoc) continue;
940
+ if (consumerSrcLoc === targetLinkLoc) continue;
941
+ const consumerEntry = srcPackages[consumerSrcLoc];
942
+ if (!consumerEntry) continue;
943
+ /** Workspace links carry dependency metadata on the importer entry,
944
+ * not the link entry itself. Skip the link side. */
945
+ if (consumerEntry.link) continue;
946
+ if (!entryDependsOn(consumerEntry, loserName)) continue;
947
+ if (resolveDepInSrcLockfile(consumerSrcLoc, loserName, srcPackages) !== collision.loserOrigLoc) continue;
948
+ const consumerNewLoc = remapToOutputLoc(consumerSrcLoc);
949
+ /** Consumer maps to the isolate root (the target itself). The root
950
+ * slot is already taken by the winning version. The target's own
951
+ * dependencies use that version — we cannot serve a different one
952
+ * here without nesting under the target, which would be its own
953
+ * collision. Accept the resolution shift. */
954
+ if (consumerNewLoc === "") continue;
955
+ /** If the consumer was itself displaced by another collision (its
956
+ * src-side entry doesn't match the entry we actually placed at its
957
+ * new location), the consumer isn't really present in the isolate.
958
+ * Its original dep needs are irrelevant here. */
959
+ const consumerOutEntry = outPackages[consumerNewLoc];
960
+ if (!consumerOutEntry || !entriesAreEquivalent(consumerOutEntry, consumerEntry)) continue;
961
+ const nestedLoc = `${consumerNewLoc}/node_modules/${loserName}`;
962
+ const existingNested = outPackages[nestedLoc];
963
+ if (existingNested) {
964
+ if (entriesAreEquivalent(existingNested, collision.loserEntry)) continue;
965
+ throw new Error(`Cannot re-nest displaced "${loserName}" under "${consumerNewLoc}": the slot "${nestedLoc}" already contains a different entry. Please report a reproduction at https://github.com/0x80/isolate-package/issues.`);
966
+ }
967
+ outPackages[nestedLoc] = { ...collision.loserEntry };
968
+ }
969
+ }
832
970
  /**
833
971
  * If the target importer didn't make it into the reachable set for any
834
972
  * reason (upstream Arborist bug, programmer error), bail loudly rather
@@ -836,8 +974,10 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
836
974
  */
837
975
  if (!outPackages[""]) throw new Error(`Target importer "${targetImporterLoc}" was not present in the reachable node set; cannot construct isolate root entry`);
838
976
  /** Overlay the isolate root with the adapted target manifest. */
839
- const rootEntry = { ...outPackages[""] };
840
- rootEntry.name = targetPackageManifest.name;
977
+ const rootEntry = {
978
+ ...outPackages[""],
979
+ name: targetPackageManifest.name
980
+ };
841
981
  if (targetPackageManifest.version) rootEntry.version = targetPackageManifest.version;
842
982
  overlayManifestDeps(rootEntry, targetPackageManifest);
843
983
  /** The isolate is no longer a workspace root. */
@@ -875,6 +1015,55 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
875
1015
  function entriesAreEquivalent(a, b) {
876
1016
  return a.version === b.version && a.resolved === b.resolved && a.integrity === b.integrity && !!a.link === !!b.link;
877
1017
  }
1018
+ /**
1019
+ * Extracts the package name from a lockfile install location. Handles scoped
1020
+ * packages, where the name is two segments after the last `node_modules/`.
1021
+ *
1022
+ * "node_modules/foo" -> "foo"
1023
+ * "node_modules/@scope/foo" -> "@scope/foo"
1024
+ * "node_modules/a/node_modules/b" -> "b"
1025
+ * "node_modules/a/node_modules/@scope/b" -> "@scope/b"
1026
+ *
1027
+ * Returns null for locations that don't contain `node_modules/`.
1028
+ */
1029
+ function extractPackageNameFromLockfileLoc(loc) {
1030
+ const lastIdx = loc.lastIndexOf("node_modules/");
1031
+ if (lastIdx < 0) return null;
1032
+ const tail = loc.slice(lastIdx + 13);
1033
+ if (tail.startsWith("@")) {
1034
+ const slashIdx = tail.indexOf("/");
1035
+ if (slashIdx < 0) return tail;
1036
+ /** Stop at the second `/` so we don't include any further nesting. */
1037
+ const secondSlash = tail.indexOf("/", slashIdx + 1);
1038
+ return secondSlash < 0 ? tail : tail.slice(0, secondSlash);
1039
+ }
1040
+ const slashIdx = tail.indexOf("/");
1041
+ return slashIdx < 0 ? tail : tail.slice(0, slashIdx);
1042
+ }
1043
+ /**
1044
+ * Returns true if the lockfile entry lists `depName` in any of its dependency
1045
+ * fields. Includes peer/optional/dev because any of them may have been the
1046
+ * reason for the dep being installed at runtime.
1047
+ */
1048
+ function entryDependsOn(entry, depName) {
1049
+ return entry.dependencies?.[depName] !== void 0 || entry.devDependencies?.[depName] !== void 0 || entry.peerDependencies?.[depName] !== void 0 || entry.optionalDependencies?.[depName] !== void 0;
1050
+ }
1051
+ /**
1052
+ * Resolves `depName` against `srcPackages` from the perspective of a consumer
1053
+ * at `consumerLoc`, mirroring Node.js `node_modules` walk-up. Returns the
1054
+ * lockfile key of the entry the consumer would load at runtime, or null when
1055
+ * no candidate exists.
1056
+ */
1057
+ function resolveDepInSrcLockfile(consumerLoc, depName, srcPackages) {
1058
+ let scope = consumerLoc;
1059
+ while (true) {
1060
+ const candidate = scope === "" ? `node_modules/${depName}` : `${scope}/node_modules/${depName}`;
1061
+ if (srcPackages[candidate]) return candidate;
1062
+ if (scope === "") return null;
1063
+ const idx = scope.lastIndexOf("/node_modules/");
1064
+ scope = idx < 0 ? "" : scope.slice(0, idx);
1065
+ }
1066
+ }
878
1067
  function overlayManifestDeps(entry, manifest) {
879
1068
  for (const field of [
880
1069
  "dependencies",
@@ -983,6 +1172,18 @@ async function generatePnpmLockfile({ workspaceRootDir, targetPackageDir, isolat
983
1172
  /** Add packageExtensionsChecksum back to the pruned lockfile if present */
984
1173
  if (lockfile.packageExtensionsChecksum) prunedLockfile.packageExtensionsChecksum = lockfile.packageExtensionsChecksum;
985
1174
  /**
1175
+ * Pruning drops the catalogs snapshot, but the isolated importers keep
1176
+ * their "catalog:" specifiers (for pnpm we don't resolve catalog deps in
1177
+ * the manifest, since the output is itself a workspace). Restore it
1178
+ * verbatim — like overrides above — so it stays in sync with the importer
1179
+ * specifiers and the preserved pnpm-workspace.yaml catalog definitions,
1180
+ * which are themselves copied verbatim (see issue #198). pnpm tolerates
1181
+ * catalog entries that no retained importer references, so there is no need
1182
+ * to narrow the snapshot.
1183
+ */
1184
+ const catalogs = lockfile.catalogs;
1185
+ if (catalogs) prunedLockfile.catalogs = catalogs;
1186
+ /**
986
1187
  * Use pre-computed patched dependencies with transformed paths. The paths
987
1188
  * are already adapted by copyPatches to match the isolated directory
988
1189
  * structure, preserving the original folder structure (not flattened).
@@ -996,9 +1197,9 @@ async function generatePnpmLockfile({ workspaceRootDir, targetPackageDir, isolat
996
1197
  patchedDependencies
997
1198
  });
998
1199
  log.debug("Created lockfile at", path.join(isolateDir, "pnpm-lock.yaml"));
999
- } catch (err) {
1000
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
1001
- throw err;
1200
+ } catch (error) {
1201
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
1202
+ throw error;
1002
1203
  }
1003
1204
  }
1004
1205
  //#endregion
@@ -1024,9 +1225,9 @@ async function generateYarnLockfile({ workspaceRootDir, isolateDir }) {
1024
1225
  log.debug(`Running local install`);
1025
1226
  execSync(`yarn install --cwd ${isolateDir}`);
1026
1227
  log.debug("Generated lockfile at", newLockfilePath);
1027
- } catch (err) {
1028
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
1029
- throw err;
1228
+ } catch (error) {
1229
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
1230
+ throw error;
1030
1231
  }
1031
1232
  }
1032
1233
  //#endregion
@@ -1103,7 +1304,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
1103
1304
  //#endregion
1104
1305
  //#region src/lib/manifest/io.ts
1105
1306
  async function readManifest(packageDir) {
1106
- return readTypedJson(path.join(packageDir, "package.json"));
1307
+ return await readTypedJson(path.join(packageDir, "package.json"));
1107
1308
  }
1108
1309
  async function writeManifest(outputDir, manifest) {
1109
1310
  await fs.writeFile(path.join(outputDir, "package.json"), JSON.stringify(manifest, null, 2));
@@ -1139,30 +1340,83 @@ function adaptManifestInternalDeps({ manifest, packagesRegistry, parentRootRelat
1139
1340
  }
1140
1341
  //#endregion
1141
1342
  //#region src/lib/manifest/helpers/resolve-catalog-dependencies.ts
1343
+ const catalogSourceCache = /* @__PURE__ */ new Map();
1344
+ /**
1345
+ * Loads catalog definitions by checking pnpm-workspace.yaml first (pnpm
1346
+ * format), then falling back to the root package.json (Bun format).
1347
+ *
1348
+ * Pnpm defines catalogs in pnpm-workspace.yaml:
1349
+ *
1350
+ * ```yaml
1351
+ * catalog:
1352
+ * react: ^18.3.1
1353
+ * catalogs:
1354
+ * react18:
1355
+ * react: ^18.3.1
1356
+ * ```
1357
+ *
1358
+ * Bun defines catalogs in package.json (at root level or under workspaces).
1359
+ */
1360
+ async function loadCatalogSource(workspaceRootDir) {
1361
+ const cached = catalogSourceCache.get(workspaceRootDir);
1362
+ if (cached) return cached;
1363
+ /**
1364
+ * Drop the cache entry if loading fails so a subsequent call can retry
1365
+ * instead of immediately rethrowing the same rejection.
1366
+ */
1367
+ const cachedLoadPromise = (async () => {
1368
+ const log = useLogger();
1369
+ /** Try pnpm-workspace.yaml first. */
1370
+ const workspaceYamlPath = path.join(workspaceRootDir, "pnpm-workspace.yaml");
1371
+ if (await fs.pathExists(workspaceYamlPath)) try {
1372
+ const rawContent = await fs.readFile(workspaceYamlPath, "utf-8");
1373
+ const yamlConfig = yaml.parse(rawContent);
1374
+ if (yamlConfig?.catalog || yamlConfig?.catalogs) return {
1375
+ catalog: yamlConfig.catalog,
1376
+ catalogs: yamlConfig.catalogs
1377
+ };
1378
+ } catch (error) {
1379
+ log.warn(`Failed to parse ${workspaceYamlPath}: ${error instanceof Error ? error.message : String(error)}. Falling back to package.json for catalog definitions.`);
1380
+ }
1381
+ const rootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1382
+ return {
1383
+ catalog: rootManifest.catalog ?? rootManifest.workspaces?.catalog,
1384
+ catalogs: rootManifest.catalogs ?? rootManifest.workspaces?.catalogs
1385
+ };
1386
+ })().catch((error) => {
1387
+ catalogSourceCache.delete(workspaceRootDir);
1388
+ throw error;
1389
+ });
1390
+ catalogSourceCache.set(workspaceRootDir, cachedLoadPromise);
1391
+ return cachedLoadPromise;
1392
+ }
1142
1393
  /**
1143
1394
  * Resolves catalog dependencies by replacing "catalog:" specifiers with their
1144
- * actual versions from the root package.json catalog field.
1395
+ * actual versions.
1145
1396
  *
1146
1397
  * Supports both pnpm and Bun catalog formats:
1147
1398
  *
1148
- * - Pnpm: catalog at root level
1149
- * - Bun: catalog or catalogs at root level, or workspaces.catalog
1399
+ * - Pnpm: catalog/catalogs defined in pnpm-workspace.yaml
1400
+ * - Bun: catalog or catalogs at root level, or workspaces.catalog in
1401
+ * package.json
1150
1402
  */
1151
1403
  async function resolveCatalogDependencies(dependencies, workspaceRootDir) {
1152
1404
  if (!dependencies) return;
1153
1405
  const log = useLogger();
1154
- const rootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1155
- const flatCatalog = rootManifest.catalog || rootManifest.workspaces?.catalog;
1156
- const nestedCatalogs = rootManifest.catalogs || rootManifest.workspaces?.catalogs;
1406
+ const { catalog: flatCatalog, catalogs: nestedCatalogs } = await loadCatalogSource(workspaceRootDir);
1157
1407
  if (!flatCatalog && !nestedCatalogs) return dependencies;
1158
1408
  const resolved = { ...dependencies };
1159
1409
  for (const [packageName, specifier] of Object.entries(dependencies)) if (specifier === "catalog:" || specifier.startsWith("catalog:")) {
1160
1410
  let catalogVersion;
1161
- if (specifier === "catalog:") catalogVersion = flatCatalog?.[packageName];
1162
- else {
1163
- const groupName = specifier.slice(8);
1164
- catalogVersion = nestedCatalogs?.[groupName]?.[packageName];
1165
- }
1411
+ const groupName = specifier === "catalog:" ? "default" : specifier.slice(8);
1412
+ if (groupName === "default")
1413
+ /**
1414
+ * Per pnpm semantics, `catalog:` and `catalog:default` are
1415
+ * equivalent: the default catalog can live under the top-level
1416
+ * `catalog` field or under `catalogs.default`, so check both.
1417
+ */
1418
+ catalogVersion = flatCatalog?.[packageName] ?? nestedCatalogs?.default?.[packageName];
1419
+ else catalogVersion = nestedCatalogs?.[groupName]?.[packageName];
1166
1420
  if (catalogVersion) {
1167
1421
  log.debug(`Resolving catalog dependency ${packageName}: "${specifier}" -> "${catalogVersion}"`);
1168
1422
  resolved[packageName] = catalogVersion;
@@ -1179,6 +1433,14 @@ async function resolveCatalogDependencies(dependencies, workspaceRootDir) {
1179
1433
  */
1180
1434
  async function adaptInternalPackageManifests({ internalPackageNames, packagesRegistry, isolateDir, forceNpm, workspaceRootDir }) {
1181
1435
  const packageManager = usePackageManager();
1436
+ /**
1437
+ * For PNPM (non-forceNpm) the isolated output is itself a pnpm workspace with
1438
+ * its `pnpm-workspace.yaml` catalog definitions preserved, so "catalog:"
1439
+ * specifiers are kept verbatim to stay in sync with the lockfile importers
1440
+ * (see issue #198). For other package managers the catalog is not available
1441
+ * in the output, so we resolve the specifiers to versions.
1442
+ */
1443
+ const isPnpmWorkspaceOutput = packageManager.name === "pnpm" && !forceNpm;
1182
1444
  await Promise.all(internalPackageNames.map(async (packageName) => {
1183
1445
  const { manifest, rootRelativeDir } = got(packagesRegistry, packageName);
1184
1446
  /** Dev dependencies are never included for internal deps */
@@ -1191,13 +1453,12 @@ async function adaptInternalPackageManifests({ internalPackageNames, packagesReg
1191
1453
  * setup (e.g. Prisma client generation).
1192
1454
  */
1193
1455
  if (strippedManifest.scripts) strippedManifest.scripts = omit(strippedManifest.scripts, ["prepare"]);
1194
- /** Resolve catalog dependencies before adapting internal deps */
1195
- const manifestWithResolvedCatalogs = {
1456
+ const preparedManifest = isPnpmWorkspaceOutput ? strippedManifest : {
1196
1457
  ...strippedManifest,
1197
1458
  dependencies: await resolveCatalogDependencies(strippedManifest.dependencies, workspaceRootDir)
1198
1459
  };
1199
- const outputManifest = (packageManager.name === "pnpm" || packageManager.name === "bun") && !forceNpm ? manifestWithResolvedCatalogs : adaptManifestInternalDeps({
1200
- manifest: manifestWithResolvedCatalogs,
1460
+ const outputManifest = (packageManager.name === "pnpm" || packageManager.name === "bun") && !forceNpm ? preparedManifest : adaptManifestInternalDeps({
1461
+ manifest: preparedManifest,
1201
1462
  packagesRegistry,
1202
1463
  parentRootRelativeDir: rootRelativeDir
1203
1464
  });
@@ -1213,7 +1474,7 @@ async function adaptInternalPackageManifests({ internalPackageNames, packagesReg
1213
1474
  */
1214
1475
  async function adoptPnpmFieldsFromRoot(targetPackageManifest, workspaceRootDir) {
1215
1476
  if (isRushWorkspace(workspaceRootDir)) return targetPackageManifest;
1216
- const rootPackageManifest = await readTypedJson(path$1.join(workspaceRootDir, "package.json"));
1477
+ const rootPackageManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1217
1478
  if (usePackageManager().name === "bun") return adoptBunFieldsFromRoot(targetPackageManifest, rootPackageManifest);
1218
1479
  return adoptPnpmFieldsOnly(targetPackageManifest, rootPackageManifest);
1219
1480
  }
@@ -1233,7 +1494,7 @@ function adoptBunFieldsFromRoot(targetPackageManifest, rootPackageManifest) {
1233
1494
  }
1234
1495
  /** Adopt pnpm-specific fields from the root manifest */
1235
1496
  function adoptPnpmFieldsOnly(targetPackageManifest, rootPackageManifest) {
1236
- const { overrides, onlyBuiltDependencies, ignoredBuiltDependencies } = rootPackageManifest.pnpm || {};
1497
+ const { overrides, onlyBuiltDependencies, ignoredBuiltDependencies } = rootPackageManifest.pnpm ?? {};
1237
1498
  /** If no pnpm fields are present, return the original manifest */
1238
1499
  if (!overrides && !onlyBuiltDependencies && !ignoredBuiltDependencies) return targetPackageManifest;
1239
1500
  const pnpmConfig = {};
@@ -1259,17 +1520,25 @@ async function adaptTargetPackageManifest({ manifest, packagesRegistry, workspac
1259
1520
  const { includeDevDependencies, pickFromScripts, omitFromScripts, omitPackageManager, forceNpm } = config;
1260
1521
  /** Dev dependencies are omitted by default */
1261
1522
  const inputManifest = includeDevDependencies ? manifest : omit(manifest, ["devDependencies"]);
1262
- /** Resolve catalog dependencies before adapting internal deps */
1263
- const manifestWithResolvedCatalogs = {
1523
+ const preparedManifest = packageManager.name === "pnpm" && !forceNpm ? inputManifest : {
1264
1524
  ...inputManifest,
1265
1525
  dependencies: await resolveCatalogDependencies(inputManifest.dependencies, workspaceRootDir)
1266
1526
  };
1267
1527
  return {
1268
- ...(packageManager.name === "pnpm" || packageManager.name === "bun") && !forceNpm ? await adoptPnpmFieldsFromRoot(manifestWithResolvedCatalogs, workspaceRootDir) : adaptManifestInternalDeps({
1269
- manifest: manifestWithResolvedCatalogs,
1528
+ ...(packageManager.name === "pnpm" || packageManager.name === "bun") && !forceNpm ? await adoptPnpmFieldsFromRoot(preparedManifest, workspaceRootDir) : adaptManifestInternalDeps({
1529
+ manifest: preparedManifest,
1270
1530
  packagesRegistry
1271
1531
  }),
1532
+ /**
1533
+ * Adopt the package manager definition from the root manifest if available.
1534
+ * The option to omit is there because some platforms might not handle it
1535
+ * properly (Cloud Run, April 24th 2024, does not handle pnpm v9)
1536
+ */
1272
1537
  packageManager: omitPackageManager ? void 0 : packageManager.packageManagerString,
1538
+ /**
1539
+ * Scripts are removed by default if not explicitly picked or omitted via
1540
+ * config.
1541
+ */
1273
1542
  scripts: pickFromScripts ? pick(manifest.scripts ?? {}, pickFromScripts) : omitFromScripts ? omit(manifest.scripts ?? {}, omitFromScripts) : {}
1274
1543
  };
1275
1544
  }
@@ -1300,8 +1569,8 @@ function validateManifestMandatoryFields(manifest, packagePath, requireFilesFiel
1300
1569
  * packed
1301
1570
  */
1302
1571
  if (requireFilesField && (!manifest.files || !Array.isArray(manifest.files) || manifest.files.length === 0)) missingFields.push("files");
1303
- if (missingFields.length > 0) {
1304
- const field = missingFields[0];
1572
+ const [field] = missingFields;
1573
+ if (field) {
1305
1574
  const errorMessage = missingFields.length === 1 ? `Package at ${packagePath} is missing the "${field}" field in its package.json. See ${fieldDocUrls[field] ?? "https://isolate-package.codecompose.dev/getting-started#prerequisites"}` : `Package at ${packagePath} is missing mandatory fields in its package.json: ${missingFields.join(", ")}. See https://isolate-package.codecompose.dev/getting-started#prerequisites`;
1306
1575
  log.error(errorMessage);
1307
1576
  throw new Error(errorMessage);
@@ -1310,7 +1579,7 @@ function validateManifestMandatoryFields(manifest, packagePath, requireFilesFiel
1310
1579
  }
1311
1580
  //#endregion
1312
1581
  //#region src/lib/output/get-build-output-dir.ts
1313
- async function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath }) {
1582
+ function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath }) {
1314
1583
  const log = useLogger();
1315
1584
  if (buildDirName) {
1316
1585
  log.debug("Using buildDirName from config:", buildDirName);
@@ -1333,6 +1602,91 @@ async function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath
1333
1602
  }
1334
1603
  }
1335
1604
  //#endregion
1605
+ //#region src/lib/utils/wait-for-complete-file.ts
1606
+ /**
1607
+ * Wait until the given file exists and its size has stopped changing across
1608
+ * two consecutive polls. Resolves once the file is considered fully written,
1609
+ * rejects with a timeout error otherwise.
1610
+ *
1611
+ * This is a cheap proxy for "the writer has finished flushing" without
1612
+ * inspecting file contents or relying on platform-specific signals. It is
1613
+ * intended for cases where an external process (e.g. `pnpm pack`) may report
1614
+ * completion before its output is fully visible on disk.
1615
+ */
1616
+ async function waitForCompleteFile(filePath, { timeoutMs, pollMs }) {
1617
+ const deadline = Date.now() + timeoutMs;
1618
+ let lastSize = -1;
1619
+ while (Date.now() < deadline) {
1620
+ try {
1621
+ const { size } = await fs$1.promises.stat(filePath);
1622
+ if (size > 0 && size === lastSize) return;
1623
+ lastSize = size;
1624
+ } catch (error) {
1625
+ if (error.code !== "ENOENT") throw error;
1626
+ }
1627
+ await new Promise((resolve) => {
1628
+ setTimeout(resolve, pollMs);
1629
+ });
1630
+ }
1631
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for file to be written: ${filePath}`);
1632
+ }
1633
+ //#endregion
1634
+ //#region src/lib/utils/pack.ts
1635
+ /**
1636
+ * How long to wait for the packed tarball to appear and stop growing on disk
1637
+ * after `pnpm pack` / `npm pack` has exited.
1638
+ */
1639
+ const PACK_FILE_READY_TIMEOUT_MS = 5e3;
1640
+ const PACK_FILE_READY_POLL_MS = 50;
1641
+ async function pack(srcDir, dstDir) {
1642
+ const log = useLogger();
1643
+ const execOptions = {
1644
+ cwd: srcDir,
1645
+ maxBuffer: 10 * 1024 * 1024
1646
+ };
1647
+ /**
1648
+ * PNPM pack seems to be a lot faster than NPM pack, so when PNPM is detected
1649
+ * we use that instead.
1650
+ */
1651
+ const packStdout = shouldUsePnpmPack() ? await new Promise((resolve, reject) => {
1652
+ exec(`pnpm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
1653
+ if (err) {
1654
+ log.error(getErrorMessage(err));
1655
+ reject(err);
1656
+ return;
1657
+ }
1658
+ resolve(stdout);
1659
+ });
1660
+ }) : await new Promise((resolve, reject) => {
1661
+ exec(`npm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
1662
+ if (err) {
1663
+ reject(err);
1664
+ return;
1665
+ }
1666
+ resolve(stdout);
1667
+ });
1668
+ });
1669
+ const lastLine = packStdout.trim().split("\n").at(-1);
1670
+ assert(lastLine, `Failed to parse last line from stdout: ${packStdout.trim()}`);
1671
+ const fileName = path.basename(lastLine);
1672
+ assert(fileName, `Failed to parse file name from: ${lastLine}`);
1673
+ const filePath = path.join(dstDir, fileName);
1674
+ /**
1675
+ * `pnpm pack` (and occasionally `npm pack`) can return before the tarball is
1676
+ * fully visible/flushed to disk. A naive `existsSync` check is not enough:
1677
+ * the directory entry can appear before the file's data has been written,
1678
+ * which causes downstream consumers (gunzip + tar) to fail with
1679
+ * "unexpected end of file". Wait until the file exists and its size has
1680
+ * stopped changing across two consecutive polls before returning.
1681
+ */
1682
+ await waitForCompleteFile(filePath, {
1683
+ timeoutMs: PACK_FILE_READY_TIMEOUT_MS,
1684
+ pollMs: PACK_FILE_READY_POLL_MS
1685
+ });
1686
+ log.debug(`Packed (temp)/${fileName}`);
1687
+ return filePath;
1688
+ }
1689
+ //#endregion
1336
1690
  //#region src/lib/output/pack-dependencies.ts
1337
1691
  /**
1338
1692
  * Pack dependencies so that we extract only the files that are supposed to be
@@ -1361,18 +1715,13 @@ async function packDependencies({ packagesRegistry, internalPackageNames, packDe
1361
1715
  }
1362
1716
  //#endregion
1363
1717
  //#region src/lib/output/process-build-output-files.ts
1364
- const TIMEOUT_MS = 5e3;
1365
1718
  async function processBuildOutputFiles({ targetPackageDir, tmpDir, isolateDir }) {
1366
- const log = useLogger();
1367
1719
  const packedFilePath = await pack(targetPackageDir, tmpDir);
1368
1720
  const unpackDir = path.join(tmpDir, "target");
1369
- const now = Date.now();
1370
- let isWaitingYet = false;
1371
- while (!fs.existsSync(packedFilePath) && Date.now() - now < TIMEOUT_MS) {
1372
- if (!isWaitingYet) log.debug(`Waiting for ${packedFilePath} to become available...`);
1373
- isWaitingYet = true;
1374
- await new Promise((resolve) => setTimeout(resolve, 100));
1375
- }
1721
+ /**
1722
+ * `pack` already waits for the tarball to be fully written before returning,
1723
+ * so it is safe to unpack immediately.
1724
+ */
1376
1725
  await unpack(packedFilePath, unpackDir);
1377
1726
  await fs.copy(path.join(unpackDir, "package"), isolateDir);
1378
1727
  }
@@ -1445,7 +1794,8 @@ function collectReachablePackageNames({ targetPackageManifest, packagesRegistry,
1445
1794
  */
1446
1795
  function findPackagesGlobs(workspaceRootDir) {
1447
1796
  const log = useLogger();
1448
- switch (usePackageManager().name) {
1797
+ const packageManager = usePackageManager();
1798
+ switch (packageManager.name) {
1449
1799
  case "pnpm": {
1450
1800
  const workspaceConfig = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"));
1451
1801
  if (!workspaceConfig) throw new Error("pnpm-workspace.yaml file is empty. Please specify packages configuration.");
@@ -1461,17 +1811,16 @@ function findPackagesGlobs(workspaceRootDir) {
1461
1811
  const { workspaces } = readTypedJsonSync(workspaceRootManifestPath);
1462
1812
  if (!workspaces) throw new Error(`No workspaces field found in ${workspaceRootManifestPath}`);
1463
1813
  if (Array.isArray(workspaces)) return workspaces;
1464
- else {
1465
- /**
1466
- * For Yarn, workspaces could be defined as an object with { packages:
1467
- * [], nohoist: [] }. See
1468
- * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
1469
- */
1470
- const workspacesObject = workspaces;
1471
- assert(workspacesObject.packages, "workspaces.packages must be an array");
1472
- return workspacesObject.packages;
1473
- }
1814
+ /**
1815
+ * For Yarn, workspaces could be defined as an object with { packages: [],
1816
+ * nohoist: [] }. See
1817
+ * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
1818
+ */
1819
+ const workspacesObject = workspaces;
1820
+ assert(Array.isArray(workspacesObject.packages), "workspaces.packages must be an array");
1821
+ return workspacesObject.packages;
1474
1822
  }
1823
+ default: throw new Error(`Unsupported package manager: ${packageManager.name}`);
1475
1824
  }
1476
1825
  }
1477
1826
  //#endregion
@@ -1490,15 +1839,14 @@ async function createPackagesRegistry(workspaceRootDir, workspacePackagesOverrid
1490
1839
  const manifestPath = path.join(absoluteDir, "package.json");
1491
1840
  if (!fs.existsSync(manifestPath)) {
1492
1841
  log.warn(`Ignoring directory ${rootRelativeDir} because it does not contain a package.json file`);
1493
- return;
1494
- } else {
1495
- log.debug(`Registering package ${rootRelativeDir}`);
1496
- return {
1497
- manifest: await readTypedJson(path.join(absoluteDir, "package.json")),
1498
- rootRelativeDir,
1499
- absoluteDir
1500
- };
1842
+ return null;
1501
1843
  }
1844
+ log.debug(`Registering package ${rootRelativeDir}`);
1845
+ return {
1846
+ manifest: await readTypedJson(path.join(absoluteDir, "package.json")),
1847
+ rootRelativeDir,
1848
+ absoluteDir
1849
+ };
1502
1850
  }))).reduce((acc, info) => {
1503
1851
  if (info) acc[info.manifest.name] = info;
1504
1852
  return acc;
@@ -1553,10 +1901,252 @@ function listInternalPackages(manifest, packagesRegistry, { includeDevDependenci
1553
1901
  return [...new Set(result)];
1554
1902
  }
1555
1903
  //#endregion
1904
+ //#region src/lib/patches/collect-installed-names-bun.ts
1905
+ /**
1906
+ * Walk the workspace bun.lock starting from the target package and its
1907
+ * internal workspace dependencies, returning the set of every package name
1908
+ * that will end up installed in the isolate (including deep
1909
+ * external-to-external transitives).
1910
+ *
1911
+ * Used by `copyPatches` to preserve patches for transitive deps that aren't
1912
+ * directly listed on any internal manifest. Returns an empty set on any
1913
+ * failure so the caller falls back to manifest-based reachability.
1914
+ */
1915
+ function collectInstalledNamesFromBunLockfile({ workspaceRootDir, targetPackageDir, internalDepPackageNames, packagesRegistry, includeDevDependencies }) {
1916
+ const log = useLogger();
1917
+ try {
1918
+ const lockfilePath = path.join(workspaceRootDir, "bun.lock");
1919
+ if (!fs.existsSync(lockfilePath)) {
1920
+ log.debug("No bun.lock available for installed-names walk");
1921
+ return /* @__PURE__ */ new Set();
1922
+ }
1923
+ const lockfile = readTypedJsonSync(lockfilePath);
1924
+ const targetWorkspaceKey = path.relative(workspaceRootDir, targetPackageDir).split(path.sep).join(path.posix.sep);
1925
+ const internalWorkspaceKeys = internalDepPackageNames.map((name) => {
1926
+ const pkg = packagesRegistry[name];
1927
+ if (!pkg) return null;
1928
+ return pkg.rootRelativeDir.split(path.sep).join(path.posix.sep);
1929
+ }).filter(Boolean);
1930
+ const directDependencyNames = /* @__PURE__ */ new Set();
1931
+ const targetEntry = lockfile.workspaces[targetWorkspaceKey];
1932
+ if (targetEntry) for (const name of collectDependencyNames(targetEntry, includeDevDependencies)) directDependencyNames.add(name);
1933
+ for (const workspaceKey of internalWorkspaceKeys) {
1934
+ const entry = lockfile.workspaces[workspaceKey];
1935
+ if (!entry) continue;
1936
+ /** Internal workspace deps never bring in their devDependencies */
1937
+ for (const name of collectDependencyNames(entry, false)) directDependencyNames.add(name);
1938
+ }
1939
+ return collectRequiredPackages(directDependencyNames, lockfile.packages);
1940
+ } catch (error) {
1941
+ log.debug(`Failed to walk bun.lock for installed names: ${error instanceof Error ? error.message : String(error)}`);
1942
+ return /* @__PURE__ */ new Set();
1943
+ }
1944
+ }
1945
+ //#endregion
1946
+ //#region src/lib/patches/collect-installed-names-pnpm.ts
1947
+ /**
1948
+ * Walk the workspace pnpm lockfile starting from the target package and its
1949
+ * internal workspace dependencies, returning the set of every package name
1950
+ * that will end up installed in the isolate (including deep
1951
+ * external-to-external transitives).
1952
+ *
1953
+ * Used by `copyPatches` to preserve patches for transitive deps that aren't
1954
+ * directly listed on any internal manifest. Returns an empty set on any
1955
+ * failure so the caller falls back to manifest-based reachability. When the
1956
+ * lockfile is present but lacks a `packages` section, returns just the
1957
+ * direct importer dep names.
1958
+ */
1959
+ async function collectInstalledNamesFromPnpmLockfile({ workspaceRootDir, targetPackageDir, internalDepPackageNames, packagesRegistry, majorVersion, includeDevDependencies }) {
1960
+ const log = useLogger();
1961
+ try {
1962
+ const useVersion9 = majorVersion >= 9;
1963
+ const isRush = isRushWorkspace(workspaceRootDir);
1964
+ const lockfileDir = isRush ? path.join(workspaceRootDir, "common/config/rush") : workspaceRootDir;
1965
+ const lockfile = useVersion9 ? await readWantedLockfile$1(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile(lockfileDir, { ignoreIncompatible: false });
1966
+ if (!lockfile) {
1967
+ log.debug("No pnpm lockfile available for installed-names walk");
1968
+ return /* @__PURE__ */ new Set();
1969
+ }
1970
+ /**
1971
+ * Normalize separators to POSIX so Windows callers match the lockfile's
1972
+ * importer keys (mirrors generate-pnpm-lockfile.ts). Applied once here so
1973
+ * the `isTarget` equality check below compares apples-to-apples — without
1974
+ * this, on Windows the raw id with backslashes wouldn't match the
1975
+ * normalized id used as the importers map key.
1976
+ */
1977
+ const targetImporterId = toLockfileImporterKey(useVersion9 ? getLockfileImporterId$1(workspaceRootDir, targetPackageDir) : getLockfileImporterId(workspaceRootDir, targetPackageDir), isRush);
1978
+ const importerIds = [targetImporterId, ...internalDepPackageNames.map((name) => packagesRegistry[name]?.rootRelativeDir).filter(Boolean).map((dir) => toLockfileImporterKey(dir, isRush))];
1979
+ const packages = lockfile.packages;
1980
+ if (!packages) {
1981
+ log.debug("Lockfile has no packages section to walk");
1982
+ return collectImporterDirectNames(lockfile.importers, importerIds, targetImporterId, includeDevDependencies);
1983
+ }
1984
+ const names = /* @__PURE__ */ new Set();
1985
+ const seen = /* @__PURE__ */ new Set();
1986
+ const queue = [];
1987
+ for (const importerId of importerIds) {
1988
+ const importer = lockfile.importers[importerId];
1989
+ if (!importer) continue;
1990
+ enqueueImporterDeps({
1991
+ importer,
1992
+ names,
1993
+ queue,
1994
+ useVersion9,
1995
+ includeDevDependencies: importerId === targetImporterId && includeDevDependencies
1996
+ });
1997
+ }
1998
+ let depPath;
1999
+ while ((depPath = queue.pop()) !== void 0) {
2000
+ if (seen.has(depPath)) continue;
2001
+ seen.add(depPath);
2002
+ names.add(extractPackageName(depPath));
2003
+ const pkg = packages[depPath];
2004
+ if (!pkg) continue;
2005
+ enqueueResolvedDeps(pkg.dependencies, names, queue, useVersion9, seen);
2006
+ enqueueResolvedDeps(pkg.optionalDependencies, names, queue, useVersion9, seen);
2007
+ /**
2008
+ * Peer requirement values are name → semver-range, not resolved depPaths.
2009
+ * Just record the names so a patch on a peer-only external transitive
2010
+ * survives filtering (mirrors the bun walker and the sister manifest
2011
+ * walker, which both include peerDependencies).
2012
+ */
2013
+ collectNames(pkg.peerDependencies, names);
2014
+ }
2015
+ return names;
2016
+ } catch (error) {
2017
+ log.debug(`Failed to walk pnpm lockfile for installed names: ${error instanceof Error ? error.message : String(error)}`);
2018
+ return /* @__PURE__ */ new Set();
2019
+ }
2020
+ }
2021
+ /**
2022
+ * Convert a raw importer id (as returned by `getLockfileImporterId` or a
2023
+ * package's rootRelativeDir) to the form actually used as a key in
2024
+ * `lockfile.importers`: POSIX separators, with the Rush `../../` prefix when
2025
+ * the workspace lives under `common/config/rush`. Lockfile keys are always
2026
+ * POSIX regardless of the host OS, so backslashes are normalized
2027
+ * unconditionally rather than relying on `path.sep`.
2028
+ */
2029
+ function toLockfileImporterKey(importerId, isRush) {
2030
+ const posix = importerId.split(path.sep).join(path.posix.sep).replace(/\\/g, "/");
2031
+ return isRush ? `../../${posix}` : posix;
2032
+ }
2033
+ function enqueueImporterDeps({ importer, names, queue, useVersion9, includeDevDependencies }) {
2034
+ enqueueResolvedDeps(importer.dependencies, names, queue, useVersion9);
2035
+ enqueueResolvedDeps(importer.optionalDependencies, names, queue, useVersion9);
2036
+ if (includeDevDependencies) enqueueResolvedDeps(importer.devDependencies, names, queue, useVersion9);
2037
+ /**
2038
+ * Importer peerDependencies usually aren't a separate map in the lockfile
2039
+ * (autoInstallPeers folds them into `dependencies`), but record names if
2040
+ * they happen to be present.
2041
+ */
2042
+ collectNames(importer.peerDependencies, names);
2043
+ }
2044
+ function enqueueResolvedDeps(deps, names, queue, useVersion9, seen) {
2045
+ if (!deps) return;
2046
+ for (const [alias, ref] of Object.entries(deps)) {
2047
+ /**
2048
+ * The alias is the name as listed in the parent's dependencies map. For
2049
+ * non-aliased installs this is also the resolved package name. We add it
2050
+ * to the set as a candidate name; visiting the actual depPath below
2051
+ * refines this with the true installed name.
2052
+ */
2053
+ names.add(alias);
2054
+ const depPath = refToRelative(ref, alias, useVersion9);
2055
+ if (depPath && !seen?.has(depPath)) queue.push(depPath);
2056
+ }
2057
+ }
2058
+ function collectNames(deps, names) {
2059
+ if (!deps) return;
2060
+ for (const name of Object.keys(deps)) names.add(name);
2061
+ }
2062
+ /**
2063
+ * Mirrors `@pnpm/dependency-path`'s `refToRelative`. The depPath shape differs
2064
+ * between pnpm 8 (lockfile v6, normalized to v5 keys like `/foo/1.0.0`) and
2065
+ * pnpm 9 (lockfile v9 keys like `foo@1.0.0`). Returns the depPath used as a
2066
+ * key in `lockfile.packages`, or null if the ref points to a workspace link.
2067
+ */
2068
+ function refToRelative(reference, pkgName, useVersion9) {
2069
+ if (!reference) return null;
2070
+ if (reference.startsWith("link:")) return null;
2071
+ return useVersion9 ? refToRelativeV9(reference, pkgName) : refToRelativeV8(reference, pkgName);
2072
+ }
2073
+ function refToRelativeV9(reference, pkgName) {
2074
+ if (reference.startsWith("@")) return reference;
2075
+ const atIndex = reference.indexOf("@");
2076
+ if (atIndex === -1) return `${pkgName}@${reference}`;
2077
+ const colonIndex = reference.indexOf(":");
2078
+ const bracketIndex = reference.indexOf("(");
2079
+ if ((colonIndex === -1 || atIndex < colonIndex) && (bracketIndex === -1 || atIndex < bracketIndex)) return reference;
2080
+ return `${pkgName}@${reference}`;
2081
+ }
2082
+ /**
2083
+ * v8 form: pnpm 8 (lockfile v6) is normalized on read to v5-style depPaths
2084
+ * with leading slash and `/` separator between name and version. Plain
2085
+ * version refs build that key; refs already containing a `/` (peer-suffixed
2086
+ * or pre-formed) are returned verbatim. Mirrors `@pnpm/dependency-path@2.x`.
2087
+ */
2088
+ function refToRelativeV8(reference, pkgName) {
2089
+ if (reference.startsWith("file:")) return reference;
2090
+ const slashIndex = reference.indexOf("/");
2091
+ const bracketIndex = reference.indexOf("(");
2092
+ const noSlashBeforeBracket = bracketIndex !== -1 && reference.lastIndexOf("/", bracketIndex) === -1;
2093
+ if (slashIndex === -1 || noSlashBeforeBracket) return `/${pkgName}/${reference}`;
2094
+ return reference;
2095
+ }
2096
+ /**
2097
+ * Extract the bare package name from a pnpm depPath. Strips the optional
2098
+ * peer-resolution suffix (e.g. `(react@18.0.0)`) before parsing. Handles
2099
+ * both v9 (`@scope/foo@1.0.0`) and v8 (`/@scope/foo/1.0.0`) shapes.
2100
+ */
2101
+ function extractPackageName(depPath) {
2102
+ const peerStart = indexOfPeersSuffix(depPath);
2103
+ const trimmed = peerStart === -1 ? depPath : depPath.slice(0, peerStart);
2104
+ if (trimmed.startsWith("/")) {
2105
+ /** v8 v5-style: `/<name>/<version>` */
2106
+ const stripped = trimmed.slice(1);
2107
+ if (stripped.startsWith("@")) {
2108
+ const secondSlash = stripped.indexOf("/", stripped.indexOf("/") + 1);
2109
+ return secondSlash === -1 ? stripped : stripped.slice(0, secondSlash);
2110
+ }
2111
+ const firstSlash = stripped.indexOf("/");
2112
+ return firstSlash === -1 ? stripped : stripped.slice(0, firstSlash);
2113
+ }
2114
+ return getPackageName(trimmed);
2115
+ }
2116
+ /**
2117
+ * Mirrors `@pnpm/dependency-path`'s `indexOfPeersSuffix`. Returns the index
2118
+ * where the peer-resolution suffix starts, or -1 if there is none.
2119
+ */
2120
+ function indexOfPeersSuffix(depPath) {
2121
+ if (!depPath.endsWith(")")) return -1;
2122
+ let open = 1;
2123
+ for (let i = depPath.length - 2; i >= 0; i--) if (depPath[i] === "(") open--;
2124
+ else if (depPath[i] === ")") open++;
2125
+ else if (!open) return i + 1;
2126
+ return -1;
2127
+ }
2128
+ /**
2129
+ * Fallback when the lockfile is missing `packages`: just return importer
2130
+ * direct dep names so we at least cover some of the graph.
2131
+ */
2132
+ function collectImporterDirectNames(importers, importerIds, targetImporterId, includeDevDependencies) {
2133
+ const names = /* @__PURE__ */ new Set();
2134
+ for (const importerId of importerIds) {
2135
+ const importer = importers[importerId];
2136
+ if (!importer) continue;
2137
+ const isTarget = importerId === targetImporterId;
2138
+ for (const name of Object.keys(importer.dependencies ?? {})) names.add(name);
2139
+ for (const name of Object.keys(importer.optionalDependencies ?? {})) names.add(name);
2140
+ for (const name of Object.keys(importer.peerDependencies ?? {})) names.add(name);
2141
+ if (isTarget && includeDevDependencies) for (const name of Object.keys(importer.devDependencies ?? {})) names.add(name);
2142
+ }
2143
+ return names;
2144
+ }
2145
+ //#endregion
1556
2146
  //#region src/lib/patches/copy-patches.ts
1557
- async function copyPatches({ workspaceRootDir, targetPackageManifest, packagesRegistry, isolateDir, includeDevDependencies }) {
2147
+ async function copyPatches({ workspaceRootDir, targetPackageDir, targetPackageManifest, packagesRegistry, internalDepPackageNames, isolateDir, includeDevDependencies }) {
1558
2148
  const log = useLogger();
1559
- const { name: packageManagerName } = usePackageManager();
2149
+ const { name: packageManagerName, majorVersion } = usePackageManager();
1560
2150
  let patchedDependencies;
1561
2151
  /**
1562
2152
  * Only try reading pnpm-workspace.yaml for pnpm workspaces. Bun workspaces
@@ -1594,6 +2184,28 @@ async function copyPatches({ workspaceRootDir, targetPackageManifest, packagesRe
1594
2184
  packagesRegistry,
1595
2185
  includeDevDependencies
1596
2186
  });
2187
+ /**
2188
+ * Manifest-based reachability misses external→external transitives because
2189
+ * external manifests aren't loaded here. Walk the package-manager's
2190
+ * lockfile to also pick up those names, so a patch for a deeply-nested
2191
+ * external dep (e.g. `@react-pdf/render` reached via `@react-pdf/renderer`)
2192
+ * survives isolation.
2193
+ */
2194
+ const lockfileInstalledNames = packageManagerName === "pnpm" ? await collectInstalledNamesFromPnpmLockfile({
2195
+ workspaceRootDir,
2196
+ targetPackageDir,
2197
+ internalDepPackageNames,
2198
+ packagesRegistry,
2199
+ majorVersion,
2200
+ includeDevDependencies
2201
+ }) : packageManagerName === "bun" ? collectInstalledNamesFromBunLockfile({
2202
+ workspaceRootDir,
2203
+ targetPackageDir,
2204
+ internalDepPackageNames,
2205
+ packagesRegistry,
2206
+ includeDevDependencies
2207
+ }) : /* @__PURE__ */ new Set();
2208
+ for (const name of lockfileInstalledNames) reachableDependencyNames.add(name);
1597
2209
  const filteredPatches = filterPatchedDependencies({
1598
2210
  patchedDependencies,
1599
2211
  targetPackageManifest,
@@ -1698,16 +2310,16 @@ function writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, copiedPatches
1698
2310
  //#endregion
1699
2311
  //#region src/isolate.ts
1700
2312
  const __dirname = getDirname(import.meta.url);
1701
- function createIsolator(config) {
1702
- const resolvedConfig = resolveConfig(config);
1703
- return async function isolate() {
2313
+ function createIsolator(initialConfig) {
2314
+ const resolvedConfig = resolveConfig(initialConfig);
2315
+ return async function runIsolate() {
1704
2316
  const config = resolvedConfig;
1705
2317
  setLogLevel(config.logLevel);
1706
2318
  const log = useLogger();
1707
2319
  const { version: libraryVersion } = await readTypedJson(path.join(path.join(__dirname, "..", "package.json")));
1708
2320
  log.debug("Using isolate-package version", libraryVersion);
1709
2321
  const { targetPackageDir, workspaceRootDir } = resolveWorkspacePaths(config);
1710
- const buildOutputDir = await getBuildOutputDir({
2322
+ const buildOutputDir = getBuildOutputDir({
1711
2323
  targetPackageDir,
1712
2324
  buildDirName: config.buildDirName,
1713
2325
  tsconfigPath: config.tsconfigPath
@@ -1717,11 +2329,14 @@ function createIsolator(config) {
1717
2329
  log.debug("Isolate target package", getRootRelativeLogPath(targetPackageDir, workspaceRootDir));
1718
2330
  const isolateDir = path.join(targetPackageDir, config.isolateDirName);
1719
2331
  log.debug("Isolate output directory", getRootRelativeLogPath(isolateDir, workspaceRootDir));
1720
- if (fs.existsSync(isolateDir)) {
1721
- await fs.remove(isolateDir);
1722
- log.debug("Cleaned the existing isolate output directory");
1723
- }
1724
- await fs.ensureDir(isolateDir);
2332
+ /**
2333
+ * Place the trash sibling outside `targetPackageDir`, since that directory
2334
+ * is later packed by `processBuildOutputFiles`. Keeping the trash next to
2335
+ * the target package (still on the same filesystem) preserves the atomic
2336
+ * rename without risking that the trash gets picked up by `npm pack` or
2337
+ * races with that step.
2338
+ */
2339
+ await resetIsolateDir(isolateDir, { trashParentDir: path.dirname(targetPackageDir) });
1725
2340
  const tmpDir = path.join(isolateDir, "__tmp");
1726
2341
  await fs.ensureDir(tmpDir);
1727
2342
  const targetPackageManifest = await readTypedJson(path.join(targetPackageDir, "package.json"));
@@ -1794,8 +2409,10 @@ function createIsolator(config) {
1794
2409
  await writeManifest(isolateDir, outputManifest);
1795
2410
  const copiedPatches = (packageManager.name === "pnpm" || packageManager.name === "bun") && !config.forceNpm ? await copyPatches({
1796
2411
  workspaceRootDir,
2412
+ targetPackageDir,
1797
2413
  targetPackageManifest: outputManifest,
1798
2414
  packagesRegistry,
2415
+ internalDepPackageNames: internalPackageNames,
1799
2416
  isolateDir,
1800
2417
  includeDevDependencies: config.includeDevDependencies
1801
2418
  }) : {};
@@ -1824,7 +2441,7 @@ function createIsolator(config) {
1824
2441
  const patchEntries = Object.fromEntries(Object.entries(copiedPatches).map(([spec, patchFile]) => [spec, patchFile.path]));
1825
2442
  if (packageManager.name === "bun") manifest.patchedDependencies = patchEntries;
1826
2443
  else {
1827
- if (!manifest.pnpm) manifest.pnpm = {};
2444
+ manifest.pnpm ??= {};
1828
2445
  manifest.pnpm.patchedDependencies = patchEntries;
1829
2446
  }
1830
2447
  log.debug(`Added ${Object.keys(copiedPatches).length} patches to isolated package.json`);
@@ -1891,6 +2508,6 @@ async function isolate(config) {
1891
2508
  return createIsolator(config)();
1892
2509
  }
1893
2510
  //#endregion
1894
- export { loadConfigFromFile as a, detectPackageManager as c, defineConfig as i, readTypedJson as l, listInternalPackages as n, resolveConfig as o, createPackagesRegistry as r, resolveWorkspacePaths as s, isolate as t, filterObjectUndefined as u };
2511
+ export { defineConfig as a, resolveWorkspacePaths as c, detectPackageManager as i, readTypedJson as l, listInternalPackages as n, loadConfigFromFile as o, createPackagesRegistry as r, resolveConfig as s, isolate as t, filterObjectUndefined as u };
1895
2512
 
1896
- //# sourceMappingURL=isolate-DyRD5Zd_.mjs.map
2513
+ //# sourceMappingURL=isolate-ts-Igq7C.mjs.map