isolate-package 1.30.0 → 1.32.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 (33) hide show
  1. package/dist/index.d.mts +7 -1
  2. package/dist/index.mjs +1 -1
  3. package/dist/{isolate-CJy3YyKG.mjs → isolate-BRD2AgVJ.mjs} +422 -125
  4. package/dist/isolate-BRD2AgVJ.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +3 -3
  6. package/dist/isolate-bin.mjs.map +1 -1
  7. package/package.json +11 -2
  8. package/src/isolate-bin.ts +2 -2
  9. package/src/isolate.ts +30 -0
  10. package/src/lib/config.ts +31 -6
  11. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package-lock.json +82 -0
  12. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/package.json +8 -0
  13. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/api/package.json +12 -0
  14. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/shared/package.json +12 -0
  15. package/src/lib/lockfile/helpers/__fixtures__/internal-deps/workspace/packages/utils/package.json +11 -0
  16. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package-lock.json +56 -0
  17. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/package.json +8 -0
  18. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/api/package.json +11 -0
  19. package/src/lib/lockfile/helpers/__fixtures__/nested-version-override/workspace/packages/other/package.json +11 -0
  20. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +243 -0
  21. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +604 -0
  22. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +417 -21
  23. package/src/lib/lockfile/process-lockfile.test.ts +4 -0
  24. package/src/lib/lockfile/process-lockfile.ts +14 -16
  25. package/src/lib/patches/copy-patches.test.ts +78 -0
  26. package/src/lib/patches/copy-patches.ts +22 -1
  27. package/src/lib/registry/collect-reachable-package-names.test.ts +239 -0
  28. package/src/lib/registry/collect-reachable-package-names.ts +60 -0
  29. package/src/lib/registry/index.ts +1 -0
  30. package/src/lib/utils/filter-patched-dependencies.test.ts +77 -0
  31. package/src/lib/utils/filter-patched-dependencies.ts +41 -17
  32. package/src/lib/utils/is-rush-workspace.ts +6 -0
  33. package/dist/isolate-CJy3YyKG.mjs.map +0 -1
@@ -4,6 +4,7 @@ import assert from "node:assert";
4
4
  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
+ import { detectMonorepo } from "detect-monorepo";
7
8
  import { pathToFileURL } from "node:url";
8
9
  import { createConsola } from "consola";
9
10
  import { fileURLToPath } from "url";
@@ -74,10 +75,12 @@ function getPackageName(packageSpec) {
74
75
  //#endregion
75
76
  //#region src/lib/utils/filter-patched-dependencies.ts
76
77
  /**
77
- * Filters patched dependencies to only include patches for packages that are
78
- * present in the target package's dependencies based on dependency type.
78
+ * Filters patched dependencies to only include patches for packages that will
79
+ * be present in the isolated output, either as a direct dependency of the
80
+ * target or as a transitive dependency reachable through internal workspace
81
+ * packages.
79
82
  */
80
- function filterPatchedDependencies({ patchedDependencies, targetPackageManifest, includeDevDependencies }) {
83
+ function filterPatchedDependencies({ patchedDependencies, targetPackageManifest, includeDevDependencies, reachableDependencyNames }) {
81
84
  const log = useLogger();
82
85
  if (!patchedDependencies || typeof patchedDependencies !== "object") return;
83
86
  const filteredPatches = {};
@@ -85,27 +88,35 @@ function filterPatchedDependencies({ patchedDependencies, targetPackageManifest,
85
88
  let excludedCount = 0;
86
89
  for (const [packageSpec, patchInfo] of Object.entries(patchedDependencies)) {
87
90
  const packageName = getPackageName(packageSpec);
88
- /** Check if it's a production dependency */
91
+ /** Direct production dependency */
89
92
  if (targetPackageManifest.dependencies?.[packageName]) {
90
93
  filteredPatches[packageSpec] = patchInfo;
91
94
  includedCount++;
92
95
  log.debug(`Including production dependency patch: ${packageSpec}`);
93
96
  continue;
94
97
  }
95
- /** Check if it's a dev dependency and we should include dev dependencies */
96
- if (targetPackageManifest.devDependencies?.[packageName]) {
97
- if (includeDevDependencies) {
98
- filteredPatches[packageSpec] = patchInfo;
99
- includedCount++;
100
- log.debug(`Including dev dependency patch: ${packageSpec}`);
101
- } else {
102
- excludedCount++;
103
- log.debug(`Excluding dev dependency patch: ${packageSpec}`);
104
- }
98
+ /** Direct dev dependency (respects the dev-deps flag) */
99
+ if (includeDevDependencies && targetPackageManifest.devDependencies?.[packageName]) {
100
+ filteredPatches[packageSpec] = patchInfo;
101
+ includedCount++;
102
+ log.debug(`Including dev dependency patch: ${packageSpec}`);
103
+ continue;
104
+ }
105
+ /**
106
+ * Reachable via an internal workspace package. This fires even when the
107
+ * package is also listed in the target's devDependencies with
108
+ * `includeDevDependencies=false`, because the package is still installed
109
+ * in the isolate as a prod transitive.
110
+ */
111
+ if (reachableDependencyNames?.has(packageName)) {
112
+ filteredPatches[packageSpec] = patchInfo;
113
+ includedCount++;
114
+ log.debug(`Including transitive dependency patch: ${packageSpec}`);
105
115
  continue;
106
116
  }
107
- /** Package not found in dependencies or devDependencies */
108
- log.debug(`Excluding patch: ${packageSpec} (package "${packageName}" not in target dependencies)`);
117
+ /** Package won't be installed in the isolate */
118
+ if (targetPackageManifest.devDependencies?.[packageName]) log.debug(`Excluding dev dependency patch: ${packageSpec}`);
119
+ else log.debug(`Excluding patch: ${packageSpec} (package "${packageName}" not reachable from target)`);
109
120
  excludedCount++;
110
121
  }
111
122
  log.debug(`Filtered patches: ${includedCount} included, ${excludedCount} excluded`);
@@ -150,6 +161,12 @@ function inspectValue(value) {
150
161
  /**
151
162
  * Detect if this is a Rush monorepo. They use a very different structure so
152
163
  * there are multiple places where we need to make exceptions based on this.
164
+ *
165
+ * This intentionally only checks the passed-in directory. Using the upward
166
+ * walk of `detectMonorepo` here would break callers that pass a subdirectory
167
+ * of the actual Rush root, because downstream code builds paths (like
168
+ * `common/config/rush`) and lockfile importer ids relative to the same
169
+ * directory it gets.
153
170
  */
154
171
  function isRushWorkspace(workspaceRootDir) {
155
172
  return fs$1.existsSync(path.join(workspaceRootDir, "rush.json"));
@@ -350,7 +367,7 @@ const configDefaults = {
350
367
  targetPackagePath: void 0,
351
368
  tsconfigPath: "./tsconfig.json",
352
369
  workspacePackages: void 0,
353
- workspaceRoot: "../..",
370
+ workspaceRoot: void 0,
354
371
  forceNpm: false,
355
372
  pickFromScripts: void 0,
356
373
  omitFromScripts: void 0,
@@ -432,12 +449,25 @@ function validateConfig(config) {
432
449
  * Resolve the target package directory and workspace root directory from the
433
450
  * configuration. When targetPackagePath is set, the config is assumed to live
434
451
  * at the workspace root. Otherwise it lives in the target package directory.
452
+ *
453
+ * When `workspaceRoot` is not explicitly set, auto-detect the monorepo root by
454
+ * walking upward from the target package directory.
435
455
  */
436
456
  function resolveWorkspacePaths(config) {
437
457
  const targetPackageDir = config.targetPackagePath ? path.join(process.cwd(), config.targetPackagePath) : process.cwd();
458
+ if (config.targetPackagePath) return {
459
+ targetPackageDir,
460
+ workspaceRootDir: process.cwd()
461
+ };
462
+ if (config.workspaceRoot !== void 0) return {
463
+ targetPackageDir,
464
+ workspaceRootDir: path.join(targetPackageDir, config.workspaceRoot)
465
+ };
466
+ const detected = detectMonorepo(targetPackageDir);
467
+ if (!detected) throw new Error(`Failed to auto-detect monorepo workspace root from ${targetPackageDir}. Set the 'workspaceRoot' config option explicitly.`);
438
468
  return {
439
469
  targetPackageDir,
440
- workspaceRootDir: config.targetPackagePath ? process.cwd() : path.join(targetPackageDir, config.workspaceRoot)
470
+ workspaceRootDir: detected.rootDir
441
471
  };
442
472
  }
443
473
  function resolveConfig(initialConfig) {
@@ -650,29 +680,227 @@ async function loadNpmConfig({ npmPath }) {
650
680
  //#endregion
651
681
  //#region src/lib/lockfile/helpers/generate-npm-lockfile.ts
652
682
  /**
653
- * Generate an isolated / pruned lockfile, based on the contents of installed
654
- * node_modules from the monorepo root plus the adapted package manifest in the
655
- * isolate directory.
683
+ * Generate an isolated NPM lockfile for the target package.
684
+ *
685
+ * When a root `package-lock.json` exists we preserve original resolved
686
+ * versions and integrity by copying entries verbatim from the source
687
+ * lockfile. When it doesn't (forceNpm from pnpm/bun/yarn or modern-Yarn
688
+ * fallback), we fall back to Arborist's `buildIdealTree` against the
689
+ * isolate directory, which matches the prior behaviour.
656
690
  */
657
- async function generateNpmLockfile({ workspaceRootDir, isolateDir }) {
691
+ async function generateNpmLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
658
692
  const log = useLogger();
659
- log.debug("Generating NPM lockfile...");
660
- const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
661
693
  try {
662
- if (!fs.existsSync(nodeModulesPath)) throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
663
- const { meta } = await new Arborist({
664
- path: isolateDir,
665
- ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
666
- }).buildIdealTree();
667
- meta?.commit();
668
- const lockfilePath = path.join(isolateDir, "package-lock.json");
669
- await fs.writeFile(lockfilePath, String(meta));
670
- log.debug("Created lockfile at", lockfilePath);
694
+ const rootLockfilePath = path.join(workspaceRootDir, "package-lock.json");
695
+ if (fs.existsSync(rootLockfilePath)) {
696
+ log.debug("Generating NPM lockfile from root package-lock.json...");
697
+ await generateFromRootLockfile({
698
+ workspaceRootDir,
699
+ isolateDir,
700
+ targetPackageName,
701
+ targetPackageManifest,
702
+ packagesRegistry,
703
+ internalDepPackageNames
704
+ });
705
+ } else {
706
+ log.debug("No root package-lock.json found; falling back to buildIdealTree generation");
707
+ await generateViaBuildIdealTree({
708
+ workspaceRootDir,
709
+ isolateDir
710
+ });
711
+ }
712
+ log.debug("Created lockfile at", path.join(isolateDir, "package-lock.json"));
671
713
  } catch (err) {
672
714
  log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`);
673
715
  throw err;
674
716
  }
675
717
  }
718
+ async function generateFromRootLockfile({ workspaceRootDir, isolateDir, targetPackageName, targetPackageManifest, packagesRegistry, internalDepPackageNames }) {
719
+ const log = useLogger();
720
+ const arborist = new Arborist({
721
+ path: workspaceRootDir,
722
+ ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
723
+ });
724
+ /**
725
+ * `loadVirtual` hydrates every Node with `resolved` and `integrity` taken
726
+ * directly from the lockfile entries. It performs no registry calls.
727
+ */
728
+ const rootTree = await arborist.loadVirtual();
729
+ const targetImporterNode = arborist.workspaceNodes(rootTree, [targetPackageName])[0];
730
+ 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`);
732
+ /**
733
+ * `workspaceDependencySet` walks `edgesOut` from each seed node. It does
734
+ * not add the seed node itself to the result, so ensure the target
735
+ * importer is included.
736
+ */
737
+ const reachableNodes = arborist.workspaceDependencySet(rootTree, [targetPackageName], false);
738
+ reachableNodes.add(targetImporterNode);
739
+ const srcData = rootTree.meta?.data;
740
+ if (!srcData || !srcData.packages || Object.keys(srcData.packages).length === 0) {
741
+ /**
742
+ * Arborist normalises v1 lockfiles to v3 in `loadVirtual`, but fall
743
+ * back defensively if the virtual tree still has no `packages` map
744
+ * (e.g. an unusual lockfile shape). The fallback generator reads
745
+ * node_modules and won't preserve original versions, but it will
746
+ * produce a valid lockfile rather than failing.
747
+ */
748
+ useLogger().debug("Source lockfile has no `packages` map; falling back to buildIdealTree");
749
+ await generateViaBuildIdealTree({
750
+ workspaceRootDir,
751
+ isolateDir
752
+ });
753
+ return;
754
+ }
755
+ const reachable = [...reachableNodes].map((node) => ({
756
+ location: node.location,
757
+ isLink: node.isLink,
758
+ target: node.target ? { location: node.target.location } : void 0
759
+ }));
760
+ const internalDepLocs = /* @__PURE__ */ new Map();
761
+ for (const depName of internalDepPackageNames) {
762
+ const pkg = packagesRegistry[depName];
763
+ if (!pkg) throw new Error(`Package ${depName} not found in packages registry`);
764
+ internalDepLocs.set(depName, toPosix(pkg.rootRelativeDir));
765
+ }
766
+ const out = buildIsolatedLockfileJson({
767
+ srcData,
768
+ reachable,
769
+ targetImporterLoc: targetImporterNode.location,
770
+ targetLinkLoc: `node_modules/${targetPackageName}`,
771
+ targetPackageManifest
772
+ });
773
+ /**
774
+ * Overlay each internal dep's adapted manifest onto its lockfile entry
775
+ * so cross-internal-dep references use `file:` instead of `workspace:*`.
776
+ */
777
+ for (const [, depLoc] of internalDepLocs) {
778
+ if (!out.packages[depLoc]) continue;
779
+ const adaptedManifestPath = path.join(isolateDir, depLoc, "package.json");
780
+ if (!fs.existsSync(adaptedManifestPath)) {
781
+ log.debug(`Adapted internal dep manifest missing at ${adaptedManifestPath}; leaving lockfile entry unchanged`);
782
+ continue;
783
+ }
784
+ const adapted = await fs.readJson(adaptedManifestPath);
785
+ overlayManifestDeps(out.packages[depLoc], adapted);
786
+ }
787
+ const outPath = path.join(isolateDir, "package-lock.json");
788
+ await fs.writeFile(outPath, JSON.stringify(out, null, 2) + "\n");
789
+ }
790
+ /**
791
+ * Pure JSON rewrite of the source lockfile into an isolated lockfile.
792
+ * Extracted so it can be unit tested without mocking Arborist.
793
+ */
794
+ function buildIsolatedLockfileJson({ srcData, reachable, targetImporterLoc, targetLinkLoc, targetPackageManifest }) {
795
+ const outPackages = {};
796
+ const srcPackages = srcData.packages;
797
+ if (!srcPackages[targetImporterLoc]) throw new Error(`Source lockfile has no entry for target importer "${targetImporterLoc}"`);
798
+ 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
+ */
802
+ const origLocByNewLoc = /* @__PURE__ */ new Map();
803
+ for (const node of reachable) {
804
+ const origLoc = node.location;
805
+ /** The target's self-link has no place in the isolate (root IS the target). */
806
+ if (origLoc === targetLinkLoc) continue;
807
+ /**
808
+ * The target workspace becomes the isolate root, so:
809
+ * "packages/app" -> ""
810
+ * "packages/app/node_modules/<name>" -> "node_modules/<name>"
811
+ * "packages/app/node_modules/a/node_modules/b" -> "node_modules/a/node_modules/b"
812
+ *
813
+ * Only `node_modules` subpaths under the target are remapped — other
814
+ * paths (e.g. a nested workspace importer like
815
+ * `packages/app/lib/core`) are preserved verbatim because their disk
816
+ * location in the isolate is unchanged.
817
+ */
818
+ let newLoc;
819
+ if (origLoc === targetImporterLoc) newLoc = "";
820
+ else if (origLoc.startsWith(targetNestedNodeModulesPrefix)) newLoc = origLoc.slice(targetImporterLoc.length + 1);
821
+ else newLoc = origLoc;
822
+ const srcEntry = srcPackages[origLoc];
823
+ if (!srcEntry) throw new Error(`Reachable node "${origLoc}" has no entry in source lockfile packages`);
824
+ const existing = outPackages[newLoc];
825
+ if (existing && !entriesAreEquivalent(existing, srcEntry)) {
826
+ 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.`);
828
+ }
829
+ outPackages[newLoc] = { ...srcEntry };
830
+ origLocByNewLoc.set(newLoc, origLoc);
831
+ }
832
+ /**
833
+ * If the target importer didn't make it into the reachable set for any
834
+ * reason (upstream Arborist bug, programmer error), bail loudly rather
835
+ * than emit a synthesised root entry with no source metadata.
836
+ */
837
+ if (!outPackages[""]) throw new Error(`Target importer "${targetImporterLoc}" was not present in the reachable node set; cannot construct isolate root entry`);
838
+ /** Overlay the isolate root with the adapted target manifest. */
839
+ const rootEntry = { ...outPackages[""] };
840
+ rootEntry.name = targetPackageManifest.name;
841
+ if (targetPackageManifest.version) rootEntry.version = targetPackageManifest.version;
842
+ overlayManifestDeps(rootEntry, targetPackageManifest);
843
+ /** The isolate is no longer a workspace root. */
844
+ delete rootEntry.workspaces;
845
+ outPackages[""] = rootEntry;
846
+ /**
847
+ * Spread unknown top-level fields from the source lockfile so future
848
+ * npm-introduced metadata survives isolation. Then override identity
849
+ * fields and the recomputed `packages`, and drop the legacy
850
+ * `dependencies` tree which would be stale now that `packages` has
851
+ * been subsetted.
852
+ */
853
+ const out = {
854
+ ...srcData,
855
+ name: targetPackageManifest.name,
856
+ version: targetPackageManifest.version,
857
+ lockfileVersion: srcData.lockfileVersion ?? 3,
858
+ packages: outPackages
859
+ };
860
+ /**
861
+ * `requires` is propagated via the `...srcData` spread when the source
862
+ * has it. Don't invent one when the source omitted it — that would be
863
+ * an unnecessary diff from the original lockfile shape.
864
+ */
865
+ if (srcData.requires === void 0) delete out.requires;
866
+ delete out.dependencies;
867
+ return out;
868
+ }
869
+ /**
870
+ * Two source entries that map to the same output location are only
871
+ * "equivalent" if they install identical content. We compare the fields
872
+ * that actually determine what npm fetches and stores — version, resolved
873
+ * URL, integrity, and the link flag for workspace links.
874
+ */
875
+ function entriesAreEquivalent(a, b) {
876
+ return a.version === b.version && a.resolved === b.resolved && a.integrity === b.integrity && !!a.link === !!b.link;
877
+ }
878
+ function overlayManifestDeps(entry, manifest) {
879
+ for (const field of [
880
+ "dependencies",
881
+ "devDependencies",
882
+ "optionalDependencies",
883
+ "peerDependencies"
884
+ ]) {
885
+ const value = manifest[field];
886
+ if (value) entry[field] = value;
887
+ else delete entry[field];
888
+ }
889
+ }
890
+ function toPosix(p) {
891
+ return p.split(path.sep).join(path.posix.sep);
892
+ }
893
+ async function generateViaBuildIdealTree({ workspaceRootDir, isolateDir }) {
894
+ const nodeModulesPath = path.join(workspaceRootDir, "node_modules");
895
+ if (!fs.existsSync(nodeModulesPath)) throw new Error(`Failed to find node_modules at ${nodeModulesPath}`);
896
+ const { meta } = await new Arborist({
897
+ path: isolateDir,
898
+ ...(await loadNpmConfig({ npmPath: workspaceRootDir })).flat
899
+ }).buildIdealTree();
900
+ meta?.commit();
901
+ const lockfilePath = path.join(isolateDir, "package-lock.json");
902
+ await fs.writeFile(lockfilePath, String(meta));
903
+ }
676
904
  //#endregion
677
905
  //#region src/lib/lockfile/helpers/pnpm-map-importer.ts
678
906
  /** Convert dependency links */
@@ -810,24 +1038,26 @@ async function generateYarnLockfile({ workspaceRootDir, isolateDir }) {
810
1038
  * be done is to remove the root dependencies and devDependencies, and rename
811
1039
  * the path to the target package to act as the new root.
812
1040
  */
813
- async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir, internalDepPackageNames, targetPackageDir, targetPackageManifest, patchedDependencies, config }) {
1041
+ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir, internalDepPackageNames, targetPackageDir, targetPackageName, targetPackageManifest, patchedDependencies, config }) {
814
1042
  const log = useLogger();
1043
+ const npmGeneratorParams = {
1044
+ workspaceRootDir,
1045
+ isolateDir,
1046
+ targetPackageName,
1047
+ targetPackageManifest,
1048
+ packagesRegistry,
1049
+ internalDepPackageNames
1050
+ };
815
1051
  if (config.forceNpm) {
816
1052
  log.debug("Forcing to use NPM for isolate output");
817
- await generateNpmLockfile({
818
- workspaceRootDir,
819
- isolateDir
820
- });
1053
+ await generateNpmLockfile(npmGeneratorParams);
821
1054
  return true;
822
1055
  }
823
1056
  const { name, majorVersion } = usePackageManager();
824
1057
  let usedFallbackToNpm = false;
825
1058
  switch (name) {
826
1059
  case "npm":
827
- await generateNpmLockfile({
828
- workspaceRootDir,
829
- isolateDir
830
- });
1060
+ await generateNpmLockfile(npmGeneratorParams);
831
1061
  break;
832
1062
  case "yarn":
833
1063
  if (majorVersion === 1) await generateYarnLockfile({
@@ -836,10 +1066,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
836
1066
  });
837
1067
  else {
838
1068
  log.warn("Detected modern version of Yarn. Using NPM lockfile fallback.");
839
- await generateNpmLockfile({
840
- workspaceRootDir,
841
- isolateDir
842
- });
1069
+ await generateNpmLockfile(npmGeneratorParams);
843
1070
  usedFallbackToNpm = true;
844
1071
  }
845
1072
  break;
@@ -868,10 +1095,7 @@ async function processLockfile({ workspaceRootDir, packagesRegistry, isolateDir,
868
1095
  break;
869
1096
  default:
870
1097
  log.warn(`Unexpected package manager ${name}. Using NPM for output`);
871
- await generateNpmLockfile({
872
- workspaceRootDir,
873
- isolateDir
874
- });
1098
+ await generateNpmLockfile(npmGeneratorParams);
875
1099
  usedFallbackToNpm = true;
876
1100
  }
877
1101
  return usedFallbackToNpm;
@@ -1168,82 +1392,48 @@ async function unpackDependencies(packedFilesByName, packagesRegistry, tmpDir, i
1168
1392
  }));
1169
1393
  }
1170
1394
  //#endregion
1171
- //#region src/lib/patches/copy-patches.ts
1172
- async function copyPatches({ workspaceRootDir, targetPackageManifest, isolateDir, includeDevDependencies }) {
1173
- const log = useLogger();
1174
- const { name: packageManagerName } = usePackageManager();
1175
- let patchedDependencies;
1176
- /**
1177
- * Only try reading pnpm-workspace.yaml for pnpm workspaces. Bun workspaces
1178
- * don't have this file and the warning would be noisy.
1179
- */
1180
- if (packageManagerName === "pnpm") try {
1181
- patchedDependencies = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"))?.patchedDependencies;
1182
- } catch (error) {
1183
- log.warn(`Could not read pnpm-workspace.yaml: ${error instanceof Error ? error.message : String(error)}`);
1184
- }
1185
- if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1186
- if (packageManagerName === "pnpm") log.debug("No patched dependencies found in pnpm-workspace.yaml; Falling back to workspace root package.json");
1187
- else log.debug("Reading patched dependencies from workspace root package.json");
1188
- try {
1189
- const workspaceRootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1190
- /** PNPM stores patches under pnpm.patchedDependencies, Bun at the top level */
1191
- patchedDependencies = workspaceRootManifest?.pnpm?.patchedDependencies ?? workspaceRootManifest?.patchedDependencies;
1192
- } catch (error) {
1193
- log.warn(`Could not read workspace root package.json: ${error instanceof Error ? error.message : String(error)}`);
1194
- }
1195
- }
1196
- if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1197
- log.debug("No patched dependencies found in workspace root package.json");
1198
- return {};
1199
- }
1200
- log.debug(`Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace`);
1201
- const filteredPatches = filterPatchedDependencies({
1202
- patchedDependencies,
1203
- targetPackageManifest,
1204
- includeDevDependencies
1205
- });
1206
- if (!filteredPatches) return {};
1207
- /**
1208
- * Read the pnpm lockfile to get patch hashes. Bun doesn't store hashes in
1209
- * its lockfile so we skip this for Bun.
1210
- */
1211
- const lockfilePatchedDependencies = packageManagerName === "pnpm" ? await readLockfilePatchedDependencies(workspaceRootDir) : void 0;
1212
- const copiedPatches = {};
1213
- for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) {
1214
- const sourcePatchPath = path.resolve(workspaceRootDir, patchPath);
1215
- if (!fs.existsSync(sourcePatchPath)) {
1216
- log.warn(`Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}`);
1217
- continue;
1218
- }
1219
- /** Preserve original folder structure */
1220
- const targetPatchPath = path.join(isolateDir, patchPath);
1221
- await fs.ensureDir(path.dirname(targetPatchPath));
1222
- await fs.copy(sourcePatchPath, targetPatchPath);
1223
- log.debug(`Copied patch for ${packageSpec}: ${patchPath}`);
1224
- const hash = (lockfilePatchedDependencies?.[packageSpec])?.hash ?? "";
1225
- if (packageManagerName === "pnpm" && !hash) log.warn(`No hash found for patch ${packageSpec} in lockfile`);
1226
- copiedPatches[packageSpec] = {
1227
- path: patchPath,
1228
- hash
1229
- };
1230
- }
1231
- if (Object.keys(copiedPatches).length > 0) log.debug(`Copied ${Object.keys(copiedPatches).length} patch files`);
1232
- return copiedPatches;
1233
- }
1395
+ //#region src/lib/registry/collect-reachable-package-names.ts
1234
1396
  /**
1235
- * Read the patchedDependencies from the original lockfile to get the hashes.
1236
- * Since the file content is the same after copying, the hash remains valid.
1397
+ * Walk the target manifest and the manifests of any internal (workspace)
1398
+ * packages reachable from it, collecting every dependency name encountered
1399
+ * (both internal and external).
1400
+ *
1401
+ * The resulting set is a superset of the target's direct dependencies: it also
1402
+ * includes dependencies of internal workspace packages that will end up in the
1403
+ * isolated output. This is used to filter workspace-level
1404
+ * `patchedDependencies` so that patches for deps introduced via internal
1405
+ * packages aren't dropped.
1406
+ *
1407
+ * `dependencies`, `optionalDependencies`, and `peerDependencies` are all
1408
+ * walked — any of them can lead to a package being installed in the isolate
1409
+ * (pnpm installs peers by default via `autoInstallPeers`). devDependencies of
1410
+ * internal packages are never followed, and devDependencies of the *target*
1411
+ * are followed only when `includeDevDependencies` is true.
1412
+ *
1413
+ * Note: only recurses through internal packages — manifests of external deps
1414
+ * aren't available here. Deep external→external transitives therefore won't
1415
+ * appear in the set.
1237
1416
  */
1238
- async function readLockfilePatchedDependencies(workspaceRootDir) {
1239
- try {
1240
- const { majorVersion } = usePackageManager();
1241
- const useVersion9 = majorVersion >= 9;
1242
- const lockfileDir = isRushWorkspace(workspaceRootDir) ? path.join(workspaceRootDir, "common/config/rush") : workspaceRootDir;
1243
- return (useVersion9 ? await readWantedLockfile$1(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }))?.patchedDependencies;
1244
- } catch {
1245
- /** Package manager not detected or lockfile not readable */
1246
- return;
1417
+ function collectReachablePackageNames({ targetPackageManifest, packagesRegistry, includeDevDependencies }) {
1418
+ const names = /* @__PURE__ */ new Set();
1419
+ const visitedInternal = /* @__PURE__ */ new Set();
1420
+ walk(targetPackageManifest, true);
1421
+ return names;
1422
+ function walk(manifest, isTarget) {
1423
+ const depNames = [
1424
+ ...Object.keys(manifest.dependencies ?? {}),
1425
+ ...Object.keys(manifest.optionalDependencies ?? {}),
1426
+ ...Object.keys(manifest.peerDependencies ?? {}),
1427
+ ...isTarget && includeDevDependencies ? Object.keys(manifest.devDependencies ?? {}) : []
1428
+ ];
1429
+ for (const name of depNames) {
1430
+ names.add(name);
1431
+ const internalPkg = packagesRegistry[name];
1432
+ if (internalPkg && !visitedInternal.has(name)) {
1433
+ visitedInternal.add(name);
1434
+ walk(internalPkg.manifest, false);
1435
+ }
1436
+ }
1247
1437
  }
1248
1438
  }
1249
1439
  //#endregion
@@ -1363,6 +1553,97 @@ function listInternalPackages(manifest, packagesRegistry, { includeDevDependenci
1363
1553
  return [...new Set(result)];
1364
1554
  }
1365
1555
  //#endregion
1556
+ //#region src/lib/patches/copy-patches.ts
1557
+ async function copyPatches({ workspaceRootDir, targetPackageManifest, packagesRegistry, isolateDir, includeDevDependencies }) {
1558
+ const log = useLogger();
1559
+ const { name: packageManagerName } = usePackageManager();
1560
+ let patchedDependencies;
1561
+ /**
1562
+ * Only try reading pnpm-workspace.yaml for pnpm workspaces. Bun workspaces
1563
+ * don't have this file and the warning would be noisy.
1564
+ */
1565
+ if (packageManagerName === "pnpm") try {
1566
+ patchedDependencies = readTypedYamlSync(path.join(workspaceRootDir, "pnpm-workspace.yaml"))?.patchedDependencies;
1567
+ } catch (error) {
1568
+ log.warn(`Could not read pnpm-workspace.yaml: ${error instanceof Error ? error.message : String(error)}`);
1569
+ }
1570
+ if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1571
+ if (packageManagerName === "pnpm") log.debug("No patched dependencies found in pnpm-workspace.yaml; Falling back to workspace root package.json");
1572
+ else log.debug("Reading patched dependencies from workspace root package.json");
1573
+ try {
1574
+ const workspaceRootManifest = await readTypedJson(path.join(workspaceRootDir, "package.json"));
1575
+ /** PNPM stores patches under pnpm.patchedDependencies, Bun at the top level */
1576
+ patchedDependencies = workspaceRootManifest?.pnpm?.patchedDependencies ?? workspaceRootManifest?.patchedDependencies;
1577
+ } catch (error) {
1578
+ log.warn(`Could not read workspace root package.json: ${error instanceof Error ? error.message : String(error)}`);
1579
+ }
1580
+ }
1581
+ if (!patchedDependencies || Object.keys(patchedDependencies).length === 0) {
1582
+ log.debug("No patched dependencies found in workspace root package.json");
1583
+ return {};
1584
+ }
1585
+ log.debug(`Found ${Object.keys(patchedDependencies).length} patched dependencies in workspace`);
1586
+ /**
1587
+ * Collect the set of dependency names reachable from the target (direct deps
1588
+ * plus deps introduced by internal workspace packages). Patches for names in
1589
+ * this set are preserved even when the target doesn't list them directly —
1590
+ * see issue #167.
1591
+ */
1592
+ const reachableDependencyNames = collectReachablePackageNames({
1593
+ targetPackageManifest,
1594
+ packagesRegistry,
1595
+ includeDevDependencies
1596
+ });
1597
+ const filteredPatches = filterPatchedDependencies({
1598
+ patchedDependencies,
1599
+ targetPackageManifest,
1600
+ includeDevDependencies,
1601
+ reachableDependencyNames
1602
+ });
1603
+ if (!filteredPatches) return {};
1604
+ /**
1605
+ * Read the pnpm lockfile to get patch hashes. Bun doesn't store hashes in
1606
+ * its lockfile so we skip this for Bun.
1607
+ */
1608
+ const lockfilePatchedDependencies = packageManagerName === "pnpm" ? await readLockfilePatchedDependencies(workspaceRootDir) : void 0;
1609
+ const copiedPatches = {};
1610
+ for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) {
1611
+ const sourcePatchPath = path.resolve(workspaceRootDir, patchPath);
1612
+ if (!fs.existsSync(sourcePatchPath)) {
1613
+ log.warn(`Patch file not found: ${getRootRelativeLogPath(sourcePatchPath, workspaceRootDir)}`);
1614
+ continue;
1615
+ }
1616
+ /** Preserve original folder structure */
1617
+ const targetPatchPath = path.join(isolateDir, patchPath);
1618
+ await fs.ensureDir(path.dirname(targetPatchPath));
1619
+ await fs.copy(sourcePatchPath, targetPatchPath);
1620
+ log.debug(`Copied patch for ${packageSpec}: ${patchPath}`);
1621
+ const hash = (lockfilePatchedDependencies?.[packageSpec])?.hash ?? "";
1622
+ if (packageManagerName === "pnpm" && !hash) log.warn(`No hash found for patch ${packageSpec} in lockfile`);
1623
+ copiedPatches[packageSpec] = {
1624
+ path: patchPath,
1625
+ hash
1626
+ };
1627
+ }
1628
+ if (Object.keys(copiedPatches).length > 0) log.debug(`Copied ${Object.keys(copiedPatches).length} patch files`);
1629
+ return copiedPatches;
1630
+ }
1631
+ /**
1632
+ * Read the patchedDependencies from the original lockfile to get the hashes.
1633
+ * Since the file content is the same after copying, the hash remains valid.
1634
+ */
1635
+ async function readLockfilePatchedDependencies(workspaceRootDir) {
1636
+ try {
1637
+ const { majorVersion } = usePackageManager();
1638
+ const useVersion9 = majorVersion >= 9;
1639
+ const lockfileDir = isRushWorkspace(workspaceRootDir) ? path.join(workspaceRootDir, "common/config/rush") : workspaceRootDir;
1640
+ return (useVersion9 ? await readWantedLockfile$1(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }))?.patchedDependencies;
1641
+ } catch {
1642
+ /** Package manager not detected or lockfile not readable */
1643
+ return;
1644
+ }
1645
+ }
1646
+ //#endregion
1366
1647
  //#region src/isolate.ts
1367
1648
  const __dirname = getDirname(import.meta.url);
1368
1649
  function createIsolator(config) {
@@ -1414,6 +1695,21 @@ function createIsolator(config) {
1414
1695
  const isProductionDependency = productionInternalPackageNames.includes(packageName);
1415
1696
  validateManifestMandatoryFields(packageDef.manifest, getRootRelativeLogPath(packageDef.absoluteDir, workspaceRootDir), isProductionDependency);
1416
1697
  }
1698
+ /**
1699
+ * Validate that workspace dev dependencies of all packages being packed
1700
+ * have a version field. Even when dev dependencies are not included in the
1701
+ * isolation output, pnpm pack resolves workspace:* specifiers and requires
1702
+ * the version field to be present.
1703
+ */
1704
+ const validatedPackageNames = new Set(internalPackageNames);
1705
+ const manifestsToPack = [targetPackageManifest, ...internalPackageNames.map((name) => got(packagesRegistry, name).manifest)];
1706
+ for (const manifest of manifestsToPack) for (const depName of Object.keys(manifest.devDependencies ?? {})) {
1707
+ if (validatedPackageNames.has(depName)) continue;
1708
+ const packageDef = packagesRegistry[depName];
1709
+ if (!packageDef) continue;
1710
+ validateManifestMandatoryFields(packageDef.manifest, getRootRelativeLogPath(packageDef.absoluteDir, workspaceRootDir), false);
1711
+ validatedPackageNames.add(depName);
1712
+ }
1417
1713
  await unpackDependencies(await packDependencies({
1418
1714
  internalPackageNames,
1419
1715
  packagesRegistry,
@@ -1447,6 +1743,7 @@ function createIsolator(config) {
1447
1743
  const copiedPatches = (packageManager.name === "pnpm" || packageManager.name === "bun") && !config.forceNpm ? await copyPatches({
1448
1744
  workspaceRootDir,
1449
1745
  targetPackageManifest: outputManifest,
1746
+ packagesRegistry,
1450
1747
  isolateDir,
1451
1748
  includeDevDependencies: config.includeDevDependencies
1452
1749
  }) : {};
@@ -1540,4 +1837,4 @@ async function isolate(config) {
1540
1837
  //#endregion
1541
1838
  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 };
1542
1839
 
1543
- //# sourceMappingURL=isolate-CJy3YyKG.mjs.map
1840
+ //# sourceMappingURL=isolate-BRD2AgVJ.mjs.map