@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.
- package/README.md +94 -0
- package/package.json +51 -0
- package/src/commands/clean.ts +40 -0
- package/src/commands/fetch.ts +152 -0
- package/src/commands/info.ts +83 -0
- package/src/commands/list.ts +107 -0
- package/src/commands/open.ts +89 -0
- package/src/commands/path.ts +38 -0
- package/src/commands/prune.ts +112 -0
- package/src/commands/remove.ts +41 -0
- package/src/commands/search.ts +102 -0
- package/src/commands/stats.ts +100 -0
- package/src/main.ts +77 -0
- package/src/services/cache.ts +109 -0
- package/src/services/git.ts +226 -0
- package/src/services/metadata.ts +141 -0
- package/src/services/registry.ts +713 -0
- package/src/test-utils/index.ts +109 -0
- package/src/test-utils/layers/cache.ts +137 -0
- package/src/test-utils/layers/git.ts +104 -0
- package/src/test-utils/layers/index.ts +33 -0
- package/src/test-utils/layers/metadata.ts +155 -0
- package/src/test-utils/layers/registry.ts +201 -0
- package/src/test-utils/run-cli.ts +157 -0
- package/src/test-utils/sequence.ts +57 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +27 -0
|
@@ -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
|
+
}
|