diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 5e50d38ded0..b732bf654b2 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -9,6 +9,7 @@ import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import type { Event } from "@opencode-ai/sdk/v2" import type { EventSource } from "./context/sdk" +import { Config } from "@/config/config" declare global { const OPENCODE_WORKER_PATH: string @@ -132,7 +133,21 @@ export const TuiThreadCommand = cmd({ networkOpts.hostname !== "127.0.0.1" // Subscribe to events from worker - await client.call("subscribe", { directory: cwd }) + const result = await client.call("subscribe", { directory: cwd }) + if (!result.subscribed) { + // Reconstruct the appropriate error type for proper formatting + if (result.name === "ConfigJsonError") { + throw new Config.JsonError({ path: result.data?.path ?? "unknown", message: result.data?.message }) + } + if (result.name === "ConfigInvalidError") { + throw new Config.InvalidError({ + path: result.data?.path, + issues: result.data?.issues, + message: result.data?.message, + }) + } + throw new Error(result.error) + } let url: string let customFetch: typeof fetch | undefined diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index ea88e45f1db..e693bee754d 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -59,18 +59,27 @@ export const rpc = { return { url: server.url.toString() } }, async subscribe(input: { directory: string }) { - return Instance.provide({ - directory: input.directory, - init: InstanceBootstrap, - fn: async () => { - Bus.subscribeAll((event) => { - Rpc.emit("event", event) - }) - // Emit connected event - Rpc.emit("event", { type: "server.connected", properties: {} }) - return { subscribed: true } - }, - }) + try { + return await Instance.provide({ + directory: input.directory, + init: InstanceBootstrap, + fn: async () => { + Bus.subscribeAll((event) => { + Rpc.emit("event", event) + }) + // Emit connected event + Rpc.emit("event", { type: "server.connected", properties: {} }) + return { subscribed: true as const } + }, + }) + } catch (e) { + return { + subscribed: false as const, + error: e instanceof Error ? e.message : String(e), + name: e instanceof Error ? e.name : undefined, + data: e && typeof e === "object" && "toObject" in e ? (e as any).toObject().data : undefined, + } + } }, async checkUpgrade(input: { directory: string }) { await Instance.provide({ diff --git a/packages/opencode/test/cli/config-syntax-error.test.ts b/packages/opencode/test/cli/config-syntax-error.test.ts new file mode 100644 index 00000000000..d5cd4b3a0b2 --- /dev/null +++ b/packages/opencode/test/cli/config-syntax-error.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import path from "path" + +// Invalid JSON config - missing comma after "type": "local" +const INVALID_CONFIG = `{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "example": { + "type": "local" + "command": ["echo", "hello"] + } + } +}` + +// Package root directory for proper module resolution +const PACKAGE_DIR = path.join(import.meta.dir, "../..") +const ENTRY_POINT = path.join(PACKAGE_DIR, "src/index.ts") + +describe("CLI startup with invalid config", () => { + test("TUI should exit with error instead of hanging on invalid JSON syntax", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), INVALID_CONFIG) + }, + }) + + // Pass the project path as argument - TUI command accepts [project] positional + const proc = Bun.spawn({ + cmd: ["bun", "run", "--conditions=browser", ENTRY_POINT, tmp.path], + cwd: PACKAGE_DIR, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + CI: "true", + }, + }) + + const TIMEOUT_MS = 5000 + const timeoutId = setTimeout(() => proc.kill(), TIMEOUT_MS) + + const exitCode = await proc.exited + clearTimeout(timeoutId) + + // If process was killed by timeout (exitCode is signal-based), it hung + // On SIGTERM, exit code is typically 143 (128 + 15) or null + const wasKilled = exitCode === null || exitCode >= 128 + expect(wasKilled).toBe(false) + + expect(exitCode).not.toBe(0) + + const stderr = await new Response(proc.stderr).text() + expect(stderr).toContain("not valid JSON") + }) +})