refactor(doctor): redesign with 3-tier output and consolidated checks
Consolidate 16 separate checks into 5 (system, config, providers, tools, models). Add 3-tier formatting: default (problems-only), --status (dashboard), --verbose (deep diagnostics). Read actual loaded plugin version from opencode cache directory. Check environment variables for provider authentication.
This commit is contained in:
@@ -149,29 +149,21 @@ This command shows:
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Check oh-my-opencode installation health and diagnose issues")
|
||||
.option("--status", "Show compact system dashboard")
|
||||
.option("--verbose", "Show detailed diagnostic information")
|
||||
.option("--json", "Output results in JSON format")
|
||||
.option("--category <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
|
||||
$ bunx oh-my-opencode doctor # Show problems only
|
||||
$ bunx oh-my-opencode doctor --status # Compact dashboard
|
||||
$ bunx oh-my-opencode doctor --verbose # Deep diagnostics
|
||||
$ bunx oh-my-opencode doctor --json # JSON output
|
||||
`)
|
||||
.action(async (options) => {
|
||||
const mode = options.status ? "status" : options.verbose ? "verbose" : "default"
|
||||
const doctorOptions: DoctorOptions = {
|
||||
verbose: options.verbose ?? false,
|
||||
mode,
|
||||
json: options.json ?? false,
|
||||
category: options.category,
|
||||
}
|
||||
const exitCode = await doctor(doctorOptions)
|
||||
process.exit(exitCode)
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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<typeof spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { parseJsonc, getOpenCodeConfigDir } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
const AUTH_PLUGINS: Record<AuthProviderId, { plugin: string; name: string }> = {
|
||||
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<CheckResult> {
|
||||
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<CheckResult> {
|
||||
return checkAuthProvider("anthropic")
|
||||
}
|
||||
|
||||
export async function checkOpenAIAuth(): Promise<CheckResult> {
|
||||
return checkAuthProvider("openai")
|
||||
}
|
||||
|
||||
export async function checkGoogleAuth(): Promise<CheckResult> {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,103 +1,27 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import { describe, it, expect } 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")
|
||||
describe("checkConfig", () => {
|
||||
it("returns a valid CheckResult", async () => {
|
||||
//#given config check is available
|
||||
//#when running the consolidated config check
|
||||
const result = await config.checkConfig()
|
||||
|
||||
// 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<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
//#then should return a properly shaped CheckResult
|
||||
expect(result.name).toBe("Configuration")
|
||||
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
|
||||
expect(typeof result.message).toBe("string")
|
||||
expect(Array.isArray(result.issues)).toBe(true)
|
||||
})
|
||||
|
||||
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: [],
|
||||
})
|
||||
it("includes issues array even when config is valid", async () => {
|
||||
//#given a normal environment
|
||||
//#when running config check
|
||||
const result = await config.checkConfig()
|
||||
|
||||
// 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)
|
||||
//#then issues should be an array (possibly empty)
|
||||
expect(Array.isArray(result.issues)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,122 +1,164 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared"
|
||||
import { OhMyOpenCodeConfigSchema } from "../../../config"
|
||||
|
||||
const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
|
||||
import { OhMyOpenCodeConfigSchema } from "../../../config"
|
||||
import { detectConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
import { getModelResolutionInfoWithOverrides } from "./model-resolution"
|
||||
import type { OmoConfig } from "./model-resolution-types"
|
||||
|
||||
const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), 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" }
|
||||
}
|
||||
interface ConfigValidationResult {
|
||||
exists: boolean
|
||||
path: string | null
|
||||
valid: boolean
|
||||
config: OmoConfig | null
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
const userDetected = detectConfigFile(USER_CONFIG_BASE)
|
||||
if (userDetected.format !== "none") {
|
||||
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
|
||||
}
|
||||
function findConfigPath(): string | null {
|
||||
const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
|
||||
if (projectConfig.format !== "none") return projectConfig.path
|
||||
|
||||
const userConfig = detectConfigFile(USER_CONFIG_BASE)
|
||||
if (userConfig.format !== "none") return userConfig.path
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
|
||||
function validateConfig(): ConfigValidationResult {
|
||||
const configPath = findConfigPath()
|
||||
if (!configPath) {
|
||||
return { exists: false, path: null, valid: true, config: null, errors: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content)
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
const rawConfig = parseJsonc<OmoConfig>(content)
|
||||
const schemaResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(
|
||||
(i) => `${i.path.join(".")}: ${i.message}`
|
||||
)
|
||||
return { valid: false, errors }
|
||||
if (!schemaResult.success) {
|
||||
return {
|
||||
exists: true,
|
||||
path: configPath,
|
||||
valid: false,
|
||||
config: rawConfig,
|
||||
errors: schemaResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, errors: [] }
|
||||
} catch (err) {
|
||||
return { exists: true, path: configPath, valid: true, config: rawConfig, errors: [] }
|
||||
} catch (error) {
|
||||
return {
|
||||
exists: true,
|
||||
path: configPath,
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : "Failed to parse config"],
|
||||
config: null,
|
||||
errors: [error instanceof Error ? error.message : "Failed to parse config"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigInfo(): ConfigInfo {
|
||||
const configPath = findConfigPath()
|
||||
function collectModelResolutionIssues(config: OmoConfig): DoctorIssue[] {
|
||||
const issues: DoctorIssue[] = []
|
||||
const availableModels = loadAvailableModelsFromCache()
|
||||
const resolution = getModelResolutionInfoWithOverrides(config)
|
||||
|
||||
if (!configPath) {
|
||||
return {
|
||||
exists: false,
|
||||
path: null,
|
||||
format: null,
|
||||
valid: true,
|
||||
errors: [],
|
||||
const invalidAgentOverrides = resolution.agents.filter(
|
||||
(agent) => agent.userOverride && !agent.userOverride.includes("/")
|
||||
)
|
||||
const invalidCategoryOverrides = resolution.categories.filter(
|
||||
(category) => category.userOverride && !category.userOverride.includes("/")
|
||||
)
|
||||
|
||||
for (const invalidAgent of invalidAgentOverrides) {
|
||||
issues.push({
|
||||
title: `Invalid agent override: ${invalidAgent.name}`,
|
||||
description: `Override '${invalidAgent.userOverride}' must be in provider/model format.`,
|
||||
severity: "warning",
|
||||
affects: [invalidAgent.name],
|
||||
})
|
||||
}
|
||||
|
||||
for (const invalidCategory of invalidCategoryOverrides) {
|
||||
issues.push({
|
||||
title: `Invalid category override: ${invalidCategory.name}`,
|
||||
description: `Override '${invalidCategory.userOverride}' must be in provider/model format.`,
|
||||
severity: "warning",
|
||||
affects: [invalidCategory.name],
|
||||
})
|
||||
}
|
||||
|
||||
if (availableModels.cacheExists) {
|
||||
const providerSet = new Set(availableModels.providers)
|
||||
const unknownProviders = [
|
||||
...resolution.agents.map((agent) => agent.userOverride),
|
||||
...resolution.categories.map((category) => category.userOverride),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.split("/")[0])
|
||||
.filter((provider) => provider.length > 0 && !providerSet.has(provider))
|
||||
|
||||
if (unknownProviders.length > 0) {
|
||||
const uniqueProviders = [...new Set(unknownProviders)]
|
||||
issues.push({
|
||||
title: "Model override uses unavailable provider",
|
||||
description: `Provider(s) not found in OpenCode model cache: ${uniqueProviders.join(", ")}`,
|
||||
severity: "warning",
|
||||
affects: ["model resolution"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export async function checkConfigValidity(): Promise<CheckResult> {
|
||||
const info = getConfigInfo()
|
||||
export async function checkConfig(): Promise<CheckResult> {
|
||||
const validation = validateConfig()
|
||||
const issues: DoctorIssue[] = []
|
||||
|
||||
if (!info.exists) {
|
||||
if (!validation.exists) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG],
|
||||
status: "pass",
|
||||
message: "Using default configuration",
|
||||
details: ["No custom config file found (optional)"],
|
||||
message: "No custom config found; defaults are used",
|
||||
details: undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.valid) {
|
||||
if (!validation.valid) {
|
||||
issues.push(
|
||||
...validation.errors.map((error) => ({
|
||||
title: "Invalid configuration",
|
||||
description: error,
|
||||
severity: "error" as const,
|
||||
affects: ["plugin startup"],
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG],
|
||||
status: "fail",
|
||||
message: "Configuration has validation errors",
|
||||
details: [
|
||||
`Path: ${info.path}`,
|
||||
...info.errors.map((e) => `Error: ${e}`),
|
||||
],
|
||||
message: `Configuration invalid (${issues.length} issue${issues.length > 1 ? "s" : ""})`,
|
||||
details: validation.path ? [`Path: ${validation.path}`] : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
status: "pass",
|
||||
message: `Valid ${info.format?.toUpperCase()} config`,
|
||||
details: [`Path: ${info.path}`],
|
||||
if (validation.config) {
|
||||
issues.push(...collectModelResolutionIssues(validation.config))
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.CONFIG_VALIDATION,
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
category: "configuration",
|
||||
check: checkConfigValidity,
|
||||
critical: false,
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG],
|
||||
status: issues.length > 0 ? "warn" : "pass",
|
||||
message: issues.length > 0 ? `${issues.length} configuration warning(s)` : "Configuration is valid",
|
||||
details: validation.path ? [`Path: ${validation.path}`] : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import * as deps from "./dependencies"
|
||||
|
||||
describe("dependencies check", () => {
|
||||
describe("checkAstGrepCli", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// given
|
||||
// when checking ast-grep cli
|
||||
it("returns valid dependency info", async () => {
|
||||
//#given ast-grep cli check
|
||||
//#when checking
|
||||
const info = await deps.checkAstGrepCli()
|
||||
|
||||
// then should return valid info
|
||||
//#then should return valid DependencyInfo
|
||||
expect(info.name).toBe("AST-Grep CLI")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
expect(typeof info.version === "string" || info.version === null).toBe(true)
|
||||
expect(typeof info.path === "string" || info.path === null).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAstGrepNapi", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// given
|
||||
// when checking ast-grep napi
|
||||
it("returns valid dependency info", async () => {
|
||||
//#given ast-grep napi check
|
||||
//#when checking
|
||||
const info = await deps.checkAstGrepNapi()
|
||||
|
||||
// then should return valid info
|
||||
//#then should return valid DependencyInfo
|
||||
expect(info.name).toBe("AST-Grep NAPI")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
@@ -29,124 +31,15 @@ describe("dependencies check", () => {
|
||||
})
|
||||
|
||||
describe("checkCommentChecker", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// given
|
||||
// when checking comment checker
|
||||
it("returns valid dependency info", async () => {
|
||||
//#given comment checker check
|
||||
//#when checking
|
||||
const info = await deps.checkCommentChecker()
|
||||
|
||||
// then should return valid info
|
||||
//#then should return valid DependencyInfo
|
||||
expect(info.name).toBe("Comment Checker")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkDependencyAstGrepCli", () => {
|
||||
let checkSpy: ReturnType<typeof spyOn>
|
||||
|
||||
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<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
checkSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when installed", async () => {
|
||||
// given napi installed
|
||||
checkSpy = spyOn(deps, "checkAstGrepNapi").mockResolvedValue({
|
||||
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<typeof spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { DependencyInfo } from "../types"
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
@@ -124,61 +123,3 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
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 = await 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as gh from "./gh"
|
||||
|
||||
describe("gh cli check", () => {
|
||||
describe("getGhCliInfo", () => {
|
||||
function createProc(opts: { stdout?: string; stderr?: string; exitCode?: number }) {
|
||||
const stdoutText = opts.stdout ?? ""
|
||||
const stderrText = opts.stderr ?? ""
|
||||
const exitCode = opts.exitCode ?? 0
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
return {
|
||||
stdout: new ReadableStream({
|
||||
start(controller) {
|
||||
if (stdoutText) controller.enqueue(encoder.encode(stdoutText))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
stderr: new ReadableStream({
|
||||
start(controller) {
|
||||
if (stderrText) controller.enqueue(encoder.encode(stderrText))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
exited: Promise.resolve(exitCode),
|
||||
exitCode,
|
||||
} as unknown as ReturnType<typeof Bun.spawn>
|
||||
}
|
||||
|
||||
it("returns gh cli info structure", async () => {
|
||||
const spawnSpy = spyOn(Bun, "spawn").mockImplementation((cmd) => {
|
||||
if (Array.isArray(cmd) && (cmd[0] === "which" || cmd[0] === "where") && cmd[1] === "gh") {
|
||||
return createProc({ stdout: "/usr/bin/gh\n" })
|
||||
}
|
||||
|
||||
if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "--version") {
|
||||
return createProc({ stdout: "gh version 2.40.0\n" })
|
||||
}
|
||||
|
||||
if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "auth" && cmd[2] === "status") {
|
||||
return createProc({
|
||||
exitCode: 0,
|
||||
stderr: "Logged in to github.com account octocat (keyring)\nToken scopes: 'repo', 'read:org'\n",
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Bun.spawn call: ${Array.isArray(cmd) ? cmd.join(" ") : String(cmd)}`)
|
||||
})
|
||||
|
||||
try {
|
||||
const info = await gh.getGhCliInfo()
|
||||
|
||||
expect(info.installed).toBe(true)
|
||||
expect(info.version).toBe("2.40.0")
|
||||
expect(typeof info.authenticated).toBe("boolean")
|
||||
expect(Array.isArray(info.scopes)).toBe(true)
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkGhCli", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns warn when gh is not installed", async () => {
|
||||
// given gh not installed
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// then should warn (optional)
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("Not installed")
|
||||
expect(result.details).toContain("Install: https://cli.github.com/")
|
||||
})
|
||||
|
||||
it("returns warn when gh is installed but not authenticated", async () => {
|
||||
// given gh installed but not authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: "not logged in",
|
||||
})
|
||||
|
||||
// when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// then should warn about auth
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("not authenticated")
|
||||
expect(result.details).toContain("Authenticate: gh auth login")
|
||||
})
|
||||
|
||||
it("returns pass when gh is installed and authenticated", async () => {
|
||||
// given gh installed and authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: true,
|
||||
username: "octocat",
|
||||
scopes: ["repo", "read:org"],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("octocat")
|
||||
expect(result.details).toContain("Account: octocat")
|
||||
expect(result.details).toContain("Scopes: repo, read:org")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGhCliCheckDefinition", () => {
|
||||
it("returns correct check definition", () => {
|
||||
// given
|
||||
// when getting definition
|
||||
const def = gh.getGhCliCheckDefinition()
|
||||
|
||||
// then should have correct properties
|
||||
expect(def.id).toBe("gh-cli")
|
||||
expect(def.name).toBe("GitHub CLI")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
const whichCmd = process.platform === "win32" ? "where" : "which"
|
||||
const proc = Bun.spawn([whichCmd, 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 getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
const match = output.match(/gh version (\S+)/)
|
||||
return match?.[1] ?? output.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - version unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getGhAuthStatus(): Promise<{
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
})
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
await proc.exited
|
||||
|
||||
const output = stderr || stdout
|
||||
|
||||
if (proc.exitCode === 0) {
|
||||
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
|
||||
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
|
||||
|
||||
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
|
||||
const scopes = scopesMatch?.[1]
|
||||
? scopesMatch[1]
|
||||
.split(/,\s*/)
|
||||
.map((s) => s.replace(/['"]/g, "").trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return { authenticated: true, username, scopes, error: null }
|
||||
}
|
||||
|
||||
const errorMatch = output.match(/error[:\s]+(.+)/i)
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: err instanceof Error ? err.message : "Failed to check auth status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
||||
const binaryCheck = await checkBinaryExists("gh")
|
||||
|
||||
if (!binaryCheck.exists) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryCheck.path,
|
||||
authenticated: authStatus.authenticated,
|
||||
username: authStatus.username,
|
||||
scopes: authStatus.scopes,
|
||||
error: authStatus.error,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkGhCli(): Promise<CheckResult> {
|
||||
const info = await getGhCliInfo()
|
||||
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
|
||||
|
||||
if (!info.installed) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: "Not installed (optional)",
|
||||
details: [
|
||||
"GitHub CLI is used by librarian agent and scripts",
|
||||
"Install: https://cli.github.com/",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.authenticated) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: `${info.version ?? "installed"} - not authenticated`,
|
||||
details: [
|
||||
info.path ? `Path: ${info.path}` : null,
|
||||
"Authenticate: gh auth login",
|
||||
info.error ? `Error: ${info.error}` : null,
|
||||
].filter((d): d is string => d !== null),
|
||||
}
|
||||
}
|
||||
|
||||
const details: string[] = []
|
||||
if (info.path) details.push(`Path: ${info.path}`)
|
||||
if (info.username) details.push(`Account: ${info.username}`)
|
||||
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "pass",
|
||||
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGhCliCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.GH_CLI,
|
||||
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
|
||||
category: "tools",
|
||||
check: checkGhCli,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,42 @@
|
||||
import type { CheckDefinition } from "../types"
|
||||
import { getOpenCodeCheckDefinition } from "./opencode"
|
||||
import { getPluginCheckDefinition } from "./plugin"
|
||||
import { getConfigCheckDefinition } from "./config"
|
||||
import { getModelResolutionCheckDefinition } from "./model-resolution"
|
||||
import { getAuthCheckDefinitions } from "./auth"
|
||||
import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getGhCliCheckDefinition } from "./gh"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { checkSystem, gatherSystemInfo } from "./system"
|
||||
import { checkConfig } from "./config"
|
||||
import { checkProviders, gatherProviderStatuses } from "./providers"
|
||||
import { checkTools, gatherToolsSummary } from "./tools"
|
||||
import { checkModels } from "./model-resolution"
|
||||
|
||||
export * from "./opencode"
|
||||
export * from "./plugin"
|
||||
export * from "./config"
|
||||
export * from "./model-resolution"
|
||||
export type { CheckDefinition }
|
||||
export * from "./model-resolution-types"
|
||||
export * from "./model-resolution-cache"
|
||||
export * from "./model-resolution-config"
|
||||
export * from "./model-resolution-effective-model"
|
||||
export * from "./model-resolution-variant"
|
||||
export * from "./model-resolution-details"
|
||||
export * from "./auth"
|
||||
export * from "./dependencies"
|
||||
export * from "./gh"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./mcp-oauth"
|
||||
export * from "./version"
|
||||
export { gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary }
|
||||
|
||||
export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
return [
|
||||
getOpenCodeCheckDefinition(),
|
||||
getPluginCheckDefinition(),
|
||||
getConfigCheckDefinition(),
|
||||
getModelResolutionCheckDefinition(),
|
||||
...getAuthCheckDefinitions(),
|
||||
...getDependencyCheckDefinitions(),
|
||||
getGhCliCheckDefinition(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getMcpOAuthCheckDefinition(),
|
||||
getVersionCheckDefinition(),
|
||||
{
|
||||
id: CHECK_IDS.SYSTEM,
|
||||
name: CHECK_NAMES[CHECK_IDS.SYSTEM],
|
||||
check: checkSystem,
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.CONFIG,
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG],
|
||||
check: checkConfig,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.PROVIDERS,
|
||||
name: CHECK_NAMES[CHECK_IDS.PROVIDERS],
|
||||
check: checkProviders,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.TOOLS,
|
||||
name: CHECK_NAMES[CHECK_IDS.TOOLS],
|
||||
check: checkTools,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.MODELS,
|
||||
name: CHECK_NAMES[CHECK_IDS.MODELS],
|
||||
check: checkModels,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it("does not spawn 'which' command (windows compatibility)", async () => {
|
||||
// given
|
||||
const spawnSpy = spyOn(Bun, "spawn")
|
||||
|
||||
try {
|
||||
// when getting servers info
|
||||
await lsp.getLspServersInfo()
|
||||
|
||||
// then should not spawn which
|
||||
const calls = spawnSpy.mock.calls
|
||||
const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which")
|
||||
expect(whichCalls.length).toBe(0)
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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<typeof spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
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"] },
|
||||
]
|
||||
|
||||
import { isServerInstalled } from "../../../tools/lsp/config"
|
||||
|
||||
export async function getLspServersInfo(): Promise<LspServerInfo[]> {
|
||||
const servers: LspServerInfo[] = []
|
||||
|
||||
for (const server of DEFAULT_LSP_SERVERS) {
|
||||
const installed = isServerInstalled([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<CheckResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as mcpOauth from "./mcp-oauth"
|
||||
|
||||
describe("mcp-oauth check", () => {
|
||||
describe("getMcpOAuthCheckDefinition", () => {
|
||||
it("returns check definition with correct properties", () => {
|
||||
// given
|
||||
// when getting definition
|
||||
const def = mcpOauth.getMcpOAuthCheckDefinition()
|
||||
|
||||
// then should have correct structure
|
||||
expect(def.id).toBe("mcp-oauth-tokens")
|
||||
expect(def.name).toBe("MCP OAuth Tokens")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkMcpOAuthTokens", () => {
|
||||
let readStoreSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
readStoreSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns skip when no tokens stored", async () => {
|
||||
// given no OAuth tokens configured
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null)
|
||||
|
||||
// when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// then should skip
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.message).toContain("No OAuth")
|
||||
})
|
||||
|
||||
it("returns pass when all tokens valid", async () => {
|
||||
// given valid tokens with future expiry (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2")
|
||||
expect(result.message).toContain("valid")
|
||||
})
|
||||
|
||||
it("returns warn when some tokens expired", async () => {
|
||||
// given mix of valid and expired tokens (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
const pastTime = Math.floor(Date.now() / 1000) - 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: pastTime,
|
||||
},
|
||||
})
|
||||
|
||||
// when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("1")
|
||||
expect(result.message).toContain("expired")
|
||||
expect(result.details?.some((d: string) => d.includes("Expired"))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it("returns pass when tokens have no expiry", async () => {
|
||||
// given tokens without expiry info
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
},
|
||||
})
|
||||
|
||||
// when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// then should pass (no expiry = assume valid)
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1")
|
||||
})
|
||||
|
||||
it("includes token details in output", async () => {
|
||||
// given multiple tokens
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"api.example.com/v1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"auth.example.com/oauth": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// then should list tokens in details
|
||||
expect(result.details).toBeDefined()
|
||||
expect(result.details?.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("api.example.com"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("auth.example.com"))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
interface OAuthTokenData {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiresAt?: number
|
||||
clientInfo?: {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TokenStore = Record<string, OAuthTokenData>
|
||||
|
||||
export function readTokenStore(): TokenStore | null {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as TokenStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMcpOAuthTokens(): Promise<CheckResult> {
|
||||
const store = readTokenStore()
|
||||
|
||||
if (!store || Object.keys(store).length === 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "skip",
|
||||
message: "No OAuth tokens configured",
|
||||
details: ["Optional: Configure OAuth tokens for MCP servers"],
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const tokens = Object.entries(store)
|
||||
const expiredTokens = tokens.filter(
|
||||
([, token]) => token.expiresAt && token.expiresAt < now
|
||||
)
|
||||
|
||||
if (expiredTokens.length > 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "warn",
|
||||
message: `${expiredTokens.length} of ${tokens.length} token(s) expired`,
|
||||
details: [
|
||||
...tokens
|
||||
.filter(([, token]) => !token.expiresAt || token.expiresAt >= now)
|
||||
.map(([key]) => `Valid: ${key}`),
|
||||
...expiredTokens.map(([key]) => `Expired: ${key}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "pass",
|
||||
message: `${tokens.length} OAuth token(s) valid`,
|
||||
details: tokens.map(([key]) => `Configured: ${key}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpOAuthCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.MCP_OAUTH_TOKENS,
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
category: "tools",
|
||||
check: checkMcpOAuthTokens,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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(2)
|
||||
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("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("2")
|
||||
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("grep_app"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkUserMcpServers", () => {
|
||||
let getUserSpy: ReturnType<typeof spyOn>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
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", "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<string, unknown>
|
||||
}
|
||||
|
||||
function loadUserMcpConfig(): Record<string, unknown> {
|
||||
const servers: Record<string, unknown> = {}
|
||||
|
||||
for (const configPath of MCP_CONFIG_PATHS) {
|
||||
if (!existsSync(configPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const config = parseJsonc<McpConfig>(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<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -165,16 +165,4 @@ describe("model-resolution check", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("getModelResolutionCheckDefinition", () => {
|
||||
it("returns valid check definition", async () => {
|
||||
const { getModelResolutionCheckDefinition } = await import("./model-resolution")
|
||||
|
||||
const def = getModelResolutionCheckDefinition()
|
||||
|
||||
expect(def.id).toBe("model-resolution")
|
||||
expect(def.name).toBe("Model Resolution")
|
||||
expect(def.category).toBe("configuration")
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import {
|
||||
AGENT_MODEL_REQUIREMENTS,
|
||||
CATEGORY_MODEL_REQUIREMENTS,
|
||||
} from "../../../shared/model-requirements"
|
||||
import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
|
||||
import { loadOmoConfig } from "./model-resolution-config"
|
||||
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
|
||||
import { buildModelResolutionDetails } from "./model-resolution-details"
|
||||
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
|
||||
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
|
||||
|
||||
export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => ({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
}),
|
||||
)
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
|
||||
name,
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
}))
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => ({
|
||||
@@ -26,27 +21,25 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
|
||||
requirement,
|
||||
effectiveModel: getEffectiveModel(requirement),
|
||||
effectiveResolution: buildEffectiveResolution(requirement),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo {
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
const userOverride = config.agents?.[name]?.model
|
||||
const userVariant = config.agents?.[name]?.variant
|
||||
return {
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
},
|
||||
)
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
|
||||
const userOverride = config.agents?.[name]?.model
|
||||
const userVariant = config.agents?.[name]?.variant
|
||||
return {
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
})
|
||||
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
@@ -60,40 +53,39 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return { agents, categories }
|
||||
}
|
||||
|
||||
export async function checkModelResolution(): Promise<CheckResult> {
|
||||
export async function checkModels(): Promise<CheckResult> {
|
||||
const config = loadOmoConfig() ?? {}
|
||||
const info = getModelResolutionInfoWithOverrides(config)
|
||||
const available = loadAvailableModelsFromCache()
|
||||
const issues: DoctorIssue[] = []
|
||||
|
||||
const agentCount = info.agents.length
|
||||
const categoryCount = info.categories.length
|
||||
const agentOverrides = info.agents.filter((a) => a.userOverride).length
|
||||
const categoryOverrides = info.categories.filter((c) => c.userOverride).length
|
||||
const totalOverrides = agentOverrides + categoryOverrides
|
||||
if (!available.cacheExists) {
|
||||
issues.push({
|
||||
title: "Model cache not found",
|
||||
description: "OpenCode model cache is missing, so model availability cannot be validated.",
|
||||
fix: "Run: opencode models --refresh",
|
||||
severity: "warning",
|
||||
affects: ["model resolution"],
|
||||
})
|
||||
}
|
||||
|
||||
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
|
||||
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found"
|
||||
const overrideCount =
|
||||
info.agents.filter((agent) => Boolean(agent.userOverride)).length +
|
||||
info.categories.filter((category) => Boolean(category.userOverride)).length
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
|
||||
status: available.cacheExists ? "pass" : "warn",
|
||||
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
|
||||
name: CHECK_NAMES[CHECK_IDS.MODELS],
|
||||
status: issues.length > 0 ? "warn" : "pass",
|
||||
message: `${info.agents.length} agents, ${info.categories.length} categories, ${overrideCount} override${overrideCount === 1 ? "" : "s"}`,
|
||||
details: buildModelResolutionDetails({ info, available, config }),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelResolutionCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.MODEL_RESOLUTION,
|
||||
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
|
||||
category: "configuration",
|
||||
check: checkModelResolution,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
export const checkModelResolution = checkModels
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
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("command helpers", () => {
|
||||
it("selects where on Windows", () => {
|
||||
// given win32 platform
|
||||
// when selecting lookup command
|
||||
// then should use where
|
||||
expect(opencode.getBinaryLookupCommand("win32")).toBe("where")
|
||||
})
|
||||
|
||||
it("selects which on non-Windows", () => {
|
||||
// given linux platform
|
||||
// when selecting lookup command
|
||||
// then should use which
|
||||
expect(opencode.getBinaryLookupCommand("linux")).toBe("which")
|
||||
expect(opencode.getBinaryLookupCommand("darwin")).toBe("which")
|
||||
})
|
||||
|
||||
it("parses command output into paths", () => {
|
||||
// given raw output with multiple lines and spaces
|
||||
const output = "C:\\\\bin\\\\opencode.ps1\r\nC:\\\\bin\\\\opencode.exe\n\n"
|
||||
|
||||
// when parsing
|
||||
const paths = opencode.parseBinaryPaths(output)
|
||||
|
||||
// then should return trimmed, non-empty paths
|
||||
expect(paths).toEqual(["C:\\\\bin\\\\opencode.ps1", "C:\\\\bin\\\\opencode.exe"])
|
||||
})
|
||||
|
||||
it("prefers exe/cmd/bat over ps1 on Windows", () => {
|
||||
// given windows paths
|
||||
const paths = [
|
||||
"C:\\\\bin\\\\opencode.ps1",
|
||||
"C:\\\\bin\\\\opencode.cmd",
|
||||
"C:\\\\bin\\\\opencode.exe",
|
||||
]
|
||||
|
||||
// when selecting binary
|
||||
const selected = opencode.selectBinaryPath(paths, "win32")
|
||||
|
||||
// then should prefer exe
|
||||
expect(selected).toBe("C:\\\\bin\\\\opencode.exe")
|
||||
})
|
||||
|
||||
it("falls back to ps1 when it is the only Windows candidate", () => {
|
||||
// given only ps1 path
|
||||
const paths = ["C:\\\\bin\\\\opencode.ps1"]
|
||||
|
||||
// when selecting binary
|
||||
const selected = opencode.selectBinaryPath(paths, "win32")
|
||||
|
||||
// then should return ps1 path
|
||||
expect(selected).toBe("C:\\\\bin\\\\opencode.ps1")
|
||||
})
|
||||
|
||||
it("builds PowerShell command for ps1 on Windows", () => {
|
||||
// given a ps1 path on Windows
|
||||
const command = opencode.buildVersionCommand(
|
||||
"C:\\\\bin\\\\opencode.ps1",
|
||||
"win32"
|
||||
)
|
||||
|
||||
// when building command
|
||||
// then should use PowerShell
|
||||
expect(command).toEqual([
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
"C:\\\\bin\\\\opencode.ps1",
|
||||
"--version",
|
||||
])
|
||||
})
|
||||
|
||||
it("builds direct command for non-ps1 binaries", () => {
|
||||
// given an exe on Windows and a binary on linux
|
||||
const winCommand = opencode.buildVersionCommand(
|
||||
"C:\\\\bin\\\\opencode.exe",
|
||||
"win32"
|
||||
)
|
||||
const linuxCommand = opencode.buildVersionCommand("opencode", "linux")
|
||||
|
||||
// when building commands
|
||||
// then should execute directly
|
||||
expect(winCommand).toEqual(["C:\\\\bin\\\\opencode.exe", "--version"])
|
||||
expect(linuxCommand).toEqual(["opencode", "--version"])
|
||||
})
|
||||
})
|
||||
|
||||
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<typeof spyOn>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDesktopAppPaths", () => {
|
||||
it("returns macOS desktop app paths for darwin platform", () => {
|
||||
// given darwin platform
|
||||
const platform: NodeJS.Platform = "darwin"
|
||||
|
||||
// when getting desktop paths
|
||||
const paths = opencode.getDesktopAppPaths(platform)
|
||||
|
||||
// then should include macOS app bundle paths with correct binary name
|
||||
expect(paths).toContain("/Applications/OpenCode.app/Contents/MacOS/OpenCode")
|
||||
expect(paths.some((p) => p.includes("Applications/OpenCode.app"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns Windows desktop app paths for win32 platform when env vars set", () => {
|
||||
// given win32 platform with env vars set
|
||||
const platform: NodeJS.Platform = "win32"
|
||||
const originalProgramFiles = process.env.ProgramFiles
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA
|
||||
process.env.ProgramFiles = "C:\\Program Files"
|
||||
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
|
||||
|
||||
// when getting desktop paths
|
||||
const paths = opencode.getDesktopAppPaths(platform)
|
||||
|
||||
// then should include Windows program paths with correct binary name
|
||||
expect(paths.some((p) => p.includes("Program Files"))).toBe(true)
|
||||
expect(paths.some((p) => p.endsWith("OpenCode.exe"))).toBe(true)
|
||||
expect(paths.every((p) => p.startsWith("C:\\"))).toBe(true)
|
||||
|
||||
// cleanup
|
||||
process.env.ProgramFiles = originalProgramFiles
|
||||
process.env.LOCALAPPDATA = originalLocalAppData
|
||||
})
|
||||
|
||||
it("returns empty array for win32 when all env vars undefined", () => {
|
||||
// given win32 platform with no env vars
|
||||
const platform: NodeJS.Platform = "win32"
|
||||
const originalProgramFiles = process.env.ProgramFiles
|
||||
const originalLocalAppData = process.env.LOCALAPPDATA
|
||||
delete process.env.ProgramFiles
|
||||
delete process.env.LOCALAPPDATA
|
||||
|
||||
// when getting desktop paths
|
||||
const paths = opencode.getDesktopAppPaths(platform)
|
||||
|
||||
// then should return empty array (no relative paths)
|
||||
expect(paths).toEqual([])
|
||||
|
||||
// cleanup
|
||||
process.env.ProgramFiles = originalProgramFiles
|
||||
process.env.LOCALAPPDATA = originalLocalAppData
|
||||
})
|
||||
|
||||
it("returns Linux desktop app paths for linux platform", () => {
|
||||
// given linux platform
|
||||
const platform: NodeJS.Platform = "linux"
|
||||
|
||||
// when getting desktop paths
|
||||
const paths = opencode.getDesktopAppPaths(platform)
|
||||
|
||||
// then should include verified Linux installation paths
|
||||
expect(paths).toContain("/usr/bin/opencode")
|
||||
expect(paths).toContain("/usr/lib/opencode/opencode")
|
||||
expect(paths.some((p) => p.includes("AppImage"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns empty array for unsupported platforms", () => {
|
||||
// given unsupported platform
|
||||
const platform = "freebsd" as NodeJS.Platform
|
||||
|
||||
// when getting desktop paths
|
||||
const paths = opencode.getDesktopAppPaths(platform)
|
||||
|
||||
// then should return empty array
|
||||
expect(paths).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("findOpenCodeBinary with desktop fallback", () => {
|
||||
it("falls back to desktop paths when PATH binary not found", async () => {
|
||||
// given no binary in PATH but desktop app exists
|
||||
const existsSyncMock = (p: string) =>
|
||||
p === "/Applications/OpenCode.app/Contents/MacOS/OpenCode"
|
||||
|
||||
// when finding binary with mocked filesystem
|
||||
const result = await opencode.findDesktopBinary("darwin", existsSyncMock)
|
||||
|
||||
// then should find desktop app
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.path).toBe("/Applications/OpenCode.app/Contents/MacOS/OpenCode")
|
||||
})
|
||||
|
||||
it("returns null when no desktop binary found", async () => {
|
||||
// given no binary exists
|
||||
const existsSyncMock = () => false
|
||||
|
||||
// when finding binary
|
||||
const result = await opencode.findDesktopBinary("darwin", existsSyncMock)
|
||||
|
||||
// then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants"
|
||||
|
||||
const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"]
|
||||
|
||||
export function getDesktopAppPaths(platform: NodeJS.Platform): string[] {
|
||||
const home = homedir()
|
||||
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return [
|
||||
"/Applications/OpenCode.app/Contents/MacOS/OpenCode",
|
||||
join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"),
|
||||
]
|
||||
case "win32": {
|
||||
const programFiles = process.env.ProgramFiles
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
|
||||
const paths: string[] = []
|
||||
if (programFiles) {
|
||||
paths.push(join(programFiles, "OpenCode", "OpenCode.exe"))
|
||||
}
|
||||
if (localAppData) {
|
||||
paths.push(join(localAppData, "OpenCode", "OpenCode.exe"))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
case "linux":
|
||||
return [
|
||||
"/usr/bin/opencode",
|
||||
"/usr/lib/opencode/opencode",
|
||||
join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"),
|
||||
join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"),
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" {
|
||||
return platform === "win32" ? "where" : "which"
|
||||
}
|
||||
|
||||
export function parseBinaryPaths(output: string): string[] {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
export function selectBinaryPath(
|
||||
paths: string[],
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
if (paths.length === 0) return null
|
||||
if (platform !== "win32") return paths[0]
|
||||
|
||||
const normalized = paths.map((path) => path.toLowerCase())
|
||||
for (const ext of WINDOWS_EXECUTABLE_EXTS) {
|
||||
const index = normalized.findIndex((path) => path.endsWith(ext))
|
||||
if (index !== -1) return paths[index]
|
||||
}
|
||||
|
||||
return paths[0]
|
||||
}
|
||||
|
||||
export function buildVersionCommand(
|
||||
binaryPath: string,
|
||||
platform: NodeJS.Platform
|
||||
): string[] {
|
||||
if (
|
||||
platform === "win32" &&
|
||||
binaryPath.toLowerCase().endsWith(".ps1")
|
||||
) {
|
||||
return [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
binaryPath,
|
||||
"--version",
|
||||
]
|
||||
}
|
||||
|
||||
return [binaryPath, "--version"]
|
||||
}
|
||||
|
||||
export function findDesktopBinary(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
checkExists: (path: string) => boolean = existsSync
|
||||
): { binary: string; path: string } | null {
|
||||
const desktopPaths = getDesktopAppPaths(platform)
|
||||
for (const desktopPath of desktopPaths) {
|
||||
if (checkExists(desktopPath)) {
|
||||
return { binary: "opencode", path: desktopPath }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
try {
|
||||
const path = Bun.which(binary)
|
||||
if (path) {
|
||||
return { binary, path }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const desktopResult = findDesktopBinary()
|
||||
if (desktopResult) {
|
||||
return desktopResult
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(
|
||||
binaryPath: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const command = buildVersionCommand(binaryPath, platform)
|
||||
const proc = Bun.spawn(command, { 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<OpenCodeInfo> {
|
||||
const binaryInfo = await findOpenCodeBinary()
|
||||
|
||||
if (!binaryInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
binary: null,
|
||||
}
|
||||
}
|
||||
|
||||
const version = await getOpenCodeVersion(binaryInfo.path ?? binaryInfo.binary)
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryInfo.path,
|
||||
binary: binaryInfo.binary as "opencode" | "opencode-desktop",
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkOpenCodeInstallation(): Promise<CheckResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
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<typeof spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
|
||||
|
||||
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
|
||||
if (existsSync(paths.configJsonc)) {
|
||||
return { path: paths.configJsonc, format: "jsonc" }
|
||||
}
|
||||
if (existsSync(paths.configJson)) {
|
||||
return { path: paths.configJson, 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 }
|
||||
}
|
||||
if (plugin.startsWith("file://") && plugin.includes(PACKAGE_NAME)) {
|
||||
return { entry: plugin, isPinned: false, version: "local-dev" }
|
||||
}
|
||||
}
|
||||
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<CheckResult> {
|
||||
const info = getPluginInfo()
|
||||
|
||||
if (!info.configPath) {
|
||||
const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "OpenCode config file not found",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
101
src/cli/doctor/checks/providers.ts
Normal file
101
src/cli/doctor/checks/providers.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
|
||||
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
import { AUTH_ENV_VARS, AUTH_PLUGINS, CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue, ProviderStatus } from "../types"
|
||||
|
||||
interface OpenCodeConfigShape {
|
||||
plugin?: string[]
|
||||
}
|
||||
|
||||
function loadOpenCodePlugins(): string[] {
|
||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
const targetPath = existsSync(configPaths.configJsonc)
|
||||
? configPaths.configJsonc
|
||||
: configPaths.configJson
|
||||
|
||||
if (!existsSync(targetPath)) return []
|
||||
|
||||
try {
|
||||
const content = readFileSync(targetPath, "utf-8")
|
||||
const parsed = parseJsonc<OpenCodeConfigShape>(content)
|
||||
return parsed.plugin ?? []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function hasProviderPlugin(plugins: string[], providerId: string): boolean {
|
||||
const definition = AUTH_PLUGINS[providerId]
|
||||
if (!definition) return false
|
||||
if (definition.plugin === "builtin") return true
|
||||
return plugins.some((plugin) => plugin === definition.plugin || plugin.startsWith(`${definition.plugin}@`))
|
||||
}
|
||||
|
||||
function hasProviderEnvVar(providerId: string): boolean {
|
||||
const envVarNames = AUTH_ENV_VARS[providerId] ?? []
|
||||
return envVarNames.some((envVarName) => Boolean(process.env[envVarName]))
|
||||
}
|
||||
|
||||
function getAffectedAgents(providerId: string): string[] {
|
||||
const affectedAgents: string[] = []
|
||||
|
||||
for (const [agentName, requirement] of Object.entries(AGENT_MODEL_REQUIREMENTS)) {
|
||||
const usesProvider = requirement.fallbackChain.some((entry) => entry.providers.includes(providerId))
|
||||
if (usesProvider) {
|
||||
affectedAgents.push(agentName)
|
||||
}
|
||||
}
|
||||
|
||||
return affectedAgents
|
||||
}
|
||||
|
||||
export function gatherProviderStatuses(): ProviderStatus[] {
|
||||
const plugins = loadOpenCodePlugins()
|
||||
|
||||
return Object.entries(AUTH_PLUGINS).map(([providerId, definition]) => {
|
||||
const hasPlugin = hasProviderPlugin(plugins, providerId)
|
||||
const hasEnvVar = hasProviderEnvVar(providerId)
|
||||
return {
|
||||
id: providerId,
|
||||
name: definition.name,
|
||||
available: hasPlugin && hasEnvVar,
|
||||
hasPlugin,
|
||||
hasEnvVar,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkProviders(): Promise<CheckResult> {
|
||||
const statuses = gatherProviderStatuses()
|
||||
const issues: DoctorIssue[] = []
|
||||
|
||||
for (const status of statuses) {
|
||||
if (status.available) continue
|
||||
|
||||
const missingParts: string[] = []
|
||||
if (!status.hasPlugin) missingParts.push("auth plugin")
|
||||
if (!status.hasEnvVar) missingParts.push("environment variable")
|
||||
|
||||
issues.push({
|
||||
title: `${status.name} authentication missing`,
|
||||
description: `Missing ${missingParts.join(" and ")} for ${status.name}.`,
|
||||
fix: `Configure ${status.name} provider in OpenCode and set ${(AUTH_ENV_VARS[status.id] ?? []).join(" or ")}`,
|
||||
affects: getAffectedAgents(status.id),
|
||||
severity: "warning",
|
||||
})
|
||||
}
|
||||
|
||||
const status = issues.length === 0 ? "pass" : "warn"
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PROVIDERS],
|
||||
status,
|
||||
message: issues.length === 0 ? "All provider auth checks passed" : `${issues.length} provider issue(s) detected`,
|
||||
details: statuses.map(
|
||||
(providerStatus) =>
|
||||
`${providerStatus.name}: plugin=${providerStatus.hasPlugin ? "yes" : "no"}, env=${providerStatus.hasEnvVar ? "yes" : "no"}`
|
||||
),
|
||||
issues,
|
||||
}
|
||||
}
|
||||
144
src/cli/doctor/checks/system-binary.ts
Normal file
144
src/cli/doctor/checks/system-binary.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { OPENCODE_BINARIES } from "../constants"
|
||||
|
||||
const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"]
|
||||
|
||||
export interface OpenCodeBinaryInfo {
|
||||
binary: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export function getDesktopAppPaths(platform: NodeJS.Platform): string[] {
|
||||
const home = homedir()
|
||||
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return [
|
||||
"/Applications/OpenCode.app/Contents/MacOS/OpenCode",
|
||||
join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"),
|
||||
]
|
||||
case "win32": {
|
||||
const programFiles = process.env.ProgramFiles
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
const paths: string[] = []
|
||||
|
||||
if (programFiles) {
|
||||
paths.push(join(programFiles, "OpenCode", "OpenCode.exe"))
|
||||
}
|
||||
if (localAppData) {
|
||||
paths.push(join(localAppData, "OpenCode", "OpenCode.exe"))
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
case "linux":
|
||||
return [
|
||||
"/usr/bin/opencode",
|
||||
"/usr/lib/opencode/opencode",
|
||||
join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"),
|
||||
join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"),
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" {
|
||||
return platform === "win32" ? "where" : "which"
|
||||
}
|
||||
|
||||
export function parseBinaryPaths(output: string): string[] {
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
export function selectBinaryPath(paths: string[], platform: NodeJS.Platform): string | null {
|
||||
if (paths.length === 0) return null
|
||||
if (platform !== "win32") return paths[0] ?? null
|
||||
|
||||
const normalizedPaths = paths.map((path) => path.toLowerCase())
|
||||
for (const extension of WINDOWS_EXECUTABLE_EXTS) {
|
||||
const pathIndex = normalizedPaths.findIndex((path) => path.endsWith(extension))
|
||||
if (pathIndex !== -1) {
|
||||
return paths[pathIndex] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return paths[0] ?? null
|
||||
}
|
||||
|
||||
export function buildVersionCommand(binaryPath: string, platform: NodeJS.Platform): string[] {
|
||||
if (platform === "win32" && binaryPath.toLowerCase().endsWith(".ps1")) {
|
||||
return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, "--version"]
|
||||
}
|
||||
|
||||
return [binaryPath, "--version"]
|
||||
}
|
||||
|
||||
export function findDesktopBinary(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
checkExists: (path: string) => boolean = existsSync
|
||||
): OpenCodeBinaryInfo | null {
|
||||
for (const desktopPath of getDesktopAppPaths(platform)) {
|
||||
if (checkExists(desktopPath)) {
|
||||
return { binary: "opencode", path: desktopPath }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function findOpenCodeBinary(): Promise<OpenCodeBinaryInfo | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
const path = Bun.which(binary)
|
||||
if (path) {
|
||||
return { binary, path }
|
||||
}
|
||||
}
|
||||
|
||||
return findDesktopBinary()
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(
|
||||
binaryPath: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const command = buildVersionCommand(binaryPath, platform)
|
||||
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
|
||||
if (processResult.exitCode !== 0) return null
|
||||
return output.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function compareVersions(current: string, minimum: string): boolean {
|
||||
const parseVersion = (version: string): number[] =>
|
||||
version
|
||||
.replace(/^v/, "")
|
||||
.split("-")[0]
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
|
||||
const currentParts = parseVersion(current)
|
||||
const minimumParts = parseVersion(minimum)
|
||||
const length = Math.max(currentParts.length, minimumParts.length)
|
||||
|
||||
for (let index = 0; index < length; index++) {
|
||||
const currentPart = currentParts[index] ?? 0
|
||||
const minimumPart = minimumParts[index] ?? 0
|
||||
if (currentPart > minimumPart) return true
|
||||
if (currentPart < minimumPart) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
79
src/cli/doctor/checks/system-loaded-version.ts
Normal file
79
src/cli/doctor/checks/system-loaded-version.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
|
||||
import { extractChannel } from "../../../hooks/auto-update-checker"
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
|
||||
|
||||
interface PackageJsonShape {
|
||||
version?: string
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LoadedVersionInfo {
|
||||
cacheDir: string
|
||||
cachePackagePath: string
|
||||
installedPackagePath: string
|
||||
expectedVersion: string | null
|
||||
loadedVersion: string | null
|
||||
}
|
||||
|
||||
function getPlatformDefaultCacheDir(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === "darwin") return join(homedir(), "Library", "Caches")
|
||||
if (platform === "win32") return process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")
|
||||
return join(homedir(), ".cache")
|
||||
}
|
||||
|
||||
function resolveOpenCodeCacheDir(): string {
|
||||
const xdgCacheHome = process.env.XDG_CACHE_HOME
|
||||
if (xdgCacheHome) return join(xdgCacheHome, "opencode")
|
||||
|
||||
const fromShared = getOpenCodeCacheDir()
|
||||
const platformDefault = join(getPlatformDefaultCacheDir(), "opencode")
|
||||
if (existsSync(fromShared) || !existsSync(platformDefault)) return fromShared
|
||||
return platformDefault
|
||||
}
|
||||
|
||||
function readPackageJson(filePath: string): PackageJsonShape | null {
|
||||
if (!existsSync(filePath)) return null
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return parseJsonc<PackageJsonShape>(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVersion(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
const match = value.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)
|
||||
return match?.[0] ?? null
|
||||
}
|
||||
|
||||
export function getLoadedPluginVersion(): LoadedVersionInfo {
|
||||
const cacheDir = resolveOpenCodeCacheDir()
|
||||
const cachePackagePath = join(cacheDir, "package.json")
|
||||
const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
|
||||
|
||||
const cachePackage = readPackageJson(cachePackagePath)
|
||||
const installedPackage = readPackageJson(installedPackagePath)
|
||||
|
||||
const expectedVersion = normalizeVersion(cachePackage?.dependencies?.[PACKAGE_NAME])
|
||||
const loadedVersion = normalizeVersion(installedPackage?.version)
|
||||
|
||||
return {
|
||||
cacheDir,
|
||||
cachePackagePath,
|
||||
installedPackagePath,
|
||||
expectedVersion,
|
||||
loadedVersion,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestPluginVersion(currentVersion: string | null): Promise<string | null> {
|
||||
const channel = extractChannel(currentVersion)
|
||||
return getLatestVersion(channel)
|
||||
}
|
||||
95
src/cli/doctor/checks/system-plugin.ts
Normal file
95
src/cli/doctor/checks/system-plugin.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { PACKAGE_NAME } from "../constants"
|
||||
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
|
||||
|
||||
export interface PluginInfo {
|
||||
registered: boolean
|
||||
configPath: string | null
|
||||
entry: string | null
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
isLocalDev: boolean
|
||||
}
|
||||
|
||||
interface OpenCodeConfigShape {
|
||||
plugin?: string[]
|
||||
}
|
||||
|
||||
function detectConfigPath(): string | null {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
if (existsSync(paths.configJsonc)) return paths.configJsonc
|
||||
if (existsSync(paths.configJson)) return paths.configJson
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePluginVersion(entry: string): string | null {
|
||||
if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null
|
||||
const value = entry.slice(PACKAGE_NAME.length + 1)
|
||||
if (!value || value === "latest") return null
|
||||
return value
|
||||
}
|
||||
|
||||
function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null {
|
||||
for (const entry of entries) {
|
||||
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
return { entry, isLocalDev: false }
|
||||
}
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
return { entry, isLocalDev: true }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPluginInfo(): PluginInfo {
|
||||
const configPath = detectConfigPath()
|
||||
if (!configPath) {
|
||||
return {
|
||||
registered: false,
|
||||
configPath: null,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
isLocalDev: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const parsedConfig = parseJsonc<OpenCodeConfigShape>(content)
|
||||
const pluginEntry = findPluginEntry(parsedConfig.plugin ?? [])
|
||||
if (!pluginEntry) {
|
||||
return {
|
||||
registered: false,
|
||||
configPath,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
isLocalDev: false,
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedVersion = parsePluginVersion(pluginEntry.entry)
|
||||
return {
|
||||
registered: true,
|
||||
configPath,
|
||||
entry: pluginEntry.entry,
|
||||
isPinned: pinnedVersion !== null,
|
||||
pinnedVersion,
|
||||
isLocalDev: pluginEntry.isLocalDev,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
registered: false,
|
||||
configPath,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
isLocalDev: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { detectConfigPath, findPluginEntry }
|
||||
129
src/cli/doctor/checks/system.ts
Normal file
129
src/cli/doctor/checks/system.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { MIN_OPENCODE_VERSION, CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue, SystemInfo } from "../types"
|
||||
import { findOpenCodeBinary, getOpenCodeVersion, compareVersions } from "./system-binary"
|
||||
import { getPluginInfo } from "./system-plugin"
|
||||
import { getLatestPluginVersion, getLoadedPluginVersion } from "./system-loaded-version"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
function isConfigValid(configPath: string | null): boolean {
|
||||
if (!configPath) return true
|
||||
if (!existsSync(configPath)) return false
|
||||
|
||||
try {
|
||||
parseJsonc<Record<string, unknown>>(readFileSync(configPath, "utf-8"))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getResultStatus(issues: DoctorIssue[]): CheckResult["status"] {
|
||||
if (issues.some((issue) => issue.severity === "error")) return "fail"
|
||||
if (issues.some((issue) => issue.severity === "warning")) return "warn"
|
||||
return "pass"
|
||||
}
|
||||
|
||||
function buildMessage(status: CheckResult["status"], issues: DoctorIssue[]): string {
|
||||
if (status === "pass") return "System checks passed"
|
||||
if (status === "fail") return `${issues.length} system issue(s) detected`
|
||||
return `${issues.length} system warning(s) detected`
|
||||
}
|
||||
|
||||
export async function gatherSystemInfo(): Promise<SystemInfo> {
|
||||
const [binaryInfo, pluginInfo] = await Promise.all([findOpenCodeBinary(), Promise.resolve(getPluginInfo())])
|
||||
const loadedInfo = getLoadedPluginVersion()
|
||||
|
||||
const opencodeVersion = binaryInfo ? await getOpenCodeVersion(binaryInfo.path) : null
|
||||
const pluginVersion = pluginInfo.pinnedVersion ?? loadedInfo.expectedVersion
|
||||
|
||||
return {
|
||||
opencodeVersion,
|
||||
opencodePath: binaryInfo?.path ?? null,
|
||||
pluginVersion,
|
||||
loadedVersion: loadedInfo.loadedVersion,
|
||||
bunVersion: Bun.version,
|
||||
configPath: pluginInfo.configPath,
|
||||
configValid: isConfigValid(pluginInfo.configPath),
|
||||
isLocalDev: pluginInfo.isLocalDev,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkSystem(): Promise<CheckResult> {
|
||||
const [systemInfo, pluginInfo] = await Promise.all([gatherSystemInfo(), Promise.resolve(getPluginInfo())])
|
||||
const loadedInfo = getLoadedPluginVersion()
|
||||
const latestVersion = await getLatestPluginVersion(systemInfo.loadedVersion)
|
||||
const issues: DoctorIssue[] = []
|
||||
|
||||
if (!systemInfo.opencodePath) {
|
||||
issues.push({
|
||||
title: "OpenCode binary not found",
|
||||
description: "Install OpenCode CLI or desktop and ensure the binary is available.",
|
||||
fix: "Install from https://opencode.ai/docs",
|
||||
severity: "error",
|
||||
affects: ["doctor", "run"],
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
systemInfo.opencodeVersion &&
|
||||
!compareVersions(systemInfo.opencodeVersion, MIN_OPENCODE_VERSION)
|
||||
) {
|
||||
issues.push({
|
||||
title: "OpenCode version below minimum",
|
||||
description: `Detected ${systemInfo.opencodeVersion}; required >= ${MIN_OPENCODE_VERSION}.`,
|
||||
fix: "Update OpenCode to the latest stable release",
|
||||
severity: "warning",
|
||||
affects: ["tooling", "doctor"],
|
||||
})
|
||||
}
|
||||
|
||||
if (!pluginInfo.registered) {
|
||||
issues.push({
|
||||
title: "oh-my-opencode is not registered",
|
||||
description: "Plugin entry is missing from OpenCode configuration.",
|
||||
fix: "Run: bunx oh-my-opencode install",
|
||||
severity: "error",
|
||||
affects: ["all agents"],
|
||||
})
|
||||
}
|
||||
|
||||
if (loadedInfo.expectedVersion && loadedInfo.loadedVersion && loadedInfo.expectedVersion !== loadedInfo.loadedVersion) {
|
||||
issues.push({
|
||||
title: "Loaded plugin version mismatch",
|
||||
description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`,
|
||||
fix: "Reinstall plugin dependencies in OpenCode cache",
|
||||
severity: "warning",
|
||||
affects: ["plugin loading"],
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
systemInfo.loadedVersion &&
|
||||
latestVersion &&
|
||||
!compareVersions(systemInfo.loadedVersion, latestVersion)
|
||||
) {
|
||||
issues.push({
|
||||
title: "Loaded plugin is outdated",
|
||||
description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`,
|
||||
fix: "Update: cd ~/.config/opencode && bun update oh-my-opencode",
|
||||
severity: "warning",
|
||||
affects: ["plugin features"],
|
||||
})
|
||||
}
|
||||
|
||||
const status = getResultStatus(issues)
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.SYSTEM],
|
||||
status,
|
||||
message: buildMessage(status, issues),
|
||||
details: [
|
||||
systemInfo.opencodeVersion ? `OpenCode: ${systemInfo.opencodeVersion}` : "OpenCode: not detected",
|
||||
`Plugin expected: ${systemInfo.pluginVersion ?? "unknown"}`,
|
||||
`Plugin loaded: ${systemInfo.loadedVersion ?? "unknown"}`,
|
||||
`Bun: ${systemInfo.bunVersion ?? "unknown"}`,
|
||||
],
|
||||
issues,
|
||||
}
|
||||
}
|
||||
105
src/cli/doctor/checks/tools-gh.ts
Normal file
105
src/cli/doctor/checks/tools-gh.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
const binaryPath = Bun.which(binary)
|
||||
return { exists: Boolean(binaryPath), path: binaryPath ?? null }
|
||||
} catch {
|
||||
return { exists: false, path: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(processResult.stdout).text()
|
||||
await processResult.exited
|
||||
if (processResult.exitCode !== 0) return null
|
||||
|
||||
const matchedVersion = output.match(/gh version (\S+)/)
|
||||
return matchedVersion?.[1] ?? output.trim().split("\n")[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getGhAuthStatus(): Promise<{
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const processResult = Bun.spawn(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
})
|
||||
|
||||
const stdout = await new Response(processResult.stdout).text()
|
||||
const stderr = await new Response(processResult.stderr).text()
|
||||
await processResult.exited
|
||||
|
||||
const output = stderr || stdout
|
||||
if (processResult.exitCode === 0) {
|
||||
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
|
||||
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
username: usernameMatch?.[1]?.replace(/[()]/g, "") ?? null,
|
||||
scopes: scopesMatch?.[1]?.split(/,\s*/).map((scope) => scope.trim()).filter(Boolean) ?? [],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const errorMatch = output.match(/error[:\s]+(.+)/i)
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: error instanceof Error ? error.message : "Failed to check auth status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
||||
const binaryStatus = await checkBinaryExists("gh")
|
||||
if (!binaryStatus.exists) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryStatus.path,
|
||||
authenticated: authStatus.authenticated,
|
||||
username: authStatus.username,
|
||||
scopes: authStatus.scopes,
|
||||
error: authStatus.error,
|
||||
}
|
||||
}
|
||||
25
src/cli/doctor/checks/tools-lsp.ts
Normal file
25
src/cli/doctor/checks/tools-lsp.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { LspServerInfo } from "../types"
|
||||
import { isServerInstalled } from "../../../tools/lsp/config"
|
||||
|
||||
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"] },
|
||||
]
|
||||
|
||||
export function getLspServersInfo(): LspServerInfo[] {
|
||||
return DEFAULT_LSP_SERVERS.map((server) => ({
|
||||
id: server.id,
|
||||
installed: isServerInstalled([server.binary]),
|
||||
extensions: server.extensions,
|
||||
source: "builtin",
|
||||
}))
|
||||
}
|
||||
|
||||
export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } {
|
||||
return {
|
||||
installed: servers.filter((server) => server.installed).length,
|
||||
total: servers.length,
|
||||
}
|
||||
}
|
||||
62
src/cli/doctor/checks/tools-mcp.ts
Normal file
62
src/cli/doctor/checks/tools-mcp.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type { McpServerInfo } from "../types"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
|
||||
|
||||
interface McpConfigShape {
|
||||
mcpServers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getMcpConfigPaths(): string[] {
|
||||
return [
|
||||
join(homedir(), ".claude", ".mcp.json"),
|
||||
join(process.cwd(), ".mcp.json"),
|
||||
join(process.cwd(), ".claude", ".mcp.json"),
|
||||
]
|
||||
}
|
||||
|
||||
function loadUserMcpConfig(): Record<string, unknown> {
|
||||
const servers: Record<string, unknown> = {}
|
||||
|
||||
for (const configPath of getMcpConfigPaths()) {
|
||||
if (!existsSync(configPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const config = parseJsonc<McpConfigShape>(content)
|
||||
if (config.mcpServers) {
|
||||
Object.assign(servers, config.mcpServers)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export function getBuiltinMcpInfo(): McpServerInfo[] {
|
||||
return BUILTIN_MCP_SERVERS.map((serverId) => ({
|
||||
id: serverId,
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
valid: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getUserMcpInfo(): McpServerInfo[] {
|
||||
return Object.entries(loadUserMcpConfig()).map(([serverId, value]) => {
|
||||
const valid = typeof value === "object" && value !== null
|
||||
return {
|
||||
id: serverId,
|
||||
type: "user",
|
||||
enabled: true,
|
||||
valid,
|
||||
error: valid ? undefined : "Invalid configuration format",
|
||||
}
|
||||
})
|
||||
}
|
||||
118
src/cli/doctor/checks/tools.ts
Normal file
118
src/cli/doctor/checks/tools.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { checkAstGrepCli, checkAstGrepNapi, checkCommentChecker } from "./dependencies"
|
||||
import { getGhCliInfo } from "./tools-gh"
|
||||
import { getLspServerStats, getLspServersInfo } from "./tools-lsp"
|
||||
import { getBuiltinMcpInfo, getUserMcpInfo } from "./tools-mcp"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue, ToolsSummary } from "../types"
|
||||
|
||||
export async function gatherToolsSummary(): Promise<ToolsSummary> {
|
||||
const [astGrepCliInfo, astGrepNapiInfo, commentCheckerInfo, ghInfo] = await Promise.all([
|
||||
checkAstGrepCli(),
|
||||
checkAstGrepNapi(),
|
||||
checkCommentChecker(),
|
||||
getGhCliInfo(),
|
||||
])
|
||||
|
||||
const lspServers = getLspServersInfo()
|
||||
const lspStats = getLspServerStats(lspServers)
|
||||
const builtinMcp = getBuiltinMcpInfo()
|
||||
const userMcp = getUserMcpInfo()
|
||||
|
||||
return {
|
||||
lspInstalled: lspStats.installed,
|
||||
lspTotal: lspStats.total,
|
||||
astGrepCli: astGrepCliInfo.installed,
|
||||
astGrepNapi: astGrepNapiInfo.installed,
|
||||
commentChecker: commentCheckerInfo.installed,
|
||||
ghCli: {
|
||||
installed: ghInfo.installed,
|
||||
authenticated: ghInfo.authenticated,
|
||||
username: ghInfo.username,
|
||||
},
|
||||
mcpBuiltin: builtinMcp.map((server) => server.id),
|
||||
mcpUser: userMcp.map((server) => server.id),
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolIssues(summary: ToolsSummary): DoctorIssue[] {
|
||||
const issues: DoctorIssue[] = []
|
||||
|
||||
if (!summary.astGrepCli && !summary.astGrepNapi) {
|
||||
issues.push({
|
||||
title: "AST-Grep unavailable",
|
||||
description: "Neither AST-Grep CLI nor NAPI backend is available.",
|
||||
fix: "Install @ast-grep/cli globally or add @ast-grep/napi",
|
||||
severity: "warning",
|
||||
affects: ["ast_grep_search", "ast_grep_replace"],
|
||||
})
|
||||
}
|
||||
|
||||
if (!summary.commentChecker) {
|
||||
issues.push({
|
||||
title: "Comment checker unavailable",
|
||||
description: "Comment checker binary is not installed.",
|
||||
fix: "Install @code-yeongyu/comment-checker",
|
||||
severity: "warning",
|
||||
affects: ["comment-checker hook"],
|
||||
})
|
||||
}
|
||||
|
||||
if (summary.lspInstalled === 0) {
|
||||
issues.push({
|
||||
title: "No LSP servers detected",
|
||||
description: "LSP-dependent tools will be limited until at least one server is installed.",
|
||||
severity: "warning",
|
||||
affects: ["lsp diagnostics", "rename", "references"],
|
||||
})
|
||||
}
|
||||
|
||||
if (!summary.ghCli.installed) {
|
||||
issues.push({
|
||||
title: "GitHub CLI missing",
|
||||
description: "gh CLI is not installed.",
|
||||
fix: "Install from https://cli.github.com/",
|
||||
severity: "warning",
|
||||
affects: ["GitHub automation"],
|
||||
})
|
||||
} else if (!summary.ghCli.authenticated) {
|
||||
issues.push({
|
||||
title: "GitHub CLI not authenticated",
|
||||
description: "gh CLI is installed but not logged in.",
|
||||
fix: "Run: gh auth login",
|
||||
severity: "warning",
|
||||
affects: ["GitHub automation"],
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export async function checkTools(): Promise<CheckResult> {
|
||||
const summary = await gatherToolsSummary()
|
||||
const userMcpServers = getUserMcpInfo()
|
||||
const invalidUserMcpServers = userMcpServers.filter((server) => !server.valid)
|
||||
const issues = buildToolIssues(summary)
|
||||
|
||||
if (invalidUserMcpServers.length > 0) {
|
||||
issues.push({
|
||||
title: "Invalid MCP server configuration",
|
||||
description: `${invalidUserMcpServers.length} user MCP server(s) have invalid config format.`,
|
||||
severity: "warning",
|
||||
affects: ["custom MCP tools"],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.TOOLS],
|
||||
status: issues.length === 0 ? "pass" : "warn",
|
||||
message: issues.length === 0 ? "All tools checks passed" : `${issues.length} tools issue(s) detected`,
|
||||
details: [
|
||||
`AST-Grep: cli=${summary.astGrepCli ? "yes" : "no"}, napi=${summary.astGrepNapi ? "yes" : "no"}`,
|
||||
`Comment checker: ${summary.commentChecker ? "yes" : "no"}`,
|
||||
`LSP: ${summary.lspInstalled}/${summary.lspTotal}`,
|
||||
`GH CLI: ${summary.ghCli.installed ? "installed" : "missing"}${summary.ghCli.authenticated ? " (authenticated)" : ""}`,
|
||||
`MCP: builtin=${summary.mcpBuiltin.length}, user=${summary.mcpUser.length}`,
|
||||
],
|
||||
issues,
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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<typeof spyOn>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import {
|
||||
getCachedVersion,
|
||||
getLatestVersion,
|
||||
isLocalDevMode,
|
||||
findPluginEntry,
|
||||
} from "../../../hooks/auto-update-checker/checker"
|
||||
|
||||
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<VersionCheckInfo> {
|
||||
const cwd = process.cwd()
|
||||
|
||||
if (isLocalDevMode(cwd)) {
|
||||
return {
|
||||
currentVersion: "local-dev",
|
||||
latestVersion: null,
|
||||
isUpToDate: true,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(cwd)
|
||||
if (pluginInfo?.isPinned) {
|
||||
return {
|
||||
currentVersion: pluginInfo.pinnedVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: true,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
const { extractChannel } = await import("../../../hooks/auto-update-checker/index")
|
||||
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
|
||||
const isUpToDate =
|
||||
!currentVersion ||
|
||||
!latestVersion ||
|
||||
compareVersions(currentVersion, latestVersion)
|
||||
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkVersionStatus(): Promise<CheckResult> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -18,50 +18,31 @@ export const STATUS_COLORS = {
|
||||
} as const
|
||||
|
||||
export const CHECK_IDS = {
|
||||
OPENCODE_INSTALLATION: "opencode-installation",
|
||||
PLUGIN_REGISTRATION: "plugin-registration",
|
||||
CONFIG_VALIDATION: "config-validation",
|
||||
MODEL_RESOLUTION: "model-resolution",
|
||||
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",
|
||||
GH_CLI: "gh-cli",
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
|
||||
VERSION_STATUS: "version-status",
|
||||
SYSTEM: "system",
|
||||
CONFIG: "config",
|
||||
PROVIDERS: "providers",
|
||||
TOOLS: "tools",
|
||||
MODELS: "models",
|
||||
} as const
|
||||
|
||||
export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
|
||||
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
|
||||
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
|
||||
[CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution",
|
||||
[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.GH_CLI]: "GitHub CLI",
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
|
||||
[CHECK_IDS.VERSION_STATUS]: "Version Status",
|
||||
[CHECK_IDS.SYSTEM]: "System",
|
||||
[CHECK_IDS.CONFIG]: "Configuration",
|
||||
[CHECK_IDS.PROVIDERS]: "Providers",
|
||||
[CHECK_IDS.TOOLS]: "Tools",
|
||||
[CHECK_IDS.MODELS]: "Models",
|
||||
} as const
|
||||
|
||||
export const CATEGORY_NAMES: Record<string, string> = {
|
||||
installation: "Installation",
|
||||
configuration: "Configuration",
|
||||
authentication: "Authentication",
|
||||
dependencies: "Dependencies",
|
||||
tools: "Tools & Servers",
|
||||
updates: "Updates",
|
||||
export const AUTH_ENV_VARS: Record<string, string[]> = {
|
||||
anthropic: ["ANTHROPIC_API_KEY"],
|
||||
openai: ["OPENAI_API_KEY"],
|
||||
google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
} as const
|
||||
|
||||
export const AUTH_PLUGINS: Record<string, { plugin: string; name: string }> = {
|
||||
anthropic: { plugin: "builtin", name: "Anthropic" },
|
||||
openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI" },
|
||||
google: { plugin: "opencode-antigravity-auth", name: "Google" },
|
||||
} as const
|
||||
|
||||
export const EXIT_CODES = {
|
||||
|
||||
83
src/cli/doctor/format-default.test.ts
Normal file
83
src/cli/doctor/format-default.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { formatDefault } from "./format-default"
|
||||
import { stripAnsi } from "./format-shared"
|
||||
import type { DoctorResult } from "./types"
|
||||
|
||||
function createBaseResult(): DoctorResult {
|
||||
return {
|
||||
results: [
|
||||
{ name: "System", status: "pass", message: "ok", issues: [] },
|
||||
{ name: "Configuration", status: "pass", message: "ok", issues: [] },
|
||||
],
|
||||
systemInfo: {
|
||||
opencodeVersion: "1.0.200",
|
||||
opencodePath: "/usr/local/bin/opencode",
|
||||
pluginVersion: "3.4.0",
|
||||
loadedVersion: "3.4.0",
|
||||
bunVersion: "1.2.0",
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
configValid: true,
|
||||
isLocalDev: false,
|
||||
},
|
||||
providers: [],
|
||||
tools: {
|
||||
lspInstalled: 0,
|
||||
lspTotal: 0,
|
||||
astGrepCli: false,
|
||||
astGrepNapi: false,
|
||||
commentChecker: false,
|
||||
ghCli: { installed: false, authenticated: false, username: null },
|
||||
mcpBuiltin: [],
|
||||
mcpUser: [],
|
||||
},
|
||||
summary: { total: 2, passed: 2, failed: 0, warnings: 0, skipped: 0, duration: 10 },
|
||||
exitCode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatDefault", () => {
|
||||
it("prints a single System OK line when no issues exist", () => {
|
||||
//#given
|
||||
const result = createBaseResult()
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDefault(result))
|
||||
|
||||
//#then
|
||||
expect(output).toContain("System OK (opencode 1.0.200")
|
||||
expect(output).not.toContain("found:")
|
||||
})
|
||||
|
||||
it("prints numbered issue list when issues exist", () => {
|
||||
//#given
|
||||
const result = createBaseResult()
|
||||
result.results = [
|
||||
{
|
||||
name: "System",
|
||||
status: "fail",
|
||||
message: "failed",
|
||||
issues: [
|
||||
{
|
||||
title: "OpenCode binary not found",
|
||||
description: "Install OpenCode",
|
||||
fix: "Install from https://opencode.ai/docs",
|
||||
severity: "error",
|
||||
},
|
||||
{
|
||||
title: "Loaded plugin is outdated",
|
||||
description: "Loaded 3.0.0, latest 3.4.0",
|
||||
severity: "warning",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
//#when
|
||||
const output = stripAnsi(formatDefault(result))
|
||||
|
||||
//#then
|
||||
expect(output).toContain("2 issues found:")
|
||||
expect(output).toContain("1. OpenCode binary not found")
|
||||
expect(output).toContain("2. Loaded plugin is outdated")
|
||||
})
|
||||
})
|
||||
35
src/cli/doctor/format-default.ts
Normal file
35
src/cli/doctor/format-default.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import color from "picocolors"
|
||||
import type { DoctorResult } from "./types"
|
||||
import { SYMBOLS } from "./constants"
|
||||
import { formatHeader, formatIssue } from "./format-shared"
|
||||
|
||||
export function formatDefault(result: DoctorResult): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(formatHeader())
|
||||
|
||||
const allIssues = result.results.flatMap((r) => r.issues)
|
||||
|
||||
if (allIssues.length === 0) {
|
||||
const opencodeVer = result.systemInfo.opencodeVersion ?? "unknown"
|
||||
const pluginVer = result.systemInfo.pluginVersion ?? "unknown"
|
||||
lines.push(
|
||||
` ${color.green(SYMBOLS.check)} ${color.green(
|
||||
`System OK (opencode ${opencodeVer} · oh-my-opencode ${pluginVer})`
|
||||
)}`
|
||||
)
|
||||
} else {
|
||||
const issueCount = allIssues.filter((i) => i.severity === "error").length
|
||||
const warnCount = allIssues.filter((i) => i.severity === "warning").length
|
||||
|
||||
const totalStr = `${issueCount + warnCount} ${issueCount + warnCount === 1 ? "issue" : "issues"}`
|
||||
lines.push(` ${color.yellow(SYMBOLS.warn)} ${totalStr} found:\n`)
|
||||
|
||||
allIssues.forEach((issue, index) => {
|
||||
lines.push(formatIssue(issue, index + 1))
|
||||
lines.push("")
|
||||
})
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
49
src/cli/doctor/format-shared.ts
Normal file
49
src/cli/doctor/format-shared.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import color from "picocolors"
|
||||
import type { CheckStatus, DoctorIssue } from "./types"
|
||||
import { SYMBOLS, STATUS_COLORS } from "./constants"
|
||||
|
||||
export function formatStatusSymbol(status: CheckStatus): string {
|
||||
const colorFn = STATUS_COLORS[status]
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return colorFn(SYMBOLS.check)
|
||||
case "fail":
|
||||
return colorFn(SYMBOLS.cross)
|
||||
case "warn":
|
||||
return colorFn(SYMBOLS.warn)
|
||||
case "skip":
|
||||
return colorFn(SYMBOLS.skip)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatStatusMark(available: boolean): string {
|
||||
return available ? color.green(SYMBOLS.check) : color.red(SYMBOLS.cross)
|
||||
}
|
||||
|
||||
export function stripAnsi(str: string): string {
|
||||
const ESC = String.fromCharCode(27)
|
||||
const pattern = ESC + "\\[[0-9;]*m"
|
||||
return str.replace(new RegExp(pattern, "g"), "")
|
||||
}
|
||||
|
||||
export function formatHeader(): string {
|
||||
return `\n${color.bgMagenta(color.white(" oMo Doctor "))}\n`
|
||||
}
|
||||
|
||||
export function formatIssue(issue: DoctorIssue, index: number): string {
|
||||
const lines: string[] = []
|
||||
const severityColor = issue.severity === "error" ? color.red : color.yellow
|
||||
|
||||
lines.push(`${index}. ${severityColor(issue.title)}`)
|
||||
lines.push(` ${color.dim(issue.description)}`)
|
||||
|
||||
if (issue.fix) {
|
||||
lines.push(` ${color.cyan("Fix:")} ${color.dim(issue.fix)}`)
|
||||
}
|
||||
|
||||
if (issue.affects && issue.affects.length > 0) {
|
||||
lines.push(` ${color.cyan("Affects:")} ${color.dim(issue.affects.join(", "))}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
41
src/cli/doctor/format-status.ts
Normal file
41
src/cli/doctor/format-status.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import color from "picocolors"
|
||||
import type { DoctorResult } from "./types"
|
||||
import { formatHeader, formatStatusMark } from "./format-shared"
|
||||
|
||||
export function formatStatus(result: DoctorResult): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(formatHeader())
|
||||
|
||||
const { systemInfo, providers, tools } = result
|
||||
const padding = " "
|
||||
|
||||
const opencodeVer = systemInfo.opencodeVersion ?? "unknown"
|
||||
const pluginVer = systemInfo.pluginVersion ?? "unknown"
|
||||
const bunVer = systemInfo.bunVersion ?? "unknown"
|
||||
lines.push(` ${padding}System ${opencodeVer} · ${pluginVer} · Bun ${bunVer}`)
|
||||
|
||||
const configPath = systemInfo.configPath ?? "unknown"
|
||||
const configStatus = systemInfo.configValid ? color.green("(valid)") : color.red("(invalid)")
|
||||
lines.push(` ${padding}Config ${configPath} ${configStatus}`)
|
||||
|
||||
const providerParts = providers.map((p) => {
|
||||
const mark = formatStatusMark(p.available)
|
||||
return `${p.name}${mark}`
|
||||
})
|
||||
lines.push(` ${padding}Providers ${providerParts.join(" ")}`)
|
||||
|
||||
const lspText = `LSP ${tools.lspInstalled}/${tools.lspTotal}`
|
||||
const astGrepMark = formatStatusMark(tools.astGrepCli)
|
||||
const ghMark = formatStatusMark(tools.ghCli.installed && tools.ghCli.authenticated)
|
||||
const ghUser = tools.ghCli.username ?? ""
|
||||
lines.push(` ${padding}Tools ${lspText} · AST-Grep ${astGrepMark} · gh ${ghMark}${ghUser ? ` (${ghUser})` : ""}`)
|
||||
|
||||
const builtinCount = tools.mcpBuiltin.length
|
||||
const userCount = tools.mcpUser.length
|
||||
const builtinText = builtinCount > 0 ? tools.mcpBuiltin.join(" · ") : "none"
|
||||
const userText = userCount > 0 ? `+ ${userCount} user` : ""
|
||||
lines.push(` ${padding}MCPs ${builtinText} ${userText}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
89
src/cli/doctor/format-verbose.ts
Normal file
89
src/cli/doctor/format-verbose.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import color from "picocolors"
|
||||
import type { DoctorResult } from "./types"
|
||||
import { formatHeader, formatStatusSymbol, formatIssue } from "./format-shared"
|
||||
|
||||
export function formatVerbose(result: DoctorResult): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(formatHeader())
|
||||
|
||||
const { systemInfo, providers, tools, results, summary } = result
|
||||
|
||||
lines.push(`${color.bold("System Information")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
lines.push(` ${formatStatusSymbol("pass")} opencode ${systemInfo.opencodeVersion ?? "unknown"}`)
|
||||
lines.push(` ${formatStatusSymbol("pass")} oh-my-opencode ${systemInfo.pluginVersion ?? "unknown"}`)
|
||||
if (systemInfo.loadedVersion) {
|
||||
lines.push(` ${formatStatusSymbol("pass")} loaded ${systemInfo.loadedVersion}`)
|
||||
}
|
||||
if (systemInfo.bunVersion) {
|
||||
lines.push(` ${formatStatusSymbol("pass")} bun ${systemInfo.bunVersion}`)
|
||||
}
|
||||
lines.push(` ${formatStatusSymbol("pass")} path ${systemInfo.opencodePath ?? "unknown"}`)
|
||||
if (systemInfo.isLocalDev) {
|
||||
lines.push(` ${color.yellow("*")} ${color.dim("(local development mode)")}`)
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
lines.push(`${color.bold("Configuration")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
const configStatus = systemInfo.configValid ? color.green("valid") : color.red("invalid")
|
||||
lines.push(` ${formatStatusSymbol(systemInfo.configValid ? "pass" : "fail")} ${systemInfo.configPath ?? "unknown"} (${configStatus})`)
|
||||
lines.push("")
|
||||
|
||||
lines.push(`${color.bold("Providers")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
for (const provider of providers) {
|
||||
const availableMark = provider.available ? color.green("✓") : color.red("✗")
|
||||
const pluginMark = provider.hasPlugin ? color.green("plugin") : color.dim("no plugin")
|
||||
const envMark = provider.hasEnvVar ? color.green("env") : color.dim("no env")
|
||||
lines.push(` ${availableMark} ${provider.name} ${pluginMark} · ${envMark}`)
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
lines.push(`${color.bold("Tools")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
lines.push(` ${formatStatusSymbol("pass")} LSP ${tools.lspInstalled}/${tools.lspTotal} installed`)
|
||||
lines.push(` ${formatStatusSymbol(tools.astGrepCli ? "pass" : "fail")} ast-grep CLI ${tools.astGrepCli ? "installed" : "not found"}`)
|
||||
lines.push(` ${formatStatusSymbol(tools.astGrepNapi ? "pass" : "fail")} ast-grep napi ${tools.astGrepNapi ? "installed" : "not found"}`)
|
||||
lines.push(` ${formatStatusSymbol(tools.commentChecker ? "pass" : "fail")} comment-checker ${tools.commentChecker ? "installed" : "not found"}`)
|
||||
lines.push(` ${formatStatusSymbol(tools.ghCli.installed && tools.ghCli.authenticated ? "pass" : "fail")} gh CLI ${tools.ghCli.installed ? "installed" : "not found"}${tools.ghCli.authenticated && tools.ghCli.username ? ` (${tools.ghCli.username})` : ""}`)
|
||||
lines.push("")
|
||||
|
||||
lines.push(`${color.bold("MCPs")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
if (tools.mcpBuiltin.length === 0) {
|
||||
lines.push(` ${color.dim("No built-in MCPs")}`)
|
||||
} else {
|
||||
for (const mcp of tools.mcpBuiltin) {
|
||||
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
|
||||
}
|
||||
}
|
||||
if (tools.mcpUser.length > 0) {
|
||||
lines.push(` ${color.cyan("+")} ${tools.mcpUser.length} user MCP(s):`)
|
||||
for (const mcp of tools.mcpUser) {
|
||||
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
const allIssues = results.flatMap((r) => r.issues)
|
||||
if (allIssues.length > 0) {
|
||||
lines.push(`${color.bold("Issues")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
allIssues.forEach((issue, index) => {
|
||||
lines.push(formatIssue(issue, index + 1))
|
||||
lines.push("")
|
||||
})
|
||||
}
|
||||
|
||||
lines.push(`${color.bold("Summary")}`)
|
||||
lines.push(`${color.dim("\u2500".repeat(40))}`)
|
||||
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : `${summary.passed} passed`
|
||||
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : `${summary.failed} failed`
|
||||
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : `${summary.warnings} warnings`
|
||||
lines.push(` ${passText}, ${failText}, ${warnText}`)
|
||||
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -1,218 +1,127 @@
|
||||
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"
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { DoctorResult } from "./types"
|
||||
|
||||
function createDoctorResult(): DoctorResult {
|
||||
return {
|
||||
results: [
|
||||
{ name: "System", status: "pass", message: "ok", issues: [] },
|
||||
{ name: "Configuration", status: "warn", message: "warn", issues: [] },
|
||||
],
|
||||
systemInfo: {
|
||||
opencodeVersion: "1.0.200",
|
||||
opencodePath: "/usr/local/bin/opencode",
|
||||
pluginVersion: "3.4.0",
|
||||
loadedVersion: "3.4.0",
|
||||
bunVersion: "1.2.0",
|
||||
configPath: "/tmp/opencode.jsonc",
|
||||
configValid: true,
|
||||
isLocalDev: false,
|
||||
},
|
||||
providers: [{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true }],
|
||||
tools: {
|
||||
lspInstalled: 2,
|
||||
lspTotal: 4,
|
||||
astGrepCli: true,
|
||||
astGrepNapi: false,
|
||||
commentChecker: true,
|
||||
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
|
||||
mcpBuiltin: ["context7", "grep_app"],
|
||||
mcpUser: ["custom"],
|
||||
},
|
||||
summary: {
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 0,
|
||||
warnings: 1,
|
||||
skipped: 0,
|
||||
duration: 12,
|
||||
},
|
||||
exitCode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("formatCheckResult", () => {
|
||||
it("includes name and message", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "All good",
|
||||
}
|
||||
describe("formatDoctorOutput", () => {
|
||||
it("dispatches to default formatter for default mode", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
|
||||
|
||||
const output = formatCheckResult(result, false)
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "default")
|
||||
|
||||
expect(output).toContain("Test Check")
|
||||
expect(output).toContain("All good")
|
||||
//#then
|
||||
expect(output).toBe("default-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("includes details when verbose", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "OK",
|
||||
details: ["Detail 1", "Detail 2"],
|
||||
}
|
||||
it("dispatches to status formatter for status mode", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
|
||||
|
||||
const output = formatCheckResult(result, true)
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "status")
|
||||
|
||||
expect(output).toContain("Detail 1")
|
||||
expect(output).toContain("Detail 2")
|
||||
//#then
|
||||
expect(output).toBe("status-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
it("hides details when not verbose", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "OK",
|
||||
details: ["Detail 1"],
|
||||
}
|
||||
it("dispatches to verbose formatter for verbose mode", async () => {
|
||||
//#given
|
||||
const formatDefaultMock = mock(() => "default-output")
|
||||
const formatStatusMock = mock(() => "status-output")
|
||||
const formatVerboseMock = mock(() => "verbose-output")
|
||||
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
|
||||
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
|
||||
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
|
||||
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
|
||||
|
||||
const output = formatCheckResult(result, false)
|
||||
//#when
|
||||
const output = formatDoctorOutput(createDoctorResult(), "verbose")
|
||||
|
||||
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")
|
||||
//#then
|
||||
expect(output).toBe("verbose-output")
|
||||
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatStatusMock).toHaveBeenCalledTimes(0)
|
||||
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
it("returns valid JSON payload", async () => {
|
||||
//#given
|
||||
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
|
||||
const result = createDoctorResult()
|
||||
|
||||
//#when
|
||||
const output = formatJsonOutput(result)
|
||||
const parsed = JSON.parse(output)
|
||||
const parsed = JSON.parse(output) as DoctorResult
|
||||
|
||||
expect(parsed.results.length).toBe(1)
|
||||
expect(parsed.summary.total).toBe(1)
|
||||
//#then
|
||||
expect(parsed.summary.total).toBe(2)
|
||||
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
|
||||
expect(parsed.tools.ghCli.username).toBe("yeongyu")
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,140 +1,19 @@
|
||||
import color from "picocolors"
|
||||
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
|
||||
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
|
||||
import type { DoctorResult, DoctorMode } from "./types"
|
||||
import { formatDefault } from "./format-default"
|
||||
import { formatStatus } from "./format-status"
|
||||
import { formatVerbose } from "./format-verbose"
|
||||
|
||||
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 formatDoctorOutput(result: DoctorResult, mode: DoctorMode): string {
|
||||
switch (mode) {
|
||||
case "default":
|
||||
return formatDefault(result)
|
||||
case "status":
|
||||
return formatStatus(result)
|
||||
case "verbose":
|
||||
return formatVerbose(result)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { DoctorOptions } from "./types"
|
||||
import { runDoctor } from "./runner"
|
||||
|
||||
export async function doctor(options: DoctorOptions = {}): Promise<number> {
|
||||
export async function doctor(options: DoctorOptions = { mode: "default" }): Promise<number> {
|
||||
const result = await runDoctor(options)
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
export { runDoctor } from "./runner"
|
||||
export { formatJsonOutput } from "./formatter"
|
||||
export { formatDoctorOutput, formatJsonOutput } from "./formatter"
|
||||
|
||||
@@ -1,153 +1,253 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import {
|
||||
runCheck,
|
||||
calculateSummary,
|
||||
determineExitCode,
|
||||
filterChecksByCategory,
|
||||
groupChecksByCategory,
|
||||
} from "./runner"
|
||||
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { CheckDefinition, CheckResult, DoctorResult, ProviderStatus, SystemInfo, ToolsSummary } from "./types"
|
||||
|
||||
function createSystemInfo(): SystemInfo {
|
||||
return {
|
||||
opencodeVersion: "1.0.200",
|
||||
opencodePath: "/usr/local/bin/opencode",
|
||||
pluginVersion: "3.4.0",
|
||||
loadedVersion: "3.4.0",
|
||||
bunVersion: "1.2.0",
|
||||
configPath: "/tmp/opencode.json",
|
||||
configValid: true,
|
||||
isLocalDev: false,
|
||||
}
|
||||
}
|
||||
|
||||
function createProviders(): ProviderStatus[] {
|
||||
return [
|
||||
{ id: "anthropic", name: "Anthropic", available: true, hasEnvVar: true, hasPlugin: true },
|
||||
{ id: "openai", name: "OpenAI", available: false, hasEnvVar: false, hasPlugin: false },
|
||||
]
|
||||
}
|
||||
|
||||
function createTools(): ToolsSummary {
|
||||
return {
|
||||
lspInstalled: 1,
|
||||
lspTotal: 4,
|
||||
astGrepCli: true,
|
||||
astGrepNapi: false,
|
||||
commentChecker: true,
|
||||
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
|
||||
mcpBuiltin: ["context7"],
|
||||
mcpUser: ["custom-mcp"],
|
||||
}
|
||||
}
|
||||
|
||||
function createPassResult(name: string): CheckResult {
|
||||
return { name, status: "pass", message: "ok", issues: [] }
|
||||
}
|
||||
|
||||
function createDeferred(): {
|
||||
promise: Promise<CheckResult>
|
||||
resolve: (value: CheckResult) => void
|
||||
} {
|
||||
let resolvePromise: (value: CheckResult) => void = () => {}
|
||||
const promise = new Promise<CheckResult>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
describe("runner", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("runCheck", () => {
|
||||
it("returns result from check function", async () => {
|
||||
it("returns fail result with issue when check throws", async () => {
|
||||
//#given
|
||||
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",
|
||||
id: "system",
|
||||
name: "System",
|
||||
check: async () => {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
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")
|
||||
throw new Error("boom")
|
||||
},
|
||||
}
|
||||
const { runCheck } = await import(`./runner?run-check-error=${Date.now()}`)
|
||||
|
||||
//#when
|
||||
const result = await runCheck(check)
|
||||
|
||||
//#then
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.message).toContain("Test error")
|
||||
expect(result.message).toBe("boom")
|
||||
expect(result.issues[0]?.title).toBe("System")
|
||||
expect(result.issues[0]?.severity).toBe("error")
|
||||
expect(typeof result.duration).toBe("number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateSummary", () => {
|
||||
it("counts each status correctly", () => {
|
||||
it("counts statuses correctly", async () => {
|
||||
//#given
|
||||
const { calculateSummary } = await import(`./runner?summary=${Date.now()}`)
|
||||
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: "" },
|
||||
{ name: "1", status: "pass", message: "", issues: [] },
|
||||
{ name: "2", status: "pass", message: "", issues: [] },
|
||||
{ name: "3", status: "fail", message: "", issues: [] },
|
||||
{ name: "4", status: "warn", message: "", issues: [] },
|
||||
{ name: "5", status: "skip", message: "", issues: [] },
|
||||
]
|
||||
|
||||
const summary = calculateSummary(results, 100)
|
||||
//#when
|
||||
const summary = calculateSummary(results, 19.9)
|
||||
|
||||
//#then
|
||||
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)
|
||||
expect(summary.duration).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("determineExitCode", () => {
|
||||
it("returns 0 when all pass", () => {
|
||||
it("returns zero when no failures exist", async () => {
|
||||
//#given
|
||||
const { determineExitCode } = await import(`./runner?exit-ok=${Date.now()}`)
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "pass", message: "" },
|
||||
{ name: "1", status: "pass", message: "", issues: [] },
|
||||
{ name: "2", status: "warn", message: "", issues: [] },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(0)
|
||||
//#when
|
||||
const code = determineExitCode(results)
|
||||
|
||||
//#then
|
||||
expect(code).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 when only warnings", () => {
|
||||
it("returns one when any failure exists", async () => {
|
||||
//#given
|
||||
const { determineExitCode } = await import(`./runner?exit-fail=${Date.now()}`)
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "warn", message: "" },
|
||||
{ name: "1", status: "pass", message: "", issues: [] },
|
||||
{ name: "2", status: "fail", message: "", issues: [] },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(0)
|
||||
})
|
||||
//#when
|
||||
const code = determineExitCode(results)
|
||||
|
||||
it("returns 1 when any failures", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "fail", message: "" },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(1)
|
||||
//#then
|
||||
expect(code).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: "" }) },
|
||||
]
|
||||
describe("runDoctor", () => {
|
||||
it("starts all checks in parallel and returns collected result", async () => {
|
||||
//#given
|
||||
const startedChecks: string[] = []
|
||||
const deferredOne = createDeferred()
|
||||
const deferredTwo = createDeferred()
|
||||
const deferredThree = createDeferred()
|
||||
const deferredFour = createDeferred()
|
||||
const deferredFive = createDeferred()
|
||||
|
||||
it("returns all checks when no category", () => {
|
||||
const filtered = filterChecksByCategory(checks)
|
||||
const checks: CheckDefinition[] = [
|
||||
{
|
||||
id: "system",
|
||||
name: "System",
|
||||
check: async () => {
|
||||
startedChecks.push("system")
|
||||
return deferredOne.promise
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "config",
|
||||
name: "Configuration",
|
||||
check: async () => {
|
||||
startedChecks.push("config")
|
||||
return deferredTwo.promise
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "providers",
|
||||
name: "Providers",
|
||||
check: async () => {
|
||||
startedChecks.push("providers")
|
||||
return deferredThree.promise
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
name: "Tools",
|
||||
check: async () => {
|
||||
startedChecks.push("tools")
|
||||
return deferredFour.promise
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
name: "Models",
|
||||
check: async () => {
|
||||
startedChecks.push("models")
|
||||
return deferredFive.promise
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(filtered.length).toBe(3)
|
||||
})
|
||||
const expectedResult: DoctorResult = {
|
||||
results: [
|
||||
createPassResult("System"),
|
||||
createPassResult("Configuration"),
|
||||
createPassResult("Providers"),
|
||||
createPassResult("Tools"),
|
||||
createPassResult("Models"),
|
||||
],
|
||||
systemInfo: createSystemInfo(),
|
||||
providers: createProviders(),
|
||||
tools: createTools(),
|
||||
summary: {
|
||||
total: 5,
|
||||
passed: 5,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
skipped: 0,
|
||||
duration: 0,
|
||||
},
|
||||
exitCode: 0,
|
||||
}
|
||||
|
||||
it("filters to specific category", () => {
|
||||
const filtered = filterChecksByCategory(checks, "installation")
|
||||
const formatDoctorOutputMock = mock((result: DoctorResult) => result.summary.total.toString())
|
||||
const formatJsonOutputMock = mock((result: DoctorResult) => JSON.stringify(result))
|
||||
|
||||
expect(filtered.length).toBe(1)
|
||||
expect(filtered[0].name).toBe("Install")
|
||||
})
|
||||
})
|
||||
mock.module("./checks", () => ({
|
||||
getAllCheckDefinitions: () => checks,
|
||||
gatherSystemInfo: async () => expectedResult.systemInfo,
|
||||
gatherProviderStatuses: () => expectedResult.providers,
|
||||
gatherToolsSummary: async () => expectedResult.tools,
|
||||
}))
|
||||
mock.module("./formatter", () => ({
|
||||
formatDoctorOutput: formatDoctorOutputMock,
|
||||
formatJsonOutput: formatJsonOutputMock,
|
||||
}))
|
||||
|
||||
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: "" }) },
|
||||
]
|
||||
const logSpy = mock(() => {})
|
||||
const originalLog = console.log
|
||||
console.log = logSpy
|
||||
|
||||
it("groups checks by category", () => {
|
||||
const groups = groupChecksByCategory(checks)
|
||||
const { runDoctor } = await import(`./runner?parallel=${Date.now()}`)
|
||||
const runPromise = runDoctor({ mode: "default" })
|
||||
|
||||
expect(groups.get("installation")?.length).toBe(2)
|
||||
expect(groups.get("configuration")?.length).toBe(1)
|
||||
})
|
||||
//#when
|
||||
await Promise.resolve()
|
||||
const startedBeforeResolve = [...startedChecks]
|
||||
deferredOne.resolve(createPassResult("System"))
|
||||
deferredTwo.resolve(createPassResult("Configuration"))
|
||||
deferredThree.resolve(createPassResult("Providers"))
|
||||
deferredFour.resolve(createPassResult("Tools"))
|
||||
deferredFive.resolve(createPassResult("Models"))
|
||||
const result = await runPromise
|
||||
|
||||
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")
|
||||
//#then
|
||||
console.log = originalLog
|
||||
expect(startedBeforeResolve.sort()).toEqual(["config", "models", "providers", "system", "tools"])
|
||||
expect(result.results.length).toBe(5)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(formatDoctorOutputMock).toHaveBeenCalledTimes(1)
|
||||
expect(formatJsonOutputMock).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
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"
|
||||
import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types"
|
||||
import { getAllCheckDefinitions, gatherSystemInfo, gatherProviderStatuses, gatherToolsSummary } from "./checks"
|
||||
import { EXIT_CODES } from "./constants"
|
||||
import { formatDoctorOutput, formatJsonOutput } from "./formatter"
|
||||
|
||||
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
|
||||
const start = performance.now()
|
||||
@@ -28,6 +14,7 @@ export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
|
||||
name: check.name,
|
||||
status: "fail",
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
issues: [{ title: check.name, description: String(err), severity: "error" }],
|
||||
duration: Math.round(performance.now() - start),
|
||||
}
|
||||
}
|
||||
@@ -45,70 +32,19 @@ export function calculateSummary(results: CheckResult[], duration: number): Doct
|
||||
}
|
||||
|
||||
export function determineExitCode(results: CheckResult[]): number {
|
||||
const hasFailures = results.some((r) => r.status === "fail")
|
||||
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
|
||||
return results.some((r) => r.status === "fail") ? 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<CheckCategory, CheckDefinition[]> {
|
||||
const groups = new Map<CheckCategory, CheckDefinition[]>()
|
||||
|
||||
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<DoctorResult> {
|
||||
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 [results, systemInfo, providers, tools] = await Promise.all([
|
||||
Promise.all(allChecks.map(runCheck)),
|
||||
gatherSystemInfo(),
|
||||
Promise.resolve(gatherProviderStatuses()),
|
||||
gatherToolsSummary(),
|
||||
])
|
||||
|
||||
const duration = performance.now() - start
|
||||
const summary = calculateSummary(results, duration)
|
||||
@@ -116,6 +52,9 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
|
||||
|
||||
const doctorResult: DoctorResult = {
|
||||
results,
|
||||
systemInfo,
|
||||
providers,
|
||||
tools,
|
||||
summary,
|
||||
exitCode,
|
||||
}
|
||||
@@ -123,9 +62,7 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
|
||||
if (options.json) {
|
||||
console.log(formatJsonOutput(doctorResult))
|
||||
} else {
|
||||
console.log("")
|
||||
console.log(formatSummary(summary))
|
||||
console.log(formatFooter(summary))
|
||||
console.log(formatDoctorOutput(doctorResult, options.mode))
|
||||
}
|
||||
|
||||
return doctorResult
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// ===== New 3-tier doctor types =====
|
||||
|
||||
export type DoctorMode = "default" | "status" | "verbose"
|
||||
|
||||
export interface DoctorOptions {
|
||||
mode: DoctorMode
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
export interface DoctorIssue {
|
||||
title: string
|
||||
description: string
|
||||
fix?: string
|
||||
affects?: string[]
|
||||
severity: "error" | "warning"
|
||||
}
|
||||
|
||||
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
|
||||
|
||||
export interface CheckResult {
|
||||
@@ -5,31 +22,47 @@ export interface CheckResult {
|
||||
status: CheckStatus
|
||||
message: string
|
||||
details?: string[]
|
||||
issues: DoctorIssue[]
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type CheckFunction = () => Promise<CheckResult>
|
||||
|
||||
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 SystemInfo {
|
||||
opencodeVersion: string | null
|
||||
opencodePath: string | null
|
||||
pluginVersion: string | null
|
||||
loadedVersion: string | null
|
||||
bunVersion: string | null
|
||||
configPath: string | null
|
||||
configValid: boolean
|
||||
isLocalDev: boolean
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
id: string
|
||||
name: string
|
||||
available: boolean
|
||||
hasEnvVar: boolean
|
||||
hasPlugin: boolean
|
||||
}
|
||||
|
||||
export interface ToolsSummary {
|
||||
lspInstalled: number
|
||||
lspTotal: number
|
||||
astGrepCli: boolean
|
||||
astGrepNapi: boolean
|
||||
commentChecker: boolean
|
||||
ghCli: { installed: boolean; authenticated: boolean; username: string | null }
|
||||
mcpBuiltin: string[]
|
||||
mcpUser: string[]
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
@@ -43,10 +76,23 @@ export interface DoctorSummary {
|
||||
|
||||
export interface DoctorResult {
|
||||
results: CheckResult[]
|
||||
systemInfo: SystemInfo
|
||||
providers: ProviderStatus[]
|
||||
tools: ToolsSummary
|
||||
summary: DoctorSummary
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
// ===== Legacy types (used by existing checks until migration) =====
|
||||
|
||||
export type CheckCategory =
|
||||
| "installation"
|
||||
| "configuration"
|
||||
| "authentication"
|
||||
| "dependencies"
|
||||
| "tools"
|
||||
| "updates"
|
||||
|
||||
export interface OpenCodeInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
|
||||
Reference in New Issue
Block a user