diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts index 44d918afeb..4a3aa7261d 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin.ts @@ -12,8 +12,9 @@ import "@opentui/solid/preload" import { Config } from "@/config/config" import { TuiConfig } from "@/config/tui" import { Log } from "@/util/log" -import { BunProc } from "@/bun" +import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" +import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared" import { registerThemes } from "./context/theme" type Slot = (props: { name: K } & TuiSlotMap[K]) => JSX.Element | null @@ -22,12 +23,6 @@ function empty(_props: { name: K } & TuiSlotMap[K]) return null } -function isRecord(value: unknown): value is Record { - if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return false - return true -} - function isTuiSlotPlugin(value: unknown): value is SolidPlugin { if (!isRecord(value)) return false if (typeof value.id !== "string") return false @@ -99,14 +94,6 @@ export namespace TuiPlugin { return loaded } - async function resolve(spec: string) { - if (spec.startsWith("file://")) return spec - const lastAtIndex = spec.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec - const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest" - return BunProc.install(pkg, version) - } - async function load(input: TuiPluginInput) { const dir = process.cwd() @@ -120,7 +107,7 @@ export namespace TuiPlugin { for (const item of plugins) { const spec = Config.pluginSpecifier(item) log.info("loading tui plugin", { path: spec }) - const target = await resolve(spec).catch((error) => { + const target = await resolvePluginTarget(spec).catch((error) => { log.error("failed to resolve tui plugin", { path: spec, error }) return }) @@ -132,10 +119,7 @@ export namespace TuiPlugin { }) if (!mod) continue - const seen = new Set() - for (const [name, entry] of Object.entries(mod)) { - if (seen.has(entry)) continue - seen.add(entry) + for (const [name, entry] of uniqueModuleEntries(mod)) { if (!entry || typeof entry !== "object") { log.warn("ignoring non-object tui plugin export", { path: spec, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a10ee0d2ab..a795ca477c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -32,6 +32,7 @@ import { Glob } from "../util/glob" import { PackageRegistry } from "@/bun/registry" import { proxied } from "@/util/proxied" import { iife } from "@/util/iife" +import { isRecord } from "@/util/record" import { Control } from "@/control" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" @@ -1324,10 +1325,6 @@ export namespace Config { return candidates[0] } - function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value) - } - function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { const edits = modify(input, path, patch, { diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 5e8c212a18..596673cb43 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -8,6 +8,7 @@ import { TuiInfo } from "./tui-schema" import { Instance } from "@/project/instance" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" +import { isRecord } from "@/util/record" import { Global } from "@/global" export namespace TuiConfig { @@ -100,12 +101,6 @@ export namespace TuiConfig { await Promise.all(deps) } - function isRecord(value: unknown): value is Record { - if (!value || typeof value !== "object") return false - if (Array.isArray(value)) return false - return true - } - async function loadFile(filepath: string): Promise { const text = await ConfigPaths.readFile(filepath) if (!text) return {} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index fe8e01e1a2..69c3e39754 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -4,13 +4,13 @@ import { Bus } from "../bus" import { Log } from "../util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Server } from "../server/server" -import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" import { CopilotAuthPlugin } from "./copilot" +import { parsePluginSpecifier, resolvePluginTarget, uniqueModuleEntries } from "./shared" import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth" export namespace Plugin { @@ -55,25 +55,22 @@ export namespace Plugin { } async function resolve(spec: string) { - if (spec.startsWith("file://")) return spec - const lastAtIndex = spec.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec - const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest" - const builtIn = BUILTIN.some((x) => x.startsWith(pkg + "@")) - const installed = await BunProc.install(pkg, version).catch((err) => { + const parsed = parsePluginSpecifier(spec) + const builtIn = BUILTIN.some((x) => x.startsWith(parsed.pkg + "@")) + const target = await resolvePluginTarget(spec, parsed).catch((err) => { const cause = err instanceof Error ? err.cause : err const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { pkg, version, error: detail }) + log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail }) const label = builtIn ? "built-in plugin" : "plugin" Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to install ${label} ${pkg}@${version}: ${detail}`, + message: `Failed to install ${label} ${parsed.pkg}@${parsed.version}: ${detail}`, }).toObject(), }) return "" }) - if (!installed) return - return installed + if (!target) return + return target } function isServerPlugin(value: unknown): value is PluginInstance { @@ -108,11 +105,8 @@ export namespace Plugin { // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). - // Object.entries(mod) would return both entries pointing to the same function reference. - const seen = new Set() - for (const entry of Object.values(mod)) { - if (seen.has(entry)) continue - seen.add(entry) + // uniqueModuleEntries keeps only the first export for each shared value reference. + for (const [, entry] of uniqueModuleEntries(mod)) { const server = getServerPlugin(entry) if (!server) continue const init = await server(input, Config.pluginOptions(item)).catch((err) => { diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts new file mode 100644 index 0000000000..a9af073961 --- /dev/null +++ b/packages/opencode/src/plugin/shared.ts @@ -0,0 +1,26 @@ +import { BunProc } from "@/bun" + +export function parsePluginSpecifier(spec: string) { + const at = spec.lastIndexOf("@") + const pkg = at > 0 ? spec.substring(0, at) : spec + const version = at > 0 ? spec.substring(at + 1) : "latest" + return { pkg, version } +} + +export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { + if (spec.startsWith("file://")) return spec + return BunProc.install(parsed.pkg, parsed.version) +} + +export function uniqueModuleEntries(mod: Record) { + const seen = new Set() + const entries: [string, unknown][] = [] + + for (const [name, entry] of Object.entries(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + entries.push([name, entry]) + } + + return entries +} diff --git a/packages/opencode/src/util/record.ts b/packages/opencode/src/util/record.ts new file mode 100644 index 0000000000..495927463b --- /dev/null +++ b/packages/opencode/src/util/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +}