@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,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
|
+
}
|