@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,112 @@
1
+ import { Command, Options } from "@effect/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { formatBytes, specToString } from "../types.js"
4
+ import { CacheService } from "../services/cache.js"
5
+ import { MetadataService } from "../services/metadata.js"
6
+
7
+ const daysOption = Options.integer("days").pipe(
8
+ Options.withAlias("d"),
9
+ Options.optional,
10
+ Options.withDescription("Remove repos not accessed in N days")
11
+ )
12
+
13
+ const maxSizeOption = Options.text("max-size").pipe(
14
+ Options.optional,
15
+ Options.withDescription("Remove repos larger than size (e.g., 100M, 1G)")
16
+ )
17
+
18
+ const dryRunOption = Options.boolean("dry-run").pipe(
19
+ Options.withDefault(false),
20
+ Options.withDescription("Show what would be removed without actually removing")
21
+ )
22
+
23
+ function parseSize(sizeStr: string): number | null {
24
+ const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|K|KB|M|MB|G|GB)?$/i)
25
+ if (!match) return null
26
+
27
+ const value = parseFloat(match[1]!)
28
+ const unit = (match[2] ?? "B").toUpperCase()
29
+
30
+ switch (unit) {
31
+ case "B":
32
+ return value
33
+ case "K":
34
+ case "KB":
35
+ return value * 1024
36
+ case "M":
37
+ case "MB":
38
+ return value * 1024 * 1024
39
+ case "G":
40
+ case "GB":
41
+ return value * 1024 * 1024 * 1024
42
+ default:
43
+ return null
44
+ }
45
+ }
46
+
47
+ export const prune = Command.make(
48
+ "prune",
49
+ { days: daysOption, maxSize: maxSizeOption, dryRun: dryRunOption },
50
+ ({ days, maxSize, dryRun }) =>
51
+ Effect.gen(function* () {
52
+ const cache = yield* CacheService
53
+ const metadata = yield* MetadataService
54
+
55
+ if (Option.isNone(days) && Option.isNone(maxSize)) {
56
+ yield* Console.log("Specify at least one of: --days or --max-size")
57
+ return
58
+ }
59
+
60
+ let toRemove = yield* metadata.all()
61
+
62
+ // Filter by age
63
+ if (Option.isSome(days)) {
64
+ const cutoff = Date.now() - days.value * 24 * 60 * 60 * 1000
65
+ toRemove = toRemove.filter(
66
+ (r) => new Date(r.lastAccessedAt).getTime() < cutoff
67
+ )
68
+ }
69
+
70
+ // Filter by size
71
+ if (Option.isSome(maxSize)) {
72
+ const maxBytes = parseSize(maxSize.value)
73
+ if (maxBytes === null) {
74
+ yield* Console.log(`Invalid size format: ${maxSize.value}`)
75
+ yield* Console.log("Use formats like: 100M, 1G, 500KB")
76
+ return
77
+ }
78
+ toRemove = toRemove.filter((r) => r.sizeBytes > maxBytes)
79
+ }
80
+
81
+ if (toRemove.length === 0) {
82
+ yield* Console.log("No repositories match the prune criteria.")
83
+ return
84
+ }
85
+
86
+ const totalSize = toRemove.reduce((sum, r) => sum + r.sizeBytes, 0)
87
+
88
+ if (dryRun) {
89
+ yield* Console.log(`Would remove ${toRemove.length} repositories:`)
90
+ yield* Console.log("")
91
+ for (const repo of toRemove) {
92
+ yield* Console.log(
93
+ ` ${specToString(repo.spec).padEnd(40)} ${formatBytes(repo.sizeBytes)}`
94
+ )
95
+ }
96
+ yield* Console.log("")
97
+ yield* Console.log(`Total: ${formatBytes(totalSize)}`)
98
+ return
99
+ }
100
+
101
+ // Actually remove
102
+ for (const repo of toRemove) {
103
+ yield* cache.remove(repo.path)
104
+ yield* metadata.remove(repo.spec)
105
+ yield* Console.log(`Removed: ${specToString(repo.spec)}`)
106
+ }
107
+
108
+ yield* Console.log("")
109
+ yield* Console.log(`Removed ${toRemove.length} repositories.`)
110
+ yield* Console.log(`Freed: ${formatBytes(totalSize)}`)
111
+ })
112
+ )
@@ -0,0 +1,41 @@
1
+ import { Args, Command } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { specToString, formatBytes } from "../types.js"
4
+ import { CacheService } from "../services/cache.js"
5
+ import { MetadataService } from "../services/metadata.js"
6
+ import { RegistryService } from "../services/registry.js"
7
+
8
+ const specArg = Args.text({ name: "spec" }).pipe(
9
+ Args.withDescription("Package spec to remove")
10
+ )
11
+
12
+ export const remove = Command.make("remove", { spec: specArg }, ({ spec }) =>
13
+ Effect.gen(function* () {
14
+ const registry = yield* RegistryService
15
+ const cache = yield* CacheService
16
+ const metadata = yield* MetadataService
17
+
18
+ // Parse the spec
19
+ const parsedSpec = yield* registry.parseSpec(spec)
20
+
21
+ // Find in metadata
22
+ const existing = yield* metadata.find(parsedSpec)
23
+ if (!existing) {
24
+ yield* Console.log(`Not found: ${specToString(parsedSpec)}`)
25
+ return
26
+ }
27
+
28
+ // Remove from cache
29
+ yield* cache.remove(existing.path)
30
+
31
+ // Remove from metadata
32
+ yield* metadata.remove(parsedSpec)
33
+
34
+ yield* Console.log(`Removed: ${specToString(parsedSpec)}`)
35
+ yield* Console.log(`Freed: ${formatBytes(existing.sizeBytes)}`)
36
+ }).pipe(
37
+ Effect.catchAll((error) =>
38
+ Console.error(`Error: ${error._tag}: ${JSON.stringify(error)}`)
39
+ )
40
+ )
41
+ )
@@ -0,0 +1,102 @@
1
+ import { Args, Command, Options } from "@effect/cli"
2
+ import { Console, Effect, Option } from "effect"
3
+ import { MetadataService } from "../services/metadata.js"
4
+
5
+ const queryArg = Args.text({ name: "query" }).pipe(
6
+ Args.withDescription("Search pattern (regex supported)")
7
+ )
8
+
9
+ const registryOption = Options.choice("registry", [
10
+ "github",
11
+ "npm",
12
+ "pypi",
13
+ "crates",
14
+ ] as const).pipe(
15
+ Options.withAlias("r"),
16
+ Options.optional,
17
+ Options.withDescription("Filter by registry")
18
+ )
19
+
20
+ const typeOption = Options.text("type").pipe(
21
+ Options.withAlias("t"),
22
+ Options.optional,
23
+ Options.withDescription("File type filter (e.g., ts, py, rs)")
24
+ )
25
+
26
+ const contextOption = Options.integer("context").pipe(
27
+ Options.withDefault(2),
28
+ Options.withDescription("Lines of context around matches")
29
+ )
30
+
31
+ export const search = Command.make(
32
+ "search",
33
+ {
34
+ query: queryArg,
35
+ registry: registryOption,
36
+ type: typeOption,
37
+ context: contextOption,
38
+ },
39
+ ({ query, registry, type, context }) =>
40
+ Effect.gen(function* () {
41
+ const metadata = yield* MetadataService
42
+ let repos = yield* metadata.all()
43
+
44
+ // Filter by registry if specified
45
+ if (Option.isSome(registry)) {
46
+ repos = repos.filter((r) => r.spec.registry === registry.value)
47
+ }
48
+
49
+ if (repos.length === 0) {
50
+ yield* Console.log("No repositories cached.")
51
+ return
52
+ }
53
+
54
+ // Build ripgrep command
55
+ const paths = repos.map((r) => r.path)
56
+ const args = ["--color=always", "-n", `-C${context}`]
57
+
58
+ if (Option.isSome(type)) {
59
+ args.push(`--type=${type.value}`)
60
+ }
61
+
62
+ args.push(query, ...paths)
63
+
64
+ yield* Console.log(
65
+ `Searching ${repos.length} repositories for: ${query}`
66
+ )
67
+ yield* Console.log("")
68
+
69
+ // Run ripgrep
70
+ const proc = Bun.spawn(["rg", ...args], {
71
+ stdout: "pipe",
72
+ stderr: "pipe",
73
+ })
74
+
75
+ const output = yield* Effect.tryPromise({
76
+ try: async () => {
77
+ const stdout = await new Response(proc.stdout).text()
78
+ await proc.exited
79
+ return stdout
80
+ },
81
+ catch: () => "", // ripgrep returns non-zero when no matches
82
+ })
83
+
84
+ if (output.trim()) {
85
+ yield* Console.log(output)
86
+ } else {
87
+ yield* Console.log("No matches found.")
88
+ }
89
+ }).pipe(
90
+ Effect.catchAll((error) =>
91
+ Effect.gen(function* () {
92
+ if (typeof error === "object" && error !== null && "_tag" in error) {
93
+ yield* Console.error(
94
+ `Error: ${(error as { _tag: string })._tag}: ${JSON.stringify(error)}`
95
+ )
96
+ } else {
97
+ yield* Console.error(`Error: ${String(error)}`)
98
+ }
99
+ })
100
+ )
101
+ )
102
+ )
@@ -0,0 +1,100 @@
1
+ import { Command, Options } from "@effect/cli"
2
+ import { Console, Effect } from "effect"
3
+ import { formatBytes, type Registry } from "../types.js"
4
+ import { MetadataService } from "../services/metadata.js"
5
+ import { CacheService } from "../services/cache.js"
6
+
7
+ const jsonOption = Options.boolean("json").pipe(
8
+ Options.withDefault(false),
9
+ Options.withDescription("Output as JSON")
10
+ )
11
+
12
+ export const stats = Command.make("stats", { json: jsonOption }, ({ json }) =>
13
+ Effect.gen(function* () {
14
+ const metadata = yield* MetadataService
15
+ const cache = yield* CacheService
16
+
17
+ const repos = yield* metadata.all()
18
+
19
+ // Group by registry
20
+ const byRegistry: Record<Registry, { count: number; size: number }> = {
21
+ github: { count: 0, size: 0 },
22
+ npm: { count: 0, size: 0 },
23
+ pypi: { count: 0, size: 0 },
24
+ crates: { count: 0, size: 0 },
25
+ }
26
+
27
+ for (const repo of repos) {
28
+ byRegistry[repo.spec.registry].count++
29
+ byRegistry[repo.spec.registry].size += repo.sizeBytes
30
+ }
31
+
32
+ const totalCount = repos.length
33
+ const totalSize = repos.reduce((sum, r) => sum + r.sizeBytes, 0)
34
+
35
+ // Find oldest and newest
36
+ const sorted = [...repos].sort(
37
+ (a, b) =>
38
+ new Date(a.lastAccessedAt).getTime() -
39
+ new Date(b.lastAccessedAt).getTime()
40
+ )
41
+ const oldest = sorted[0]
42
+ const newest = sorted[sorted.length - 1]
43
+
44
+ if (json) {
45
+ yield* Console.log(
46
+ JSON.stringify(
47
+ {
48
+ cacheDir: cache.cacheDir,
49
+ totalCount,
50
+ totalSize,
51
+ byRegistry,
52
+ oldest: oldest
53
+ ? {
54
+ name: oldest.spec.name,
55
+ lastAccessed: oldest.lastAccessedAt,
56
+ }
57
+ : null,
58
+ newest: newest
59
+ ? {
60
+ name: newest.spec.name,
61
+ lastAccessed: newest.lastAccessedAt,
62
+ }
63
+ : null,
64
+ },
65
+ null,
66
+ 2
67
+ )
68
+ )
69
+ return
70
+ }
71
+
72
+ yield* Console.log("")
73
+ yield* Console.log("Cache Statistics")
74
+ yield* Console.log("═".repeat(50))
75
+ yield* Console.log(`Cache directory: ${cache.cacheDir}`)
76
+ yield* Console.log("")
77
+
78
+ yield* Console.log("By Registry:")
79
+ yield* Console.log("─".repeat(50))
80
+ for (const [registry, data] of Object.entries(byRegistry)) {
81
+ if (data.count > 0) {
82
+ yield* Console.log(
83
+ ` ${registry.padEnd(10)} ${String(data.count).padStart(5)} repos ${formatBytes(data.size).padStart(10)}`
84
+ )
85
+ }
86
+ }
87
+ yield* Console.log("─".repeat(50))
88
+ yield* Console.log(
89
+ ` ${"Total".padEnd(10)} ${String(totalCount).padStart(5)} repos ${formatBytes(totalSize).padStart(10)}`
90
+ )
91
+
92
+ if (oldest) {
93
+ yield* Console.log("")
94
+ yield* Console.log(`Oldest: ${oldest.spec.name} (${oldest.lastAccessedAt})`)
95
+ }
96
+ if (newest) {
97
+ yield* Console.log(`Newest: ${newest.spec.name} (${newest.lastAccessedAt})`)
98
+ }
99
+ })
100
+ )
package/src/main.ts ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "@effect/cli"
3
+ import { BunContext, BunRuntime } from "@effect/platform-bun"
4
+ import { Effect, Layer } from "effect"
5
+
6
+ import { fetch } from "./commands/fetch.js"
7
+ import { list } from "./commands/list.js"
8
+ import { search } from "./commands/search.js"
9
+ import { remove } from "./commands/remove.js"
10
+ import { clean } from "./commands/clean.js"
11
+ import { prune } from "./commands/prune.js"
12
+ import { stats } from "./commands/stats.js"
13
+ import { open } from "./commands/open.js"
14
+ import { path } from "./commands/path.js"
15
+ import { info } from "./commands/info.js"
16
+
17
+ import { CacheService } from "./services/cache.js"
18
+ import { MetadataService } from "./services/metadata.js"
19
+ import { GitService } from "./services/git.js"
20
+ import { RegistryService } from "./services/registry.js"
21
+
22
+ // Layer composition:
23
+ // 1. CacheService.layer and MetadataService.layer need FileSystem+Path (from BunContext)
24
+ // 2. GitService.layer is standalone (no deps)
25
+ // 3. RegistryService.layer needs GitService + CacheService
26
+
27
+ // First, provide BunContext to the platform-dependent services
28
+ const PlatformServicesLayer = Layer.mergeAll(
29
+ CacheService.layer,
30
+ MetadataService.layer
31
+ ).pipe(Layer.provide(BunContext.layer))
32
+
33
+ // GitService is standalone
34
+ const GitLayer = GitService.layer
35
+
36
+ // RegistryService needs Git and Cache
37
+ const RegistryLayer = RegistryService.layer.pipe(
38
+ Layer.provide(GitLayer),
39
+ Layer.provide(PlatformServicesLayer)
40
+ )
41
+
42
+ // All services together, merged with BunContext for CLI
43
+ const MainLayer = Layer.mergeAll(
44
+ PlatformServicesLayer,
45
+ GitLayer,
46
+ RegistryLayer,
47
+ BunContext.layer
48
+ )
49
+
50
+ // Root command
51
+ const repo = Command.make("repo").pipe(
52
+ Command.withDescription("Multi-registry source code cache manager"),
53
+ Command.withSubcommands([
54
+ fetch,
55
+ list,
56
+ search,
57
+ remove,
58
+ clean,
59
+ prune,
60
+ stats,
61
+ open,
62
+ path,
63
+ info,
64
+ ])
65
+ )
66
+
67
+ // CLI runner
68
+ const cli = Command.run(repo, {
69
+ name: "repo",
70
+ version: "1.0.0",
71
+ })
72
+
73
+ // Run with all services
74
+ cli(process.argv).pipe(
75
+ Effect.provide(MainLayer),
76
+ BunRuntime.runMain
77
+ )
@@ -0,0 +1,109 @@
1
+ import { FileSystem, Path } from "@effect/platform"
2
+ import { Context, Effect, Layer, Option } from "effect"
3
+ import type { PackageSpec } from "../types.js"
4
+
5
+ // Service interface
6
+ export class CacheService extends Context.Tag("@repo/CacheService")<
7
+ CacheService,
8
+ {
9
+ readonly cacheDir: string
10
+ readonly getPath: (spec: PackageSpec) => Effect.Effect<string>
11
+ readonly exists: (spec: PackageSpec) => Effect.Effect<boolean>
12
+ readonly remove: (path: string) => Effect.Effect<void>
13
+ readonly removeAll: () => Effect.Effect<void>
14
+ readonly getSize: (path: string) => Effect.Effect<number>
15
+ readonly ensureDir: (path: string) => Effect.Effect<void>
16
+ }
17
+ >() {
18
+ // Live layer using real filesystem
19
+ static readonly layer = Layer.effect(
20
+ CacheService,
21
+ Effect.gen(function* () {
22
+ const fs = yield* FileSystem.FileSystem
23
+ const pathService = yield* Path.Path
24
+ const home = process.env.HOME ?? "~"
25
+ const cacheDir = pathService.join(home, ".cache", "repo")
26
+
27
+ // Ensure cache root exists
28
+ yield* fs.makeDirectory(cacheDir, { recursive: true }).pipe(Effect.ignore)
29
+
30
+ const getPath = (spec: PackageSpec) =>
31
+ Effect.sync(() => {
32
+ // All repos stored as: ~/.cache/repo/{name}[@version]
33
+ // GitHub: owner/repo -> ~/.cache/repo/owner/repo
34
+ // npm: package@version -> ~/.cache/repo/package/version (or package/default)
35
+ // pypi/crates: same pattern
36
+ const version = Option.getOrElse(spec.version, () => "default")
37
+ switch (spec.registry) {
38
+ case "github":
39
+ // GitHub repos don't have versions in path (use git refs)
40
+ return pathService.join(cacheDir, spec.name)
41
+ case "npm":
42
+ case "pypi":
43
+ case "crates":
44
+ // Package registries include version in path
45
+ return pathService.join(cacheDir, spec.name, version)
46
+ }
47
+ })
48
+
49
+ const exists = (spec: PackageSpec) =>
50
+ Effect.gen(function* () {
51
+ const path = yield* getPath(spec)
52
+ return yield* fs.exists(path)
53
+ }).pipe(Effect.orElse(() => Effect.succeed(false)))
54
+
55
+ const remove = (path: string) =>
56
+ Effect.gen(function* () {
57
+ const pathExists = yield* fs.exists(path)
58
+ if (pathExists) {
59
+ yield* fs.remove(path, { recursive: true })
60
+ }
61
+ }).pipe(Effect.ignore)
62
+
63
+ const removeAll = () =>
64
+ Effect.gen(function* () {
65
+ const pathExists = yield* fs.exists(cacheDir)
66
+ if (pathExists) {
67
+ yield* fs.remove(cacheDir, { recursive: true })
68
+ yield* fs.makeDirectory(cacheDir, { recursive: true })
69
+ }
70
+ }).pipe(Effect.ignore)
71
+
72
+ const getSize = (path: string): Effect.Effect<number> =>
73
+ Effect.gen(function* () {
74
+ const pathExists = yield* fs.exists(path)
75
+ if (!pathExists) return 0
76
+
77
+ const calculateSize = (dir: string): Effect.Effect<number> =>
78
+ Effect.gen(function* () {
79
+ const stat = yield* fs.stat(dir)
80
+
81
+ if (stat.type === "Directory") {
82
+ const entries = yield* fs.readDirectory(dir)
83
+ const sizes = yield* Effect.forEach(entries, (entry) =>
84
+ calculateSize(pathService.join(dir, entry))
85
+ )
86
+ return sizes.reduce((a, b) => a + b, 0)
87
+ }
88
+ return Number(stat.size)
89
+ }).pipe(Effect.orElse(() => Effect.succeed(0)))
90
+
91
+ return yield* calculateSize(path)
92
+ }).pipe(Effect.orElse(() => Effect.succeed(0)))
93
+
94
+ const ensureDir = (path: string) =>
95
+ fs.makeDirectory(path, { recursive: true }).pipe(Effect.ignore)
96
+
97
+ return CacheService.of({
98
+ cacheDir,
99
+ getPath,
100
+ exists,
101
+ remove,
102
+ removeAll,
103
+ getSize,
104
+ ensureDir,
105
+ })
106
+ })
107
+ )
108
+
109
+ }