@packall/core 0.1.0 → 0.2.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/dist/archive-6sqTn1C4.js +274 -0
  4. package/dist/archive-6sqTn1C4.js.map +1 -0
  5. package/dist/archive.d.ts +47 -15
  6. package/dist/archive.d.ts.map +1 -1
  7. package/dist/bundle.d.ts +6 -3
  8. package/dist/bundle.d.ts.map +1 -1
  9. package/dist/dependency-range.d.ts.map +1 -1
  10. package/dist/download.d.ts +2 -1
  11. package/dist/download.d.ts.map +1 -1
  12. package/dist/enums/artifact-kind.d.ts.map +1 -1
  13. package/dist/enums/edge-kind.d.ts.map +1 -1
  14. package/dist/enums/input-file-kind.d.ts.map +1 -1
  15. package/dist/enums/layout.d.ts.map +1 -1
  16. package/dist/enums/lockfile-format.d.ts.map +1 -1
  17. package/dist/enums/phase.d.ts.map +1 -1
  18. package/dist/errors.d.ts.map +1 -1
  19. package/dist/index.d.ts +12 -10
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +169 -344
  22. package/dist/index.js.map +1 -1
  23. package/dist/input-file.d.ts.map +1 -1
  24. package/dist/integrity.d.ts +37 -5
  25. package/dist/integrity.d.ts.map +1 -1
  26. package/dist/layout.d.ts +41 -7
  27. package/dist/layout.d.ts.map +1 -1
  28. package/dist/locked-resolve.d.ts.map +1 -1
  29. package/dist/lockfile/bun.d.ts.map +1 -1
  30. package/dist/lockfile/detect.d.ts.map +1 -1
  31. package/dist/lockfile/json.d.ts.map +1 -1
  32. package/dist/lockfile/names.d.ts.map +1 -1
  33. package/dist/lockfile/npm.d.ts.map +1 -1
  34. package/dist/lockfile/parse.d.ts.map +1 -1
  35. package/dist/lockfile/pnpm.d.ts.map +1 -1
  36. package/dist/lockfile/tree-builder.d.ts.map +1 -1
  37. package/dist/lockfile/types.d.ts.map +1 -1
  38. package/dist/manifest.d.ts.map +1 -1
  39. package/dist/node/archiver.d.ts +5 -0
  40. package/dist/node/archiver.d.ts.map +1 -0
  41. package/dist/node/index.d.ts +13 -0
  42. package/dist/node/index.d.ts.map +1 -0
  43. package/dist/node/index.js +55 -0
  44. package/dist/node/index.js.map +1 -0
  45. package/dist/options.d.ts +3 -3
  46. package/dist/options.d.ts.map +1 -1
  47. package/dist/platform.d.ts +5 -10
  48. package/dist/platform.d.ts.map +1 -1
  49. package/dist/progress.d.ts.map +1 -1
  50. package/dist/registry.d.ts.map +1 -1
  51. package/dist/resolve.d.ts.map +1 -1
  52. package/dist/schemas/lenient.d.ts.map +1 -1
  53. package/dist/schemas/package-json.d.ts.map +1 -1
  54. package/dist/spec.d.ts.map +1 -1
  55. package/dist/types/value-of.d.ts.map +1 -1
  56. package/dist/utils/is-record.d.ts.map +1 -1
  57. package/package.json +11 -10
  58. package/src/archive.ts +95 -92
  59. package/src/bundle.ts +736 -616
  60. package/src/dependency-range.ts +121 -115
  61. package/src/download.ts +155 -133
  62. package/src/enums/artifact-kind.ts +5 -5
  63. package/src/enums/edge-kind.ts +13 -13
  64. package/src/enums/input-file-kind.ts +9 -9
  65. package/src/enums/layout.ts +20 -20
  66. package/src/enums/lockfile-format.ts +6 -6
  67. package/src/enums/phase.ts +8 -8
  68. package/src/errors.ts +200 -198
  69. package/src/index.ts +126 -113
  70. package/src/input-file.ts +246 -234
  71. package/src/integrity.ts +165 -121
  72. package/src/layout.ts +63 -29
  73. package/src/locked-resolve.ts +471 -461
  74. package/src/lockfile/bun.ts +214 -207
  75. package/src/lockfile/detect.ts +80 -80
  76. package/src/lockfile/json.ts +91 -92
  77. package/src/lockfile/names.ts +9 -9
  78. package/src/lockfile/npm.ts +328 -308
  79. package/src/lockfile/parse.ts +32 -32
  80. package/src/lockfile/pnpm.ts +230 -225
  81. package/src/lockfile/tree-builder.ts +116 -116
  82. package/src/lockfile/types.ts +52 -52
  83. package/src/manifest.ts +129 -133
  84. package/src/node/archiver.ts +85 -0
  85. package/src/node/index.ts +13 -0
  86. package/src/options.ts +84 -84
  87. package/src/platform.ts +97 -101
  88. package/src/progress.ts +105 -106
  89. package/src/registry.ts +116 -115
  90. package/src/resolve.ts +560 -537
  91. package/src/schemas/lenient.ts +67 -60
  92. package/src/schemas/package-json.ts +11 -10
  93. package/src/spec.ts +123 -119
  94. package/src/types/value-of.ts +1 -1
  95. package/src/utils/is-record.ts +1 -1
package/dist/index.js CHANGED
@@ -1,227 +1,16 @@
1
+ import { _ as OutputError, a as layerCallback, b as RegistryUnreachableError, c as ArchiveError, d as InvalidInputFileError, f as InvalidSpecError, g as NoMatchingVersionsError, h as LockfileOutOfDateError, i as emit, l as AuthenticationError, m as LockfileIncompleteError, n as createArchive, o as layerSilent, p as LockfileError, r as Progress, s as makeCollector, t as Archiver, u as IntegrityError, v as PackageNotFoundError, x as VersionNotFoundError, y as RegistryResponseError } from "./archive-6sqTn1C4.js";
1
2
  import * as Effect from "effect/Effect";
2
3
  import * as Option from "effect/Option";
3
4
  import * as Schema from "effect/Schema";
4
5
  import * as Getter from "effect/SchemaGetter";
5
6
  import semver from "semver";
6
7
  import * as Context from "effect/Context";
7
- import * as Layer from "effect/Layer";
8
- import * as Ref from "effect/Ref";
9
- import { createHash, timingSafeEqual } from "node:crypto";
8
+ import * as Crypto from "effect/Crypto";
9
+ import * as Encoding from "effect/Encoding";
10
10
  import { parse } from "yaml";
11
11
  import * as FileSystem from "effect/FileSystem";
12
12
  import * as Path from "effect/Path";
13
- import { create } from "tar";
14
13
 
15
- //#region src/errors.ts
16
- var BundlerErrorBase = class extends Error {
17
- constructor(message, options) {
18
- super(message, options);
19
- this.name = new.target.name;
20
- }
21
- };
22
- /** A package spec on the command line or in an input file could not be parsed. */
23
- var InvalidSpecError = class extends BundlerErrorBase {
24
- _tag = "InvalidSpecError";
25
- spec;
26
- reason;
27
- constructor(spec, reason) {
28
- super(`Invalid package spec ${JSON.stringify(spec)}: ${reason}`);
29
- this.spec = spec;
30
- this.reason = reason;
31
- }
32
- };
33
- /** `--file` pointed at something that is neither a usable package.json nor a spec list. */
34
- var InvalidInputFileError = class extends BundlerErrorBase {
35
- _tag = "InvalidInputFileError";
36
- path;
37
- reason;
38
- constructor(path, reason, options) {
39
- super(`Cannot read specs from ${path}: ${reason}`, options);
40
- this.path = path;
41
- this.reason = reason;
42
- }
43
- };
44
- /** A lockfile could not be read, or is in a format we do not support. */
45
- var LockfileError = class extends BundlerErrorBase {
46
- _tag = "LockfileError";
47
- path;
48
- reason;
49
- constructor(path, reason, options) {
50
- super(`Lockfile ${path}: ${reason}`, options);
51
- this.path = path;
52
- this.reason = reason;
53
- }
54
- };
55
- /**
56
- * The lockfile parsed, but does not pin everything the bundle needs.
57
- *
58
- * Falling back to a range here would defeat the entire point of reading a
59
- * lockfile — you would get a bundle that is *mostly* what CI installed, with no
60
- * indication of which parts were guessed. So this is fatal, and it names every
61
- * gap at once so one `npm install` fixes all of them.
62
- */
63
- var LockfileIncompleteError = class extends BundlerErrorBase {
64
- _tag = "LockfileIncompleteError";
65
- path;
66
- missing;
67
- constructor(path, missing, detail, remedy) {
68
- const shown = missing.slice(0, 10).map((entry) => ` ${entry}`).join("\n");
69
- const rest = missing.length > 10 ? `\n … and ${missing.length - 10} more` : "";
70
- super(`${path} is incomplete — ${detail}:\n${shown}${rest}\n ${remedy}\n Or pass --lockfile off to resolve version ranges fresh instead.`);
71
- this.path = path;
72
- this.missing = missing;
73
- }
74
- };
75
- /**
76
- * The lockfile pins versions the registry no longer serves.
77
- *
78
- * Unpublished, or never mirrored into a private registry. Either way the
79
- * bundle cannot be built as specified, and quietly substituting a nearby
80
- * version would produce exactly the mismatch this feature exists to prevent.
81
- */
82
- var LockfileOutOfDateError = class extends BundlerErrorBase {
83
- _tag = "LockfileOutOfDateError";
84
- path;
85
- registry;
86
- missing;
87
- constructor(path, registry, missing) {
88
- const shown = missing.slice(0, 10).map((entry) => ` ${entry.name}@${entry.version} — ${entry.detail}`).join("\n");
89
- const rest = missing.length > 10 ? `\n … and ${missing.length - 10} more` : "";
90
- super(`${path} pins ${missing.length} version${missing.length === 1 ? "" : "s"} that ${registry} does not serve:\n${shown}${rest}\n Refresh the lockfile against this registry, or pass --lockfile off to\n resolve version ranges fresh instead.`);
91
- this.path = path;
92
- this.registry = registry;
93
- this.missing = missing.map(({ name, version }) => ({
94
- name,
95
- version
96
- }));
97
- }
98
- };
99
- /**
100
- * The registry could not be reached at all — DNS failure, refused connection,
101
- * proxy blackhole, or a preflight that timed out.
102
- *
103
- * This is the error that fixes "hangs forever with no network": we surface it
104
- * quickly and say which host we could not reach.
105
- */
106
- var RegistryUnreachableError = class extends BundlerErrorBase {
107
- _tag = "RegistryUnreachableError";
108
- registry;
109
- timeoutMs;
110
- constructor(registry, detail, options) {
111
- super(`Cannot reach registry ${registry}: ${detail}.\n Check your network connection, VPN, and the proxy settings in your .npmrc.`, options);
112
- this.registry = registry;
113
- this.timeoutMs = options?.timeoutMs;
114
- }
115
- };
116
- /** The registry answered, but has never heard of this package. */
117
- var PackageNotFoundError = class extends BundlerErrorBase {
118
- _tag = "PackageNotFoundError";
119
- packageName;
120
- registry;
121
- constructor(packageName, registry) {
122
- super(`Package "${packageName}" was not found on ${registry}`);
123
- this.packageName = packageName;
124
- this.registry = registry;
125
- }
126
- };
127
- /** The package exists but the exact version requested does not. */
128
- var VersionNotFoundError = class extends BundlerErrorBase {
129
- _tag = "VersionNotFoundError";
130
- packageName;
131
- version;
132
- available;
133
- constructor(packageName, version, available) {
134
- const tail = available.slice(-5).join(", ");
135
- super(`${packageName}@${version} does not exist.` + (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""));
136
- this.packageName = packageName;
137
- this.version = version;
138
- this.available = available;
139
- }
140
- };
141
- /** A range (or dist-tag) matched nothing that is actually published. */
142
- var NoMatchingVersionsError = class extends BundlerErrorBase {
143
- _tag = "NoMatchingVersionsError";
144
- packageName;
145
- selector;
146
- available;
147
- constructor(packageName, selector, available) {
148
- const tail = available.slice(-5).join(", ");
149
- super(`No published version of ${packageName} satisfies "${selector}".` + (tail.length > 0 ? ` Most recent published versions: ${tail}` : ""));
150
- this.packageName = packageName;
151
- this.selector = selector;
152
- this.available = available;
153
- }
154
- };
155
- /** The registry responded, but with something we cannot use. */
156
- var RegistryResponseError = class extends BundlerErrorBase {
157
- _tag = "RegistryResponseError";
158
- url;
159
- status;
160
- constructor(url, detail, options) {
161
- super(`Unexpected response from ${url}: ${detail}`, options);
162
- this.url = url;
163
- this.status = options?.status;
164
- }
165
- };
166
- /** 401/403 — almost always a missing or stale token in `.npmrc`. */
167
- var AuthenticationError = class extends BundlerErrorBase {
168
- _tag = "AuthenticationError";
169
- registry;
170
- status;
171
- constructor(registry, status, packageName) {
172
- super(`Registry ${registry} rejected the request with HTTP ${status}` + (packageName ? ` while fetching "${packageName}"` : "") + `.\n Add credentials to your .npmrc, e.g.\n //${safeHost(registry)}/:_authToken=\${NPM_TOKEN}`);
173
- this.registry = registry;
174
- this.status = status;
175
- }
176
- };
177
- /**
178
- * A downloaded tarball did not match the checksum the registry advertised.
179
- *
180
- * Never soft-fail this: a bundle is a supply-chain artifact and a corrupt or
181
- * substituted tarball is exactly what integrity checking exists to catch.
182
- */
183
- var IntegrityError = class extends BundlerErrorBase {
184
- _tag = "IntegrityError";
185
- packageName;
186
- version;
187
- expected;
188
- actual;
189
- constructor(packageName, version, expected, actual) {
190
- super(`Integrity check failed for ${packageName}@${version}.\n expected: ${expected}\n actual: ${actual}\n The download was discarded. This is either corruption in transit or a tampered artifact.`);
191
- this.packageName = packageName;
192
- this.version = version;
193
- this.expected = expected;
194
- this.actual = actual;
195
- }
196
- };
197
- /** Something went wrong writing the `.tgz`. */
198
- var ArchiveError = class extends BundlerErrorBase {
199
- _tag = "ArchiveError";
200
- path;
201
- constructor(path, detail, options) {
202
- super(`Failed to create archive ${path}: ${detail}`, options);
203
- this.path = path;
204
- }
205
- };
206
- /** Something went wrong preparing or writing to the output directory. */
207
- var OutputError = class extends BundlerErrorBase {
208
- _tag = "OutputError";
209
- path;
210
- constructor(path, detail, options) {
211
- super(`Output error at ${path}: ${detail}`, options);
212
- this.path = path;
213
- }
214
- };
215
- const safeHost = (registry) => {
216
- try {
217
- const url = new URL(registry);
218
- return url.host + url.pathname.replace(/\/+$/, "");
219
- } catch {
220
- return registry;
221
- }
222
- };
223
-
224
- //#endregion
225
14
  //#region src/enums/artifact-kind.ts
226
15
  /** What a bundle run wrote to the output directory. */
227
16
  const ArtifactKind = {
@@ -764,49 +553,25 @@ const defaultBundleOptions = {
764
553
  var Registry = class extends Context.Service()("@packall/core/Registry") {};
765
554
 
766
555
  //#endregion
767
- //#region src/progress.ts
768
- /** Service tag for progress reporting. */
769
- var Progress = class extends Context.Service()("@packall/core/Progress") {};
770
- /** Emits an event to whichever reporter is installed. */
771
- const emit = (event) => Effect.gen(function* () {
772
- yield* (yield* Progress).emit(event);
773
- });
774
- /**
775
- * Discards every event.
776
- *
777
- * The default for library consumers and for tests that do not care about
778
- * progress — silence should never require ceremony.
779
- */
780
- const layerSilent = Layer.succeed(Progress)({ emit: () => Effect.void });
781
- /** Sends every event to a callback. Used by the CLI renderer. */
782
- const layerCallback = (onEvent) => Layer.succeed(Progress)({ emit: (event) => Effect.sync(() => onEvent(event)) });
556
+ //#region src/integrity.ts
783
557
  /**
784
- * Accumulates every event into a `Ref`, for assertions.
558
+ * The digests npm publishes, mapped to what `Crypto.digest` calls them.
785
559
  *
786
- * Returned as `{ layer, events }` so a test can provide the layer and then read
787
- * the transcript afterwards.
560
+ * Doubles as the supported-algorithm list: an SRI token whose algorithm is not
561
+ * a key here is one we cannot check, and is dropped during parsing.
788
562
  */
789
- const makeCollector = Effect.gen(function* () {
790
- const ref = yield* Ref.make([]);
791
- return {
792
- layer: Layer.succeed(Progress)({ emit: (event) => Ref.update(ref, (events) => [...events, event]) }),
793
- events: Ref.get(ref)
794
- };
795
- });
796
-
797
- //#endregion
798
- //#region src/integrity.ts
799
- const SUPPORTED = new Set([
800
- "sha512",
801
- "sha384",
802
- "sha256",
803
- "sha1"
804
- ]);
563
+ const digestAlgorithmByName = {
564
+ sha512: "SHA-512",
565
+ sha384: "SHA-384",
566
+ sha256: "SHA-256",
567
+ sha1: "SHA-1"
568
+ };
569
+ const isSupported = (algorithm) => Object.hasOwn(digestAlgorithmByName, algorithm);
805
570
  /**
806
571
  * Parses an SRI string such as `sha512-abc...==`.
807
572
  *
808
573
  * npm permits several space-separated hashes; we keep every one we can verify
809
- * and ignore algorithms Node does not implement.
574
+ * and ignore algorithms we cannot.
810
575
  */
811
576
  const parseIntegrity = (integrity) => {
812
577
  const hashes = [];
@@ -816,7 +581,7 @@ const parseIntegrity = (integrity) => {
816
581
  if (dash <= 0) continue;
817
582
  const algorithm = token.slice(0, dash).toLowerCase();
818
583
  const digest = token.slice(dash + 1);
819
- if (!SUPPORTED.has(algorithm) || digest.length === 0) continue;
584
+ if (!isSupported(algorithm) || digest.length === 0) continue;
820
585
  hashes.push({
821
586
  algorithm,
822
587
  digest
@@ -824,15 +589,26 @@ const parseIntegrity = (integrity) => {
824
589
  }
825
590
  return hashes;
826
591
  };
592
+ const digestBytes = (data, algorithm) => Effect.gen(function* () {
593
+ return yield* (yield* Crypto.Crypto).digest(digestAlgorithmByName[algorithm], data);
594
+ });
827
595
  /** Computes the base64 digest of some bytes under one algorithm. */
828
- const digestOf = (data, algorithm) => createHash(algorithm).update(data).digest("base64");
596
+ const digestOf = (data, algorithm) => Effect.map(digestBytes(data, algorithm), Encoding.encodeBase64);
829
597
  /** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
830
- const hexDigestOf = (data, algorithm) => createHash(algorithm).update(data).digest("hex");
831
- /** Constant-time comparison of two digest strings of equal length. */
598
+ const hexDigestOf = (data, algorithm) => Effect.map(digestBytes(data, algorithm), Encoding.encodeHex);
599
+ /**
600
+ * Constant-time comparison of two digest strings.
601
+ *
602
+ * Written by hand rather than with `node:crypto`'s `timingSafeEqual`, which
603
+ * needs `Buffer` and would pin this module to Node. Both digests here are
604
+ * public knowledge, so the timing property is close to theatre — but it costs
605
+ * four lines, and the alternative is explaining in a review why it was dropped.
606
+ */
832
607
  const digestsEqual = (a, b) => {
833
- const left = Buffer.from(a);
834
- const right = Buffer.from(b);
835
- return left.length === right.length && timingSafeEqual(left, right);
608
+ if (a.length !== b.length) return false;
609
+ let difference = 0;
610
+ for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
611
+ return difference === 0;
836
612
  };
837
613
  /**
838
614
  * Checks bytes against whichever checksums the registry supplied.
@@ -842,14 +618,14 @@ const digestsEqual = (a, b) => {
842
618
  * as `Unverifiable` rather than silently treated as a pass — the caller decides
843
619
  * whether to tolerate it.
844
620
  */
845
- const verify = (data, dist) => {
621
+ const verify = (data, dist) => Effect.gen(function* () {
846
622
  if (dist.integrity !== void 0 && dist.integrity.length > 0) {
847
- const [best] = parseIntegrity(dist.integrity).toSorted((a, b) => strength(b.algorithm) - strength(a.algorithm));
623
+ const [best] = parseIntegrity(dist.integrity).toSorted((a, b) => strengthByAlgorithm[b.algorithm] - strengthByAlgorithm[a.algorithm]);
848
624
  if (best === void 0) return {
849
625
  _tag: "Unverifiable",
850
626
  reason: `no supported algorithm in "${dist.integrity}"`
851
627
  };
852
- const actual = digestOf(data, best.algorithm);
628
+ const actual = yield* digestOf(data, best.algorithm);
853
629
  return digestsEqual(actual, best.digest) ? {
854
630
  _tag: "Verified",
855
631
  using: best.algorithm
@@ -860,7 +636,7 @@ const verify = (data, dist) => {
860
636
  };
861
637
  }
862
638
  if (dist.shasum !== void 0 && dist.shasum.length > 0) {
863
- const actual = hexDigestOf(data, "sha1");
639
+ const actual = yield* hexDigestOf(data, "sha1");
864
640
  return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase()) ? {
865
641
  _tag: "Verified",
866
642
  using: "sha1"
@@ -874,14 +650,12 @@ const verify = (data, dist) => {
874
650
  _tag: "Unverifiable",
875
651
  reason: "registry advertised no integrity or shasum"
876
652
  };
877
- };
878
- const strength = (algorithm) => {
879
- switch (algorithm) {
880
- case "sha512": return 4;
881
- case "sha384": return 3;
882
- case "sha256": return 2;
883
- default: return 1;
884
- }
653
+ });
654
+ const strengthByAlgorithm = {
655
+ sha512: 4,
656
+ sha384: 3,
657
+ sha256: 2,
658
+ sha1: 1
885
659
  };
886
660
 
887
661
  //#endregion
@@ -895,10 +669,12 @@ const strength = (algorithm) => {
895
669
  * @babel/core/-/core-7.24.0.tgz
896
670
  *
897
671
  * That is not an Artifactory convention — it is the path structure in every
898
- * `dist.tarball` URL npm publishes, which is why the same tree imports into
899
- * Artifactory, Nexus and Verdaccio without translation. Note that a scoped
900
- * package's file name drops the scope: `@babel/core` becomes `core-7.24.0.tgz`
901
- * under an `@babel/core/-/` directory.
672
+ * `dist.tarball` URL npm publishes. See `notes/requirements.md` § FR-12 for
673
+ * what that buys.
674
+ *
675
+ * The one shape worth noticing before editing anything here: a scoped
676
+ * package's file name drops the scope, so `@babel/core` becomes
677
+ * `core-7.24.0.tgz` under an `@babel/core/-/` directory.
902
678
  */
903
679
  /** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
904
680
  const splitName = (name) => {
@@ -934,13 +710,45 @@ const README_FILE = "IMPORT.md";
934
710
  /**
935
711
  * Name of the archive produced for one root spec in `per-spec` layout.
936
712
  *
937
- * Scoped names are flattened (`@babel/core` -> `babel-core`) because a `/` in a
938
- * file name is not a thing, and `@` confuses enough shells and web UIs to be
939
- * worth avoiding.
713
+ * It is the spec, with the one character a file name cannot hold taken out:
714
+ * `@types/react@19.2.18` becomes `@types-react@19.2.18.tgz`. So the name of the
715
+ * file answers the question somebody has when they are looking at a directory
716
+ * of them — which package, and which version — in the notation they would have
717
+ * typed to ask for it.
718
+ *
719
+ * The version is joined with `@` rather than `-` for the same reason. A
720
+ * hyphen made `foo-bar-1.0.0` ambiguous by eye: package `foo` at `bar-1.0.0`,
721
+ * or `foo-bar` at `1.0.0`? npm's own syntax has no such problem, because a
722
+ * package name may not contain `@` beyond its scope.
723
+ *
724
+ * **The scope's `@` stays**, and it is the difference between a correct bundle
725
+ * and a quietly incomplete one.
726
+ *
727
+ * Dropping it — which this did, matching what `npm pack` produces — meant
728
+ * `@something/somewhat` and `something-somewhat` were both
729
+ * `something-somewhat-1.0.0.tgz`. Two different packages, one path: the second
730
+ * landed on the first, and the run reported two artifacts for the one file that
731
+ * survived. A bundle short a package, called complete, found at the far end of
732
+ * an air gap.
733
+ *
734
+ * Keeping the `@` makes that impossible by construction rather than by a check,
735
+ * because an unscoped npm name may not begin with one. It is also the more
736
+ * honest name: the scope is part of what the package *is*, and a directory of
737
+ * archives that has forgotten which ones were scoped is a directory somebody
738
+ * has to cross-reference against a manifest.
739
+ *
740
+ * It does not make every collision impossible — `@a/b-c` and `@a-b/c` still
741
+ * meet — so `assertDistinctOutputs` still runs. That pair is pathological; the
742
+ * scoped-against-unscoped one is `@babel/core` beside `babel-core`, which is a
743
+ * real migration somebody could be bundling.
744
+ *
745
+ * The leading `@` is safe here in a way it is not inside the archive: tar's
746
+ * splice convention applies to the *file list* it is given, which is what
747
+ * `archive.ts` guards with `./`. This is the output path, not an entry.
940
748
  */
941
749
  const perSpecArchiveName = (name, version) => {
942
750
  const { scope, bare } = splitName(name);
943
- return `${scope === void 0 ? bare : `${scope.slice(1)}-${bare}`}-${version}.tgz`;
751
+ return `${scope === void 0 ? bare : `${scope}-${bare}`}@${version}.tgz`;
944
752
  };
945
753
  /** Name of the archive produced in `single` layout. */
946
754
  const singleArchiveName = (base = "bundle") => `${base}.tgz`;
@@ -2545,66 +2353,6 @@ const formatSpecErrors = (errors, rawLines) => {
2545
2353
  };
2546
2354
  const describeError = (error) => error instanceof Error ? error.message : String(error);
2547
2355
 
2548
- //#endregion
2549
- //#region src/archive.ts
2550
- /**
2551
- * Packs `entries` (paths relative to `cwd`) into a gzipped tar at `outPath`.
2552
- *
2553
- * `portable` normalises uid/gid/mtime so the same inputs produce byte-identical
2554
- * output — which matters when a security team wants to diff two bundles or
2555
- * re-derive one from a manifest.
2556
- */
2557
- const createArchive = (options) => Effect.gen(function* () {
2558
- const fs = yield* FileSystem.FileSystem;
2559
- yield* emit({
2560
- _tag: "ArchiveStarted",
2561
- path: options.outPath,
2562
- entryCount: options.entries.length
2563
- });
2564
- yield* Effect.tryPromise({
2565
- try: () => create({
2566
- gzip: options.gzipLevel === void 0 ? true : { level: options.gzipLevel },
2567
- file: options.outPath,
2568
- cwd: options.cwd,
2569
- portable: true,
2570
- onWriteEntry: (entry) => {
2571
- entry.path = entry.path.replace(/^\.\//, "");
2572
- }
2573
- }, options.entries.toSorted().map(dotSlash)),
2574
- catch: (cause) => new ArchiveError(options.outPath, describe(cause), { cause })
2575
- });
2576
- const info = yield* fs.stat(options.outPath);
2577
- const bytes = Number(info.size);
2578
- yield* emit({
2579
- _tag: "ArchiveCompleted",
2580
- path: options.outPath,
2581
- bytes
2582
- });
2583
- return {
2584
- path: options.outPath,
2585
- bytes
2586
- };
2587
- });
2588
- /**
2589
- * Guards every entry against tar's `@` convention.
2590
- *
2591
- * In a tar file list, a leading `@` means "splice in the entries of this other
2592
- * archive" — a GNU convention `node-tar` implements by stripping the `@` and
2593
- * looking for what is left. Every scoped package is a top-level entry starting
2594
- * with `@`, so this hit the bundler in both possible ways:
2595
- *
2596
- * - `@oxc-project` became `oxc-project`, which does not exist, and the run died
2597
- * with an ENOENT naming a path that appears nowhere in the staging tree.
2598
- * - `@esbuild` became `esbuild`, which *does* exist — the unscoped package of
2599
- * the same name sitting right next to it. No error, and every scoped tarball
2600
- * silently missing from a bundle that reported success. That is the dangerous
2601
- * one: you would not find out until the install failed behind the firewall.
2602
- *
2603
- * `./@scope` is not subject to the convention and resolves identically.
2604
- */
2605
- const dotSlash = (entry) => entry.startsWith("./") ? entry : `./${entry}`;
2606
- const describe = (cause) => cause instanceof Error ? cause.message : String(cause);
2607
-
2608
2356
  //#endregion
2609
2357
  //#region src/download.ts
2610
2358
  /**
@@ -2635,7 +2383,7 @@ const downloadAll = (packages, destDir, options) => Effect.gen(function* () {
2635
2383
  });
2636
2384
  const bytes = yield* registry.download(pkg.manifest);
2637
2385
  if (options.verifyIntegrity) {
2638
- const result = verify(bytes, pkg.manifest.dist);
2386
+ const result = yield* verify(bytes, pkg.manifest.dist);
2639
2387
  if (result._tag === "Mismatch") return yield* Effect.fail(new IntegrityError(pkg.name, pkg.version, result.expected, result.actual));
2640
2388
  if (result._tag === "Unverifiable") yield* emit({
2641
2389
  _tag: "Warning",
@@ -2679,11 +2427,44 @@ const downloadAll = (packages, destDir, options) => Effect.gen(function* () {
2679
2427
 
2680
2428
  //#endregion
2681
2429
  //#region src/bundle.ts
2430
+ /**
2431
+ * The archives `per-spec` emits — one per distinct root package version.
2432
+ *
2433
+ * Roots are *specs*, and several specs can name one package: `react`,
2434
+ * `react@latest` and `react@19.2.8` are three ways of asking for the same
2435
+ * thing, and after resolution they are indistinguishable. Without this they
2436
+ * were three archives — the same bytes, under the same file name, written over
2437
+ * each other. The run then reported three artifacts and three times the size
2438
+ * for one file on disk, and the duplication ratio it computes to decide whether
2439
+ * to ask about the layout counted them too, so the tool would offer to
2440
+ * deduplicate a bundle it had duplicated itself.
2441
+ *
2442
+ * Keyed by name and version rather than by the archive's file name. A scoped
2443
+ * name is flattened to make a file name, so `@foo/bar` and `foo-bar` can spell
2444
+ * the same one — and those are two different packages, which should collide
2445
+ * loudly rather than being merged here.
2446
+ *
2447
+ * Roots only. The packages *inside* an archive were deduplicated by resolution
2448
+ * long before this, which is why the same closure fetched from twenty specs is
2449
+ * fetched once.
2450
+ */
2451
+ const perSpecTargets = (resolution) => {
2452
+ const seen = /* @__PURE__ */ new Set();
2453
+ return resolution.roots.flatMap((root) => root.versions.flatMap((version) => {
2454
+ const key = `${root.spec.name}@${version}`;
2455
+ if (seen.has(key)) return [];
2456
+ seen.add(key);
2457
+ return {
2458
+ root,
2459
+ version
2460
+ };
2461
+ }));
2462
+ };
2682
2463
  /** Computes the plan summary for a resolution. */
2683
2464
  const summarize = (resolution) => {
2684
2465
  let perSpecArchives = 0;
2685
2466
  let perSpecEntries = 0;
2686
- for (const root of resolution.roots) for (const version of root.versions) {
2467
+ for (const { root, version } of perSpecTargets(resolution)) {
2687
2468
  perSpecArchives += 1;
2688
2469
  perSpecEntries += (root.closures.get(version) ?? []).length;
2689
2470
  }
@@ -2775,11 +2556,11 @@ const bundleResolved = (resolution, options) => Effect.gen(function* () {
2775
2556
  const plannedOutputs = (resolution, options) => {
2776
2557
  if (options.layout === Layout.Dir) return [{ file: MANIFEST_FILE }, { file: README_FILE }];
2777
2558
  if (options.layout === Layout.Single) return [{ file: singleArchiveName(options.archiveName ?? "bundle") }];
2778
- return resolution.roots.flatMap((root) => root.versions.map((version) => ({
2559
+ return perSpecTargets(resolution).map(({ root, version }) => ({
2779
2560
  file: perSpecArchiveName(root.spec.name, version),
2780
2561
  name: root.spec.name,
2781
2562
  version
2782
- })));
2563
+ }));
2783
2564
  };
2784
2565
  const runBundle = (resolution, options) => Effect.gen(function* () {
2785
2566
  const fs = yield* FileSystem.FileSystem;
@@ -2889,7 +2670,7 @@ const emitPerSpec = (input, staging) => Effect.gen(function* () {
2889
2670
  const index = indexPackages(input.resolution);
2890
2671
  const artifacts = [];
2891
2672
  let counter = 0;
2892
- for (const root of input.resolution.roots) for (const version of root.versions) {
2673
+ for (const { root, version } of perSpecTargets(input.resolution)) {
2893
2674
  if (input.options.skipExisting?.has(perSpecArchiveName(root.spec.name, version)) === true) continue;
2894
2675
  const included = includedPackages(index, root.closures.get(version) ?? []);
2895
2676
  const rootDir = path.join(staging, "roots", String(counter++));
@@ -2970,6 +2751,48 @@ const topLevelEntries = (dir) => Effect.gen(function* () {
2970
2751
  return (yield* (yield* FileSystem.FileSystem).readDirectory(dir)).toSorted();
2971
2752
  });
2972
2753
  /**
2754
+ * Two different packages must not want the same file name.
2755
+ *
2756
+ * Most of what this used to catch is now impossible: an archive is named for
2757
+ * the spec, `@types-react@19.2.18.tgz`, and an unscoped npm name may not begin
2758
+ * with `@`, so it cannot spell a scoped one. What survives is **two scoped
2759
+ * packages whose scope boundary falls in a different place**, because that
2760
+ * boundary is the one character the file name drops:
2761
+ *
2762
+ * @a/b-c -> @a-b-c@1.0.0.tgz
2763
+ * @a-b/c -> @a-b-c@1.0.0.tgz
2764
+ *
2765
+ * Pathological rather than plausible — it needs somebody to hold the `@a-b`
2766
+ * scope as well as `@a`, and both packages in one run at one version. It is
2767
+ * kept anyway because of what it costs to be wrong: they are *different
2768
+ * packages*, so deduplicating them would be wrong, and writing both is worse —
2769
+ * the second lands on the first and the run reports two artifacts for the one
2770
+ * file that survived. A bundle short a package, called complete, found at the
2771
+ * far end of an air gap by somebody who cannot go and fetch the missing one.
2772
+ *
2773
+ * Nothing else would catch it. The overwrite check below compares this run's
2774
+ * planned files against what is *already on disk*, and both of these are new.
2775
+ *
2776
+ * So it refuses, and names both specs. Not silently renamed: an archive whose
2777
+ * name nobody chose is a file somebody has to identify later.
2778
+ *
2779
+ * Unconditional, unlike the overwrite check: `--force` is permission to replace
2780
+ * what was already in the directory, not to let one run overwrite itself.
2781
+ */
2782
+ const assertDistinctOutputs = (resolution, options) => Effect.gen(function* () {
2783
+ const owners = /* @__PURE__ */ new Map();
2784
+ for (const planned of plannedOutputs(resolution, options)) {
2785
+ const claimed = owners.get(planned.file);
2786
+ const spec = planned.name === void 0 ? planned.file : `${planned.name}@${planned.version}`;
2787
+ if (claimed === void 0) owners.set(planned.file, [spec]);
2788
+ else claimed.push(spec);
2789
+ }
2790
+ const collisions = [...owners].filter(([, specs]) => specs.length > 1);
2791
+ if (collisions.length === 0) return;
2792
+ const described = collisions.map(([file, specs]) => `${specs.join(" and ")} would both be ${file}`).join("; ");
2793
+ return yield* Effect.fail(new OutputError(options.outDir, `two packages want the same archive name — ${described}. The \`/\` in a scope becomes a \`-\` to make a file name, so \`@a/b-c\` and \`@a-b/c\` meet. Use --layout single, or bundle them into separate --out directories.`));
2794
+ });
2795
+ /**
2973
2796
  * Fails if the run would overwrite something, unless `--force`.
2974
2797
  *
2975
2798
  * The guard is on the *files this run writes*, not on whether the directory has
@@ -2979,12 +2802,14 @@ const topLevelEntries = (dir) => Effect.gen(function* () {
2979
2802
  * default `.` unusable.
2980
2803
  *
2981
2804
  * Checked before anything is downloaded. Discovering the collision *after* a
2982
- * twenty-minute download would be a uniquely irritating way to fail.
2805
+ * twenty-minute download would be a uniquely irritating way to fail — which is
2806
+ * also why `assertDistinctOutputs` runs from here rather than at emit time.
2983
2807
  */
2984
2808
  const prepareOutputDir = (resolution, options) => Effect.gen(function* () {
2985
2809
  const fs = yield* FileSystem.FileSystem;
2986
2810
  const path = yield* Path.Path;
2987
2811
  const outDir = options.outDir;
2812
+ yield* assertDistinctOutputs(resolution, options);
2988
2813
  if (!options.force) {
2989
2814
  const clashes = [];
2990
2815
  for (const planned of plannedOutputs(resolution, options)) {
@@ -2998,5 +2823,5 @@ const prepareOutputDir = (resolution, options) => Effect.gen(function* () {
2998
2823
  });
2999
2824
 
3000
2825
  //#endregion
3001
- export { ArchiveError, ArtifactKind, AuthenticationError, DirectOnlyKind, EdgeKind, InputFileKind, IntegrityError, InvalidInputFileError, InvalidSpecError, Layout, Layouts, LockedRootKind, LockfileError, LockfileFormat, LockfileIncompleteError, LockfileOutOfDateError, MANIFEST_FILE, MANIFEST_VERSION, NoMatchingVersionsError, OptionalFlagRecordOrAbsentSchema, OptionalFlagRecordSchema, OptionalStringArraySchema, OptionalStringRecordSchema, OutputError, PackageNotFoundError, Phase, Progress, README_FILE, Registry, RegistryResponseError, RegistryUnreachableError, StringArraySchema, StringRecordSchema, VersionNotFoundError, allPlatforms, buildManifest, bundle, bundleLocked, bundleResolved, currentPlatform, dedupeSpecs, defaultBundleOptions, defaultInputFileOptions, defaultResolveOptions, defaultScope, detectLockfile, digestOf, emit as emitProgress, formatByLockfileName, formatPlatformFilter, formatSelector, formatSpec, hexDigestOf, importGuide, indexPackages, isIncluded, isRecord, layerCallback as layerCallbackProgress, layerSilent as layerSilentProgress, lockfileNamesFor, makeCollector as makeProgressCollector, packageKey, packagePath, parseDependencyTarget, parseInputFile, parseIntegrity, parseLockfile, parsePlatformTarget, parseSpec, parseSpecs, perSpecArchiveName, plan, planLocked, plannedOutputs, platformTargets, readInputFile, resolve, resolveLocked, selectVersions, serializeManifest, singleArchiveName, splitName, summarize, tarballFileName, tolerant, verify as verifyIntegrity, withLayout };
2826
+ export { ArchiveError, Archiver, ArtifactKind, AuthenticationError, DirectOnlyKind, EdgeKind, InputFileKind, IntegrityError, InvalidInputFileError, InvalidSpecError, Layout, Layouts, LockedRootKind, LockfileError, LockfileFormat, LockfileIncompleteError, LockfileOutOfDateError, MANIFEST_FILE, MANIFEST_VERSION, NoMatchingVersionsError, OptionalFlagRecordOrAbsentSchema, OptionalFlagRecordSchema, OptionalStringArraySchema, OptionalStringRecordSchema, OutputError, PackageNotFoundError, Phase, Progress, README_FILE, Registry, RegistryResponseError, RegistryUnreachableError, StringArraySchema, StringRecordSchema, VersionNotFoundError, allPlatforms, buildManifest, bundle, bundleLocked, bundleResolved, createArchive, currentPlatform, dedupeSpecs, defaultBundleOptions, defaultInputFileOptions, defaultResolveOptions, defaultScope, detectLockfile, digestOf, emit as emitProgress, formatByLockfileName, formatPlatformFilter, formatSelector, formatSpec, hexDigestOf, importGuide, indexPackages, isIncluded, isRecord, layerCallback as layerCallbackProgress, layerSilent as layerSilentProgress, lockfileNamesFor, makeCollector as makeProgressCollector, packageKey, packagePath, parseDependencyTarget, parseInputFile, parseIntegrity, parseLockfile, parsePlatformTarget, parseSpec, parseSpecs, perSpecArchiveName, plan, planLocked, plannedOutputs, platformTargets, readInputFile, resolve, resolveLocked, selectVersions, serializeManifest, singleArchiveName, splitName, summarize, tarballFileName, tolerant, verify as verifyIntegrity, withLayout };
3002
2827
  //# sourceMappingURL=index.js.map