@napi-rs/cli 3.10.6 → 3.10.8

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.
@@ -1,4 +1,4 @@
1
- import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
1
+ import { mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import {
4
4
  dirname,
@@ -18,9 +18,11 @@ import {
18
18
  } from '../def/artifacts.js'
19
19
  import {
20
20
  AVAILABLE_TARGETS,
21
+ canonicalizeManagedPackagePath,
21
22
  commitFileSystemTransaction,
22
23
  debugFactory,
23
24
  fileExists,
25
+ managedPackagePathIsWithin,
24
26
  parseTriple,
25
27
  readFileAsync,
26
28
  readNapiConfig,
@@ -233,15 +235,25 @@ async function collectArtifactsUnlocked(
233
235
  throw error
234
236
  }
235
237
  }
238
+ const previouslyManagedRootEntries = await collectManagedRootEntries(
239
+ packageRoot,
240
+ binaryName,
241
+ wasiSources,
242
+ )
243
+
236
244
  await addArtifactRootEntry({
237
245
  pendingWrites,
238
246
  packageRoot,
239
247
  packageMain: packageJson.main,
248
+ packageExports: packageJson.exports,
249
+ publishConfig: packageJson.publishConfig,
250
+ previouslyManagedRootEntries,
240
251
  binaryName,
241
252
  targets,
242
253
  artifactsByIdentity,
243
254
  wasiTargets,
244
255
  wasiSources,
256
+ requireArtifactSourceRootEntry: options.buildOutputDir !== undefined,
245
257
  })
246
258
 
247
259
  for (const target of wasiTargets) {
@@ -276,7 +288,7 @@ async function collectArtifactsUnlocked(
276
288
  binaryName,
277
289
  targets,
278
290
  pendingWrites,
279
- await collectManagedRootEntries(packageRoot, binaryName, wasiSources),
291
+ previouslyManagedRootEntries,
280
292
  protectedSourcePaths,
281
293
  )
282
294
 
@@ -470,7 +482,7 @@ async function addWasiRootEntry(
470
482
  `WASI artifact source ${source.dir} is incomplete: missing required root entry ${rootEntry}`,
471
483
  )
472
484
  }
473
- const destination = resolveArtifactRelativePath(
485
+ const destination = resolvePackageRootEntry(
474
486
  packageRoot,
475
487
  sourcePath.relative,
476
488
  'WASI root entry destination',
@@ -487,25 +499,283 @@ async function addArtifactRootEntry({
487
499
  pendingWrites,
488
500
  packageRoot,
489
501
  packageMain,
502
+ packageExports,
503
+ publishConfig,
504
+ previouslyManagedRootEntries,
490
505
  binaryName,
491
506
  targets,
492
507
  artifactsByIdentity,
493
508
  wasiTargets,
494
509
  wasiSources,
510
+ requireArtifactSourceRootEntry,
495
511
  }: {
496
512
  pendingWrites: Map<string, PendingWrite>
497
513
  packageRoot: string
498
514
  packageMain: string | undefined
515
+ packageExports: unknown
516
+ publishConfig: { main?: string; exports?: unknown } | undefined
517
+ previouslyManagedRootEntries: string[]
499
518
  binaryName: string
500
519
  targets: Target[]
501
520
  artifactsByIdentity: Map<string, string[]>
502
521
  wasiTargets: Target[]
503
522
  wasiSources: Map<string, WasiArtifactSource>
523
+ requireArtifactSourceRootEntry: boolean
504
524
  }) {
505
525
  const nativeTargets = targets.filter((target) => target.platform !== 'wasi')
506
- const rootCandidates = packageRootEntryCandidates(packageMain)
507
526
 
527
+ // The WASI loader metadata records which file was generated as the package
528
+ // root entry when the loaders were built (`--js` on `napi build`, e.g.
529
+ // `binding.js`). That name can differ from `package.json#main`, which may
530
+ // point at a handwritten wrapper like `index.js`; both files must survive
531
+ // reconciliation, so the recorded entry is a root candidate too.
532
+ const wasiDeclaredRootEntries = new Map<string, string | null>()
533
+ for (const target of wasiTargets) {
534
+ const source = wasiSources.get(target.platformArchABI)
535
+ if (!source) {
536
+ continue
537
+ }
538
+ const bindingPath = source.files.get(
539
+ `${binaryName}.${wasiLoaderSuffix(target.platformArchABI)}.cjs`,
540
+ )
541
+ if (!bindingPath) {
542
+ continue
543
+ }
544
+ const metadata = parseWasiArtifactMetadata(
545
+ await readFileAsync(bindingPath, 'utf8'),
546
+ bindingPath,
547
+ )
548
+ if (metadata === undefined) {
549
+ continue
550
+ }
551
+ wasiDeclaredRootEntries.set(
552
+ target.platformArchABI,
553
+ metadata.rootEntry === null
554
+ ? null
555
+ : resolveArtifactRelativePath(
556
+ packageRoot,
557
+ metadata.rootEntry,
558
+ 'WASI root entry',
559
+ ).relative,
560
+ )
561
+ }
562
+ // All flavors must declare the same root entry: their loaders are
563
+ // interchangeable to consumers, and a divergence means the artifacts came
564
+ // from builds with different `--js` options that cannot be spliced into a
565
+ // coherent package.
566
+ const declaredRootEntryContracts = new Set(wasiDeclaredRootEntries.values())
567
+ if (declaredRootEntryContracts.size > 1) {
568
+ throw new Error(
569
+ `WASI loader metadata declares conflicting root entries: ${[
570
+ ...wasiDeclaredRootEntries,
571
+ ]
572
+ .map(([abi, entry]) => `${abi}: ${entry ?? 'none'}`)
573
+ .join(', ')}`,
574
+ )
575
+ }
576
+ const wasiRootEntries = new Set(
577
+ [...wasiDeclaredRootEntries.values()].filter(
578
+ (entry): entry is string => entry !== null,
579
+ ),
580
+ )
581
+ // Resolve `main` against the effective post-reconciliation file set: the
582
+ // package root plus the native artifact directories, whose contents move
583
+ // to the root during this transaction. A clean package root with the
584
+ // resolved entry beside the `.node` still counts as shared, so the WASI
585
+ // loader copy never occupies the package entry path.
586
+ const nativeArtifactDirs = [
587
+ ...new Set(
588
+ nativeTargets
589
+ .map(
590
+ (target) =>
591
+ artifactsByIdentity.get(artifactName(binaryName, target))?.[0],
592
+ )
593
+ .filter((path): path is string => path !== undefined)
594
+ .map(dirname),
595
+ ),
596
+ ]
597
+ // Previously managed root entries the new loader contract no longer
598
+ // declares are deleted by this transaction, so they cannot satisfy Node's
599
+ // `main` lookup: an old `dist.js` must not shadow a declared
600
+ // `dist/index.js` that outlives it.
601
+ const normalizeRootEntry = (entry: string) => {
602
+ try {
603
+ return resolveArtifactRelativePath(packageRoot, entry, 'root entry')
604
+ .relative
605
+ } catch {
606
+ return null
607
+ }
608
+ }
609
+ const dyingManagedPaths = new Set(
610
+ previouslyManagedRootEntries
611
+ .map(normalizeRootEntry)
612
+ .filter(
613
+ (entry): entry is string =>
614
+ entry !== null && !wasiRootEntries.has(entry),
615
+ ),
616
+ )
617
+ const sharedRootEntryPaths = new Set<string>()
618
+ for (const main of [packageMain, publishConfig?.main]) {
619
+ if (typeof main !== 'string') {
620
+ continue
621
+ }
622
+ for (const path of await packageMainResolutionPaths(
623
+ packageRoot,
624
+ main,
625
+ nativeArtifactDirs,
626
+ dyingManagedPaths,
627
+ )) {
628
+ sharedRootEntryPaths.add(path)
629
+ }
630
+ }
631
+ if (packageMain === undefined) {
632
+ // With no `main` field at all, Node's implicit default is `index.js`.
633
+ sharedRootEntryPaths.add('index.js')
634
+ }
635
+ // Consumers resolve `exports["."]` ahead of `main`, and publishers may
636
+ // swap either field through `publishConfig`, so every declared entry
637
+ // target counts as shared with the native root entry — including
638
+ // auxiliary conditions like `types`, whose targets still name the
639
+ // published declaration file.
640
+ const auxiliaryEntryPaths = new Set<string>()
641
+ for (const field of [packageExports, publishConfig?.exports]) {
642
+ const { runtime, auxiliary } = packageExportTargets(field)
643
+ for (const target of [...runtime, ...auxiliary]) {
644
+ const normalized = normalizeRootEntry(target)
645
+ if (normalized !== null) {
646
+ sharedRootEntryPaths.add(normalized)
647
+ }
648
+ }
649
+ for (const target of auxiliary) {
650
+ const normalized = normalizeRootEntry(target)
651
+ if (normalized !== null) {
652
+ auxiliaryEntryPaths.add(normalized)
653
+ }
654
+ }
655
+ }
656
+ // Native root-entry scan candidates are a narrower set than the shared
657
+ // paths: auxiliary export targets like `types` name the published file
658
+ // but cannot satisfy a runtime `require`, so they must not consume the
659
+ // single root copy. Declared shared entries come first because the
660
+ // metadata's own rootEntry name is the path consumers resolve, and they
661
+ // are admitted regardless of extension since `--js` accepts arbitrary
662
+ // filenames.
663
+ const rootCandidates = [
664
+ ...new Set([
665
+ ...[...wasiRootEntries].filter((entry) =>
666
+ sharedRootEntryPaths.has(entry),
667
+ ),
668
+ ...[...sharedRootEntryPaths].filter(
669
+ (path) => !auxiliaryEntryPaths.has(path),
670
+ ),
671
+ ...packageRootEntryCandidates(packageMain),
672
+ ...(typeof publishConfig?.main === 'string'
673
+ ? packageRootEntryCandidates(publishConfig.main)
674
+ : []),
675
+ ]),
676
+ ]
677
+
678
+ // The root loader below is taken from the threaded WASI target when both
679
+ // flavors are configured, so a distinct root entry must come from that
680
+ // target's source first: the generated entry embeds flavor-specific
681
+ // loader references, and a threadless copy next to the threaded root
682
+ // loader publishes a mismatched pair.
683
+ const rootWasiTarget =
684
+ wasiTargets.find((target) => wasiTargetHasThreads(target)) ?? wasiTargets[0]
685
+ const orderedWasiTargets = [
686
+ ...(rootWasiTarget ? [rootWasiTarget] : []),
687
+ ...wasiTargets.filter((target) => target !== rootWasiTarget),
688
+ ].filter(
689
+ (target, index, list) =>
690
+ list.findIndex(
691
+ (other) => other.platformArchABI === target.platformArchABI,
692
+ ) === index,
693
+ )
694
+
695
+ // A metadata-declared root entry whose name is not a package `main`
696
+ // resolution (e.g. `binding.js` next to a handwritten `index.js`) is a
697
+ // file `napi build` generated alongside the loader; reconciliation must
698
+ // keep it. It is resolved before the native scan so a stale copy beside
699
+ // the `.node` artifacts cannot claim the destination, preferring in
700
+ // order: the WASI artifact source, native artifact dirs, then the
701
+ // existing package-root file below. Entries that share a `main` path skip
702
+ // this path entirely: their WASI-source copy is the WASI loader
703
+ // (`*.wasi.cjs` chain), which must never replace the native root loader.
704
+ // Destinations are compared after normalization since `main` values like
705
+ // `./binding.js` and metadata entries like `binding.js` alias the same
706
+ // package-root path.
707
+ const distinctWasiRootEntries = [...wasiRootEntries].filter(
708
+ (entry) => !sharedRootEntryPaths.has(entry),
709
+ )
710
+ const wasiSourceResolvedEntries = new Set<string>()
711
+ if (nativeTargets.length > 0) {
712
+ for (const entry of distinctWasiRootEntries) {
713
+ const destination = resolvePackageRootEntry(
714
+ packageRoot,
715
+ entry,
716
+ 'WASI root entry destination',
717
+ ).absolute
718
+ let resolved = false
719
+ for (const target of orderedWasiTargets) {
720
+ if (wasiDeclaredRootEntries.get(target.platformArchABI) !== entry) {
721
+ continue
722
+ }
723
+ const source = wasiSources.get(target.platformArchABI)
724
+ if (!source) {
725
+ continue
726
+ }
727
+ const sourcePath = resolveArtifactRelativePath(
728
+ source.dir,
729
+ entry,
730
+ 'WASI root entry',
731
+ )
732
+ if (!(await fileExists(sourcePath.absolute))) {
733
+ continue
734
+ }
735
+ addPendingWrite(
736
+ pendingWrites,
737
+ destination,
738
+ sourcePath.absolute,
739
+ await readFileAsync(sourcePath.absolute),
740
+ )
741
+ wasiSourceResolvedEntries.add(entry)
742
+ resolved = true
743
+ break
744
+ }
745
+ if (resolved) {
746
+ continue
747
+ }
748
+ for (const target of nativeTargets) {
749
+ const artifactPath = artifactsByIdentity.get(
750
+ artifactName(binaryName, target),
751
+ )?.[0]
752
+ if (!artifactPath) {
753
+ continue
754
+ }
755
+ const sourcePath = resolveArtifactRelativePath(
756
+ dirname(artifactPath),
757
+ entry,
758
+ 'native-adjacent WASI root entry',
759
+ )
760
+ if (!(await fileExists(sourcePath.absolute))) {
761
+ continue
762
+ }
763
+ addPendingWrite(
764
+ pendingWrites,
765
+ destination,
766
+ sourcePath.absolute,
767
+ await readFileAsync(sourcePath.absolute),
768
+ )
769
+ break
770
+ }
771
+ }
772
+ }
773
+
774
+ let copiedNativeRoot = false
508
775
  for (const target of nativeTargets) {
776
+ if (copiedNativeRoot) {
777
+ break
778
+ }
509
779
  const artifactPath = artifactsByIdentity.get(
510
780
  artifactName(binaryName, target),
511
781
  )?.[0]
@@ -519,32 +789,45 @@ async function addArtifactRootEntry({
519
789
  candidate,
520
790
  'native root entry',
521
791
  )
522
- if (!(await fileExists(source.absolute))) {
792
+ if (!(await regularFileExists(source.absolute))) {
523
793
  continue
524
794
  }
525
- const destination = resolveArtifactRelativePath(
795
+ const destination = resolvePackageRootEntry(
526
796
  packageRoot,
527
- source.relative,
797
+ candidate,
528
798
  'native root entry destination',
529
799
  )
800
+ if (pendingWrites.has(destination.absolute)) {
801
+ continue
802
+ }
530
803
  addPendingWrite(
531
804
  pendingWrites,
532
805
  destination.absolute,
533
806
  source.absolute,
534
807
  await readFileAsync(source.absolute),
535
808
  )
536
- return
809
+ copiedNativeRoot = true
810
+ break
537
811
  }
538
812
  }
539
813
 
540
814
  if (nativeTargets.length > 0) {
541
- for (const candidate of rootCandidates) {
542
- const existing = resolveArtifactRelativePath(
815
+ // Every root entry candidate that already exists in the package stays
816
+ // managed: keeping it out of pendingWrites would mark it as a stale
817
+ // managed destination and delete it. This must run even when a root entry
818
+ // was copied from an artifact dir above, since a WASI-only candidate
819
+ // such as `binding.js` never sits next to the `.node` artifacts.
820
+ let keptExisting = false
821
+ for (const candidate of [...rootCandidates, ...wasiRootEntries]) {
822
+ const existing = resolvePackageRootEntry(
543
823
  packageRoot,
544
824
  candidate,
545
825
  'existing native root entry',
546
826
  )
547
- if (!(await fileExists(existing.absolute))) {
827
+ if (
828
+ pendingWrites.has(existing.absolute) ||
829
+ !(await regularFileExists(existing.absolute))
830
+ ) {
548
831
  continue
549
832
  }
550
833
  addPendingWrite(
@@ -553,19 +836,54 @@ async function addArtifactRootEntry({
553
836
  existing.absolute,
554
837
  await readFileAsync(existing.absolute),
555
838
  )
839
+ keptExisting = true
840
+ }
841
+ // A declared root entry that no artifact source or existing file
842
+ // resolved would publish a package whose entry point does not exist.
843
+ // Fail loudly like addWasiRootEntry instead of silently dropping it.
844
+ for (const entry of wasiRootEntries) {
845
+ const destination = resolvePackageRootEntry(
846
+ packageRoot,
847
+ entry,
848
+ 'WASI root entry destination',
849
+ ).absolute
850
+ // With an explicit --build-output-dir the WASI output is authoritative:
851
+ // a distinct declared entry it does not contain cannot be satisfied by
852
+ // a possibly stale package-root or native-adjacent copy, the same way
853
+ // the WASI-only path already rejects an incomplete source.
854
+ if (
855
+ requireArtifactSourceRootEntry &&
856
+ distinctWasiRootEntries.includes(entry) &&
857
+ !wasiSourceResolvedEntries.has(entry)
858
+ ) {
859
+ throw new Error(
860
+ `WASI loader metadata declares root entry ${entry}, but the configured build output directory does not contain it`,
861
+ )
862
+ }
863
+ // Only distinct entries must already be pending. Entries shared with
864
+ // the package entry may legitimately be absent, in which case the
865
+ // WASI root fallback below publishes the loader copy.
866
+ if (
867
+ distinctWasiRootEntries.includes(entry) &&
868
+ !pendingWrites.has(destination)
869
+ ) {
870
+ throw new Error(
871
+ `WASI loader metadata declares root entry ${entry}, but it was found in neither the artifact sources nor the package root`,
872
+ )
873
+ }
874
+ }
875
+ if (copiedNativeRoot || keptExisting) {
556
876
  return
557
877
  }
558
878
  }
559
879
 
560
- const rootTarget =
561
- wasiTargets.find((target) => wasiTargetHasThreads(target)) ?? wasiTargets[0]
562
- if (rootTarget) {
880
+ if (rootWasiTarget) {
563
881
  await addWasiRootEntry(
564
882
  pendingWrites,
565
883
  packageRoot,
566
884
  binaryName,
567
- rootTarget,
568
- wasiSources.get(rootTarget.platformArchABI)!,
885
+ rootWasiTarget,
886
+ wasiSources.get(rootWasiTarget.platformArchABI)!,
569
887
  packageMain,
570
888
  )
571
889
  }
@@ -578,6 +896,185 @@ function packageRootEntryCandidates(packageMain: string | undefined) {
578
896
  ]
579
897
  }
580
898
 
899
+ async function regularFileExists(path: string) {
900
+ try {
901
+ return (await stat(path)).isFile()
902
+ } catch {
903
+ return false
904
+ }
905
+ }
906
+
907
+ // Package-root-relative paths a `main` value resolves to as the native root
908
+ // entry, following Node's CommonJS lookup order: the specifier itself, the
909
+ // LOAD_AS_FILE extension variants, then LOAD_AS_DIRECTORY — a nested
910
+ // `package.json#main` or `index.*` inside the directory. Existence is
911
+ // probed in the package root and in `additionalDirs` (the native artifact
912
+ // directories), since artifact files move to the package root in the same
913
+ // transaction. Only the specifier itself is always shared: ESM importers
914
+ // resolve `main` literally, so it names the package entry even when no
915
+ // file exists. Every other candidate is shared only when it is the first
916
+ // existing file in Node's order; an earlier match shadows the later
917
+ // spellings, so they must not suppress a WASI-declared root entry of the
918
+ // same name. With no `main`, the implicit default is `index.js`.
919
+ async function packageMainResolutionPaths(
920
+ packageRoot: string,
921
+ packageMain: string | undefined,
922
+ additionalDirs: string[] = [],
923
+ excludedPaths: ReadonlySet<string> = new Set(),
924
+ ) {
925
+ const paths = new Set<string>()
926
+ const normalizeCandidate = (entry: string) => {
927
+ try {
928
+ return resolveArtifactRelativePath(packageRoot, entry, 'package main')
929
+ .relative
930
+ } catch {
931
+ // A `main` that escapes the package root cannot alias a managed root
932
+ // entry, so it contributes no shared names.
933
+ return null
934
+ }
935
+ }
936
+ const enqueue = (entry: string) => {
937
+ const normalized = normalizeCandidate(entry)
938
+ if (normalized !== null) {
939
+ paths.add(normalized)
940
+ }
941
+ }
942
+ const candidateExists = async (entry: string) => {
943
+ // A package-root file scheduled for deletion cannot satisfy the
944
+ // lookup, and a pending write is the only root change this transaction
945
+ // introduces; artifact directories publish their files unchanged.
946
+ const normalized = normalizeCandidate(entry)
947
+ if (normalized === null || excludedPaths.has(normalized)) {
948
+ return false
949
+ }
950
+ for (const dir of [packageRoot, ...additionalDirs]) {
951
+ if (await regularFileExists(join(dir, entry))) {
952
+ return true
953
+ }
954
+ }
955
+ return false
956
+ }
957
+ if (packageMain === undefined) {
958
+ enqueue('index.js')
959
+ return paths
960
+ }
961
+ // LOAD_AS_FILE: the specifier itself, then the extension variants.
962
+ const visitAsFile = async (specifier: string): Promise<boolean> => {
963
+ for (const candidate of [
964
+ specifier,
965
+ `${specifier}.js`,
966
+ `${specifier}.json`,
967
+ `${specifier}.node`,
968
+ ]) {
969
+ if (await candidateExists(candidate)) {
970
+ enqueue(candidate)
971
+ return true
972
+ }
973
+ }
974
+ return false
975
+ }
976
+ const visitAsIndex = async (directory: string): Promise<boolean> => {
977
+ for (const extension of ['js', 'json', 'node']) {
978
+ const index = join(directory, `index.${extension}`)
979
+ if (await candidateExists(index)) {
980
+ enqueue(index)
981
+ return true
982
+ }
983
+ }
984
+ return false
985
+ }
986
+ const visit = async (specifier: string): Promise<boolean> => {
987
+ // The exact specifier is shared even when it does not exist: ESM
988
+ // importers resolve `main` literally, so it still names the package
989
+ // entry.
990
+ enqueue(specifier)
991
+ if (await candidateExists(specifier)) {
992
+ return true
993
+ }
994
+ for (const extension of ['.js', '.json', '.node']) {
995
+ const candidate = `${specifier}${extension}`
996
+ if (await candidateExists(candidate)) {
997
+ enqueue(candidate)
998
+ return true
999
+ }
1000
+ }
1001
+ // LOAD_AS_DIRECTORY: a nested package.json main is tried as a file and
1002
+ // as an index-bearing directory once — Node does not recurse into
1003
+ // further manifests — then the specifier's own index.* wins.
1004
+ const manifest = join(specifier, 'package.json')
1005
+ if (await regularFileExists(join(packageRoot, manifest))) {
1006
+ let nestedMain: unknown
1007
+ try {
1008
+ nestedMain = JSON.parse(
1009
+ await readFileAsync(join(packageRoot, manifest), 'utf8'),
1010
+ )?.main
1011
+ } catch {
1012
+ nestedMain = undefined
1013
+ }
1014
+ if (typeof nestedMain === 'string' && nestedMain.length > 0) {
1015
+ const nested = join(specifier, nestedMain)
1016
+ if ((await visitAsFile(nested)) || (await visitAsIndex(nested))) {
1017
+ return true
1018
+ }
1019
+ }
1020
+ }
1021
+ return visitAsIndex(specifier)
1022
+ }
1023
+ // When the configured `main` fails to resolve entirely, Node falls back
1024
+ // to `index.*` at the package root.
1025
+ if (!(await visit(packageMain))) {
1026
+ await visitAsIndex('')
1027
+ }
1028
+ return paths
1029
+ }
1030
+
1031
+ // Literal file targets under `exports["."]`, split into runtime targets
1032
+ // that `require`/`import` can load and auxiliary targets guarded only by
1033
+ // non-runtime conditions such as `types`. The top level is a subpath map
1034
+ // only when a key starts with `.`; a condition-only map like
1035
+ // `{ node: ..., default: ... }` or a fallback array describes the root
1036
+ // entry directly. Nested conditions and arrays are flattened; anything
1037
+ // that is not a string contributes nothing.
1038
+ function packageExportTargets(exportsField: unknown): {
1039
+ runtime: string[]
1040
+ auxiliary: string[]
1041
+ } {
1042
+ const runtime = new Set<string>()
1043
+ const auxiliary = new Set<string>()
1044
+ const collect = (value: unknown, isAuxiliary: boolean) => {
1045
+ if (typeof value === 'string') {
1046
+ ;(isAuxiliary ? auxiliary : runtime).add(value)
1047
+ return
1048
+ }
1049
+ if (Array.isArray(value)) {
1050
+ for (const item of value) {
1051
+ collect(item, isAuxiliary)
1052
+ }
1053
+ return
1054
+ }
1055
+ if (value && typeof value === 'object') {
1056
+ for (const [key, item] of Object.entries(
1057
+ value as Record<string, unknown>,
1058
+ )) {
1059
+ collect(item, isAuxiliary || key === 'types')
1060
+ }
1061
+ }
1062
+ }
1063
+ if (
1064
+ exportsField &&
1065
+ typeof exportsField === 'object' &&
1066
+ !Array.isArray(exportsField) &&
1067
+ Object.keys(exportsField as Record<string, unknown>).some((key) =>
1068
+ key.startsWith('.'),
1069
+ )
1070
+ ) {
1071
+ collect((exportsField as Record<string, unknown>)['.'], false)
1072
+ } else {
1073
+ collect(exportsField, false)
1074
+ }
1075
+ return { runtime: [...runtime], auxiliary: [...auxiliary] }
1076
+ }
1077
+
581
1078
  function parseWasiArtifactMetadata(content: string, source: string) {
582
1079
  const firstLine = content.split(/\r?\n/, 1)[0]
583
1080
  if (!firstLine.startsWith(WASI_ARTIFACT_METADATA_PREFIX)) {
@@ -671,6 +1168,27 @@ function resolveArtifactRelativePath(
671
1168
  return { absolute, relative: relativePath }
672
1169
  }
673
1170
 
1171
+ // Root entry names come from `package.json#main` or WASI loader metadata and
1172
+ // may be nested (e.g. `nested/binding.js`). The lexical check in
1173
+ // resolveArtifactRelativePath cannot see symlinked intermediate directories,
1174
+ // and the file-system transaction only guards the workspace boundary, so a
1175
+ // symlinked parent could redirect a pending write into a sibling package.
1176
+ // Canonicalizing the destination's nearest existing ancestor closes that gap.
1177
+ function resolvePackageRootEntry(
1178
+ packageRoot: string,
1179
+ entry: string,
1180
+ description: string,
1181
+ ) {
1182
+ const resolved = resolveArtifactRelativePath(packageRoot, entry, description)
1183
+ const canonical = canonicalizeManagedPackagePath(resolved.absolute)
1184
+ if (!managedPackagePathIsWithin(packageRoot, canonical)) {
1185
+ throw new Error(
1186
+ `${description} resolves outside the package root through a symlinked path: ${entry}`,
1187
+ )
1188
+ }
1189
+ return resolved
1190
+ }
1191
+
674
1192
  function addPendingWrite(
675
1193
  writes: Map<string, PendingWrite>,
676
1194
  destination: string,
@@ -801,11 +1319,19 @@ async function collectStaleManagedDestinations(
801
1319
  }
802
1320
 
803
1321
  for (const entry of managedRootEntries) {
804
- const path = resolveArtifactRelativePath(
805
- packageRoot,
806
- entry,
807
- 'managed WASI root entry',
808
- ).absolute
1322
+ let path: string
1323
+ try {
1324
+ // A stale entry recorded when a different loader was installed can
1325
+ // now resolve through a symlinked directory into a sibling package;
1326
+ // reconciliation must not delete files the package does not own.
1327
+ path = resolvePackageRootEntry(
1328
+ packageRoot,
1329
+ entry,
1330
+ 'managed WASI root entry',
1331
+ ).absolute
1332
+ } catch {
1333
+ continue
1334
+ }
809
1335
  if (!pendingWrites.has(path)) {
810
1336
  stalePaths.push(path)
811
1337
  }