diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..e2be7d74 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "opencode-dev", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:1": {} + }, + "postCreateCommand": "curl -fsSL https://bun.sh/install | bash && echo 'export PATH=\"$HOME/.bun/bin:$PATH\"' >> ~/.bashrc", + "customizations": { + "vscode": { + "extensions": ["dbaeumer.vscode-eslint"] + } + } +} + diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 407547e4..1a71170c 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -4,6 +4,8 @@ import { isRecord } from "@/util/record" type ConfigIssue = { message: string; path: string[] } +type ErrorFormatter = (input: unknown) => string | undefined + function isTaggedError(error: unknown, tag: string): error is Record { return isRecord(error) && error._tag === tag } @@ -32,99 +34,129 @@ function configIssues(input: Record): ConfigIssue[] { : [] } -export function FormatError(input: unknown): string | undefined { - if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) { - const formatted = FormatError(input.cause.body) - if (formatted) return formatted - } +// CliError: domain failure surfaced from an effectCmd handler via fail("...") +function formatCliError(input: unknown): string | undefined { + if (!isTaggedError(input, "CliError")) return undefined + if (typeof input.exitCode === "number") process.exitCode = input.exitCode + return stringField(input, "message") ?? "" +} - // CliError: domain failure surfaced from an effectCmd handler via fail("...") - if (isTaggedError(input, "CliError")) { - if (typeof input.exitCode === "number") process.exitCode = input.exitCode - return stringField(input, "message") ?? "" - } +// MCPFailed: { name: string } +function formatMcpFailed(input: unknown): string | undefined { + if (!NamedError.hasName(input, "MCPFailed")) return undefined + const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined + return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.` +} - // MCPFailed: { name: string } - if (NamedError.hasName(input, "MCPFailed")) { - const data = isRecord(input) && isRecord(input.data) ? stringField(input.data, "name") : undefined - return `MCP server "${data}" failed. Note, opencode does not support MCP authentication yet.` - } +// AccountServiceError, AccountTransportError: TaggedErrorClass +function formatAccountError(input: unknown): string | undefined { + if (!isTaggedError(input, "AccountServiceError") && !isTaggedError(input, "AccountTransportError")) return undefined + return stringField(input, "message") ?? "" +} - // AccountServiceError, AccountTransportError: TaggedErrorClass - if (isTaggedError(input, "AccountServiceError") || isTaggedError(input, "AccountTransportError")) { - return stringField(input, "message") ?? "" - } +// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } +function formatProviderModelNotFound(input: unknown): string | undefined { + const data = configData(input, "ProviderModelNotFoundError") + if (!data) return undefined + const suggestions = Array.isArray(data.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : [] + return [ + `Model not found: ${stringField(data, "providerID")}/${stringField(data, "modelID")}`, + ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), + `Try: \`opencode models\` to list available models`, + `Or check your config (opencode.json) provider/model names`, + ].join("\n") +} - // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } - const providerModelNotFound = configData(input, "ProviderModelNotFoundError") - if (providerModelNotFound) { - const suggestions = Array.isArray(providerModelNotFound.suggestions) - ? providerModelNotFound.suggestions.filter((x) => typeof x === "string") - : [] - return [ - `Model not found: ${stringField(providerModelNotFound, "providerID")}/${stringField(providerModelNotFound, "modelID")}`, - ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), - `Try: \`opencode models\` to list available models`, - `Or check your config (opencode.json) provider/model names`, - ].join("\n") - } +// ProviderInitError: { providerID: string } +function formatProviderInit(input: unknown): string | undefined { + const data = configData(input, "ProviderInitError") + if (!data) return undefined + return `Failed to initialize provider "${stringField(data, "providerID")}". Check credentials and configuration.` +} - // ProviderInitError: { providerID: string } - const providerInit = configData(input, "ProviderInitError") - if (providerInit) { - return `Failed to initialize provider "${stringField(providerInit, "providerID")}". Check credentials and configuration.` - } +// ConfigJsonError: { path: string, message?: string } +function formatConfigJson(input: unknown): string | undefined { + const data = configData(input, "ConfigJsonError") + if (!data) return undefined + const message = stringField(data, "message") + return `Config file at ${stringField(data, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") +} - // ConfigJsonError: { path: string, message?: string } - const configJson = configData(input, "ConfigJsonError") - if (configJson) { - 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 } +function formatConfigDirectoryTypo(input: unknown): string | undefined { + const data = configData(input, "ConfigDirectoryTypoError") + if (!data) return undefined + return `Directory "${stringField(data, "dir")}" in ${stringField(data, "path")} is not valid. Rename the directory to "${stringField(data, "suggestion")}" or remove it. This is a common typo.` +} - // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } - const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError") - if (configDirectoryTypo) { - 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 } +function formatConfigFrontmatter(input: unknown): string | undefined { + const data = configData(input, "ConfigFrontmatterError") + if (!data) return undefined + return stringField(data, "message") ?? "" +} - // ConfigFrontmatterError: { message: string } - const configFrontmatter = configData(input, "ConfigFrontmatterError") - if (configFrontmatter) { - return stringField(configFrontmatter, "message") ?? "" - } +// ConfigRemoteAuthError: { url: string, remote: string } +function formatConfigRemoteAuth(input: unknown): string | undefined { + const data = configData(input, "ConfigRemoteAuthError") + if (!data) return undefined + const url = stringField(data, "url") + const remote = stringField(data, "remote") + return [ + `Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`, + `Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`, + ...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []), + ].join("\n") +} - // ConfigRemoteAuthError: { url: string, remote: string } - const remoteAuth = configData(input, "ConfigRemoteAuthError") - if (remoteAuth) { - const url = stringField(remoteAuth, "url") - const remote = stringField(remoteAuth, "remote") - return [ - `Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`, - `Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`, - ...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []), - ].join("\n") - } +// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } +function formatConfigInvalid(input: unknown): string | undefined { + const data = configData(input, "ConfigInvalidError") + if (!data) return undefined + const path = stringField(data, "path") + const message = stringField(data, "message") + const issues = configIssues(data) + return [ + `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), + ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), + ].join("\n") +} - // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } - const configInvalid = configData(input, "ConfigInvalidError") - if (configInvalid) { - const path = stringField(configInvalid, "path") - const message = stringField(configInvalid, "message") - const issues = configIssues(configInvalid) - return [ - `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), - ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), - ].join("\n") +// UICancelledError: user cancelled an interactive CLI prompt +function formatUICancelled(input: unknown): string | undefined { + if (!isTaggedError(input, "UICancelledError") && !NamedError.hasName(input, "UICancelledError")) return undefined + return "" +} + +// Order matters: the first formatter that recognizes the input wins. +const FORMATTERS: ErrorFormatter[] = [ + formatCliError, + formatMcpFailed, + formatAccountError, + formatProviderModelNotFound, + formatProviderInit, + formatConfigJson, + formatConfigDirectoryTypo, + formatConfigFrontmatter, + formatConfigRemoteAuth, + formatConfigInvalid, + formatUICancelled, +] + +export function FormatError(input: unknown): string | undefined { + if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) { + const formatted = FormatError(input.cause.body) + if (formatted) return formatted } - // UICancelledError: user cancelled an interactive CLI prompt - if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) { - return "" + for (const format of FORMATTERS) { + const result = format(input) + if (result !== undefined) return result } + return undefined } export function FormatUnknownError(input: unknown): string { return errorFormat(input) -} +} \ No newline at end of file diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b29ca2b3..127cb4dd 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -92,4 +92,61 @@ describe("cli.error", () => { test("formats cancelled UI errors as empty output", () => { expect(FormatError(new UI.CancelledError())).toBe("") }) -}) + + test("formats CLI errors and applies their exit code", () => { + const previous = process.exitCode + try { + expect(FormatError({ _tag: "CliError", message: "something broke", exitCode: 3 })).toBe("something broke") + expect(process.exitCode).toBe(3) + } finally { + process.exitCode = previous ?? 0 + } + expect(process.exitCode).toBe(previous ?? 0) + }) + + test("formats CLI errors without a message as empty output", () => { + const previous = process.exitCode + try { + expect(FormatError({ _tag: "CliError" })).toBe("") + } finally { + process.exitCode = previous ?? 0 + } + }) + + test("formats remote config auth errors with a login hint", () => { + const data = { url: "https://config.example.com", remote: "team-config" } + const expected = [ + "Failed to load remote config from team-config: the server returned a login page instead of JSON.", + "Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).", + "Run `opencode auth login https://config.example.com` to re-authenticate.", + ].join("\n") + + expect(FormatError({ name: "ConfigRemoteAuthError", data })).toBe(expected) + expect(FormatError({ _tag: "ConfigRemoteAuthError", ...data })).toBe(expected) + }) + + test("formats remote config auth errors without a url", () => { + const expected = [ + "Failed to load remote config: the server returned a login page instead of JSON.", + "Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).", + ].join("\n") + + expect(FormatError({ _tag: "ConfigRemoteAuthError" })).toBe(expected) + }) + + test("formats config invalid errors with no path and no issues", () => { + expect(FormatError({ _tag: "ConfigInvalidError", path: "config" })).toBe("Configuration is invalid") + }) + + test("unwraps errors nested under cause.body", () => { + const wrapped = new Error("outer", { cause: { body: { _tag: "ProviderInitError", providerID: "anthropic" } } }) + + expect(FormatError(wrapped)).toBe('Failed to initialize provider "anthropic". Check credentials and configuration.') + }) + + test("returns undefined for errors it does not recognize", () => { + expect(FormatError({ _tag: "SomethingElse", message: "nope" })).toBeUndefined() + expect(FormatError("just a string")).toBeUndefined() + expect(FormatError(undefined)).toBeUndefined() + }) +}) \ No newline at end of file