Skip to content

Commit 367665d

Browse files
authored
fix(cli): render tagged config errors (anomalyco#27256)
1 parent 4aaece2 commit 367665d

2 files changed

Lines changed: 79 additions & 18 deletions

File tree

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

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,37 @@ interface ErrorLike {
55
name?: string
66
_tag?: string
77
message?: string
8-
data?: Record<string, any>
8+
data?: Record<string, unknown>
9+
}
10+
11+
type ConfigIssue = { message: string; path: string[] }
12+
13+
function isRecord(input: unknown): input is Record<string, unknown> {
14+
return typeof input === "object" && input !== null
915
}
1016

1117
function isTaggedError(error: unknown, tag: string): boolean {
12-
return (
13-
typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
14-
)
18+
return isRecord(error) && error._tag === tag
19+
}
20+
21+
function configData(input: unknown, tag: string): Record<string, unknown> | undefined {
22+
if (!isRecord(input)) return undefined
23+
if (input.name === tag && isRecord(input.data)) return input.data
24+
if (input._tag === tag) return input
25+
return undefined
26+
}
27+
28+
function stringField(input: Record<string, unknown>, key: string): string | undefined {
29+
return typeof input[key] === "string" ? input[key] : undefined
30+
}
31+
32+
function configIssues(input: Record<string, unknown>): ConfigIssue[] {
33+
return Array.isArray(input.issues)
34+
? input.issues.filter((issue): issue is ConfigIssue => {
35+
if (!isRecord(issue)) return false
36+
return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string")
37+
})
38+
: []
1539
}
1640

1741
export function FormatError(input: unknown) {
@@ -35,7 +59,7 @@ export function FormatError(input: unknown) {
3559
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
3660
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
3761
const data = (input as ErrorLike).data
38-
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
62+
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
3963
return [
4064
`Model not found: ${data?.providerID}/${data?.modelID}`,
4165
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
@@ -50,28 +74,30 @@ export function FormatError(input: unknown) {
5074
}
5175

5276
// ConfigJsonError: { path: string, message?: string }
53-
if (NamedError.hasName(input, "ConfigJsonError")) {
54-
const data = (input as ErrorLike).data
55-
return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
77+
const configJson = configData(input, "ConfigJsonError")
78+
if (configJson) {
79+
const message = stringField(configJson, "message")
80+
return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
5681
}
5782

5883
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
59-
if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
60-
const data = (input as ErrorLike).data
61-
return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
84+
const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError")
85+
if (configDirectoryTypo) {
86+
return `Directory "${stringField(configDirectoryTypo, "dir")}" in ${stringField(configDirectoryTypo, "path")} is not valid. Rename the directory to "${stringField(configDirectoryTypo, "suggestion")}" or remove it. This is a common typo.`
6287
}
6388

6489
// ConfigFrontmatterError: { message: string }
65-
if (NamedError.hasName(input, "ConfigFrontmatterError")) {
66-
return (input as ErrorLike).data?.message ?? ""
90+
const configFrontmatter = configData(input, "ConfigFrontmatterError")
91+
if (configFrontmatter) {
92+
return stringField(configFrontmatter, "message") ?? ""
6793
}
6894

6995
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
70-
if (NamedError.hasName(input, "ConfigInvalidError")) {
71-
const data = (input as ErrorLike).data
72-
const path = data?.path
73-
const message = data?.message
74-
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
96+
const configInvalid = configData(input, "ConfigInvalidError")
97+
if (configInvalid) {
98+
const path = stringField(configInvalid, "path")
99+
const message = stringField(configInvalid, "message")
100+
const issues = configIssues(configInvalid)
75101
return [
76102
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
77103
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,41 @@ import { FormatError } from "../../src/cli/error"
44
import { UI } from "../../src/cli/ui"
55

66
describe("cli.error", () => {
7+
test("formats legacy and tagged config errors the same way", () => {
8+
const cases = [
9+
{
10+
tag: "ConfigJsonError",
11+
data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" },
12+
expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token",
13+
},
14+
{
15+
tag: "ConfigDirectoryTypoError",
16+
data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" },
17+
expected:
18+
'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.',
19+
},
20+
{
21+
tag: "ConfigFrontmatterError",
22+
data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" },
23+
expected: "failed frontmatter",
24+
},
25+
{
26+
tag: "ConfigInvalidError",
27+
data: {
28+
path: "/tmp/opencode.jsonc",
29+
message: "schema mismatch",
30+
issues: [{ message: "Expected string", path: ["provider", "id"] }],
31+
},
32+
expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id",
33+
},
34+
]
35+
36+
for (const item of cases) {
37+
expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected)
38+
expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected)
39+
}
40+
})
41+
742
test("formats account transport errors clearly", () => {
843
const error = new AccountTransportError({
944
method: "POST",

0 commit comments

Comments
 (0)