ocx 0.1.1
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/dist/bin/ocx-darwin-arm64 +0 -0
- package/dist/index.js +11857 -0
- package/dist/index.js.map +78 -0
- package/package.json +29 -0
- package/scripts/build-binary.ts +96 -0
- package/scripts/build.ts +17 -0
- package/scripts/install.sh +65 -0
- package/src/commands/add.ts +229 -0
- package/src/commands/build.ts +150 -0
- package/src/commands/diff.ts +139 -0
- package/src/commands/init.ts +90 -0
- package/src/commands/registry.ts +153 -0
- package/src/commands/search.ts +159 -0
- package/src/constants.ts +18 -0
- package/src/index.ts +42 -0
- package/src/registry/fetcher.ts +168 -0
- package/src/registry/index.ts +2 -0
- package/src/registry/opencode-config.ts +182 -0
- package/src/registry/resolver.ts +127 -0
- package/src/schemas/config.ts +207 -0
- package/src/schemas/index.ts +6 -0
- package/src/schemas/registry.ts +268 -0
- package/src/utils/env.ts +27 -0
- package/src/utils/errors.ts +81 -0
- package/src/utils/handle-error.ts +108 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/json-output.ts +107 -0
- package/src/utils/logger.ts +72 -0
- package/src/utils/spinner.ts +46 -0
- package/tests/add.test.ts +102 -0
- package/tests/build.test.ts +136 -0
- package/tests/diff.test.ts +47 -0
- package/tests/helpers.ts +68 -0
- package/tests/init.test.ts +52 -0
- package/tests/mock-registry.ts +105 -0
- package/tests/registry.test.ts +78 -0
- package/tests/search.test.ts +64 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Fetcher with in-memory caching
|
|
3
|
+
* Based on: https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/src/registry/fetcher.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ComponentManifest, RegistryIndex, McpServer, Packument } from "../schemas/registry.js"
|
|
7
|
+
import {
|
|
8
|
+
componentManifestSchema,
|
|
9
|
+
registryIndexSchema,
|
|
10
|
+
packumentSchema,
|
|
11
|
+
} from "../schemas/registry.js"
|
|
12
|
+
import { NotFoundError, NetworkError, ValidationError } from "../utils/errors.js"
|
|
13
|
+
|
|
14
|
+
// In-memory cache for deduplication
|
|
15
|
+
const cache = new Map<string, Promise<unknown>>()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch with caching - deduplicates concurrent requests
|
|
19
|
+
*/
|
|
20
|
+
async function fetchWithCache<T>(url: string, parse: (data: unknown) => T): Promise<T> {
|
|
21
|
+
const cached = cache.get(url)
|
|
22
|
+
if (cached) {
|
|
23
|
+
return cached as Promise<T>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const promise = (async () => {
|
|
27
|
+
const response = await fetch(url)
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (response.status === 404) {
|
|
31
|
+
throw new NotFoundError(`Not found: ${url}`)
|
|
32
|
+
}
|
|
33
|
+
throw new NetworkError(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const data = await response.json()
|
|
37
|
+
return parse(data)
|
|
38
|
+
})()
|
|
39
|
+
|
|
40
|
+
cache.set(url, promise)
|
|
41
|
+
|
|
42
|
+
// Clean up cache on error
|
|
43
|
+
promise.catch(() => cache.delete(url))
|
|
44
|
+
|
|
45
|
+
return promise
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Clear the fetch cache
|
|
50
|
+
*/
|
|
51
|
+
export function clearCache(): void {
|
|
52
|
+
cache.clear()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fetch registry index
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchRegistryIndex(baseUrl: string): Promise<RegistryIndex> {
|
|
59
|
+
const url = `${baseUrl.replace(/\/$/, "")}/index.json`
|
|
60
|
+
|
|
61
|
+
return fetchWithCache(url, (data) => {
|
|
62
|
+
const result = registryIndexSchema.safeParse(data)
|
|
63
|
+
if (!result.success) {
|
|
64
|
+
throw new ValidationError(`Invalid registry format at ${url}: ${result.error.message}`)
|
|
65
|
+
}
|
|
66
|
+
return result.data
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch a component from registry and return the latest manifest
|
|
72
|
+
*/
|
|
73
|
+
export async function fetchComponent(baseUrl: string, name: string): Promise<ComponentManifest> {
|
|
74
|
+
const url = `${baseUrl.replace(/\/$/, "")}/components/${name}.json`
|
|
75
|
+
|
|
76
|
+
return fetchWithCache(url, (data) => {
|
|
77
|
+
// 1. Parse as packument
|
|
78
|
+
const packumentResult = packumentSchema.safeParse(data)
|
|
79
|
+
if (!packumentResult.success) {
|
|
80
|
+
throw new ValidationError(
|
|
81
|
+
`Invalid packument format for "${name}": ${packumentResult.error.message}`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const packument = packumentResult.data
|
|
86
|
+
const latestVersion = packument["dist-tags"].latest
|
|
87
|
+
const manifest = packument.versions[latestVersion]
|
|
88
|
+
|
|
89
|
+
if (!manifest) {
|
|
90
|
+
throw new ValidationError(
|
|
91
|
+
`Component "${name}" has no manifest for latest version ${latestVersion}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Validate manifest
|
|
96
|
+
const manifestResult = componentManifestSchema.safeParse(manifest)
|
|
97
|
+
if (!manifestResult.success) {
|
|
98
|
+
throw new ValidationError(
|
|
99
|
+
`Invalid component manifest for "${name}@${latestVersion}": ${manifestResult.error.message}`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return manifestResult.data
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetch multiple components in parallel
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchComponents(
|
|
111
|
+
baseUrl: string,
|
|
112
|
+
names: string[],
|
|
113
|
+
): Promise<ComponentManifest[]> {
|
|
114
|
+
const results = await Promise.allSettled(names.map((name) => fetchComponent(baseUrl, name)))
|
|
115
|
+
|
|
116
|
+
const components: ComponentManifest[] = []
|
|
117
|
+
const errors: string[] = []
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < results.length; i++) {
|
|
120
|
+
const result = results[i]
|
|
121
|
+
if (result.status === "fulfilled") {
|
|
122
|
+
components.push(result.value)
|
|
123
|
+
} else {
|
|
124
|
+
errors.push(`${names[i]}: ${result.reason.message}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (errors.length > 0) {
|
|
129
|
+
throw new NetworkError(`Failed to fetch components:\n${errors.join("\n")}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return components
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if a component exists in registry
|
|
137
|
+
*/
|
|
138
|
+
export async function componentExists(baseUrl: string, name: string): Promise<boolean> {
|
|
139
|
+
try {
|
|
140
|
+
await fetchComponent(baseUrl, name)
|
|
141
|
+
return true
|
|
142
|
+
} catch {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fetch actual file content from registry
|
|
149
|
+
*/
|
|
150
|
+
export async function fetchFileContent(
|
|
151
|
+
baseUrl: string,
|
|
152
|
+
componentName: string,
|
|
153
|
+
filePath: string,
|
|
154
|
+
): Promise<string> {
|
|
155
|
+
const url = `${baseUrl.replace(/\/$/, "")}/components/${componentName}/${filePath}`
|
|
156
|
+
|
|
157
|
+
const response = await fetch(url)
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new NetworkError(
|
|
160
|
+
`Failed to fetch file ${filePath} for ${componentName}: ${response.status} ${response.statusText}`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return response.text()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Re-export types for convenience
|
|
168
|
+
export type { ComponentManifest, RegistryIndex, McpServer }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode.json Modifier
|
|
3
|
+
* Handles reading, merging, and writing opencode.json configuration
|
|
4
|
+
*
|
|
5
|
+
* Key responsibilities:
|
|
6
|
+
* - Add MCP server definitions
|
|
7
|
+
* - Deep merge without clobbering user config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { McpServer } from "../schemas/registry.js"
|
|
11
|
+
|
|
12
|
+
export interface OpencodeConfig {
|
|
13
|
+
$schema?: string
|
|
14
|
+
mcp?: Record<string, McpServerConfig>
|
|
15
|
+
tools?: Record<string, boolean>
|
|
16
|
+
agent?: Record<string, AgentConfig>
|
|
17
|
+
default_agent?: string
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface McpServerConfig {
|
|
22
|
+
type: "remote" | "local"
|
|
23
|
+
url?: string
|
|
24
|
+
command?: string[]
|
|
25
|
+
headers?: Record<string, string>
|
|
26
|
+
enabled?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AgentConfig {
|
|
30
|
+
disable?: boolean
|
|
31
|
+
tools?: Record<string, boolean>
|
|
32
|
+
[key: string]: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read opencode.json or opencode.jsonc from a directory
|
|
37
|
+
*/
|
|
38
|
+
export async function readOpencodeConfig(cwd: string): Promise<{
|
|
39
|
+
config: OpencodeConfig
|
|
40
|
+
path: string
|
|
41
|
+
} | null> {
|
|
42
|
+
const jsonPath = `${cwd}/opencode.json`
|
|
43
|
+
const jsoncPath = `${cwd}/opencode.jsonc`
|
|
44
|
+
|
|
45
|
+
// Try opencode.jsonc first, then opencode.json
|
|
46
|
+
for (const configPath of [jsoncPath, jsonPath]) {
|
|
47
|
+
const file = Bun.file(configPath)
|
|
48
|
+
if (await file.exists()) {
|
|
49
|
+
const content = await file.text()
|
|
50
|
+
// Strip comments for JSONC
|
|
51
|
+
const stripped = configPath.endsWith(".jsonc") ? stripJsonComments(content) : content
|
|
52
|
+
return {
|
|
53
|
+
config: JSON.parse(stripped) as OpencodeConfig,
|
|
54
|
+
path: configPath,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write opencode.json config
|
|
64
|
+
*/
|
|
65
|
+
export async function writeOpencodeConfig(path: string, config: OpencodeConfig): Promise<void> {
|
|
66
|
+
const content = JSON.stringify(config, null, 2)
|
|
67
|
+
await Bun.write(path, content)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply MCP servers to opencode config
|
|
72
|
+
* Non-destructive: only adds new servers, doesn't overwrite existing
|
|
73
|
+
*/
|
|
74
|
+
export function applyMcpServers(
|
|
75
|
+
config: OpencodeConfig,
|
|
76
|
+
mcpServers: Record<string, McpServer>,
|
|
77
|
+
): { config: OpencodeConfig; added: string[]; skipped: string[] } {
|
|
78
|
+
const added: string[] = []
|
|
79
|
+
const skipped: string[] = []
|
|
80
|
+
|
|
81
|
+
if (!config.mcp) {
|
|
82
|
+
config.mcp = {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const [name, server] of Object.entries(mcpServers)) {
|
|
86
|
+
if (config.mcp[name]) {
|
|
87
|
+
// Already exists, skip
|
|
88
|
+
skipped.push(name)
|
|
89
|
+
} else {
|
|
90
|
+
// Add new server
|
|
91
|
+
const serverConfig: McpServerConfig = {
|
|
92
|
+
type: server.type,
|
|
93
|
+
enabled: server.enabled,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (server.type === "remote" && server.url) {
|
|
97
|
+
serverConfig.url = server.url
|
|
98
|
+
}
|
|
99
|
+
if (server.type === "local" && server.command) {
|
|
100
|
+
serverConfig.command = server.command
|
|
101
|
+
}
|
|
102
|
+
if (server.headers) {
|
|
103
|
+
serverConfig.headers = server.headers
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
config.mcp[name] = serverConfig
|
|
107
|
+
added.push(name)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { config, added, skipped }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Strip JSON comments (simple implementation)
|
|
116
|
+
*/
|
|
117
|
+
function stripJsonComments(content: string): string {
|
|
118
|
+
// Remove single-line comments
|
|
119
|
+
let result = content.replace(/\/\/.*$/gm, "")
|
|
120
|
+
// Remove multi-line comments
|
|
121
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create or update opencode.json with required configuration
|
|
127
|
+
*/
|
|
128
|
+
export async function updateOpencodeConfig(
|
|
129
|
+
cwd: string,
|
|
130
|
+
options: {
|
|
131
|
+
mcpServers?: Record<string, McpServer>
|
|
132
|
+
defaultAgent?: string
|
|
133
|
+
},
|
|
134
|
+
): Promise<{
|
|
135
|
+
path: string
|
|
136
|
+
created: boolean
|
|
137
|
+
mcpAdded: string[]
|
|
138
|
+
mcpSkipped: string[]
|
|
139
|
+
}> {
|
|
140
|
+
let existing = await readOpencodeConfig(cwd)
|
|
141
|
+
let config: OpencodeConfig
|
|
142
|
+
let configPath: string
|
|
143
|
+
let created = false
|
|
144
|
+
|
|
145
|
+
if (existing) {
|
|
146
|
+
config = existing.config
|
|
147
|
+
configPath = existing.path
|
|
148
|
+
} else {
|
|
149
|
+
// Create new config
|
|
150
|
+
config = {
|
|
151
|
+
$schema: "https://opencode.ai/config.json",
|
|
152
|
+
}
|
|
153
|
+
configPath = `${cwd}/opencode.json`
|
|
154
|
+
created = true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let mcpAdded: string[] = []
|
|
158
|
+
let mcpSkipped: string[] = []
|
|
159
|
+
|
|
160
|
+
// Apply MCP servers
|
|
161
|
+
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
|
|
162
|
+
const result = applyMcpServers(config, options.mcpServers)
|
|
163
|
+
config = result.config
|
|
164
|
+
mcpAdded = result.added
|
|
165
|
+
mcpSkipped = result.skipped
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Set default agent if provided and not already set
|
|
169
|
+
if (options.defaultAgent && !config.default_agent) {
|
|
170
|
+
config.default_agent = options.defaultAgent
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Write config
|
|
174
|
+
await writeOpencodeConfig(configPath, config)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
path: configPath,
|
|
178
|
+
created,
|
|
179
|
+
mcpAdded,
|
|
180
|
+
mcpSkipped,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Resolver with topological sort
|
|
3
|
+
* Based on: https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/src/registry/resolver.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ComponentManifest, McpServer } from "../schemas/registry.js"
|
|
7
|
+
import { fetchComponent } from "./fetcher.js"
|
|
8
|
+
import { ValidationError, OCXError } from "../utils/errors.js"
|
|
9
|
+
import type { RegistryConfig } from "../schemas/config.js"
|
|
10
|
+
|
|
11
|
+
export interface ResolvedComponent extends ComponentManifest {
|
|
12
|
+
registryName: string
|
|
13
|
+
baseUrl: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ResolvedDependencies {
|
|
17
|
+
/** All components in dependency order (dependencies first) */
|
|
18
|
+
components: ResolvedComponent[]
|
|
19
|
+
/** Install order (component names) */
|
|
20
|
+
installOrder: string[]
|
|
21
|
+
/** Aggregated MCP servers from all components */
|
|
22
|
+
mcpServers: Record<string, McpServer>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve all dependencies for a set of components across multiple registries
|
|
27
|
+
* Returns components in topological order (dependencies first)
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveDependencies(
|
|
30
|
+
registries: Record<string, RegistryConfig>,
|
|
31
|
+
componentNames: string[],
|
|
32
|
+
): Promise<ResolvedDependencies> {
|
|
33
|
+
const resolved = new Map<string, ResolvedComponent>()
|
|
34
|
+
const visiting = new Set<string>()
|
|
35
|
+
const mcpServers: Record<string, McpServer> = {}
|
|
36
|
+
|
|
37
|
+
async function resolve(name: string, path: string[] = []): Promise<void> {
|
|
38
|
+
// Already resolved
|
|
39
|
+
if (resolved.has(name)) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cycle detection
|
|
44
|
+
if (visiting.has(name)) {
|
|
45
|
+
const cycle = [...path, name].join(" → ")
|
|
46
|
+
throw new ValidationError(`Circular dependency detected: ${cycle}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
visiting.add(name)
|
|
50
|
+
|
|
51
|
+
// Find component in any registry
|
|
52
|
+
let component: ComponentManifest | null = null
|
|
53
|
+
let foundRegistry: { name: string; url: string } | null = null
|
|
54
|
+
|
|
55
|
+
const registryEntries = Object.entries(registries)
|
|
56
|
+
|
|
57
|
+
for (const [regName, regConfig] of registryEntries) {
|
|
58
|
+
try {
|
|
59
|
+
const manifest = await fetchComponent(regConfig.url, name)
|
|
60
|
+
component = manifest
|
|
61
|
+
foundRegistry = { name: regName, url: regConfig.url }
|
|
62
|
+
break
|
|
63
|
+
} catch (_err) {
|
|
64
|
+
// Component not in this registry, try next
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!component || !foundRegistry) {
|
|
70
|
+
throw new OCXError(
|
|
71
|
+
`Component '${name}' not found in any configured registry.`,
|
|
72
|
+
"NOT_FOUND",
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve dependencies first (depth-first)
|
|
77
|
+
for (const dep of component.dependencies) {
|
|
78
|
+
await resolve(dep, [...path, name])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add to resolved (dependencies are already added)
|
|
82
|
+
resolved.set(name, {
|
|
83
|
+
...component,
|
|
84
|
+
registryName: foundRegistry.name,
|
|
85
|
+
baseUrl: foundRegistry.url,
|
|
86
|
+
})
|
|
87
|
+
visiting.delete(name)
|
|
88
|
+
|
|
89
|
+
// Collect MCP servers
|
|
90
|
+
if (component.mcpServers) {
|
|
91
|
+
for (const [serverName, config] of Object.entries(component.mcpServers)) {
|
|
92
|
+
mcpServers[serverName] = config as McpServer
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Resolve all requested components
|
|
98
|
+
for (const name of componentNames) {
|
|
99
|
+
await resolve(name)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Convert to array (already in topological order due to depth-first)
|
|
103
|
+
const components = Array.from(resolved.values())
|
|
104
|
+
const installOrder = Array.from(resolved.keys())
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
components,
|
|
108
|
+
installOrder,
|
|
109
|
+
mcpServers,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if installing components would create conflicts
|
|
115
|
+
*/
|
|
116
|
+
export function checkConflicts(existing: string[], toInstall: string[]): string[] {
|
|
117
|
+
const conflicts: string[] = []
|
|
118
|
+
const existingSet = new Set(existing)
|
|
119
|
+
|
|
120
|
+
for (const name of toInstall) {
|
|
121
|
+
if (existingSet.has(name)) {
|
|
122
|
+
conflicts.push(name)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return conflicts
|
|
127
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config & Lockfile Schemas
|
|
3
|
+
*
|
|
4
|
+
* Schemas for ocx.jsonc (user config) and ocx.lock (auto-generated lockfile).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from "zod"
|
|
8
|
+
import { mcpServerSchema } from "./registry.js"
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// OCX CONFIG SCHEMA (ocx.jsonc)
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registry configuration in ocx.jsonc
|
|
16
|
+
*/
|
|
17
|
+
export const registryConfigSchema = z.object({
|
|
18
|
+
/** Registry URL */
|
|
19
|
+
url: z.string().url("Registry URL must be a valid URL"),
|
|
20
|
+
|
|
21
|
+
/** Optional version pin */
|
|
22
|
+
version: z.string().optional(),
|
|
23
|
+
|
|
24
|
+
/** Optional auth headers (supports ${ENV_VAR} expansion) */
|
|
25
|
+
headers: z.record(z.string()).optional(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export type RegistryConfig = z.infer<typeof registryConfigSchema>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Main OCX config schema (ocx.jsonc)
|
|
32
|
+
*/
|
|
33
|
+
export const ocxConfigSchema = z.object({
|
|
34
|
+
/** Schema URL for IDE support */
|
|
35
|
+
$schema: z.string().optional(),
|
|
36
|
+
|
|
37
|
+
/** Configured registries */
|
|
38
|
+
registries: z.record(registryConfigSchema).default({}),
|
|
39
|
+
|
|
40
|
+
/** Lock registries - prevent adding/removing (enterprise feature) */
|
|
41
|
+
lockRegistries: z.boolean().default(false),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export type OcxConfig = z.infer<typeof ocxConfigSchema>
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// OCX LOCKFILE SCHEMA (ocx.lock)
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Installed component entry in lockfile
|
|
52
|
+
*/
|
|
53
|
+
export const installedComponentSchema = z.object({
|
|
54
|
+
/** Registry this was installed from */
|
|
55
|
+
registry: z.string(),
|
|
56
|
+
|
|
57
|
+
/** Version at time of install */
|
|
58
|
+
version: z.string(),
|
|
59
|
+
|
|
60
|
+
/** SHA-256 hash of installed files for integrity */
|
|
61
|
+
hash: z.string(),
|
|
62
|
+
|
|
63
|
+
/** Target path where installed */
|
|
64
|
+
target: z.string(),
|
|
65
|
+
|
|
66
|
+
/** ISO timestamp of installation */
|
|
67
|
+
installedAt: z.string(),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export type InstalledComponent = z.infer<typeof installedComponentSchema>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* OCX lockfile schema (ocx.lock)
|
|
74
|
+
*/
|
|
75
|
+
export const ocxLockSchema = z.object({
|
|
76
|
+
/** Lockfile format version */
|
|
77
|
+
lockVersion: z.literal(1),
|
|
78
|
+
|
|
79
|
+
/** Installed components */
|
|
80
|
+
installed: z.record(installedComponentSchema).default({}),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export type OcxLock = z.infer<typeof ocxLockSchema>
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// OPENCODE.JSON MODIFICATION SCHEMAS
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* MCP server config for opencode.json
|
|
91
|
+
*/
|
|
92
|
+
export const opencodeMcpSchema = z.record(mcpServerSchema)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Agent config for opencode.json
|
|
96
|
+
*/
|
|
97
|
+
export const opencodeAgentSchema = z.object({
|
|
98
|
+
disable: z.boolean().optional(),
|
|
99
|
+
tools: z.record(z.boolean()).optional(),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Partial opencode.json schema (what OCX modifies)
|
|
104
|
+
*/
|
|
105
|
+
export const opencodeConfigPatchSchema = z.object({
|
|
106
|
+
/** Default agent */
|
|
107
|
+
default_agent: z.string().optional(),
|
|
108
|
+
|
|
109
|
+
/** MCP servers */
|
|
110
|
+
mcp: opencodeMcpSchema.optional(),
|
|
111
|
+
|
|
112
|
+
/** Tool configuration */
|
|
113
|
+
tools: z.record(z.boolean()).optional(),
|
|
114
|
+
|
|
115
|
+
/** Agent configuration */
|
|
116
|
+
agent: z.record(opencodeAgentSchema).optional(),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export type OpencodeConfigPatch = z.infer<typeof opencodeConfigPatchSchema>
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// SCHEMA INDEX
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
export const schemas = {
|
|
126
|
+
config: ocxConfigSchema,
|
|
127
|
+
lock: ocxLockSchema,
|
|
128
|
+
registryConfig: registryConfigSchema,
|
|
129
|
+
installedComponent: installedComponentSchema,
|
|
130
|
+
opencodeConfigPatch: opencodeConfigPatchSchema,
|
|
131
|
+
} as const
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// CONFIG FILE HELPERS
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
const CONFIG_FILE = "ocx.jsonc"
|
|
138
|
+
const LOCK_FILE = "ocx.lock"
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Strip JSONC comments for parsing.
|
|
142
|
+
* Robust version that respects strings (avoids breaking URLs).
|
|
143
|
+
*/
|
|
144
|
+
export function stripJsonComments(content: string): string {
|
|
145
|
+
// Regex that matches either a string, a block comment, or a line comment
|
|
146
|
+
// We use this to correctly skip slashes inside strings
|
|
147
|
+
return content.replace(/("(?:[^"\\]|\\.)*")|(\/\*[\s\S]*?\*\/)|(\/\/.*)$/gm, (match, string, block, line) => {
|
|
148
|
+
if (string) return string; // Return string as is
|
|
149
|
+
return ""; // Remove comment
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read ocx.jsonc config file
|
|
155
|
+
*/
|
|
156
|
+
export async function readOcxConfig(cwd: string): Promise<OcxConfig | null> {
|
|
157
|
+
const configPath = `${cwd}/${CONFIG_FILE}`
|
|
158
|
+
const file = Bun.file(configPath)
|
|
159
|
+
|
|
160
|
+
if (!(await file.exists())) {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const content = await file.text()
|
|
165
|
+
try {
|
|
166
|
+
const json = JSON.parse(stripJsonComments(content))
|
|
167
|
+
return ocxConfigSchema.parse(json)
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// If parsing fails, we want to know why
|
|
170
|
+
console.error(`Error parsing ${configPath}:`, error)
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Write ocx.jsonc config file
|
|
177
|
+
*/
|
|
178
|
+
export async function writeOcxConfig(cwd: string, config: OcxConfig): Promise<void> {
|
|
179
|
+
const configPath = `${cwd}/${CONFIG_FILE}`
|
|
180
|
+
const content = JSON.stringify(config, null, 2)
|
|
181
|
+
await Bun.write(configPath, content)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read ocx.lock lockfile
|
|
186
|
+
*/
|
|
187
|
+
export async function readOcxLock(cwd: string): Promise<OcxLock | null> {
|
|
188
|
+
const lockPath = `${cwd}/${LOCK_FILE}`
|
|
189
|
+
const file = Bun.file(lockPath)
|
|
190
|
+
|
|
191
|
+
if (!(await file.exists())) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const content = await file.text()
|
|
196
|
+
const json = JSON.parse(stripJsonComments(content))
|
|
197
|
+
return ocxLockSchema.parse(json)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Write ocx.lock lockfile
|
|
202
|
+
*/
|
|
203
|
+
export async function writeOcxLock(cwd: string, lock: OcxLock): Promise<void> {
|
|
204
|
+
const lockPath = `${cwd}/${LOCK_FILE}`
|
|
205
|
+
const content = JSON.stringify(lock, null, 2)
|
|
206
|
+
await Bun.write(lockPath, content)
|
|
207
|
+
}
|