From 84c4f63244ac7ebeed235c3e263e8deb63dcee9c Mon Sep 17 00:00:00 2001 From: vuinx Date: Sun, 30 Aug 2026 10:23:16 +0300 Subject: [PATCH 1/4] Added devcontainer files --- .devcontainer/Dockerfile | 12 ++++++++++++ .devcontainer/devcontainer.json | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..b5093d01 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,12 @@ +FROM oven/bun:debian + +# Config Bun +ENV PATH="~/.bun/bin:${PATH}" +RUN ln -s /usr/local/bin/bun /usr/local/bin/node + +# Update packages +RUN if [ "debian" == "alpine" ] ; then apk update ; else apt-get update ; fi + +# Install Git +RUN if [ "debian" == "alpine" ] ; then apk add git ; else apt-get install -y git ; fi + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..f4df5339 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/marcosgomesneto/bun-devcontainers/tree/main/src/basic-bun +{ + "name": "Bun", + "dockerFile": "Dockerfile", + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "oven.bun-vscode" + ] + } + } +} \ No newline at end of file From c002f155e4a84ce0be06b03ca96a77ac32b4db7a Mon Sep 17 00:00:00 2001 From: vuinx Date: Fri, 4 Sep 2026 18:08:24 +0300 Subject: [PATCH 2/4] Updated devcontainer dockerfile to include qlty --- .devcontainer/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b5093d01..8beb1030 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -10,3 +10,8 @@ RUN if [ "debian" == "alpine" ] ; then apk update ; else apt-get update ; fi # Install Git RUN if [ "debian" == "alpine" ] ; then apk add git ; else apt-get install -y git ; fi +# Install curl +RUN if [ "debian" == "alpine" ] ; then apk add curl; else apt-get install -y curl ; fi +RUN if [ "debian" == "alpine" ] ; then apk add xz-utils; else apt-get install -y xz-utils ; fi + +RUN curl https://qlty.sh | bash From e188ab4e985f590972c8802b608898406db9a954 Mon Sep 17 00:00:00 2001 From: vuinx Date: Fri, 4 Sep 2026 18:12:53 +0300 Subject: [PATCH 3/4] Refactored the function cliErrorMessage to reduce the number of return statements used --- packages/tui/src/util/error.ts | 71 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/packages/tui/src/util/error.ts b/packages/tui/src/util/error.ts index 757758a2..78e59ef5 100644 --- a/packages/tui/src/util/error.ts +++ b/packages/tui/src/util/error.ts @@ -1,54 +1,56 @@ +import { error } from "node:console"; import { isRecord } from "./record" type ConfigIssue = { message: string; path: string[] } export function cliErrorMessage(input: unknown): string | undefined { + + var error_msg: string | undefined = undefined; + if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) { const formatted = cliErrorMessage(input.cause.body) - if (formatted) return formatted + if (formatted && error_msg === undefined) error_msg = formatted + + } else if (tagged(input, "CliError") || + tagged(input, "AccountServiceError") || + tagged(input, "AccountTransportError")) { + if (tagged(input, "CliError") && typeof input.exitCode === "number") { + process.exitCode = input.exitCode + } + error_msg = field(input, "message") ?? "" } - if (tagged(input, "CliError")) { - if (typeof input.exitCode === "number") process.exitCode = input.exitCode - return field(input, "message") ?? "" - } - if (tagged(input, "AccountServiceError") || tagged(input, "AccountTransportError")) { - return field(input, "message") ?? "" + if (error_msg !== undefined) { + return error_msg } const model = configData(input, "ProviderModelNotFoundError") + const provider = configData(input, "ProviderInitError") + const json = configData(input, "ConfigJsonError") + const directory = configData(input, "ConfigDirectoryTypoError") + const frontmatter = configData(input, "ConfigFrontmatterError") + const invalid = configData(input, "ConfigInvalidError") + if (model) { const suggestions = Array.isArray(model.suggestions) ? model.suggestions.filter((item): item is string => typeof item === "string") : [] - return [ + error_msg = [ `Model not found: ${field(model, "providerID")}/${field(model, "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") - } - - const provider = configData(input, "ProviderInitError") - if (provider) - return `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.` - - const json = configData(input, "ConfigJsonError") - if (json) { + } else if (provider) { + error_msg = `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.` + } else if (json) { const message = field(json, "message") - return `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") - } - - const directory = configData(input, "ConfigDirectoryTypoError") - if (directory) { - return `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.` - } - - const frontmatter = configData(input, "ConfigFrontmatterError") - if (frontmatter) return field(frontmatter, "message") ?? "" - - const invalid = configData(input, "ConfigInvalidError") - if (invalid) { + error_msg = `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") + } else if (directory) { + error_msg = `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.` + } else if (frontmatter) { + error_msg = field(frontmatter, "message") ?? "" + } else if (invalid) { const path = field(invalid, "path") const message = field(invalid, "message") const issues = Array.isArray(invalid.issues) @@ -61,18 +63,17 @@ export function cliErrorMessage(input: unknown): string | undefined { ) }) : [] - return [ + error_msg = [ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), ].join("\n") } - - if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) return "" - if (isRecord(input) && named(input, "MCPFailed")) { + else if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) error_msg = "" + else if (isRecord(input) && named(input, "MCPFailed")) { const name = isRecord(input.data) ? field(input.data, "name") : undefined - return `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.` + error_msg = `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.` } - return undefined + return error_msg } function tagged(input: unknown, tag: string): input is Record { From 69b4f69eabae2fb8b130bbd15c1f8e03b5683ad3 Mon Sep 17 00:00:00 2001 From: vuinx Date: Fri, 4 Sep 2026 19:16:04 +0300 Subject: [PATCH 4/4] Added test cases for cliErrorMessage --- packages/tui/test/util/error.test.ts | 165 ++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/packages/tui/test/util/error.test.ts b/packages/tui/test/util/error.test.ts index 58d6e3c0..499cbae0 100644 --- a/packages/tui/test/util/error.test.ts +++ b/packages/tui/test/util/error.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { errorData, errorFormat, errorMessage } from "../../src/util/error" +import { cliErrorMessage, errorData, errorFormat, errorMessage } from "../../src/util/error" describe("util.error", () => { test("formats native Error instances", () => { @@ -46,4 +46,167 @@ describe("util.error", () => { expect(data.message).toBe("ResolveMessage: Cannot resolve module") expect(String(data.formatted)).toContain("ResolveMessage") }) + + // Test cliErrorMessage + // written by AI + + test("formats native Error instances with a matching cause.body", () => { + const err = new Error("wrapper") + ;(err as any).cause = { body: { _tag: "CliError", message: "inner failure", exitCode: 1 } } + expect(cliErrorMessage(err)).toBe("inner failure") + }) + + test("returns undefined for Errors without a usable cause", () => { + expect(cliErrorMessage(new Error("plain"))).toBeUndefined() + + const stringCause = new Error("wrapper") + ;(stringCause as any).cause = "just a string" + expect(cliErrorMessage(stringCause)).toBeUndefined() + + const noBody = new Error("wrapper") + ;(noBody as any).cause = { something: "else" } + expect(cliErrorMessage(noBody)).toBeUndefined() + }) + + test("propagates empty-string results from cause.body", () => { + const err = new Error("wrapper") + ;(err as any).cause = { body: { _tag: "UICancelledError" } } + expect(cliErrorMessage(err)).toBeUndefined() + }) + + test("extracts message from tagged errors and sets exitCode for CliError", () => { + const prev = process.exitCode + try { + const err = { _tag: "CliError", message: "boom", exitCode: 42 } + expect(cliErrorMessage(err)).toBe("boom") + expect(process.exitCode).toBe(42) + + const zero = { _tag: "CliError", message: "ok-ish", exitCode: 0 } + cliErrorMessage(zero) + expect(process.exitCode).toBe(0) + } finally { + process.exitCode = prev + } + }) + + test("falls back to empty string when tagged error has no message field", () => { + expect(cliErrorMessage({ _tag: "AccountServiceError" })).toBe("") + }) + + test("formats model-not-found errors from name+data envelopes", () => { + const err = { + name: "ProviderModelNotFoundError", + data: { + providerID: "anthropic", + modelID: "cluade-4", + suggestions: ["claude-4", "claude-4-sonnet", 42, null], + }, + } + expect(cliErrorMessage(err)).toBe( + [ + "Model not found: anthropic/cluade-4", + "Did you mean: claude-4, claude-4-sonnet", + "Try: `opencode models` to list available models", + "Or check your config (opencode.json) provider/model names", + ].join("\n"), + ) + }) + + test("also supports the _tag shape for config errors", () => { + const err = { + _tag: "ProviderInitError", + providerID: "openai", + } + expect(cliErrorMessage(err)).toBe( + 'Failed to initialize provider "openai". Check credentials and configuration.', + ) + }) + + test("omits suggestion line when suggestions are missing or malformed", () => { + const err = { + name: "ProviderModelNotFoundError", + data: { providerID: "openai", modelID: "gpt-x", suggestions: "not-an-array" }, + } + const result = cliErrorMessage(err)! + expect(result).toContain("Model not found: openai/gpt-x") + expect(result).not.toContain("Did you mean") + }) + + test("formats config json errors, with and without parse detail", () => { + const bare = { name: "ConfigJsonError", data: { path: "/home/user/.config/opencode.json" } } + expect(cliErrorMessage(bare)).toBe( + "Config file at /home/user/.config/opencode.json is not valid JSON(C)", + ) + + const detailed = { name: "ConfigJsonError", data: { path: "opencode.json", message: "Unexpected token }" } } + expect(cliErrorMessage(detailed)).toBe( + "Config file at opencode.json is not valid JSON(C): Unexpected token }", + ) + }) + + test("formats directory typo errors", () => { + const err = { + name: "ConfigDirectoryTypoError", + data: { dir: "cmmands", path: "/project/.opencode", suggestion: "commands" }, + } + expect(cliErrorMessage(err)).toBe( + 'Directory "cmmands" in /project/.opencode is not valid. Rename the directory to "commands" or remove it. This is a common typo.', + ) + }) + + test("formats frontmatter errors from their message field", () => { + expect(cliErrorMessage({ name: "ConfigFrontmatterError", data: { message: "missing title" } })).toBe("missing title") + expect(cliErrorMessage({ name: "ConfigFrontmatterError" })).toBeUndefined() + }) + + test("formats invalid-config errors with path, message, and filtered issues", () => { + const err = { + name: "ConfigInvalidError", + data: { + path: "agents", + message: "validation failed", + issues: [ + { message: "missing field", path: ["agent", "model"] }, + { message: 123, path: ["x"] }, + { message: "bad path", path: "not-array" }, + "not-a-record", + ], + }, + } + expect(cliErrorMessage(err)).toBe( + ["Configuration is invalid at agents: validation failed", "↳ missing field agent.model"].join("\n"), + ) + }) + + test("uses generic header and omits 'at' when path is 'config'", () => { + const err = { name: "ConfigInvalidError", data: { path: "config", message: "broken" } } + const result = cliErrorMessage(err)! + expect(result.startsWith("Configuration is invalid: broken")).toBe(true) + expect(result).not.toContain(" at ") + }) + + test("formats UICancelledError as an empty string, via _tag or name", () => { + expect(cliErrorMessage({ _tag: "UICancelledError" })).toBe("") + expect(cliErrorMessage({ name: "UICancelledError" })).toBe("") + }) + + test("formats MCP failures with the server name", () => { + const err = { name: "MCPFailed", data: { name: "my-server" } } + expect(cliErrorMessage(err)).toBe( + 'MCP server "my-server" failed. Note, opencode does not support MCP authentication yet.', + ) + }) + + test("handles MCP failures with missing or malformed data", () => { + expect(cliErrorMessage({ name: "MCPFailed" })).toContain('MCP server "undefined" failed') + expect(cliErrorMessage({ _tag: "MCPFailed", data: "not-a-record" })).toContain('MCP server "undefined" failed') + }) + + test("returns undefined for unrecognized inputs", () => { + expect(cliErrorMessage(undefined)).toBeUndefined() + expect(cliErrorMessage(null)).toBeUndefined() + expect(cliErrorMessage(42)).toBeUndefined() + expect(cliErrorMessage("plain string")).toBeUndefined() + expect(cliErrorMessage({ name: "SomeUnknownError", message: "hi" })).toBeUndefined() + }) })