feat(cli): add doctor command for installation health checks (#334)
Implements a comprehensive 'doctor' command that diagnoses oh-my-opencode installation health with a beautiful TUI output. Checks performed: - OpenCode installation (version, path, binary) - Plugin registration in opencode.json - Configuration file validity (oh-my-opencode.json) - Auth providers (Anthropic, OpenAI, Google) - Dependencies (ast-grep CLI/NAPI, comment-checker) - LSP servers availability - MCP servers (builtin and user) - Version status and updates Features: - Beautiful TUI with symbols and colors - --verbose flag for detailed output - --json flag for machine-readable output - --category flag for running specific checks - Exit code 1 on failures for CI integration Closes #333 Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
@@ -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<string | null> {
|
||||
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<DependencyInfo> {
|
||||
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<DependencyInfo> {
|
||||
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<CheckResult> {
|
||||
const info = await checkAstGrepCli()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI])
|
||||
}
|
||||
|
||||
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
|
||||
const info = checkAstGrepNapi()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
|
||||
}
|
||||
|
||||
export async function checkDependencyCommentChecker(): Promise<CheckResult> {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user