@cvr/repo 1.0.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.
@@ -0,0 +1,713 @@
1
+ import { Context, Effect, Layer, Option } from "effect"
2
+ import type { PackageSpec, Registry } from "../types.js"
3
+ import { SpecParseError, RegistryError, NetworkError } from "../types.js"
4
+ import { GitService } from "./git.js"
5
+ import { CacheService } from "./cache.js"
6
+
7
+ // Fetch options
8
+ export interface FetchOptions {
9
+ fullHistory?: boolean
10
+ }
11
+
12
+ // Service interface
13
+ export class RegistryService extends Context.Tag("@repo/RegistryService")<
14
+ RegistryService,
15
+ {
16
+ readonly parseSpec: (
17
+ input: string
18
+ ) => Effect.Effect<PackageSpec, SpecParseError>
19
+ readonly fetch: (
20
+ spec: PackageSpec,
21
+ destPath: string,
22
+ options?: FetchOptions
23
+ ) => Effect.Effect<void, RegistryError | NetworkError>
24
+ }
25
+ >() {
26
+ // Live layer
27
+ static readonly layer = Layer.effect(
28
+ RegistryService,
29
+ Effect.gen(function* () {
30
+ const cache = yield* CacheService
31
+ const git = yield* GitService
32
+
33
+ const parseSpec = (
34
+ input: string
35
+ ): Effect.Effect<PackageSpec, SpecParseError> =>
36
+ Effect.sync(() => {
37
+ const trimmed = input.trim()
38
+
39
+ // Check for registry prefixes
40
+ if (trimmed.startsWith("npm:")) {
41
+ return parseNpmSpec(trimmed.slice(4))
42
+ }
43
+ if (trimmed.startsWith("pypi:") || trimmed.startsWith("pip:")) {
44
+ const prefix = trimmed.startsWith("pypi:") ? "pypi:" : "pip:"
45
+ return parsePypiSpec(trimmed.slice(prefix.length))
46
+ }
47
+ if (
48
+ trimmed.startsWith("crates:") ||
49
+ trimmed.startsWith("cargo:") ||
50
+ trimmed.startsWith("rust:")
51
+ ) {
52
+ const prefixLen = trimmed.indexOf(":") + 1
53
+ return parseCratesSpec(trimmed.slice(prefixLen))
54
+ }
55
+ if (trimmed.startsWith("github:")) {
56
+ return parseGithubSpec(trimmed.slice(7))
57
+ }
58
+
59
+ // Check if it looks like a GitHub repo (contains /)
60
+ if (trimmed.includes("/") && !trimmed.startsWith("@")) {
61
+ return parseGithubSpec(trimmed)
62
+ }
63
+
64
+ // Default: treat as npm package if no prefix and no slash
65
+ return parseNpmSpec(trimmed)
66
+ }).pipe(
67
+ Effect.flatMap((result) => {
68
+ if ("error" in result) {
69
+ return Effect.fail(
70
+ new SpecParseError({ input, message: result.error })
71
+ )
72
+ }
73
+ return Effect.succeed(result)
74
+ })
75
+ )
76
+
77
+ // Create a layer with the acquired GitService for providing to fetch helpers
78
+ const gitLayer = Layer.succeed(GitService, git)
79
+
80
+ const fetch = (spec: PackageSpec, destPath: string, options?: FetchOptions) =>
81
+ Effect.gen(function* () {
82
+ yield* cache.ensureDir(destPath).pipe(
83
+ Effect.mapError(
84
+ (e) =>
85
+ new RegistryError({
86
+ registry: spec.registry,
87
+ operation: "ensureDir",
88
+ cause: e,
89
+ })
90
+ )
91
+ )
92
+
93
+ const depth = options?.fullHistory ? undefined : 100
94
+
95
+ switch (spec.registry) {
96
+ case "github":
97
+ yield* fetchGithub(spec, destPath, depth).pipe(Effect.provide(gitLayer))
98
+ break
99
+ case "npm":
100
+ yield* fetchNpm(spec, destPath, depth).pipe(Effect.provide(gitLayer))
101
+ break
102
+ case "pypi":
103
+ yield* fetchPypi(spec, destPath, depth).pipe(Effect.provide(gitLayer))
104
+ break
105
+ case "crates":
106
+ yield* fetchCrates(spec, destPath, depth).pipe(Effect.provide(gitLayer))
107
+ break
108
+ }
109
+ })
110
+
111
+ return RegistryService.of({ parseSpec, fetch })
112
+ })
113
+ )
114
+
115
+ }
116
+
117
+ // Parser helpers
118
+
119
+ type ParseResult = PackageSpec | { error: string }
120
+
121
+ function parseGithubSpec(input: string): ParseResult {
122
+ // Handle owner/repo@ref or owner/repo#ref
123
+ const refMatch = input.match(/^([^@#]+)[@#](.+)$/)
124
+ if (refMatch) {
125
+ const [, name, ref] = refMatch
126
+ if (!name?.includes("/")) {
127
+ return { error: "GitHub spec must be owner/repo format" }
128
+ }
129
+ return {
130
+ registry: "github" as Registry,
131
+ name: name,
132
+ version: Option.some(ref!),
133
+ }
134
+ }
135
+
136
+ if (!input.includes("/")) {
137
+ return { error: "GitHub spec must be owner/repo format" }
138
+ }
139
+
140
+ return {
141
+ registry: "github" as Registry,
142
+ name: input,
143
+ version: Option.none(),
144
+ }
145
+ }
146
+
147
+ function parseNpmSpec(input: string): ParseResult {
148
+ // Handle scoped packages: @scope/package@version
149
+ if (input.startsWith("@")) {
150
+ const match = input.match(/^(@[^@]+)(?:@(.+))?$/)
151
+ if (!match) {
152
+ return { error: "Invalid scoped npm package spec" }
153
+ }
154
+ const [, name, version] = match
155
+ return {
156
+ registry: "npm" as Registry,
157
+ name: name!,
158
+ version: version ? Option.some(version) : Option.none(),
159
+ }
160
+ }
161
+
162
+ // Handle regular packages: package@version
163
+ const parts = input.split("@")
164
+ if (parts.length > 2) {
165
+ return { error: "Invalid npm package spec" }
166
+ }
167
+
168
+ const [name, version] = parts
169
+ if (!name) {
170
+ return { error: "Package name is required" }
171
+ }
172
+
173
+ return {
174
+ registry: "npm" as Registry,
175
+ name,
176
+ version: version ? Option.some(version) : Option.none(),
177
+ }
178
+ }
179
+
180
+ function parsePypiSpec(input: string): ParseResult {
181
+ // Handle package@version or package==version
182
+ const match = input.match(/^([^@=]+)(?:[@=]=?(.+))?$/)
183
+ if (!match) {
184
+ return { error: "Invalid PyPI package spec" }
185
+ }
186
+
187
+ const [, name, version] = match
188
+ if (!name) {
189
+ return { error: "Package name is required" }
190
+ }
191
+
192
+ return {
193
+ registry: "pypi" as Registry,
194
+ name: name.trim(),
195
+ version: version ? Option.some(version.trim()) : Option.none(),
196
+ }
197
+ }
198
+
199
+ function parseCratesSpec(input: string): ParseResult {
200
+ const parts = input.split("@")
201
+ if (parts.length > 2) {
202
+ return { error: "Invalid crates.io spec" }
203
+ }
204
+
205
+ const [name, version] = parts
206
+ if (!name) {
207
+ return { error: "Crate name is required" }
208
+ }
209
+
210
+ return {
211
+ registry: "crates" as Registry,
212
+ name: name.trim(),
213
+ version: version ? Option.some(version.trim()) : Option.none(),
214
+ }
215
+ }
216
+
217
+ // Fetch helpers - these access GitService from context
218
+
219
+ function fetchGithub(
220
+ spec: PackageSpec,
221
+ destPath: string,
222
+ depth?: number
223
+ ): Effect.Effect<void, RegistryError, GitService> {
224
+ return Effect.gen(function* () {
225
+ const git = yield* GitService
226
+ const url = `https://github.com/${spec.name}.git`
227
+ const ref = Option.getOrUndefined(spec.version)
228
+
229
+ const cloneOptions: { depth?: number; ref?: string } = {}
230
+ if (depth) cloneOptions.depth = depth
231
+ if (ref) cloneOptions.ref = ref
232
+
233
+ yield* git
234
+ .clone(url, destPath, cloneOptions)
235
+ .pipe(
236
+ Effect.mapError(
237
+ (e) =>
238
+ new RegistryError({
239
+ registry: "github",
240
+ operation: "clone",
241
+ cause: e,
242
+ })
243
+ )
244
+ )
245
+ })
246
+ }
247
+
248
+ // Supported git hosts and their URL patterns
249
+ type GitHost = "github" | "gitlab" | "bitbucket" | "codeberg" | "sourcehut"
250
+
251
+ interface RepoInfo {
252
+ host: GitHost
253
+ owner: string
254
+ repo: string
255
+ }
256
+
257
+ // Extract repository info from various git hosting URL formats
258
+ function extractRepoInfo(
259
+ repository: { type?: string; url?: string } | string | undefined
260
+ ): RepoInfo | null {
261
+ if (!repository) return null
262
+
263
+ const url = typeof repository === "string" ? repository : repository.url
264
+ if (!url) return null
265
+
266
+ // Patterns for each host
267
+ const hostPatterns: Array<{ host: GitHost; pattern: RegExp }> = [
268
+ // GitHub
269
+ { host: "github", pattern: /github\.com[/:]([^/]+)\/([^/.\s#]+)/ },
270
+ { host: "github", pattern: /^github:([^/]+)\/([^/.\s#]+)/ },
271
+ // GitLab
272
+ { host: "gitlab", pattern: /gitlab\.com[/:]([^/]+)\/([^/.\s#]+)/ },
273
+ { host: "gitlab", pattern: /^gitlab:([^/]+)\/([^/.\s#]+)/ },
274
+ // Bitbucket
275
+ { host: "bitbucket", pattern: /bitbucket\.org[/:]([^/]+)\/([^/.\s#]+)/ },
276
+ { host: "bitbucket", pattern: /^bitbucket:([^/]+)\/([^/.\s#]+)/ },
277
+ // Codeberg
278
+ { host: "codeberg", pattern: /codeberg\.org[/:]([^/]+)\/([^/.\s#]+)/ },
279
+ // Sourcehut
280
+ { host: "sourcehut", pattern: /sr\.ht[/:]~([^/]+)\/([^/.\s#]+)/ },
281
+ { host: "sourcehut", pattern: /git\.sr\.ht[/:]~([^/]+)\/([^/.\s#]+)/ },
282
+ ]
283
+
284
+ for (const { host, pattern } of hostPatterns) {
285
+ const match = url.match(pattern)
286
+ if (match?.[1] && match?.[2]) {
287
+ return {
288
+ host,
289
+ owner: match[1],
290
+ repo: match[2].replace(/\.git$/, ""),
291
+ }
292
+ }
293
+ }
294
+
295
+ return null
296
+ }
297
+
298
+ // Get clone URL for a repository
299
+ function getCloneUrl(info: RepoInfo): string {
300
+ switch (info.host) {
301
+ case "github":
302
+ return `https://github.com/${info.owner}/${info.repo}.git`
303
+ case "gitlab":
304
+ return `https://gitlab.com/${info.owner}/${info.repo}.git`
305
+ case "bitbucket":
306
+ return `https://bitbucket.org/${info.owner}/${info.repo}.git`
307
+ case "codeberg":
308
+ return `https://codeberg.org/${info.owner}/${info.repo}.git`
309
+ case "sourcehut":
310
+ return `https://git.sr.ht/~${info.owner}/${info.repo}`
311
+ }
312
+ }
313
+
314
+ // Clone from any supported git host
315
+ function cloneFromRepoInfo(
316
+ info: RepoInfo,
317
+ destPath: string,
318
+ ref: string | undefined,
319
+ depth?: number
320
+ ): Effect.Effect<void, RegistryError, GitService> {
321
+ return Effect.gen(function* () {
322
+ const git = yield* GitService
323
+ const url = getCloneUrl(info)
324
+
325
+ const cloneOptions: { depth?: number; ref?: string } = {}
326
+ if (depth) cloneOptions.depth = depth
327
+ if (ref) cloneOptions.ref = ref
328
+
329
+ yield* git
330
+ .clone(url, destPath, cloneOptions)
331
+ .pipe(
332
+ Effect.mapError(
333
+ (e) =>
334
+ new RegistryError({
335
+ registry: "github",
336
+ operation: "clone",
337
+ cause: e,
338
+ })
339
+ )
340
+ )
341
+ })
342
+ }
343
+
344
+ function fetchNpm(
345
+ spec: PackageSpec,
346
+ destPath: string,
347
+ depth?: number
348
+ ): Effect.Effect<void, RegistryError | NetworkError, GitService> {
349
+ return Effect.gen(function* () {
350
+ // Query npm registry for package info
351
+ const version = Option.getOrElse(spec.version, () => "latest")
352
+ const url = `https://registry.npmjs.org/${spec.name}`
353
+
354
+ const response = yield* Effect.tryPromise({
355
+ try: () => fetch(url),
356
+ catch: (cause) => new NetworkError({ url, cause }),
357
+ })
358
+
359
+ if (!response.ok) {
360
+ return yield* Effect.fail(
361
+ new RegistryError({
362
+ registry: "npm",
363
+ operation: "fetch-metadata",
364
+ cause: new Error(`HTTP ${response.status}: ${response.statusText}`),
365
+ })
366
+ )
367
+ }
368
+
369
+ const data = (yield* Effect.tryPromise({
370
+ try: () => response.json(),
371
+ catch: (cause) =>
372
+ new RegistryError({
373
+ registry: "npm",
374
+ operation: "parse-metadata",
375
+ cause,
376
+ }),
377
+ })) as {
378
+ versions: Record<string, { dist: { tarball: string } }>
379
+ "dist-tags": Record<string, string>
380
+ repository?: { type?: string; url?: string } | string
381
+ }
382
+
383
+ // Resolve version
384
+ const resolvedVersion =
385
+ version === "latest"
386
+ ? data["dist-tags"]?.["latest"]
387
+ : version in data.versions
388
+ ? version
389
+ : data["dist-tags"]?.[version]
390
+
391
+ if (!resolvedVersion || !data.versions[resolvedVersion]) {
392
+ return yield* Effect.fail(
393
+ new RegistryError({
394
+ registry: "npm",
395
+ operation: "resolve-version",
396
+ cause: new Error(`Version ${version} not found`),
397
+ })
398
+ )
399
+ }
400
+
401
+ // Try to find source repo URL (GitHub, GitLab, etc.)
402
+ const repoInfo = extractRepoInfo(data.repository)
403
+
404
+ if (repoInfo) {
405
+ // Try to clone from source repo first
406
+ const gitRef = resolvedVersion.startsWith("v") ? resolvedVersion : `v${resolvedVersion}`
407
+ const cloneResult = yield* cloneFromRepoInfo(
408
+ repoInfo,
409
+ destPath,
410
+ gitRef,
411
+ depth
412
+ ).pipe(Effect.either)
413
+
414
+ if (cloneResult._tag === "Right") {
415
+ return // Success - cloned from source repo
416
+ }
417
+ // Clone failed, fall back to tarball
418
+ }
419
+
420
+ // Fallback: download tarball
421
+ const tarballUrl = data.versions[resolvedVersion]?.dist?.tarball
422
+ if (!tarballUrl) {
423
+ return yield* Effect.fail(
424
+ new RegistryError({
425
+ registry: "npm",
426
+ operation: "get-tarball-url",
427
+ cause: new Error("No tarball URL found"),
428
+ })
429
+ )
430
+ }
431
+
432
+ yield* downloadAndExtractTarball(tarballUrl, destPath, "npm")
433
+ })
434
+ }
435
+
436
+ function fetchPypi(
437
+ spec: PackageSpec,
438
+ destPath: string,
439
+ depth?: number
440
+ ): Effect.Effect<void, RegistryError | NetworkError, GitService> {
441
+ return Effect.gen(function* () {
442
+ const version = Option.getOrUndefined(spec.version)
443
+ const url = version
444
+ ? `https://pypi.org/pypi/${spec.name}/${version}/json`
445
+ : `https://pypi.org/pypi/${spec.name}/json`
446
+
447
+ const response = yield* Effect.tryPromise({
448
+ try: () => fetch(url),
449
+ catch: (cause) => new NetworkError({ url, cause }),
450
+ })
451
+
452
+ if (!response.ok) {
453
+ return yield* Effect.fail(
454
+ new RegistryError({
455
+ registry: "pypi",
456
+ operation: "fetch-metadata",
457
+ cause: new Error(`HTTP ${response.status}: ${response.statusText}`),
458
+ })
459
+ )
460
+ }
461
+
462
+ const data = (yield* Effect.tryPromise({
463
+ try: () => response.json(),
464
+ catch: (cause) =>
465
+ new RegistryError({
466
+ registry: "pypi",
467
+ operation: "parse-metadata",
468
+ cause,
469
+ }),
470
+ })) as {
471
+ urls: Array<{ packagetype: string; url: string }>
472
+ info: {
473
+ project_urls?: Record<string, string>
474
+ home_page?: string
475
+ version: string
476
+ }
477
+ }
478
+
479
+ // Try to find source repo URL first (GitHub, GitLab, etc.)
480
+ const repoInfo = extractRepoInfoFromPypi(data.info)
481
+
482
+ if (repoInfo) {
483
+ // Try to clone from source repo first
484
+ const resolvedVersion = data.info.version
485
+ const gitRef = resolvedVersion.startsWith("v") ? resolvedVersion : `v${resolvedVersion}`
486
+ const cloneResult = yield* cloneFromRepoInfo(
487
+ repoInfo,
488
+ destPath,
489
+ gitRef,
490
+ depth
491
+ ).pipe(Effect.either)
492
+
493
+ if (cloneResult._tag === "Right") {
494
+ return // Success - cloned from source repo
495
+ }
496
+ // Clone failed, fall back to tarball
497
+ }
498
+
499
+ // Fallback: download tarball
500
+ const sdist = data.urls.find((u) => u.packagetype === "sdist")
501
+ const wheel = data.urls.find((u) => u.packagetype === "bdist_wheel")
502
+ const tarballUrl = sdist?.url ?? wheel?.url
503
+
504
+ if (!tarballUrl) {
505
+ return yield* Effect.fail(
506
+ new RegistryError({
507
+ registry: "pypi",
508
+ operation: "get-download-url",
509
+ cause: new Error("No source distribution found"),
510
+ })
511
+ )
512
+ }
513
+
514
+ yield* downloadAndExtractTarball(tarballUrl, destPath, "pypi")
515
+ })
516
+ }
517
+
518
+ // Extract repo info from PyPI project info (supports GitHub, GitLab, etc.)
519
+ function extractRepoInfoFromPypi(info: {
520
+ project_urls?: Record<string, string>
521
+ home_page?: string
522
+ }): RepoInfo | null {
523
+ const urls = [
524
+ info.project_urls?.["Source"],
525
+ info.project_urls?.["Source Code"],
526
+ info.project_urls?.["GitHub"],
527
+ info.project_urls?.["GitLab"],
528
+ info.project_urls?.["Repository"],
529
+ info.project_urls?.["Code"],
530
+ info.home_page,
531
+ ]
532
+
533
+ for (const url of urls) {
534
+ if (url) {
535
+ const result = extractRepoInfo(url)
536
+ if (result) return result
537
+ }
538
+ }
539
+
540
+ return null
541
+ }
542
+
543
+ function fetchCrates(
544
+ spec: PackageSpec,
545
+ destPath: string,
546
+ depth?: number
547
+ ): Effect.Effect<void, RegistryError | NetworkError, GitService> {
548
+ return Effect.gen(function* () {
549
+ const url = `https://crates.io/api/v1/crates/${spec.name}`
550
+
551
+ const response = yield* Effect.tryPromise({
552
+ try: () =>
553
+ fetch(url, {
554
+ headers: {
555
+ "User-Agent": "repo-cli/1.0.0",
556
+ },
557
+ }),
558
+ catch: (cause) => new NetworkError({ url, cause }),
559
+ })
560
+
561
+ if (!response.ok) {
562
+ return yield* Effect.fail(
563
+ new RegistryError({
564
+ registry: "crates",
565
+ operation: "fetch-metadata",
566
+ cause: new Error(`HTTP ${response.status}: ${response.statusText}`),
567
+ })
568
+ )
569
+ }
570
+
571
+ const data = (yield* Effect.tryPromise({
572
+ try: () => response.json(),
573
+ catch: (cause) =>
574
+ new RegistryError({
575
+ registry: "crates",
576
+ operation: "parse-metadata",
577
+ cause,
578
+ }),
579
+ })) as {
580
+ crate: { repository?: string; homepage?: string }
581
+ versions: Array<{ num: string; dl_path: string }>
582
+ }
583
+
584
+ const version = Option.getOrUndefined(spec.version)
585
+ const versionInfo = version
586
+ ? data.versions.find((v) => v.num === version)
587
+ : data.versions[0] // latest
588
+
589
+ if (!versionInfo) {
590
+ return yield* Effect.fail(
591
+ new RegistryError({
592
+ registry: "crates",
593
+ operation: "resolve-version",
594
+ cause: new Error(`Version ${version ?? "latest"} not found`),
595
+ })
596
+ )
597
+ }
598
+
599
+ // Try to find source repo URL first (GitHub, GitLab, etc.)
600
+ const repoInfo = extractRepoInfo(data.crate.repository) ?? extractRepoInfo(data.crate.homepage)
601
+
602
+ if (repoInfo) {
603
+ // Try to clone from source repo first
604
+ const resolvedVersion = versionInfo.num
605
+ const gitRef = resolvedVersion.startsWith("v") ? resolvedVersion : `v${resolvedVersion}`
606
+ const cloneResult = yield* cloneFromRepoInfo(
607
+ repoInfo,
608
+ destPath,
609
+ gitRef,
610
+ depth
611
+ ).pipe(Effect.either)
612
+
613
+ if (cloneResult._tag === "Right") {
614
+ return // Success - cloned from source repo
615
+ }
616
+ // Clone failed, fall back to tarball
617
+ }
618
+
619
+ // Fallback: download tarball
620
+ const tarballUrl = `https://crates.io${versionInfo.dl_path}`
621
+ yield* downloadAndExtractTarball(tarballUrl, destPath, "crates")
622
+ })
623
+ }
624
+
625
+ function downloadAndExtractTarball(
626
+ url: string,
627
+ destPath: string,
628
+ registry: Registry
629
+ ): Effect.Effect<void, RegistryError | NetworkError> {
630
+ return Effect.gen(function* () {
631
+ const response = yield* Effect.tryPromise({
632
+ try: () => fetch(url),
633
+ catch: (cause) => new NetworkError({ url, cause }),
634
+ })
635
+
636
+ if (!response.ok) {
637
+ return yield* Effect.fail(
638
+ new RegistryError({
639
+ registry,
640
+ operation: "download-tarball",
641
+ cause: new Error(`HTTP ${response.status}: ${response.statusText}`),
642
+ })
643
+ )
644
+ }
645
+
646
+ const buffer = yield* Effect.tryPromise({
647
+ try: () => response.arrayBuffer(),
648
+ catch: (cause) =>
649
+ new RegistryError({
650
+ registry,
651
+ operation: "read-tarball",
652
+ cause,
653
+ }),
654
+ })
655
+
656
+ // Use bun to extract tarball
657
+ const tempFile = `/tmp/repo-${Date.now()}.tgz`
658
+ yield* Effect.tryPromise({
659
+ try: async () => {
660
+ await Bun.write(tempFile, buffer)
661
+ },
662
+ catch: (cause) =>
663
+ new RegistryError({
664
+ registry,
665
+ operation: "write-temp",
666
+ cause,
667
+ }),
668
+ })
669
+
670
+ // Extract using tar
671
+ const proc = Bun.spawn(
672
+ ["tar", "-xzf", tempFile, "-C", destPath, "--strip-components=1"],
673
+ {
674
+ stdout: "pipe",
675
+ stderr: "pipe",
676
+ }
677
+ )
678
+
679
+ const exitCode = yield* Effect.tryPromise({
680
+ try: () => proc.exited,
681
+ catch: (cause) =>
682
+ new RegistryError({
683
+ registry,
684
+ operation: "extract-tarball",
685
+ cause,
686
+ }),
687
+ })
688
+
689
+ // Clean up temp file
690
+ yield* Effect.tryPromise({
691
+ try: async () => {
692
+ const { unlink } = await import("node:fs/promises")
693
+ await unlink(tempFile)
694
+ },
695
+ catch: () =>
696
+ new RegistryError({
697
+ registry,
698
+ operation: "cleanup-temp",
699
+ cause: new Error("Failed to cleanup temp file"),
700
+ }),
701
+ }).pipe(Effect.ignore)
702
+
703
+ if (exitCode !== 0) {
704
+ return yield* Effect.fail(
705
+ new RegistryError({
706
+ registry,
707
+ operation: "extract-tarball",
708
+ cause: new Error(`tar exited with code ${exitCode}`),
709
+ })
710
+ )
711
+ }
712
+ })
713
+ }