Skip to content

Commit 41dba0d

Browse files
committed
config validation
1 parent 6674c60 commit 41dba0d

10 files changed

Lines changed: 174 additions & 72 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

opencode.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
33
"keybinds": {},
4-
"mcp": {}
4+
"mcp": {},
5+
"provider": {}
56
}

packages/opencode/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"xdg-basedir": "5.1.0",
4343
"yargs": "18.0.0",
4444
"zod": "catalog:",
45-
"zod-openapi": "4.2.4"
45+
"zod-openapi": "4.2.4",
46+
"zod-validation-error": "3.5.2"
4647
}
4748
}

packages/opencode/src/cli/error.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Config } from "../config/config"
2+
3+
export function FormatError(input: unknown) {
4+
if (Config.JsonError.isInstance(input))
5+
return `Config file at ${input.data.path} is not valid JSON`
6+
if (Config.InvalidError.isInstance(input))
7+
return [
8+
`Config file at ${input.data.path} is invalid`,
9+
...(input.data.issues?.map(
10+
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
11+
) ?? []),
12+
].join("\n")
13+
}

packages/opencode/src/config/config.ts

Lines changed: 107 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,17 @@ import { mergeDeep } from "remeda"
88
import { Global } from "../global"
99
import fs from "fs/promises"
1010
import { lazy } from "../util/lazy"
11+
import { NamedError } from "../util/error"
1112

1213
export namespace Config {
1314
const log = Log.create({ service: "config" })
1415

1516
export const state = App.state("config", async (app) => {
1617
let result = await global()
1718
for (const file of ["opencode.jsonc", "opencode.json"]) {
18-
const [resolved] = await Filesystem.findUp(
19-
file,
20-
app.path.cwd,
21-
app.path.root,
22-
)
23-
if (!resolved) continue
24-
try {
25-
result = mergeDeep(
26-
result,
27-
await import(resolved).then((mod) => Info.parse(mod.default)),
28-
)
29-
log.info("found", { path: resolved })
30-
break
31-
} catch (e) {
32-
if (e instanceof z.ZodError) {
33-
for (const issue of e.issues) {
34-
log.info(issue.message)
35-
}
36-
throw e
37-
}
38-
continue
19+
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
20+
for (const resolved of found.toReversed()) {
21+
result = mergeDeep(result, await load(resolved))
3922
}
4023
}
4124
log.info("loaded", result)
@@ -45,9 +28,16 @@ export namespace Config {
4528
export const McpLocal = z
4629
.object({
4730
type: z.literal("local").describe("Type of MCP server connection"),
48-
command: z.string().array().describe("Command and arguments to run the MCP server"),
49-
environment: z.record(z.string(), z.string()).optional().describe("Environment variables to set when running the MCP server"),
31+
command: z
32+
.string()
33+
.array()
34+
.describe("Command and arguments to run the MCP server"),
35+
environment: z
36+
.record(z.string(), z.string())
37+
.optional()
38+
.describe("Environment variables to set when running the MCP server"),
5039
})
40+
.strict()
5141
.openapi({
5242
ref: "Config.McpLocal",
5343
})
@@ -57,6 +47,7 @@ export namespace Config {
5747
type: z.literal("remote").describe("Type of MCP server connection"),
5848
url: z.string().describe("URL of the remote MCP server"),
5949
})
50+
.strict()
6051
.openapi({
6152
ref: "Config.McpRemote",
6253
})
@@ -66,41 +57,84 @@ export namespace Config {
6657

6758
export const Keybinds = z
6859
.object({
69-
leader: z.string().optional().describe("Leader key for keybind combinations"),
60+
leader: z
61+
.string()
62+
.optional()
63+
.describe("Leader key for keybind combinations"),
7064
help: z.string().optional().describe("Show help dialog"),
7165
editor_open: z.string().optional().describe("Open external editor"),
7266
session_new: z.string().optional().describe("Create a new session"),
7367
session_list: z.string().optional().describe("List all sessions"),
7468
session_share: z.string().optional().describe("Share current session"),
75-
session_interrupt: z.string().optional().describe("Interrupt current session"),
76-
session_compact: z.string().optional().describe("Toggle compact mode for session"),
69+
session_interrupt: z
70+
.string()
71+
.optional()
72+
.describe("Interrupt current session"),
73+
session_compact: z
74+
.string()
75+
.optional()
76+
.describe("Toggle compact mode for session"),
7777
tool_details: z.string().optional().describe("Show tool details"),
7878
model_list: z.string().optional().describe("List available models"),
7979
theme_list: z.string().optional().describe("List available themes"),
80-
project_init: z.string().optional().describe("Initialize project configuration"),
80+
project_init: z
81+
.string()
82+
.optional()
83+
.describe("Initialize project configuration"),
8184
input_clear: z.string().optional().describe("Clear input field"),
8285
input_paste: z.string().optional().describe("Paste from clipboard"),
8386
input_submit: z.string().optional().describe("Submit input"),
8487
input_newline: z.string().optional().describe("Insert newline in input"),
85-
history_previous: z.string().optional().describe("Navigate to previous history item"),
86-
history_next: z.string().optional().describe("Navigate to next history item"),
87-
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
88-
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
89-
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
90-
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
91-
messages_previous: z.string().optional().describe("Navigate to previous message"),
88+
history_previous: z
89+
.string()
90+
.optional()
91+
.describe("Navigate to previous history item"),
92+
history_next: z
93+
.string()
94+
.optional()
95+
.describe("Navigate to next history item"),
96+
messages_page_up: z
97+
.string()
98+
.optional()
99+
.describe("Scroll messages up by one page"),
100+
messages_page_down: z
101+
.string()
102+
.optional()
103+
.describe("Scroll messages down by one page"),
104+
messages_half_page_up: z
105+
.string()
106+
.optional()
107+
.describe("Scroll messages up by half page"),
108+
messages_half_page_down: z
109+
.string()
110+
.optional()
111+
.describe("Scroll messages down by half page"),
112+
messages_previous: z
113+
.string()
114+
.optional()
115+
.describe("Navigate to previous message"),
92116
messages_next: z.string().optional().describe("Navigate to next message"),
93-
messages_first: z.string().optional().describe("Navigate to first message"),
117+
messages_first: z
118+
.string()
119+
.optional()
120+
.describe("Navigate to first message"),
94121
messages_last: z.string().optional().describe("Navigate to last message"),
95122
app_exit: z.string().optional().describe("Exit the application"),
96123
})
124+
.strict()
97125
.openapi({
98126
ref: "Config.Keybinds",
99127
})
100128
export const Info = z
101129
.object({
102-
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
103-
theme: z.string().optional().describe("Theme name to use for the interface"),
130+
$schema: z
131+
.string()
132+
.optional()
133+
.describe("JSON schema reference for configuration validation"),
134+
theme: z
135+
.string()
136+
.optional()
137+
.describe("Theme name to use for the interface"),
104138
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
105139
autoshare: z
106140
.boolean()
@@ -129,19 +163,20 @@ export namespace Config {
129163
)
130164
.optional()
131165
.describe("Custom provider configurations and model overrides"),
132-
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
166+
mcp: z
167+
.record(z.string(), Mcp)
168+
.optional()
169+
.describe("MCP (Model Context Protocol) server configurations"),
133170
})
171+
.strict()
134172
.openapi({
135173
ref: "Config.Info",
136174
})
137175

138176
export type Info = z.output<typeof Info>
139177

140178
export const global = lazy(async () => {
141-
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
142-
.json()
143-
.then((mod) => Info.parse(mod))
144-
.catch(() => ({}) as Info)
179+
let result = await load(path.join(Global.Path.config, "config.json"))
145180

146181
await import(path.join(Global.Path.config, "config"), {
147182
with: {
@@ -160,9 +195,38 @@ export namespace Config {
160195
await fs.unlink(path.join(Global.Path.config, "config"))
161196
})
162197
.catch(() => {})
163-
return Info.parse(result)
198+
199+
return result
164200
})
165201

202+
async function load(path: string) {
203+
const data = await Bun.file(path)
204+
.json()
205+
.catch((err) => {
206+
if (err.code === "ENOENT") return {}
207+
throw new JsonError({ path }, { cause: err })
208+
})
209+
210+
const parsed = Info.safeParse(data)
211+
if (parsed.success) return parsed.data
212+
throw new InvalidError({ path, issues: parsed.error.issues })
213+
}
214+
215+
export const JsonError = NamedError.create(
216+
"ConfigJsonError",
217+
z.object({
218+
path: z.string(),
219+
}),
220+
)
221+
222+
export const InvalidError = NamedError.create(
223+
"ConfigInvalidError",
224+
z.object({
225+
path: z.string(),
226+
issues: z.custom<z.ZodIssue[]>().optional(),
227+
}),
228+
)
229+
166230
export function get() {
167231
return state()
168232
}

packages/opencode/src/index.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { UI } from "./cli/ui"
1818
import { Installation } from "./installation"
1919
import { Bus } from "./bus"
2020
import { Config } from "./config/config"
21+
import { NamedError } from "./util/error"
22+
import { FormatError } from "./cli/error"
2123

2224
const cli = yargs(hideBin(process.argv))
2325
.scriptName("opencode")
@@ -84,21 +86,21 @@ const cli = yargs(hideBin(process.argv))
8486
},
8587
})
8688

87-
; (async () => {
88-
if (Installation.VERSION === "dev") return
89-
if (Installation.isSnapshot()) return
90-
const config = await Config.global()
91-
if (config.autoupdate === false) return
92-
const latest = await Installation.latest()
93-
if (Installation.VERSION === latest) return
94-
const method = await Installation.method()
95-
if (method === "unknown") return
96-
await Installation.upgrade(method, latest)
97-
.then(() => {
98-
Bus.publish(Installation.Event.Updated, { version: latest })
99-
})
100-
.catch(() => { })
101-
})()
89+
;(async () => {
90+
if (Installation.VERSION === "dev") return
91+
if (Installation.isSnapshot()) return
92+
const config = await Config.global()
93+
if (config.autoupdate === false) return
94+
const latest = await Installation.latest()
95+
if (Installation.VERSION === latest) return
96+
const method = await Installation.method()
97+
if (method === "unknown") return
98+
await Installation.upgrade(method, latest)
99+
.then(() => {
100+
Bus.publish(Installation.Event.Updated, { version: latest })
101+
})
102+
.catch(() => {})
103+
})()
102104

103105
await proc.exited
104106
server.stop()
@@ -133,7 +135,25 @@ const cli = yargs(hideBin(process.argv))
133135
try {
134136
await cli.parse()
135137
} catch (e) {
136-
Log.Default.error(e, {
137-
stack: e instanceof Error ? e.stack : undefined,
138-
})
138+
const data: Record<string, any> = {}
139+
if (e instanceof NamedError) {
140+
const obj = e.toObject()
141+
Object.assign(data, {
142+
...obj.data,
143+
})
144+
}
145+
if (e instanceof Error) {
146+
Object.assign(data, {
147+
name: e.name,
148+
message: e.message,
149+
cause: e.cause?.toString(),
150+
})
151+
}
152+
Log.Default.error("fatal", data)
153+
const formatted = FormatError(e)
154+
if (formatted) UI.error(formatted)
155+
if (!formatted)
156+
UI.error(
157+
"Unexpected error, check log file at " + Log.file() + " for more details",
158+
)
139159
}

packages/opencode/src/provider/provider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@ export namespace Provider {
181181
mergeProvider(providerID, provider.options ?? {}, "config")
182182
}
183183

184-
for (const providerID of Object.keys(providers)) {
184+
for (const [providerID, provider] of Object.entries(providers)) {
185+
if (Object.keys(provider.info.models).length === 0) {
186+
delete providers[providerID]
187+
continue
188+
}
185189
log.info("found", { providerID })
186190
}
187191

packages/opencode/src/server/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Message } from "../session/message"
1010
import { Provider } from "../provider/provider"
1111
import { App } from "../app/app"
1212
import { Global } from "../global"
13-
import { mapValues } from "remeda"
13+
import { filter, mapValues } from "remeda"
1414
import { NamedError } from "../util/error"
1515
import { ModelsDev } from "../provider/models"
1616
import { Ripgrep } from "../external/ripgrep"

packages/opencode/src/util/error.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,6 @@ export abstract class NamedError extends Error {
3030
) {
3131
super(name, options)
3232
this.name = name
33-
log.error(name, {
34-
...this.data,
35-
cause: options?.cause?.toString(),
36-
})
3733
}
3834

3935
static isInstance(input: any): input is InstanceType<typeof result> {

0 commit comments

Comments
 (0)