@packall/core 0.0.1 → 0.1.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 (126) hide show
  1. package/dist/{Archive.d.ts → archive.d.ts} +5 -5
  2. package/dist/archive.d.ts.map +1 -0
  3. package/dist/{Bundle.d.ts → bundle.d.ts} +51 -36
  4. package/dist/bundle.d.ts.map +1 -0
  5. package/dist/{DependencyRange.d.ts → dependency-range.d.ts} +2 -2
  6. package/dist/dependency-range.d.ts.map +1 -0
  7. package/dist/{Download.d.ts → download.d.ts} +7 -7
  8. package/dist/download.d.ts.map +1 -0
  9. package/dist/enums/artifact-kind.d.ts +8 -0
  10. package/dist/enums/artifact-kind.d.ts.map +1 -0
  11. package/dist/enums/edge-kind.d.ts +29 -0
  12. package/dist/enums/edge-kind.d.ts.map +1 -0
  13. package/dist/enums/input-file-kind.d.ts +12 -0
  14. package/dist/enums/input-file-kind.d.ts.map +1 -0
  15. package/dist/enums/layout.d.ts +23 -0
  16. package/dist/enums/layout.d.ts.map +1 -0
  17. package/dist/enums/lockfile-format.d.ts +9 -0
  18. package/dist/enums/lockfile-format.d.ts.map +1 -0
  19. package/dist/enums/phase.d.ts +11 -0
  20. package/dist/enums/phase.d.ts.map +1 -0
  21. package/dist/{Errors.d.ts → errors.d.ts} +49 -7
  22. package/dist/errors.d.ts.map +1 -0
  23. package/dist/index.d.ts +44 -28
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2416 -1180
  26. package/dist/index.js.map +1 -1
  27. package/dist/input-file.d.ts +77 -0
  28. package/dist/input-file.d.ts.map +1 -0
  29. package/dist/{Integrity.d.ts → integrity.d.ts} +3 -3
  30. package/dist/integrity.d.ts.map +1 -0
  31. package/dist/{Layout.d.ts → layout.d.ts} +1 -1
  32. package/dist/{Layout.d.ts.map → layout.d.ts.map} +1 -1
  33. package/dist/locked-resolve.d.ts +47 -0
  34. package/dist/locked-resolve.d.ts.map +1 -0
  35. package/dist/lockfile/bun.d.ts +3 -0
  36. package/dist/lockfile/bun.d.ts.map +1 -0
  37. package/dist/lockfile/detect.d.ts +31 -0
  38. package/dist/lockfile/detect.d.ts.map +1 -0
  39. package/dist/lockfile/json.d.ts +23 -0
  40. package/dist/lockfile/json.d.ts.map +1 -0
  41. package/dist/lockfile/names.d.ts +16 -0
  42. package/dist/lockfile/names.d.ts.map +1 -0
  43. package/dist/lockfile/npm.d.ts +3 -0
  44. package/dist/lockfile/npm.d.ts.map +1 -0
  45. package/dist/lockfile/parse.d.ts +23 -0
  46. package/dist/lockfile/parse.d.ts.map +1 -0
  47. package/dist/lockfile/pnpm.d.ts +20 -0
  48. package/dist/lockfile/pnpm.d.ts.map +1 -0
  49. package/dist/lockfile/tree-builder.d.ts +70 -0
  50. package/dist/lockfile/tree-builder.d.ts.map +1 -0
  51. package/dist/lockfile/types.d.ts +70 -0
  52. package/dist/lockfile/types.d.ts.map +1 -0
  53. package/dist/{Manifest.d.ts → manifest.d.ts} +7 -15
  54. package/dist/manifest.d.ts.map +1 -0
  55. package/dist/{Options.d.ts → options.d.ts} +9 -27
  56. package/dist/options.d.ts.map +1 -0
  57. package/dist/{Platform.d.ts → platform.d.ts} +9 -9
  58. package/dist/platform.d.ts.map +1 -0
  59. package/dist/{Progress.d.ts → progress.d.ts} +10 -9
  60. package/dist/progress.d.ts.map +1 -0
  61. package/dist/{Registry.d.ts → registry.d.ts} +42 -44
  62. package/dist/registry.d.ts.map +1 -0
  63. package/dist/{Resolve.d.ts → resolve.d.ts} +25 -26
  64. package/dist/resolve.d.ts.map +1 -0
  65. package/dist/schemas/lenient.d.ts +38 -0
  66. package/dist/schemas/lenient.d.ts.map +1 -0
  67. package/dist/schemas/package-json.d.ts +44 -0
  68. package/dist/schemas/package-json.d.ts.map +1 -0
  69. package/dist/{Spec.d.ts → spec.d.ts} +4 -4
  70. package/dist/spec.d.ts.map +1 -0
  71. package/dist/types/value-of.d.ts +9 -0
  72. package/dist/types/value-of.d.ts.map +1 -0
  73. package/dist/utils/is-record.d.ts +8 -0
  74. package/dist/utils/is-record.d.ts.map +1 -0
  75. package/package.json +3 -2
  76. package/src/{Archive.ts → archive.ts} +92 -92
  77. package/src/{Bundle.ts → bundle.ts} +616 -570
  78. package/src/{DependencyRange.ts → dependency-range.ts} +115 -115
  79. package/src/{Download.ts → download.ts} +133 -132
  80. package/src/enums/artifact-kind.ts +9 -0
  81. package/src/enums/edge-kind.ts +32 -0
  82. package/src/enums/input-file-kind.ts +13 -0
  83. package/src/enums/layout.ts +25 -0
  84. package/src/enums/lockfile-format.ts +10 -0
  85. package/src/enums/phase.ts +12 -0
  86. package/src/{Errors.ts → errors.ts} +87 -5
  87. package/src/index.ts +133 -35
  88. package/src/{InputFile.ts → input-file.ts} +127 -44
  89. package/src/{Integrity.ts → integrity.ts} +121 -122
  90. package/src/locked-resolve.ts +461 -0
  91. package/src/lockfile/bun.ts +207 -0
  92. package/src/lockfile/detect.ts +80 -0
  93. package/src/lockfile/json.ts +132 -0
  94. package/src/lockfile/names.ts +20 -0
  95. package/src/lockfile/npm.ts +308 -0
  96. package/src/lockfile/parse.ts +56 -0
  97. package/src/lockfile/pnpm.ts +225 -0
  98. package/src/lockfile/tree-builder.ts +166 -0
  99. package/src/lockfile/types.ts +74 -0
  100. package/src/{Manifest.ts → manifest.ts} +133 -133
  101. package/src/{Options.ts → options.ts} +7 -27
  102. package/src/{Platform.ts → platform.ts} +23 -23
  103. package/src/{Progress.ts → progress.ts} +106 -106
  104. package/src/{Registry.ts → registry.ts} +115 -121
  105. package/src/{Resolve.ts → resolve.ts} +537 -528
  106. package/src/schemas/lenient.ts +123 -0
  107. package/src/schemas/package-json.ts +27 -0
  108. package/src/{Spec.ts → spec.ts} +11 -19
  109. package/src/types/value-of.ts +8 -0
  110. package/src/utils/is-record.ts +8 -0
  111. package/dist/Archive.d.ts.map +0 -1
  112. package/dist/Bundle.d.ts.map +0 -1
  113. package/dist/DependencyRange.d.ts.map +0 -1
  114. package/dist/Download.d.ts.map +0 -1
  115. package/dist/Errors.d.ts.map +0 -1
  116. package/dist/InputFile.d.ts +0 -46
  117. package/dist/InputFile.d.ts.map +0 -1
  118. package/dist/Integrity.d.ts.map +0 -1
  119. package/dist/Manifest.d.ts.map +0 -1
  120. package/dist/Options.d.ts.map +0 -1
  121. package/dist/Platform.d.ts.map +0 -1
  122. package/dist/Progress.d.ts.map +0 -1
  123. package/dist/Registry.d.ts.map +0 -1
  124. package/dist/Resolve.d.ts.map +0 -1
  125. package/dist/Spec.d.ts.map +0 -1
  126. /package/src/{Layout.ts → layout.ts} +0 -0
package/dist/index.js CHANGED
@@ -1,26 +1,18 @@
1
1
  import * as Effect from "effect/Effect";
2
- import * as FileSystem from "effect/FileSystem";
3
- import { create } from "tar";
2
+ import * as Option from "effect/Option";
3
+ import * as Schema from "effect/Schema";
4
+ import * as Getter from "effect/SchemaGetter";
5
+ import semver from "semver";
4
6
  import * as Context from "effect/Context";
5
7
  import * as Layer from "effect/Layer";
6
8
  import * as Ref from "effect/Ref";
7
- import * as Path from "effect/Path";
8
9
  import { createHash, timingSafeEqual } from "node:crypto";
9
- import semver from "semver";
10
-
11
- //#region rolldown:runtime
12
- var __defProp = Object.defineProperty;
13
- var __export = (all) => {
14
- let target = {};
15
- for (var name in all) __defProp(target, name, {
16
- get: all[name],
17
- enumerable: true
18
- });
19
- return target;
20
- };
10
+ import { parse } from "yaml";
11
+ import * as FileSystem from "effect/FileSystem";
12
+ import * as Path from "effect/Path";
13
+ import { create } from "tar";
21
14
 
22
- //#endregion
23
- //#region src/Errors.ts
15
+ //#region src/errors.ts
24
16
  var BundlerErrorBase = class extends Error {
25
17
  constructor(message, options) {
26
18
  super(message, options);
@@ -49,6 +41,61 @@ var InvalidInputFileError = class extends BundlerErrorBase {
49
41
  this.reason = reason;
50
42
  }
51
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
+ };
52
99
  /**
53
100
  * The registry could not be reached at all — DNS failure, refused connection,
54
101
  * proxy blackhole, or a preflight that timed out.
@@ -59,11 +106,11 @@ var InvalidInputFileError = class extends BundlerErrorBase {
59
106
  var RegistryUnreachableError = class extends BundlerErrorBase {
60
107
  _tag = "RegistryUnreachableError";
61
108
  registry;
62
- timeoutMillis;
109
+ timeoutMs;
63
110
  constructor(registry, detail, options) {
64
111
  super(`Cannot reach registry ${registry}: ${detail}.\n Check your network connection, VPN, and the proxy settings in your .npmrc.`, options);
65
112
  this.registry = registry;
66
- this.timeoutMillis = options?.timeoutMillis;
113
+ this.timeoutMs = options?.timeoutMs;
67
114
  }
68
115
  };
69
116
  /** The registry answered, but has never heard of this package. */
@@ -175,1148 +222,2463 @@ const safeHost = (registry) => {
175
222
  };
176
223
 
177
224
  //#endregion
178
- //#region src/Progress.ts
179
- var Progress_exports = /* @__PURE__ */ __export({
180
- Progress: () => Progress,
181
- emit: () => emit,
182
- layerCallback: () => layerCallback,
183
- layerSilent: () => layerSilent,
184
- makeCollector: () => makeCollector
185
- });
186
- /** Service tag for progress reporting. */
187
- var Progress = class extends Context.Service()("@packall/core/Progress") {};
188
- /** Emits an event to whichever reporter is installed. */
189
- const emit = (event) => Effect.gen(function* () {
190
- yield* (yield* Progress).emit(event);
191
- });
225
+ //#region src/enums/artifact-kind.ts
226
+ /** What a bundle run wrote to the output directory. */
227
+ const ArtifactKind = {
228
+ Archive: "archive",
229
+ Directory: "directory"
230
+ };
231
+
232
+ //#endregion
233
+ //#region src/enums/edge-kind.ts
192
234
  /**
193
- * Discards every event.
235
+ * Why one package depends on another.
194
236
  *
195
- * The default for library consumers and for tests that do not care about
196
- * progress silence should never require ceremony.
237
+ * `EdgeKind` and `LockedRootKind` are one concept in two parts, which is why
238
+ * they share a file: `Dev` exists only at the top level, because lockfiles
239
+ * record dev dependencies for the project alone — a transitive devDependency is
240
+ * never installed, so it is never locked.
197
241
  */
198
- const layerSilent = Layer.succeed(Progress)({ emit: () => Effect.void });
199
- /** Sends every event to a callback. Used by the CLI renderer. */
200
- const layerCallback = (onEvent) => Layer.succeed(Progress)({ emit: (event) => Effect.sync(() => onEvent(event)) });
242
+ const EdgeKind = {
243
+ Prod: "prod",
244
+ Optional: "optional",
245
+ Peer: "peer"
246
+ };
247
+ /** The kind that only a direct dependency of the locked project can have. */
248
+ const DirectOnlyKind = { Dev: "dev" };
249
+ /** Why a direct dependency of the locked project is a root. */
250
+ const LockedRootKind = {
251
+ ...EdgeKind,
252
+ ...DirectOnlyKind
253
+ };
254
+
255
+ //#endregion
256
+ //#region src/enums/input-file-kind.ts
257
+ /** Which of the three shapes a `--file` input turned out to be. */
258
+ const InputFileKind = {
259
+ PackageJson: "package.json",
260
+ Lockfile: "lockfile",
261
+ List: "list"
262
+ };
263
+
264
+ //#endregion
265
+ //#region src/enums/layout.ts
266
+ /** How the resulting tarball(s) are shaped. */
267
+ const Layout = {
268
+ PerSpec: "per-spec",
269
+ Single: "single",
270
+ Dir: "dir"
271
+ };
272
+ const Layouts = Object.values(Layout);
273
+
274
+ //#endregion
275
+ //#region src/enums/lockfile-format.ts
276
+ /** Which package manager wrote a lockfile. */
277
+ const LockfileFormat = {
278
+ Npm: "npm",
279
+ Pnpm: "pnpm",
280
+ Bun: "bun"
281
+ };
282
+
283
+ //#endregion
284
+ //#region src/enums/phase.ts
285
+ /** The phases a bundle run moves through, in order. */
286
+ const Phase = {
287
+ Preflight: "preflight",
288
+ Resolve: "resolve",
289
+ Download: "download",
290
+ Archive: "archive",
291
+ Done: "done"
292
+ };
293
+
294
+ //#endregion
295
+ //#region src/utils/is-record.ts
201
296
  /**
202
- * Accumulates every event into a `Ref`, for assertions.
297
+ * Narrows an unknown value to a plain object.
203
298
  *
204
- * Returned as `[layer, ref]` so a test can provide the layer and then read the
205
- * transcript afterwards.
299
+ * The entry point for reading anything the tool does not control lockfiles,
300
+ * package.json files, registry responses — without reaching for a cast.
206
301
  */
207
- const makeCollector = Effect.gen(function* () {
208
- const ref = yield* Ref.make([]);
209
- return {
210
- layer: Layer.succeed(Progress)({ emit: (event) => Ref.update(ref, (events) => [...events, event]) }),
211
- events: Ref.get(ref)
212
- };
213
- });
302
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
214
303
 
215
304
  //#endregion
216
- //#region src/Archive.ts
217
- var Archive_exports = /* @__PURE__ */ __export({ createArchive: () => createArchive });
305
+ //#region src/schemas/lenient.ts
218
306
  /**
219
- * Packs `entries` (paths relative to `cwd`) into a gzipped tar at `outPath`.
307
+ * An optional field that goes *absent* rather than failing when it is
308
+ * malformed.
220
309
  *
221
- * `portable` normalises uid/gid/mtime so the same inputs produce byte-identical
222
- * output which matters when a security team wants to diff two bundles or
223
- * re-derive one from a manifest.
310
+ * This is the difference between "we do not understand this field" and "we do
311
+ * not understand this document". An `os: "linux"` where an array was expected
312
+ * costs the platform filter one signal; it should not cost the run the package.
224
313
  */
225
- const createArchive = (options) => Effect.gen(function* () {
226
- const fs = yield* FileSystem.FileSystem;
227
- yield* emit({
228
- _tag: "ArchiveStarted",
229
- path: options.outPath,
230
- entryCount: options.entries.length
231
- });
232
- yield* Effect.tryPromise({
233
- try: () => create({
234
- gzip: options.gzipLevel === void 0 ? true : { level: options.gzipLevel },
235
- file: options.outPath,
236
- cwd: options.cwd,
237
- portable: true,
238
- onWriteEntry: (entry) => {
239
- entry.path = entry.path.replace(/^\.\//, "");
240
- }
241
- }, [...options.entries].sort().map(dotSlash)),
242
- catch: (cause) => new ArchiveError(options.outPath, describe$1(cause), { cause })
243
- });
244
- const info = yield* fs.stat(options.outPath);
245
- const bytes = Number(info.size);
246
- yield* emit({
247
- _tag: "ArchiveCompleted",
248
- path: options.outPath,
249
- bytes
250
- });
251
- return {
252
- path: options.outPath,
253
- bytes
254
- };
255
- });
314
+ const tolerant = (schema) => Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(void 0)))));
256
315
  /**
257
- * Guards every entry against tar's `@` convention.
258
- *
259
- * In a tar file list, a leading `@` means "splice in the entries of this other
260
- * archive" — a GNU convention `node-tar` implements by stripping the `@` and
261
- * looking for what is left. Every scoped package is a top-level entry starting
262
- * with `@`, so this hit the bundler in both possible ways:
263
- *
264
- * - `@oxc-project` became `oxc-project`, which does not exist, and the run died
265
- * with an ENOENT naming a path that appears nowhere in the staging tree.
266
- * - `@esbuild` became `esbuild`, which *does* exist — the unscoped package of
267
- * the same name sitting right next to it. No error, and every scoped tarball
268
- * silently missing from a bundle that reported success. That is the dangerous
269
- * one: you would not find out until the install failed behind the firewall.
316
+ * Keeps only the string-valued members of an untyped object.
270
317
  *
271
- * `./@scope` is not subject to the convention and resolves identically.
318
+ * `{ "lodash": "^4.0.0", "broken": 3 }` decodes to `{ "lodash": "^4.0.0" }`.
319
+ * Per-entry rather than whole-record tolerance, because one unreadable edge in
320
+ * a dependency block is no reason to forget the others.
272
321
  */
273
- const dotSlash = (entry) => entry.startsWith("./") ? entry : `./${entry}`;
274
- const describe$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
275
-
276
- //#endregion
277
- //#region src/Integrity.ts
278
- var Integrity_exports = /* @__PURE__ */ __export({
279
- digestOf: () => digestOf,
280
- hexDigestOf: () => hexDigestOf,
281
- parseIntegrity: () => parseIntegrity,
282
- verify: () => verify
283
- });
284
- const SUPPORTED = new Set([
285
- "sha512",
286
- "sha384",
287
- "sha256",
288
- "sha1"
289
- ]);
322
+ const StringRecordSchema = Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.decodeTo(Schema.Record(Schema.String, Schema.String), {
323
+ decode: Getter.transform((raw) => {
324
+ const out = {};
325
+ for (const [key, value] of Object.entries(raw)) if (typeof value === "string") out[key] = value;
326
+ return out;
327
+ }),
328
+ encode: Getter.passthroughSubtype()
329
+ }));
330
+ /** The same per-entry tolerance for a list: `["linux", 7]` decodes to `["linux"]`. */
331
+ const StringArraySchema = Schema.Array(Schema.Unknown).pipe(Schema.decodeTo(Schema.Array(Schema.String), {
332
+ decode: Getter.transform((raw) => raw.flatMap((entry) => typeof entry === "string" ? entry : [])),
333
+ encode: Getter.passthroughSubtype()
334
+ }));
290
335
  /**
291
- * Parses an SRI string such as `sha512-abc...==`.
336
+ * `peerDependenciesMeta`, normalised.
292
337
  *
293
- * npm permits several space-separated hashes; we keep every one we can verify
294
- * and ignore algorithms Node does not implement.
338
+ * Only `optional` is read, and only its `true` is meaningful npm writes the
339
+ * flag as a boolean, a hand-edited file may carry anything, and every other
340
+ * value means "not optional".
295
341
  */
296
- const parseIntegrity = (integrity) => {
297
- const hashes = [];
298
- for (const token of integrity.trim().split(/\s+/)) {
299
- if (token.length === 0) continue;
300
- const dash = token.indexOf("-");
301
- if (dash <= 0) continue;
302
- const algorithm = token.slice(0, dash).toLowerCase();
303
- const digest = token.slice(dash + 1);
304
- if (!SUPPORTED.has(algorithm) || digest.length === 0) continue;
305
- hashes.push({
306
- algorithm,
307
- digest
308
- });
342
+ const OptionalFlagRecordSchema = Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.decodeTo(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.Boolean })), {
343
+ decode: Getter.transform((raw) => {
344
+ const out = {};
345
+ for (const [key, value] of Object.entries(raw)) if (isRecord(value)) out[key] = { optional: value["optional"] === true };
346
+ return out;
347
+ }),
348
+ encode: Getter.passthroughSubtype()
349
+ }));
350
+ const absentWhenEmpty = (value) => Object.keys(value).length > 0 ? value : void 0;
351
+ const OptionalStringRecordSchema = StringRecordSchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.String)), {
352
+ decode: Getter.transform(absentWhenEmpty),
353
+ encode: Getter.transform((value) => value ?? {})
354
+ }));
355
+ const OptionalStringArraySchema = StringArraySchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Array(Schema.String)), {
356
+ decode: Getter.transform((value) => value.length > 0 ? value : void 0),
357
+ encode: Getter.transform((value) => value ?? [])
358
+ }));
359
+ const OptionalFlagRecordOrAbsentSchema = OptionalFlagRecordSchema.pipe(Schema.decodeTo(Schema.UndefinedOr(Schema.Record(Schema.String, Schema.Struct({ optional: Schema.Boolean }))), {
360
+ decode: Getter.transform(absentWhenEmpty),
361
+ encode: Getter.transform((value) => value ?? {})
362
+ }));
363
+
364
+ //#endregion
365
+ //#region src/spec.ts
366
+ const MAX_NAME_LENGTH = 214;
367
+ const SEGMENT_RE = /^[a-z0-9\-._~]+$/;
368
+ const DIST_TAG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
369
+ /** Renders a spec back into the canonical `name@selector` form. */
370
+ const formatSpec = (spec) => `${spec.name}@${formatSelector(spec.selector)}`;
371
+ /** Human-readable form of just the selector part. */
372
+ const formatSelector = (selector) => {
373
+ switch (selector._tag) {
374
+ case "Exact": return selector.version;
375
+ case "Range": return selector.range;
376
+ case "Tag": return selector.tag;
309
377
  }
310
- return hashes;
311
- };
312
- /** Computes the base64 digest of some bytes under one algorithm. */
313
- const digestOf = (data, algorithm) => createHash(algorithm).update(data).digest("base64");
314
- /** Computes the hex digest of some bytes — the form legacy `shasum` uses. */
315
- const hexDigestOf = (data, algorithm) => createHash(algorithm).update(data).digest("hex");
316
- /** Constant-time comparison of two digest strings of equal length. */
317
- const digestsEqual = (a, b) => {
318
- const left = Buffer.from(a);
319
- const right = Buffer.from(b);
320
- return left.length === right.length && timingSafeEqual(left, right);
321
378
  };
322
379
  /**
323
- * Checks bytes against whichever checksums the registry supplied.
324
- *
325
- * SRI is preferred; `shasum` is the fallback for versions published before
326
- * integrity strings existed. If the registry gave us neither, that is reported
327
- * as `Unverifiable` rather than silently treated as a pass — the caller decides
328
- * whether to tolerate it.
380
+ * Validates an npm package name against the rules the registry actually
381
+ * enforces. Returns `null` when valid, or the reason it is not.
329
382
  */
330
- const verify = (data, dist) => {
331
- if (dist.integrity !== void 0 && dist.integrity.length > 0) {
332
- const hashes = parseIntegrity(dist.integrity);
333
- if (hashes.length === 0) return {
334
- _tag: "Unverifiable",
335
- reason: `no supported algorithm in "${dist.integrity}"`
336
- };
337
- const best = [...hashes].sort((a, b) => strength(b.algorithm) - strength(a.algorithm))[0];
338
- const actual = digestOf(data, best.algorithm);
339
- return digestsEqual(actual, best.digest) ? {
340
- _tag: "Verified",
341
- using: best.algorithm
342
- } : {
343
- _tag: "Mismatch",
344
- expected: `${best.algorithm}-${best.digest}`,
345
- actual: `${best.algorithm}-${actual}`
346
- };
347
- }
348
- if (dist.shasum !== void 0 && dist.shasum.length > 0) {
349
- const actual = hexDigestOf(data, "sha1");
350
- return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase()) ? {
351
- _tag: "Verified",
352
- using: "sha1"
353
- } : {
354
- _tag: "Mismatch",
355
- expected: `sha1-${dist.shasum}`,
356
- actual: `sha1-${actual}`
357
- };
358
- }
359
- return {
360
- _tag: "Unverifiable",
361
- reason: "registry advertised no integrity or shasum"
362
- };
363
- };
364
- const strength = (algorithm) => {
365
- switch (algorithm) {
366
- case "sha512": return 4;
367
- case "sha384": return 3;
368
- case "sha256": return 2;
369
- default: return 1;
383
+ const validatePackageName = (name) => {
384
+ if (name.length === 0) return "name is empty";
385
+ if (name.length > MAX_NAME_LENGTH) return `name is longer than ${MAX_NAME_LENGTH} characters`;
386
+ if (name.trim() !== name) return "name has leading or trailing whitespace";
387
+ if (name.startsWith(".")) return "name cannot start with a period";
388
+ if (name.startsWith("_")) return "name cannot start with an underscore";
389
+ if (name !== name.toLowerCase()) return "name cannot contain capital letters";
390
+ if (name.startsWith("@")) {
391
+ const slash = name.indexOf("/");
392
+ if (slash === -1) return "scoped name is missing the '/name' part";
393
+ const scope = name.slice(1, slash);
394
+ const rest = name.slice(slash + 1);
395
+ if (scope.length === 0) return "scope is empty";
396
+ if (rest.length === 0) return "name after the scope is empty";
397
+ if (rest.includes("/")) return "name contains more than one '/'";
398
+ if (!SEGMENT_RE.test(scope)) return `scope "${scope}" contains illegal characters`;
399
+ if (!SEGMENT_RE.test(rest)) return `name "${rest}" contains illegal characters`;
400
+ return null;
370
401
  }
402
+ if (name.includes("/")) return "unscoped name cannot contain '/'";
403
+ if (!SEGMENT_RE.test(name)) return "name contains illegal characters";
404
+ return null;
371
405
  };
372
-
373
- //#endregion
374
- //#region src/Layout.ts
375
- var Layout_exports = /* @__PURE__ */ __export({
376
- MANIFEST_FILE: () => MANIFEST_FILE,
377
- README_FILE: () => README_FILE,
378
- importGuide: () => importGuide,
379
- packagePath: () => packagePath,
380
- perSpecArchiveName: () => perSpecArchiveName,
381
- singleArchiveName: () => singleArchiveName,
382
- splitName: () => splitName,
383
- tarballFileName: () => tarballFileName
384
- });
385
406
  /**
386
- * The on-disk shape of a bundle.
387
- *
388
- * Everything is laid out exactly the way a registry serves it:
389
- *
390
- * lodash/-/lodash-4.17.21.tgz
391
- * @babel/core/-/core-7.24.0.tgz
407
+ * Parses a single spec string.
392
408
  *
393
- * That is not an Artifactory convention it is the path structure in every
394
- * `dist.tarball` URL npm publishes, which is why the same tree imports into
409
+ * Splitting on `@` is the fiddly part: a scoped name *starts* with `@`, so we
410
+ * look for the last `@` that is not at index 0.
411
+ */
412
+ const parseSpec = (raw) => {
413
+ const input = raw.trim();
414
+ if (input.length === 0) throw new InvalidSpecError(raw, "spec is empty");
415
+ const at = input.lastIndexOf("@");
416
+ const hasSelector = at > 0;
417
+ const name = hasSelector ? input.slice(0, at) : input;
418
+ const selectorText = hasSelector ? input.slice(at + 1) : "";
419
+ const nameProblem = validatePackageName(name);
420
+ if (nameProblem !== null) throw new InvalidSpecError(raw, nameProblem);
421
+ if (!hasSelector || selectorText.length === 0) return {
422
+ name,
423
+ selector: {
424
+ _tag: "Tag",
425
+ tag: "latest"
426
+ },
427
+ raw: input
428
+ };
429
+ return {
430
+ name,
431
+ selector: parseSelector(raw, selectorText),
432
+ raw: input
433
+ };
434
+ };
435
+ /** Classifies the part after the `@` as an exact version, a range, or a dist-tag. */
436
+ const parseSelector = (raw, text) => {
437
+ const exact = semver.valid(text, { loose: false });
438
+ if (exact !== null) return {
439
+ _tag: "Exact",
440
+ version: exact
441
+ };
442
+ if (semver.validRange(text, { loose: false }) !== null) return {
443
+ _tag: "Range",
444
+ range: text
445
+ };
446
+ if (!DIST_TAG_RE.test(text)) throw new InvalidSpecError(raw, `"${text}" is not a valid version, semver range, or dist-tag`);
447
+ return {
448
+ _tag: "Tag",
449
+ tag: text
450
+ };
451
+ };
452
+ /**
453
+ * Parses many specs, collecting *every* failure rather than stopping at the
454
+ * first. Somebody bundling forty packages should be told about all four typos
455
+ * in one go, not made to re-run four times.
456
+ */
457
+ const parseSpecs = (inputs) => {
458
+ const specs = [];
459
+ const errors = [];
460
+ for (const input of inputs) try {
461
+ specs.push(parseSpec(input));
462
+ } catch (error) {
463
+ if (error instanceof InvalidSpecError) errors.push(error);
464
+ else throw error;
465
+ }
466
+ return {
467
+ specs,
468
+ errors
469
+ };
470
+ };
471
+ /**
472
+ * Collapses duplicate specs, keeping the first occurrence.
473
+ *
474
+ * `npmb react react@latest` is a plausible thing to type and should not
475
+ * download React twice.
476
+ */
477
+ const dedupeSpecs = (specs) => {
478
+ const seen = /* @__PURE__ */ new Set();
479
+ const out = [];
480
+ for (const spec of specs) {
481
+ const key = formatSpec(spec);
482
+ if (seen.has(key)) continue;
483
+ seen.add(key);
484
+ out.push(spec);
485
+ }
486
+ return out;
487
+ };
488
+
489
+ //#endregion
490
+ //#region src/dependency-range.ts
491
+ const UNSUPPORTED_PROTOCOLS = [
492
+ ["file:", "local file path"],
493
+ ["link:", "local link"],
494
+ ["workspace:", "workspace protocol"],
495
+ ["portal:", "portal protocol"],
496
+ ["patch:", "patch protocol"],
497
+ ["git:", "git dependency"],
498
+ ["git+", "git dependency"],
499
+ ["github:", "GitHub shorthand"],
500
+ ["gitlab:", "GitLab shorthand"],
501
+ ["bitbucket:", "Bitbucket shorthand"],
502
+ ["http:", "remote tarball URL"],
503
+ ["https:", "remote tarball URL"]
504
+ ];
505
+ /**
506
+ * Classifies one `name -> range` entry.
507
+ *
508
+ * An empty range, `*`, and `latest` all mean "any published version"; npm
509
+ * treats them interchangeably and so do we.
510
+ */
511
+ const parseDependencyTarget = (name, raw) => {
512
+ const text = raw.trim();
513
+ if (text.length === 0 || text === "*" || text === "x" || text === "latest") return {
514
+ _tag: "Registry",
515
+ name,
516
+ selector: {
517
+ _tag: "Range",
518
+ range: "*"
519
+ }
520
+ };
521
+ if (text.startsWith("npm:")) return parseAlias(name, text);
522
+ for (const [prefix, reason] of UNSUPPORTED_PROTOCOLS) if (text.startsWith(prefix)) return {
523
+ _tag: "Unsupported",
524
+ name,
525
+ raw: text,
526
+ reason
527
+ };
528
+ if (semver.validRange(text, { loose: true }) === null && /^[\w.-]+\/[\w.-]+/.test(text)) return {
529
+ _tag: "Unsupported",
530
+ name,
531
+ raw: text,
532
+ reason: "git shorthand"
533
+ };
534
+ const exact = semver.valid(text, { loose: true });
535
+ if (exact !== null) return {
536
+ _tag: "Registry",
537
+ name,
538
+ selector: {
539
+ _tag: "Exact",
540
+ version: exact
541
+ }
542
+ };
543
+ if (semver.validRange(text, { loose: true }) !== null) return {
544
+ _tag: "Registry",
545
+ name,
546
+ selector: {
547
+ _tag: "Range",
548
+ range: text
549
+ }
550
+ };
551
+ if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(text)) return {
552
+ _tag: "Registry",
553
+ name,
554
+ selector: {
555
+ _tag: "Tag",
556
+ tag: text
557
+ }
558
+ };
559
+ return {
560
+ _tag: "Unsupported",
561
+ name,
562
+ raw: text,
563
+ reason: "unrecognised version specifier"
564
+ };
565
+ };
566
+ /**
567
+ * Parses `npm:<name>[@<range>]`.
568
+ *
569
+ * The nested name may itself be scoped, so the `@` split has the same
570
+ * "not at index 0" caveat as top-level spec parsing.
571
+ */
572
+ const parseAlias = (key, text) => {
573
+ const body = text.slice(4);
574
+ if (body.length === 0) return {
575
+ _tag: "Unsupported",
576
+ name: key,
577
+ raw: text,
578
+ reason: "empty npm: alias"
579
+ };
580
+ const at = body.lastIndexOf("@");
581
+ const target = at > 0 ? body.slice(0, at) : body;
582
+ const rangeText = at > 0 ? body.slice(at + 1) : "";
583
+ if (target.length === 0) return {
584
+ _tag: "Unsupported",
585
+ name: key,
586
+ raw: text,
587
+ reason: "empty npm: alias target"
588
+ };
589
+ const inner = parseDependencyTarget(target, rangeText);
590
+ if (inner._tag === "Unsupported") return {
591
+ ...inner,
592
+ name: key,
593
+ raw: text
594
+ };
595
+ return {
596
+ _tag: "Registry",
597
+ name: target,
598
+ selector: inner.selector,
599
+ aliasOf: key
600
+ };
601
+ };
602
+
603
+ //#endregion
604
+ //#region src/platform.ts
605
+ const allPlatforms = { _tag: "All" };
606
+ const platformTargets = (targets) => ({
607
+ _tag: "Targets",
608
+ targets
609
+ });
610
+ /** The machine we are currently running on. */
611
+ const currentPlatform = () => ({
612
+ os: process.platform,
613
+ cpu: process.arch
614
+ });
615
+ /**
616
+ * npm's documented `os` values, plus the names people actually type.
617
+ *
618
+ * An OS on its own is a valid target, so a typo has to be caught here — the
619
+ * whole failure mode this tool exists to prevent is a bundle that looks fine
620
+ * and installs nothing. `--platform windows` silently matching no binding at
621
+ * all would be precisely that, so it is corrected rather than accepted.
622
+ */
623
+ const osByAlias = {
624
+ win: "win32",
625
+ windows: "win32",
626
+ mac: "darwin",
627
+ macos: "darwin",
628
+ osx: "darwin"
629
+ };
630
+ const KNOWN_OS = new Set([
631
+ "aix",
632
+ "android",
633
+ "cygwin",
634
+ "darwin",
635
+ "freebsd",
636
+ "haiku",
637
+ "linux",
638
+ "netbsd",
639
+ "openbsd",
640
+ "sunos",
641
+ "win32"
642
+ ]);
643
+ const LIBC = new Set(["musl", "glibc"]);
644
+ /**
645
+ * Parses `linux`, `linux-x64`, `darwin-arm64`, `win32-x64`, `linux-x64-musl`,
646
+ * `linux-musl`.
647
+ *
648
+ * An OS on its own means every architecture for that OS, which is almost always
649
+ * what you want: "the Windows and Linux bindings" is a far more natural way to
650
+ * describe a target set than enumerating four `os-arch` pairs, and it keeps
651
+ * working when a package adds an arm64 build.
652
+ *
653
+ * Returns `null` rather than throwing so the CLI can report every bad value at
654
+ * once alongside the list of accepted forms.
655
+ */
656
+ const parsePlatformTarget = (input) => {
657
+ const parts = input.trim().toLowerCase().split("-").filter((part) => part.length > 0);
658
+ const [rawOs, second, third] = parts;
659
+ if (rawOs === void 0 || parts.length > 3) return null;
660
+ const os = osByAlias[rawOs] ?? rawOs;
661
+ if (!KNOWN_OS.has(os)) return null;
662
+ if (second === void 0) return { os };
663
+ if (third === void 0) return LIBC.has(second) ? {
664
+ os,
665
+ libc: second
666
+ } : {
667
+ os,
668
+ cpu: second
669
+ };
670
+ if (!LIBC.has(third)) return null;
671
+ return {
672
+ os,
673
+ cpu: second,
674
+ libc: third
675
+ };
676
+ };
677
+ /**
678
+ * npm's `os`/`cpu`/`libc` fields use a list of allowed values, where a leading
679
+ * `!` negates. An empty or absent list means "no constraint".
680
+ *
681
+ * The semantics npm implements: if any negated entry matches, reject. Otherwise
682
+ * if there are any positive entries, at least one must match.
683
+ */
684
+ const listAllows = (list, value) => {
685
+ if (list === void 0 || list.length === 0) return true;
686
+ let hasPositive = false;
687
+ let positiveMatched = false;
688
+ for (const entry of list) {
689
+ const normalized = entry.trim().toLowerCase();
690
+ if (normalized.length === 0) continue;
691
+ if (normalized === "any" || normalized === "*") return true;
692
+ if (normalized.startsWith("!")) {
693
+ if (normalized.slice(1) === value) return false;
694
+ } else {
695
+ hasPositive = true;
696
+ if (normalized === value) positiveMatched = true;
697
+ }
698
+ }
699
+ return hasPositive ? positiveMatched : true;
700
+ };
701
+ /** Would this package install on this specific target? */
702
+ const matchesTarget = (constraints, target) => {
703
+ if (!listAllows(constraints.os, target.os)) return false;
704
+ if (target.cpu !== void 0 && !listAllows(constraints.cpu, target.cpu)) return false;
705
+ if (target.libc !== void 0 && !listAllows(constraints.libc, target.libc)) return false;
706
+ return true;
707
+ };
708
+ /**
709
+ * Should this package be included, given the active filter?
710
+ *
711
+ * Under `All` this is unconditionally `true` — that is the whole point.
712
+ */
713
+ const isIncluded = (constraints, filter) => {
714
+ if (filter._tag === "All") return true;
715
+ return filter.targets.some((target) => matchesTarget(constraints, target));
716
+ };
717
+ /** Renders a filter for the manifest and for `--dry-run` output. */
718
+ const formatPlatformFilter = (filter) => {
719
+ if (filter._tag === "All") return "all";
720
+ return filter.targets.map((target) => [
721
+ target.os,
722
+ target.cpu,
723
+ target.libc
724
+ ].filter((part) => part !== void 0).join("-")).join(",");
725
+ };
726
+
727
+ //#endregion
728
+ //#region src/options.ts
729
+ const defaultScope = {
730
+ optional: true,
731
+ peer: true,
732
+ platforms: allPlatforms
733
+ };
734
+ const defaultResolveOptions = {
735
+ scope: defaultScope,
736
+ allVersions: false,
737
+ includePrerelease: false,
738
+ concurrency: 10
739
+ };
740
+ const defaultBundleOptions = {
741
+ ...defaultResolveOptions,
742
+ layout: Layout.PerSpec,
743
+ dryRun: false,
744
+ verifyIntegrity: true,
745
+ force: false
746
+ };
747
+
748
+ //#endregion
749
+ //#region src/registry.ts
750
+ /**
751
+ * Service tag for the registry backend.
752
+ *
753
+ * @example
754
+ * ```ts
755
+ * import { Effect } from "effect"
756
+ * import { Registry } from "@packall/core"
757
+ *
758
+ * const program = Effect.gen(function*() {
759
+ * const registry = yield* Registry
760
+ * return yield* registry.packument("lodash")
761
+ * })
762
+ * ```
763
+ */
764
+ var Registry = class extends Context.Service()("@packall/core/Registry") {};
765
+
766
+ //#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)) });
783
+ /**
784
+ * Accumulates every event into a `Ref`, for assertions.
785
+ *
786
+ * Returned as `{ layer, events }` so a test can provide the layer and then read
787
+ * the transcript afterwards.
788
+ */
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
+ ]);
805
+ /**
806
+ * Parses an SRI string such as `sha512-abc...==`.
807
+ *
808
+ * npm permits several space-separated hashes; we keep every one we can verify
809
+ * and ignore algorithms Node does not implement.
810
+ */
811
+ const parseIntegrity = (integrity) => {
812
+ const hashes = [];
813
+ for (const token of integrity.trim().split(/\s+/)) {
814
+ if (token.length === 0) continue;
815
+ const dash = token.indexOf("-");
816
+ if (dash <= 0) continue;
817
+ const algorithm = token.slice(0, dash).toLowerCase();
818
+ const digest = token.slice(dash + 1);
819
+ if (!SUPPORTED.has(algorithm) || digest.length === 0) continue;
820
+ hashes.push({
821
+ algorithm,
822
+ digest
823
+ });
824
+ }
825
+ return hashes;
826
+ };
827
+ /** Computes the base64 digest of some bytes under one algorithm. */
828
+ const digestOf = (data, algorithm) => createHash(algorithm).update(data).digest("base64");
829
+ /** 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. */
832
+ 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);
836
+ };
837
+ /**
838
+ * Checks bytes against whichever checksums the registry supplied.
839
+ *
840
+ * SRI is preferred; `shasum` is the fallback for versions published before
841
+ * integrity strings existed. If the registry gave us neither, that is reported
842
+ * as `Unverifiable` rather than silently treated as a pass — the caller decides
843
+ * whether to tolerate it.
844
+ */
845
+ const verify = (data, dist) => {
846
+ if (dist.integrity !== void 0 && dist.integrity.length > 0) {
847
+ const [best] = parseIntegrity(dist.integrity).toSorted((a, b) => strength(b.algorithm) - strength(a.algorithm));
848
+ if (best === void 0) return {
849
+ _tag: "Unverifiable",
850
+ reason: `no supported algorithm in "${dist.integrity}"`
851
+ };
852
+ const actual = digestOf(data, best.algorithm);
853
+ return digestsEqual(actual, best.digest) ? {
854
+ _tag: "Verified",
855
+ using: best.algorithm
856
+ } : {
857
+ _tag: "Mismatch",
858
+ expected: `${best.algorithm}-${best.digest}`,
859
+ actual: `${best.algorithm}-${actual}`
860
+ };
861
+ }
862
+ if (dist.shasum !== void 0 && dist.shasum.length > 0) {
863
+ const actual = hexDigestOf(data, "sha1");
864
+ return digestsEqual(actual.toLowerCase(), dist.shasum.toLowerCase()) ? {
865
+ _tag: "Verified",
866
+ using: "sha1"
867
+ } : {
868
+ _tag: "Mismatch",
869
+ expected: `sha1-${dist.shasum}`,
870
+ actual: `sha1-${actual}`
871
+ };
872
+ }
873
+ return {
874
+ _tag: "Unverifiable",
875
+ reason: "registry advertised no integrity or shasum"
876
+ };
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
+ }
885
+ };
886
+
887
+ //#endregion
888
+ //#region src/layout.ts
889
+ /**
890
+ * The on-disk shape of a bundle.
891
+ *
892
+ * Everything is laid out exactly the way a registry serves it:
893
+ *
894
+ * lodash/-/lodash-4.17.21.tgz
895
+ * @babel/core/-/core-7.24.0.tgz
896
+ *
897
+ * 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
395
899
  * Artifactory, Nexus and Verdaccio without translation. Note that a scoped
396
900
  * package's file name drops the scope: `@babel/core` becomes `core-7.24.0.tgz`
397
901
  * under an `@babel/core/-/` directory.
398
902
  */
399
- /** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
400
- const splitName = (name) => {
401
- if (!name.startsWith("@")) return {
402
- scope: void 0,
403
- bare: name
903
+ /** Splits `@scope/name` into its parts. `scope` is undefined when unscoped. */
904
+ const splitName = (name) => {
905
+ if (!name.startsWith("@")) return {
906
+ scope: void 0,
907
+ bare: name
908
+ };
909
+ const slash = name.indexOf("/");
910
+ if (slash === -1) return {
911
+ scope: void 0,
912
+ bare: name
913
+ };
914
+ return {
915
+ scope: name.slice(0, slash),
916
+ bare: name.slice(slash + 1)
917
+ };
918
+ };
919
+ /** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
920
+ const tarballFileName = (name, version) => {
921
+ const { bare } = splitName(name);
922
+ return `${bare}-${version}.tgz`;
923
+ };
924
+ /**
925
+ * The path of one package tarball within a bundle, using `/` separators
926
+ * regardless of host platform — these become tar entry names, and tar entries
927
+ * are always POSIX.
928
+ */
929
+ const packagePath = (name, version) => `${name}/-/${tarballFileName(name, version)}`;
930
+ /** File name of the manifest that ships inside every bundle. */
931
+ const MANIFEST_FILE = "bundle-manifest.json";
932
+ /** File name of the short import guide that ships inside every bundle. */
933
+ const README_FILE = "IMPORT.md";
934
+ /**
935
+ * Name of the archive produced for one root spec in `per-spec` layout.
936
+ *
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.
940
+ */
941
+ const perSpecArchiveName = (name, version) => {
942
+ const { scope, bare } = splitName(name);
943
+ return `${scope === void 0 ? bare : `${scope.slice(1)}-${bare}`}-${version}.tgz`;
944
+ };
945
+ /** Name of the archive produced in `single` layout. */
946
+ const singleArchiveName = (base = "bundle") => `${base}.tgz`;
947
+ /**
948
+ * The import guide written into each bundle.
949
+ *
950
+ * Kept short and copy-pasteable on purpose: whoever opens this is mid-task on a
951
+ * restricted network and does not want prose.
952
+ */
953
+ const importGuide = (options) => `# Importing this bundle
954
+
955
+ ${options.packageCount} package tarball(s), laid out exactly as an npm registry serves them:
956
+
957
+ <package-name>/-/<file>.tgz
958
+ @<scope>/<name>/-/<file>.tgz
959
+
960
+ Created ${options.createdAt} by packall ${options.toolVersion}.
961
+ See ${MANIFEST_FILE} for the full list with checksums.
962
+
963
+ ## JFrog Artifactory
964
+
965
+ Unpack, then upload the tree into a **local npm** repository. The layout already
966
+ matches what Artifactory expects, so no path rewriting is needed:
967
+
968
+ tar -xzf <this-bundle>.tgz -C ./bundle
969
+ jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
970
+
971
+ ## Verdaccio / Nexus / any npm registry
972
+
973
+ Publish each tarball individually:
974
+
975
+ find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
976
+
977
+ ## Verifying before import
978
+
979
+ node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
980
+
981
+ Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
982
+ advertised, and each tarball was checked against it at download time.
983
+ `;
984
+
985
+ //#endregion
986
+ //#region src/manifest.ts
987
+ /** Schema version, bumped when the shape changes incompatibly. */
988
+ const MANIFEST_VERSION = 1;
989
+ /** Renders a package's reasons into short, greppable strings. */
990
+ const formatReasons = (pkg) => pkg.reasons.map((reason) => reason._tag === "Root" ? `root:${reason.spec}` : `${reason.kind}:${reason.from}`);
991
+ /** Builds the manifest for a completed (or dry) run. */
992
+ const buildManifest = (input) => {
993
+ const includedNames = new Set(input.included.map((pkg) => `${pkg.name}@${pkg.version}`));
994
+ const packages = input.included.map((pkg) => {
995
+ const key = `${pkg.name}@${pkg.version}`;
996
+ return {
997
+ name: pkg.name,
998
+ version: pkg.version,
999
+ path: packagePath(pkg.name, pkg.version),
1000
+ tarball: pkg.manifest.dist.tarball,
1001
+ integrity: pkg.manifest.dist.integrity,
1002
+ shasum: pkg.manifest.dist.shasum,
1003
+ bytes: input.sizes.get(key),
1004
+ reasons: formatReasons(pkg)
1005
+ };
1006
+ });
1007
+ const totalBytes = packages.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0);
1008
+ return {
1009
+ manifestVersion: MANIFEST_VERSION,
1010
+ tool: {
1011
+ name: "packall",
1012
+ version: input.toolVersion
1013
+ },
1014
+ createdAt: (input.createdAt ?? /* @__PURE__ */ new Date()).toISOString(),
1015
+ registry: input.registry,
1016
+ requested: input.resolution.roots.map((root) => formatSpec(root.spec)),
1017
+ roots: input.resolution.roots.map((root) => ({
1018
+ spec: formatSpec(root.spec),
1019
+ versions: root.versions,
1020
+ packageCount: root.closure.filter((key) => includedNames.has(key)).length
1021
+ })),
1022
+ options: {
1023
+ layout: input.options.layout,
1024
+ optionalDependencies: input.options.scope.optional,
1025
+ peerDependencies: input.options.scope.peer,
1026
+ platforms: formatPlatformFilter(input.options.scope.platforms),
1027
+ allVersions: input.options.allVersions,
1028
+ includePrerelease: input.options.includePrerelease,
1029
+ integrityVerified: input.options.verifyIntegrity
1030
+ },
1031
+ packages,
1032
+ warnings: input.resolution.warnings.map((warning) => warning.from === void 0 ? warning.message : `${warning.from}: ${warning.message}`),
1033
+ totals: {
1034
+ packages: packages.length,
1035
+ bytes: totalBytes
1036
+ }
1037
+ };
1038
+ };
1039
+ /** Serialises a manifest with stable key order and a trailing newline. */
1040
+ const serializeManifest = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`;
1041
+
1042
+ //#endregion
1043
+ //#region src/resolve.ts
1044
+ const packageKey = (name, version) => `${name}@${version}`;
1045
+ /** Index of a resolution by `name@version`, for lookups during bundling. */
1046
+ const indexPackages = (resolution) => new Map(resolution.packages.map((pkg) => [packageKey(pkg.name, pkg.version), pkg]));
1047
+ /**
1048
+ * Turns a selector into the concrete version(s) it names.
1049
+ *
1050
+ * `all` controls whether a range collapses to its best match — npm's behaviour,
1051
+ * and what you always want for a transitive dependency — or expands to every
1052
+ * satisfying published version, which is what `--all-versions` is for.
1053
+ */
1054
+ const selectVersions = (packument, selector, options) => {
1055
+ const available = Object.keys(packument.versions);
1056
+ switch (selector._tag) {
1057
+ case "Exact":
1058
+ if (!Object.hasOwn(packument.versions, selector.version)) throw new VersionNotFoundError(packument.name, selector.version, sortVersions(available));
1059
+ return [selector.version];
1060
+ case "Tag": {
1061
+ const version = packument.distTags[selector.tag];
1062
+ if (version === void 0 || !Object.hasOwn(packument.versions, version)) throw new NoMatchingVersionsError(packument.name, selector.tag, sortVersions(available));
1063
+ return [version];
1064
+ }
1065
+ case "Range": {
1066
+ const satisfying = available.filter((version) => semver.satisfies(version, selector.range, {
1067
+ loose: true,
1068
+ includePrerelease: options.includePrerelease
1069
+ })).toSorted(semver.rcompare);
1070
+ const [best] = satisfying;
1071
+ if (best === void 0) throw new NoMatchingVersionsError(packument.name, selector.range, sortVersions(available));
1072
+ if (!options.all) return [best];
1073
+ return options.maxVersions !== void 0 && options.maxVersions > 0 ? satisfying.slice(0, options.maxVersions) : satisfying;
1074
+ }
1075
+ }
1076
+ };
1077
+ const sortVersions = (versions) => versions.toSorted((a, b) => semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b));
1078
+ const makeWalkState = () => ({
1079
+ resolved: /* @__PURE__ */ new Map(),
1080
+ edges: /* @__PURE__ */ new Map(),
1081
+ packuments: /* @__PURE__ */ new Map(),
1082
+ warnings: [],
1083
+ warningKeys: /* @__PURE__ */ new Set()
1084
+ });
1085
+ const addWarning = (state, warning) => {
1086
+ const key = `${warning.from ?? ""}|${warning.message}`;
1087
+ if (state.warningKeys.has(key)) return;
1088
+ state.warningKeys.add(key);
1089
+ state.warnings.push(warning);
1090
+ };
1091
+ /**
1092
+ * Fetches a packument, reusing anything already seen in this run.
1093
+ *
1094
+ * Two fibers in the same wave can race here and both fetch. That is one
1095
+ * duplicated GET at worst, and de-racing it would cost more complexity than it
1096
+ * saves.
1097
+ */
1098
+ const getPackument = (state, name) => Effect.gen(function* () {
1099
+ const cached = state.packuments.get(name);
1100
+ if (cached !== void 0) return cached;
1101
+ const packument = yield* (yield* Registry).packument(name);
1102
+ state.packuments.set(name, packument);
1103
+ return packument;
1104
+ });
1105
+ /**
1106
+ * Re-fails a thrown selection error into the Effect error channel.
1107
+ *
1108
+ * `selectVersions` throws `BundlerError` subclasses and nothing else, so this
1109
+ * narrows rather than casting; anything unexpected is rethrown as a defect,
1110
+ * which is what an unexpected exception actually is.
1111
+ */
1112
+ const asBundlerError = (error) => {
1113
+ if (error instanceof VersionNotFoundError || error instanceof NoMatchingVersionsError) return error;
1114
+ throw error;
1115
+ };
1116
+ /** Expands one resolved manifest into the edges that follow from it. */
1117
+ const edgesOf$3 = (manifest, options) => {
1118
+ const from = packageKey(manifest.name, manifest.version);
1119
+ const tasks = [];
1120
+ const warnings = [];
1121
+ const add = (entries, kind, tolerateFailure, skip) => {
1122
+ if (entries === void 0) return;
1123
+ for (const [name, raw] of Object.entries(entries)) {
1124
+ if (skip?.(name)) continue;
1125
+ const target = parseDependencyTarget(name, raw);
1126
+ if (target._tag === "Unsupported") {
1127
+ warnings.push({
1128
+ from,
1129
+ message: `Skipped ${name}@${target.raw} (${target.reason}) — it cannot be fetched from a registry. Vendor it separately if the offline install needs it.`
1130
+ });
1131
+ continue;
1132
+ }
1133
+ tasks.push({
1134
+ name: target.name,
1135
+ selector: target.selector,
1136
+ reason: {
1137
+ _tag: "Edge",
1138
+ from,
1139
+ kind
1140
+ },
1141
+ tolerateFailure
1142
+ });
1143
+ }
1144
+ };
1145
+ add(manifest.dependencies, EdgeKind.Prod, false);
1146
+ if (options.scope.optional) add(manifest.optionalDependencies, EdgeKind.Optional, true);
1147
+ if (options.scope.peer) {
1148
+ const meta = manifest.peerDependenciesMeta ?? {};
1149
+ add(manifest.peerDependencies, EdgeKind.Peer, false, (name) => meta[name]?.optional === true);
1150
+ }
1151
+ return {
1152
+ tasks,
1153
+ warnings
1154
+ };
1155
+ };
1156
+ /** Edges for a package, computed once and cached. */
1157
+ const edgesFor = (state, manifest, options) => {
1158
+ const key = packageKey(manifest.name, manifest.version);
1159
+ const cached = state.edges.get(key);
1160
+ if (cached !== void 0) return cached;
1161
+ const { tasks, warnings } = edgesOf$3(manifest, options);
1162
+ for (const warning of warnings) addWarning(state, warning);
1163
+ state.edges.set(key, tasks);
1164
+ return tasks;
1165
+ };
1166
+ /**
1167
+ * Resolves everything reachable from `seeds`, returning the closure.
1168
+ *
1169
+ * `state` is mutated with newly resolved packages and warnings; the returned
1170
+ * array is scoped to *this* walk, which is what makes per-root-version
1171
+ * closures possible.
1172
+ */
1173
+ const walk = (state, seeds, options) => Effect.gen(function* () {
1174
+ const discovered = /* @__PURE__ */ new Set();
1175
+ const attempted = /* @__PURE__ */ new Set();
1176
+ let frontier = seeds;
1177
+ while (frontier.length > 0) {
1178
+ const wave = [];
1179
+ for (const task of frontier) {
1180
+ const id = `${task.name} ${formatSelector(task.selector)}`;
1181
+ if (attempted.has(id)) continue;
1182
+ attempted.add(id);
1183
+ wave.push(task);
1184
+ }
1185
+ if (wave.length === 0) break;
1186
+ const results = yield* Effect.forEach(wave, (task) => resolveTask(state, task, options), { concurrency: options.concurrency });
1187
+ const next = [];
1188
+ for (const result of results) {
1189
+ if (result === null) continue;
1190
+ for (const { manifest, reason } of result) {
1191
+ const key = packageKey(manifest.name, manifest.version);
1192
+ const existing = state.resolved.get(key);
1193
+ if (existing === void 0) {
1194
+ state.resolved.set(key, {
1195
+ name: manifest.name,
1196
+ version: manifest.version,
1197
+ manifest,
1198
+ reasons: [reason]
1199
+ });
1200
+ yield* emit({
1201
+ _tag: "PackageResolved",
1202
+ name: manifest.name,
1203
+ version: manifest.version,
1204
+ resolvedCount: state.resolved.size,
1205
+ pendingCount: next.length
1206
+ });
1207
+ if (manifest.deprecated !== void 0 && manifest.deprecated !== "") addWarning(state, {
1208
+ from: key,
1209
+ message: `${key} is deprecated: ${manifest.deprecated}`
1210
+ });
1211
+ } else if (!hasReason$1(existing.reasons, reason)) state.resolved.set(key, {
1212
+ ...existing,
1213
+ reasons: [...existing.reasons, reason]
1214
+ });
1215
+ if (!discovered.has(key)) {
1216
+ discovered.add(key);
1217
+ next.push(...edgesFor(state, manifest, options));
1218
+ }
1219
+ }
1220
+ }
1221
+ frontier = next;
1222
+ }
1223
+ return [...discovered];
1224
+ });
1225
+ const hasReason$1 = (reasons, candidate) => reasons.some((reason) => {
1226
+ if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec;
1227
+ if (reason._tag === "Edge" && candidate._tag === "Edge") return reason.from === candidate.from && reason.kind === candidate.kind;
1228
+ return false;
1229
+ });
1230
+ /**
1231
+ * Resolves one task to its manifest(s).
1232
+ *
1233
+ * Returns `null` when an optional edge could not be resolved — the caller
1234
+ * treats that as "skip quietly", which is what npm does for
1235
+ * `optionalDependencies`.
1236
+ */
1237
+ const resolveTask = (state, task, options) => Effect.gen(function* () {
1238
+ const packument = yield* getPackument(state, task.name);
1239
+ return (yield* Effect.try({
1240
+ try: () => selectVersions(packument, task.selector, {
1241
+ all: false,
1242
+ includePrerelease: options.includePrerelease
1243
+ }),
1244
+ catch: asBundlerError
1245
+ })).flatMap((version) => {
1246
+ const manifest = packument.versions[version];
1247
+ if (manifest === void 0) return [];
1248
+ if (task.reason._tag === "Edge" && task.reason.kind === EdgeKind.Optional && !isIncluded(manifest, options.scope.platforms)) return [];
1249
+ return {
1250
+ manifest,
1251
+ reason: task.reason
1252
+ };
1253
+ });
1254
+ }).pipe(Effect.catch((error) => {
1255
+ if (!task.tolerateFailure) return Effect.fail(error);
1256
+ return Effect.sync(() => {
1257
+ addWarning(state, { message: `Optional dependency ${task.name}@${formatSelector(task.selector)} could not be resolved and was skipped: ${error.message}` });
1258
+ return null;
1259
+ });
1260
+ }));
1261
+ /**
1262
+ * Resolves every requested spec.
1263
+ *
1264
+ * Each selected root *version* is walked separately so `per-spec` layout has an
1265
+ * exact closure per tarball, while all walks share one cache, so a package
1266
+ * reached from twenty roots is fetched once.
1267
+ */
1268
+ const resolve = (specs, options) => Effect.gen(function* () {
1269
+ yield* emit({
1270
+ _tag: "PhaseStarted",
1271
+ phase: Phase.Resolve,
1272
+ total: specs.length
1273
+ });
1274
+ const state = makeWalkState();
1275
+ const roots = [];
1276
+ for (const spec of specs) {
1277
+ const packument = yield* getPackument(state, spec.name);
1278
+ const versions = yield* Effect.try({
1279
+ try: () => selectVersions(packument, spec.selector, {
1280
+ all: options.allVersions,
1281
+ maxVersions: options.maxVersions,
1282
+ includePrerelease: options.includePrerelease
1283
+ }),
1284
+ catch: asBundlerError
1285
+ });
1286
+ const closures = /* @__PURE__ */ new Map();
1287
+ const union = /* @__PURE__ */ new Set();
1288
+ for (const version of versions) {
1289
+ const closure = yield* walk(state, [{
1290
+ name: spec.name,
1291
+ selector: {
1292
+ _tag: "Exact",
1293
+ version
1294
+ },
1295
+ reason: {
1296
+ _tag: "Root",
1297
+ spec: formatSpec(spec)
1298
+ },
1299
+ tolerateFailure: false
1300
+ }], options);
1301
+ closures.set(version, closure);
1302
+ for (const key of closure) union.add(key);
1303
+ }
1304
+ roots.push({
1305
+ spec,
1306
+ versions,
1307
+ closures,
1308
+ closure: [...union]
1309
+ });
1310
+ }
1311
+ yield* emit({
1312
+ _tag: "PhaseCompleted",
1313
+ phase: Phase.Resolve
1314
+ });
1315
+ return {
1316
+ roots,
1317
+ packages: [...state.resolved.values()].toSorted(compareResolved$1),
1318
+ warnings: state.warnings
404
1319
  };
405
- const slash = name.indexOf("/");
406
- if (slash === -1) return {
407
- scope: void 0,
408
- bare: name
1320
+ });
1321
+ const compareResolved$1 = (a, b) => {
1322
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1;
1323
+ return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
1324
+ };
1325
+
1326
+ //#endregion
1327
+ //#region src/locked-resolve.ts
1328
+ /** Turns a parsed lockfile into a resolution, pinning every version. */
1329
+ const resolveLocked = (lock, options) => Effect.gen(function* () {
1330
+ const roots = selectRoots(lock, options);
1331
+ yield* emit({
1332
+ _tag: "PhaseStarted",
1333
+ phase: Phase.Resolve,
1334
+ total: roots.length
1335
+ });
1336
+ const gap = findGaps(lock, roots, options.declared);
1337
+ if (gap !== null) return yield* Effect.fail(gap);
1338
+ const manifests = yield* fetchManifests(lock, reachableKeys(lock, roots, options), options);
1339
+ const warnings = lock.warnings.map((message) => ({ message }));
1340
+ const resolved = /* @__PURE__ */ new Map();
1341
+ const seenWarnings = /* @__PURE__ */ new Set();
1342
+ const addWarning$1 = (warning) => {
1343
+ const key = `${warning.from ?? ""}|${warning.message}`;
1344
+ if (seenWarnings.has(key)) return;
1345
+ seenWarnings.add(key);
1346
+ warnings.push(warning);
409
1347
  };
1348
+ const rootResolutions = [];
1349
+ for (const root of roots) {
1350
+ const key = packageKey(root.name, root.version);
1351
+ if (!manifests.has(key)) continue;
1352
+ const spec = {
1353
+ name: root.name,
1354
+ selector: {
1355
+ _tag: "Exact",
1356
+ version: root.version
1357
+ },
1358
+ raw: `${root.name}@${root.version}`
1359
+ };
1360
+ if (root.kind === LockedRootKind.Optional && !platformAllows(manifests, key, options)) continue;
1361
+ const closure = walkLocked({
1362
+ lock,
1363
+ manifests,
1364
+ options,
1365
+ seed: key,
1366
+ rootSpec: formatSpec(spec),
1367
+ resolved,
1368
+ addWarning: addWarning$1
1369
+ });
1370
+ rootResolutions.push({
1371
+ spec,
1372
+ versions: [root.version],
1373
+ closures: new Map([[root.version, closure]]),
1374
+ closure
1375
+ });
1376
+ }
1377
+ for (const pkg of resolved.values()) {
1378
+ const deprecated = pkg.manifest.deprecated;
1379
+ if (deprecated !== void 0 && deprecated !== "") addWarning$1({
1380
+ from: packageKey(pkg.name, pkg.version),
1381
+ message: `${packageKey(pkg.name, pkg.version)} is deprecated: ${deprecated}`
1382
+ });
1383
+ }
1384
+ yield* emit({
1385
+ _tag: "PhaseCompleted",
1386
+ phase: Phase.Resolve
1387
+ });
410
1388
  return {
411
- scope: name.slice(0, slash),
412
- bare: name.slice(slash + 1)
1389
+ roots: rootResolutions,
1390
+ packages: [...resolved.values()].toSorted(compareResolved),
1391
+ warnings
413
1392
  };
414
- };
415
- /** `@babel/core` + `7.24.0` -> `core-7.24.0.tgz` */
416
- const tarballFileName = (name, version) => {
417
- const { bare } = splitName(name);
418
- return `${bare}-${version}.tgz`;
419
- };
1393
+ });
1394
+ /** Which of the project's direct dependencies this run should bundle. */
1395
+ const selectRoots = (lock, options) => lock.roots.filter((root) => {
1396
+ if (root.kind === LockedRootKind.Dev) return options.includeDev;
1397
+ if (root.kind === LockedRootKind.Optional) return options.scope.optional;
1398
+ if (root.kind === LockedRootKind.Peer) return options.scope.peer;
1399
+ return true;
1400
+ }).toSorted((a, b) => a.name === b.name ? 0 : a.name < b.name ? -1 : 1);
420
1401
  /**
421
- * The path of one package tarball within a bundle, using `/` separators
422
- * regardless of host platform — these become tar entry names, and tar entries
423
- * are always POSIX.
1402
+ * The two ways a lockfile can be partial, checked before anything is fetched.
1403
+ *
1404
+ * Both mean the same thing to whoever is waiting on the bundle — a package they
1405
+ * expected will not be in it — and both are invisible unless something says so,
1406
+ * because the run otherwise succeeds and simply produces less.
424
1407
  */
425
- const packagePath = (name, version) => `${name}/-/${tarballFileName(name, version)}`;
426
- /** File name of the manifest that ships inside every bundle. */
427
- const MANIFEST_FILE = "bundle-manifest.json";
428
- /** File name of the short import guide that ships inside every bundle. */
429
- const README_FILE = "IMPORT.md";
1408
+ const findGaps = (lock, roots, declared) => {
1409
+ const remedy = `Run \`${lock.format} install\` to bring the lockfile up to date.`;
1410
+ if (declared !== void 0 && declared.length > 0) {
1411
+ const locked = /* @__PURE__ */ new Set();
1412
+ for (const pkg of lock.packages.values()) locked.add(pkg.name);
1413
+ for (const root of roots) locked.add(root.name);
1414
+ const missing = declared.filter((name) => !locked.has(name));
1415
+ if (missing.length > 0) return new LockfileIncompleteError(lock.path, missing, `the package.json declares ${missing.length} dependenc${missing.length === 1 ? "y" : "ies"} it does not pin`, remedy);
1416
+ }
1417
+ if (lock.incomplete.length > 0) return new LockfileIncompleteError(lock.path, lock.incomplete, `${lock.incomplete.length} required dependenc${lock.incomplete.length === 1 ? "y is" : "ies are"} not pinned`, remedy);
1418
+ return null;
1419
+ };
430
1420
  /**
431
- * Name of the archive produced for one root spec in `per-spec` layout.
1421
+ * Every package the roots can reach through the locked edges.
432
1422
  *
433
- * Scoped names are flattened (`@babel/core` -> `babel-core`) because a `/` in a
434
- * file name is not a thing, and `@` confuses enough shells and web UIs to be
435
- * worth avoiding.
1423
+ * Scope filters apply here `--no-optional` genuinely removes a subtree but
1424
+ * the platform filter does not, because deciding it needs the `os`/`cpu` fields
1425
+ * that only the registry has.
436
1426
  */
437
- const perSpecArchiveName = (name, version) => {
438
- const { scope, bare } = splitName(name);
439
- return `${scope === void 0 ? bare : `${scope.slice(1)}-${bare}`}-${version}.tgz`;
1427
+ const reachableKeys = (lock, roots, options) => {
1428
+ const seen = /* @__PURE__ */ new Set();
1429
+ const queue = roots.map((root) => packageKey(root.name, root.version));
1430
+ while (queue.length > 0) {
1431
+ const key = queue.pop();
1432
+ if (key === void 0 || seen.has(key)) continue;
1433
+ seen.add(key);
1434
+ for (const edge of lock.packages.get(key)?.dependencies ?? []) {
1435
+ if (!followsEdge(edge.kind, options)) continue;
1436
+ queue.push(packageKey(edge.name, edge.version));
1437
+ }
1438
+ }
1439
+ return seen;
1440
+ };
1441
+ const followsEdge = (kind, options) => {
1442
+ if (kind === LockedRootKind.Optional) return options.scope.optional;
1443
+ if (kind === LockedRootKind.Peer) return options.scope.peer;
1444
+ return true;
440
1445
  };
441
- /** Name of the archive produced in `single` layout. */
442
- const singleArchiveName = (base = "bundle") => `${base}.tgz`;
443
1446
  /**
444
- * The import guide written into each bundle.
1447
+ * Fetches the published manifest for every pinned version.
445
1448
  *
446
- * Kept short and copy-pasteable on purpose: whoever opens this is mid-task on a
447
- * restricted network and does not want prose.
1449
+ * One packument per distinct *name*, not per version a lockfile holding four
1450
+ * versions of `tslib` costs one request. Every pinned version that the registry
1451
+ * does not serve is collected and reported together, because "refresh your
1452
+ * lockfile" is a single action and finding out about the gaps one run at a time
1453
+ * would be miserable.
448
1454
  */
449
- const importGuide = (options) => `# Importing this bundle
450
-
451
- ${options.packageCount} package tarball(s), laid out exactly as an npm registry serves them:
452
-
453
- <package-name>/-/<file>.tgz
454
- @<scope>/<name>/-/<file>.tgz
455
-
456
- Created ${options.createdAt} by packall ${options.toolVersion}.
457
- See ${MANIFEST_FILE} for the full list with checksums.
458
-
459
- ## JFrog Artifactory
460
-
461
- Unpack, then upload the tree into a **local npm** repository. The layout already
462
- matches what Artifactory expects, so no path rewriting is needed:
463
-
464
- tar -xzf <this-bundle>.tgz -C ./bundle
465
- jf rt upload "bundle/(**)" "<npm-local-repo>/{1}" --flat=false
466
-
467
- ## Verdaccio / Nexus / any npm registry
468
-
469
- Publish each tarball individually:
470
-
471
- find ./bundle -name '*.tgz' -exec npm publish --registry <url> {} \\;
472
-
473
- ## Verifying before import
474
-
475
- node -e "const m=require('./bundle/${MANIFEST_FILE}');console.log(m.packages.length+' packages')"
476
-
477
- Every entry in ${MANIFEST_FILE} carries the integrity string the source registry
478
- advertised, and each tarball was checked against it at download time.
479
- `;
480
-
481
- //#endregion
482
- //#region src/Registry.ts
1455
+ const fetchManifests = (lock, reachable, options) => Effect.gen(function* () {
1456
+ const registry = yield* Registry;
1457
+ const wanted = /* @__PURE__ */ new Map();
1458
+ for (const key of reachable) {
1459
+ const pkg = lock.packages.get(key);
1460
+ if (pkg === void 0) continue;
1461
+ const versions = wanted.get(pkg.name);
1462
+ if (versions === void 0) wanted.set(pkg.name, [pkg.version]);
1463
+ else versions.push(pkg.version);
1464
+ }
1465
+ const manifests = /* @__PURE__ */ new Map();
1466
+ const missing = [];
1467
+ const fetched = yield* Effect.forEach([...wanted.entries()], ([name, versions]) => registry.packument(name).pipe(Effect.map((packument) => ({
1468
+ name,
1469
+ versions,
1470
+ packument
1471
+ })), Effect.catch((error) => error._tag === "PackageNotFoundError" ? Effect.succeed({
1472
+ name,
1473
+ versions,
1474
+ packument: null
1475
+ }) : Effect.fail(error))), { concurrency: options.concurrency });
1476
+ for (const { name, versions, packument } of fetched) for (const version of versions) {
1477
+ if (packument === null) {
1478
+ missing.push({
1479
+ name,
1480
+ version,
1481
+ detail: "package not found"
1482
+ });
1483
+ continue;
1484
+ }
1485
+ const manifest = packument.versions[version];
1486
+ if (manifest === void 0) {
1487
+ missing.push({
1488
+ name,
1489
+ version,
1490
+ detail: describeAvailable(Object.keys(packument.versions))
1491
+ });
1492
+ continue;
1493
+ }
1494
+ manifests.set(packageKey(name, version), manifest);
1495
+ yield* emit({
1496
+ _tag: "PackageResolved",
1497
+ name,
1498
+ version,
1499
+ resolvedCount: manifests.size,
1500
+ pendingCount: reachable.size - manifests.size
1501
+ });
1502
+ }
1503
+ if (missing.length > 0) return yield* Effect.fail(new LockfileOutOfDateError(lock.path, registry.registryFor(missing[0]?.name ?? ""), missing));
1504
+ return manifests;
1505
+ });
1506
+ const describeAvailable = (available) => {
1507
+ const recent = available.toSorted((a, b) => semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b)).slice(-3);
1508
+ return recent.length === 0 ? "no published versions" : `no longer published (latest: ${recent.join(", ")})`;
1509
+ };
483
1510
  /**
484
- * Service tag for the registry backend.
485
- *
486
- * @example
487
- * ```ts
488
- * import { Effect } from "effect"
489
- * import { Registry } from "@packall/core"
1511
+ * Collects one root's closure by following locked edges.
490
1512
  *
491
- * const program = Effect.gen(function*() {
492
- * const registry = yield* Registry
493
- * return yield* registry.packument("lodash")
494
- * })
495
- * ```
1513
+ * Pure and synchronous every manifest is already in hand — so a per-root
1514
+ * closure costs nothing beyond the traversal, which is what makes `per-spec`
1515
+ * layout as cheap here as it is for a range-resolved run.
496
1516
  */
497
- var Registry = class extends Context.Service()("@packall/core/Registry") {};
1517
+ const walkLocked = (input) => {
1518
+ const closure = /* @__PURE__ */ new Set();
1519
+ const queue = [{
1520
+ key: input.seed,
1521
+ reason: {
1522
+ _tag: "Root",
1523
+ spec: input.rootSpec
1524
+ }
1525
+ }];
1526
+ while (queue.length > 0) {
1527
+ const next = queue.pop();
1528
+ if (next === void 0) continue;
1529
+ const manifest = input.manifests.get(next.key);
1530
+ const locked = input.lock.packages.get(next.key);
1531
+ if (manifest === void 0 || locked === void 0) continue;
1532
+ record(input.resolved, next.key, manifest, locked, next.reason);
1533
+ if (closure.has(next.key)) continue;
1534
+ closure.add(next.key);
1535
+ for (const edge of locked.dependencies) {
1536
+ if (!followsEdge(edge.kind, input.options)) continue;
1537
+ const target = packageKey(edge.name, edge.version);
1538
+ if (!input.manifests.has(target)) continue;
1539
+ if (edge.kind === LockedRootKind.Optional && !platformAllows(input.manifests, target, input.options)) {
1540
+ input.addWarning({
1541
+ from: next.key,
1542
+ message: `Skipped optional ${target} — it does not build for the selected platforms.`
1543
+ });
1544
+ continue;
1545
+ }
1546
+ queue.push({
1547
+ key: target,
1548
+ reason: {
1549
+ _tag: "Edge",
1550
+ from: next.key,
1551
+ kind: edge.kind
1552
+ }
1553
+ });
1554
+ }
1555
+ }
1556
+ return [...closure];
1557
+ };
1558
+ /** Adds a package, or merges one more justification into an existing entry. */
1559
+ const record = (resolved, key, manifest, locked, reason) => {
1560
+ const existing = resolved.get(key);
1561
+ if (existing === void 0) {
1562
+ resolved.set(key, {
1563
+ name: locked.name,
1564
+ version: locked.version,
1565
+ manifest,
1566
+ reasons: [reason]
1567
+ });
1568
+ return;
1569
+ }
1570
+ if (hasReason(existing.reasons, reason)) return;
1571
+ resolved.set(key, {
1572
+ ...existing,
1573
+ reasons: [...existing.reasons, reason]
1574
+ });
1575
+ };
1576
+ const hasReason = (reasons, candidate) => reasons.some((reason) => {
1577
+ if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec;
1578
+ if (reason._tag === "Edge" && candidate._tag === "Edge") return reason.from === candidate.from && reason.kind === candidate.kind;
1579
+ return false;
1580
+ });
1581
+ const platformAllows = (manifests, key, options) => {
1582
+ const manifest = manifests.get(key);
1583
+ if (manifest === void 0) return false;
1584
+ return isIncluded(manifest, options.scope.platforms);
1585
+ };
1586
+ const compareResolved = (a, b) => {
1587
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1;
1588
+ return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
1589
+ };
498
1590
 
499
1591
  //#endregion
500
- //#region src/Download.ts
501
- var Download_exports = /* @__PURE__ */ __export({ downloadAll: () => downloadAll });
1592
+ //#region src/lockfile/json.ts
1593
+ const stringOr = (value, fallback) => typeof value === "string" ? value : fallback;
1594
+ const decodeStringRecord = Schema.decodeUnknownOption(StringRecordSchema);
1595
+ /** Keeps only the string-valued members of an untyped object. */
1596
+ const stringRecord = (value) => Option.getOrElse(decodeStringRecord(value), () => ({}));
1597
+ const decodeOptionalFlags = Schema.decodeUnknownOption(OptionalFlagRecordSchema);
1598
+ /** Names a package marked its peers optional with. */
1599
+ const optionalPeerNames = (value) => {
1600
+ const flags = Option.getOrElse(decodeOptionalFlags(value), () => ({}));
1601
+ return new Set(Object.entries(flags).flatMap(([name, meta]) => meta.optional ? name : []));
1602
+ };
1603
+ const parseJsonObject = (path, content) => {
1604
+ const parsed = tryParseJsonc(content);
1605
+ if (parsed === void 0) throw new LockfileError(path, "not valid JSON");
1606
+ if (!isRecord(parsed)) throw new LockfileError(path, "top level is not an object");
1607
+ return parsed;
1608
+ };
502
1609
  /**
503
- * Downloads every package into `destDir`, laid out the way a registry serves
504
- * them.
1610
+ * Parses JSON, tolerating the comments and trailing commas bun writes.
505
1611
  *
506
- * `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
507
- * makes it into a corporate registry is far more expensive than a failed run.
1612
+ * `bun.lock` is JSONC by design bun puts explanatory comments in it — so
1613
+ * plain `JSON.parse` rejects real lockfiles. Returns `undefined` rather than
1614
+ * throwing so detection can use it as a probe.
508
1615
  */
509
- const downloadAll = (packages, destDir, options) => Effect.gen(function* () {
510
- const fs = yield* FileSystem.FileSystem;
511
- const path = yield* Path.Path;
512
- const registry = yield* Registry;
513
- yield* emit({
514
- _tag: "PhaseStarted",
515
- phase: "download",
516
- total: packages.length
517
- });
518
- let completed = 0;
519
- const sizes = /* @__PURE__ */ new Map();
520
- const unverified = [];
521
- const results = yield* Effect.forEach(packages, (pkg) => Effect.gen(function* () {
522
- const key = `${pkg.name}@${pkg.version}`;
523
- yield* emit({
524
- _tag: "DownloadStarted",
525
- name: pkg.name,
526
- version: pkg.version
527
- });
528
- const bytes = yield* registry.download(pkg.manifest);
529
- if (options.verifyIntegrity) {
530
- const result = verify(bytes, pkg.manifest.dist);
531
- if (result._tag === "Mismatch") return yield* Effect.fail(new IntegrityError(pkg.name, pkg.version, result.expected, result.actual));
532
- if (result._tag === "Unverifiable") yield* emit({
533
- _tag: "Warning",
534
- message: `${key} could not be verified (${result.reason})`
535
- });
1616
+ const tryParseJsonc = (content) => {
1617
+ try {
1618
+ return JSON.parse(content);
1619
+ } catch {
1620
+ try {
1621
+ return JSON.parse(stripJsonc(content));
1622
+ } catch {
1623
+ return;
536
1624
  }
537
- const verifiable = (pkg.manifest.dist.integrity?.length ?? 0) > 0 || (pkg.manifest.dist.shasum?.length ?? 0) > 0;
538
- const relative = packagePath(pkg.name, pkg.version);
539
- const target = path.join(destDir, ...relative.split("/"));
540
- yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new OutputError(path.dirname(target), "could not create directory", { cause })));
541
- yield* fs.writeFile(target, bytes).pipe(Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause })));
542
- completed += 1;
543
- yield* emit({
544
- _tag: "DownloadCompleted",
545
- name: pkg.name,
546
- version: pkg.version,
547
- bytes: bytes.byteLength,
548
- completedCount: completed,
549
- totalCount: packages.length
550
- });
551
- return {
552
- key,
553
- bytes: bytes.byteLength,
554
- verifiable
555
- };
556
- }), { concurrency: options.concurrency });
557
- for (const result of results) {
558
- sizes.set(result.key, result.bytes);
559
- if (!result.verifiable) unverified.push(result.key);
560
1625
  }
561
- yield* emit({
562
- _tag: "PhaseCompleted",
563
- phase: "download"
564
- });
565
- return {
566
- sizes,
567
- unverified,
568
- totalBytes: results.reduce((sum, r) => sum + r.bytes, 0)
1626
+ };
1627
+ /**
1628
+ * Removes comments and trailing commas.
1629
+ *
1630
+ * String-aware, because a `//` inside a tarball URL is not a comment and an
1631
+ * escaped quote does not end a string. Both appear in every real lockfile.
1632
+ */
1633
+ const stripJsonc = (content) => {
1634
+ let out = "";
1635
+ let index = 0;
1636
+ let inString = false;
1637
+ while (index < content.length) {
1638
+ const char = content[index];
1639
+ if (inString) {
1640
+ out += char;
1641
+ if (char === "\\") {
1642
+ out += content[index + 1] ?? "";
1643
+ index += 2;
1644
+ continue;
1645
+ }
1646
+ if (char === "\"") inString = false;
1647
+ index += 1;
1648
+ continue;
1649
+ }
1650
+ if (char === "\"") {
1651
+ inString = true;
1652
+ out += char;
1653
+ index += 1;
1654
+ continue;
1655
+ }
1656
+ if (char === "/" && content[index + 1] === "/") {
1657
+ const end = content.indexOf("\n", index);
1658
+ index = end === -1 ? content.length : end;
1659
+ continue;
1660
+ }
1661
+ if (char === "/" && content[index + 1] === "*") {
1662
+ const end = content.indexOf("*/", index + 2);
1663
+ index = end === -1 ? content.length : end + 2;
1664
+ continue;
1665
+ }
1666
+ if (char === ",") {
1667
+ const next = content.slice(index + 1).search(/\S/);
1668
+ const following = next === -1 ? "" : content[index + 1 + next];
1669
+ if (following === "}" || following === "]") {
1670
+ index += 1;
1671
+ continue;
1672
+ }
1673
+ }
1674
+ out += char;
1675
+ index += 1;
1676
+ }
1677
+ return out;
1678
+ };
1679
+ const describeError$1 = (error) => error instanceof Error ? error.message : String(error);
1680
+
1681
+ //#endregion
1682
+ //#region src/lockfile/detect.ts
1683
+ /**
1684
+ * Deliberately cheap and total: it never throws, so `--file` can try this
1685
+ * first and fall through to its other shapes when the answer is `NotALockfile`.
1686
+ */
1687
+ const detectLockfile = (content) => {
1688
+ const trimmed = content.trimStart();
1689
+ if (trimmed.startsWith("bun-lockfile-format-v0")) return {
1690
+ _tag: "Unsupported",
1691
+ label: "a binary bun lockfile (bun.lockb)",
1692
+ hint: "Run `bun install --save-text-lockfile` to produce a bun.lock, and pass that instead."
569
1693
  };
570
- });
1694
+ if (/^__metadata:/m.test(trimmed) || /^# yarn lockfile v\d/m.test(trimmed)) return {
1695
+ _tag: "Unsupported",
1696
+ label: "a yarn lockfile",
1697
+ hint: "yarn is not supported yet. Point --file at the package.json instead, or pass --lockfile off."
1698
+ };
1699
+ if (trimmed.startsWith("{")) return detectJsonLockfile(trimmed);
1700
+ if (/^lockfileVersion:/m.test(trimmed) || /^(importers|snapshots):/m.test(trimmed)) return {
1701
+ _tag: "Supported",
1702
+ format: LockfileFormat.Pnpm
1703
+ };
1704
+ return { _tag: "NotALockfile" };
1705
+ };
1706
+ /**
1707
+ * Separates the two JSON lockfiles from each other and from a package.json.
1708
+ *
1709
+ * Both npm and bun write a numeric `lockfileVersion`, so that field alone
1710
+ * cannot decide it. bun is the one that carries a `workspaces` map keyed by the
1711
+ * empty string; npm keeps the root project under `packages[""]` instead.
1712
+ */
1713
+ const detectJsonLockfile = (trimmed) => {
1714
+ const parsed = tryParseJsonc(trimmed);
1715
+ if (!isRecord(parsed)) return { _tag: "NotALockfile" };
1716
+ const workspaces = parsed["workspaces"];
1717
+ if (isRecord(workspaces) && Object.hasOwn(workspaces, "")) return {
1718
+ _tag: "Supported",
1719
+ format: LockfileFormat.Bun
1720
+ };
1721
+ if (typeof parsed["lockfileVersion"] === "number") return {
1722
+ _tag: "Supported",
1723
+ format: LockfileFormat.Npm
1724
+ };
1725
+ return { _tag: "NotALockfile" };
1726
+ };
571
1727
 
572
1728
  //#endregion
573
- //#region src/Platform.ts
574
- var Platform_exports = /* @__PURE__ */ __export({
575
- allPlatforms: () => allPlatforms,
576
- currentPlatform: () => currentPlatform,
577
- formatPlatformFilter: () => formatPlatformFilter,
578
- isIncluded: () => isIncluded,
579
- matchesTarget: () => matchesTarget,
580
- parsePlatformTarget: () => parsePlatformTarget,
581
- platformTargets: () => platformTargets
582
- });
583
- const allPlatforms = { _tag: "All" };
584
- const platformTargets = (targets) => ({
585
- _tag: "Targets",
586
- targets
587
- });
588
- /** The machine we are currently running on. */
589
- const currentPlatform = () => ({
590
- os: process.platform,
591
- cpu: process.arch
592
- });
1729
+ //#region src/lockfile/names.ts
593
1730
  /**
594
- * npm's documented `os` values, plus the names people actually type.
1731
+ * The file name each supported manager writes, in detection order.
595
1732
  *
596
- * An OS on its own is a valid target, so a typo has to be caught here — the
597
- * whole failure mode this tool exists to prevent is a bundle that looks fine
598
- * and installs nothing. `--platform windows` silently matching no binding at
599
- * all would be precisely that, so it is corrected rather than accepted.
1733
+ * Ordered: `package-lock.json` before `npm-shrinkwrap.json` before pnpm's and
1734
+ * bun's, so a directory holding several is searched the way npm itself would.
600
1735
  */
601
- const OS_ALIASES = {
602
- win: "win32",
603
- windows: "win32",
604
- mac: "darwin",
605
- macos: "darwin",
606
- osx: "darwin"
1736
+ const formatByLockfileName = {
1737
+ "package-lock.json": LockfileFormat.Npm,
1738
+ "npm-shrinkwrap.json": LockfileFormat.Npm,
1739
+ "pnpm-lock.yaml": LockfileFormat.Pnpm,
1740
+ "bun.lock": LockfileFormat.Bun
607
1741
  };
608
- const KNOWN_OS = new Set([
609
- "aix",
610
- "android",
611
- "cygwin",
612
- "darwin",
613
- "freebsd",
614
- "haiku",
615
- "linux",
616
- "netbsd",
617
- "openbsd",
618
- "sunos",
619
- "win32"
620
- ]);
621
- const LIBC = new Set(["musl", "glibc"]);
1742
+ /** The names a given manager writes. */
1743
+ const lockfileNamesFor = (format) => Object.entries(formatByLockfileName).flatMap(([name, candidate]) => candidate === format ? name : []);
1744
+
1745
+ //#endregion
1746
+ //#region src/lockfile/tree-builder.ts
622
1747
  /**
623
- * Parses `linux`, `linux-x64`, `darwin-arm64`, `win32-x64`, `linux-x64-musl`,
624
- * `linux-musl`.
1748
+ * Which manifest block each root kind is declared in.
625
1749
  *
626
- * An OS on its own means every architecture for that OS, which is almost always
627
- * what you want: "the Windows and Linux bindings" is a far more natural way to
628
- * describe a target set than enumerating four `os-arch` pairs, and it keeps
629
- * working when a package adds an arm64 build.
1750
+ * The block names are the same across npm, pnpm and bun; only which of them a
1751
+ * given format writes differs, which is why each parser supplies its own order
1752
+ * rather than iterating this.
1753
+ */
1754
+ const fieldByRootKind = {
1755
+ [LockedRootKind.Prod]: "dependencies",
1756
+ [LockedRootKind.Optional]: "optionalDependencies",
1757
+ [LockedRootKind.Dev]: "devDependencies",
1758
+ [LockedRootKind.Peer]: "peerDependencies"
1759
+ };
1760
+ var TreeBuilder = class {
1761
+ packages = /* @__PURE__ */ new Map();
1762
+ roots = [];
1763
+ warnings = [];
1764
+ incomplete = [];
1765
+ /** Names dropped on purpose, so an edge into one is explained, not alarming. */
1766
+ skipped = /* @__PURE__ */ new Set();
1767
+ seenWarnings = /* @__PURE__ */ new Set();
1768
+ add(pkg) {
1769
+ const key = packageKey(pkg.name, pkg.version);
1770
+ const existing = this.packages.get(key);
1771
+ if (existing === void 0) {
1772
+ this.packages.set(key, pkg);
1773
+ return;
1774
+ }
1775
+ this.packages.set(key, {
1776
+ ...existing,
1777
+ dependencies: mergeEdges(existing.dependencies, pkg.dependencies)
1778
+ });
1779
+ }
1780
+ addRoot(root) {
1781
+ if (this.roots.some((existing) => existing.name === root.name)) return;
1782
+ this.roots.push(root);
1783
+ }
1784
+ warn(message) {
1785
+ if (this.seenWarnings.has(message)) return;
1786
+ this.seenWarnings.add(message);
1787
+ this.warnings.push(message);
1788
+ }
1789
+ /** Notes an entry that is real but cannot come from a registry. */
1790
+ skip(name, spec, reason) {
1791
+ this.skipped.add(name);
1792
+ this.warn(`skipped ${name}@${spec} (${reason}) — not fetchable from a registry`);
1793
+ }
1794
+ /**
1795
+ * Records a required edge that resolved to nothing.
1796
+ *
1797
+ * An edge into something skipped on purpose is expected — you cannot bundle a
1798
+ * `workspace:` sibling from a registry — so it stays a warning. Anything else
1799
+ * means the file does not pin what it claims to.
1800
+ */
1801
+ dangling(from, name) {
1802
+ if (this.skipped.has(name)) {
1803
+ this.warn(`${from} requires ${name}, which is not fetchable from a registry`);
1804
+ return;
1805
+ }
1806
+ this.incomplete.push(`${name} (required by ${from})`);
1807
+ }
1808
+ finish(input) {
1809
+ if (this.packages.size === 0) throw new LockfileError(input.path, "lockfile pins no packages that can be bundled");
1810
+ return {
1811
+ format: input.format,
1812
+ lockfileVersion: input.lockfileVersion,
1813
+ path: input.path,
1814
+ importer: input.importer,
1815
+ packages: this.packages,
1816
+ roots: this.roots,
1817
+ warnings: this.warnings,
1818
+ incomplete: this.incomplete
1819
+ };
1820
+ }
1821
+ };
1822
+ const mergeEdges = (a, b) => {
1823
+ const merged = /* @__PURE__ */ new Map();
1824
+ for (const edge of [...a, ...b]) merged.set(`${edge.name}@${edge.version}|${edge.kind}`, edge);
1825
+ return [...merged.values()];
1826
+ };
1827
+ /**
1828
+ * Reads the right-hand side of a locked entry as an exact version.
630
1829
  *
631
- * Returns `null` rather than throwing so the CLI can report every bad value at
632
- * once alongside the list of accepted forms.
1830
+ * Returns the version when it is one, or the reason it is not `file:../x`,
1831
+ * `workspace:*`, a git URL. `parseDependencyTarget` already classifies every
1832
+ * one of those for package.json parsing, so the vocabulary of skip reasons
1833
+ * stays identical between the two paths.
633
1834
  */
634
- const parsePlatformTarget = (input) => {
635
- const parts = input.trim().toLowerCase().split("-").filter((p) => p.length > 0);
636
- if (parts.length === 0 || parts.length > 3) return null;
637
- const rawOs = parts[0];
638
- const os = OS_ALIASES[rawOs] ?? rawOs;
639
- if (!KNOWN_OS.has(os)) return null;
640
- if (parts.length === 1) return { os };
641
- if (parts.length === 2 && LIBC.has(parts[1])) return {
642
- os,
643
- libc: parts[1]
1835
+ const asExactVersion = (name, raw) => {
1836
+ const exact = semver.valid(raw, { loose: true });
1837
+ if (exact !== null) return {
1838
+ _tag: "Version",
1839
+ version: exact
644
1840
  };
645
- const cpu = parts[1];
646
- if (parts.length === 2) return {
647
- os,
648
- cpu
1841
+ const target = parseDependencyTarget(name, raw);
1842
+ if (target._tag === "Unsupported") return {
1843
+ _tag: "Unsupported",
1844
+ reason: target.reason
649
1845
  };
650
- const libc = parts[2];
651
- if (!LIBC.has(libc)) return null;
652
1846
  return {
653
- os,
654
- cpu,
655
- libc
1847
+ _tag: "Unsupported",
1848
+ reason: "not an exact version"
656
1849
  };
657
1850
  };
658
1851
  /**
659
- * npm's `os`/`cpu`/`libc` fields use a list of allowed values, where a leading
660
- * `!` negates. An empty or absent list means "no constraint".
1852
+ * Raised when a lockfile found by walking up turns out not to cover the
1853
+ * package.json that went looking for it.
661
1854
  *
662
- * The semantics npm implements: if any negated entry matches, reject. Otherwise
663
- * if there are any positive entries, at least one must match.
1855
+ * Worth distinguishing from "no lockfile at all": the file exists and is
1856
+ * readable, it simply belongs to a different project, and bundling it would
1857
+ * produce someone else's dependency set.
664
1858
  */
665
- const listAllows = (list, value) => {
666
- if (list === void 0 || list.length === 0) return true;
667
- let hasPositive = false;
668
- let positiveMatched = false;
669
- for (const entry of list) {
670
- const normalized = entry.trim().toLowerCase();
671
- if (normalized.length === 0) continue;
672
- if (normalized === "any" || normalized === "*") return true;
673
- if (normalized.startsWith("!")) {
674
- if (normalized.slice(1) === value) return false;
675
- } else {
676
- hasPositive = true;
677
- if (normalized === value) positiveMatched = true;
678
- }
679
- }
680
- return hasPositive ? positiveMatched : true;
1859
+ const notCovered = (path, importer, known) => {
1860
+ const listed = known.slice(0, 8).map((entry) => ` ${entry}`).join("\n");
1861
+ throw new LockfileError(path, `it does not cover ${importer}.\n` + (known.length === 0 ? " It records no workspace members." : ` Workspace members it does record:\n${listed}` + (known.length > 8 ? `\n … and ${known.length - 8} more` : "")));
681
1862
  };
682
- /** Would this package install on this specific target? */
683
- const matchesTarget = (constraints, target) => {
684
- if (!listAllows(constraints.os, target.os)) return false;
685
- if (target.cpu !== void 0 && !listAllows(constraints.cpu, target.cpu)) return false;
686
- if (target.libc !== void 0 && !listAllows(constraints.libc, target.libc)) return false;
687
- return true;
1863
+ /** `""` is how npm and bun spell the project at the lockfile's own level. */
1864
+ const importerKey = (importer) => importer === "." ? "" : importer;
1865
+
1866
+ //#endregion
1867
+ //#region src/lockfile/bun.ts
1868
+ /** Root blocks in bun's precedence order — the first mention of a name wins. */
1869
+ const ROOT_KINDS$2 = [
1870
+ LockedRootKind.Prod,
1871
+ LockedRootKind.Optional,
1872
+ LockedRootKind.Dev,
1873
+ LockedRootKind.Peer
1874
+ ];
1875
+ const parseBunLockfile = (path, content, importer) => {
1876
+ const root = parseJsonObject(path, content);
1877
+ const version = String(root["lockfileVersion"] ?? "?");
1878
+ const builder = new TreeBuilder();
1879
+ const packages = root["packages"];
1880
+ const entries = isRecord(packages) ? collectEntries$1(builder, packages) : /* @__PURE__ */ new Map();
1881
+ const keys = new Set(entries.keys());
1882
+ for (const entry of entries.values()) builder.add({
1883
+ name: entry.name,
1884
+ version: entry.version,
1885
+ dependencies: edgesOf$2(builder, entries, keys, entry)
1886
+ });
1887
+ collectRoots$2(builder, entries, keys, root["workspaces"], path, importer);
1888
+ return builder.finish({
1889
+ format: LockfileFormat.Bun,
1890
+ lockfileVersion: version,
1891
+ path,
1892
+ importer
1893
+ });
688
1894
  };
689
1895
  /**
690
- * Should this package be included, given the active filter?
1896
+ * Narrows bun's tuple entries.
691
1897
  *
692
- * Under `All` this is unconditionally `true` that is the whole point.
1898
+ * The value is a positional array whose shape depends on where the package came
1899
+ * from. A registry package is `[id, registry, info, integrity]`; a workspace
1900
+ * member or a git dependency is shorter and its `id` carries the protocol.
693
1901
  */
694
- const isIncluded = (constraints, filter) => {
695
- if (filter._tag === "All") return true;
696
- return filter.targets.some((target) => matchesTarget(constraints, target));
1902
+ const collectEntries$1 = (builder, packages) => {
1903
+ const entries = /* @__PURE__ */ new Map();
1904
+ for (const [key, raw] of Object.entries(packages)) {
1905
+ if (!Array.isArray(raw)) continue;
1906
+ const [id, , info] = raw;
1907
+ if (typeof id !== "string") continue;
1908
+ const at = id.lastIndexOf("@");
1909
+ if (at <= 0) continue;
1910
+ const name = id.slice(0, at);
1911
+ const spec = id.slice(at + 1);
1912
+ const version = asExactVersion(name, spec);
1913
+ if (version._tag === "Unsupported") {
1914
+ builder.skip(name, spec, version.reason);
1915
+ continue;
1916
+ }
1917
+ const details = isRecord(info) ? info : {};
1918
+ entries.set(key, {
1919
+ key,
1920
+ name,
1921
+ version: version.version,
1922
+ dependencies: stringRecord(details["dependencies"]),
1923
+ optionalDependencies: stringRecord(details["optionalDependencies"]),
1924
+ peerDependencies: stringRecord(details["peerDependencies"]),
1925
+ optionalPeers: collectOptionalPeers(details)
1926
+ });
1927
+ }
1928
+ return entries;
697
1929
  };
698
- /** Renders a filter for the manifest and for `--dry-run` output. */
699
- const formatPlatformFilter = (filter) => {
700
- if (filter._tag === "All") return "all";
701
- return filter.targets.map((t) => [
702
- t.os,
703
- t.cpu,
704
- t.libc
705
- ].filter((part) => part !== void 0).join("-")).join(",");
1930
+ /** bun lists optional peers as a name array rather than npm's metadata object. */
1931
+ const collectOptionalPeers = (details) => {
1932
+ const listed = details["optionalPeers"];
1933
+ const names = Array.isArray(listed) ? listed.flatMap((entry) => typeof entry === "string" ? entry : []) : [];
1934
+ return new Set([...names, ...optionalPeerNames(details["peerDependenciesMeta"])]);
706
1935
  };
707
-
708
- //#endregion
709
- //#region src/Spec.ts
710
- var Spec_exports = /* @__PURE__ */ __export({
711
- dedupeSpecs: () => dedupeSpecs,
712
- formatSelector: () => formatSelector,
713
- formatSpec: () => formatSpec,
714
- parseSelector: () => parseSelector,
715
- parseSpec: () => parseSpec,
716
- parseSpecs: () => parseSpecs,
717
- validatePackageName: () => validatePackageName
718
- });
719
- /** Renders a spec back into the canonical `name@selector` form. */
720
- const formatSpec = (spec) => {
721
- switch (spec.selector._tag) {
722
- case "Exact": return `${spec.name}@${spec.selector.version}`;
723
- case "Range": return `${spec.name}@${spec.selector.range}`;
724
- case "Tag": return `${spec.name}@${spec.selector.tag}`;
1936
+ const edgesOf$2 = (builder, entries, keys, entry) => {
1937
+ const edges = [];
1938
+ const add = (names, kind) => {
1939
+ for (const name of names) {
1940
+ const target = findEntry(entries, keys, entry.key, name);
1941
+ if (target === void 0) {
1942
+ if (kind === EdgeKind.Prod) builder.dangling(`${entry.name}@${entry.version}`, name);
1943
+ continue;
1944
+ }
1945
+ edges.push({
1946
+ name: target.name,
1947
+ version: target.version,
1948
+ kind
1949
+ });
1950
+ }
1951
+ };
1952
+ add(Object.keys(entry.dependencies), EdgeKind.Prod);
1953
+ add(Object.keys(entry.optionalDependencies), EdgeKind.Optional);
1954
+ add(Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)), EdgeKind.Peer);
1955
+ return edges;
1956
+ };
1957
+ const collectRoots$2 = (builder, entries, keys, workspaces, path, member) => {
1958
+ if (!isRecord(workspaces)) return;
1959
+ const selected = member === void 0 ? Object.values(workspaces) : Object.hasOwn(workspaces, importerKey(member)) ? [workspaces[importerKey(member)]] : notCovered(path, member, workspacePaths$1(workspaces));
1960
+ for (const workspace of selected) {
1961
+ if (!isRecord(workspace)) continue;
1962
+ for (const kind of ROOT_KINDS$2) {
1963
+ const declared = stringRecord(workspace[fieldByRootKind[kind]]);
1964
+ for (const [name, specifier] of Object.entries(declared)) {
1965
+ const target = findEntry(entries, keys, "", name);
1966
+ if (target === void 0) continue;
1967
+ builder.addRoot({
1968
+ name: target.name,
1969
+ version: target.version,
1970
+ kind,
1971
+ specifier
1972
+ });
1973
+ }
1974
+ }
725
1975
  }
726
1976
  };
727
- /** Human-readable form of just the selector part. */
728
- const formatSelector = (selector) => {
729
- switch (selector._tag) {
730
- case "Exact": return selector.version;
731
- case "Range": return selector.range;
732
- case "Tag": return selector.tag;
1977
+ const workspacePaths$1 = (workspaces) => Object.keys(workspaces).map((key) => key === "" ? "." : key);
1978
+ /**
1979
+ * bun nests with a plain `/`, and package names contain `/` too, so a parent
1980
+ * key cannot be found by string-splitting alone — `@babel/core` would split
1981
+ * into a scope that is not a key. `parentKey` only accepts a prefix that is
1982
+ * itself an entry, which resolves the ambiguity.
1983
+ */
1984
+ const findEntry = (entries, keys, fromKey, name) => {
1985
+ let scope = fromKey;
1986
+ for (;;) {
1987
+ const found = entries.get(scope === "" ? name : `${scope}/${name}`);
1988
+ if (found !== void 0) return found;
1989
+ if (scope === "") return void 0;
1990
+ scope = parentKey(keys, scope);
733
1991
  }
734
1992
  };
735
- const MAX_NAME_LENGTH = 214;
1993
+ const parentKey = (keys, key) => {
1994
+ for (let at = key.lastIndexOf("/"); at > 0; at = key.lastIndexOf("/", at - 1)) {
1995
+ const prefix = key.slice(0, at);
1996
+ if (keys.has(prefix)) return prefix;
1997
+ }
1998
+ return "";
1999
+ };
2000
+
2001
+ //#endregion
2002
+ //#region src/lockfile/npm.ts
2003
+ /** Root blocks in npm's precedence order — the first mention of a name wins. */
2004
+ const ROOT_KINDS$1 = [
2005
+ LockedRootKind.Prod,
2006
+ LockedRootKind.Optional,
2007
+ LockedRootKind.Dev,
2008
+ LockedRootKind.Peer
2009
+ ];
736
2010
  /**
737
- * Validates an npm package name against the rules the registry actually
738
- * enforces. Returns `null` when valid, or the reason it is not.
2011
+ * The node resolution walk-up, over a flat map of install paths.
2012
+ *
2013
+ * A package at `node_modules/a/node_modules/b` depending on `c` gets, in order,
2014
+ * `node_modules/a/node_modules/b/node_modules/c`, then
2015
+ * `node_modules/a/node_modules/c`, then `node_modules/c`. This is what makes a
2016
+ * range on an edge unambiguous: the nesting already recorded which copy won.
739
2017
  */
740
- const validatePackageName = (name) => {
741
- if (name.length === 0) return "name is empty";
742
- if (name.length > MAX_NAME_LENGTH) return `name is longer than ${MAX_NAME_LENGTH} characters`;
743
- if (name.trim() !== name) return "name has leading or trailing whitespace";
744
- if (name.startsWith(".")) return "name cannot start with a period";
745
- if (name.startsWith("_")) return "name cannot start with an underscore";
746
- if (name !== name.toLowerCase()) return "name cannot contain capital letters";
747
- if (name.startsWith("@")) {
748
- const slash = name.indexOf("/");
749
- if (slash === -1) return "scoped name is missing the '/name' part";
750
- const scope = name.slice(1, slash);
751
- const rest = name.slice(slash + 1);
752
- if (scope.length === 0) return "scope is empty";
753
- if (rest.length === 0) return "name after the scope is empty";
754
- if (rest.includes("/")) return "name contains more than one '/'";
755
- if (!SEGMENT_RE.test(scope)) return `scope "${scope}" contains illegal characters`;
756
- if (!SEGMENT_RE.test(rest)) return `name "${rest}" contains illegal characters`;
757
- return null;
2018
+ const NESTING = "/node_modules/";
2019
+ const parseNpmLockfile = (path, content, importer) => {
2020
+ const root = parseJsonObject(path, content);
2021
+ const version = String(root["lockfileVersion"] ?? "?");
2022
+ const builder = new TreeBuilder();
2023
+ const packages = root["packages"];
2024
+ const entries = isRecord(packages) ? collectEntries(builder, packages) : collectLegacyEntries(builder, root);
2025
+ for (const entry of entries.values()) builder.add({
2026
+ name: entry.realName,
2027
+ version: entry.version,
2028
+ dependencies: edgesOf$1(builder, entries, entry)
2029
+ });
2030
+ collectRoots$1(builder, entries, isRecord(packages) ? packages : root, path, importer);
2031
+ return builder.finish({
2032
+ format: LockfileFormat.Npm,
2033
+ lockfileVersion: version,
2034
+ path,
2035
+ importer
2036
+ });
2037
+ };
2038
+ /** Narrows the v2/v3 `packages` map, dropping what cannot be bundled. */
2039
+ const collectEntries = (builder, packages) => {
2040
+ const entries = /* @__PURE__ */ new Map();
2041
+ for (const [entryPath, raw] of Object.entries(packages)) {
2042
+ const installName = installNameFromPath(entryPath);
2043
+ if (installName === null) continue;
2044
+ if (!isRecord(raw)) continue;
2045
+ if (raw["link"] === true) {
2046
+ builder.skip(installName, stringOr(raw["resolved"], "link"), "workspace link");
2047
+ continue;
2048
+ }
2049
+ const rawVersion = raw["version"];
2050
+ if (typeof rawVersion !== "string") {
2051
+ builder.warn(`skipped ${installName} — its lockfile entry records no version, so there is nothing to pin`);
2052
+ continue;
2053
+ }
2054
+ const resolvedName = typeof raw["name"] === "string" ? raw["name"] : installName;
2055
+ const version = asExactVersion(resolvedName, rawVersion);
2056
+ if (version._tag === "Unsupported") {
2057
+ builder.skip(resolvedName, rawVersion, version.reason);
2058
+ continue;
2059
+ }
2060
+ const resolvedUrl = raw["resolved"];
2061
+ if (typeof resolvedUrl === "string" && !isRegistryUrl(resolvedUrl)) {
2062
+ builder.skip(resolvedName, resolvedUrl, "not a registry tarball");
2063
+ continue;
2064
+ }
2065
+ entries.set(entryPath, {
2066
+ path: entryPath,
2067
+ version: version.version,
2068
+ realName: resolvedName,
2069
+ dependencies: stringRecord(raw["dependencies"]),
2070
+ optionalDependencies: stringRecord(raw["optionalDependencies"]),
2071
+ peerDependencies: stringRecord(raw["peerDependencies"]),
2072
+ optionalPeers: optionalPeerNames(raw["peerDependenciesMeta"])
2073
+ });
758
2074
  }
759
- if (name.includes("/")) return "unscoped name cannot contain '/'";
760
- if (!SEGMENT_RE.test(name)) return "name contains illegal characters";
761
- return null;
2075
+ return entries;
762
2076
  };
763
- const SEGMENT_RE = /^[a-z0-9\-._~]+$/;
764
2077
  /**
765
- * Parses a single spec string.
2078
+ * Flattens a v1 lockfile's nested `dependencies` tree into the same
2079
+ * path-keyed shape v2 uses.
766
2080
  *
767
- * Splitting on `@` is the fiddly part: a scoped name *starts* with `@`, so we
768
- * look for the last `@` that is not at index 0.
2081
+ * v1 is old npm 6 but it is exactly the vintage still pinned inside a lot
2082
+ * of the locked-down environments this tool exists for, so it is worth the
2083
+ * thirty lines rather than an error telling somebody to upgrade npm.
769
2084
  */
770
- const parseSpec = (raw) => {
771
- const input = raw.trim();
772
- if (input.length === 0) throw new InvalidSpecError(raw, "spec is empty");
773
- const at = input.lastIndexOf("@");
774
- const hasSelector = at > 0;
775
- const name = hasSelector ? input.slice(0, at) : input;
776
- const selectorText = hasSelector ? input.slice(at + 1) : "";
777
- const nameProblem = validatePackageName(name);
778
- if (nameProblem !== null) throw new InvalidSpecError(raw, nameProblem);
779
- if (!hasSelector || selectorText.length === 0) return {
780
- name,
781
- selector: {
782
- _tag: "Tag",
783
- tag: "latest"
784
- },
785
- raw: input
786
- };
787
- return {
788
- name,
789
- selector: parseSelector(raw, selectorText),
790
- raw: input
2085
+ const collectLegacyEntries = (builder, root) => {
2086
+ const entries = /* @__PURE__ */ new Map();
2087
+ const visit = (tree, prefix) => {
2088
+ if (!isRecord(tree)) return;
2089
+ for (const [name, raw] of Object.entries(tree)) {
2090
+ if (!isRecord(raw)) continue;
2091
+ const entryPath = prefix === "" ? `node_modules/${name}` : `${prefix}/node_modules/${name}`;
2092
+ const rawVersion = raw["version"];
2093
+ if (typeof rawVersion === "string") {
2094
+ const version = asExactVersion(name, rawVersion);
2095
+ if (version._tag === "Unsupported") builder.skip(name, rawVersion, version.reason);
2096
+ else entries.set(entryPath, {
2097
+ path: entryPath,
2098
+ version: version.version,
2099
+ realName: name,
2100
+ dependencies: stringRecord(raw["requires"]),
2101
+ optionalDependencies: {},
2102
+ peerDependencies: {},
2103
+ optionalPeers: /* @__PURE__ */ new Set()
2104
+ });
2105
+ }
2106
+ visit(raw["dependencies"], entryPath);
2107
+ }
791
2108
  };
2109
+ visit(root["dependencies"], "");
2110
+ return entries;
792
2111
  };
793
- /** Classifies the part after the `@` as an exact version, a range, or a dist-tag. */
794
- const parseSelector = (raw, text) => {
795
- const exact = semver.valid(text, { loose: false });
796
- if (exact !== null) return {
797
- _tag: "Exact",
798
- version: exact
799
- };
800
- if (semver.validRange(text, { loose: false }) !== null) return {
801
- _tag: "Range",
802
- range: text
803
- };
804
- if (!DIST_TAG_RE.test(text)) throw new InvalidSpecError(raw, `"${text}" is not a valid version, semver range, or dist-tag`);
805
- return {
806
- _tag: "Tag",
807
- tag: text
2112
+ /** Turns one entry's ranges into edges, resolved through the directory nesting. */
2113
+ const edgesOf$1 = (builder, entries, entry) => {
2114
+ const edges = [];
2115
+ const add = (names, kind) => {
2116
+ for (const name of names) {
2117
+ const targetPath = resolveNestedEdge(entries, entry.path, name);
2118
+ const target = targetPath === null ? void 0 : entries.get(targetPath);
2119
+ if (target === void 0) {
2120
+ if (kind === EdgeKind.Prod) builder.dangling(`${entry.realName}@${entry.version}`, name);
2121
+ continue;
2122
+ }
2123
+ edges.push({
2124
+ name: target.realName,
2125
+ version: target.version,
2126
+ kind
2127
+ });
2128
+ }
808
2129
  };
2130
+ const optionalNames = Object.keys(entry.optionalDependencies);
2131
+ const optional = new Set(optionalNames);
2132
+ add(Object.keys(entry.dependencies).filter((name) => !optional.has(name)), EdgeKind.Prod);
2133
+ add(optionalNames, EdgeKind.Optional);
2134
+ add(Object.keys(entry.peerDependencies).filter((name) => !entry.optionalPeers.has(name)), EdgeKind.Peer);
2135
+ return edges;
809
2136
  };
810
- const DIST_TAG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
811
2137
  /**
812
- * Parses many specs, collecting *every* failure rather than stopping at the
813
- * first. Somebody bundling forty packages should be told about all four typos
814
- * in one go, not made to re-run four times.
2138
+ * Reads the root project's direct dependencies.
2139
+ *
2140
+ * The lockfile records these under `packages[""]` for v2/v3. v1 has no such
2141
+ * entry, so every top-level `node_modules/x` is treated as direct — which
2142
+ * over-counts roots slightly on v1 and is the best that file can support.
815
2143
  */
816
- const parseSpecs = (inputs) => {
817
- const specs = [];
818
- const errors = [];
819
- for (const input of inputs) try {
820
- specs.push(parseSpec(input));
821
- } catch (error) {
822
- if (error instanceof InvalidSpecError) errors.push(error);
823
- else throw error;
824
- }
825
- return {
826
- specs,
827
- errors
2144
+ const collectRoots$1 = (builder, entries, source, path, importer) => {
2145
+ const collectFrom = (from, project) => {
2146
+ for (const kind of ROOT_KINDS$1) {
2147
+ const field = fieldByRootKind[kind];
2148
+ for (const [name, specifier] of Object.entries(stringRecord(project[field]))) {
2149
+ const targetPath = resolveNestedEdge(entries, from, name);
2150
+ const target = targetPath === null ? void 0 : entries.get(targetPath);
2151
+ if (target === void 0) continue;
2152
+ builder.addRoot({
2153
+ name: target.realName,
2154
+ version: target.version,
2155
+ kind,
2156
+ specifier
2157
+ });
2158
+ }
2159
+ }
828
2160
  };
2161
+ if (importer !== void 0) {
2162
+ const key = importerKey(importer);
2163
+ const project = source[key];
2164
+ if (!isRecord(project)) notCovered(path, importer, workspacePaths(source));
2165
+ else collectFrom(key, project);
2166
+ return;
2167
+ }
2168
+ const projects = Object.entries(source).flatMap(([key, value]) => isRecord(value) && !key.includes("node_modules/") ? [[key, value]] : []);
2169
+ if (projects.length > 0) {
2170
+ for (const [key, project] of projects) collectFrom(key, project);
2171
+ return;
2172
+ }
2173
+ for (const entry of entries.values()) {
2174
+ if (entry.path.includes(NESTING)) continue;
2175
+ builder.addRoot({
2176
+ name: entry.realName,
2177
+ version: entry.version,
2178
+ kind: LockedRootKind.Prod
2179
+ });
2180
+ }
829
2181
  };
2182
+ /** The workspace paths a v2/v3 lockfile records, for a "does not cover" message. */
2183
+ const workspacePaths = (source) => Object.keys(source).flatMap((key) => key === "" ? "." : key.includes("node_modules/") ? [] : key);
830
2184
  /**
831
- * Collapses duplicate specs, keeping the first occurrence.
832
- *
833
- * `npmb react react@latest` is a plausible thing to type and should not
834
- * download React twice.
2185
+ * npm records non-registry sources as a URL in `resolved`. Registry tarballs
2186
+ * are ordinary http(s); anything with a `git+`, `file:` or bare path form is
2187
+ * something we cannot fetch.
835
2188
  */
836
- const dedupeSpecs = (specs) => {
837
- const seen = /* @__PURE__ */ new Set();
838
- const out = [];
839
- for (const spec of specs) {
840
- const key = formatSpec(spec);
841
- if (seen.has(key)) continue;
842
- seen.add(key);
843
- out.push(spec);
2189
+ const isRegistryUrl = (url) => url.startsWith("http://") || url.startsWith("https://");
2190
+ const resolveNestedEdge = (entries, fromPath, name) => {
2191
+ let scope = fromPath;
2192
+ for (;;) {
2193
+ const candidate = scope === "" ? `node_modules/${name}` : `${scope}${NESTING}${name}`;
2194
+ if (entries.has(candidate)) return candidate;
2195
+ if (scope === "") return null;
2196
+ const at = scope.lastIndexOf(NESTING);
2197
+ scope = at === -1 ? "" : scope.slice(0, at);
844
2198
  }
845
- return out;
2199
+ };
2200
+ /** `node_modules/@babel/core` -> `@babel/core`; a workspace path -> `null`. */
2201
+ const installNameFromPath = (entryPath) => {
2202
+ const at = entryPath.lastIndexOf("node_modules/");
2203
+ if (at === -1) return null;
2204
+ const name = entryPath.slice(at + 13);
2205
+ return name.length === 0 ? null : name;
846
2206
  };
847
2207
 
848
2208
  //#endregion
849
- //#region src/Manifest.ts
850
- var Manifest_exports = /* @__PURE__ */ __export({
851
- MANIFEST_VERSION: () => MANIFEST_VERSION,
852
- buildManifest: () => buildManifest,
853
- formatReasons: () => formatReasons,
854
- serializeManifest: () => serializeManifest
855
- });
856
- /** Schema version, bumped when the shape changes incompatibly. */
857
- const MANIFEST_VERSION = 1;
858
- /** Renders a package's reasons into short, greppable strings. */
859
- const formatReasons = (pkg) => pkg.reasons.map((reason) => reason._tag === "Root" ? `root:${reason.spec}` : `${reason.kind}:${reason.from}`);
860
- /** Builds the manifest for a completed (or dry) run. */
861
- const buildManifest = (input) => {
862
- const includedNames = new Set(input.included.map((p) => `${p.name}@${p.version}`));
863
- const packages = input.included.map((pkg) => {
864
- const key = `${pkg.name}@${pkg.version}`;
865
- return {
866
- name: pkg.name,
867
- version: pkg.version,
868
- path: packagePath(pkg.name, pkg.version),
869
- tarball: pkg.manifest.dist.tarball,
870
- integrity: pkg.manifest.dist.integrity,
871
- shasum: pkg.manifest.dist.shasum,
872
- bytes: input.sizes.get(key),
873
- reasons: formatReasons(pkg)
874
- };
2209
+ //#region src/lockfile/pnpm.ts
2210
+ /** pnpm never records peer dependencies against an importer. */
2211
+ const ROOT_KINDS = [
2212
+ LockedRootKind.Prod,
2213
+ LockedRootKind.Optional,
2214
+ LockedRootKind.Dev
2215
+ ];
2216
+ const parsePnpmLockfile = (path, content, importer) => {
2217
+ let root;
2218
+ try {
2219
+ root = parse(content);
2220
+ } catch (error) {
2221
+ throw new LockfileError(path, `not valid YAML — ${describeError$1(error)}`, { cause: error });
2222
+ }
2223
+ if (!isRecord(root)) throw new LockfileError(path, "top level is not a mapping");
2224
+ const version = String(root["lockfileVersion"] ?? "?");
2225
+ const builder = new TreeBuilder();
2226
+ const packages = isRecord(root["packages"]) ? root["packages"] : {};
2227
+ const snapshots = isRecord(root["snapshots"]) ? root["snapshots"] : packages;
2228
+ const known = /* @__PURE__ */ new Map();
2229
+ for (const key of Object.keys(packages)) {
2230
+ const parsed = parsePnpmKey(key);
2231
+ if (parsed === null) continue;
2232
+ known.set(key, parsed);
2233
+ }
2234
+ for (const [key, raw] of Object.entries(snapshots)) {
2235
+ const parsed = parsePnpmKey(key) ?? known.get(key);
2236
+ if (parsed === void 0 || parsed === null) {
2237
+ const described = describeKey(key);
2238
+ if (described !== null) builder.skip(described.name, described.spec, described.reason);
2239
+ continue;
2240
+ }
2241
+ builder.add({
2242
+ name: parsed.name,
2243
+ version: parsed.version,
2244
+ dependencies: edgesOf(builder, parsed, isRecord(raw) ? raw : {})
2245
+ });
2246
+ }
2247
+ collectRoots(builder, root, path, importer);
2248
+ return builder.finish({
2249
+ format: LockfileFormat.Pnpm,
2250
+ lockfileVersion: version,
2251
+ path,
2252
+ importer
875
2253
  });
876
- const totalBytes = packages.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0);
877
- return {
878
- manifestVersion: MANIFEST_VERSION,
879
- tool: {
880
- name: "packall",
881
- version: input.toolVersion
882
- },
883
- createdAt: (input.createdAt ?? /* @__PURE__ */ new Date()).toISOString(),
884
- registry: input.registry,
885
- requested: input.resolution.roots.map((root) => formatSpec(root.spec)),
886
- roots: input.resolution.roots.map((root) => ({
887
- spec: formatSpec(root.spec),
888
- versions: root.versions,
889
- packageCount: root.closure.filter((key) => includedNames.has(key)).length
890
- })),
891
- options: {
892
- layout: input.options.layout,
893
- optionalDependencies: input.options.scope.optional,
894
- peerDependencies: input.options.scope.peer,
895
- platforms: formatPlatformFilter(input.options.scope.platforms),
896
- allVersions: input.options.allVersions,
897
- includePrerelease: input.options.includePrerelease,
898
- integrityVerified: input.options.verifyIntegrity
899
- },
900
- packages,
901
- warnings: input.resolution.warnings.map((warning) => warning.from === void 0 ? warning.message : `${warning.from}: ${warning.message}`),
902
- totals: {
903
- packages: packages.length,
904
- bytes: totalBytes
2254
+ };
2255
+ const edgesOf = (builder, from, snapshot) => {
2256
+ const edges = [];
2257
+ const add = (field, kind) => {
2258
+ const entries = snapshot[field];
2259
+ if (!isRecord(entries)) return;
2260
+ for (const [name, raw] of Object.entries(entries)) {
2261
+ if (typeof raw !== "string") continue;
2262
+ const target = parsePnpmReference(name, raw);
2263
+ if (target === null) {
2264
+ if (kind === EdgeKind.Prod) builder.dangling(`${from.name}@${from.version}`, name);
2265
+ continue;
2266
+ }
2267
+ edges.push({
2268
+ ...target,
2269
+ kind
2270
+ });
905
2271
  }
906
2272
  };
2273
+ add(fieldByRootKind[LockedRootKind.Prod], EdgeKind.Prod);
2274
+ add(fieldByRootKind[LockedRootKind.Optional], EdgeKind.Optional);
2275
+ add(fieldByRootKind[LockedRootKind.Peer], EdgeKind.Peer);
2276
+ return edges;
907
2277
  };
908
- /** Serialises a manifest with stable key order and a trailing newline. */
909
- const serializeManifest = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`;
910
-
911
- //#endregion
912
- //#region src/DependencyRange.ts
913
- var DependencyRange_exports = /* @__PURE__ */ __export({ parseDependencyTarget: () => parseDependencyTarget });
914
- const UNSUPPORTED_PROTOCOLS = [
915
- ["file:", "local file path"],
916
- ["link:", "local link"],
917
- ["workspace:", "workspace protocol"],
918
- ["portal:", "portal protocol"],
919
- ["patch:", "patch protocol"],
920
- ["git:", "git dependency"],
921
- ["git+", "git dependency"],
922
- ["github:", "GitHub shorthand"],
923
- ["gitlab:", "GitLab shorthand"],
924
- ["bitbucket:", "Bitbucket shorthand"],
925
- ["http:", "remote tarball URL"],
926
- ["https:", "remote tarball URL"]
927
- ];
928
2278
  /**
929
- * Classifies one `name -> range` entry.
2279
+ * The importers' direct dependencies become the roots.
930
2280
  *
931
- * An empty range, `*`, and `latest` all mean "any published version"; npm
932
- * treats them interchangeably and so do we.
2281
+ * pnpm is the format that makes this easy: `importers` is already keyed by the
2282
+ * path each package.json sits at, so scoping to one member is a lookup rather
2283
+ * than a reconstruction.
933
2284
  */
934
- const parseDependencyTarget = (name, raw) => {
935
- const text = raw.trim();
936
- if (text.length === 0 || text === "*" || text === "x" || text === "latest") return {
937
- _tag: "Registry",
938
- name,
939
- selector: {
940
- _tag: "Range",
941
- range: "*"
2285
+ const collectRoots = (builder, root, path, member) => {
2286
+ const importers = isRecord(root["importers"]) ? root["importers"] : { ".": root };
2287
+ const selected = member === void 0 ? Object.values(importers) : Object.hasOwn(importers, member) ? [importers[member]] : notCovered(path, member, Object.keys(importers));
2288
+ for (const importer of selected) {
2289
+ if (!isRecord(importer)) continue;
2290
+ for (const kind of ROOT_KINDS) {
2291
+ const entries = importer[fieldByRootKind[kind]];
2292
+ if (!isRecord(entries)) continue;
2293
+ for (const [name, raw] of Object.entries(entries)) {
2294
+ const value = typeof raw === "string" ? raw : isRecord(raw) ? raw["version"] : void 0;
2295
+ if (typeof value !== "string") continue;
2296
+ const target = parsePnpmReference(name, value);
2297
+ if (target === null) continue;
2298
+ const specifier = isRecord(raw) && typeof raw["specifier"] === "string" ? raw["specifier"] : void 0;
2299
+ builder.addRoot({
2300
+ ...target,
2301
+ kind,
2302
+ specifier
2303
+ });
2304
+ }
942
2305
  }
943
- };
944
- if (text.startsWith("npm:")) return parseAlias(name, text);
945
- for (const [prefix, reason] of UNSUPPORTED_PROTOCOLS) if (text.startsWith(prefix)) return {
946
- _tag: "Unsupported",
947
- name,
948
- raw: text,
949
- reason
950
- };
951
- if (semver.validRange(text, { loose: true }) === null && /^[\w.-]+\/[\w.-]+/.test(text)) return {
952
- _tag: "Unsupported",
953
- name,
954
- raw: text,
955
- reason: "git shorthand"
956
- };
957
- const exact = semver.valid(text, { loose: true });
2306
+ }
2307
+ };
2308
+ /**
2309
+ * Reads a dependency value from a snapshot or importer.
2310
+ *
2311
+ * The value is either a bare version (`1.4.0`) or a full package key
2312
+ * (`/lodash@4.17.21`, used when the install name is an alias of another
2313
+ * package). Both may carry a peer-resolution suffix in parentheses.
2314
+ */
2315
+ const parsePnpmReference = (name, raw) => {
2316
+ const value = stripSuffix(raw);
2317
+ const exact = semver.valid(value, { loose: true });
958
2318
  if (exact !== null) return {
959
- _tag: "Registry",
960
- name,
961
- selector: {
962
- _tag: "Exact",
963
- version: exact
964
- }
965
- };
966
- if (semver.validRange(text, { loose: true }) !== null) return {
967
- _tag: "Registry",
968
2319
  name,
969
- selector: {
970
- _tag: "Range",
971
- range: text
972
- }
973
- };
974
- if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(text)) return {
975
- _tag: "Registry",
976
- name,
977
- selector: {
978
- _tag: "Tag",
979
- tag: text
980
- }
981
- };
982
- return {
983
- _tag: "Unsupported",
984
- name,
985
- raw: text,
986
- reason: "unrecognised version specifier"
2320
+ version: exact
987
2321
  };
2322
+ return parsePnpmKey(raw);
988
2323
  };
989
2324
  /**
990
- * Parses `npm:<name>[@<range>]`.
2325
+ * Splits a package key into name and version.
991
2326
  *
992
- * The nested name may itself be scoped, so the `@` split has the same
993
- * "not at index 0" caveat as top-level spec parsing.
2327
+ * Three encodings across the versions still in the wild:
2328
+ * v5 `/@babel/core/7.0.0`
2329
+ * v6 `/@babel/core@7.0.0`
2330
+ * v9 `@babel/core@7.0.0`
2331
+ *
2332
+ * plus an optional `(peer@1.0.0)` or `(patch_hash=…)` suffix on any of them.
994
2333
  */
995
- const parseAlias = (key, text) => {
996
- const body = text.slice(4);
997
- if (body.length === 0) return {
998
- _tag: "Unsupported",
999
- name: key,
1000
- raw: text,
1001
- reason: "empty npm: alias"
1002
- };
2334
+ const parsePnpmKey = (key) => {
2335
+ const body = stripSuffix(key.startsWith("/") ? key.slice(1) : key);
1003
2336
  const at = body.lastIndexOf("@");
1004
- const target = at > 0 ? body.slice(0, at) : body;
1005
- const rangeText = at > 0 ? body.slice(at + 1) : "";
1006
- if (target.length === 0) return {
1007
- _tag: "Unsupported",
1008
- name: key,
1009
- raw: text,
1010
- reason: "empty npm: alias target"
1011
- };
1012
- const inner = parseDependencyTarget(target, rangeText);
1013
- if (inner._tag === "Unsupported") return {
1014
- ...inner,
1015
- name: key,
1016
- raw: text
1017
- };
2337
+ const cut = at > 0 ? at : body.lastIndexOf("/");
2338
+ if (cut <= 0) return null;
2339
+ const name = body.slice(0, cut);
2340
+ const version = semver.valid(body.slice(cut + 1), { loose: true });
2341
+ if (version === null || name.length === 0) return null;
1018
2342
  return {
1019
- _tag: "Registry",
1020
- name: target,
1021
- selector: inner.selector,
1022
- aliasOf: key
2343
+ name,
2344
+ version
1023
2345
  };
1024
2346
  };
1025
-
1026
- //#endregion
1027
- //#region src/Resolve.ts
1028
- var Resolve_exports = /* @__PURE__ */ __export({
1029
- edgesOf: () => edgesOf,
1030
- indexPackages: () => indexPackages,
1031
- packageKey: () => packageKey,
1032
- resolve: () => resolve,
1033
- selectVersions: () => selectVersions
1034
- });
1035
- const packageKey = (name, version) => `${name}@${version}`;
1036
- /** Index of a resolution by `name@version`, for lookups during bundling. */
1037
- const indexPackages = (resolution) => new Map(resolution.packages.map((p) => [packageKey(p.name, p.version), p]));
1038
- /**
1039
- * Turns a selector into the concrete version(s) it names.
1040
- *
1041
- * `all` controls whether a range collapses to its best match — npm's behaviour,
1042
- * and what you always want for a transitive dependency — or expands to every
1043
- * satisfying published version, which is what `--all-versions` is for.
1044
- */
1045
- const selectVersions = (packument, selector, options) => {
1046
- const available = Object.keys(packument.versions);
1047
- switch (selector._tag) {
1048
- case "Exact":
1049
- if (!Object.hasOwn(packument.versions, selector.version)) throw new VersionNotFoundError(packument.name, selector.version, sortVersions(available));
1050
- return [selector.version];
1051
- case "Tag": {
1052
- const version = packument.distTags[selector.tag];
1053
- if (version === void 0 || !Object.hasOwn(packument.versions, version)) throw new NoMatchingVersionsError(packument.name, selector.tag, sortVersions(available));
1054
- return [version];
1055
- }
1056
- case "Range": {
1057
- const satisfying = available.filter((v) => semver.satisfies(v, selector.range, {
1058
- loose: true,
1059
- includePrerelease: options.includePrerelease
1060
- })).sort(semver.rcompare);
1061
- if (satisfying.length === 0) throw new NoMatchingVersionsError(packument.name, selector.range, sortVersions(available));
1062
- if (!options.all) return [satisfying[0]];
1063
- return options.maxVersions !== void 0 && options.maxVersions > 0 ? satisfying.slice(0, options.maxVersions) : satisfying;
1064
- }
1065
- }
2347
+ /** Describes a key we cannot pin, so the skip warning can say why. */
2348
+ const describeKey = (key) => {
2349
+ const body = key.startsWith("/") ? key.slice(1) : key;
2350
+ const at = body.lastIndexOf("@");
2351
+ if (at <= 0) return null;
2352
+ const name = body.slice(0, at);
2353
+ const spec = body.slice(at + 1);
2354
+ const target = parseDependencyTarget(name, spec);
2355
+ return {
2356
+ name,
2357
+ spec,
2358
+ reason: target._tag === "Unsupported" ? target.reason : "not an exact version"
2359
+ };
1066
2360
  };
1067
- const sortVersions = (versions) => [...versions].sort((a, b) => semver.valid(a) && semver.valid(b) ? semver.compare(a, b) : a.localeCompare(b));
1068
- const makeWalkState = () => ({
1069
- resolved: /* @__PURE__ */ new Map(),
1070
- edges: /* @__PURE__ */ new Map(),
1071
- packuments: /* @__PURE__ */ new Map(),
1072
- warnings: [],
1073
- warningKeys: /* @__PURE__ */ new Set()
1074
- });
1075
- const addWarning = (state, warning) => {
1076
- const key = `${warning.from ?? ""}|${warning.message}`;
1077
- if (state.warningKeys.has(key)) return;
1078
- state.warningKeys.add(key);
1079
- state.warnings.push(warning);
2361
+ /**
2362
+ * Drops the parenthesised peer-resolution suffix.
2363
+ *
2364
+ * pnpm distinguishes the same version installed against different peers —
2365
+ * `react-dom@18.3.1(react@18.3.1)`. Those are the same published tarball, which
2366
+ * is all this tool downloads, so the suffix is noise here.
2367
+ */
2368
+ const stripSuffix = (value) => {
2369
+ const open = value.indexOf("(");
2370
+ return open === -1 ? value : value.slice(0, open);
1080
2371
  };
2372
+
2373
+ //#endregion
2374
+ //#region src/lockfile/parse.ts
1081
2375
  /**
1082
- * Fetches a packument, reusing anything already seen in this run.
2376
+ * Parses lockfile content into a flat graph.
1083
2377
  *
1084
- * Two fibers in the same wave can race here and both fetch. That is one
1085
- * duplicated GET at worst, and de-racing it would cost more complexity than it
1086
- * saves.
2378
+ * Throws `LockfileError`; the caller is expected to be inside an `Effect.try`.
1087
2379
  */
1088
- const getPackument = (state, name) => Effect.gen(function* () {
1089
- const cached = state.packuments.get(name);
1090
- if (cached !== void 0) return cached;
1091
- const packument = yield* (yield* Registry).packument(name);
1092
- state.packuments.set(name, packument);
1093
- return packument;
2380
+ const parseLockfile = (path, content, format, options = {}) => {
2381
+ const resolved = format ?? resolveFormat(path, content);
2382
+ const importer = options.importer;
2383
+ switch (resolved) {
2384
+ case LockfileFormat.Npm: return parseNpmLockfile(path, content, importer);
2385
+ case LockfileFormat.Pnpm: return parsePnpmLockfile(path, content, importer);
2386
+ case LockfileFormat.Bun: return parseBunLockfile(path, content, importer);
2387
+ }
2388
+ };
2389
+ const resolveFormat = (path, content) => {
2390
+ const detected = detectLockfile(content);
2391
+ if (detected._tag === "Supported") return detected.format;
2392
+ if (detected._tag === "Unsupported") throw new LockfileError(path, `${detected.label} — ${detected.hint}`);
2393
+ throw new LockfileError(path, "content does not look like a package-lock.json, pnpm-lock.yaml or bun.lock");
2394
+ };
2395
+
2396
+ //#endregion
2397
+ //#region src/schemas/package-json.ts
2398
+ const PackageJsonSchema = Schema.Struct({
2399
+ dependencies: tolerant(StringRecordSchema),
2400
+ devDependencies: tolerant(StringRecordSchema),
2401
+ optionalDependencies: tolerant(StringRecordSchema),
2402
+ peerDependencies: tolerant(StringRecordSchema),
2403
+ peerDependenciesMeta: tolerant(OptionalFlagRecordSchema)
1094
2404
  });
1095
- /** Expands one resolved manifest into the edges that follow from it. */
1096
- const edgesOf = (manifest, options) => {
1097
- const from = packageKey(manifest.name, manifest.version);
1098
- const tasks = [];
2405
+ const decodePackageJson = Schema.decodeUnknownOption(PackageJsonSchema);
2406
+
2407
+ //#endregion
2408
+ //#region src/input-file.ts
2409
+ const defaultInputFileOptions = { includeDev: true };
2410
+ /** Reads and parses an input file. */
2411
+ const readInputFile = (filePath, options = defaultInputFileOptions) => Effect.gen(function* () {
2412
+ const fs = yield* FileSystem.FileSystem;
2413
+ if (!(yield* fs.exists(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))))) return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"));
2414
+ const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause })));
2415
+ return yield* Effect.try({
2416
+ try: () => parseInputFile(filePath, content, options),
2417
+ catch: (error) => error instanceof InvalidInputFileError || isLockfileError(error) ? error : new InvalidInputFileError(filePath, describeError(error), { cause: error })
2418
+ });
2419
+ });
2420
+ const isLockfileError = (error) => error instanceof Error && "_tag" in error && error._tag === "LockfileError";
2421
+ /**
2422
+ * Parses file content that has already been read.
2423
+ *
2424
+ * Split out from the IO so the whole of this logic is testable with plain
2425
+ * strings and no file system at all.
2426
+ */
2427
+ const parseInputFile = (filePath, content, options = defaultInputFileOptions) => {
2428
+ const trimmed = content.trim();
2429
+ if (trimmed.length === 0) throw new InvalidInputFileError(filePath, "file is empty");
2430
+ const detected = detectLockfile(trimmed);
2431
+ if (detected._tag === "Unsupported") throw new InvalidInputFileError(filePath, `${detected.label} — ${detected.hint}`);
2432
+ if (detected._tag === "Supported") return fromLockfile(filePath, content, detected.format, options);
2433
+ return looksLikeJson(trimmed) ? fromPackageJson(filePath, trimmed, options) : fromList(filePath, content);
2434
+ };
2435
+ const looksLikeJson = (trimmed) => trimmed.startsWith("{");
2436
+ const fromLockfile = (filePath, content, format, options) => {
2437
+ const lockfile = parseLockfile(filePath, content, format, { importer: options.importer });
2438
+ const roots = lockfile.roots.filter((root) => options.includeDev || root.kind !== "dev");
2439
+ return {
2440
+ kind: InputFileKind.Lockfile,
2441
+ specs: roots.map((root) => ({
2442
+ name: root.name,
2443
+ selector: {
2444
+ _tag: "Exact",
2445
+ version: root.version
2446
+ },
2447
+ raw: `${root.name}@${root.version}`
2448
+ })),
2449
+ warnings: lockfile.warnings,
2450
+ lockfile
2451
+ };
2452
+ };
2453
+ const fromPackageJson = (filePath, content, options) => {
2454
+ const parsed = parsePackageJson(filePath, content);
2455
+ const specs = [];
1099
2456
  const warnings = [];
1100
- const add = (entries, kind, tolerateFailure, skip) => {
2457
+ const required = [];
2458
+ const seen = /* @__PURE__ */ new Set();
2459
+ const collect = (entries, label, skip) => {
1101
2460
  if (entries === void 0) return;
1102
2461
  for (const [name, raw] of Object.entries(entries)) {
1103
2462
  if (skip?.(name)) continue;
1104
2463
  const target = parseDependencyTarget(name, raw);
1105
2464
  if (target._tag === "Unsupported") {
1106
- warnings.push({
1107
- from,
1108
- message: `Skipped ${name}@${target.raw} (${target.reason}) — it cannot be fetched from a registry. Vendor it separately if the offline install needs it.`
1109
- });
2465
+ warnings.push(`${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`);
1110
2466
  continue;
1111
2467
  }
1112
- tasks.push({
2468
+ if (label === "dependencies" || label === "devDependencies") required.push(target.name);
2469
+ const key = `${target.name}|${JSON.stringify(target.selector)}`;
2470
+ if (seen.has(key)) continue;
2471
+ seen.add(key);
2472
+ specs.push({
1113
2473
  name: target.name,
1114
2474
  selector: target.selector,
1115
- reason: {
1116
- _tag: "Edge",
1117
- from,
1118
- kind
1119
- },
1120
- tolerateFailure
2475
+ raw: `${target.name}@${raw}`
1121
2476
  });
1122
2477
  }
1123
2478
  };
1124
- add(manifest.dependencies, "prod", false);
1125
- if (options.scope.optional) add(manifest.optionalDependencies, "optional", true);
1126
- if (options.scope.peer) {
1127
- const meta = manifest.peerDependenciesMeta ?? {};
1128
- add(manifest.peerDependencies, "peer", false, (name) => meta[name]?.optional === true);
1129
- }
2479
+ collect(parsed.dependencies, "dependencies");
2480
+ collect(parsed.optionalDependencies, "optionalDependencies");
2481
+ if (options.includeDev) collect(parsed.devDependencies, "devDependencies");
2482
+ const meta = parsed.peerDependenciesMeta ?? {};
2483
+ collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true);
2484
+ if (specs.length === 0) throw new InvalidInputFileError(filePath, options.includeDev ? "package.json declares no dependencies to bundle" : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)");
1130
2485
  return {
1131
- tasks,
1132
- warnings
2486
+ kind: InputFileKind.PackageJson,
2487
+ specs,
2488
+ warnings,
2489
+ required
1133
2490
  };
1134
2491
  };
1135
- /** Edges for a package, computed once and cached. */
1136
- const edgesFor = (state, manifest, options) => {
1137
- const key = packageKey(manifest.name, manifest.version);
1138
- const cached = state.edges.get(key);
1139
- if (cached !== void 0) return cached;
1140
- const { tasks, warnings } = edgesOf(manifest, options);
1141
- for (const warning of warnings) addWarning(state, warning);
1142
- state.edges.set(key, tasks);
1143
- return tasks;
1144
- };
1145
2492
  /**
1146
- * Resolves everything reachable from `seeds`, returning the closure.
2493
+ * Reads a package.json, or gives up on its contents rather than on the run.
1147
2494
  *
1148
- * `state` is mutated with newly resolved packages and warnings; the returned
1149
- * array is scoped to *this* walk, which is what makes per-root-version
1150
- * closures possible.
2495
+ * Invalid JSON is an error worth raising somebody pointed `--file` at a
2496
+ * broken file. A well-formed JSON document this schema cannot recognise is
2497
+ * not: it decodes to no blocks, and the caller reports "declares no
2498
+ * dependencies to bundle", which is both true and more useful.
1151
2499
  */
1152
- const walk = (state, seeds, options) => Effect.gen(function* () {
1153
- const discovered = /* @__PURE__ */ new Set();
1154
- const attempted = /* @__PURE__ */ new Set();
1155
- let frontier = seeds;
1156
- while (frontier.length > 0) {
1157
- const wave = [];
1158
- for (const task of frontier) {
1159
- const id = `${task.name} ${formatSelector(task.selector)}`;
1160
- if (attempted.has(id)) continue;
1161
- attempted.add(id);
1162
- wave.push(task);
1163
- }
1164
- if (wave.length === 0) break;
1165
- const results = yield* Effect.forEach(wave, (task) => resolveTask(state, task, options), { concurrency: options.concurrency });
1166
- const next = [];
1167
- for (const result of results) {
1168
- if (result === null) continue;
1169
- for (const { manifest, reason } of result) {
1170
- const key = packageKey(manifest.name, manifest.version);
1171
- if (!state.resolved.has(key)) {
1172
- state.resolved.set(key, {
1173
- name: manifest.name,
1174
- version: manifest.version,
1175
- manifest,
1176
- reasons: [reason]
1177
- });
1178
- yield* emit({
1179
- _tag: "PackageResolved",
1180
- name: manifest.name,
1181
- version: manifest.version,
1182
- resolvedCount: state.resolved.size,
1183
- pendingCount: next.length
1184
- });
1185
- if (manifest.deprecated !== void 0 && manifest.deprecated !== "") addWarning(state, {
1186
- from: key,
1187
- message: `${key} is deprecated: ${manifest.deprecated}`
1188
- });
1189
- } else {
1190
- const existing = state.resolved.get(key);
1191
- if (!hasReason(existing.reasons, reason)) state.resolved.set(key, {
1192
- ...existing,
1193
- reasons: [...existing.reasons, reason]
1194
- });
1195
- }
1196
- if (!discovered.has(key)) {
1197
- discovered.add(key);
1198
- next.push(...edgesFor(state, manifest, options));
1199
- }
1200
- }
1201
- }
1202
- frontier = next;
2500
+ const parsePackageJson = (filePath, content) => {
2501
+ let parsed;
2502
+ try {
2503
+ parsed = JSON.parse(content);
2504
+ } catch (error) {
2505
+ throw new InvalidInputFileError(filePath, `not valid JSON — ${describeError(error)}`, { cause: error });
1203
2506
  }
1204
- return [...discovered];
1205
- });
1206
- const hasReason = (reasons, candidate) => reasons.some((reason) => {
1207
- if (reason._tag === "Root" && candidate._tag === "Root") return reason.spec === candidate.spec;
1208
- if (reason._tag === "Edge" && candidate._tag === "Edge") return reason.from === candidate.from && reason.kind === candidate.kind;
1209
- return false;
1210
- });
2507
+ return Option.getOrElse(decodePackageJson(parsed), () => ({}));
2508
+ };
2509
+ const fromList = (filePath, content) => {
2510
+ const rawLines = content.split(/\r?\n/);
2511
+ const lines = rawLines.flatMap((line) => {
2512
+ const withoutComment = stripComment(line).trim();
2513
+ return withoutComment.length === 0 ? [] : withoutComment;
2514
+ });
2515
+ if (lines.length === 0) throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)");
2516
+ const { specs, errors } = parseSpecs(lines);
2517
+ if (errors.length > 0) throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines));
2518
+ return {
2519
+ kind: InputFileKind.List,
2520
+ specs,
2521
+ warnings: []
2522
+ };
2523
+ };
1211
2524
  /**
1212
- * Resolves one task to its manifest(s).
2525
+ * Strips `#` and `//` comments.
1213
2526
  *
1214
- * Returns `null` when an optional edge could not be resolved the caller
1215
- * treats that as "skip quietly", which is what npm does for
1216
- * `optionalDependencies`.
2527
+ * `//` only counts when it is not part of a URL, so a future line containing
2528
+ * `https://…` does not get silently truncated.
1217
2529
  */
1218
- const resolveTask = (state, task, options) => Effect.gen(function* () {
1219
- const packument = yield* getPackument(state, task.name);
1220
- const versions = yield* Effect.try({
1221
- try: () => selectVersions(packument, task.selector, {
1222
- all: false,
1223
- includePrerelease: options.includePrerelease
1224
- }),
1225
- catch: (error) => error
2530
+ const stripComment = (line) => {
2531
+ const hash = line.indexOf("#");
2532
+ const result = hash === -1 ? line : line.slice(0, hash);
2533
+ const slashes = result.indexOf("//");
2534
+ if (slashes === 0) return "";
2535
+ if (slashes > 0 && result[slashes - 1] !== ":") return result.slice(0, slashes);
2536
+ return result;
2537
+ };
2538
+ const formatSpecErrors = (errors, rawLines) => {
2539
+ const details = errors.map((error) => {
2540
+ const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec);
2541
+ const where = index === -1 ? "" : ` (line ${index + 1})`;
2542
+ return ` ${error.spec}${where}: ${error.reason}`;
1226
2543
  });
1227
- const out = [];
1228
- for (const version of versions) {
1229
- const manifest = packument.versions[version];
1230
- if (manifest === void 0) continue;
1231
- if (task.reason._tag === "Edge" && task.reason.kind === "optional" && !isIncluded(manifest, options.scope.platforms)) continue;
1232
- out.push({
1233
- manifest,
1234
- reason: task.reason
1235
- });
1236
- }
1237
- return out;
1238
- }).pipe(Effect.catch((error) => {
1239
- if (!task.tolerateFailure) return Effect.fail(error);
1240
- return Effect.sync(() => {
1241
- addWarning(state, { message: `Optional dependency ${task.name}@${formatSelector(task.selector)} could not be resolved and was skipped: ${error.message}` });
1242
- return null;
2544
+ return `${errors.length} invalid spec(s):\n${details.join("\n")}`;
2545
+ };
2546
+ const describeError = (error) => error instanceof Error ? error.message : String(error);
2547
+
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
1243
2563
  });
1244
- }));
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
+ //#endregion
2609
+ //#region src/download.ts
1245
2610
  /**
1246
- * Resolves every requested spec.
2611
+ * Downloads every package into `destDir`, laid out the way a registry serves
2612
+ * them.
1247
2613
  *
1248
- * Each selected root *version* is walked separately so `per-spec` layout has an
1249
- * exact closure per tarball, while all walks share one cache, so a package
1250
- * reached from twenty roots is fetched once.
2614
+ * `verifyIntegrity` defaulting to on is deliberate: a corrupt tarball that
2615
+ * makes it into a corporate registry is far more expensive than a failed run.
1251
2616
  */
1252
- const resolve = (specs, options) => Effect.gen(function* () {
2617
+ const downloadAll = (packages, destDir, options) => Effect.gen(function* () {
2618
+ const fs = yield* FileSystem.FileSystem;
2619
+ const path = yield* Path.Path;
2620
+ const registry = yield* Registry;
1253
2621
  yield* emit({
1254
2622
  _tag: "PhaseStarted",
1255
- phase: "resolve",
1256
- total: specs.length
2623
+ phase: Phase.Download,
2624
+ total: packages.length
1257
2625
  });
1258
- const state = makeWalkState();
1259
- const roots = [];
1260
- for (const spec of specs) {
1261
- const packument = yield* getPackument(state, spec.name);
1262
- const versions = yield* Effect.try({
1263
- try: () => selectVersions(packument, spec.selector, {
1264
- all: options.allVersions,
1265
- maxVersions: options.maxVersions,
1266
- includePrerelease: options.includePrerelease
1267
- }),
1268
- catch: (error) => error
2626
+ let completed = 0;
2627
+ const sizes = /* @__PURE__ */ new Map();
2628
+ const unverified = [];
2629
+ const results = yield* Effect.forEach(packages, (pkg) => Effect.gen(function* () {
2630
+ const key = `${pkg.name}@${pkg.version}`;
2631
+ yield* emit({
2632
+ _tag: "DownloadStarted",
2633
+ name: pkg.name,
2634
+ version: pkg.version
1269
2635
  });
1270
- const closures = /* @__PURE__ */ new Map();
1271
- const union = /* @__PURE__ */ new Set();
1272
- for (const version of versions) {
1273
- const closure = yield* walk(state, [{
1274
- name: spec.name,
1275
- selector: {
1276
- _tag: "Exact",
1277
- version
1278
- },
1279
- reason: {
1280
- _tag: "Root",
1281
- spec: formatSpec(spec)
1282
- },
1283
- tolerateFailure: false
1284
- }], options);
1285
- closures.set(version, closure);
1286
- for (const key of closure) union.add(key);
2636
+ const bytes = yield* registry.download(pkg.manifest);
2637
+ if (options.verifyIntegrity) {
2638
+ const result = verify(bytes, pkg.manifest.dist);
2639
+ if (result._tag === "Mismatch") return yield* Effect.fail(new IntegrityError(pkg.name, pkg.version, result.expected, result.actual));
2640
+ if (result._tag === "Unverifiable") yield* emit({
2641
+ _tag: "Warning",
2642
+ message: `${key} could not be verified (${result.reason})`
2643
+ });
1287
2644
  }
1288
- roots.push({
1289
- spec,
1290
- versions,
1291
- closures,
1292
- closure: [...union]
2645
+ const verifiable = (pkg.manifest.dist.integrity?.length ?? 0) > 0 || (pkg.manifest.dist.shasum?.length ?? 0) > 0;
2646
+ const relative = packagePath(pkg.name, pkg.version);
2647
+ const target = path.join(destDir, ...relative.split("/"));
2648
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => new OutputError(path.dirname(target), "could not create directory", { cause })));
2649
+ yield* fs.writeFile(target, bytes).pipe(Effect.mapError((cause) => new OutputError(target, "could not write tarball", { cause })));
2650
+ completed += 1;
2651
+ yield* emit({
2652
+ _tag: "DownloadCompleted",
2653
+ name: pkg.name,
2654
+ version: pkg.version,
2655
+ bytes: bytes.byteLength,
2656
+ completedCount: completed,
2657
+ totalCount: packages.length
1293
2658
  });
2659
+ return {
2660
+ key,
2661
+ bytes: bytes.byteLength,
2662
+ verifiable
2663
+ };
2664
+ }), { concurrency: options.concurrency });
2665
+ for (const result of results) {
2666
+ sizes.set(result.key, result.bytes);
2667
+ if (!result.verifiable) unverified.push(result.key);
1294
2668
  }
1295
2669
  yield* emit({
1296
2670
  _tag: "PhaseCompleted",
1297
- phase: "resolve"
2671
+ phase: Phase.Download
1298
2672
  });
1299
2673
  return {
1300
- roots,
1301
- packages: [...state.resolved.values()].sort(compareResolved),
1302
- warnings: state.warnings
2674
+ sizes,
2675
+ unverified,
2676
+ totalBytes: results.reduce((sum, result) => sum + result.bytes, 0)
1303
2677
  };
1304
2678
  });
1305
- const compareResolved = (a, b) => {
1306
- if (a.name !== b.name) return a.name < b.name ? -1 : 1;
1307
- return semver.valid(a.version) && semver.valid(b.version) ? semver.compare(a.version, b.version) : a.version.localeCompare(b.version);
1308
- };
1309
2679
 
1310
2680
  //#endregion
1311
- //#region src/Bundle.ts
1312
- var Bundle_exports = /* @__PURE__ */ __export({
1313
- bundle: () => bundle,
1314
- bundleResolved: () => bundleResolved,
1315
- plan: () => plan,
1316
- plannedOutputs: () => plannedOutputs,
1317
- summarize: () => summarize,
1318
- withLayout: () => withLayout
1319
- });
2681
+ //#region src/bundle.ts
1320
2682
  /** Computes the plan summary for a resolution. */
1321
2683
  const summarize = (resolution) => {
1322
2684
  let perSpecArchives = 0;
@@ -1345,17 +2707,33 @@ const withLayout = (options, layout) => ({
1345
2707
  * expected.
1346
2708
  */
1347
2709
  const plan = (specs, options) => Effect.gen(function* () {
2710
+ yield* preflight;
2711
+ return yield* resolve(specs, options);
2712
+ });
2713
+ /**
2714
+ * The lockfile counterpart of `plan`.
2715
+ *
2716
+ * Same preflight, same `Resolution` out; the difference is entirely in how the
2717
+ * package set is arrived at. Kept as a separate entry point rather than an
2718
+ * option on `plan` because the two take genuinely different inputs — a set of
2719
+ * specs to satisfy versus a graph to reproduce — and blurring that is how a
2720
+ * "pinned" run quietly starts resolving ranges again.
2721
+ */
2722
+ const planLocked = (lockfile, options) => Effect.gen(function* () {
2723
+ yield* preflight;
2724
+ return yield* resolveLocked(lockfile, options);
2725
+ });
2726
+ const preflight = Effect.gen(function* () {
1348
2727
  const registry = yield* Registry;
1349
2728
  yield* emit({
1350
2729
  _tag: "PhaseStarted",
1351
- phase: "preflight"
2730
+ phase: Phase.Preflight
1352
2731
  });
1353
2732
  yield* registry.preflight;
1354
2733
  yield* emit({
1355
2734
  _tag: "PhaseCompleted",
1356
- phase: "preflight"
2735
+ phase: Phase.Preflight
1357
2736
  });
1358
- return yield* resolve(specs, options);
1359
2737
  });
1360
2738
  /**
1361
2739
  * Resolves, downloads and packages a set of specs.
@@ -1367,6 +2745,10 @@ const plan = (specs, options) => Effect.gen(function* () {
1367
2745
  const bundle = (specs, options) => Effect.gen(function* () {
1368
2746
  return yield* bundleResolved(yield* plan(specs, options), options);
1369
2747
  });
2748
+ /** `bundle`, pinned to a lockfile. */
2749
+ const bundleLocked = (lockfile, options) => Effect.gen(function* () {
2750
+ return yield* bundleResolved(yield* planLocked(lockfile, options), options);
2751
+ });
1370
2752
  /**
1371
2753
  * Downloads and packages an already-computed resolution.
1372
2754
  *
@@ -1384,6 +2766,21 @@ const bundleResolved = (resolution, options) => Effect.gen(function* () {
1384
2766
  };
1385
2767
  return yield* Effect.scoped(runBundle(resolution, options));
1386
2768
  });
2769
+ /**
2770
+ * The files a run will write.
2771
+ *
2772
+ * Computed before anything is downloaded, so a collision is reported in the
2773
+ * first second rather than after a twenty-minute transfer.
2774
+ */
2775
+ const plannedOutputs = (resolution, options) => {
2776
+ if (options.layout === Layout.Dir) return [{ file: MANIFEST_FILE }, { file: README_FILE }];
2777
+ if (options.layout === Layout.Single) return [{ file: singleArchiveName(options.archiveName ?? "bundle") }];
2778
+ return resolution.roots.flatMap((root) => root.versions.map((version) => ({
2779
+ file: perSpecArchiveName(root.spec.name, version),
2780
+ name: root.spec.name,
2781
+ version
2782
+ })));
2783
+ };
1387
2784
  const runBundle = (resolution, options) => Effect.gen(function* () {
1388
2785
  const fs = yield* FileSystem.FileSystem;
1389
2786
  const path = yield* Path.Path;
@@ -1398,42 +2795,25 @@ const runBundle = (resolution, options) => Effect.gen(function* () {
1398
2795
  });
1399
2796
  yield* emit({
1400
2797
  _tag: "PhaseStarted",
1401
- phase: "archive"
2798
+ phase: Phase.Archive
1402
2799
  });
1403
- const registryInfo = {
1404
- kind: registry.kind,
1405
- url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
1406
- };
1407
- const index = indexPackages(resolution);
1408
- const artifacts = options.layout === "single" ? yield* emitSingle({
1409
- resolution,
1410
- options,
1411
- staging,
1412
- packagesDir,
1413
- report,
1414
- registryInfo
1415
- }) : options.layout === "dir" ? yield* emitDirectory({
1416
- resolution,
1417
- options,
1418
- packagesDir,
1419
- report,
1420
- registryInfo
1421
- }) : yield* emitPerSpec({
2800
+ const artifacts = yield* emit$1({
1422
2801
  resolution,
1423
2802
  options,
1424
- staging,
1425
2803
  packagesDir,
1426
2804
  report,
1427
- registryInfo,
1428
- index
1429
- });
2805
+ registryInfo: {
2806
+ kind: registry.kind,
2807
+ url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
2808
+ }
2809
+ }, staging);
1430
2810
  yield* emit({
1431
2811
  _tag: "PhaseCompleted",
1432
- phase: "archive"
2812
+ phase: Phase.Archive
1433
2813
  });
1434
2814
  yield* emit({
1435
2815
  _tag: "PhaseStarted",
1436
- phase: "done"
2816
+ phase: Phase.Done
1437
2817
  });
1438
2818
  return {
1439
2819
  resolution,
@@ -1443,6 +2823,13 @@ const runBundle = (resolution, options) => Effect.gen(function* () {
1443
2823
  dryRun: false
1444
2824
  };
1445
2825
  });
2826
+ const emit$1 = (input, staging) => {
2827
+ switch (input.options.layout) {
2828
+ case Layout.Single: return emitSingle(input);
2829
+ case Layout.Dir: return emitDirectory(input);
2830
+ case Layout.PerSpec: return emitPerSpec(input, staging);
2831
+ }
2832
+ };
1446
2833
  /** One tarball containing the deduplicated union of every closure. */
1447
2834
  const emitSingle = (input) => Effect.gen(function* () {
1448
2835
  const path = yield* Path.Path;
@@ -1463,7 +2850,7 @@ const emitSingle = (input) => Effect.gen(function* () {
1463
2850
  outPath
1464
2851
  });
1465
2852
  return [{
1466
- kind: "archive",
2853
+ kind: ArtifactKind.Archive,
1467
2854
  path: result.path,
1468
2855
  bytes: result.bytes,
1469
2856
  packageCount: input.resolution.packages.length
@@ -1483,7 +2870,7 @@ const emitDirectory = (input) => Effect.gen(function* () {
1483
2870
  yield* fs.copy(input.packagesDir, input.options.outDir, { overwrite: true }).pipe(Effect.mapError((cause) => new OutputError(input.options.outDir, "could not write output tree", { cause })));
1484
2871
  const bytes = [...input.report.sizes.values()].reduce((a, b) => a + b, 0);
1485
2872
  return [{
1486
- kind: "directory",
2873
+ kind: ArtifactKind.Directory,
1487
2874
  path: input.options.outDir,
1488
2875
  bytes,
1489
2876
  packageCount: input.resolution.packages.length
@@ -1496,15 +2883,16 @@ const emitDirectory = (input) => Effect.gen(function* () {
1496
2883
  * `react-18.1.0.tgz` and so on, each independently importable — which is the
1497
2884
  * whole reason to prefer this layout.
1498
2885
  */
1499
- const emitPerSpec = (input) => Effect.gen(function* () {
2886
+ const emitPerSpec = (input, staging) => Effect.gen(function* () {
1500
2887
  const fs = yield* FileSystem.FileSystem;
1501
2888
  const path = yield* Path.Path;
2889
+ const index = indexPackages(input.resolution);
1502
2890
  const artifacts = [];
1503
2891
  let counter = 0;
1504
2892
  for (const root of input.resolution.roots) for (const version of root.versions) {
1505
2893
  if (input.options.skipExisting?.has(perSpecArchiveName(root.spec.name, version)) === true) continue;
1506
- const included = (root.closures.get(version) ?? []).map((key) => input.index.get(key)).filter((pkg) => pkg !== void 0);
1507
- const rootDir = path.join(input.staging, "roots", String(counter++));
2894
+ const included = includedPackages(index, root.closures.get(version) ?? []);
2895
+ const rootDir = path.join(staging, "roots", String(counter++));
1508
2896
  yield* fs.makeDirectory(rootDir, { recursive: true });
1509
2897
  for (const pkg of included) {
1510
2898
  const parts = packagePath(pkg.name, pkg.version).split("/");
@@ -1534,7 +2922,7 @@ const emitPerSpec = (input) => Effect.gen(function* () {
1534
2922
  outPath
1535
2923
  });
1536
2924
  artifacts.push({
1537
- kind: "archive",
2925
+ kind: ArtifactKind.Archive,
1538
2926
  path: result.path,
1539
2927
  bytes: result.bytes,
1540
2928
  packageCount: included.length,
@@ -1544,6 +2932,7 @@ const emitPerSpec = (input) => Effect.gen(function* () {
1544
2932
  }
1545
2933
  return artifacts;
1546
2934
  });
2935
+ const includedPackages = (index, closure) => closure.flatMap((key) => index.get(key) ?? []);
1547
2936
  /**
1548
2937
  * Hard-links a file, falling back to a copy.
1549
2938
  *
@@ -1578,17 +2967,8 @@ const writeBundleMetadata = (input) => Effect.gen(function* () {
1578
2967
  });
1579
2968
  /** Sorted top-level entries of a directory, used as the tar entry list. */
1580
2969
  const topLevelEntries = (dir) => Effect.gen(function* () {
1581
- return [...yield* (yield* FileSystem.FileSystem).readDirectory(dir)].sort();
2970
+ return (yield* (yield* FileSystem.FileSystem).readDirectory(dir)).toSorted();
1582
2971
  });
1583
- const plannedOutputs = (resolution, options) => {
1584
- if (options.layout === "dir") return [{ file: MANIFEST_FILE }, { file: README_FILE }];
1585
- if (options.layout === "single") return [{ file: singleArchiveName(options.archiveName ?? "bundle") }];
1586
- return resolution.roots.flatMap((root) => root.versions.map((version) => ({
1587
- file: perSpecArchiveName(root.spec.name, version),
1588
- name: root.spec.name,
1589
- version
1590
- })));
1591
- };
1592
2972
  /**
1593
2973
  * Fails if the run would overwrite something, unless `--force`.
1594
2974
  *
@@ -1618,149 +2998,5 @@ const prepareOutputDir = (resolution, options) => Effect.gen(function* () {
1618
2998
  });
1619
2999
 
1620
3000
  //#endregion
1621
- //#region src/InputFile.ts
1622
- var InputFile_exports = /* @__PURE__ */ __export({
1623
- defaultInputFileOptions: () => defaultInputFileOptions,
1624
- parseInputFile: () => parseInputFile,
1625
- readInputFile: () => readInputFile
1626
- });
1627
- const defaultInputFileOptions = { includeDev: true };
1628
- /** Reads and parses an input file. */
1629
- const readInputFile = (filePath, options = defaultInputFileOptions) => Effect.gen(function* () {
1630
- const fs = yield* FileSystem.FileSystem;
1631
- if (!(yield* fs.exists(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause }))))) return yield* Effect.fail(new InvalidInputFileError(filePath, "file does not exist"));
1632
- const content = yield* fs.readFileString(filePath).pipe(Effect.mapError((cause) => new InvalidInputFileError(filePath, "could not be read", { cause })));
1633
- return yield* Effect.try({
1634
- try: () => parseInputFile(filePath, content, options),
1635
- catch: (error) => error instanceof InvalidInputFileError ? error : new InvalidInputFileError(filePath, describe(error), { cause: error })
1636
- });
1637
- });
1638
- /**
1639
- * Parses file content that has already been read.
1640
- *
1641
- * Split out from the IO so the whole of this logic is testable with plain
1642
- * strings and no file system at all.
1643
- */
1644
- const parseInputFile = (filePath, content, options = defaultInputFileOptions) => {
1645
- const trimmed = content.trim();
1646
- if (trimmed.length === 0) throw new InvalidInputFileError(filePath, "file is empty");
1647
- return looksLikeJson(trimmed) ? fromPackageJson(filePath, trimmed, options) : fromList(filePath, content);
1648
- };
1649
- const looksLikeJson = (trimmed) => trimmed.startsWith("{");
1650
- const fromPackageJson = (filePath, content, options) => {
1651
- let parsed;
1652
- try {
1653
- parsed = JSON.parse(content);
1654
- } catch (error) {
1655
- throw new InvalidInputFileError(filePath, `not valid JSON — ${describe(error)}`, { cause: error });
1656
- }
1657
- const specs = [];
1658
- const warnings = [];
1659
- const seen = /* @__PURE__ */ new Set();
1660
- const collect = (entries, label, skip) => {
1661
- if (entries === void 0) return;
1662
- for (const [name, raw] of Object.entries(entries)) {
1663
- if (skip?.(name)) continue;
1664
- const target = parseDependencyTarget(name, raw);
1665
- if (target._tag === "Unsupported") {
1666
- warnings.push(`${label}: skipped ${name}@${target.raw} (${target.reason}) — not fetchable from a registry`);
1667
- continue;
1668
- }
1669
- const key = `${target.name}|${JSON.stringify(target.selector)}`;
1670
- if (seen.has(key)) continue;
1671
- seen.add(key);
1672
- specs.push({
1673
- name: target.name,
1674
- selector: target.selector,
1675
- raw: `${target.name}@${raw}`
1676
- });
1677
- }
1678
- };
1679
- collect(parsed.dependencies, "dependencies");
1680
- collect(parsed.optionalDependencies, "optionalDependencies");
1681
- if (options.includeDev) collect(parsed.devDependencies, "devDependencies");
1682
- const meta = parsed.peerDependenciesMeta ?? {};
1683
- collect(parsed.peerDependencies, "peerDependencies", (name) => meta[name]?.optional === true);
1684
- if (specs.length === 0) throw new InvalidInputFileError(filePath, options.includeDev ? "package.json declares no dependencies to bundle" : "package.json declares no non-dev dependencies to bundle (drop --prod to include devDependencies)");
1685
- return {
1686
- kind: "package.json",
1687
- specs,
1688
- warnings
1689
- };
1690
- };
1691
- const fromList = (filePath, content) => {
1692
- const lines = [];
1693
- const rawLines = content.split(/\r?\n/);
1694
- for (const line of rawLines) {
1695
- const withoutComment = stripComment(line).trim();
1696
- if (withoutComment.length === 0) continue;
1697
- lines.push(withoutComment);
1698
- }
1699
- if (lines.length === 0) throw new InvalidInputFileError(filePath, "no package specs found (only blank lines and comments)");
1700
- const { specs, errors } = parseSpecs(lines);
1701
- if (errors.length > 0) throw new InvalidInputFileError(filePath, formatSpecErrors(errors, rawLines));
1702
- return {
1703
- kind: "list",
1704
- specs,
1705
- warnings: []
1706
- };
1707
- };
1708
- /**
1709
- * Strips `#` and `//` comments.
1710
- *
1711
- * `//` only counts when it is not part of a URL, so a future line containing
1712
- * `https://…` does not get silently truncated.
1713
- */
1714
- const stripComment = (line) => {
1715
- const hash = line.indexOf("#");
1716
- let result = hash === -1 ? line : line.slice(0, hash);
1717
- const slashes = result.indexOf("//");
1718
- if (slashes > 0 && result[slashes - 1] !== ":") result = result.slice(0, slashes);
1719
- else if (slashes === 0) result = "";
1720
- return result;
1721
- };
1722
- const formatSpecErrors = (errors, rawLines) => {
1723
- const details = errors.map((error) => {
1724
- const index = rawLines.findIndex((line) => stripComment(line).trim() === error.spec);
1725
- const where = index === -1 ? "" : ` (line ${index + 1})`;
1726
- return ` ${error.spec}${where}: ${error.reason}`;
1727
- });
1728
- return `${errors.length} invalid spec(s):\n${details.join("\n")}`;
1729
- };
1730
- const describe = (error) => error instanceof Error ? error.message : String(error);
1731
-
1732
- //#endregion
1733
- //#region src/Options.ts
1734
- var Options_exports = /* @__PURE__ */ __export({
1735
- defaultBundleOptions: () => defaultBundleOptions,
1736
- defaultResolveOptions: () => defaultResolveOptions,
1737
- defaultScope: () => defaultScope,
1738
- layouts: () => layouts
1739
- });
1740
- const layouts = [
1741
- "per-spec",
1742
- "single",
1743
- "dir"
1744
- ];
1745
- const defaultScope = {
1746
- optional: true,
1747
- peer: true,
1748
- platforms: allPlatforms
1749
- };
1750
- const defaultResolveOptions = {
1751
- scope: defaultScope,
1752
- allVersions: false,
1753
- includePrerelease: false,
1754
- concurrency: 10
1755
- };
1756
- const defaultBundleOptions = {
1757
- ...defaultResolveOptions,
1758
- layout: "per-spec",
1759
- dryRun: false,
1760
- verifyIntegrity: true,
1761
- force: false
1762
- };
1763
-
1764
- //#endregion
1765
- export { Archive_exports as Archive, ArchiveError, AuthenticationError, Bundle_exports as Bundle, DependencyRange_exports as DependencyRange, Download_exports as Download, InputFile_exports as InputFile, Integrity_exports as Integrity, IntegrityError, InvalidInputFileError, InvalidSpecError, Layout_exports as Layout, Manifest_exports as Manifest, NoMatchingVersionsError, Options_exports as Options, OutputError, PackageNotFoundError, Platform_exports as Platform, Progress_exports as Progress, Progress as ProgressTag, Registry, RegistryResponseError, RegistryUnreachableError, Resolve_exports as Resolve, Spec_exports as Spec, VersionNotFoundError, allPlatforms, bundle, bundleResolved, currentPlatform, defaultBundleOptions, defaultResolveOptions, defaultScope, layouts, parsePlatformTarget, parseSpec, parseSpecs, plan, plannedOutputs, platformTargets, resolve, summarize, withLayout };
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 };
1766
3002
  //# sourceMappingURL=index.js.map