Merge pull request #4048 from PeterPonyu/feat/doctor-check-tui-plugin
feat(doctor): warn when oh-my-openagent/tui is missing from tui.json
This commit is contained in:
@@ -5,6 +5,7 @@ import { checkConfig } from "./config"
|
||||
import { checkTools, gatherToolsSummary } from "./tools"
|
||||
import { checkModels } from "./model-resolution"
|
||||
import { checkTeamMode } from "./team-mode"
|
||||
import { checkTuiPluginConfig } from "./tui-plugin-config"
|
||||
|
||||
export type { CheckDefinition }
|
||||
export * from "./model-resolution-types"
|
||||
@@ -23,6 +24,11 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG],
|
||||
check: checkConfig,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.TUI_PLUGIN,
|
||||
name: CHECK_NAMES[CHECK_IDS.TUI_PLUGIN],
|
||||
check: checkTuiPluginConfig,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.TOOLS,
|
||||
name: CHECK_NAMES[CHECK_IDS.TOOLS],
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { PLUGIN_NAME } from "../../../shared"
|
||||
import { checkTuiPluginConfig } from "./tui-plugin-config"
|
||||
|
||||
let testConfigDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
function writeOpenCodeConfig(plugins: string[]): void {
|
||||
writeFileSync(
|
||||
join(testConfigDir, "opencode.json"),
|
||||
JSON.stringify({ plugin: plugins }, null, 2) + "\n",
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
function writeTuiConfig(plugins: string[]): void {
|
||||
writeFileSync(
|
||||
join(testConfigDir, "tui.json"),
|
||||
JSON.stringify({ plugin: plugins }, null, 2) + "\n",
|
||||
"utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
function writeFilePluginPackage(dir: string, packageName: string): string {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: packageName }, null, 2) + "\n",
|
||||
"utf-8",
|
||||
)
|
||||
return `file:${dir}`
|
||||
}
|
||||
|
||||
describe("tui-plugin-config check", () => {
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
testConfigDir = join(
|
||||
tmpdir(),
|
||||
`omo-doctor-tui-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
mkdirSync(testConfigDir, { recursive: true })
|
||||
process.env.OPENCODE_CONFIG_DIR = testConfigDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testConfigDir, { recursive: true, force: true })
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
})
|
||||
|
||||
it("passes when both server and TUI entries are registered", async () => {
|
||||
//#given opencode.json has the server entry and tui.json has the TUI entry
|
||||
writeOpenCodeConfig([PLUGIN_NAME])
|
||||
writeTuiConfig([`${PLUGIN_NAME}/tui`])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then both are detected and status is pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.issues).toHaveLength(0)
|
||||
expect(result.name).toBe("TUI Plugin")
|
||||
})
|
||||
|
||||
it("warns when server is registered but TUI entry is missing", async () => {
|
||||
//#given opencode.json has the server entry but tui.json does not
|
||||
writeOpenCodeConfig([PLUGIN_NAME])
|
||||
writeTuiConfig(["some-other-tui-plugin"])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then status is warn with a single warning issue
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].severity).toBe("warning")
|
||||
expect(result.issues[0].title).toContain("TUI plugin entry missing")
|
||||
expect(result.issues[0].fix).toBeDefined()
|
||||
})
|
||||
|
||||
it("warns when server is registered but tui.json does not exist", async () => {
|
||||
//#given opencode.json has the server entry and tui.json is absent
|
||||
writeOpenCodeConfig([PLUGIN_NAME])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then status is warn — missing file means missing entry
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].severity).toBe("warning")
|
||||
})
|
||||
|
||||
it("passes when tui.json has a file: entry pointing at our package", async () => {
|
||||
//#given opencode.json has the server entry and tui.json uses a file: URL
|
||||
//# pointing at a local checkout of our package
|
||||
writeOpenCodeConfig([PLUGIN_NAME])
|
||||
const localPkgDir = join(testConfigDir, "local-checkout")
|
||||
const fileEntry = writeFilePluginPackage(localPkgDir, "oh-my-opencode")
|
||||
writeTuiConfig([fileEntry])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then file: entry satisfies registration and status is pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("warns when tui.json has our entry but server plugin is missing from opencode.json", async () => {
|
||||
//#given tui.json has the TUI entry but opencode.json does not have the server entry
|
||||
writeOpenCodeConfig(["some-other-plugin"])
|
||||
writeTuiConfig([`${PLUGIN_NAME}/tui`])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then status is warn — TUI-only registration can't function without the server side
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].severity).toBe("warning")
|
||||
expect(result.issues[0].title).toContain("Server plugin entry missing")
|
||||
expect(result.issues[0].fix).toBeDefined()
|
||||
})
|
||||
|
||||
it("skips when neither config registers the plugin", async () => {
|
||||
//#given an opencode.json and tui.json with no oh-my-openagent entries
|
||||
writeOpenCodeConfig(["some-other-plugin"])
|
||||
writeTuiConfig(["some-other-tui-plugin"])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then status is skip — plugin not installed at all
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.issues).toHaveLength(0)
|
||||
expect(result.message).toContain("not registered")
|
||||
})
|
||||
|
||||
it("passes when legacy server entry is paired with legacy TUI entry", async () => {
|
||||
//#given legacy package names in both configs
|
||||
writeOpenCodeConfig(["oh-my-opencode"])
|
||||
writeTuiConfig(["oh-my-opencode/tui"])
|
||||
|
||||
//#when running the check
|
||||
const result = await checkTuiPluginConfig()
|
||||
|
||||
//#then legacy aliases are accepted
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import {
|
||||
ACCEPTED_PACKAGE_NAMES,
|
||||
LEGACY_PLUGIN_NAME,
|
||||
PLUGIN_NAME,
|
||||
getOpenCodeConfigDir,
|
||||
getOpenCodeConfigPaths,
|
||||
parseJsonc,
|
||||
} from "../../../shared"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import type { CheckResult, DoctorIssue } from "../types"
|
||||
|
||||
const TUI_SUBPATH = "tui"
|
||||
|
||||
interface OpenCodeConfigShape {
|
||||
plugin?: string[]
|
||||
}
|
||||
|
||||
interface TuiConfigShape {
|
||||
plugin?: string[]
|
||||
}
|
||||
|
||||
interface ServerPluginInfo {
|
||||
registered: boolean
|
||||
configPath: string | null
|
||||
}
|
||||
|
||||
interface TuiPluginInfo {
|
||||
registered: boolean
|
||||
configPath: string | null
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
// Returns true if `entry` is a file:-URL pointing at a directory whose
|
||||
// package.json declares one of our accepted package names. opencode-tui loads
|
||||
// such entries via the `./tui` subpath export, so a `file:` entry already
|
||||
// satisfies the TUI plugin registration even without an explicit
|
||||
// `oh-my-openagent/tui` entry. Mirrors the helper used in
|
||||
// add-tui-plugin-to-tui-config.ts during installation.
|
||||
function isOurFilePluginEntry(entry: string): boolean {
|
||||
if (!entry.startsWith("file:")) return false
|
||||
let path = entry.slice("file:".length)
|
||||
if (path.startsWith("//")) path = path.slice(2)
|
||||
try {
|
||||
const pkgJsonPath = join(path, "package.json")
|
||||
if (!existsSync(pkgJsonPath)) return false
|
||||
const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as { name?: unknown }
|
||||
return typeof parsed.name === "string"
|
||||
&& (ACCEPTED_PACKAGE_NAMES as readonly string[]).includes(parsed.name)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isServerPluginEntry(entry: string): boolean {
|
||||
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) return true
|
||||
if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) return true
|
||||
if (entry.startsWith("file:") && isOurFilePluginEntry(entry)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isTuiPluginEntry(entry: string): boolean {
|
||||
const canonicalPrefix = `${PLUGIN_NAME}/${TUI_SUBPATH}`
|
||||
const legacyPrefix = `${LEGACY_PLUGIN_NAME}/${TUI_SUBPATH}`
|
||||
if (entry === canonicalPrefix || entry.startsWith(`${canonicalPrefix}@`)) return true
|
||||
if (entry === legacyPrefix || entry.startsWith(`${legacyPrefix}@`)) return true
|
||||
// file: entries pointing at our package already expose the ./tui subpath via
|
||||
// package.json `exports`, so the TUI plugin loads without a separate entry.
|
||||
if (entry.startsWith("file:") && isOurFilePluginEntry(entry)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function detectServerPluginRegistration(): ServerPluginInfo {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
const configPath = existsSync(paths.configJsonc)
|
||||
? paths.configJsonc
|
||||
: existsSync(paths.configJson)
|
||||
? paths.configJson
|
||||
: null
|
||||
|
||||
if (!configPath) {
|
||||
return { registered: false, configPath: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseJsonc<OpenCodeConfigShape>(readFileSync(configPath, "utf-8"))
|
||||
const plugins = parsed.plugin ?? []
|
||||
return { registered: plugins.some(isServerPluginEntry), configPath }
|
||||
} catch {
|
||||
return { registered: false, configPath }
|
||||
}
|
||||
}
|
||||
|
||||
export function detectTuiPluginRegistration(): TuiPluginInfo {
|
||||
const tuiJsonPath = join(getOpenCodeConfigDir({ binary: "opencode" }), "tui.json")
|
||||
if (!existsSync(tuiJsonPath)) {
|
||||
return { registered: false, configPath: tuiJsonPath, exists: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseJsonc<TuiConfigShape>(readFileSync(tuiJsonPath, "utf-8"))
|
||||
const plugins = parsed.plugin ?? []
|
||||
return { registered: plugins.some(isTuiPluginEntry), configPath: tuiJsonPath, exists: true }
|
||||
} catch {
|
||||
return { registered: false, configPath: tuiJsonPath, exists: true }
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkTuiPluginConfig(): Promise<CheckResult> {
|
||||
const name = CHECK_NAMES[CHECK_IDS.TUI_PLUGIN]
|
||||
const server = detectServerPluginRegistration()
|
||||
const tui = detectTuiPluginRegistration()
|
||||
const issues: DoctorIssue[] = []
|
||||
const details: string[] = []
|
||||
|
||||
if (server.configPath) details.push(`opencode.json: ${server.configPath}`)
|
||||
if (tui.configPath) details.push(`tui.json: ${tui.configPath}`)
|
||||
|
||||
if (!server.registered && !tui.registered) {
|
||||
return {
|
||||
name,
|
||||
status: "skip",
|
||||
message: "Plugin not registered (server or TUI)",
|
||||
details: details.length > 0 ? details : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
if (server.registered && !tui.registered) {
|
||||
issues.push({
|
||||
title: "TUI plugin entry missing from tui.json",
|
||||
description:
|
||||
"The server plugin is registered in opencode.json, but the TUI plugin entry "
|
||||
+ `("${PLUGIN_NAME}/${TUI_SUBPATH}") is missing from tui.json. The Roles · `
|
||||
+ "Models sidebar section and TUI-only commands will not appear.",
|
||||
fix: "Re-run the installer (`npx oh-my-openagent install`) to auto-write tui.json, "
|
||||
+ `or add "${PLUGIN_NAME}/${TUI_SUBPATH}" to the "plugin" array in ${tui.configPath}.`,
|
||||
affects: ["TUI sidebar", "TUI commands"],
|
||||
severity: "warning",
|
||||
})
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: "TUI plugin entry missing from tui.json",
|
||||
details: details.length > 0 ? details : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
if (!server.registered && tui.registered) {
|
||||
issues.push({
|
||||
title: "Server plugin entry missing from opencode.json",
|
||||
description:
|
||||
`The TUI plugin entry ("${PLUGIN_NAME}/${TUI_SUBPATH}") is registered in tui.json, `
|
||||
+ "but the server plugin (oh-my-openagent) is missing from opencode.json. "
|
||||
+ "The plugin cannot function correctly without both halves — the server side "
|
||||
+ "handles tool dispatch, hook execution, and SDK integration.",
|
||||
fix: "Re-run the installer (`npx oh-my-openagent install`) to auto-write opencode.json, "
|
||||
+ `or add "${PLUGIN_NAME}" to the "plugin" array in ${server.configPath ?? "opencode.json"}.`,
|
||||
affects: ["tool dispatch", "hook execution", "SDK integration"],
|
||||
severity: "warning",
|
||||
})
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: "Server plugin entry missing from opencode.json",
|
||||
details: details.length > 0 ? details : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "pass",
|
||||
message: "Server and TUI plugin entries are both registered",
|
||||
details: details.length > 0 ? details : undefined,
|
||||
issues,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const STATUS_COLORS = {
|
||||
export const CHECK_IDS = {
|
||||
SYSTEM: "system",
|
||||
CONFIG: "config",
|
||||
TUI_PLUGIN: "tui-plugin",
|
||||
TOOLS: "tools",
|
||||
MODELS: "models",
|
||||
TEAM_MODE: "team-mode",
|
||||
@@ -29,6 +30,7 @@ export const CHECK_IDS = {
|
||||
export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.SYSTEM]: "System",
|
||||
[CHECK_IDS.CONFIG]: "Configuration",
|
||||
[CHECK_IDS.TUI_PLUGIN]: "TUI Plugin",
|
||||
[CHECK_IDS.TOOLS]: "Tools",
|
||||
[CHECK_IDS.MODELS]: "Models",
|
||||
[CHECK_IDS.TEAM_MODE]: "Team Mode",
|
||||
|
||||
Reference in New Issue
Block a user