config validation

This commit is contained in:
Dax Raad 2025-06-20 00:57:28 -04:00
parent 6674c6083a
commit 41dba0db08
10 changed files with 174 additions and 72 deletions

View file

@ -43,6 +43,7 @@
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2",
},
"devDependencies": {
"@ai-sdk/anthropic": "1.2.12",
@ -1655,6 +1656,8 @@
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
"zod-validation-error": ["zod-validation-error@3.5.2", "", { "peerDependencies": { "zod": "^3.25.0" } }, "sha512-mdi7YOLtram5dzJ5aDtm1AG9+mxRma1iaMrZdYIpFO7epdKBUwLHIxTF8CPDeCQ828zAXYtizrKlEJAtzgfgrw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],

View file

@ -1,5 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {},
"mcp": {}
"mcp": {},
"provider": {}
}

View file

@ -42,6 +42,7 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-openapi": "4.2.4"
"zod-openapi": "4.2.4",
"zod-validation-error": "3.5.2"
}
}

View file

@ -0,0 +1,13 @@
import { Config } from "../config/config"
export function FormatError(input: unknown) {
if (Config.JsonError.isInstance(input))
return `Config file at ${input.data.path} is not valid JSON`
if (Config.InvalidError.isInstance(input))
return [
`Config file at ${input.data.path} is invalid`,
...(input.data.issues?.map(
(issue) => "↳ " + issue.message + " " + issue.path.join("."),
) ?? []),
].join("\n")
}

View file

@ -8,6 +8,7 @@ import { mergeDeep } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import { NamedError } from "../util/error"
export namespace Config {
const log = Log.create({ service: "config" })
@ -15,27 +16,9 @@ export namespace Config {
export const state = App.state("config", async (app) => {
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const [resolved] = await Filesystem.findUp(
file,
app.path.cwd,
app.path.root,
)
if (!resolved) continue
try {
result = mergeDeep(
result,
await import(resolved).then((mod) => Info.parse(mod.default)),
)
log.info("found", { path: resolved })
break
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message)
}
throw e
}
continue
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await load(resolved))
}
}
log.info("loaded", result)
@ -45,9 +28,16 @@ export namespace Config {
export const McpLocal = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z.record(z.string(), z.string()).optional().describe("Environment variables to set when running the MCP server"),
command: z
.string()
.array()
.describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
})
.strict()
.openapi({
ref: "Config.McpLocal",
})
@ -57,6 +47,7 @@ export namespace Config {
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
})
.strict()
.openapi({
ref: "Config.McpRemote",
})
@ -66,41 +57,84 @@ export namespace Config {
export const Keybinds = z
.object({
leader: z.string().optional().describe("Leader key for keybind combinations"),
leader: z
.string()
.optional()
.describe("Leader key for keybind combinations"),
help: z.string().optional().describe("Show help dialog"),
editor_open: z.string().optional().describe("Open external editor"),
session_new: z.string().optional().describe("Create a new session"),
session_list: z.string().optional().describe("List all sessions"),
session_share: z.string().optional().describe("Share current session"),
session_interrupt: z.string().optional().describe("Interrupt current session"),
session_compact: z.string().optional().describe("Toggle compact mode for session"),
session_interrupt: z
.string()
.optional()
.describe("Interrupt current session"),
session_compact: z
.string()
.optional()
.describe("Toggle compact mode for session"),
tool_details: z.string().optional().describe("Show tool details"),
model_list: z.string().optional().describe("List available models"),
theme_list: z.string().optional().describe("List available themes"),
project_init: z.string().optional().describe("Initialize project configuration"),
project_init: z
.string()
.optional()
.describe("Initialize project configuration"),
input_clear: z.string().optional().describe("Clear input field"),
input_paste: z.string().optional().describe("Paste from clipboard"),
input_submit: z.string().optional().describe("Submit input"),
input_newline: z.string().optional().describe("Insert newline in input"),
history_previous: z.string().optional().describe("Navigate to previous history item"),
history_next: z.string().optional().describe("Navigate to next history item"),
messages_page_up: z.string().optional().describe("Scroll messages up by one page"),
messages_page_down: z.string().optional().describe("Scroll messages down by one page"),
messages_half_page_up: z.string().optional().describe("Scroll messages up by half page"),
messages_half_page_down: z.string().optional().describe("Scroll messages down by half page"),
messages_previous: z.string().optional().describe("Navigate to previous message"),
history_previous: z
.string()
.optional()
.describe("Navigate to previous history item"),
history_next: z
.string()
.optional()
.describe("Navigate to next history item"),
messages_page_up: z
.string()
.optional()
.describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.describe("Scroll messages down by one page"),
messages_half_page_up: z
.string()
.optional()
.describe("Scroll messages up by half page"),
messages_half_page_down: z
.string()
.optional()
.describe("Scroll messages down by half page"),
messages_previous: z
.string()
.optional()
.describe("Navigate to previous message"),
messages_next: z.string().optional().describe("Navigate to next message"),
messages_first: z.string().optional().describe("Navigate to first message"),
messages_first: z
.string()
.optional()
.describe("Navigate to first message"),
messages_last: z.string().optional().describe("Navigate to last message"),
app_exit: z.string().optional().describe("Exit the application"),
})
.strict()
.openapi({
ref: "Config.Keybinds",
})
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
$schema: z
.string()
.optional()
.describe("JSON schema reference for configuration validation"),
theme: z
.string()
.optional()
.describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
autoshare: z
.boolean()
@ -129,8 +163,12 @@ export namespace Config {
)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
mcp: z
.record(z.string(), Mcp)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
})
.strict()
.openapi({
ref: "Config.Info",
})
@ -138,10 +176,7 @@ export namespace Config {
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result = await Bun.file(path.join(Global.Path.config, "config.json"))
.json()
.then((mod) => Info.parse(mod))
.catch(() => ({}) as Info)
let result = await load(path.join(Global.Path.config, "config.json"))
await import(path.join(Global.Path.config, "config"), {
with: {
@ -160,9 +195,38 @@ export namespace Config {
await fs.unlink(path.join(Global.Path.config, "config"))
})
.catch(() => {})
return Info.parse(result)
return result
})
async function load(path: string) {
const data = await Bun.file(path)
.json()
.catch((err) => {
if (err.code === "ENOENT") return {}
throw new JsonError({ path }, { cause: err })
})
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({ path, issues: parsed.error.issues })
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.ZodIssue[]>().optional(),
}),
)
export function get() {
return state()
}

View file

@ -18,6 +18,8 @@ import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { Bus } from "./bus"
import { Config } from "./config/config"
import { NamedError } from "./util/error"
import { FormatError } from "./cli/error"
const cli = yargs(hideBin(process.argv))
.scriptName("opencode")
@ -84,21 +86,21 @@ const cli = yargs(hideBin(process.argv))
},
})
; (async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => { })
})()
;(async () => {
if (Installation.VERSION === "dev") return
if (Installation.isSnapshot()) return
const config = await Config.global()
if (config.autoupdate === false) return
const latest = await Installation.latest()
if (Installation.VERSION === latest) return
const method = await Installation.method()
if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() => {
Bus.publish(Installation.Event.Updated, { version: latest })
})
.catch(() => {})
})()
await proc.exited
server.stop()
@ -133,7 +135,25 @@ const cli = yargs(hideBin(process.argv))
try {
await cli.parse()
} catch (e) {
Log.Default.error(e, {
stack: e instanceof Error ? e.stack : undefined,
})
const data: Record<string, any> = {}
if (e instanceof NamedError) {
const obj = e.toObject()
Object.assign(data, {
...obj.data,
})
}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (!formatted)
UI.error(
"Unexpected error, check log file at " + Log.file() + " for more details",
)
}

View file

@ -181,7 +181,11 @@ export namespace Provider {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const providerID of Object.keys(providers)) {
for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
}

View file

@ -10,7 +10,7 @@ import { Message } from "../session/message"
import { Provider } from "../provider/provider"
import { App } from "../app/app"
import { Global } from "../global"
import { mapValues } from "remeda"
import { filter, mapValues } from "remeda"
import { NamedError } from "../util/error"
import { ModelsDev } from "../provider/models"
import { Ripgrep } from "../external/ripgrep"

View file

@ -30,10 +30,6 @@ export abstract class NamedError extends Error {
) {
super(name, options)
this.name = name
log.error(name, {
...this.data,
cause: options?.cause?.toString(),
})
}
static isInstance(input: any): input is InstanceType<typeof result> {

View file

@ -68,13 +68,13 @@ export namespace Log {
}
const result = {
info(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("INFO " + build(message, extra))
},
error(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("ERROR " + build(message, extra))
},
warn(message?: any, extra?: Record<string, any>) {
process.stderr.write(build(message, extra))
process.stderr.write("WARN " + build(message, extra))
},
tag(key: string, value: string) {
if (tags) tags[key] = value