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:
+122
-80
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user