@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
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# repo
|
|
2
|
+
|
|
3
|
+
Multi-registry source code cache manager. Fetch and cache source code from GitHub, npm, PyPI, and Crates.io.
|
|
4
|
+
|
|
5
|
+
Inspired by [vercel-labs/opensrc](https://github.com/vercel-labs/opensrc).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Source-first**: Package registries (npm, PyPI, Crates) resolve to source git repos when possible, with tarball fallback
|
|
10
|
+
- **Multi-registry**: GitHub, npm, PyPI, Crates.io support
|
|
11
|
+
- **Shallow clones**: Default depth of 100 commits for fast fetching
|
|
12
|
+
- **Auto-update**: Re-fetching updates existing git repos
|
|
13
|
+
- **Global cache**: All repos cached at `~/.cache/repo/`
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Clone and build
|
|
19
|
+
git clone https://github.com/cevr/repo.git
|
|
20
|
+
cd repo
|
|
21
|
+
bun install
|
|
22
|
+
bun run build
|
|
23
|
+
|
|
24
|
+
# Link globally
|
|
25
|
+
bun link
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Fetch a GitHub repo
|
|
32
|
+
repo fetch vercel/next.js
|
|
33
|
+
|
|
34
|
+
# Fetch an npm package (resolves to source repo)
|
|
35
|
+
repo fetch npm:effect@3.0.0
|
|
36
|
+
|
|
37
|
+
# Get the cached path
|
|
38
|
+
repo path vercel/next.js
|
|
39
|
+
|
|
40
|
+
# List all cached repos
|
|
41
|
+
repo list
|
|
42
|
+
|
|
43
|
+
# Search across all cached repos
|
|
44
|
+
repo search "createServer"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Spec Formats
|
|
48
|
+
|
|
49
|
+
| Format | Example |
|
|
50
|
+
|--------|---------|
|
|
51
|
+
| GitHub | `owner/repo`, `owner/repo@v1.0.0` |
|
|
52
|
+
| npm | `npm:lodash`, `npm:@effect/cli@0.73.0` |
|
|
53
|
+
| PyPI | `pypi:requests@2.31.0` |
|
|
54
|
+
| Crates | `crates:serde@1.0.0` |
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Description |
|
|
59
|
+
|---------|-------------|
|
|
60
|
+
| `repo fetch <spec>` | Fetch/update repository |
|
|
61
|
+
| `repo path <spec>` | Get cached path |
|
|
62
|
+
| `repo info <spec>` | Show repository metadata |
|
|
63
|
+
| `repo list` | List cached repos |
|
|
64
|
+
| `repo search <query>` | Search across cached repos |
|
|
65
|
+
| `repo stats` | Cache statistics |
|
|
66
|
+
| `repo remove <spec>` | Remove from cache |
|
|
67
|
+
| `repo prune` | Remove old/large repos |
|
|
68
|
+
| `repo clean` | Clear entire cache |
|
|
69
|
+
| `repo open <spec>` | Open in editor |
|
|
70
|
+
|
|
71
|
+
See [SKILL.md](./SKILL.md) for detailed usage.
|
|
72
|
+
|
|
73
|
+
## Storage
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
~/.cache/repo/
|
|
77
|
+
├── metadata.json # Index
|
|
78
|
+
├── {owner}/{repo}/ # GitHub repos
|
|
79
|
+
└── {package}/{version}/ # Package registries
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Tech Stack
|
|
83
|
+
|
|
84
|
+
- [Bun](https://bun.sh) - Runtime
|
|
85
|
+
- [Effect](https://effect.website) - Functional TypeScript
|
|
86
|
+
- [@effect/cli](https://github.com/Effect-TS/effect/tree/main/packages/cli) - CLI framework
|
|
87
|
+
|
|
88
|
+
## Credits
|
|
89
|
+
|
|
90
|
+
Inspired by [vercel-labs/opensrc](https://github.com/vercel-labs/opensrc) - a tool for exploring open source projects.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cvr/repo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"repo": "./bin/repo"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"src",
|
|
10
|
+
"bin",
|
|
11
|
+
"tsconfig.json"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "bun build src/main.ts --outfile bin/repo --compile",
|
|
15
|
+
"postinstall": "bun run build",
|
|
16
|
+
"prepublishOnly": "rm -rf bin",
|
|
17
|
+
"dev": "bun run src/main.ts",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"prepare": "effect-language-service patch",
|
|
22
|
+
"link": "ln -sf $(pwd)/bin/repo ~/.bun/bin/repo",
|
|
23
|
+
"version": "changeset version",
|
|
24
|
+
"release": "changeset publish"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/cevr/repo.git"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"bun": ">=1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@effect/cli": "^0.73.0",
|
|
38
|
+
"@effect/platform": "^0.94.1",
|
|
39
|
+
"@effect/platform-bun": "^0.87.0",
|
|
40
|
+
"effect": "^3.19.14"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@changesets/changelog-github": "^0.5.0",
|
|
44
|
+
"@changesets/cli": "^2.27.0",
|
|
45
|
+
"@effect/language-service": "^0.64.0",
|
|
46
|
+
"@effect/vitest": "^0.27.0",
|
|
47
|
+
"@types/bun": "^1.3.5",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.0.16"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { formatBytes } from "../types.js"
|
|
4
|
+
import { CacheService } from "../services/cache.js"
|
|
5
|
+
import { MetadataService } from "../services/metadata.js"
|
|
6
|
+
|
|
7
|
+
const confirmOption = Options.boolean("yes").pipe(
|
|
8
|
+
Options.withAlias("y"),
|
|
9
|
+
Options.withDefault(false),
|
|
10
|
+
Options.withDescription("Skip confirmation prompt")
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export const clean = Command.make("clean", { yes: confirmOption }, ({ yes }) =>
|
|
14
|
+
Effect.gen(function* () {
|
|
15
|
+
const cache = yield* CacheService
|
|
16
|
+
const metadata = yield* MetadataService
|
|
17
|
+
|
|
18
|
+
const repos = yield* metadata.all()
|
|
19
|
+
if (repos.length === 0) {
|
|
20
|
+
yield* Console.log("Cache is already empty.")
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const totalSize = repos.reduce((sum, r) => sum + r.sizeBytes, 0)
|
|
25
|
+
|
|
26
|
+
if (!yes) {
|
|
27
|
+
yield* Console.log(
|
|
28
|
+
`This will remove ${repos.length} cached repositories (${formatBytes(totalSize)}).`
|
|
29
|
+
)
|
|
30
|
+
yield* Console.log('Use --yes to confirm.')
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
yield* cache.removeAll()
|
|
35
|
+
yield* metadata.save({ version: 1, repos: [] })
|
|
36
|
+
|
|
37
|
+
yield* Console.log(`Removed ${repos.length} repositories.`)
|
|
38
|
+
yield* Console.log(`Freed: ${formatBytes(totalSize)}`)
|
|
39
|
+
})
|
|
40
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { formatBytes, specToString } 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
|
+
import { GitService } from "../services/git.js"
|
|
8
|
+
|
|
9
|
+
const specArg = Args.text({ name: "spec" }).pipe(
|
|
10
|
+
Args.withDescription(
|
|
11
|
+
"Package spec: owner/repo, npm:package[@version], pypi:package, crates:crate"
|
|
12
|
+
)
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const forceOption = Options.boolean("force").pipe(
|
|
16
|
+
Options.withAlias("f"),
|
|
17
|
+
Options.withDefault(false),
|
|
18
|
+
Options.withDescription("Force re-clone (removes existing and clones fresh)")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const updateOption = Options.boolean("update").pipe(
|
|
22
|
+
Options.withAlias("u"),
|
|
23
|
+
Options.withDefault(false),
|
|
24
|
+
Options.withDescription("Update existing repo (git pull)")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const fullHistoryOption = Options.boolean("full").pipe(
|
|
28
|
+
Options.withDefault(false),
|
|
29
|
+
Options.withDescription("Clone full git history (default: shallow clone with depth 100)")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export const fetch = Command.make(
|
|
33
|
+
"fetch",
|
|
34
|
+
{ spec: specArg, force: forceOption, update: updateOption, full: fullHistoryOption },
|
|
35
|
+
({ spec, force, update, full }) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const registry = yield* RegistryService
|
|
38
|
+
const cache = yield* CacheService
|
|
39
|
+
const metadata = yield* MetadataService
|
|
40
|
+
const git = yield* GitService
|
|
41
|
+
|
|
42
|
+
// Parse the spec
|
|
43
|
+
const parsedSpec = yield* registry.parseSpec(spec)
|
|
44
|
+
const specStr = specToString(parsedSpec)
|
|
45
|
+
|
|
46
|
+
// Check if already cached
|
|
47
|
+
const existing = yield* metadata.find(parsedSpec)
|
|
48
|
+
const destPath = yield* cache.getPath(parsedSpec)
|
|
49
|
+
|
|
50
|
+
if (existing) {
|
|
51
|
+
const isGit = yield* git.isGitRepo(existing.path)
|
|
52
|
+
|
|
53
|
+
if (force) {
|
|
54
|
+
// Force: remove and re-clone
|
|
55
|
+
yield* Console.log(`Force re-fetching ${specStr}...`)
|
|
56
|
+
yield* cache.remove(existing.path)
|
|
57
|
+
yield* metadata.remove(parsedSpec)
|
|
58
|
+
} else if (update || isGit) {
|
|
59
|
+
// Update existing git repo
|
|
60
|
+
if (isGit) {
|
|
61
|
+
yield* Console.log(`Updating ${specStr}...`)
|
|
62
|
+
yield* git.update(existing.path).pipe(
|
|
63
|
+
Effect.catchAll((e) =>
|
|
64
|
+
Console.log(`Update failed, repo may be up to date: ${e._tag}`)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Recalculate size after update
|
|
69
|
+
const sizeBytes = yield* cache.getSize(existing.path)
|
|
70
|
+
const currentRef = yield* git.getCurrentRef(existing.path).pipe(
|
|
71
|
+
Effect.orElseSucceed(() => "unknown")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
yield* metadata.add({
|
|
75
|
+
spec: parsedSpec,
|
|
76
|
+
fetchedAt: existing.fetchedAt,
|
|
77
|
+
lastAccessedAt: new Date().toISOString(),
|
|
78
|
+
sizeBytes,
|
|
79
|
+
path: existing.path,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
yield* Console.log(`Updated: ${existing.path}`)
|
|
83
|
+
yield* Console.log(`Current ref: ${currentRef}`)
|
|
84
|
+
yield* Console.log(`Size: ${formatBytes(sizeBytes)}`)
|
|
85
|
+
return
|
|
86
|
+
} else {
|
|
87
|
+
// Not a git repo, just report it exists
|
|
88
|
+
yield* metadata.updateAccessTime(parsedSpec)
|
|
89
|
+
yield* Console.log(`Already cached at: ${existing.path}`)
|
|
90
|
+
yield* Console.log(`Size: ${formatBytes(existing.sizeBytes)}`)
|
|
91
|
+
yield* Console.log(`(Not a git repo - use --force to re-fetch)`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
// Already cached, just update access time
|
|
96
|
+
yield* metadata.updateAccessTime(parsedSpec)
|
|
97
|
+
yield* Console.log(`Already cached at: ${existing.path}`)
|
|
98
|
+
yield* Console.log(`Size: ${formatBytes(existing.sizeBytes)}`)
|
|
99
|
+
if (isGit) {
|
|
100
|
+
yield* Console.log(`Use --update to pull latest changes`)
|
|
101
|
+
}
|
|
102
|
+
yield* Console.log(`Use --force to re-fetch from scratch`)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fresh fetch
|
|
108
|
+
yield* Console.log(`Fetching ${specStr}...`)
|
|
109
|
+
|
|
110
|
+
// Ensure parent directory exists
|
|
111
|
+
const parentPath = destPath.split("/").slice(0, -1).join("/")
|
|
112
|
+
yield* cache.ensureDir(parentPath)
|
|
113
|
+
|
|
114
|
+
// Fetch from registry
|
|
115
|
+
yield* registry.fetch(parsedSpec, destPath, { fullHistory: full })
|
|
116
|
+
|
|
117
|
+
// Calculate size
|
|
118
|
+
const sizeBytes = yield* cache.getSize(destPath)
|
|
119
|
+
|
|
120
|
+
// Get current ref if it's a git repo
|
|
121
|
+
const isGit = yield* git.isGitRepo(destPath)
|
|
122
|
+
if (isGit) {
|
|
123
|
+
const currentRef = yield* git.getCurrentRef(destPath).pipe(
|
|
124
|
+
Effect.orElseSucceed(() => "unknown")
|
|
125
|
+
)
|
|
126
|
+
yield* Console.log(`Ref: ${currentRef}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Update metadata
|
|
130
|
+
const now = new Date().toISOString()
|
|
131
|
+
yield* metadata.add({
|
|
132
|
+
spec: parsedSpec,
|
|
133
|
+
fetchedAt: now,
|
|
134
|
+
lastAccessedAt: now,
|
|
135
|
+
sizeBytes,
|
|
136
|
+
path: destPath,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
yield* Console.log(`Fetched to: ${destPath}`)
|
|
140
|
+
yield* Console.log(`Size: ${formatBytes(sizeBytes)}`)
|
|
141
|
+
}).pipe(
|
|
142
|
+
Effect.catchAll((error) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
if (typeof error === "object" && error !== null && "_tag" in error) {
|
|
145
|
+
yield* Console.error(`Error: ${error._tag}: ${JSON.stringify(error)}`)
|
|
146
|
+
} else {
|
|
147
|
+
yield* Console.error(`Error: ${String(error)}`)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect, Option } from "effect"
|
|
3
|
+
import { specToString, formatBytes, formatRelativeTime } from "../types.js"
|
|
4
|
+
import { MetadataService } from "../services/metadata.js"
|
|
5
|
+
import { RegistryService } from "../services/registry.js"
|
|
6
|
+
import { GitService } from "../services/git.js"
|
|
7
|
+
|
|
8
|
+
const specArg = Args.text({ name: "spec" }).pipe(
|
|
9
|
+
Args.withDescription("Package spec to get info for")
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
13
|
+
Options.withDefault(false),
|
|
14
|
+
Options.withDescription("Output as JSON")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
export const info = Command.make(
|
|
18
|
+
"info",
|
|
19
|
+
{ spec: specArg, json: jsonOption },
|
|
20
|
+
({ spec, json }) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const registry = yield* RegistryService
|
|
23
|
+
const metadata = yield* MetadataService
|
|
24
|
+
const git = yield* GitService
|
|
25
|
+
|
|
26
|
+
const parsedSpec = yield* registry.parseSpec(spec)
|
|
27
|
+
const existing = yield* metadata.find(parsedSpec)
|
|
28
|
+
|
|
29
|
+
if (!existing) {
|
|
30
|
+
yield* Console.error(`Not cached: ${specToString(parsedSpec)}`)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isGit = yield* git.isGitRepo(existing.path)
|
|
35
|
+
const currentRef = isGit
|
|
36
|
+
? yield* git
|
|
37
|
+
.getCurrentRef(existing.path)
|
|
38
|
+
.pipe(Effect.orElseSucceed(() => "unknown"))
|
|
39
|
+
: null
|
|
40
|
+
|
|
41
|
+
if (json) {
|
|
42
|
+
yield* Console.log(
|
|
43
|
+
JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
spec: {
|
|
46
|
+
registry: existing.spec.registry,
|
|
47
|
+
name: existing.spec.name,
|
|
48
|
+
version: Option.getOrNull(existing.spec.version),
|
|
49
|
+
},
|
|
50
|
+
path: existing.path,
|
|
51
|
+
sizeBytes: existing.sizeBytes,
|
|
52
|
+
sizeHuman: formatBytes(existing.sizeBytes),
|
|
53
|
+
fetchedAt: existing.fetchedAt,
|
|
54
|
+
lastAccessedAt: existing.lastAccessedAt,
|
|
55
|
+
isGitRepo: isGit,
|
|
56
|
+
currentRef,
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
} else {
|
|
63
|
+
yield* Console.log(``)
|
|
64
|
+
yield* Console.log(`Repository Info`)
|
|
65
|
+
yield* Console.log("═".repeat(50))
|
|
66
|
+
yield* Console.log(`Spec: ${specToString(existing.spec)}`)
|
|
67
|
+
yield* Console.log(`Registry: ${existing.spec.registry}`)
|
|
68
|
+
yield* Console.log(`Path: ${existing.path}`)
|
|
69
|
+
yield* Console.log(`Size: ${formatBytes(existing.sizeBytes)}`)
|
|
70
|
+
yield* Console.log(
|
|
71
|
+
`Fetched: ${formatRelativeTime(new Date(existing.fetchedAt))}`
|
|
72
|
+
)
|
|
73
|
+
yield* Console.log(
|
|
74
|
+
`Accessed: ${formatRelativeTime(new Date(existing.lastAccessedAt))}`
|
|
75
|
+
)
|
|
76
|
+
if (isGit && currentRef) {
|
|
77
|
+
yield* Console.log(`Git ref: ${currentRef}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
yield* metadata.updateAccessTime(parsedSpec)
|
|
82
|
+
})
|
|
83
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect, Option } from "effect"
|
|
3
|
+
import { formatBytes, formatRelativeTime, specToString } from "../types.js"
|
|
4
|
+
import { MetadataService } from "../services/metadata.js"
|
|
5
|
+
|
|
6
|
+
const registryOption = Options.choice("registry", [
|
|
7
|
+
"github",
|
|
8
|
+
"npm",
|
|
9
|
+
"pypi",
|
|
10
|
+
"crates",
|
|
11
|
+
] as const).pipe(
|
|
12
|
+
Options.withAlias("r"),
|
|
13
|
+
Options.optional,
|
|
14
|
+
Options.withDescription("Filter by registry")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const jsonOption = Options.boolean("json").pipe(
|
|
18
|
+
Options.withDefault(false),
|
|
19
|
+
Options.withDescription("Output as JSON")
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const sortOption = Options.choice("sort", [
|
|
23
|
+
"date",
|
|
24
|
+
"size",
|
|
25
|
+
"name",
|
|
26
|
+
] as const).pipe(
|
|
27
|
+
Options.withAlias("s"),
|
|
28
|
+
Options.withDefault("date" as const),
|
|
29
|
+
Options.withDescription("Sort by: date, size, name")
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export const list = Command.make(
|
|
33
|
+
"list",
|
|
34
|
+
{ registry: registryOption, json: jsonOption, sort: sortOption },
|
|
35
|
+
({ registry, json, sort }) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const metadata = yield* MetadataService
|
|
38
|
+
let repos = yield* metadata.all()
|
|
39
|
+
|
|
40
|
+
// Filter by registry if specified
|
|
41
|
+
if (Option.isSome(registry)) {
|
|
42
|
+
repos = repos.filter((r) => r.spec.registry === registry.value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Sort
|
|
46
|
+
const sorted = [...repos].sort((a, b) => {
|
|
47
|
+
switch (sort) {
|
|
48
|
+
case "date":
|
|
49
|
+
return (
|
|
50
|
+
new Date(b.lastAccessedAt).getTime() -
|
|
51
|
+
new Date(a.lastAccessedAt).getTime()
|
|
52
|
+
)
|
|
53
|
+
case "size":
|
|
54
|
+
return b.sizeBytes - a.sizeBytes
|
|
55
|
+
case "name":
|
|
56
|
+
return a.spec.name.localeCompare(b.spec.name)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (json) {
|
|
61
|
+
yield* Console.log(
|
|
62
|
+
JSON.stringify(
|
|
63
|
+
{
|
|
64
|
+
repos: sorted.map((r) => ({
|
|
65
|
+
...r,
|
|
66
|
+
spec: {
|
|
67
|
+
registry: r.spec.registry,
|
|
68
|
+
name: r.spec.name,
|
|
69
|
+
version: Option.getOrNull(r.spec.version),
|
|
70
|
+
},
|
|
71
|
+
})),
|
|
72
|
+
total: sorted.length,
|
|
73
|
+
totalSize: sorted.reduce((sum, r) => sum + r.sizeBytes, 0),
|
|
74
|
+
},
|
|
75
|
+
null,
|
|
76
|
+
2
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (sorted.length === 0) {
|
|
83
|
+
yield* Console.log("No repositories cached.")
|
|
84
|
+
yield* Console.log('Use "repo fetch <spec>" to cache a repository.')
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
yield* Console.log("")
|
|
89
|
+
yield* Console.log(`Cached Repositories (${sorted.length})`)
|
|
90
|
+
yield* Console.log("═".repeat(80))
|
|
91
|
+
|
|
92
|
+
for (const repo of sorted) {
|
|
93
|
+
const spec = specToString(repo.spec)
|
|
94
|
+
const size = formatBytes(repo.sizeBytes)
|
|
95
|
+
const date = formatRelativeTime(new Date(repo.lastAccessedAt))
|
|
96
|
+
const registryStr = repo.spec.registry.padEnd(6)
|
|
97
|
+
|
|
98
|
+
yield* Console.log(
|
|
99
|
+
`${registryStr} ${spec.padEnd(40)} ${size.padStart(10)} ${date}`
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
yield* Console.log("═".repeat(80))
|
|
104
|
+
const totalSize = sorted.reduce((sum, r) => sum + r.sizeBytes, 0)
|
|
105
|
+
yield* Console.log(`Total: ${formatBytes(totalSize)}`)
|
|
106
|
+
})
|
|
107
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect, Option } from "effect"
|
|
3
|
+
import { OpenError, specToString } from "../types.js"
|
|
4
|
+
import { MetadataService } from "../services/metadata.js"
|
|
5
|
+
import { RegistryService } from "../services/registry.js"
|
|
6
|
+
|
|
7
|
+
const specArg = Args.text({ name: "spec" }).pipe(
|
|
8
|
+
Args.withDescription("Package spec to open")
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const finderOption = Options.boolean("finder").pipe(
|
|
12
|
+
Options.withAlias("f"),
|
|
13
|
+
Options.withDefault(false),
|
|
14
|
+
Options.withDescription("Open in Finder instead of editor")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const editorOption = Options.text("editor").pipe(
|
|
18
|
+
Options.withAlias("e"),
|
|
19
|
+
Options.optional,
|
|
20
|
+
Options.withDescription("Editor to use (defaults to $EDITOR or code)")
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
export const open = Command.make(
|
|
24
|
+
"open",
|
|
25
|
+
{ spec: specArg, finder: finderOption, editor: editorOption },
|
|
26
|
+
({ spec, finder, editor }) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const registry = yield* RegistryService
|
|
29
|
+
const metadata = yield* MetadataService
|
|
30
|
+
|
|
31
|
+
// Parse the spec
|
|
32
|
+
const parsedSpec = yield* registry.parseSpec(spec)
|
|
33
|
+
|
|
34
|
+
// Find in metadata
|
|
35
|
+
const existing = yield* metadata.find(parsedSpec)
|
|
36
|
+
if (!existing) {
|
|
37
|
+
yield* Console.log(`Not found: ${specToString(parsedSpec)}`)
|
|
38
|
+
yield* Console.log('Use "repo fetch" first to cache it.')
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Update access time
|
|
43
|
+
yield* metadata.updateAccessTime(parsedSpec)
|
|
44
|
+
|
|
45
|
+
if (finder) {
|
|
46
|
+
// Open in Finder (macOS)
|
|
47
|
+
const proc = Bun.spawn(["open", existing.path], {
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
})
|
|
51
|
+
yield* Effect.tryPromise({
|
|
52
|
+
try: () => proc.exited.then((code) => {
|
|
53
|
+
if (code !== 0) throw new Error(`exit code ${code}`)
|
|
54
|
+
}),
|
|
55
|
+
catch: (e) => new OpenError({ command: "open", cause: e }),
|
|
56
|
+
})
|
|
57
|
+
yield* Console.log(`Opened in Finder: ${existing.path}`)
|
|
58
|
+
} else {
|
|
59
|
+
// Open in editor
|
|
60
|
+
const editorCmd = Option.isSome(editor)
|
|
61
|
+
? editor.value
|
|
62
|
+
: process.env.EDITOR ?? "code"
|
|
63
|
+
|
|
64
|
+
const proc = Bun.spawn([editorCmd, existing.path], {
|
|
65
|
+
stdout: "pipe",
|
|
66
|
+
stderr: "pipe",
|
|
67
|
+
})
|
|
68
|
+
yield* Effect.tryPromise({
|
|
69
|
+
try: () => proc.exited.then((code) => {
|
|
70
|
+
if (code !== 0) throw new Error(`exit code ${code}`)
|
|
71
|
+
}),
|
|
72
|
+
catch: (e) => new OpenError({ command: editorCmd, cause: e }),
|
|
73
|
+
})
|
|
74
|
+
yield* Console.log(`Opened in ${editorCmd}: ${existing.path}`)
|
|
75
|
+
}
|
|
76
|
+
}).pipe(
|
|
77
|
+
Effect.catchAll((error) =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
if (typeof error === "object" && error !== null && "_tag" in error) {
|
|
80
|
+
yield* Console.error(
|
|
81
|
+
`Error: ${(error as { _tag: string })._tag}: ${JSON.stringify(error)}`
|
|
82
|
+
)
|
|
83
|
+
} else {
|
|
84
|
+
yield* Console.error(`Error: ${String(error)}`)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Args, Command, Options } from "@effect/cli"
|
|
2
|
+
import { Console, Effect } from "effect"
|
|
3
|
+
import { NotFoundError, specToString } from "../types.js"
|
|
4
|
+
import { MetadataService } from "../services/metadata.js"
|
|
5
|
+
import { RegistryService } from "../services/registry.js"
|
|
6
|
+
|
|
7
|
+
const specArg = Args.text({ name: "spec" }).pipe(
|
|
8
|
+
Args.withDescription("Package spec to get path for")
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const quietOption = Options.boolean("quiet").pipe(
|
|
12
|
+
Options.withAlias("q"),
|
|
13
|
+
Options.withDefault(false),
|
|
14
|
+
Options.withDescription("Output only the path, exit 1 if not cached")
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
export const path = Command.make(
|
|
18
|
+
"path",
|
|
19
|
+
{ spec: specArg, quiet: quietOption },
|
|
20
|
+
({ spec, quiet }) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const registry = yield* RegistryService
|
|
23
|
+
const metadata = yield* MetadataService
|
|
24
|
+
|
|
25
|
+
const parsedSpec = yield* registry.parseSpec(spec)
|
|
26
|
+
const existing = yield* metadata.find(parsedSpec)
|
|
27
|
+
|
|
28
|
+
if (!existing) {
|
|
29
|
+
if (!quiet) {
|
|
30
|
+
yield* Console.error(`Not cached: ${specToString(parsedSpec)}`)
|
|
31
|
+
yield* Console.error(`Run: repo fetch ${spec}`)
|
|
32
|
+
}
|
|
33
|
+
return yield* Effect.fail(new NotFoundError({ spec: parsedSpec }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
yield* Console.log(existing.path)
|
|
37
|
+
}).pipe(Effect.catchAll(() => Effect.void))
|
|
38
|
+
)
|