@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,201 @@
|
|
|
1
|
+
import { Effect, Layer, Option, Ref } from "effect"
|
|
2
|
+
import { RegistryService } from "../../services/registry.js"
|
|
3
|
+
import type { PackageSpec, Registry } from "../../types.js"
|
|
4
|
+
import { SpecParseError } from "../../types.js"
|
|
5
|
+
import { recordCall, type SequenceRef } from "../sequence.js"
|
|
6
|
+
|
|
7
|
+
// ─── Mock State ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface MockRegistryState {
|
|
10
|
+
fetchedSpecs: Map<string, PackageSpec>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const defaultMockRegistryState: MockRegistryState = {
|
|
14
|
+
fetchedSpecs: new Map(),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── Mock Implementation ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface CreateMockRegistryServiceOptions {
|
|
20
|
+
initialState?: Partial<MockRegistryState>
|
|
21
|
+
sequenceRef?: SequenceRef
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createMockRegistryService(options: CreateMockRegistryServiceOptions = {}): {
|
|
25
|
+
layer: Layer.Layer<RegistryService>
|
|
26
|
+
stateRef: Ref.Ref<MockRegistryState>
|
|
27
|
+
getState: () => Effect.Effect<MockRegistryState>
|
|
28
|
+
} {
|
|
29
|
+
const initialState = options.initialState ?? {}
|
|
30
|
+
const sequenceRef = options.sequenceRef
|
|
31
|
+
|
|
32
|
+
const state: MockRegistryState = {
|
|
33
|
+
...defaultMockRegistryState,
|
|
34
|
+
...initialState,
|
|
35
|
+
fetchedSpecs: new Map(initialState.fetchedSpecs ?? []),
|
|
36
|
+
}
|
|
37
|
+
const stateRef = Ref.unsafeMake(state)
|
|
38
|
+
|
|
39
|
+
const record = (method: string, args: unknown, result?: unknown): Effect.Effect<void> =>
|
|
40
|
+
sequenceRef ? recordCall(sequenceRef, { service: "registry", method, args, result }) : Effect.void
|
|
41
|
+
|
|
42
|
+
const layer = Layer.succeed(
|
|
43
|
+
RegistryService,
|
|
44
|
+
RegistryService.of({
|
|
45
|
+
parseSpec: (input) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const result = parseSpecSync(input)
|
|
48
|
+
yield* record("parseSpec", { input }, result)
|
|
49
|
+
return result
|
|
50
|
+
}),
|
|
51
|
+
|
|
52
|
+
fetch: (spec, destPath, options) =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
yield* record("fetch", { spec, destPath, options })
|
|
55
|
+
yield* Ref.update(stateRef, (s) => {
|
|
56
|
+
const newFetched = new Map(s.fetchedSpecs)
|
|
57
|
+
newFetched.set(destPath, spec)
|
|
58
|
+
return { ...s, fetchedSpecs: newFetched }
|
|
59
|
+
})
|
|
60
|
+
}),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
layer,
|
|
66
|
+
stateRef,
|
|
67
|
+
getState: () => Ref.get(stateRef),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Spec Parsing (copied from registry.ts for test layer) ────────────────────
|
|
72
|
+
|
|
73
|
+
type ParseResult = PackageSpec | { error: string }
|
|
74
|
+
|
|
75
|
+
function parseGithubSpec(input: string): ParseResult {
|
|
76
|
+
const refMatch = input.match(/^([^@#]+)[@#](.+)$/)
|
|
77
|
+
if (refMatch) {
|
|
78
|
+
const [, name, ref] = refMatch
|
|
79
|
+
if (!name?.includes("/")) {
|
|
80
|
+
return { error: "GitHub spec must be owner/repo format" }
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
registry: "github" as Registry,
|
|
84
|
+
name: name,
|
|
85
|
+
version: Option.some(ref!),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!input.includes("/")) {
|
|
90
|
+
return { error: "GitHub spec must be owner/repo format" }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
registry: "github" as Registry,
|
|
95
|
+
name: input,
|
|
96
|
+
version: Option.none(),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseNpmSpec(input: string): ParseResult {
|
|
101
|
+
if (input.startsWith("@")) {
|
|
102
|
+
const match = input.match(/^(@[^@]+)(?:@(.+))?$/)
|
|
103
|
+
if (!match) {
|
|
104
|
+
return { error: "Invalid scoped npm package spec" }
|
|
105
|
+
}
|
|
106
|
+
const [, name, version] = match
|
|
107
|
+
return {
|
|
108
|
+
registry: "npm" as Registry,
|
|
109
|
+
name: name!,
|
|
110
|
+
version: version ? Option.some(version) : Option.none(),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parts = input.split("@")
|
|
115
|
+
if (parts.length > 2) {
|
|
116
|
+
return { error: "Invalid npm package spec" }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [name, version] = parts
|
|
120
|
+
if (!name) {
|
|
121
|
+
return { error: "Package name is required" }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
registry: "npm" as Registry,
|
|
126
|
+
name,
|
|
127
|
+
version: version ? Option.some(version) : Option.none(),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePypiSpec(input: string): ParseResult {
|
|
132
|
+
const match = input.match(/^([^@=]+)(?:[@=]=?(.+))?$/)
|
|
133
|
+
if (!match) {
|
|
134
|
+
return { error: "Invalid PyPI package spec" }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const [, name, version] = match
|
|
138
|
+
if (!name) {
|
|
139
|
+
return { error: "Package name is required" }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
registry: "pypi" as Registry,
|
|
144
|
+
name: name.trim(),
|
|
145
|
+
version: version ? Option.some(version.trim()) : Option.none(),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseCratesSpec(input: string): ParseResult {
|
|
150
|
+
const parts = input.split("@")
|
|
151
|
+
if (parts.length > 2) {
|
|
152
|
+
return { error: "Invalid crates.io spec" }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const [name, version] = parts
|
|
156
|
+
if (!name) {
|
|
157
|
+
return { error: "Crate name is required" }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
registry: "crates" as Registry,
|
|
162
|
+
name: name.trim(),
|
|
163
|
+
version: version ? Option.some(version.trim()) : Option.none(),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseSpecSync(input: string): PackageSpec {
|
|
168
|
+
const trimmed = input.trim()
|
|
169
|
+
|
|
170
|
+
let result: ParseResult
|
|
171
|
+
|
|
172
|
+
if (trimmed.startsWith("npm:")) {
|
|
173
|
+
result = parseNpmSpec(trimmed.slice(4))
|
|
174
|
+
} else if (trimmed.startsWith("pypi:") || trimmed.startsWith("pip:")) {
|
|
175
|
+
const prefix = trimmed.startsWith("pypi:") ? "pypi:" : "pip:"
|
|
176
|
+
result = parsePypiSpec(trimmed.slice(prefix.length))
|
|
177
|
+
} else if (
|
|
178
|
+
trimmed.startsWith("crates:") ||
|
|
179
|
+
trimmed.startsWith("cargo:") ||
|
|
180
|
+
trimmed.startsWith("rust:")
|
|
181
|
+
) {
|
|
182
|
+
const prefixLen = trimmed.indexOf(":") + 1
|
|
183
|
+
result = parseCratesSpec(trimmed.slice(prefixLen))
|
|
184
|
+
} else if (trimmed.startsWith("github:")) {
|
|
185
|
+
result = parseGithubSpec(trimmed.slice(7))
|
|
186
|
+
} else if (trimmed.includes("/") && !trimmed.startsWith("@")) {
|
|
187
|
+
result = parseGithubSpec(trimmed)
|
|
188
|
+
} else {
|
|
189
|
+
result = parseNpmSpec(trimmed)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if ("error" in result) {
|
|
193
|
+
throw new SpecParseError({ input, message: result.error })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Preset Configurations ────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export const MockRegistryServiceDefault = createMockRegistryService()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Command } from "@effect/cli"
|
|
2
|
+
import { BunContext } from "@effect/platform-bun"
|
|
3
|
+
import { Effect, Either, Layer, Ref } from "effect"
|
|
4
|
+
import { expect } from "vitest"
|
|
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 { createTestLayer, type CreateTestLayerOptions } from "./index.js"
|
|
18
|
+
import type { ExpectedCall, RecordedCall } from "./sequence.js"
|
|
19
|
+
|
|
20
|
+
// ─── Root Command ──────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const rootCommand = Command.make("repo").pipe(
|
|
23
|
+
Command.withDescription("Multi-registry source code cache manager"),
|
|
24
|
+
Command.withSubcommands([fetch, list, search, remove, clean, prune, stats, open, path, info])
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// ─── Re-export types ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export type { ExpectedCall } from "./sequence.js"
|
|
30
|
+
|
|
31
|
+
// ─── Sequence Matching ────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function assertSequenceContains(actual: RecordedCall[], expected: ExpectedCall[]): void {
|
|
34
|
+
let actualIndex = 0
|
|
35
|
+
|
|
36
|
+
for (const exp of expected) {
|
|
37
|
+
let found = false
|
|
38
|
+
|
|
39
|
+
while (actualIndex < actual.length) {
|
|
40
|
+
const act = actual[actualIndex]!
|
|
41
|
+
actualIndex++
|
|
42
|
+
|
|
43
|
+
if (act.service === exp.service && act.method === exp.method) {
|
|
44
|
+
if (exp.match) {
|
|
45
|
+
expect(act.args).toMatchObject(exp.match)
|
|
46
|
+
}
|
|
47
|
+
found = true
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!found) {
|
|
53
|
+
const remainingCalls = actual.slice(Math.max(0, actualIndex - 1))
|
|
54
|
+
const formattedCalls = remainingCalls.map((c) => ` ${c.service}.${c.method}`).join("\n")
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Expected ${exp.service}.${exp.method} not found in sequence.\n` +
|
|
57
|
+
`Remaining calls:\n${formattedCalls || " (none)"}\n` +
|
|
58
|
+
`Full sequence:\n${actual.map((c) => ` ${c.service}.${c.method}`).join("\n")}`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── CLI Test Runner ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export class CliTestRunner {
|
|
67
|
+
constructor(
|
|
68
|
+
private args: string[],
|
|
69
|
+
private options: CreateTestLayerOptions
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
expectSequence(expected: ExpectedCall[]): Effect.Effect<void> {
|
|
73
|
+
return Effect.gen(this, function* () {
|
|
74
|
+
const { layer, sequenceRef } = createTestLayer(this.options)
|
|
75
|
+
|
|
76
|
+
const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
|
|
77
|
+
const argv = ["bun", "repo", ...this.args]
|
|
78
|
+
const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
|
|
79
|
+
yield* cli(argv).pipe(Effect.provide(fullLayer), Effect.either)
|
|
80
|
+
|
|
81
|
+
const actual = yield* Ref.get(sequenceRef)
|
|
82
|
+
assertSequenceContains(actual, expected)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expectError(errorTag: string): Effect.Effect<void> {
|
|
87
|
+
return Effect.gen(this, function* () {
|
|
88
|
+
const { layer } = createTestLayer(this.options)
|
|
89
|
+
|
|
90
|
+
const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
|
|
91
|
+
const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
|
|
92
|
+
const result = yield* cli(["bun", "repo", ...this.args]).pipe(
|
|
93
|
+
Effect.provide(fullLayer),
|
|
94
|
+
Effect.either
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
expect(Either.isLeft(result)).toBe(true)
|
|
98
|
+
if (Either.isLeft(result)) {
|
|
99
|
+
const error = result.left as { _tag?: string }
|
|
100
|
+
expect(error._tag).toBe(errorTag)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expectSuccess(): Effect.Effect<void> {
|
|
106
|
+
return this.expectSequence([])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getSequence(): Effect.Effect<RecordedCall[]> {
|
|
110
|
+
return Effect.gen(this, function* () {
|
|
111
|
+
const { layer, sequenceRef } = createTestLayer(this.options)
|
|
112
|
+
|
|
113
|
+
const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
|
|
114
|
+
const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
|
|
115
|
+
yield* cli(["bun", "repo", ...this.args]).pipe(
|
|
116
|
+
Effect.provide(fullLayer),
|
|
117
|
+
Effect.either
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return yield* Ref.get(sequenceRef)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Main API ─────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a CLI test runner with the given arguments and options.
|
|
129
|
+
*
|
|
130
|
+
* IMPORTANT: Due to @effect/cli parser behavior, options with space-separated values
|
|
131
|
+
* must come BEFORE positional arguments. Alternatively, use equals syntax (--option=value).
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```ts
|
|
135
|
+
* // CORRECT: options before positional arg
|
|
136
|
+
* runCli('fetch -f vercel/next.js', {...})
|
|
137
|
+
*
|
|
138
|
+
* // CORRECT: equals syntax
|
|
139
|
+
* runCli('fetch vercel/next.js --force', {...})
|
|
140
|
+
*
|
|
141
|
+
* // Full example:
|
|
142
|
+
* it.effect('fetches a GitHub repo', () =>
|
|
143
|
+
* runCli('fetch vercel/next.js', {
|
|
144
|
+
* cache: { cacheDir: '/tmp/test' },
|
|
145
|
+
* }).expectSequence([
|
|
146
|
+
* { service: 'registry', method: 'parseSpec' },
|
|
147
|
+
* { service: 'cache', method: 'getPath' },
|
|
148
|
+
* { service: 'cache', method: 'exists' },
|
|
149
|
+
* { service: 'git', method: 'clone' },
|
|
150
|
+
* { service: 'metadata', method: 'add' },
|
|
151
|
+
* ])
|
|
152
|
+
* );
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export function runCli(args: string, options: CreateTestLayerOptions = {}): CliTestRunner {
|
|
156
|
+
return new CliTestRunner(args.split(/\s+/), options)
|
|
157
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Effect, Ref } from "effect"
|
|
2
|
+
|
|
3
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A recorded call to a mock service method.
|
|
7
|
+
* Used for sequence-based test assertions.
|
|
8
|
+
*/
|
|
9
|
+
export interface RecordedCall {
|
|
10
|
+
/** Service name (e.g., 'git', 'cache', 'registry') */
|
|
11
|
+
service: string
|
|
12
|
+
/** Method name (e.g., 'clone', 'getPath', 'parseSpec') */
|
|
13
|
+
method: string
|
|
14
|
+
/** Arguments passed to the method */
|
|
15
|
+
args: unknown
|
|
16
|
+
/** Return value (optional, for debugging) */
|
|
17
|
+
result?: unknown
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Expected call for sequence matching.
|
|
22
|
+
* Uses partial matching - only specified fields are checked.
|
|
23
|
+
*/
|
|
24
|
+
export interface ExpectedCall {
|
|
25
|
+
service: string
|
|
26
|
+
method: string
|
|
27
|
+
/** Partial match on args - uses expect().toMatchObject() */
|
|
28
|
+
match?: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Sequence Ref ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export type SequenceRef = Ref.Ref<RecordedCall[]>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new sequence ref for recording calls.
|
|
37
|
+
*/
|
|
38
|
+
export function createSequenceRef(): SequenceRef {
|
|
39
|
+
return Ref.unsafeMake<RecordedCall[]>([])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record a call to the sequence.
|
|
44
|
+
*/
|
|
45
|
+
export function recordCall(
|
|
46
|
+
sequenceRef: SequenceRef,
|
|
47
|
+
call: Omit<RecordedCall, "result"> & { result?: unknown }
|
|
48
|
+
): Effect.Effect<void> {
|
|
49
|
+
return Ref.update(sequenceRef, (seq) => [...seq, call])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get all recorded calls.
|
|
54
|
+
*/
|
|
55
|
+
export function getSequence(sequenceRef: SequenceRef): Effect.Effect<RecordedCall[]> {
|
|
56
|
+
return Ref.get(sequenceRef)
|
|
57
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Data, Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
// Registry types
|
|
4
|
+
export type Registry = "github" | "npm" | "pypi" | "crates"
|
|
5
|
+
|
|
6
|
+
// Package spec - identifies a package/repo across registries
|
|
7
|
+
export const PackageSpec = Schema.Struct({
|
|
8
|
+
registry: Schema.Literal("github", "npm", "pypi", "crates"),
|
|
9
|
+
name: Schema.String,
|
|
10
|
+
version: Schema.optionalWith(Schema.String, { as: "Option" }),
|
|
11
|
+
})
|
|
12
|
+
export type PackageSpec = typeof PackageSpec.Type
|
|
13
|
+
|
|
14
|
+
// Metadata stored per-cached entry
|
|
15
|
+
export const RepoMetadata = Schema.Struct({
|
|
16
|
+
spec: PackageSpec,
|
|
17
|
+
fetchedAt: Schema.String,
|
|
18
|
+
lastAccessedAt: Schema.String,
|
|
19
|
+
sizeBytes: Schema.Number,
|
|
20
|
+
path: Schema.String,
|
|
21
|
+
})
|
|
22
|
+
export type RepoMetadata = typeof RepoMetadata.Type
|
|
23
|
+
|
|
24
|
+
// Global metadata index
|
|
25
|
+
export const MetadataIndex = Schema.Struct({
|
|
26
|
+
version: Schema.Number,
|
|
27
|
+
repos: Schema.Array(RepoMetadata),
|
|
28
|
+
})
|
|
29
|
+
export type MetadataIndex = typeof MetadataIndex.Type
|
|
30
|
+
|
|
31
|
+
// Tagged Errors
|
|
32
|
+
export class SpecParseError extends Data.TaggedError("SpecParseError")<{
|
|
33
|
+
readonly input: string
|
|
34
|
+
readonly message: string
|
|
35
|
+
}> {}
|
|
36
|
+
|
|
37
|
+
export class RegistryError extends Data.TaggedError("RegistryError")<{
|
|
38
|
+
readonly registry: Registry
|
|
39
|
+
readonly operation: string
|
|
40
|
+
readonly cause: unknown
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
export class GitError extends Data.TaggedError("GitError")<{
|
|
44
|
+
readonly operation: string
|
|
45
|
+
readonly repo: string
|
|
46
|
+
readonly cause: unknown
|
|
47
|
+
}> {}
|
|
48
|
+
|
|
49
|
+
export class CacheError extends Data.TaggedError("CacheError")<{
|
|
50
|
+
readonly operation: string
|
|
51
|
+
readonly path: string
|
|
52
|
+
readonly cause: unknown
|
|
53
|
+
}> {}
|
|
54
|
+
|
|
55
|
+
export class MetadataError extends Data.TaggedError("MetadataError")<{
|
|
56
|
+
readonly operation: string
|
|
57
|
+
readonly cause: unknown
|
|
58
|
+
}> {}
|
|
59
|
+
|
|
60
|
+
export class NotFoundError extends Data.TaggedError("NotFoundError")<{
|
|
61
|
+
readonly spec: PackageSpec
|
|
62
|
+
}> {}
|
|
63
|
+
|
|
64
|
+
export class NetworkError extends Data.TaggedError("NetworkError")<{
|
|
65
|
+
readonly url: string
|
|
66
|
+
readonly cause: unknown
|
|
67
|
+
}> {}
|
|
68
|
+
|
|
69
|
+
export class OpenError extends Data.TaggedError("OpenError")<{
|
|
70
|
+
readonly command: string
|
|
71
|
+
readonly cause: unknown
|
|
72
|
+
}> {}
|
|
73
|
+
|
|
74
|
+
// Utility to format bytes
|
|
75
|
+
export const formatBytes = (bytes: number): string => {
|
|
76
|
+
if (bytes < 1024) return `${bytes} B`
|
|
77
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
78
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
79
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Utility to format relative time
|
|
83
|
+
export const formatRelativeTime = (date: Date): string => {
|
|
84
|
+
const now = new Date()
|
|
85
|
+
const diffMs = now.getTime() - date.getTime()
|
|
86
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
87
|
+
|
|
88
|
+
if (diffDays === 0) return "today"
|
|
89
|
+
if (diffDays === 1) return "yesterday"
|
|
90
|
+
if (diffDays < 7) return `${diffDays} days ago`
|
|
91
|
+
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
|
|
92
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
|
|
93
|
+
return `${Math.floor(diffDays / 365)} years ago`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Spec display helper
|
|
97
|
+
export const specToString = (spec: PackageSpec): string => {
|
|
98
|
+
const prefix = spec.registry === "github" ? "" : `${spec.registry}:`
|
|
99
|
+
const version = spec.version._tag === "Some" ? `@${spec.version.value}` : ""
|
|
100
|
+
return `${prefix}${spec.name}${version}`
|
|
101
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "preserve",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"exactOptionalPropertyTypes": true,
|
|
9
|
+
"noUnusedLocals": true,
|
|
10
|
+
"noImplicitOverride": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"outDir": "dist",
|
|
17
|
+
"rootDir": "src",
|
|
18
|
+
"declaration": true,
|
|
19
|
+
"declarationMap": true,
|
|
20
|
+
"sourceMap": true,
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"types": ["bun-types"],
|
|
23
|
+
"plugins": [{ "name": "@effect/language-service" }]
|
|
24
|
+
},
|
|
25
|
+
"include": ["src/**/*"],
|
|
26
|
+
"exclude": ["node_modules", "dist"]
|
|
27
|
+
}
|