@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
@@ -1,570 +1,616 @@
1
- /**
2
- * End-to-end orchestration: specs in, tarballs out.
3
- *
4
- * Two properties this module is responsible for, both of them fixes for how
5
- * the original tool behaved:
6
- *
7
- * 1. **Nothing is written outside the output directory.** Every intermediate
8
- * file lives in a scoped temp directory obtained from
9
- * `makeTempDirectoryScoped`, so the scope closing — on success, on failure,
10
- * or on Ctrl-C — takes the whole staging tree with it. The working
11
- * directory is never touched.
12
- *
13
- * 2. **Each package is downloaded exactly once**, no matter how many bundles
14
- * it ends up in. Per-spec archives are assembled by hard-linking out of one
15
- * shared download tree, so `per-spec` costs extra disk in the *output*, not
16
- * extra network.
17
- */
18
- import * as Effect from "effect/Effect"
19
- import * as FileSystem from "effect/FileSystem"
20
- import * as Path from "effect/Path"
21
- import type { PlatformError } from "effect/PlatformError"
22
- import type { Scope } from "effect/Scope"
23
- import { createArchive } from "./Archive.js"
24
- import { downloadAll } from "./Download.js"
25
- import type { BundlerError } from "./Errors.js"
26
- import { OutputError } from "./Errors.js"
27
- import {
28
- importGuide,
29
- MANIFEST_FILE,
30
- packagePath,
31
- perSpecArchiveName,
32
- README_FILE,
33
- singleArchiveName
34
- } from "./Layout.js"
35
- import { buildManifest, serializeManifest } from "./Manifest.js"
36
- import type { BundleOptions, Layout } from "./Options.js"
37
- import * as Progress from "./Progress.js"
38
- import { Registry } from "./Registry.js"
39
- import type { PackageKey, Resolution, ResolvedPackage } from "./Resolve.js"
40
- import { indexPackages, resolve } from "./Resolve.js"
41
- import type { PackageSpec } from "./Spec.js"
42
- import { formatSpec } from "./Spec.js"
43
-
44
- /** One thing written to the output directory. */
45
- export interface BundleArtifact {
46
- readonly kind: "archive" | "directory"
47
- readonly path: string
48
- readonly bytes: number
49
- readonly packageCount: number
50
- /** Which spec this artifact covers. Absent for `single` and `dir`. */
51
- readonly spec?: string | undefined
52
- readonly version?: string | undefined
53
- }
54
-
55
- /** The outcome of a bundle run. */
56
- export interface BundleResult {
57
- readonly resolution: Resolution
58
- readonly artifacts: ReadonlyArray<BundleArtifact>
59
- /** Total bytes downloaded from the registry. */
60
- readonly downloadedBytes: number
61
- /** Packages the registry advertised no usable checksum for. */
62
- readonly unverified: ReadonlyArray<string>
63
- readonly dryRun: boolean
64
- }
65
-
66
- /** Everything `bundle` needs beyond the user-facing options. */
67
- export interface BundleContext extends BundleOptions {
68
- /** Recorded in the manifest so a bundle can be traced to the tool that built it. */
69
- readonly toolVersion: string
70
- }
71
-
72
- /**
73
- * A summary of what a resolution would produce, for deciding whether the
74
- * chosen layout is still the right one.
75
- */
76
- export interface PlanSummary {
77
- /** Distinct packages in the deduplicated union. */
78
- readonly uniquePackages: number
79
- /** How many archives `per-spec` layout would emit. */
80
- readonly perSpecArchives: number
81
- /**
82
- * Total package entries across all per-spec archives, counting shared
83
- * dependencies once per archive they appear in. The gap between this and
84
- * `uniquePackages` is exactly what `single` layout would save.
85
- */
86
- readonly perSpecEntries: number
87
- }
88
-
89
- /** Computes the plan summary for a resolution. */
90
- export const summarize = (resolution: Resolution): PlanSummary => {
91
- let perSpecArchives = 0
92
- let perSpecEntries = 0
93
- for (const root of resolution.roots) {
94
- for (const version of root.versions) {
95
- perSpecArchives += 1
96
- perSpecEntries += (root.closures.get(version) ?? []).length
97
- }
98
- }
99
- return {
100
- uniquePackages: resolution.packages.length,
101
- perSpecArchives,
102
- perSpecEntries
103
- }
104
- }
105
-
106
- /** Replaces the layout on a set of options. */
107
- export const withLayout = <T extends BundleOptions>(options: T, layout: Layout): T => ({
108
- ...options,
109
- layout
110
- })
111
-
112
- type BundleEnv = Registry | Progress.Progress | FileSystem.FileSystem | Path.Path
113
-
114
- /**
115
- * Checks the registry is reachable, then resolves every spec.
116
- *
117
- * Split out from `bundle` so a caller can inspect the plan — how many packages,
118
- * how many archives and act on it before any bytes move. The CLI uses this to
119
- * offer a different layout when a run turns out to be much larger than
120
- * expected.
121
- */
122
- export const plan = (
123
- specs: ReadonlyArray<PackageSpec>,
124
- options: BundleOptions
125
- ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
126
- Effect.gen(function*() {
127
- const registry = yield* Registry
128
-
129
- yield* Progress.emit({ _tag: "PhaseStarted", phase: "preflight" })
130
- yield* registry.preflight
131
- yield* Progress.emit({ _tag: "PhaseCompleted", phase: "preflight" })
132
-
133
- return yield* resolve(specs, options)
134
- })
135
-
136
- /**
137
- * Resolves, downloads and packages a set of specs.
138
- *
139
- * Under `dryRun` this stops after resolution and reports the plan — useful for
140
- * checking what a command *would* pull before committing to a multi-gigabyte
141
- * download over a slow VPN.
142
- */
143
- export const bundle = (
144
- specs: ReadonlyArray<PackageSpec>,
145
- options: BundleContext
146
- ): Effect.Effect<BundleResult, BundlerError | PlatformError, BundleEnv> =>
147
- Effect.gen(function*() {
148
- const resolution = yield* plan(specs, options)
149
- return yield* bundleResolved(resolution, options)
150
- })
151
-
152
- /**
153
- * Downloads and packages an already-computed resolution.
154
- *
155
- * Everything from here on touches the disk, and all of it happens inside
156
- * `Effect.scoped` — so the staging tree's lifetime is exactly this call,
157
- * including when it fails partway through or the user hits Ctrl-C.
158
- */
159
- export const bundleResolved = (
160
- resolution: Resolution,
161
- options: BundleContext
162
- ): Effect.Effect<BundleResult, BundlerError | PlatformError, BundleEnv> =>
163
- Effect.gen(function*() {
164
- if (options.dryRun) {
165
- return {
166
- resolution,
167
- artifacts: [],
168
- downloadedBytes: 0,
169
- unverified: [],
170
- dryRun: true
171
- }
172
- }
173
- return yield* Effect.scoped(runBundle(resolution, options))
174
- })
175
-
176
- const runBundle = (
177
- resolution: Resolution,
178
- options: BundleContext
179
- ): Effect.Effect<
180
- BundleResult,
181
- BundlerError | PlatformError,
182
- BundleEnv | Scope
183
- > =>
184
- Effect.gen(function*() {
185
- const fs = yield* FileSystem.FileSystem
186
- const path = yield* Path.Path
187
- const registry = yield* Registry
188
-
189
- yield* prepareOutputDir(resolution, options)
190
-
191
- const staging = yield* fs.makeTempDirectoryScoped({ prefix: "packall-" })
192
- const packagesDir = path.join(staging, "packages")
193
- yield* fs.makeDirectory(packagesDir, { recursive: true })
194
-
195
- const report = yield* downloadAll(resolution.packages, packagesDir, {
196
- concurrency: options.concurrency,
197
- verifyIntegrity: options.verifyIntegrity
198
- })
199
-
200
- yield* Progress.emit({ _tag: "PhaseStarted", phase: "archive" })
201
-
202
- const registryInfo = {
203
- kind: registry.kind,
204
- url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
205
- }
206
-
207
- const index = indexPackages(resolution)
208
-
209
- const artifacts =
210
- options.layout === "single"
211
- ? yield* emitSingle({ resolution, options, staging, packagesDir, report, registryInfo })
212
- : options.layout === "dir"
213
- ? yield* emitDirectory({ resolution, options, packagesDir, report, registryInfo })
214
- : yield* emitPerSpec({
215
- resolution,
216
- options,
217
- staging,
218
- packagesDir,
219
- report,
220
- registryInfo,
221
- index
222
- })
223
-
224
- yield* Progress.emit({ _tag: "PhaseCompleted", phase: "archive" })
225
- yield* Progress.emit({ _tag: "PhaseStarted", phase: "done" })
226
-
227
- return {
228
- resolution,
229
- artifacts,
230
- downloadedBytes: report.totalBytes,
231
- unverified: report.unverified,
232
- dryRun: false
233
- }
234
- })
235
-
236
- /* -------------------------------------------------------------------------- */
237
- /* Emitters */
238
- /* -------------------------------------------------------------------------- */
239
-
240
- interface EmitInput {
241
- readonly resolution: Resolution
242
- readonly options: BundleContext
243
- readonly packagesDir: string
244
- readonly report: { readonly sizes: ReadonlyMap<string, number> }
245
- readonly registryInfo: { readonly kind: string; readonly url: string }
246
- }
247
-
248
- /** One tarball containing the deduplicated union of every closure. */
249
- const emitSingle = (
250
- input: EmitInput & { readonly staging: string }
251
- ): Effect.Effect<
252
- ReadonlyArray<BundleArtifact>,
253
- BundlerError | PlatformError,
254
- FileSystem.FileSystem | Path.Path | Progress.Progress
255
- > =>
256
- Effect.gen(function*() {
257
- const path = yield* Path.Path
258
-
259
- const archiveName = singleArchiveName(input.options.archiveName ?? "bundle")
260
- // Declined at the prompt, and it is the run's only output.
261
- if (input.options.skipExisting?.has(archiveName) === true) return []
262
-
263
- yield* writeBundleMetadata({
264
- dir: input.packagesDir,
265
- resolution: input.resolution,
266
- included: input.resolution.packages,
267
- options: input.options,
268
- registryInfo: input.registryInfo,
269
- sizes: input.report.sizes
270
- })
271
-
272
- const outPath = path.join(input.options.outDir, archiveName)
273
-
274
- const result = yield* createArchive({
275
- cwd: input.packagesDir,
276
- entries: yield* topLevelEntries(input.packagesDir),
277
- outPath
278
- })
279
-
280
- return [
281
- {
282
- kind: "archive" as const,
283
- path: result.path,
284
- bytes: result.bytes,
285
- packageCount: input.resolution.packages.length
286
- }
287
- ]
288
- })
289
-
290
- /** The raw npm-layout tree, no archive. */
291
- const emitDirectory = (
292
- input: EmitInput
293
- ): Effect.Effect<
294
- ReadonlyArray<BundleArtifact>,
295
- BundlerError | PlatformError,
296
- FileSystem.FileSystem | Path.Path
297
- > =>
298
- Effect.gen(function*() {
299
- const fs = yield* FileSystem.FileSystem
300
-
301
- yield* writeBundleMetadata({
302
- dir: input.packagesDir,
303
- resolution: input.resolution,
304
- included: input.resolution.packages,
305
- options: input.options,
306
- registryInfo: input.registryInfo,
307
- sizes: input.report.sizes
308
- })
309
-
310
- // The staging tree is about to be deleted with its scope, so this is a
311
- // real copy rather than a move — `copy` also works across devices, which
312
- // matters because the temp dir often lives on a different filesystem.
313
- yield* fs.copy(input.packagesDir, input.options.outDir, { overwrite: true }).pipe(
314
- Effect.mapError(
315
- (cause) => new OutputError(input.options.outDir, "could not write output tree", { cause })
316
- )
317
- )
318
-
319
- const bytes = [...input.report.sizes.values()].reduce((a, b) => a + b, 0)
320
-
321
- return [
322
- {
323
- kind: "directory" as const,
324
- path: input.options.outDir,
325
- bytes,
326
- packageCount: input.resolution.packages.length
327
- }
328
- ]
329
- })
330
-
331
- /**
332
- * One tarball per resolved root *version*.
333
- *
334
- * Under `--all-versions react@^18` this produces `react-18.0.0.tgz`,
335
- * `react-18.1.0.tgz` and so on, each independently importable — which is the
336
- * whole reason to prefer this layout.
337
- */
338
- const emitPerSpec = (
339
- input: EmitInput & {
340
- readonly staging: string
341
- readonly index: ReadonlyMap<PackageKey, ResolvedPackage>
342
- }
343
- ): Effect.Effect<
344
- ReadonlyArray<BundleArtifact>,
345
- BundlerError | PlatformError,
346
- FileSystem.FileSystem | Path.Path | Progress.Progress
347
- > =>
348
- Effect.gen(function*() {
349
- const fs = yield* FileSystem.FileSystem
350
- const path = yield* Path.Path
351
- const artifacts: Array<BundleArtifact> = []
352
-
353
- let counter = 0
354
- for (const root of input.resolution.roots) {
355
- for (const version of root.versions) {
356
- // Declined at the prompt: the existing file stays, and this archive is
357
- // simply never built. Skipping the staging work too, not just the write.
358
- if (input.options.skipExisting?.has(perSpecArchiveName(root.spec.name, version)) === true) {
359
- continue
360
- }
361
-
362
- const closure = root.closures.get(version) ?? []
363
- const included = closure
364
- .map((key) => input.index.get(key))
365
- .filter((pkg): pkg is ResolvedPackage => pkg !== undefined)
366
-
367
- const rootDir = path.join(input.staging, "roots", String(counter++))
368
- yield* fs.makeDirectory(rootDir, { recursive: true })
369
-
370
- for (const pkg of included) {
371
- const relative = packagePath(pkg.name, pkg.version)
372
- const parts = relative.split("/")
373
- const source = path.join(input.packagesDir, ...parts)
374
- const target = path.join(rootDir, ...parts)
375
- yield* fs.makeDirectory(path.dirname(target), { recursive: true })
376
- yield* linkOrCopy(source, target)
377
- }
378
-
379
- yield* writeBundleMetadata({
380
- dir: rootDir,
381
- resolution: {
382
- ...input.resolution,
383
- roots: [{ ...root, versions: [version] }]
384
- },
385
- included,
386
- options: input.options,
387
- registryInfo: input.registryInfo,
388
- sizes: input.report.sizes
389
- })
390
-
391
- const outPath = path.join(
392
- input.options.outDir,
393
- perSpecArchiveName(root.spec.name, version)
394
- )
395
-
396
- const result = yield* createArchive({
397
- cwd: rootDir,
398
- entries: yield* topLevelEntries(rootDir),
399
- outPath
400
- })
401
-
402
- artifacts.push({
403
- kind: "archive",
404
- path: result.path,
405
- bytes: result.bytes,
406
- packageCount: included.length,
407
- spec: formatSpec(root.spec),
408
- version
409
- })
410
- }
411
- }
412
-
413
- return artifacts
414
- })
415
-
416
- /* -------------------------------------------------------------------------- */
417
- /* Helpers */
418
- /* -------------------------------------------------------------------------- */
419
-
420
- /**
421
- * Hard-links a file, falling back to a copy.
422
- *
423
- * Hard links make `per-spec` layout nearly free in the staging tree. They fail
424
- * across devices and on some Windows configurations, so the copy fallback is
425
- * not optional.
426
- */
427
- const linkOrCopy = (
428
- source: string,
429
- target: string
430
- ): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
431
- Effect.gen(function*() {
432
- const fs = yield* FileSystem.FileSystem
433
- yield* fs.link(source, target).pipe(
434
- Effect.catch(() => fs.copyFile(source, target))
435
- )
436
- })
437
-
438
- /** Writes the manifest and import guide into a staging directory. */
439
- const writeBundleMetadata = (input: {
440
- readonly dir: string
441
- readonly resolution: Resolution
442
- readonly included: ReadonlyArray<ResolvedPackage>
443
- readonly options: BundleContext
444
- readonly registryInfo: { readonly kind: string; readonly url: string }
445
- readonly sizes: ReadonlyMap<string, number>
446
- }): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
447
- Effect.gen(function*() {
448
- const fs = yield* FileSystem.FileSystem
449
- const path = yield* Path.Path
450
-
451
- const createdAt = new Date()
452
- const manifest = buildManifest({
453
- resolution: input.resolution,
454
- included: input.included,
455
- options: input.options,
456
- registry: input.registryInfo,
457
- toolVersion: input.options.toolVersion,
458
- sizes: input.sizes,
459
- createdAt
460
- })
461
-
462
- yield* fs.writeFileString(path.join(input.dir, MANIFEST_FILE), serializeManifest(manifest))
463
- yield* fs.writeFileString(
464
- path.join(input.dir, README_FILE),
465
- importGuide({
466
- packageCount: input.included.length,
467
- createdAt: createdAt.toISOString(),
468
- toolVersion: input.options.toolVersion
469
- })
470
- )
471
- })
472
-
473
- /** Sorted top-level entries of a directory, used as the tar entry list. */
474
- const topLevelEntries = (
475
- dir: string
476
- ): Effect.Effect<ReadonlyArray<string>, PlatformError, FileSystem.FileSystem> =>
477
- Effect.gen(function*() {
478
- const fs = yield* FileSystem.FileSystem
479
- const entries = yield* fs.readDirectory(dir)
480
- return [...entries].sort()
481
- })
482
-
483
- /**
484
- * Creates the output directory, refusing to clobber an existing non-empty one
485
- * unless `--force` was passed.
486
- *
487
- * Silently overwriting somebody's previous bundle is the kind of thing a tool
488
- * only gets forgiven for once.
489
- */
490
- /**
491
- * The files a run will write, known before anything is downloaded.
492
- *
493
- * `dir` layout is the odd one: it merges a package tree into `outDir`, and
494
- * package paths are `name/-/name-version.tgz` — the same package at the same
495
- * version is the same bytes, so an overlap there is idempotent rather than
496
- * destructive. Only the two summary files are genuinely replaced, and those are
497
- * the ones worth guarding.
498
- */
499
- export interface PlannedOutput {
500
- readonly file: string
501
- /** The root this file belongs to, when one file corresponds to one spec. */
502
- readonly name?: string | undefined
503
- readonly version?: string | undefined
504
- }
505
-
506
- export const plannedOutputs = (
507
- resolution: Resolution,
508
- options: BundleContext
509
- ): ReadonlyArray<PlannedOutput> => {
510
- if (options.layout === "dir") return [{ file: MANIFEST_FILE }, { file: README_FILE }]
511
- if (options.layout === "single") {
512
- return [{ file: singleArchiveName(options.archiveName ?? "bundle") }]
513
- }
514
- return resolution.roots.flatMap((root) =>
515
- root.versions.map((version) => ({
516
- file: perSpecArchiveName(root.spec.name, version),
517
- name: root.spec.name,
518
- version
519
- }))
520
- )
521
- }
522
-
523
- /**
524
- * Fails if the run would overwrite something, unless `--force`.
525
- *
526
- * The guard is on the *files this run writes*, not on whether the directory has
527
- * anything in it. An output directory accumulating bundles is the normal way to
528
- * use this — bundling `esbuild` into an `out/` that already holds `tsdown` is
529
- * not a conflict, and refusing it made `--out` a single-use directory and the
530
- * default `.` unusable.
531
- *
532
- * Checked before anything is downloaded. Discovering the collision *after* a
533
- * twenty-minute download would be a uniquely irritating way to fail.
534
- */
535
- const prepareOutputDir = (
536
- resolution: Resolution,
537
- options: BundleContext
538
- ): Effect.Effect<void, OutputError | PlatformError, FileSystem.FileSystem | Path.Path> =>
539
- Effect.gen(function*() {
540
- const fs = yield* FileSystem.FileSystem
541
- const path = yield* Path.Path
542
- const outDir = options.outDir
543
-
544
- if (!options.force) {
545
- const clashes: Array<string> = []
546
- for (const planned of plannedOutputs(resolution, options)) {
547
- if (options.skipExisting?.has(planned.file) === true) continue
548
- if (options.overwrite?.has(planned.file) === true) continue
549
- const exists = yield* fs
550
- .exists(path.join(outDir, planned.file))
551
- .pipe(Effect.orElseSucceed(() => false))
552
- if (exists) clashes.push(planned.file)
553
- }
554
-
555
- if (clashes.length > 0) {
556
- return yield* Effect.fail(
557
- new OutputError(
558
- outDir,
559
- `would overwrite ${clashes.length} existing file${clashes.length === 1 ? "" : "s"} ` +
560
- `(${clashes.slice(0, 3).join(", ")}${clashes.length > 3 ? ", …" : ""}). ` +
561
- `Pass --force true to replace ${clashes.length === 1 ? "it" : "them"}, or choose a different --out.`
562
- )
563
- )
564
- }
565
- }
566
-
567
- yield* fs.makeDirectory(outDir, { recursive: true }).pipe(
568
- Effect.mapError((cause) => new OutputError(outDir, "could not create output directory", { cause }))
569
- )
570
- })
1
+ /**
2
+ * End-to-end orchestration: specs in, tarballs out.
3
+ *
4
+ * Two properties this module is responsible for, both of them fixes for how
5
+ * the original tool behaved:
6
+ *
7
+ * 1. **Nothing is written outside the output directory.** Every intermediate
8
+ * file lives in a scoped temp directory obtained from
9
+ * `makeTempDirectoryScoped`, so the scope closing — on success, on failure,
10
+ * or on Ctrl-C — takes the whole staging tree with it. The working
11
+ * directory is never touched.
12
+ *
13
+ * 2. **Each package is downloaded exactly once**, no matter how many bundles
14
+ * it ends up in. Per-spec archives are assembled by hard-linking out of one
15
+ * shared download tree, so `per-spec` costs extra disk in the *output*, not
16
+ * extra network.
17
+ */
18
+ import * as Effect from "effect/Effect"
19
+ import * as FileSystem from "effect/FileSystem"
20
+ import * as Path from "effect/Path"
21
+ import type { PlatformError } from "effect/PlatformError"
22
+ import type { Scope } from "effect/Scope"
23
+ import { createArchive } from "./archive.js"
24
+ import { downloadAll } from "./download.js"
25
+ import { ArtifactKind } from "./enums/artifact-kind.js"
26
+ import { Layout } from "./enums/layout.js"
27
+ import { Phase } from "./enums/phase.js"
28
+ import type { BundlerError } from "./errors.js"
29
+ import { OutputError } from "./errors.js"
30
+ import {
31
+ importGuide,
32
+ MANIFEST_FILE,
33
+ packagePath,
34
+ perSpecArchiveName,
35
+ README_FILE,
36
+ singleArchiveName
37
+ } from "./layout.js"
38
+ import type { LockedResolveOptions } from "./locked-resolve.js"
39
+ import { resolveLocked } from "./locked-resolve.js"
40
+ import type { LockedTree } from "./lockfile/types.js"
41
+ import { buildManifest, serializeManifest } from "./manifest.js"
42
+ import type { BundleOptions } from "./options.js"
43
+ import * as Progress from "./progress.js"
44
+ import { Registry } from "./registry.js"
45
+ import type { PackageKey, Resolution, ResolvedPackage } from "./resolve.js"
46
+ import { indexPackages, resolve } from "./resolve.js"
47
+ import type { PackageSpec } from "./spec.js"
48
+ import { formatSpec } from "./spec.js"
49
+
50
+ /** One thing written to the output directory. */
51
+ export type BundleArtifact = {
52
+ readonly kind: ArtifactKind
53
+ readonly path: string
54
+ readonly bytes: number
55
+ readonly packageCount: number
56
+ /** Which spec this artifact covers. Absent for `single` and `dir`. */
57
+ readonly spec?: string | undefined
58
+ readonly version?: string | undefined
59
+ }
60
+
61
+ /** The outcome of a bundle run. */
62
+ export type BundleResult = {
63
+ readonly resolution: Resolution
64
+ readonly artifacts: ReadonlyArray<BundleArtifact>
65
+ /** Total bytes downloaded from the registry. */
66
+ readonly downloadedBytes: number
67
+ /** Packages the registry advertised no usable checksum for. */
68
+ readonly unverified: ReadonlyArray<string>
69
+ readonly dryRun: boolean
70
+ }
71
+
72
+ /** Everything `bundle` needs beyond the user-facing options. */
73
+ export type BundleContext = BundleOptions & {
74
+ /** Recorded in the manifest so a bundle can be traced to the tool that built it. */
75
+ readonly toolVersion: string
76
+ }
77
+
78
+ /**
79
+ * A summary of what a resolution would produce, for deciding whether the
80
+ * chosen layout is still the right one.
81
+ */
82
+ export type PlanSummary = {
83
+ /** Distinct packages in the deduplicated union. */
84
+ readonly uniquePackages: number
85
+ /** How many archives `per-spec` layout would emit. */
86
+ readonly perSpecArchives: number
87
+ /**
88
+ * Total package entries across all per-spec archives, counting shared
89
+ * dependencies once per archive they appear in. The gap between this and
90
+ * `uniquePackages` is exactly what `single` layout would save.
91
+ */
92
+ readonly perSpecEntries: number
93
+ }
94
+
95
+ /**
96
+ * The files a run will write, known before anything is downloaded.
97
+ *
98
+ * `dir` layout is the odd one: it merges a package tree into `outDir`, and
99
+ * package paths are `name/-/name-version.tgz` — the same package at the same
100
+ * version is the same bytes, so an overlap there is idempotent rather than
101
+ * destructive. Only the two summary files are genuinely replaced, and those are
102
+ * the ones worth guarding.
103
+ */
104
+ export type PlannedOutput = {
105
+ readonly file: string
106
+ /** The root this file belongs to, when one file corresponds to one spec. */
107
+ readonly name?: string | undefined
108
+ readonly version?: string | undefined
109
+ }
110
+
111
+ type BundleEnv = Registry | Progress.Progress | FileSystem.FileSystem | Path.Path
112
+
113
+ type EmitInput = {
114
+ readonly resolution: Resolution
115
+ readonly options: BundleContext
116
+ readonly packagesDir: string
117
+ readonly report: { readonly sizes: ReadonlyMap<string, number> }
118
+ readonly registryInfo: { readonly kind: string; readonly url: string }
119
+ }
120
+
121
+ /** Computes the plan summary for a resolution. */
122
+ export const summarize = (resolution: Resolution): PlanSummary => {
123
+ let perSpecArchives = 0
124
+ let perSpecEntries = 0
125
+ for (const root of resolution.roots) {
126
+ for (const version of root.versions) {
127
+ perSpecArchives += 1
128
+ perSpecEntries += (root.closures.get(version) ?? []).length
129
+ }
130
+ }
131
+ return {
132
+ uniquePackages: resolution.packages.length,
133
+ perSpecArchives,
134
+ perSpecEntries
135
+ }
136
+ }
137
+
138
+ /** Replaces the layout on a set of options. */
139
+ export const withLayout = <T extends BundleOptions>(options: T, layout: Layout): T => ({
140
+ ...options,
141
+ layout
142
+ })
143
+
144
+ /**
145
+ * Checks the registry is reachable, then resolves every spec.
146
+ *
147
+ * Split out from `bundle` so a caller can inspect the plan — how many packages,
148
+ * how many archives — and act on it before any bytes move. The CLI uses this to
149
+ * offer a different layout when a run turns out to be much larger than
150
+ * expected.
151
+ */
152
+ export const plan = (
153
+ specs: ReadonlyArray<PackageSpec>,
154
+ options: BundleOptions
155
+ ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
156
+ Effect.gen(function*() {
157
+ yield* preflight
158
+ return yield* resolve(specs, options)
159
+ })
160
+
161
+ /**
162
+ * The lockfile counterpart of `plan`.
163
+ *
164
+ * Same preflight, same `Resolution` out; the difference is entirely in how the
165
+ * package set is arrived at. Kept as a separate entry point rather than an
166
+ * option on `plan` because the two take genuinely different inputs — a set of
167
+ * specs to satisfy versus a graph to reproduce — and blurring that is how a
168
+ * "pinned" run quietly starts resolving ranges again.
169
+ */
170
+ export const planLocked = (
171
+ lockfile: LockedTree,
172
+ options: BundleOptions & LockedResolveOptions
173
+ ): Effect.Effect<Resolution, BundlerError, Registry | Progress.Progress> =>
174
+ Effect.gen(function*() {
175
+ yield* preflight
176
+ return yield* resolveLocked(lockfile, options)
177
+ })
178
+
179
+ const preflight: Effect.Effect<void, BundlerError, Registry | Progress.Progress> = Effect.gen(
180
+ function*() {
181
+ const registry = yield* Registry
182
+ yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Preflight })
183
+ yield* registry.preflight
184
+ yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Preflight })
185
+ }
186
+ )
187
+
188
+ /**
189
+ * Resolves, downloads and packages a set of specs.
190
+ *
191
+ * Under `dryRun` this stops after resolution and reports the plan — useful for
192
+ * checking what a command *would* pull before committing to a multi-gigabyte
193
+ * download over a slow VPN.
194
+ */
195
+ export const bundle = (
196
+ specs: ReadonlyArray<PackageSpec>,
197
+ options: BundleContext
198
+ ): Effect.Effect<BundleResult, BundlerError | PlatformError, BundleEnv> =>
199
+ Effect.gen(function*() {
200
+ const resolution = yield* plan(specs, options)
201
+ return yield* bundleResolved(resolution, options)
202
+ })
203
+
204
+ /** `bundle`, pinned to a lockfile. */
205
+ export const bundleLocked = (
206
+ lockfile: LockedTree,
207
+ options: BundleContext & LockedResolveOptions
208
+ ): Effect.Effect<BundleResult, BundlerError | PlatformError, BundleEnv> =>
209
+ Effect.gen(function*() {
210
+ const resolution = yield* planLocked(lockfile, options)
211
+ return yield* bundleResolved(resolution, options)
212
+ })
213
+
214
+ /**
215
+ * Downloads and packages an already-computed resolution.
216
+ *
217
+ * Everything from here on touches the disk, and all of it happens inside
218
+ * `Effect.scoped` — so the staging tree's lifetime is exactly this call,
219
+ * including when it fails partway through or the user hits Ctrl-C.
220
+ */
221
+ export const bundleResolved = (
222
+ resolution: Resolution,
223
+ options: BundleContext
224
+ ): Effect.Effect<BundleResult, BundlerError | PlatformError, BundleEnv> =>
225
+ Effect.gen(function*() {
226
+ if (options.dryRun) {
227
+ return {
228
+ resolution,
229
+ artifacts: [],
230
+ downloadedBytes: 0,
231
+ unverified: [],
232
+ dryRun: true
233
+ }
234
+ }
235
+ return yield* Effect.scoped(runBundle(resolution, options))
236
+ })
237
+
238
+ /**
239
+ * The files a run will write.
240
+ *
241
+ * Computed before anything is downloaded, so a collision is reported in the
242
+ * first second rather than after a twenty-minute transfer.
243
+ */
244
+ export const plannedOutputs = (
245
+ resolution: Resolution,
246
+ options: BundleContext
247
+ ): ReadonlyArray<PlannedOutput> => {
248
+ if (options.layout === Layout.Dir) return [{ file: MANIFEST_FILE }, { file: README_FILE }]
249
+ if (options.layout === Layout.Single) {
250
+ return [{ file: singleArchiveName(options.archiveName ?? "bundle") }]
251
+ }
252
+ return resolution.roots.flatMap((root) =>
253
+ root.versions.map((version) => ({
254
+ file: perSpecArchiveName(root.spec.name, version),
255
+ name: root.spec.name,
256
+ version
257
+ }))
258
+ )
259
+ }
260
+
261
+ const runBundle = (
262
+ resolution: Resolution,
263
+ options: BundleContext
264
+ ): Effect.Effect<
265
+ BundleResult,
266
+ BundlerError | PlatformError,
267
+ BundleEnv | Scope
268
+ > =>
269
+ Effect.gen(function*() {
270
+ const fs = yield* FileSystem.FileSystem
271
+ const path = yield* Path.Path
272
+ const registry = yield* Registry
273
+
274
+ yield* prepareOutputDir(resolution, options)
275
+
276
+ const staging = yield* fs.makeTempDirectoryScoped({ prefix: "packall-" })
277
+ const packagesDir = path.join(staging, "packages")
278
+ yield* fs.makeDirectory(packagesDir, { recursive: true })
279
+
280
+ const report = yield* downloadAll(resolution.packages, packagesDir, {
281
+ concurrency: options.concurrency,
282
+ verifyIntegrity: options.verifyIntegrity
283
+ })
284
+
285
+ yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Archive })
286
+
287
+ const input: EmitInput = {
288
+ resolution,
289
+ options,
290
+ packagesDir,
291
+ report,
292
+ registryInfo: {
293
+ kind: registry.kind,
294
+ url: registry.registryFor(resolution.roots[0]?.spec.name ?? "")
295
+ }
296
+ }
297
+
298
+ const artifacts = yield* emit(input, staging)
299
+
300
+ yield* Progress.emit({ _tag: "PhaseCompleted", phase: Phase.Archive })
301
+ yield* Progress.emit({ _tag: "PhaseStarted", phase: Phase.Done })
302
+
303
+ return {
304
+ resolution,
305
+ artifacts,
306
+ downloadedBytes: report.totalBytes,
307
+ unverified: report.unverified,
308
+ dryRun: false
309
+ }
310
+ })
311
+
312
+ /* -------------------------------------------------------------------------- */
313
+ /* Emitters */
314
+ /* -------------------------------------------------------------------------- */
315
+
316
+ const emit = (
317
+ input: EmitInput,
318
+ staging: string
319
+ ): Effect.Effect<
320
+ ReadonlyArray<BundleArtifact>,
321
+ BundlerError | PlatformError,
322
+ FileSystem.FileSystem | Path.Path | Progress.Progress
323
+ > => {
324
+ switch (input.options.layout) {
325
+ case Layout.Single:
326
+ return emitSingle(input)
327
+ case Layout.Dir:
328
+ return emitDirectory(input)
329
+ case Layout.PerSpec:
330
+ return emitPerSpec(input, staging)
331
+ }
332
+ }
333
+
334
+ /** One tarball containing the deduplicated union of every closure. */
335
+ const emitSingle = (
336
+ input: EmitInput
337
+ ): Effect.Effect<
338
+ ReadonlyArray<BundleArtifact>,
339
+ BundlerError | PlatformError,
340
+ FileSystem.FileSystem | Path.Path | Progress.Progress
341
+ > =>
342
+ Effect.gen(function*() {
343
+ const path = yield* Path.Path
344
+
345
+ const archiveName = singleArchiveName(input.options.archiveName ?? "bundle")
346
+ // Declined at the prompt, and it is the run's only output.
347
+ if (input.options.skipExisting?.has(archiveName) === true) return []
348
+
349
+ yield* writeBundleMetadata({
350
+ dir: input.packagesDir,
351
+ resolution: input.resolution,
352
+ included: input.resolution.packages,
353
+ options: input.options,
354
+ registryInfo: input.registryInfo,
355
+ sizes: input.report.sizes
356
+ })
357
+
358
+ const outPath = path.join(input.options.outDir, archiveName)
359
+
360
+ const result = yield* createArchive({
361
+ cwd: input.packagesDir,
362
+ entries: yield* topLevelEntries(input.packagesDir),
363
+ outPath
364
+ })
365
+
366
+ return [
367
+ {
368
+ kind: ArtifactKind.Archive,
369
+ path: result.path,
370
+ bytes: result.bytes,
371
+ packageCount: input.resolution.packages.length
372
+ }
373
+ ]
374
+ })
375
+
376
+ /** The raw npm-layout tree, no archive. */
377
+ const emitDirectory = (
378
+ input: EmitInput
379
+ ): Effect.Effect<
380
+ ReadonlyArray<BundleArtifact>,
381
+ BundlerError | PlatformError,
382
+ FileSystem.FileSystem | Path.Path
383
+ > =>
384
+ Effect.gen(function*() {
385
+ const fs = yield* FileSystem.FileSystem
386
+
387
+ yield* writeBundleMetadata({
388
+ dir: input.packagesDir,
389
+ resolution: input.resolution,
390
+ included: input.resolution.packages,
391
+ options: input.options,
392
+ registryInfo: input.registryInfo,
393
+ sizes: input.report.sizes
394
+ })
395
+
396
+ // The staging tree is about to be deleted with its scope, so this is a
397
+ // real copy rather than a move — `copy` also works across devices, which
398
+ // matters because the temp dir often lives on a different filesystem.
399
+ yield* fs.copy(input.packagesDir, input.options.outDir, { overwrite: true }).pipe(
400
+ Effect.mapError(
401
+ (cause) => new OutputError(input.options.outDir, "could not write output tree", { cause })
402
+ )
403
+ )
404
+
405
+ const bytes = [...input.report.sizes.values()].reduce((a, b) => a + b, 0)
406
+
407
+ return [
408
+ {
409
+ kind: ArtifactKind.Directory,
410
+ path: input.options.outDir,
411
+ bytes,
412
+ packageCount: input.resolution.packages.length
413
+ }
414
+ ]
415
+ })
416
+
417
+ /**
418
+ * One tarball per resolved root *version*.
419
+ *
420
+ * Under `--all-versions react@^18` this produces `react-18.0.0.tgz`,
421
+ * `react-18.1.0.tgz` and so on, each independently importable which is the
422
+ * whole reason to prefer this layout.
423
+ */
424
+ const emitPerSpec = (
425
+ input: EmitInput,
426
+ staging: string
427
+ ): Effect.Effect<
428
+ ReadonlyArray<BundleArtifact>,
429
+ BundlerError | PlatformError,
430
+ FileSystem.FileSystem | Path.Path | Progress.Progress
431
+ > =>
432
+ Effect.gen(function*() {
433
+ const fs = yield* FileSystem.FileSystem
434
+ const path = yield* Path.Path
435
+ const index = indexPackages(input.resolution)
436
+ const artifacts: Array<BundleArtifact> = []
437
+
438
+ let counter = 0
439
+ for (const root of input.resolution.roots) {
440
+ for (const version of root.versions) {
441
+ // Declined at the prompt: the existing file stays, and this archive is
442
+ // simply never built. Skipping the staging work too, not just the write.
443
+ if (input.options.skipExisting?.has(perSpecArchiveName(root.spec.name, version)) === true) {
444
+ continue
445
+ }
446
+
447
+ const included = includedPackages(index, root.closures.get(version) ?? [])
448
+
449
+ const rootDir = path.join(staging, "roots", String(counter++))
450
+ yield* fs.makeDirectory(rootDir, { recursive: true })
451
+
452
+ for (const pkg of included) {
453
+ const parts = packagePath(pkg.name, pkg.version).split("/")
454
+ const source = path.join(input.packagesDir, ...parts)
455
+ const target = path.join(rootDir, ...parts)
456
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true })
457
+ yield* linkOrCopy(source, target)
458
+ }
459
+
460
+ yield* writeBundleMetadata({
461
+ dir: rootDir,
462
+ resolution: {
463
+ ...input.resolution,
464
+ roots: [{ ...root, versions: [version] }]
465
+ },
466
+ included,
467
+ options: input.options,
468
+ registryInfo: input.registryInfo,
469
+ sizes: input.report.sizes
470
+ })
471
+
472
+ const outPath = path.join(
473
+ input.options.outDir,
474
+ perSpecArchiveName(root.spec.name, version)
475
+ )
476
+
477
+ const result = yield* createArchive({
478
+ cwd: rootDir,
479
+ entries: yield* topLevelEntries(rootDir),
480
+ outPath
481
+ })
482
+
483
+ artifacts.push({
484
+ kind: ArtifactKind.Archive,
485
+ path: result.path,
486
+ bytes: result.bytes,
487
+ packageCount: included.length,
488
+ spec: formatSpec(root.spec),
489
+ version
490
+ })
491
+ }
492
+ }
493
+
494
+ return artifacts
495
+ })
496
+
497
+ /* -------------------------------------------------------------------------- */
498
+ /* Helpers */
499
+ /* -------------------------------------------------------------------------- */
500
+
501
+ const includedPackages = (
502
+ index: ReadonlyMap<PackageKey, ResolvedPackage>,
503
+ closure: ReadonlyArray<PackageKey>
504
+ ): ReadonlyArray<ResolvedPackage> => closure.flatMap((key) => index.get(key) ?? [])
505
+
506
+ /**
507
+ * Hard-links a file, falling back to a copy.
508
+ *
509
+ * Hard links make `per-spec` layout nearly free in the staging tree. They fail
510
+ * across devices and on some Windows configurations, so the copy fallback is
511
+ * not optional.
512
+ */
513
+ const linkOrCopy = (
514
+ source: string,
515
+ target: string
516
+ ): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
517
+ Effect.gen(function*() {
518
+ const fs = yield* FileSystem.FileSystem
519
+ yield* fs.link(source, target).pipe(
520
+ Effect.catch(() => fs.copyFile(source, target))
521
+ )
522
+ })
523
+
524
+ /** Writes the manifest and import guide into a staging directory. */
525
+ const writeBundleMetadata = (input: {
526
+ readonly dir: string
527
+ readonly resolution: Resolution
528
+ readonly included: ReadonlyArray<ResolvedPackage>
529
+ readonly options: BundleContext
530
+ readonly registryInfo: { readonly kind: string; readonly url: string }
531
+ readonly sizes: ReadonlyMap<string, number>
532
+ }): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
533
+ Effect.gen(function*() {
534
+ const fs = yield* FileSystem.FileSystem
535
+ const path = yield* Path.Path
536
+
537
+ const createdAt = new Date()
538
+ const manifest = buildManifest({
539
+ resolution: input.resolution,
540
+ included: input.included,
541
+ options: input.options,
542
+ registry: input.registryInfo,
543
+ toolVersion: input.options.toolVersion,
544
+ sizes: input.sizes,
545
+ createdAt
546
+ })
547
+
548
+ yield* fs.writeFileString(path.join(input.dir, MANIFEST_FILE), serializeManifest(manifest))
549
+ yield* fs.writeFileString(
550
+ path.join(input.dir, README_FILE),
551
+ importGuide({
552
+ packageCount: input.included.length,
553
+ createdAt: createdAt.toISOString(),
554
+ toolVersion: input.options.toolVersion
555
+ })
556
+ )
557
+ })
558
+
559
+ /** Sorted top-level entries of a directory, used as the tar entry list. */
560
+ const topLevelEntries = (
561
+ dir: string
562
+ ): Effect.Effect<ReadonlyArray<string>, PlatformError, FileSystem.FileSystem> =>
563
+ Effect.gen(function*() {
564
+ const fs = yield* FileSystem.FileSystem
565
+ const entries = yield* fs.readDirectory(dir)
566
+ return entries.toSorted()
567
+ })
568
+
569
+ /**
570
+ * Fails if the run would overwrite something, unless `--force`.
571
+ *
572
+ * The guard is on the *files this run writes*, not on whether the directory has
573
+ * anything in it. An output directory accumulating bundles is the normal way to
574
+ * use this — bundling `esbuild` into an `out/` that already holds `tsdown` is
575
+ * not a conflict, and refusing it made `--out` a single-use directory and the
576
+ * default `.` unusable.
577
+ *
578
+ * Checked before anything is downloaded. Discovering the collision *after* a
579
+ * twenty-minute download would be a uniquely irritating way to fail.
580
+ */
581
+ const prepareOutputDir = (
582
+ resolution: Resolution,
583
+ options: BundleContext
584
+ ): Effect.Effect<void, OutputError | PlatformError, FileSystem.FileSystem | Path.Path> =>
585
+ Effect.gen(function*() {
586
+ const fs = yield* FileSystem.FileSystem
587
+ const path = yield* Path.Path
588
+ const outDir = options.outDir
589
+
590
+ if (!options.force) {
591
+ const clashes: Array<string> = []
592
+ for (const planned of plannedOutputs(resolution, options)) {
593
+ if (options.skipExisting?.has(planned.file) === true) continue
594
+ if (options.overwrite?.has(planned.file) === true) continue
595
+ const exists = yield* fs
596
+ .exists(path.join(outDir, planned.file))
597
+ .pipe(Effect.orElseSucceed(() => false))
598
+ if (exists) clashes.push(planned.file)
599
+ }
600
+
601
+ if (clashes.length > 0) {
602
+ return yield* Effect.fail(
603
+ new OutputError(
604
+ outDir,
605
+ `would overwrite ${clashes.length} existing file${clashes.length === 1 ? "" : "s"} ` +
606
+ `(${clashes.slice(0, 3).join(", ")}${clashes.length > 3 ? ", …" : ""}). ` +
607
+ `Pass --force true to replace ${clashes.length === 1 ? "it" : "them"}, or choose a different --out.`
608
+ )
609
+ )
610
+ }
611
+ }
612
+
613
+ yield* fs.makeDirectory(outDir, { recursive: true }).pipe(
614
+ Effect.mapError((cause) => new OutputError(outDir, "could not create output directory", { cause }))
615
+ )
616
+ })