fix(cli): render tagged config errors
This commit is contained in:
parent
67e6408cef
commit
e1a075d495
2 changed files with 79 additions and 18 deletions
|
|
@ -5,13 +5,37 @@ interface ErrorLike {
|
||||||
name?: string
|
name?: string
|
||||||
_tag?: string
|
_tag?: string
|
||||||
message?: string
|
message?: string
|
||||||
data?: Record<string, any>
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigIssue = { message: string; path: string[] }
|
||||||
|
|
||||||
|
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||||
|
return typeof input === "object" && input !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTaggedError(error: unknown, tag: string): boolean {
|
function isTaggedError(error: unknown, tag: string): boolean {
|
||||||
return (
|
return isRecord(error) && error._tag === tag
|
||||||
typeof error === "object" && error !== null && "_tag" in error && (error as Record<string, unknown>)._tag === tag
|
}
|
||||||
)
|
|
||||||
|
function configData(input: unknown, tag: string): Record<string, unknown> | undefined {
|
||||||
|
if (!isRecord(input)) return undefined
|
||||||
|
if (input.name === tag && isRecord(input.data)) return input.data
|
||||||
|
if (input._tag === tag) return input
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(input: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
return typeof input[key] === "string" ? input[key] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function configIssues(input: Record<string, unknown>): ConfigIssue[] {
|
||||||
|
return Array.isArray(input.issues)
|
||||||
|
? input.issues.filter((issue): issue is ConfigIssue => {
|
||||||
|
if (!isRecord(issue)) return false
|
||||||
|
return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string")
|
||||||
|
})
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormatError(input: unknown) {
|
export function FormatError(input: unknown) {
|
||||||
|
|
@ -35,7 +59,7 @@ export function FormatError(input: unknown) {
|
||||||
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
|
||||||
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
if (NamedError.hasName(input, "ProviderModelNotFoundError")) {
|
||||||
const data = (input as ErrorLike).data
|
const data = (input as ErrorLike).data
|
||||||
const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : []
|
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : []
|
||||||
return [
|
return [
|
||||||
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
`Model not found: ${data?.providerID}/${data?.modelID}`,
|
||||||
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
|
||||||
|
|
@ -50,28 +74,30 @@ export function FormatError(input: unknown) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigJsonError: { path: string, message?: string }
|
// ConfigJsonError: { path: string, message?: string }
|
||||||
if (NamedError.hasName(input, "ConfigJsonError")) {
|
const configJson = configData(input, "ConfigJsonError")
|
||||||
const data = (input as ErrorLike).data
|
if (configJson) {
|
||||||
return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "")
|
const message = stringField(configJson, "message")
|
||||||
|
return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
|
// ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string }
|
||||||
if (NamedError.hasName(input, "ConfigDirectoryTypoError")) {
|
const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError")
|
||||||
const data = (input as ErrorLike).data
|
if (configDirectoryTypo) {
|
||||||
return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.`
|
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.`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigFrontmatterError: { message: string }
|
// ConfigFrontmatterError: { message: string }
|
||||||
if (NamedError.hasName(input, "ConfigFrontmatterError")) {
|
const configFrontmatter = configData(input, "ConfigFrontmatterError")
|
||||||
return (input as ErrorLike).data?.message ?? ""
|
if (configFrontmatter) {
|
||||||
|
return stringField(configFrontmatter, "message") ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
|
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
|
||||||
if (NamedError.hasName(input, "ConfigInvalidError")) {
|
const configInvalid = configData(input, "ConfigInvalidError")
|
||||||
const data = (input as ErrorLike).data
|
if (configInvalid) {
|
||||||
const path = data?.path
|
const path = stringField(configInvalid, "path")
|
||||||
const message = data?.message
|
const message = stringField(configInvalid, "message")
|
||||||
const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : []
|
const issues = configIssues(configInvalid)
|
||||||
return [
|
return [
|
||||||
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
|
||||||
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,41 @@ import { FormatError } from "../../src/cli/error"
|
||||||
import { UI } from "../../src/cli/ui"
|
import { UI } from "../../src/cli/ui"
|
||||||
|
|
||||||
describe("cli.error", () => {
|
describe("cli.error", () => {
|
||||||
|
test("formats legacy and tagged config errors the same way", () => {
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
tag: "ConfigJsonError",
|
||||||
|
data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" },
|
||||||
|
expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "ConfigDirectoryTypoError",
|
||||||
|
data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" },
|
||||||
|
expected:
|
||||||
|
'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "ConfigFrontmatterError",
|
||||||
|
data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" },
|
||||||
|
expected: "failed frontmatter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "ConfigInvalidError",
|
||||||
|
data: {
|
||||||
|
path: "/tmp/opencode.jsonc",
|
||||||
|
message: "schema mismatch",
|
||||||
|
issues: [{ message: "Expected string", path: ["provider", "id"] }],
|
||||||
|
},
|
||||||
|
expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected)
|
||||||
|
expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("formats account transport errors clearly", () => {
|
test("formats account transport errors clearly", () => {
|
||||||
const error = new AccountTransportError({
|
const error = new AccountTransportError({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue