isolate-package 1.33.0-0 → 1.34.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 (67) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/{isolate-DTwgcMAN.mjs → isolate-DI3eUTci.mjs} +576 -242
  4. package/dist/isolate-DI3eUTci.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +5 -6
  6. package/dist/isolate-bin.mjs.map +1 -1
  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 +20 -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 +21 -11
  15. package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
  16. package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +7 -7
  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 +2 -2
  21. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +6 -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/helpers/adapt-internal-package-manifests.test.ts +3 -3
  25. package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +2 -2
  26. package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
  27. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
  28. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
  29. package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
  30. package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
  31. package/src/lib/manifest/io.ts +6 -2
  32. package/src/lib/manifest/validate-manifest.ts +2 -2
  33. package/src/lib/output/get-build-output-dir.ts +1 -1
  34. package/src/lib/output/pack-dependencies.ts +1 -1
  35. package/src/lib/output/process-build-output-files.ts +6 -17
  36. package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
  37. package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
  38. package/src/lib/package-manager/index.ts +1 -1
  39. package/src/lib/package-manager/names.ts +8 -10
  40. package/src/lib/patches/collect-installed-names-bun.test.ts +2 -2
  41. package/src/lib/patches/collect-installed-names-bun.ts +8 -8
  42. package/src/lib/patches/collect-installed-names-pnpm.test.ts +1 -1
  43. package/src/lib/patches/collect-installed-names-pnpm.ts +13 -12
  44. package/src/lib/patches/copy-patches.test.ts +5 -13
  45. package/src/lib/patches/copy-patches.ts +9 -9
  46. package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
  47. package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
  48. package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
  49. package/src/lib/registry/create-packages-registry.ts +34 -31
  50. package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
  51. package/src/lib/registry/list-internal-packages.test.ts +2 -2
  52. package/src/lib/types.ts +2 -2
  53. package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
  54. package/src/lib/utils/filter-patched-dependencies.ts +2 -2
  55. package/src/lib/utils/get-dirname.ts +1 -1
  56. package/src/lib/utils/index.ts +1 -1
  57. package/src/lib/utils/json.ts +12 -14
  58. package/src/lib/utils/pack.ts +32 -22
  59. package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
  60. package/src/lib/utils/reset-isolate-dir.ts +147 -0
  61. package/src/lib/utils/unpack.test.ts +76 -0
  62. package/src/lib/utils/unpack.ts +16 -10
  63. package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
  64. package/src/lib/utils/wait-for-complete-file.ts +44 -0
  65. package/src/lib/utils/yaml.ts +8 -9
  66. package/src/testing/setup.ts +1 -1
  67. package/dist/isolate-DTwgcMAN.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;
293
- }
294
- function shouldUsePnpmPack() {
295
- const { name, majorVersion } = usePackageManager();
296
- return name === "pnpm" && majorVersion >= 8;
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("-");
297
279
  }
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,6 +452,104 @@ function resolveConfig(initialConfig) {
486
452
  return config;
487
453
  }
488
454
  //#endregion
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
+ }
533
+ /**
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.
537
+ */
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.
544
+ */
545
+ packageManager = inferFromManifest(workspaceRootDir) ?? inferFromFiles(workspaceRootDir);
546
+ return packageManager;
547
+ }
548
+ function shouldUsePnpmPack() {
549
+ const { name, majorVersion } = usePackageManager();
550
+ return name === "pnpm" && majorVersion >= 8;
551
+ }
552
+ //#endregion
489
553
  //#region src/lib/lockfile/helpers/bun-lockfile.ts
490
554
  /** Extract dependency names from a workspace entry. */
491
555
  function collectDependencyNames(entry, includeDevDependencies) {
@@ -533,8 +597,8 @@ function getPackageInfoObject(entry) {
533
597
  function collectRequiredPackages(directDependencyNames, packages) {
534
598
  const required = /* @__PURE__ */ new Set();
535
599
  const queue = [...directDependencyNames];
536
- while (queue.length > 0) {
537
- const name = queue.pop();
600
+ let name;
601
+ while ((name = queue.pop()) !== void 0) {
538
602
  if (required.has(name)) continue;
539
603
  const entry = packages[name];
540
604
  if (!entry) continue;
@@ -546,15 +610,19 @@ function collectRequiredPackages(directDependencyNames, packages) {
546
610
  "dependencies",
547
611
  "optionalDependencies",
548
612
  "peerDependencies"
549
- ]) {
550
- const deps = info[depField];
551
- if (deps && typeof deps === "object") {
552
- for (const depName of Object.keys(deps)) if (!required.has(depName)) queue.push(depName);
553
- }
554
- }
613
+ ]) enqueueDeps(info[depField], required, queue);
555
614
  }
556
615
  return required;
557
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
+ }
558
626
  //#endregion
559
627
  //#region src/lib/lockfile/helpers/generate-bun-lockfile.ts
560
628
  /**
@@ -659,9 +727,9 @@ async function generateBunLockfile({ workspaceRootDir, targetPackageDir, isolate
659
727
  /** Append trailing newline to match Bun's native output format */
660
728
  await fs.writeFile(outputPath, serializeWithTrailingCommas(outputLockfile) + "\n");
661
729
  log.debug("Created lockfile at", outputPath);
662
- } catch (err) {
663
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
664
- throw err;
730
+ } catch (error) {
731
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
732
+ throw error;
665
733
  }
666
734
  }
667
735
  //#endregion
@@ -709,9 +777,9 @@ async function generateNpmLockfile({ workspaceRootDir, isolateDir, targetPackage
709
777
  });
710
778
  }
711
779
  log.debug("Created lockfile at", path.join(isolateDir, "package-lock.json"));
712
- } catch (err) {
713
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
714
- throw err;
780
+ } catch (error) {
781
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
782
+ throw error;
715
783
  }
716
784
  }
717
785
  async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
@@ -727,7 +795,7 @@ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPa
727
795
  const rootTree = await arborist.loadVirtual();
728
796
  const targetImporterNode = arborist.workspaceNodes(rootTree, [targetPackageName])[0];
729
797
  if (!targetImporterNode) throw new Error(`Target workspace "${targetPackageName}" not found in root package-lock.json`);
730
- 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`);
731
799
  /**
732
800
  * `workspaceDependencySet` walks `edgesOut` from each seed node. It does
733
801
  * not add the seed node itself to the result, so ensure the target
@@ -766,6 +834,12 @@ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPa
766
834
  srcData,
767
835
  reachable,
768
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
+ */
769
843
  targetLinkLoc: `node_modules/${targetPackageName}`,
770
844
  targetPackageManifest
771
845
  });
@@ -795,10 +869,16 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
795
869
  const srcPackages = srcData.packages;
796
870
  if (!srcPackages[targetImporterLoc]) throw new Error(`Source lockfile has no entry for target importer "${targetImporterLoc}"`);
797
871
  const targetNestedNodeModulesPrefix = `${targetImporterLoc}/node_modules/`;
798
- /** Track the source location each output entry came from, so we can
799
- * produce a clear error if two source paths remap to the same target.
800
- */
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. */
801
880
  const origLocByNewLoc = /* @__PURE__ */ new Map();
881
+ const collisions = [];
802
882
  for (const node of reachable) {
803
883
  const origLoc = node.location;
804
884
  /** The target's self-link has no place in the isolate (root IS the target). */
@@ -814,20 +894,79 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
814
894
  * `packages/app/lib/core`) are preserved verbatim because their disk
815
895
  * location in the isolate is unchanged.
816
896
  */
817
- let newLoc;
818
- if (origLoc === targetImporterLoc) newLoc = "";
819
- else if (origLoc.startsWith(targetNestedNodeModulesPrefix)) newLoc = origLoc.slice(targetImporterLoc.length + 1);
820
- else newLoc = origLoc;
897
+ const newLoc = remapToOutputLoc(origLoc);
821
898
  const srcEntry = srcPackages[origLoc];
822
899
  if (!srcEntry) throw new Error(`Reachable node "${origLoc}" has no entry in source lockfile packages`);
823
900
  const existing = outPackages[newLoc];
824
901
  if (existing && !entriesAreEquivalent(existing, srcEntry)) {
825
902
  const previousOrigLoc = origLocByNewLoc.get(newLoc) ?? "<unknown>";
826
- 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.`);
827
928
  }
828
929
  outPackages[newLoc] = { ...srcEntry };
829
930
  origLocByNewLoc.set(newLoc, origLoc);
830
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
+ }
831
970
  /**
832
971
  * If the target importer didn't make it into the reachable set for any
833
972
  * reason (upstream Arborist bug, programmer error), bail loudly rather
@@ -835,8 +974,10 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
835
974
  */
836
975
  if (!outPackages[""]) throw new Error(`Target importer "${targetImporterLoc}" was not present in the reachable node set; cannot construct isolate root entry`);
837
976
  /** Overlay the isolate root with the adapted target manifest. */
838
- const rootEntry = { ...outPackages[""] };
839
- rootEntry.name = targetPackageManifest.name;
977
+ const rootEntry = {
978
+ ...outPackages[""],
979
+ name: targetPackageManifest.name
980
+ };
840
981
  if (targetPackageManifest.version) rootEntry.version = targetPackageManifest.version;
841
982
  overlayManifestDeps(rootEntry, targetPackageManifest);
842
983
  /** The isolate is no longer a workspace root. */
@@ -874,6 +1015,55 @@ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targ
874
1015
  function entriesAreEquivalent(a, b) {
875
1016
  return a.version === b.version && a.resolved === b.resolved && a.integrity === b.integrity && !!a.link === !!b.link;
876
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
+ }
877
1067
  function overlayManifestDeps(entry, manifest) {
878
1068
  for (const field of [
879
1069
  "dependencies",
@@ -995,9 +1185,9 @@ async function generatePnpmLockfile({ workspaceRootDir, targetPackageDir, isolat
995
1185
  patchedDependencies
996
1186
  });
997
1187
  log.debug("Created lockfile at", path.join(isolateDir, "pnpm-lock.yaml"));
998
- } catch (err) {
999
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
1000
- throw err;
1188
+ } catch (error) {
1189
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
1190
+ throw error;
1001
1191
  }
1002
1192
  }
1003
1193
  //#endregion
@@ -1023,9 +1213,9 @@ async function generateYarnLockfile({ workspaceRootDir, isolateDir }) {
1023
1213
  log.debug(`Running local install`);
1024
1214
  execSync(`yarn install --cwd ${isolateDir}`);
1025
1215
  log.debug("Generated lockfile at", newLockfilePath);
1026
- } catch (err) {
1027
- log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
1028
- throw err;
1216
+ } catch (error) {
1217
+ log.error(`Failed to generate lockfile: ${getErrorMessage(error)}`);
1218
+ throw error;
1029
1219
  }
1030
1220
  }
1031
1221
  //#endregion
@@ -1102,7 +1292,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
1102
1292
  //#endregion
1103
1293
  //#region src/lib/manifest/io.ts
1104
1294
  async function readManifest(packageDir) {
1105
- return readTypedJson(path.join(packageDir, "package.json"));
1295
+ return await readTypedJson(path.join(packageDir, "package.json"));
1106
1296
  }
1107
1297
  async function writeManifest(outputDir, manifest) {
1108
1298
  await fs.writeFile(path.join(outputDir, "package.json"), JSON.stringify(manifest, null, 2));
@@ -1138,30 +1328,83 @@ function adaptManifestInternalDeps({ manifest, packagesRegistry, parentRootRelat
1138
1328
  }
1139
1329
  //#endregion
1140
1330
  //#region src/lib/manifest/helpers/resolve-catalog-dependencies.ts
1331
+ const catalogSourceCache = /* @__PURE__ */ new Map();
1332
+ /**
1333
+ * Loads catalog definitions by checking pnpm-workspace.yaml first (pnpm
1334
+ * format), then falling back to the root package.json (Bun format).
1335
+ *
1336
+ * Pnpm defines catalogs in pnpm-workspace.yaml:
1337
+ *
1338
+ * ```yaml
1339
+ * catalog:
1340
+ * react: ^18.3.1
1341
+ * catalogs:
1342
+ * react18:
1343
+ * react: ^18.3.1
1344
+ * ```
1345
+ *
1346
+ * Bun defines catalogs in package.json (at root level or under workspaces).
1347
+ */
1348
+ async function loadCatalogSource(workspaceRootDir) {
1349
+ const cached = catalogSourceCache.get(workspaceRootDir);
1350
+ if (cached) return cached;
1351
+ /**
1352
+ * Drop the cache entry if loading fails so a subsequent call can retry
1353
+ * instead of immediately rethrowing the same rejection.
1354
+ */
1355
+ const cachedLoadPromise = (async () => {
1356
+ const log = useLogger();
1357
+ /** Try pnpm-workspace.yaml first. */
1358
+ const workspaceYamlPath = path.join(workspaceRootDir, "pnpm-workspace.yaml");
1359
+ if (await fs.pathExists(workspaceYamlPath)) try {
1360
+ const rawContent = await fs.readFile(workspaceYamlPath, "utf-8");
1361
+ const yamlConfig = yaml.parse(rawContent);
1362
+ if (yamlConfig?.catalog || yamlConfig?.catalogs) return {
1363
+ catalog: yamlConfig.catalog,
1364
+ catalogs: yamlConfig.catalogs
1365
+ };
1366
+ } catch (error) {
1367
+ log.warn(`Failed to parse ${workspaceYamlPath}: ${error instanceof Error ? error.message : String(error)}. Falling back to package.json for catalog definitions.`);
1368
+ }
1369
+ const rootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1370
+ return {
1371
+ catalog: rootManifest.catalog ?? rootManifest.workspaces?.catalog,
1372
+ catalogs: rootManifest.catalogs ?? rootManifest.workspaces?.catalogs
1373
+ };
1374
+ })().catch((error) => {
1375
+ catalogSourceCache.delete(workspaceRootDir);
1376
+ throw error;
1377
+ });
1378
+ catalogSourceCache.set(workspaceRootDir, cachedLoadPromise);
1379
+ return cachedLoadPromise;
1380
+ }
1141
1381
  /**
1142
1382
  * Resolves catalog dependencies by replacing "catalog:" specifiers with their
1143
- * actual versions from the root package.json catalog field.
1383
+ * actual versions.
1144
1384
  *
1145
1385
  * Supports both pnpm and Bun catalog formats:
1146
1386
  *
1147
- * - Pnpm: catalog at root level
1148
- * - Bun: catalog or catalogs at root level, or workspaces.catalog
1387
+ * - Pnpm: catalog/catalogs defined in pnpm-workspace.yaml
1388
+ * - Bun: catalog or catalogs at root level, or workspaces.catalog in
1389
+ * package.json
1149
1390
  */
1150
1391
  async function resolveCatalogDependencies(dependencies, workspaceRootDir) {
1151
1392
  if (!dependencies) return;
1152
1393
  const log = useLogger();
1153
- const rootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1154
- const flatCatalog = rootManifest.catalog || rootManifest.workspaces?.catalog;
1155
- const nestedCatalogs = rootManifest.catalogs || rootManifest.workspaces?.catalogs;
1394
+ const { catalog: flatCatalog, catalogs: nestedCatalogs } = await loadCatalogSource(workspaceRootDir);
1156
1395
  if (!flatCatalog && !nestedCatalogs) return dependencies;
1157
1396
  const resolved = { ...dependencies };
1158
1397
  for (const [packageName, specifier] of Object.entries(dependencies)) if (specifier === "catalog:" || specifier.startsWith("catalog:")) {
1159
1398
  let catalogVersion;
1160
- if (specifier === "catalog:") catalogVersion = flatCatalog?.[packageName];
1161
- else {
1162
- const groupName = specifier.slice(8);
1163
- catalogVersion = nestedCatalogs?.[groupName]?.[packageName];
1164
- }
1399
+ const groupName = specifier === "catalog:" ? "default" : specifier.slice(8);
1400
+ if (groupName === "default")
1401
+ /**
1402
+ * Per pnpm semantics, `catalog:` and `catalog:default` are
1403
+ * equivalent: the default catalog can live under the top-level
1404
+ * `catalog` field or under `catalogs.default`, so check both.
1405
+ */
1406
+ catalogVersion = flatCatalog?.[packageName] ?? nestedCatalogs?.default?.[packageName];
1407
+ else catalogVersion = nestedCatalogs?.[groupName]?.[packageName];
1165
1408
  if (catalogVersion) {
1166
1409
  log.debug(`Resolving catalog dependency ${packageName}: "${specifier}" -> "${catalogVersion}"`);
1167
1410
  resolved[packageName] = catalogVersion;
@@ -1212,7 +1455,7 @@ async function adaptInternalPackageManifests({ internalPackageNames, packagesReg
1212
1455
  */
1213
1456
  async function adoptPnpmFieldsFromRoot(targetPackageManifest, workspaceRootDir) {
1214
1457
  if (isRushWorkspace(workspaceRootDir)) return targetPackageManifest;
1215
- const rootPackageManifest = await readTypedJson(path$1.join(workspaceRootDir, "package.json"));
1458
+ const rootPackageManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1216
1459
  if (usePackageManager().name === "bun") return adoptBunFieldsFromRoot(targetPackageManifest, rootPackageManifest);
1217
1460
  return adoptPnpmFieldsOnly(targetPackageManifest, rootPackageManifest);
1218
1461
  }
@@ -1232,7 +1475,7 @@ function adoptBunFieldsFromRoot(targetPackageManifest, rootPackageManifest) {
1232
1475
  }
1233
1476
  /** Adopt pnpm-specific fields from the root manifest */
1234
1477
  function adoptPnpmFieldsOnly(targetPackageManifest, rootPackageManifest) {
1235
- const { overrides, onlyBuiltDependencies, ignoredBuiltDependencies } = rootPackageManifest.pnpm || {};
1478
+ const { overrides, onlyBuiltDependencies, ignoredBuiltDependencies } = rootPackageManifest.pnpm ?? {};
1236
1479
  /** If no pnpm fields are present, return the original manifest */
1237
1480
  if (!overrides && !onlyBuiltDependencies && !ignoredBuiltDependencies) return targetPackageManifest;
1238
1481
  const pnpmConfig = {};
@@ -1268,7 +1511,16 @@ async function adaptTargetPackageManifest({ manifest, packagesRegistry, workspac
1268
1511
  manifest: manifestWithResolvedCatalogs,
1269
1512
  packagesRegistry
1270
1513
  }),
1514
+ /**
1515
+ * Adopt the package manager definition from the root manifest if available.
1516
+ * The option to omit is there because some platforms might not handle it
1517
+ * properly (Cloud Run, April 24th 2024, does not handle pnpm v9)
1518
+ */
1271
1519
  packageManager: omitPackageManager ? void 0 : packageManager.packageManagerString,
1520
+ /**
1521
+ * Scripts are removed by default if not explicitly picked or omitted via
1522
+ * config.
1523
+ */
1272
1524
  scripts: pickFromScripts ? pick(manifest.scripts ?? {}, pickFromScripts) : omitFromScripts ? omit(manifest.scripts ?? {}, omitFromScripts) : {}
1273
1525
  };
1274
1526
  }
@@ -1299,8 +1551,8 @@ function validateManifestMandatoryFields(manifest, packagePath, requireFilesFiel
1299
1551
  * packed
1300
1552
  */
1301
1553
  if (requireFilesField && (!manifest.files || !Array.isArray(manifest.files) || manifest.files.length === 0)) missingFields.push("files");
1302
- if (missingFields.length > 0) {
1303
- const field = missingFields[0];
1554
+ const [field] = missingFields;
1555
+ if (field) {
1304
1556
  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`;
1305
1557
  log.error(errorMessage);
1306
1558
  throw new Error(errorMessage);
@@ -1309,7 +1561,7 @@ function validateManifestMandatoryFields(manifest, packagePath, requireFilesFiel
1309
1561
  }
1310
1562
  //#endregion
1311
1563
  //#region src/lib/output/get-build-output-dir.ts
1312
- async function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath }) {
1564
+ function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath }) {
1313
1565
  const log = useLogger();
1314
1566
  if (buildDirName) {
1315
1567
  log.debug("Using buildDirName from config:", buildDirName);
@@ -1332,6 +1584,91 @@ async function getBuildOutputDir({ targetPackageDir, buildDirName, tsconfigPath
1332
1584
  }
1333
1585
  }
1334
1586
  //#endregion
1587
+ //#region src/lib/utils/wait-for-complete-file.ts
1588
+ /**
1589
+ * Wait until the given file exists and its size has stopped changing across
1590
+ * two consecutive polls. Resolves once the file is considered fully written,
1591
+ * rejects with a timeout error otherwise.
1592
+ *
1593
+ * This is a cheap proxy for "the writer has finished flushing" without
1594
+ * inspecting file contents or relying on platform-specific signals. It is
1595
+ * intended for cases where an external process (e.g. `pnpm pack`) may report
1596
+ * completion before its output is fully visible on disk.
1597
+ */
1598
+ async function waitForCompleteFile(filePath, { timeoutMs, pollMs }) {
1599
+ const deadline = Date.now() + timeoutMs;
1600
+ let lastSize = -1;
1601
+ while (Date.now() < deadline) {
1602
+ try {
1603
+ const { size } = await fs$1.promises.stat(filePath);
1604
+ if (size > 0 && size === lastSize) return;
1605
+ lastSize = size;
1606
+ } catch (error) {
1607
+ if (error.code !== "ENOENT") throw error;
1608
+ }
1609
+ await new Promise((resolve) => {
1610
+ setTimeout(resolve, pollMs);
1611
+ });
1612
+ }
1613
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for file to be written: ${filePath}`);
1614
+ }
1615
+ //#endregion
1616
+ //#region src/lib/utils/pack.ts
1617
+ /**
1618
+ * How long to wait for the packed tarball to appear and stop growing on disk
1619
+ * after `pnpm pack` / `npm pack` has exited.
1620
+ */
1621
+ const PACK_FILE_READY_TIMEOUT_MS = 5e3;
1622
+ const PACK_FILE_READY_POLL_MS = 50;
1623
+ async function pack(srcDir, dstDir) {
1624
+ const log = useLogger();
1625
+ const execOptions = {
1626
+ cwd: srcDir,
1627
+ maxBuffer: 10 * 1024 * 1024
1628
+ };
1629
+ /**
1630
+ * PNPM pack seems to be a lot faster than NPM pack, so when PNPM is detected
1631
+ * we use that instead.
1632
+ */
1633
+ const packStdout = shouldUsePnpmPack() ? await new Promise((resolve, reject) => {
1634
+ exec(`pnpm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
1635
+ if (err) {
1636
+ log.error(getErrorMessage(err));
1637
+ reject(err);
1638
+ return;
1639
+ }
1640
+ resolve(stdout);
1641
+ });
1642
+ }) : await new Promise((resolve, reject) => {
1643
+ exec(`npm pack --pack-destination "${dstDir}"`, execOptions, (err, stdout) => {
1644
+ if (err) {
1645
+ reject(err);
1646
+ return;
1647
+ }
1648
+ resolve(stdout);
1649
+ });
1650
+ });
1651
+ const lastLine = packStdout.trim().split("\n").at(-1);
1652
+ assert(lastLine, `Failed to parse last line from stdout: ${packStdout.trim()}`);
1653
+ const fileName = path.basename(lastLine);
1654
+ assert(fileName, `Failed to parse file name from: ${lastLine}`);
1655
+ const filePath = path.join(dstDir, fileName);
1656
+ /**
1657
+ * `pnpm pack` (and occasionally `npm pack`) can return before the tarball is
1658
+ * fully visible/flushed to disk. A naive `existsSync` check is not enough:
1659
+ * the directory entry can appear before the file's data has been written,
1660
+ * which causes downstream consumers (gunzip + tar) to fail with
1661
+ * "unexpected end of file". Wait until the file exists and its size has
1662
+ * stopped changing across two consecutive polls before returning.
1663
+ */
1664
+ await waitForCompleteFile(filePath, {
1665
+ timeoutMs: PACK_FILE_READY_TIMEOUT_MS,
1666
+ pollMs: PACK_FILE_READY_POLL_MS
1667
+ });
1668
+ log.debug(`Packed (temp)/${fileName}`);
1669
+ return filePath;
1670
+ }
1671
+ //#endregion
1335
1672
  //#region src/lib/output/pack-dependencies.ts
1336
1673
  /**
1337
1674
  * Pack dependencies so that we extract only the files that are supposed to be
@@ -1360,18 +1697,13 @@ async function packDependencies({ packagesRegistry, internalPackageNames, packDe
1360
1697
  }
1361
1698
  //#endregion
1362
1699
  //#region src/lib/output/process-build-output-files.ts
1363
- const TIMEOUT_MS = 5e3;
1364
1700
  async function processBuildOutputFiles({ targetPackageDir, tmpDir, isolateDir }) {
1365
- const log = useLogger();
1366
1701
  const packedFilePath = await pack(targetPackageDir, tmpDir);
1367
1702
  const unpackDir = path.join(tmpDir, "target");
1368
- const now = Date.now();
1369
- let isWaitingYet = false;
1370
- while (!fs.existsSync(packedFilePath) && Date.now() - now < TIMEOUT_MS) {
1371
- if (!isWaitingYet) log.debug(`Waiting for ${packedFilePath} to become available...`);
1372
- isWaitingYet = true;
1373
- await new Promise((resolve) => setTimeout(resolve, 100));
1374
- }
1703
+ /**
1704
+ * `pack` already waits for the tarball to be fully written before returning,
1705
+ * so it is safe to unpack immediately.
1706
+ */
1375
1707
  await unpack(packedFilePath, unpackDir);
1376
1708
  await fs.copy(path.join(unpackDir, "package"), isolateDir);
1377
1709
  }
@@ -1444,7 +1776,8 @@ function collectReachablePackageNames({ targetPackageManifest, packagesRegistry,
1444
1776
  */
1445
1777
  function findPackagesGlobs(workspaceRootDir) {
1446
1778
  const log = useLogger();
1447
- switch (usePackageManager().name) {
1779
+ const packageManager = usePackageManager();
1780
+ switch (packageManager.name) {
1448
1781
  case "pnpm": {
1449
1782
  const workspaceConfig = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"));
1450
1783
  if (!workspaceConfig) throw new Error("pnpm-workspace.yaml file is empty. Please specify packages configuration.");
@@ -1460,17 +1793,16 @@ function findPackagesGlobs(workspaceRootDir) {
1460
1793
  const { workspaces } = readTypedJsonSync(workspaceRootManifestPath);
1461
1794
  if (!workspaces) throw new Error(`No workspaces field found in ${workspaceRootManifestPath}`);
1462
1795
  if (Array.isArray(workspaces)) return workspaces;
1463
- else {
1464
- /**
1465
- * For Yarn, workspaces could be defined as an object with { packages:
1466
- * [], nohoist: [] }. See
1467
- * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
1468
- */
1469
- const workspacesObject = workspaces;
1470
- assert(workspacesObject.packages, "workspaces.packages must be an array");
1471
- return workspacesObject.packages;
1472
- }
1796
+ /**
1797
+ * For Yarn, workspaces could be defined as an object with { packages: [],
1798
+ * nohoist: [] }. See
1799
+ * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
1800
+ */
1801
+ const workspacesObject = workspaces;
1802
+ assert(Array.isArray(workspacesObject.packages), "workspaces.packages must be an array");
1803
+ return workspacesObject.packages;
1473
1804
  }
1805
+ default: throw new Error(`Unsupported package manager: ${packageManager.name}`);
1474
1806
  }
1475
1807
  }
1476
1808
  //#endregion
@@ -1489,15 +1821,14 @@ async function createPackagesRegistry(workspaceRootDir, workspacePackagesOverrid
1489
1821
  const manifestPath = path.join(absoluteDir, "package.json");
1490
1822
  if (!fs.existsSync(manifestPath)) {
1491
1823
  log.warn(`Ignoring directory ${rootRelativeDir} because it does not contain a package.json file`);
1492
- return;
1493
- } else {
1494
- log.debug(`Registering package ${rootRelativeDir}`);
1495
- return {
1496
- manifest: await readTypedJson(path.join(absoluteDir, "package.json")),
1497
- rootRelativeDir,
1498
- absoluteDir
1499
- };
1824
+ return null;
1500
1825
  }
1826
+ log.debug(`Registering package ${rootRelativeDir}`);
1827
+ return {
1828
+ manifest: await readTypedJson(path.join(absoluteDir, "package.json")),
1829
+ rootRelativeDir,
1830
+ absoluteDir
1831
+ };
1501
1832
  }))).reduce((acc, info) => {
1502
1833
  if (info) acc[info.manifest.name] = info;
1503
1834
  return acc;
@@ -1577,7 +1908,7 @@ function collectInstalledNamesFromBunLockfile({ workspaceRootDir, targetPackageD
1577
1908
  const pkg = packagesRegistry[name];
1578
1909
  if (!pkg) return null;
1579
1910
  return pkg.rootRelativeDir.split(path.sep).join(path.posix.sep);
1580
- }).filter((key) => Boolean(key));
1911
+ }).filter(Boolean);
1581
1912
  const directDependencyNames = /* @__PURE__ */ new Set();
1582
1913
  const targetEntry = lockfile.workspaces[targetWorkspaceKey];
1583
1914
  if (targetEntry) for (const name of collectDependencyNames(targetEntry, includeDevDependencies)) directDependencyNames.add(name);
@@ -1588,8 +1919,8 @@ function collectInstalledNamesFromBunLockfile({ workspaceRootDir, targetPackageD
1588
1919
  for (const name of collectDependencyNames(entry, false)) directDependencyNames.add(name);
1589
1920
  }
1590
1921
  return collectRequiredPackages(directDependencyNames, lockfile.packages);
1591
- } catch (err) {
1592
- log.debug(`Failed to walk bun.lock for installed names: ${err instanceof Error ? err.message : String(err)}`);
1922
+ } catch (error) {
1923
+ log.debug(`Failed to walk bun.lock for installed names: ${error instanceof Error ? error.message : String(error)}`);
1593
1924
  return /* @__PURE__ */ new Set();
1594
1925
  }
1595
1926
  }
@@ -1626,7 +1957,7 @@ async function collectInstalledNamesFromPnpmLockfile({ workspaceRootDir, targetP
1626
1957
  * normalized id used as the importers map key.
1627
1958
  */
1628
1959
  const targetImporterId = toLockfileImporterKey(useVersion9 ? getLockfileImporterId$1(workspaceRootDir, targetPackageDir) : getLockfileImporterId(workspaceRootDir, targetPackageDir), isRush);
1629
- const importerIds = [targetImporterId, ...internalDepPackageNames.map((name) => packagesRegistry[name]?.rootRelativeDir).filter((dir) => Boolean(dir)).map((dir) => toLockfileImporterKey(dir, isRush))];
1960
+ const importerIds = [targetImporterId, ...internalDepPackageNames.map((name) => packagesRegistry[name]?.rootRelativeDir).filter(Boolean).map((dir) => toLockfileImporterKey(dir, isRush))];
1630
1961
  const packages = lockfile.packages;
1631
1962
  if (!packages) {
1632
1963
  log.debug("Lockfile has no packages section to walk");
@@ -1646,8 +1977,8 @@ async function collectInstalledNamesFromPnpmLockfile({ workspaceRootDir, targetP
1646
1977
  includeDevDependencies: importerId === targetImporterId && includeDevDependencies
1647
1978
  });
1648
1979
  }
1649
- while (queue.length > 0) {
1650
- const depPath = queue.pop();
1980
+ let depPath;
1981
+ while ((depPath = queue.pop()) !== void 0) {
1651
1982
  if (seen.has(depPath)) continue;
1652
1983
  seen.add(depPath);
1653
1984
  names.add(extractPackageName(depPath));
@@ -1664,8 +1995,8 @@ async function collectInstalledNamesFromPnpmLockfile({ workspaceRootDir, targetP
1664
1995
  collectNames(pkg.peerDependencies, names);
1665
1996
  }
1666
1997
  return names;
1667
- } catch (err) {
1668
- log.debug(`Failed to walk pnpm lockfile for installed names: ${err instanceof Error ? err.message : String(err)}`);
1998
+ } catch (error) {
1999
+ log.debug(`Failed to walk pnpm lockfile for installed names: ${error instanceof Error ? error.message : String(error)}`);
1669
2000
  return /* @__PURE__ */ new Set();
1670
2001
  }
1671
2002
  }
@@ -1751,7 +2082,7 @@ function refToRelativeV8(reference, pkgName) {
1751
2082
  */
1752
2083
  function extractPackageName(depPath) {
1753
2084
  const peerStart = indexOfPeersSuffix(depPath);
1754
- const trimmed = peerStart === -1 ? depPath : depPath.substring(0, peerStart);
2085
+ const trimmed = peerStart === -1 ? depPath : depPath.slice(0, peerStart);
1755
2086
  if (trimmed.startsWith("/")) {
1756
2087
  /** v8 v5-style: `/<name>/<version>` */
1757
2088
  const stripped = trimmed.slice(1);
@@ -1961,16 +2292,16 @@ function writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, copiedPatches
1961
2292
  //#endregion
1962
2293
  //#region src/isolate.ts
1963
2294
  const __dirname = getDirname(import.meta.url);
1964
- function createIsolator(config) {
1965
- const resolvedConfig = resolveConfig(config);
1966
- return async function isolate() {
2295
+ function createIsolator(initialConfig) {
2296
+ const resolvedConfig = resolveConfig(initialConfig);
2297
+ return async function runIsolate() {
1967
2298
  const config = resolvedConfig;
1968
2299
  setLogLevel(config.logLevel);
1969
2300
  const log = useLogger();
1970
2301
  const { version: libraryVersion } = await readTypedJson(path.join(path.join(__dirname, "..", "package.json")));
1971
2302
  log.debug("Using isolate-package version", libraryVersion);
1972
2303
  const { targetPackageDir, workspaceRootDir } = resolveWorkspacePaths(config);
1973
- const buildOutputDir = await getBuildOutputDir({
2304
+ const buildOutputDir = getBuildOutputDir({
1974
2305
  targetPackageDir,
1975
2306
  buildDirName: config.buildDirName,
1976
2307
  tsconfigPath: config.tsconfigPath
@@ -1980,11 +2311,14 @@ function createIsolator(config) {
1980
2311
  log.debug("Isolate target package", getRootRelativeLogPath(targetPackageDir, workspaceRootDir));
1981
2312
  const isolateDir = path.join(targetPackageDir, config.isolateDirName);
1982
2313
  log.debug("Isolate output directory", getRootRelativeLogPath(isolateDir, workspaceRootDir));
1983
- if (fs.existsSync(isolateDir)) {
1984
- await fs.remove(isolateDir);
1985
- log.debug("Cleaned the existing isolate output directory");
1986
- }
1987
- await fs.ensureDir(isolateDir);
2314
+ /**
2315
+ * Place the trash sibling outside `targetPackageDir`, since that directory
2316
+ * is later packed by `processBuildOutputFiles`. Keeping the trash next to
2317
+ * the target package (still on the same filesystem) preserves the atomic
2318
+ * rename without risking that the trash gets picked up by `npm pack` or
2319
+ * races with that step.
2320
+ */
2321
+ await resetIsolateDir(isolateDir, { trashParentDir: path.dirname(targetPackageDir) });
1988
2322
  const tmpDir = path.join(isolateDir, "__tmp");
1989
2323
  await fs.ensureDir(tmpDir);
1990
2324
  const targetPackageManifest = await readTypedJson(path.join(targetPackageDir, "package.json"));
@@ -2089,7 +2423,7 @@ function createIsolator(config) {
2089
2423
  const patchEntries = Object.fromEntries(Object.entries(copiedPatches).map(([spec, patchFile]) => [spec, patchFile.path]));
2090
2424
  if (packageManager.name === "bun") manifest.patchedDependencies = patchEntries;
2091
2425
  else {
2092
- if (!manifest.pnpm) manifest.pnpm = {};
2426
+ manifest.pnpm ??= {};
2093
2427
  manifest.pnpm.patchedDependencies = patchEntries;
2094
2428
  }
2095
2429
  log.debug(`Added ${Object.keys(copiedPatches).length} patches to isolated package.json`);
@@ -2156,6 +2490,6 @@ async function isolate(config) {
2156
2490
  return createIsolator(config)();
2157
2491
  }
2158
2492
  //#endregion
2159
- 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 };
2493
+ 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 };
2160
2494
 
2161
- //# sourceMappingURL=isolate-DTwgcMAN.mjs.map
2495
+ //# sourceMappingURL=isolate-DI3eUTci.mjs.map