Skip to content

Commit d25577b

Browse files
committed
fix: show concrete config validation errors (anomalyco#17333)
1 parent c3ddc85 commit d25577b

4 files changed

Lines changed: 140 additions & 7 deletions

File tree

‎packages/opencode/src/cli/error.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { MCP } from "../mcp"
44
import { Provider } from "../provider/provider"
55
import { UI } from "./ui"
66

7+
function formatTree(tree: { errors: string[]; properties?: Record<string, unknown> }, path = ""): string[] {
8+
const lines: string[] = []
9+
if (tree.errors) {
10+
for (const err of tree.errors) {
11+
lines.push("↳ " + (path || "(root)") + ": " + err)
12+
}
13+
}
14+
if (tree.properties) {
15+
for (const [key, sub] of Object.entries(tree.properties)) {
16+
const subPath = path ? path + "." + key : key
17+
lines.push(...formatTree(sub as { errors: string[]; properties?: Record<string, unknown> }, subPath))
18+
}
19+
}
20+
return lines
21+
}
22+
723
export function FormatError(input: unknown) {
824
if (MCP.Failed.isInstance(input))
925
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
@@ -30,12 +46,15 @@ export function FormatError(input: unknown) {
3046
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
3147
return input.data.message
3248
}
33-
if (Config.InvalidError.isInstance(input))
34-
return [
49+
if (Config.InvalidError.isInstance(input)) {
50+
const header =
3551
`Configuration is invalid${input.data.path && input.data.path !== "config" ? ` at ${input.data.path}` : ""}` +
36-
(input.data.message ? `: ${input.data.message}` : ""),
37-
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
38-
].join("\n")
52+
(input.data.message ? `: ${input.data.message}` : "")
53+
const details = input.data.tree
54+
? formatTree(input.data.tree)
55+
: (input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? [])
56+
return [header, ...details].join("\n")
57+
}
3958

4059
if (UI.CancelledError.isInstance(input)) return ""
4160
}

‎packages/opencode/src/config/config.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,10 @@ export namespace Config {
414414
result[config.name] = parsed.data
415415
continue
416416
}
417-
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
417+
throw new InvalidError(
418+
{ path: item, issues: parsed.error.issues, tree: z.treeifyError(parsed.error) },
419+
{ cause: parsed.error },
420+
)
418421
}
419422
return result
420423
}
@@ -453,7 +456,10 @@ export namespace Config {
453456
result[config.name] = parsed.data
454457
continue
455458
}
456-
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
459+
throw new InvalidError(
460+
{ path: item, issues: parsed.error.issues, tree: z.treeifyError(parsed.error) },
461+
{ cause: parsed.error },
462+
)
457463
}
458464
return result
459465
}
@@ -1325,6 +1331,7 @@ export namespace Config {
13251331
throw new InvalidError({
13261332
path: source,
13271333
issues: parsed.error.issues,
1334+
tree: z.treeifyError(parsed.error),
13281335
})
13291336
}
13301337
export const { JsonError, InvalidError } = ConfigPaths
@@ -1415,6 +1422,7 @@ export namespace Config {
14151422
throw new InvalidError({
14161423
path: filepath,
14171424
issues: parsed.error.issues,
1425+
tree: z.treeifyError(parsed.error),
14181426
})
14191427
}
14201428

‎packages/opencode/src/config/paths.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export namespace ConfigPaths {
5959
z.object({
6060
path: z.string(),
6161
issues: z.custom<z.core.$ZodIssue[]>().optional(),
62+
tree: z.custom<{ errors: string[]; properties?: Record<string, unknown> }>().optional(),
6263
message: z.string().optional(),
6364
}),
6465
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { test, expect, describe } from "bun:test"
2+
import { FormatError } from "../../src/cli/error"
3+
import { Config } from "../../src/config/config"
4+
import z from "zod"
5+
6+
function parseOrFail(schema: z.ZodType, input: unknown) {
7+
const result = schema.safeParse(input)
8+
if (!result.success) return result.error
9+
throw new Error("expected parse to fail")
10+
}
11+
12+
describe("FormatError for ConfigInvalidError", () => {
13+
test("uses tree to show concrete field errors instead of generic message", () => {
14+
const McpLocal = z.object({ type: z.literal("local"), command: z.string() }).strict()
15+
const McpRemote = z.object({ type: z.literal("remote"), url: z.string() }).strict()
16+
const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
17+
const schema = z.object({ mcp: z.record(z.string(), Mcp) }).strict()
18+
19+
const err = parseOrFail(schema, { mcp: { postgres: { type: "local" } } })
20+
const invalid = new Config.InvalidError({
21+
path: "/test/opencode.json",
22+
issues: err.issues,
23+
tree: z.treeifyError(err),
24+
})
25+
26+
const formatted = FormatError(invalid)
27+
expect(formatted).toContain("Configuration is invalid at /test/opencode.json")
28+
expect(formatted).toContain("command")
29+
expect(formatted).not.toContain("Invalid input mcp.postgres")
30+
})
31+
32+
test("shows unrecognized keys from tree", () => {
33+
const schema = z.object({ model: z.string() }).strict()
34+
const err = parseOrFail(schema, { model: "test", unknown_key: 1 })
35+
36+
const invalid = new Config.InvalidError({
37+
path: "/test/opencode.json",
38+
issues: err.issues,
39+
tree: z.treeifyError(err),
40+
})
41+
42+
const formatted = FormatError(invalid)
43+
expect(formatted).toContain("unknown_key")
44+
})
45+
46+
test("shows type mismatch details from tree", () => {
47+
const schema = z.object({ provider: z.object({ api_key: z.string() }).strict() }).strict()
48+
const err = parseOrFail(schema, { provider: { api_key: 123 } })
49+
50+
const invalid = new Config.InvalidError({
51+
path: "/test/opencode.json",
52+
issues: err.issues,
53+
tree: z.treeifyError(err),
54+
})
55+
56+
const formatted = FormatError(invalid)
57+
expect(formatted).toContain("api_key")
58+
expect(formatted).toContain("string")
59+
})
60+
61+
test("falls back to issues when tree is not provided", () => {
62+
const invalid = new Config.InvalidError({
63+
path: "/test/opencode.json",
64+
issues: [
65+
{
66+
code: "invalid_type",
67+
expected: "string",
68+
path: ["mcp", "postgres", "command"],
69+
message: "Required",
70+
} as any,
71+
],
72+
})
73+
74+
const formatted = FormatError(invalid)
75+
expect(formatted).toContain("Configuration is invalid at /test/opencode.json")
76+
expect(formatted).toContain("mcp.postgres.command")
77+
})
78+
79+
test("formats deeply nested errors", () => {
80+
const schema = z
81+
.object({
82+
provider: z
83+
.object({
84+
settings: z
85+
.object({
86+
timeout: z.number(),
87+
})
88+
.strict(),
89+
})
90+
.strict(),
91+
})
92+
.strict()
93+
94+
const err = parseOrFail(schema, { provider: { settings: { timeout: "not a number" } } })
95+
96+
const invalid = new Config.InvalidError({
97+
path: "/test/opencode.json",
98+
issues: err.issues,
99+
tree: z.treeifyError(err),
100+
})
101+
102+
const formatted = FormatError(invalid)
103+
expect(formatted).toContain("provider.settings.timeout")
104+
})
105+
})

0 commit comments

Comments
 (0)