diff --git a/src/cli/doctor/checks/auth.test.ts b/src/cli/doctor/checks/auth.test.ts new file mode 100644 index 0000000..7940349 --- /dev/null +++ b/src/cli/doctor/checks/auth.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as auth from "./auth" + +describe("auth check", () => { + describe("getAuthProviderInfo", () => { + it("returns anthropic as always available", () => { + // #given anthropic provider + // #when getting info + const info = auth.getAuthProviderInfo("anthropic") + + // #then should show plugin installed (builtin) + expect(info.id).toBe("anthropic") + expect(info.pluginInstalled).toBe(true) + }) + + it("returns correct name for each provider", () => { + // #given each provider + // #when getting info + // #then should have correct names + expect(auth.getAuthProviderInfo("anthropic").name).toContain("Claude") + expect(auth.getAuthProviderInfo("openai").name).toContain("ChatGPT") + expect(auth.getAuthProviderInfo("google").name).toContain("Gemini") + }) + }) + + describe("checkAuthProvider", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns pass when plugin installed", async () => { + // #given plugin installed + getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({ + id: "anthropic", + name: "Anthropic (Claude)", + pluginInstalled: true, + configured: true, + }) + + // #when checking + const result = await auth.checkAuthProvider("anthropic") + + // #then should pass + expect(result.status).toBe("pass") + }) + + it("returns skip when plugin not installed", async () => { + // #given plugin not installed + getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({ + id: "openai", + name: "OpenAI (ChatGPT)", + pluginInstalled: false, + configured: false, + }) + + // #when checking + const result = await auth.checkAuthProvider("openai") + + // #then should skip + expect(result.status).toBe("skip") + expect(result.message).toContain("not installed") + }) + }) + + describe("checkAnthropicAuth", () => { + it("returns a check result", async () => { + // #given + // #when checking anthropic + const result = await auth.checkAnthropicAuth() + + // #then should return valid result + expect(result.name).toBeDefined() + expect(["pass", "fail", "warn", "skip"]).toContain(result.status) + }) + }) + + describe("checkOpenAIAuth", () => { + it("returns a check result", async () => { + // #given + // #when checking openai + const result = await auth.checkOpenAIAuth() + + // #then should return valid result + expect(result.name).toBeDefined() + expect(["pass", "fail", "warn", "skip"]).toContain(result.status) + }) + }) + + describe("checkGoogleAuth", () => { + it("returns a check result", async () => { + // #given + // #when checking google + const result = await auth.checkGoogleAuth() + + // #then should return valid result + expect(result.name).toBeDefined() + expect(["pass", "fail", "warn", "skip"]).toContain(result.status) + }) + }) + + describe("getAuthCheckDefinitions", () => { + it("returns definitions for all three providers", () => { + // #given + // #when getting definitions + const defs = auth.getAuthCheckDefinitions() + + // #then should have 3 definitions + expect(defs.length).toBe(3) + expect(defs.every((d) => d.category === "authentication")).toBe(true) + }) + }) +}) diff --git a/src/cli/doctor/checks/auth.ts b/src/cli/doctor/checks/auth.ts new file mode 100644 index 0000000..1721a1e --- /dev/null +++ b/src/cli/doctor/checks/auth.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types" +import { CHECK_IDS, CHECK_NAMES } from "../constants" +import { parseJsonc } from "../../../shared" + +const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") +const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") + +const AUTH_PLUGINS: Record = { + anthropic: { plugin: "builtin", name: "Anthropic (Claude)" }, + openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI (ChatGPT)" }, + google: { plugin: "opencode-antigravity-auth", name: "Google (Gemini)" }, +} + +function getOpenCodeConfig(): { plugin?: string[] } | null { + const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON + if (!existsSync(configPath)) return null + + try { + const content = readFileSync(configPath, "utf-8") + return parseJsonc<{ plugin?: string[] }>(content) + } catch { + return null + } +} + +function isPluginInstalled(plugins: string[], pluginName: string): boolean { + if (pluginName === "builtin") return true + return plugins.some((p) => p === pluginName || p.startsWith(`${pluginName}@`)) +} + +export function getAuthProviderInfo(providerId: AuthProviderId): AuthProviderInfo { + const config = getOpenCodeConfig() + const plugins = config?.plugin ?? [] + const authConfig = AUTH_PLUGINS[providerId] + + const pluginInstalled = isPluginInstalled(plugins, authConfig.plugin) + + return { + id: providerId, + name: authConfig.name, + pluginInstalled, + configured: pluginInstalled, + } +} + +export async function checkAuthProvider(providerId: AuthProviderId): Promise { + const info = getAuthProviderInfo(providerId) + const checkId = `auth-${providerId}` as keyof typeof CHECK_NAMES + const checkName = CHECK_NAMES[checkId] || info.name + + if (!info.pluginInstalled) { + return { + name: checkName, + status: "skip", + message: "Auth plugin not installed", + details: [ + `Plugin: ${AUTH_PLUGINS[providerId].plugin}`, + "Run: bunx oh-my-opencode install", + ], + } + } + + return { + name: checkName, + status: "pass", + message: "Auth plugin available", + details: [ + providerId === "anthropic" + ? "Run: opencode auth login (select Anthropic)" + : `Plugin: ${AUTH_PLUGINS[providerId].plugin}`, + ], + } +} + +export async function checkAnthropicAuth(): Promise { + return checkAuthProvider("anthropic") +} + +export async function checkOpenAIAuth(): Promise { + return checkAuthProvider("openai") +} + +export async function checkGoogleAuth(): Promise { + return checkAuthProvider("google") +} + +export function getAuthCheckDefinitions(): CheckDefinition[] { + return [ + { + id: CHECK_IDS.AUTH_ANTHROPIC, + name: CHECK_NAMES[CHECK_IDS.AUTH_ANTHROPIC], + category: "authentication", + check: checkAnthropicAuth, + critical: false, + }, + { + id: CHECK_IDS.AUTH_OPENAI, + name: CHECK_NAMES[CHECK_IDS.AUTH_OPENAI], + category: "authentication", + check: checkOpenAIAuth, + critical: false, + }, + { + id: CHECK_IDS.AUTH_GOOGLE, + name: CHECK_NAMES[CHECK_IDS.AUTH_GOOGLE], + category: "authentication", + check: checkGoogleAuth, + critical: false, + }, + ] +} diff --git a/src/cli/doctor/checks/config.test.ts b/src/cli/doctor/checks/config.test.ts new file mode 100644 index 0000000..81129a8 --- /dev/null +++ b/src/cli/doctor/checks/config.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as config from "./config" + +describe("config check", () => { + describe("validateConfig", () => { + it("returns valid: false for non-existent file", () => { + // #given non-existent file path + // #when validating + const result = config.validateConfig("/non/existent/path.json") + + // #then should indicate invalid + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + }) + }) + + describe("getConfigInfo", () => { + it("returns exists: false when no config found", () => { + // #given no config file exists + // #when getting config info + const info = config.getConfigInfo() + + // #then should handle gracefully + expect(typeof info.exists).toBe("boolean") + expect(typeof info.valid).toBe("boolean") + }) + }) + + describe("checkConfigValidity", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns pass when no config exists (uses defaults)", async () => { + // #given no config file + getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ + exists: false, + path: null, + format: null, + valid: true, + errors: [], + }) + + // #when checking validity + const result = await config.checkConfigValidity() + + // #then should pass with default message + expect(result.status).toBe("pass") + expect(result.message).toContain("default") + }) + + it("returns pass when config is valid", async () => { + // #given valid config + getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ + exists: true, + path: "/home/user/.config/opencode/oh-my-opencode.json", + format: "json", + valid: true, + errors: [], + }) + + // #when checking validity + const result = await config.checkConfigValidity() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("JSON") + }) + + it("returns fail when config has validation errors", async () => { + // #given invalid config + getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({ + exists: true, + path: "/home/user/.config/opencode/oh-my-opencode.json", + format: "json", + valid: false, + errors: ["agents.oracle: Invalid model format"], + }) + + // #when checking validity + const result = await config.checkConfigValidity() + + // #then should fail with errors + expect(result.status).toBe("fail") + expect(result.details?.some((d) => d.includes("Error"))).toBe(true) + }) + }) + + describe("getConfigCheckDefinition", () => { + it("returns valid check definition", () => { + // #given + // #when getting definition + const def = config.getConfigCheckDefinition() + + // #then should have required properties + expect(def.id).toBe("config-validation") + expect(def.category).toBe("configuration") + expect(def.critical).toBe(false) + }) + }) +}) diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts new file mode 100644 index 0000000..302e8f6 --- /dev/null +++ b/src/cli/doctor/checks/config.ts @@ -0,0 +1,123 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { CheckResult, CheckDefinition, ConfigInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" +import { parseJsonc, detectConfigFile } from "../../../shared" +import { OhMyOpenCodeConfigSchema } from "../../../config" + +const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") +const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`) +const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) + +function findConfigPath(): { path: string; format: "json" | "jsonc" } | null { + const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE) + if (projectDetected.format !== "none") { + return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" } + } + + const userDetected = detectConfigFile(USER_CONFIG_BASE) + if (userDetected.format !== "none") { + return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" } + } + + return null +} + +export function validateConfig(configPath: string): { valid: boolean; errors: string[] } { + try { + const content = readFileSync(configPath, "utf-8") + const rawConfig = parseJsonc>(content) + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig) + + if (!result.success) { + const errors = result.error.issues.map( + (i) => `${i.path.join(".")}: ${i.message}` + ) + return { valid: false, errors } + } + + return { valid: true, errors: [] } + } catch (err) { + return { + valid: false, + errors: [err instanceof Error ? err.message : "Failed to parse config"], + } + } +} + +export function getConfigInfo(): ConfigInfo { + const configPath = findConfigPath() + + if (!configPath) { + return { + exists: false, + path: null, + format: null, + valid: true, + errors: [], + } + } + + if (!existsSync(configPath.path)) { + return { + exists: false, + path: configPath.path, + format: configPath.format, + valid: true, + errors: [], + } + } + + const validation = validateConfig(configPath.path) + + return { + exists: true, + path: configPath.path, + format: configPath.format, + valid: validation.valid, + errors: validation.errors, + } +} + +export async function checkConfigValidity(): Promise { + const info = getConfigInfo() + + if (!info.exists) { + return { + name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + status: "pass", + message: "Using default configuration", + details: ["No custom config file found (optional)"], + } + } + + if (!info.valid) { + return { + name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + status: "fail", + message: "Configuration has validation errors", + details: [ + `Path: ${info.path}`, + ...info.errors.map((e) => `Error: ${e}`), + ], + } + } + + return { + name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + status: "pass", + message: `Valid ${info.format?.toUpperCase()} config`, + details: [`Path: ${info.path}`], + } +} + +export function getConfigCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.CONFIG_VALIDATION, + name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION], + category: "configuration", + check: checkConfigValidity, + critical: false, + } +} diff --git a/src/cli/doctor/checks/dependencies.test.ts b/src/cli/doctor/checks/dependencies.test.ts new file mode 100644 index 0000000..523f959 --- /dev/null +++ b/src/cli/doctor/checks/dependencies.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as deps from "./dependencies" + +describe("dependencies check", () => { + describe("checkAstGrepCli", () => { + it("returns dependency info", async () => { + // #given + // #when checking ast-grep cli + const info = await deps.checkAstGrepCli() + + // #then should return valid info + expect(info.name).toBe("AST-Grep CLI") + expect(info.required).toBe(false) + expect(typeof info.installed).toBe("boolean") + }) + }) + + describe("checkAstGrepNapi", () => { + it("returns dependency info", () => { + // #given + // #when checking ast-grep napi + const info = deps.checkAstGrepNapi() + + // #then should return valid info + expect(info.name).toBe("AST-Grep NAPI") + expect(info.required).toBe(false) + expect(typeof info.installed).toBe("boolean") + }) + }) + + describe("checkCommentChecker", () => { + it("returns dependency info", async () => { + // #given + // #when checking comment checker + const info = await deps.checkCommentChecker() + + // #then should return valid info + expect(info.name).toBe("Comment Checker") + expect(info.required).toBe(false) + expect(typeof info.installed).toBe("boolean") + }) + }) + + describe("checkDependencyAstGrepCli", () => { + let checkSpy: ReturnType + + afterEach(() => { + checkSpy?.mockRestore() + }) + + it("returns pass when installed", async () => { + // #given ast-grep installed + checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({ + name: "AST-Grep CLI", + required: false, + installed: true, + version: "0.25.0", + path: "/usr/local/bin/sg", + }) + + // #when checking + const result = await deps.checkDependencyAstGrepCli() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("0.25.0") + }) + + it("returns warn when not installed", async () => { + // #given ast-grep not installed + checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({ + name: "AST-Grep CLI", + required: false, + installed: false, + version: null, + path: null, + installHint: "Install: npm install -g @ast-grep/cli", + }) + + // #when checking + const result = await deps.checkDependencyAstGrepCli() + + // #then should warn (optional) + expect(result.status).toBe("warn") + expect(result.message).toContain("optional") + }) + }) + + describe("checkDependencyAstGrepNapi", () => { + let checkSpy: ReturnType + + afterEach(() => { + checkSpy?.mockRestore() + }) + + it("returns pass when installed", async () => { + // #given napi installed + checkSpy = spyOn(deps, "checkAstGrepNapi").mockReturnValue({ + name: "AST-Grep NAPI", + required: false, + installed: true, + version: null, + path: null, + }) + + // #when checking + const result = await deps.checkDependencyAstGrepNapi() + + // #then should pass + expect(result.status).toBe("pass") + }) + }) + + describe("checkDependencyCommentChecker", () => { + let checkSpy: ReturnType + + afterEach(() => { + checkSpy?.mockRestore() + }) + + it("returns warn when not installed", async () => { + // #given comment checker not installed + checkSpy = spyOn(deps, "checkCommentChecker").mockResolvedValue({ + name: "Comment Checker", + required: false, + installed: false, + version: null, + path: null, + installHint: "Hook will be disabled if not available", + }) + + // #when checking + const result = await deps.checkDependencyCommentChecker() + + // #then should warn + expect(result.status).toBe("warn") + }) + }) + + describe("getDependencyCheckDefinitions", () => { + it("returns definitions for all dependencies", () => { + // #given + // #when getting definitions + const defs = deps.getDependencyCheckDefinitions() + + // #then should have 3 definitions + expect(defs.length).toBe(3) + expect(defs.every((d) => d.category === "dependencies")).toBe(true) + expect(defs.every((d) => d.critical === false)).toBe(true) + }) + }) +}) diff --git a/src/cli/doctor/checks/dependencies.ts b/src/cli/doctor/checks/dependencies.ts new file mode 100644 index 0000000..2a941a8 --- /dev/null +++ b/src/cli/doctor/checks/dependencies.ts @@ -0,0 +1,163 @@ +import type { CheckResult, CheckDefinition, DependencyInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES } from "../constants" + +async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> { + try { + const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + return { exists: true, path: output.trim() } + } + } catch { + // intentionally empty - binary not found + } + return { exists: false, path: null } +} + +async function getBinaryVersion(binary: string): Promise { + try { + const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + return output.trim().split("\n")[0] + } + } catch { + // intentionally empty - version unavailable + } + return null +} + +export async function checkAstGrepCli(): Promise { + const binaryCheck = await checkBinaryExists("sg") + const altBinaryCheck = !binaryCheck.exists ? await checkBinaryExists("ast-grep") : null + + const binary = binaryCheck.exists ? binaryCheck : altBinaryCheck + if (!binary || !binary.exists) { + return { + name: "AST-Grep CLI", + required: false, + installed: false, + version: null, + path: null, + installHint: "Install: npm install -g @ast-grep/cli", + } + } + + const version = await getBinaryVersion(binary.path!) + + return { + name: "AST-Grep CLI", + required: false, + installed: true, + version, + path: binary.path, + } +} + +export function checkAstGrepNapi(): DependencyInfo { + try { + require.resolve("@ast-grep/napi") + return { + name: "AST-Grep NAPI", + required: false, + installed: true, + version: null, + path: null, + } + } catch { + return { + name: "AST-Grep NAPI", + required: false, + installed: false, + version: null, + path: null, + installHint: "Will use CLI fallback if available", + } + } +} + +export async function checkCommentChecker(): Promise { + const binaryCheck = await checkBinaryExists("comment-checker") + + if (!binaryCheck.exists) { + return { + name: "Comment Checker", + required: false, + installed: false, + version: null, + path: null, + installHint: "Hook will be disabled if not available", + } + } + + const version = await getBinaryVersion("comment-checker") + + return { + name: "Comment Checker", + required: false, + installed: true, + version, + path: binaryCheck.path, + } +} + +function dependencyToCheckResult(dep: DependencyInfo, checkName: string): CheckResult { + if (dep.installed) { + return { + name: checkName, + status: "pass", + message: dep.version ?? "installed", + details: dep.path ? [`Path: ${dep.path}`] : undefined, + } + } + + return { + name: checkName, + status: "warn", + message: "Not installed (optional)", + details: dep.installHint ? [dep.installHint] : undefined, + } +} + +export async function checkDependencyAstGrepCli(): Promise { + const info = await checkAstGrepCli() + return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI]) +} + +export async function checkDependencyAstGrepNapi(): Promise { + const info = checkAstGrepNapi() + return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI]) +} + +export async function checkDependencyCommentChecker(): Promise { + const info = await checkCommentChecker() + return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER]) +} + +export function getDependencyCheckDefinitions(): CheckDefinition[] { + return [ + { + id: CHECK_IDS.DEP_AST_GREP_CLI, + name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI], + category: "dependencies", + check: checkDependencyAstGrepCli, + critical: false, + }, + { + id: CHECK_IDS.DEP_AST_GREP_NAPI, + name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI], + category: "dependencies", + check: checkDependencyAstGrepNapi, + critical: false, + }, + { + id: CHECK_IDS.DEP_COMMENT_CHECKER, + name: CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER], + category: "dependencies", + check: checkDependencyCommentChecker, + critical: false, + }, + ] +} diff --git a/src/cli/doctor/checks/index.ts b/src/cli/doctor/checks/index.ts new file mode 100644 index 0000000..09457f5 --- /dev/null +++ b/src/cli/doctor/checks/index.ts @@ -0,0 +1,31 @@ +import type { CheckDefinition } from "../types" +import { getOpenCodeCheckDefinition } from "./opencode" +import { getPluginCheckDefinition } from "./plugin" +import { getConfigCheckDefinition } from "./config" +import { getAuthCheckDefinitions } from "./auth" +import { getDependencyCheckDefinitions } from "./dependencies" +import { getLspCheckDefinition } from "./lsp" +import { getMcpCheckDefinitions } from "./mcp" +import { getVersionCheckDefinition } from "./version" + +export * from "./opencode" +export * from "./plugin" +export * from "./config" +export * from "./auth" +export * from "./dependencies" +export * from "./lsp" +export * from "./mcp" +export * from "./version" + +export function getAllCheckDefinitions(): CheckDefinition[] { + return [ + getOpenCodeCheckDefinition(), + getPluginCheckDefinition(), + getConfigCheckDefinition(), + ...getAuthCheckDefinitions(), + ...getDependencyCheckDefinitions(), + getLspCheckDefinition(), + ...getMcpCheckDefinitions(), + getVersionCheckDefinition(), + ] +} diff --git a/src/cli/doctor/checks/lsp.test.ts b/src/cli/doctor/checks/lsp.test.ts new file mode 100644 index 0000000..b266cc0 --- /dev/null +++ b/src/cli/doctor/checks/lsp.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as lsp from "./lsp" +import type { LspServerInfo } from "../types" + +describe("lsp check", () => { + describe("getLspServersInfo", () => { + it("returns array of server info", async () => { + // #given + // #when getting servers info + const servers = await lsp.getLspServersInfo() + + // #then should return array with expected structure + expect(Array.isArray(servers)).toBe(true) + servers.forEach((s) => { + expect(s.id).toBeDefined() + expect(typeof s.installed).toBe("boolean") + expect(Array.isArray(s.extensions)).toBe(true) + }) + }) + }) + + describe("getLspServerStats", () => { + it("counts installed servers correctly", () => { + // #given servers with mixed installation status + const servers = [ + { id: "ts", installed: true, extensions: [".ts"], source: "builtin" as const }, + { id: "py", installed: false, extensions: [".py"], source: "builtin" as const }, + { id: "go", installed: true, extensions: [".go"], source: "builtin" as const }, + ] + + // #when getting stats + const stats = lsp.getLspServerStats(servers) + + // #then should count correctly + expect(stats.installed).toBe(2) + expect(stats.total).toBe(3) + }) + + it("handles empty array", () => { + // #given no servers + const servers: LspServerInfo[] = [] + + // #when getting stats + const stats = lsp.getLspServerStats(servers) + + // #then should return zeros + expect(stats.installed).toBe(0) + expect(stats.total).toBe(0) + }) + }) + + describe("checkLspServers", () => { + let getServersSpy: ReturnType + + afterEach(() => { + getServersSpy?.mockRestore() + }) + + it("returns warn when no servers installed", async () => { + // #given no servers installed + getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ + { id: "typescript-language-server", installed: false, extensions: [".ts"], source: "builtin" }, + { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, + ]) + + // #when checking + const result = await lsp.checkLspServers() + + // #then should warn + expect(result.status).toBe("warn") + expect(result.message).toContain("No LSP servers") + }) + + it("returns pass when servers installed", async () => { + // #given some servers installed + getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ + { id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" }, + { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, + ]) + + // #when checking + const result = await lsp.checkLspServers() + + // #then should pass with count + expect(result.status).toBe("pass") + expect(result.message).toContain("1/2") + }) + + it("lists installed and missing servers in details", async () => { + // #given mixed installation + getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([ + { id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" }, + { id: "pyright", installed: false, extensions: [".py"], source: "builtin" }, + ]) + + // #when checking + const result = await lsp.checkLspServers() + + // #then should list both + expect(result.details?.some((d) => d.includes("Installed"))).toBe(true) + expect(result.details?.some((d) => d.includes("Not found"))).toBe(true) + }) + }) + + describe("getLspCheckDefinition", () => { + it("returns valid check definition", () => { + // #given + // #when getting definition + const def = lsp.getLspCheckDefinition() + + // #then should have required properties + expect(def.id).toBe("lsp-servers") + expect(def.category).toBe("tools") + expect(def.critical).toBe(false) + }) + }) +}) diff --git a/src/cli/doctor/checks/lsp.ts b/src/cli/doctor/checks/lsp.ts new file mode 100644 index 0000000..70350ed --- /dev/null +++ b/src/cli/doctor/checks/lsp.ts @@ -0,0 +1,85 @@ +import type { CheckResult, CheckDefinition, LspServerInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES } from "../constants" + +const DEFAULT_LSP_SERVERS: Array<{ + id: string + binary: string + extensions: string[] +}> = [ + { id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] }, + { id: "pyright", binary: "pyright-langserver", extensions: [".py"] }, + { id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] }, + { id: "gopls", binary: "gopls", extensions: [".go"] }, +] + +async function checkBinaryExists(binary: string): Promise { + try { + const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) + await proc.exited + return proc.exitCode === 0 + } catch { + return false + } +} + +export async function getLspServersInfo(): Promise { + const servers: LspServerInfo[] = [] + + for (const server of DEFAULT_LSP_SERVERS) { + const installed = await checkBinaryExists(server.binary) + servers.push({ + id: server.id, + installed, + extensions: server.extensions, + source: "builtin", + }) + } + + return servers +} + +export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } { + const installed = servers.filter((s) => s.installed).length + return { installed, total: servers.length } +} + +export async function checkLspServers(): Promise { + const servers = await getLspServersInfo() + const stats = getLspServerStats(servers) + const installedServers = servers.filter((s) => s.installed) + const missingServers = servers.filter((s) => !s.installed) + + if (stats.installed === 0) { + return { + name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], + status: "warn", + message: "No LSP servers detected", + details: [ + "LSP tools will have limited functionality", + ...missingServers.map((s) => `Missing: ${s.id}`), + ], + } + } + + const details = [ + ...installedServers.map((s) => `Installed: ${s.id}`), + ...missingServers.map((s) => `Not found: ${s.id} (optional)`), + ] + + return { + name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], + status: "pass", + message: `${stats.installed}/${stats.total} servers available`, + details, + } +} + +export function getLspCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.LSP_SERVERS, + name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS], + category: "tools", + check: checkLspServers, + critical: false, + } +} diff --git a/src/cli/doctor/checks/mcp.test.ts b/src/cli/doctor/checks/mcp.test.ts new file mode 100644 index 0000000..cb8b1b6 --- /dev/null +++ b/src/cli/doctor/checks/mcp.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as mcp from "./mcp" + +describe("mcp check", () => { + describe("getBuiltinMcpInfo", () => { + it("returns builtin servers", () => { + // #given + // #when getting builtin info + const servers = mcp.getBuiltinMcpInfo() + + // #then should include expected servers + expect(servers.length).toBe(3) + expect(servers.every((s) => s.type === "builtin")).toBe(true) + expect(servers.every((s) => s.enabled === true)).toBe(true) + expect(servers.map((s) => s.id)).toContain("context7") + expect(servers.map((s) => s.id)).toContain("websearch_exa") + expect(servers.map((s) => s.id)).toContain("grep_app") + }) + }) + + describe("getUserMcpInfo", () => { + it("returns empty array when no user config", () => { + // #given no user config exists + // #when getting user info + const servers = mcp.getUserMcpInfo() + + // #then should return array (may be empty) + expect(Array.isArray(servers)).toBe(true) + }) + }) + + describe("checkBuiltinMcpServers", () => { + it("returns pass with server count", async () => { + // #given + // #when checking builtin servers + const result = await mcp.checkBuiltinMcpServers() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("3") + expect(result.message).toContain("enabled") + }) + + it("lists enabled servers in details", async () => { + // #given + // #when checking builtin servers + const result = await mcp.checkBuiltinMcpServers() + + // #then should list servers + expect(result.details?.some((d) => d.includes("context7"))).toBe(true) + expect(result.details?.some((d) => d.includes("websearch_exa"))).toBe(true) + expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true) + }) + }) + + describe("checkUserMcpServers", () => { + let getUserSpy: ReturnType + + afterEach(() => { + getUserSpy?.mockRestore() + }) + + it("returns skip when no user config", async () => { + // #given no user servers + getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([]) + + // #when checking + const result = await mcp.checkUserMcpServers() + + // #then should skip + expect(result.status).toBe("skip") + expect(result.message).toContain("No user MCP") + }) + + it("returns pass when valid user servers", async () => { + // #given valid user servers + getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([ + { id: "custom-mcp", type: "user", enabled: true, valid: true }, + ]) + + // #when checking + const result = await mcp.checkUserMcpServers() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("1") + }) + + it("returns warn when servers have issues", async () => { + // #given invalid server config + getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([ + { id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" }, + ]) + + // #when checking + const result = await mcp.checkUserMcpServers() + + // #then should warn + expect(result.status).toBe("warn") + expect(result.details?.some((d) => d.includes("Invalid"))).toBe(true) + }) + }) + + describe("getMcpCheckDefinitions", () => { + it("returns definitions for builtin and user", () => { + // #given + // #when getting definitions + const defs = mcp.getMcpCheckDefinitions() + + // #then should have 2 definitions + expect(defs.length).toBe(2) + expect(defs.every((d) => d.category === "tools")).toBe(true) + expect(defs.map((d) => d.id)).toContain("mcp-builtin") + expect(defs.map((d) => d.id)).toContain("mcp-user") + }) + }) +}) diff --git a/src/cli/doctor/checks/mcp.ts b/src/cli/doctor/checks/mcp.ts new file mode 100644 index 0000000..ba89457 --- /dev/null +++ b/src/cli/doctor/checks/mcp.ts @@ -0,0 +1,128 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { CheckResult, CheckDefinition, McpServerInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES } from "../constants" +import { parseJsonc } from "../../../shared" + +const BUILTIN_MCP_SERVERS = ["context7", "websearch_exa", "grep_app"] + +const MCP_CONFIG_PATHS = [ + join(homedir(), ".claude", ".mcp.json"), + join(process.cwd(), ".mcp.json"), + join(process.cwd(), ".claude", ".mcp.json"), +] + +interface McpConfig { + mcpServers?: Record +} + +function loadUserMcpConfig(): Record { + const servers: Record = {} + + for (const configPath of MCP_CONFIG_PATHS) { + if (!existsSync(configPath)) continue + + try { + const content = readFileSync(configPath, "utf-8") + const config = parseJsonc(content) + if (config.mcpServers) { + Object.assign(servers, config.mcpServers) + } + } catch { + // intentionally empty - skip invalid configs + } + } + + return servers +} + +export function getBuiltinMcpInfo(): McpServerInfo[] { + return BUILTIN_MCP_SERVERS.map((id) => ({ + id, + type: "builtin" as const, + enabled: true, + valid: true, + })) +} + +export function getUserMcpInfo(): McpServerInfo[] { + const userServers = loadUserMcpConfig() + const servers: McpServerInfo[] = [] + + for (const [id, config] of Object.entries(userServers)) { + const isValid = typeof config === "object" && config !== null + servers.push({ + id, + type: "user", + enabled: true, + valid: isValid, + error: isValid ? undefined : "Invalid configuration format", + }) + } + + return servers +} + +export async function checkBuiltinMcpServers(): Promise { + const servers = getBuiltinMcpInfo() + + return { + name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN], + status: "pass", + message: `${servers.length} built-in servers enabled`, + details: servers.map((s) => `Enabled: ${s.id}`), + } +} + +export async function checkUserMcpServers(): Promise { + const servers = getUserMcpInfo() + + if (servers.length === 0) { + return { + name: CHECK_NAMES[CHECK_IDS.MCP_USER], + status: "skip", + message: "No user MCP configuration found", + details: ["Optional: Add .mcp.json for custom MCP servers"], + } + } + + const invalidServers = servers.filter((s) => !s.valid) + if (invalidServers.length > 0) { + return { + name: CHECK_NAMES[CHECK_IDS.MCP_USER], + status: "warn", + message: `${invalidServers.length} server(s) have configuration issues`, + details: [ + ...servers.filter((s) => s.valid).map((s) => `Valid: ${s.id}`), + ...invalidServers.map((s) => `Invalid: ${s.id} - ${s.error}`), + ], + } + } + + return { + name: CHECK_NAMES[CHECK_IDS.MCP_USER], + status: "pass", + message: `${servers.length} user server(s) configured`, + details: servers.map((s) => `Configured: ${s.id}`), + } +} + +export function getMcpCheckDefinitions(): CheckDefinition[] { + return [ + { + id: CHECK_IDS.MCP_BUILTIN, + name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN], + category: "tools", + check: checkBuiltinMcpServers, + critical: false, + }, + { + id: CHECK_IDS.MCP_USER, + name: CHECK_NAMES[CHECK_IDS.MCP_USER], + category: "tools", + check: checkUserMcpServers, + critical: false, + }, + ] +} diff --git a/src/cli/doctor/checks/opencode.test.ts b/src/cli/doctor/checks/opencode.test.ts new file mode 100644 index 0000000..160dfcb --- /dev/null +++ b/src/cli/doctor/checks/opencode.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test" +import * as opencode from "./opencode" +import { MIN_OPENCODE_VERSION } from "../constants" + +describe("opencode check", () => { + describe("compareVersions", () => { + it("returns true when current >= minimum", () => { + // #given versions where current is greater + // #when comparing + // #then should return true + expect(opencode.compareVersions("1.0.200", "1.0.150")).toBe(true) + expect(opencode.compareVersions("1.1.0", "1.0.150")).toBe(true) + expect(opencode.compareVersions("2.0.0", "1.0.150")).toBe(true) + }) + + it("returns true when versions are equal", () => { + // #given equal versions + // #when comparing + // #then should return true + expect(opencode.compareVersions("1.0.150", "1.0.150")).toBe(true) + }) + + it("returns false when current < minimum", () => { + // #given version below minimum + // #when comparing + // #then should return false + expect(opencode.compareVersions("1.0.100", "1.0.150")).toBe(false) + expect(opencode.compareVersions("0.9.0", "1.0.150")).toBe(false) + }) + + it("handles version prefixes", () => { + // #given version with v prefix + // #when comparing + // #then should strip prefix and compare correctly + expect(opencode.compareVersions("v1.0.200", "1.0.150")).toBe(true) + }) + + it("handles prerelease versions", () => { + // #given prerelease version + // #when comparing + // #then should use base version + expect(opencode.compareVersions("1.0.200-beta.1", "1.0.150")).toBe(true) + }) + }) + + describe("getOpenCodeInfo", () => { + it("returns installed: false when binary not found", async () => { + // #given no opencode binary + const spy = spyOn(opencode, "findOpenCodeBinary").mockResolvedValue(null) + + // #when getting info + const info = await opencode.getOpenCodeInfo() + + // #then should indicate not installed + expect(info.installed).toBe(false) + expect(info.version).toBeNull() + expect(info.path).toBeNull() + expect(info.binary).toBeNull() + + spy.mockRestore() + }) + }) + + describe("checkOpenCodeInstallation", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns fail when not installed", async () => { + // #given opencode not installed + getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ + installed: false, + version: null, + path: null, + binary: null, + }) + + // #when checking installation + const result = await opencode.checkOpenCodeInstallation() + + // #then should fail with installation hint + expect(result.status).toBe("fail") + expect(result.message).toContain("not installed") + expect(result.details).toBeDefined() + expect(result.details?.some((d) => d.includes("opencode.ai"))).toBe(true) + }) + + it("returns warn when version below minimum", async () => { + // #given old version installed + getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ + installed: true, + version: "1.0.100", + path: "/usr/local/bin/opencode", + binary: "opencode", + }) + + // #when checking installation + const result = await opencode.checkOpenCodeInstallation() + + // #then should warn about old version + expect(result.status).toBe("warn") + expect(result.message).toContain("below minimum") + expect(result.details?.some((d) => d.includes(MIN_OPENCODE_VERSION))).toBe(true) + }) + + it("returns pass when properly installed", async () => { + // #given current version installed + getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({ + installed: true, + version: "1.0.200", + path: "/usr/local/bin/opencode", + binary: "opencode", + }) + + // #when checking installation + const result = await opencode.checkOpenCodeInstallation() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("1.0.200") + }) + }) + + describe("getOpenCodeCheckDefinition", () => { + it("returns valid check definition", () => { + // #given + // #when getting definition + const def = opencode.getOpenCodeCheckDefinition() + + // #then should have required properties + expect(def.id).toBe("opencode-installation") + expect(def.category).toBe("installation") + expect(def.critical).toBe(true) + expect(typeof def.check).toBe("function") + }) + }) +}) diff --git a/src/cli/doctor/checks/opencode.ts b/src/cli/doctor/checks/opencode.ts new file mode 100644 index 0000000..e6a2345 --- /dev/null +++ b/src/cli/doctor/checks/opencode.ts @@ -0,0 +1,118 @@ +import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants" + +export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> { + for (const binary of OPENCODE_BINARIES) { + try { + const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + return { binary, path: output.trim() } + } + } catch { + continue + } + } + return null +} + +export async function getOpenCodeVersion(binary: string): Promise { + try { + const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" }) + const output = await new Response(proc.stdout).text() + await proc.exited + if (proc.exitCode === 0) { + return output.trim() + } + } catch { + return null + } + return null +} + +export function compareVersions(current: string, minimum: string): boolean { + const parseVersion = (v: string): number[] => { + const cleaned = v.replace(/^v/, "").split("-")[0] + return cleaned.split(".").map((n) => parseInt(n, 10) || 0) + } + + const curr = parseVersion(current) + const min = parseVersion(minimum) + + for (let i = 0; i < Math.max(curr.length, min.length); i++) { + const c = curr[i] ?? 0 + const m = min[i] ?? 0 + if (c > m) return true + if (c < m) return false + } + return true +} + +export async function getOpenCodeInfo(): Promise { + const binaryInfo = await findOpenCodeBinary() + + if (!binaryInfo) { + return { + installed: false, + version: null, + path: null, + binary: null, + } + } + + const version = await getOpenCodeVersion(binaryInfo.binary) + + return { + installed: true, + version, + path: binaryInfo.path, + binary: binaryInfo.binary as "opencode" | "opencode-desktop", + } +} + +export async function checkOpenCodeInstallation(): Promise { + const info = await getOpenCodeInfo() + + if (!info.installed) { + return { + name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], + status: "fail", + message: "OpenCode is not installed", + details: [ + "Visit: https://opencode.ai/docs for installation instructions", + "Run: npm install -g opencode", + ], + } + } + + if (info.version && !compareVersions(info.version, MIN_OPENCODE_VERSION)) { + return { + name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], + status: "warn", + message: `Version ${info.version} is below minimum ${MIN_OPENCODE_VERSION}`, + details: [ + `Current: ${info.version}`, + `Required: >= ${MIN_OPENCODE_VERSION}`, + "Run: npm update -g opencode", + ], + } + } + + return { + name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], + status: "pass", + message: info.version ?? "installed", + details: info.path ? [`Path: ${info.path}`] : undefined, + } +} + +export function getOpenCodeCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.OPENCODE_INSTALLATION, + name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION], + category: "installation", + check: checkOpenCodeInstallation, + critical: true, + } +} diff --git a/src/cli/doctor/checks/plugin.test.ts b/src/cli/doctor/checks/plugin.test.ts new file mode 100644 index 0000000..e6a3612 --- /dev/null +++ b/src/cli/doctor/checks/plugin.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as plugin from "./plugin" + +describe("plugin check", () => { + describe("getPluginInfo", () => { + it("returns registered: false when config not found", () => { + // #given no config file exists + // #when getting plugin info + // #then should indicate not registered + const info = plugin.getPluginInfo() + expect(typeof info.registered).toBe("boolean") + expect(typeof info.isPinned).toBe("boolean") + }) + }) + + describe("checkPluginRegistration", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns fail when config file not found", async () => { + // #given no config file + getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ + registered: false, + configPath: null, + entry: null, + isPinned: false, + pinnedVersion: null, + }) + + // #when checking registration + const result = await plugin.checkPluginRegistration() + + // #then should fail with hint + expect(result.status).toBe("fail") + expect(result.message).toContain("not found") + }) + + it("returns fail when plugin not registered", async () => { + // #given config exists but plugin not registered + getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ + registered: false, + configPath: "/home/user/.config/opencode/opencode.json", + entry: null, + isPinned: false, + pinnedVersion: null, + }) + + // #when checking registration + const result = await plugin.checkPluginRegistration() + + // #then should fail + expect(result.status).toBe("fail") + expect(result.message).toContain("not registered") + }) + + it("returns pass when plugin registered", async () => { + // #given plugin registered + getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ + registered: true, + configPath: "/home/user/.config/opencode/opencode.json", + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + }) + + // #when checking registration + const result = await plugin.checkPluginRegistration() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("Registered") + }) + + it("indicates pinned version when applicable", async () => { + // #given plugin pinned to version + getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({ + registered: true, + configPath: "/home/user/.config/opencode/opencode.json", + entry: "oh-my-opencode@2.7.0", + isPinned: true, + pinnedVersion: "2.7.0", + }) + + // #when checking registration + const result = await plugin.checkPluginRegistration() + + // #then should show pinned version + expect(result.status).toBe("pass") + expect(result.message).toContain("pinned") + expect(result.message).toContain("2.7.0") + }) + }) + + describe("getPluginCheckDefinition", () => { + it("returns valid check definition", () => { + // #given + // #when getting definition + const def = plugin.getPluginCheckDefinition() + + // #then should have required properties + expect(def.id).toBe("plugin-registration") + expect(def.category).toBe("installation") + expect(def.critical).toBe(true) + }) + }) +}) diff --git a/src/cli/doctor/checks/plugin.ts b/src/cli/doctor/checks/plugin.ts new file mode 100644 index 0000000..05a0f85 --- /dev/null +++ b/src/cli/doctor/checks/plugin.ts @@ -0,0 +1,127 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { CheckResult, CheckDefinition, PluginInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" +import { parseJsonc } from "../../../shared" + +const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") +const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") + +function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null { + if (existsSync(OPENCODE_JSONC)) { + return { path: OPENCODE_JSONC, format: "jsonc" } + } + if (existsSync(OPENCODE_JSON)) { + return { path: OPENCODE_JSON, format: "json" } + } + return null +} + +function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean; version: string | null } | null { + for (const plugin of plugins) { + if (plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`)) { + const isPinned = plugin.includes("@") + const version = isPinned ? plugin.split("@")[1] : null + return { entry: plugin, isPinned, version } + } + } + return null +} + +export function getPluginInfo(): PluginInfo { + const configInfo = detectConfigPath() + + if (!configInfo) { + return { + registered: false, + configPath: null, + entry: null, + isPinned: false, + pinnedVersion: null, + } + } + + try { + const content = readFileSync(configInfo.path, "utf-8") + const config = parseJsonc<{ plugin?: string[] }>(content) + const plugins = config.plugin ?? [] + const pluginEntry = findPluginEntry(plugins) + + if (!pluginEntry) { + return { + registered: false, + configPath: configInfo.path, + entry: null, + isPinned: false, + pinnedVersion: null, + } + } + + return { + registered: true, + configPath: configInfo.path, + entry: pluginEntry.entry, + isPinned: pluginEntry.isPinned, + pinnedVersion: pluginEntry.version, + } + } catch { + return { + registered: false, + configPath: configInfo.path, + entry: null, + isPinned: false, + pinnedVersion: null, + } + } +} + +export async function checkPluginRegistration(): Promise { + const info = getPluginInfo() + + if (!info.configPath) { + return { + name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], + status: "fail", + message: "OpenCode config file not found", + details: [ + "Run: bunx oh-my-opencode install", + `Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`, + ], + } + } + + if (!info.registered) { + return { + name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], + status: "fail", + message: "Plugin not registered in config", + details: [ + "Run: bunx oh-my-opencode install", + `Config: ${info.configPath}`, + ], + } + } + + const message = info.isPinned + ? `Registered (pinned: ${info.pinnedVersion})` + : "Registered" + + return { + name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], + status: "pass", + message, + details: [`Config: ${info.configPath}`], + } +} + +export function getPluginCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.PLUGIN_REGISTRATION, + name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION], + category: "installation", + check: checkPluginRegistration, + critical: true, + } +} diff --git a/src/cli/doctor/checks/version.test.ts b/src/cli/doctor/checks/version.test.ts new file mode 100644 index 0000000..c0851ff --- /dev/null +++ b/src/cli/doctor/checks/version.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import * as version from "./version" + +describe("version check", () => { + describe("getVersionInfo", () => { + it("returns version check info structure", async () => { + // #given + // #when getting version info + const info = await version.getVersionInfo() + + // #then should have expected structure + expect(typeof info.isUpToDate).toBe("boolean") + expect(typeof info.isLocalDev).toBe("boolean") + expect(typeof info.isPinned).toBe("boolean") + }) + }) + + describe("checkVersionStatus", () => { + let getInfoSpy: ReturnType + + afterEach(() => { + getInfoSpy?.mockRestore() + }) + + it("returns pass when in local dev mode", async () => { + // #given local dev mode + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: "local-dev", + latestVersion: "2.7.0", + isUpToDate: true, + isLocalDev: true, + isPinned: false, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should pass with dev message + expect(result.status).toBe("pass") + expect(result.message).toContain("local development") + }) + + it("returns pass when pinned", async () => { + // #given pinned version + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: "2.6.0", + latestVersion: "2.7.0", + isUpToDate: true, + isLocalDev: false, + isPinned: true, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should pass with pinned message + expect(result.status).toBe("pass") + expect(result.message).toContain("Pinned") + }) + + it("returns warn when unable to determine version", async () => { + // #given no version info + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: null, + latestVersion: "2.7.0", + isUpToDate: false, + isLocalDev: false, + isPinned: false, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should warn + expect(result.status).toBe("warn") + expect(result.message).toContain("Unable to determine") + }) + + it("returns warn when network error", async () => { + // #given network error + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: "2.6.0", + latestVersion: null, + isUpToDate: true, + isLocalDev: false, + isPinned: false, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should warn + expect(result.status).toBe("warn") + expect(result.details?.some((d) => d.includes("network"))).toBe(true) + }) + + it("returns warn when update available", async () => { + // #given update available + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: "2.6.0", + latestVersion: "2.7.0", + isUpToDate: false, + isLocalDev: false, + isPinned: false, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should warn with update info + expect(result.status).toBe("warn") + expect(result.message).toContain("Update available") + expect(result.message).toContain("2.6.0") + expect(result.message).toContain("2.7.0") + }) + + it("returns pass when up to date", async () => { + // #given up to date + getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({ + currentVersion: "2.7.0", + latestVersion: "2.7.0", + isUpToDate: true, + isLocalDev: false, + isPinned: false, + }) + + // #when checking + const result = await version.checkVersionStatus() + + // #then should pass + expect(result.status).toBe("pass") + expect(result.message).toContain("Up to date") + }) + }) + + describe("getVersionCheckDefinition", () => { + it("returns valid check definition", () => { + // #given + // #when getting definition + const def = version.getVersionCheckDefinition() + + // #then should have required properties + expect(def.id).toBe("version-status") + expect(def.category).toBe("updates") + expect(def.critical).toBe(false) + }) + }) +}) diff --git a/src/cli/doctor/checks/version.ts b/src/cli/doctor/checks/version.ts new file mode 100644 index 0000000..b110372 --- /dev/null +++ b/src/cli/doctor/checks/version.ts @@ -0,0 +1,177 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" +import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types" +import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" +import { parseJsonc } from "../../../shared" + +const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json") +const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") +const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") + +async function fetchLatestVersion(): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) return null + const data = (await res.json()) as { version: string } + return data.version + } catch { + return null + } +} + +function getCurrentVersion(): { + version: string | null + isLocalDev: boolean + isPinned: boolean + pinnedVersion: string | null +} { + const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON + + if (!existsSync(configPath)) { + return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null } + } + + try { + const content = readFileSync(configPath, "utf-8") + const config = parseJsonc<{ plugin?: string[] }>(content) + const plugins = config.plugin ?? [] + + for (const plugin of plugins) { + if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) { + return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null } + } + if (plugin.startsWith(`${PACKAGE_NAME}@`)) { + const pinnedVersion = plugin.split("@")[1] + return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion } + } + if (plugin === PACKAGE_NAME) { + if (existsSync(OPENCODE_PACKAGE_JSON)) { + try { + const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8") + const pkg = JSON.parse(pkgContent) as { dependencies?: Record } + const depVersion = pkg.dependencies?.[PACKAGE_NAME] + if (depVersion) { + const cleanVersion = depVersion.replace(/^[\^~]/, "") + return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null } + } + } catch { + // intentionally empty - parse errors ignored + } + } + return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null } + } + } + + return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null } + } catch { + return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null } + } +} + +function compareVersions(current: string, latest: string): boolean { + const parseVersion = (v: string): number[] => { + const cleaned = v.replace(/^v/, "").split("-")[0] + return cleaned.split(".").map((n) => parseInt(n, 10) || 0) + } + + const curr = parseVersion(current) + const lat = parseVersion(latest) + + for (let i = 0; i < Math.max(curr.length, lat.length); i++) { + const c = curr[i] ?? 0 + const l = lat[i] ?? 0 + if (c < l) return false + if (c > l) return true + } + return true +} + +export async function getVersionInfo(): Promise { + const current = getCurrentVersion() + const latestVersion = await fetchLatestVersion() + + const isUpToDate = + current.isLocalDev || + current.isPinned || + !current.version || + !latestVersion || + compareVersions(current.version, latestVersion) + + return { + currentVersion: current.version, + latestVersion, + isUpToDate, + isLocalDev: current.isLocalDev, + isPinned: current.isPinned, + } +} + +export async function checkVersionStatus(): Promise { + const info = await getVersionInfo() + + if (info.isLocalDev) { + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "pass", + message: "Running in local development mode", + details: ["Using file:// protocol from config"], + } + } + + if (info.isPinned) { + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "pass", + message: `Pinned to version ${info.currentVersion}`, + details: ["Update check skipped for pinned versions"], + } + } + + if (!info.currentVersion) { + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "warn", + message: "Unable to determine current version", + details: ["Run: bunx oh-my-opencode get-local-version"], + } + } + + if (!info.latestVersion) { + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "warn", + message: `Current: ${info.currentVersion}`, + details: ["Unable to check for updates (network error)"], + } + } + + if (!info.isUpToDate) { + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "warn", + message: `Update available: ${info.currentVersion} -> ${info.latestVersion}`, + details: ["Run: cd ~/.config/opencode && bun update oh-my-opencode"], + } + } + + return { + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + status: "pass", + message: `Up to date (${info.currentVersion})`, + details: info.latestVersion ? [`Latest: ${info.latestVersion}`] : undefined, + } +} + +export function getVersionCheckDefinition(): CheckDefinition { + return { + id: CHECK_IDS.VERSION_STATUS, + name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS], + category: "updates", + check: checkVersionStatus, + critical: false, + } +} diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts new file mode 100644 index 0000000..7a3d7d6 --- /dev/null +++ b/src/cli/doctor/constants.ts @@ -0,0 +1,70 @@ +import color from "picocolors" + +export const SYMBOLS = { + check: color.green("\u2713"), + cross: color.red("\u2717"), + warn: color.yellow("\u26A0"), + info: color.blue("\u2139"), + arrow: color.cyan("\u2192"), + bullet: color.dim("\u2022"), + skip: color.dim("\u25CB"), +} as const + +export const STATUS_COLORS = { + pass: color.green, + fail: color.red, + warn: color.yellow, + skip: color.dim, +} as const + +export const CHECK_IDS = { + OPENCODE_INSTALLATION: "opencode-installation", + PLUGIN_REGISTRATION: "plugin-registration", + CONFIG_VALIDATION: "config-validation", + AUTH_ANTHROPIC: "auth-anthropic", + AUTH_OPENAI: "auth-openai", + AUTH_GOOGLE: "auth-google", + DEP_AST_GREP_CLI: "dep-ast-grep-cli", + DEP_AST_GREP_NAPI: "dep-ast-grep-napi", + DEP_COMMENT_CHECKER: "dep-comment-checker", + LSP_SERVERS: "lsp-servers", + MCP_BUILTIN: "mcp-builtin", + MCP_USER: "mcp-user", + VERSION_STATUS: "version-status", +} as const + +export const CHECK_NAMES: Record = { + [CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation", + [CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration", + [CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity", + [CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth", + [CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth", + [CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth", + [CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI", + [CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI", + [CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker", + [CHECK_IDS.LSP_SERVERS]: "LSP Servers", + [CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers", + [CHECK_IDS.MCP_USER]: "User MCP Configuration", + [CHECK_IDS.VERSION_STATUS]: "Version Status", +} as const + +export const CATEGORY_NAMES: Record = { + installation: "Installation", + configuration: "Configuration", + authentication: "Authentication", + dependencies: "Dependencies", + tools: "Tools & Servers", + updates: "Updates", +} as const + +export const EXIT_CODES = { + SUCCESS: 0, + FAILURE: 1, +} as const + +export const MIN_OPENCODE_VERSION = "1.0.150" + +export const PACKAGE_NAME = "oh-my-opencode" + +export const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const diff --git a/src/cli/doctor/formatter.test.ts b/src/cli/doctor/formatter.test.ts new file mode 100644 index 0000000..062d6c6 --- /dev/null +++ b/src/cli/doctor/formatter.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "bun:test" +import { + formatStatusSymbol, + formatCheckResult, + formatCategoryHeader, + formatSummary, + formatHeader, + formatFooter, + formatJsonOutput, + formatBox, + formatHelpSuggestions, +} from "./formatter" +import type { CheckResult, DoctorSummary, DoctorResult } from "./types" + +describe("formatter", () => { + describe("formatStatusSymbol", () => { + it("returns green check for pass", () => { + const symbol = formatStatusSymbol("pass") + expect(symbol).toContain("\u2713") + }) + + it("returns red cross for fail", () => { + const symbol = formatStatusSymbol("fail") + expect(symbol).toContain("\u2717") + }) + + it("returns yellow warning for warn", () => { + const symbol = formatStatusSymbol("warn") + expect(symbol).toContain("\u26A0") + }) + + it("returns dim circle for skip", () => { + const symbol = formatStatusSymbol("skip") + expect(symbol).toContain("\u25CB") + }) + }) + + describe("formatCheckResult", () => { + it("includes name and message", () => { + const result: CheckResult = { + name: "Test Check", + status: "pass", + message: "All good", + } + + const output = formatCheckResult(result, false) + + expect(output).toContain("Test Check") + expect(output).toContain("All good") + }) + + it("includes details when verbose", () => { + const result: CheckResult = { + name: "Test Check", + status: "pass", + message: "OK", + details: ["Detail 1", "Detail 2"], + } + + const output = formatCheckResult(result, true) + + expect(output).toContain("Detail 1") + expect(output).toContain("Detail 2") + }) + + it("hides details when not verbose", () => { + const result: CheckResult = { + name: "Test Check", + status: "pass", + message: "OK", + details: ["Detail 1"], + } + + const output = formatCheckResult(result, false) + + expect(output).not.toContain("Detail 1") + }) + }) + + describe("formatCategoryHeader", () => { + it("formats category name with styling", () => { + const header = formatCategoryHeader("installation") + + expect(header).toContain("Installation") + }) + }) + + describe("formatSummary", () => { + it("shows all counts", () => { + const summary: DoctorSummary = { + total: 10, + passed: 7, + failed: 1, + warnings: 2, + skipped: 0, + duration: 150, + } + + const output = formatSummary(summary) + + expect(output).toContain("7 passed") + expect(output).toContain("1 failed") + expect(output).toContain("2 warnings") + expect(output).toContain("10 checks") + expect(output).toContain("150ms") + }) + }) + + describe("formatHeader", () => { + it("includes doctor branding", () => { + const header = formatHeader() + + expect(header).toContain("Doctor") + }) + }) + + describe("formatFooter", () => { + it("shows error message when failures", () => { + const summary: DoctorSummary = { + total: 5, + passed: 4, + failed: 1, + warnings: 0, + skipped: 0, + duration: 100, + } + + const footer = formatFooter(summary) + + expect(footer).toContain("Issues detected") + }) + + it("shows warning message when warnings only", () => { + const summary: DoctorSummary = { + total: 5, + passed: 4, + failed: 0, + warnings: 1, + skipped: 0, + duration: 100, + } + + const footer = formatFooter(summary) + + expect(footer).toContain("warnings") + }) + + it("shows success message when all pass", () => { + const summary: DoctorSummary = { + total: 5, + passed: 5, + failed: 0, + warnings: 0, + skipped: 0, + duration: 100, + } + + const footer = formatFooter(summary) + + expect(footer).toContain("operational") + }) + }) + + describe("formatJsonOutput", () => { + it("returns valid JSON", () => { + const result: DoctorResult = { + results: [{ name: "Test", status: "pass", message: "OK" }], + summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 }, + exitCode: 0, + } + + const output = formatJsonOutput(result) + const parsed = JSON.parse(output) + + expect(parsed.results.length).toBe(1) + expect(parsed.summary.total).toBe(1) + expect(parsed.exitCode).toBe(0) + }) + }) + + describe("formatBox", () => { + it("wraps content in box", () => { + const box = formatBox("Test content") + + expect(box).toContain("Test content") + expect(box).toContain("\u2500") + }) + + it("includes title when provided", () => { + const box = formatBox("Content", "My Title") + + expect(box).toContain("My Title") + }) + }) + + describe("formatHelpSuggestions", () => { + it("extracts suggestions from failed checks", () => { + const results: CheckResult[] = [ + { name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] }, + { name: "OK", status: "pass", message: "Good" }, + ] + + const suggestions = formatHelpSuggestions(results) + + expect(suggestions).toContain("Run: fix-command") + }) + + it("returns empty array when no failures", () => { + const results: CheckResult[] = [ + { name: "OK", status: "pass", message: "Good" }, + ] + + const suggestions = formatHelpSuggestions(results) + + expect(suggestions.length).toBe(0) + }) + }) +}) diff --git a/src/cli/doctor/formatter.ts b/src/cli/doctor/formatter.ts new file mode 100644 index 0000000..976a328 --- /dev/null +++ b/src/cli/doctor/formatter.ts @@ -0,0 +1,140 @@ +import color from "picocolors" +import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types" +import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants" + +export function formatStatusSymbol(status: CheckResult["status"]): string { + switch (status) { + case "pass": + return SYMBOLS.check + case "fail": + return SYMBOLS.cross + case "warn": + return SYMBOLS.warn + case "skip": + return SYMBOLS.skip + } +} + +export function formatCheckResult(result: CheckResult, verbose: boolean): string { + const symbol = formatStatusSymbol(result.status) + const colorFn = STATUS_COLORS[result.status] + const name = colorFn(result.name) + const message = color.dim(result.message) + + let line = ` ${symbol} ${name}` + if (result.message) { + line += ` ${SYMBOLS.arrow} ${message}` + } + + if (verbose && result.details && result.details.length > 0) { + const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n") + line += "\n" + detailLines + } + + return line +} + +export function formatCategoryHeader(category: CheckCategory): string { + const name = CATEGORY_NAMES[category] || category + return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}` +} + +export function formatSummary(summary: DoctorSummary): string { + const lines: string[] = [] + + lines.push(color.bold(color.white("Summary"))) + lines.push(color.dim("\u2500".repeat(40))) + lines.push("") + + const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed") + const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed") + const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings") + const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : "" + + const parts = [passText, failText, warnText] + if (skipText) parts.push(skipText) + + lines.push(` ${parts.join(", ")}`) + lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`) + + return lines.join("\n") +} + +export function formatHeader(): string { + return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n` +} + +export function formatFooter(summary: DoctorSummary): string { + if (summary.failed > 0) { + return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n` + } + if (summary.warnings > 0) { + return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n` + } + return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n` +} + +export function formatProgress(current: number, total: number, name: string): string { + const progress = color.dim(`[${current}/${total}]`) + return `${progress} Checking ${name}...` +} + +export function formatJsonOutput(result: DoctorResult): string { + return JSON.stringify(result, null, 2) +} + +export function formatDetails(details: string[]): string { + return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n") +} + +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, "") +} + +export function formatBox(content: string, title?: string): string { + const lines = content.split("\n") + const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4 + const border = color.dim("\u2500".repeat(maxWidth)) + + const output: string[] = [] + output.push("") + + if (title) { + output.push( + color.dim("\u250C\u2500") + + color.bold(` ${title} `) + + color.dim("\u2500".repeat(maxWidth - title.length - 4)) + + color.dim("\u2510") + ) + } else { + output.push(color.dim("\u250C") + border + color.dim("\u2510")) + } + + for (const line of lines) { + const stripped = stripAnsi(line) + const padding = maxWidth - stripped.length + output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502")) + } + + output.push(color.dim("\u2514") + border + color.dim("\u2518")) + output.push("") + + return output.join("\n") +} + +export function formatHelpSuggestions(results: CheckResult[]): string[] { + const suggestions: string[] = [] + + for (const result of results) { + if (result.status === "fail" && result.details) { + for (const detail of result.details) { + if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) { + suggestions.push(detail) + } + } + } + } + + return suggestions +} diff --git a/src/cli/doctor/index.ts b/src/cli/doctor/index.ts new file mode 100644 index 0000000..40de646 --- /dev/null +++ b/src/cli/doctor/index.ts @@ -0,0 +1,11 @@ +import type { DoctorOptions } from "./types" +import { runDoctor } from "./runner" + +export async function doctor(options: DoctorOptions = {}): Promise { + const result = await runDoctor(options) + return result.exitCode +} + +export * from "./types" +export { runDoctor } from "./runner" +export { formatJsonOutput } from "./formatter" diff --git a/src/cli/doctor/runner.test.ts b/src/cli/doctor/runner.test.ts new file mode 100644 index 0000000..b1c3fc8 --- /dev/null +++ b/src/cli/doctor/runner.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, spyOn, afterEach } from "bun:test" +import { + runCheck, + calculateSummary, + determineExitCode, + filterChecksByCategory, + groupChecksByCategory, +} from "./runner" +import type { CheckResult, CheckDefinition, CheckCategory } from "./types" + +describe("runner", () => { + describe("runCheck", () => { + it("returns result from check function", async () => { + const check: CheckDefinition = { + id: "test", + name: "Test Check", + category: "installation", + check: async () => ({ name: "Test Check", status: "pass", message: "OK" }), + } + + const result = await runCheck(check) + + expect(result.name).toBe("Test Check") + expect(result.status).toBe("pass") + }) + + it("measures duration", async () => { + const check: CheckDefinition = { + id: "test", + name: "Test Check", + category: "installation", + check: async () => { + await new Promise((r) => setTimeout(r, 10)) + return { name: "Test", status: "pass", message: "OK" } + }, + } + + const result = await runCheck(check) + + expect(result.duration).toBeGreaterThanOrEqual(10) + }) + + it("returns fail on error", async () => { + const check: CheckDefinition = { + id: "test", + name: "Test Check", + category: "installation", + check: async () => { + throw new Error("Test error") + }, + } + + const result = await runCheck(check) + + expect(result.status).toBe("fail") + expect(result.message).toContain("Test error") + }) + }) + + describe("calculateSummary", () => { + it("counts each status correctly", () => { + const results: CheckResult[] = [ + { name: "1", status: "pass", message: "" }, + { name: "2", status: "pass", message: "" }, + { name: "3", status: "fail", message: "" }, + { name: "4", status: "warn", message: "" }, + { name: "5", status: "skip", message: "" }, + ] + + const summary = calculateSummary(results, 100) + + expect(summary.total).toBe(5) + expect(summary.passed).toBe(2) + expect(summary.failed).toBe(1) + expect(summary.warnings).toBe(1) + expect(summary.skipped).toBe(1) + expect(summary.duration).toBe(100) + }) + }) + + describe("determineExitCode", () => { + it("returns 0 when all pass", () => { + const results: CheckResult[] = [ + { name: "1", status: "pass", message: "" }, + { name: "2", status: "pass", message: "" }, + ] + + expect(determineExitCode(results)).toBe(0) + }) + + it("returns 0 when only warnings", () => { + const results: CheckResult[] = [ + { name: "1", status: "pass", message: "" }, + { name: "2", status: "warn", message: "" }, + ] + + expect(determineExitCode(results)).toBe(0) + }) + + it("returns 1 when any failures", () => { + const results: CheckResult[] = [ + { name: "1", status: "pass", message: "" }, + { name: "2", status: "fail", message: "" }, + ] + + expect(determineExitCode(results)).toBe(1) + }) + }) + + describe("filterChecksByCategory", () => { + const checks: CheckDefinition[] = [ + { id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, + { id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) }, + { id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) }, + ] + + it("returns all checks when no category", () => { + const filtered = filterChecksByCategory(checks) + + expect(filtered.length).toBe(3) + }) + + it("filters to specific category", () => { + const filtered = filterChecksByCategory(checks, "installation") + + expect(filtered.length).toBe(1) + expect(filtered[0].name).toBe("Install") + }) + }) + + describe("groupChecksByCategory", () => { + const checks: CheckDefinition[] = [ + { id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, + { id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) }, + { id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) }, + ] + + it("groups checks by category", () => { + const groups = groupChecksByCategory(checks) + + expect(groups.get("installation")?.length).toBe(2) + expect(groups.get("configuration")?.length).toBe(1) + }) + + it("maintains order within categories", () => { + const groups = groupChecksByCategory(checks) + const installChecks = groups.get("installation")! + + expect(installChecks[0].name).toBe("Install1") + expect(installChecks[1].name).toBe("Install2") + }) + }) +}) diff --git a/src/cli/doctor/runner.ts b/src/cli/doctor/runner.ts new file mode 100644 index 0000000..af4c316 --- /dev/null +++ b/src/cli/doctor/runner.ts @@ -0,0 +1,132 @@ +import type { + DoctorOptions, + DoctorResult, + CheckDefinition, + CheckResult, + DoctorSummary, + CheckCategory, +} from "./types" +import { getAllCheckDefinitions } from "./checks" +import { EXIT_CODES, CATEGORY_NAMES } from "./constants" +import { + formatHeader, + formatCategoryHeader, + formatCheckResult, + formatSummary, + formatFooter, + formatJsonOutput, +} from "./formatter" + +export async function runCheck(check: CheckDefinition): Promise { + const start = performance.now() + try { + const result = await check.check() + result.duration = Math.round(performance.now() - start) + return result + } catch (err) { + return { + name: check.name, + status: "fail", + message: err instanceof Error ? err.message : "Unknown error", + duration: Math.round(performance.now() - start), + } + } +} + +export function calculateSummary(results: CheckResult[], duration: number): DoctorSummary { + return { + total: results.length, + passed: results.filter((r) => r.status === "pass").length, + failed: results.filter((r) => r.status === "fail").length, + warnings: results.filter((r) => r.status === "warn").length, + skipped: results.filter((r) => r.status === "skip").length, + duration: Math.round(duration), + } +} + +export function determineExitCode(results: CheckResult[]): number { + const hasFailures = results.some((r) => r.status === "fail") + return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS +} + +export function filterChecksByCategory( + checks: CheckDefinition[], + category?: CheckCategory +): CheckDefinition[] { + if (!category) return checks + return checks.filter((c) => c.category === category) +} + +export function groupChecksByCategory( + checks: CheckDefinition[] +): Map { + const groups = new Map() + + for (const check of checks) { + const existing = groups.get(check.category) ?? [] + existing.push(check) + groups.set(check.category, existing) + } + + return groups +} + +const CATEGORY_ORDER: CheckCategory[] = [ + "installation", + "configuration", + "authentication", + "dependencies", + "tools", + "updates", +] + +export async function runDoctor(options: DoctorOptions): Promise { + const start = performance.now() + const allChecks = getAllCheckDefinitions() + const filteredChecks = filterChecksByCategory(allChecks, options.category) + const groupedChecks = groupChecksByCategory(filteredChecks) + + const results: CheckResult[] = [] + + if (!options.json) { + console.log(formatHeader()) + } + + for (const category of CATEGORY_ORDER) { + const checks = groupedChecks.get(category) + if (!checks || checks.length === 0) continue + + if (!options.json) { + console.log(formatCategoryHeader(category)) + } + + for (const check of checks) { + const result = await runCheck(check) + results.push(result) + + if (!options.json) { + console.log(formatCheckResult(result, options.verbose ?? false)) + } + } + } + + const duration = performance.now() - start + const summary = calculateSummary(results, duration) + const exitCode = determineExitCode(results) + + const doctorResult: DoctorResult = { + results, + summary, + exitCode, + } + + if (options.json) { + console.log(formatJsonOutput(doctorResult)) + } else { + console.log("") + console.log(formatSummary(summary)) + console.log(formatFooter(summary)) + } + + return doctorResult +} diff --git a/src/cli/doctor/types.ts b/src/cli/doctor/types.ts new file mode 100644 index 0000000..b512c6d --- /dev/null +++ b/src/cli/doctor/types.ts @@ -0,0 +1,113 @@ +export type CheckStatus = "pass" | "fail" | "warn" | "skip" + +export interface CheckResult { + name: string + status: CheckStatus + message: string + details?: string[] + duration?: number +} + +export type CheckFunction = () => Promise + +export type CheckCategory = + | "installation" + | "configuration" + | "authentication" + | "dependencies" + | "tools" + | "updates" + +export interface CheckDefinition { + id: string + name: string + category: CheckCategory + check: CheckFunction + critical?: boolean +} + +export interface DoctorOptions { + verbose?: boolean + json?: boolean + category?: CheckCategory +} + +export interface DoctorSummary { + total: number + passed: number + failed: number + warnings: number + skipped: number + duration: number +} + +export interface DoctorResult { + results: CheckResult[] + summary: DoctorSummary + exitCode: number +} + +export interface OpenCodeInfo { + installed: boolean + version: string | null + path: string | null + binary: "opencode" | "opencode-desktop" | null +} + +export interface PluginInfo { + registered: boolean + configPath: string | null + entry: string | null + isPinned: boolean + pinnedVersion: string | null +} + +export interface ConfigInfo { + exists: boolean + path: string | null + format: "json" | "jsonc" | null + valid: boolean + errors: string[] +} + +export type AuthProviderId = "anthropic" | "openai" | "google" + +export interface AuthProviderInfo { + id: AuthProviderId + name: string + pluginInstalled: boolean + configured: boolean + error?: string +} + +export interface DependencyInfo { + name: string + required: boolean + installed: boolean + version: string | null + path: string | null + installHint?: string +} + +export interface LspServerInfo { + id: string + installed: boolean + extensions: string[] + source: "builtin" | "config" | "plugin" +} + +export interface McpServerInfo { + id: string + type: "builtin" | "user" + enabled: boolean + valid: boolean + error?: string +} + +export interface VersionCheckInfo { + currentVersion: string | null + latestVersion: string | null + isUpToDate: boolean + isLocalDev: boolean + isPinned: boolean +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 6301e39..cad0e8c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,9 +3,11 @@ import { Command } from "commander" import { install } from "./install" import { run } from "./run" import { getLocalVersion } from "./get-local-version" +import { doctor } from "./doctor" import type { InstallArgs } from "./types" import type { RunOptions } from "./run" import type { GetLocalVersionOptions } from "./get-local-version/types" +import type { DoctorOptions } from "./doctor" const packageJson = await import("../../package.json") const VERSION = packageJson.version @@ -101,6 +103,37 @@ This command shows: process.exit(exitCode) }) +program + .command("doctor") + .description("Check oh-my-opencode installation health and diagnose issues") + .option("--verbose", "Show detailed diagnostic information") + .option("--json", "Output results in JSON format") + .option("--category ", "Run only specific category") + .addHelpText("after", ` +Examples: + $ bunx oh-my-opencode doctor + $ bunx oh-my-opencode doctor --verbose + $ bunx oh-my-opencode doctor --json + $ bunx oh-my-opencode doctor --category authentication + +Categories: + installation Check OpenCode and plugin installation + configuration Validate configuration files + authentication Check auth provider status + dependencies Check external dependencies + tools Check LSP and MCP servers + updates Check for version updates +`) + .action(async (options) => { + const doctorOptions: DoctorOptions = { + verbose: options.verbose ?? false, + json: options.json ?? false, + category: options.category, + } + const exitCode = await doctor(doctorOptions) + process.exit(exitCode) + }) + program .command("version") .description("Show version information")