tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions

View File

@@ -1,9 +1,8 @@
import type { Hooks, PluginInput, Plugin as PluginInstance } from "@opencode-ai/plugin"
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { BunProc } from "../bun"
import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
@@ -14,6 +13,17 @@ import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { Installation } from "@/installation"
import {
checkPluginCompatibility,
getDefaultPlugin,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
resolvePluginEntrypoint,
resolvePluginTarget,
} from "./shared"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -22,6 +32,12 @@ export namespace Plugin {
hooks: Hooks[]
}
type Loaded = {
item: Config.PluginSpec
spec: string
mod: Record<string, unknown>
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -46,8 +62,115 @@ export namespace Plugin {
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
// Old npm package names for plugins that are now built-in — skip if users still have them in config
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
}
function getServerPlugin(value: unknown) {
if (isServerPlugin(value)) return value
if (!value || typeof value !== "object" || !("server" in value)) return
if (!isServerPlugin(value.server)) return
return value.server
}
function getLegacyPlugins(mod: Record<string, unknown>) {
const seen = new Set<unknown>()
const result: PluginInstance[] = []
for (const entry of Object.values(mod)) {
if (seen.has(entry)) continue
seen.add(entry)
const plugin = getServerPlugin(entry)
if (!plugin) throw new TypeError("Plugin export is not a function")
result.push(plugin)
}
return result
}
async function resolvePlugin(spec: string) {
const parsed = parsePluginSpecifier(spec)
const target = await resolvePluginTarget(spec, parsed).catch((err) => {
const cause = err instanceof Error ? err.cause : err
const detail = errorMessage(cause ?? err)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: detail })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!target) return
return target
}
async function prepPlugin(item: Config.PluginSpec): Promise<Loaded | undefined> {
const spec = Config.pluginSpecifier(item)
if (isDeprecatedPlugin(spec)) return
log.info("loading plugin", { path: spec })
const resolved = await resolvePlugin(spec)
if (!resolved) return
if (pluginSource(spec) === "npm") {
const incompatible = await checkPluginCompatibility(resolved, Installation.VERSION)
.then(() => false)
.catch((err) => {
const message = errorMessage(err)
log.warn("plugin incompatible", { path: spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${spec} skipped: ${message}`,
}).toObject(),
})
return true
})
if (incompatible) return
}
const target = resolved
const entry = await resolvePluginEntrypoint(spec, target, "server").catch((err) => {
const message = errorMessage(err)
log.error("failed to resolve plugin server entry", { path: spec, target, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!entry) return
const mod = await import(entry).catch((err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: spec, target: entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
}).toObject(),
})
return
})
if (!mod) return
return {
item,
spec,
mod,
}
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = getDefaultPlugin(load.mod) as PluginModule | undefined
if (plugin?.server) {
hooks.push(await plugin.server(input, Config.pluginOptions(load.item)))
return
}
for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, Config.pluginOptions(load.item)))
}
}
export const layer = Layer.effect(
Service,
@@ -91,51 +214,27 @@ export namespace Plugin {
if (init) hooks.push(init)
}
let plugins = cfg.plugin ?? []
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
}
if (plugins.length) await Config.waitForDependencies()
for (let plugin of plugins) {
if (DEPRECATED_PLUGIN_PACKAGES.some((pkg) => plugin.includes(pkg))) continue
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const idx = plugin.lastIndexOf("@")
const pkg = idx > 0 ? plugin.substring(0, idx) : plugin
const version = idx > 0 ? plugin.substring(idx + 1) : "latest"
plugin = await BunProc.install(pkg, version).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 })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
}).toObject(),
})
return ""
})
if (!plugin) continue
}
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
for (const load of loaded) {
if (!load) continue
// 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.
await import(plugin)
.then(async (mod) => {
const seen = new Set<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: plugin, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plugin}: ${message}`,
}).toObject(),
})
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
await applyPlugin(load, input, hooks).catch((err) => {
const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
})
})
}
// Notify plugins of current config

View File

@@ -0,0 +1,351 @@
import path from "path"
import {
type ParseError as JsoncParseError,
applyEdits,
modify,
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { ConfigPaths } from "@/config/paths"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui"
export type Target = {
kind: Kind
opts?: Record<string, unknown>
}
export type InstallDeps = {
resolve: (spec: string) => Promise<string>
}
export type PatchDeps = {
readText: (file: string) => Promise<string>
write: (file: string, text: string) => Promise<void>
exists: (file: string) => Promise<boolean>
files: (dir: string, name: "opencode" | "tui") => string[]
}
export type PatchInput = {
spec: string
targets: Target[]
force?: boolean
global?: boolean
vcs?: string
worktree: string
directory: string
config?: string
}
type Ok<T> = {
ok: true
} & T
type Err<C extends string, T> = {
ok: false
code: C
} & T
export type InstallResult = Ok<{ target: string }> | Err<"install_failed", { error: unknown }>
export type ManifestResult =
| Ok<{ targets: Target[] }>
| Err<"manifest_read_failed", { file: string; error: unknown }>
| Err<"manifest_no_targets", { file: string }>
export type PatchItem = {
kind: Kind
mode: Mode
file: string
}
type PatchErr =
| Err<"invalid_json", { kind: Kind; file: string; line: number; col: number; parse: string }>
| Err<"patch_failed", { kind: Kind; error: unknown }>
type PatchOne = Ok<{ item: PatchItem }> | PatchErr
export type PatchResult = Ok<{ dir: string; items: PatchItem[] }> | (PatchErr & { dir: string })
const defaultInstallDeps: InstallDeps = {
resolve: (spec) => resolvePluginTarget(spec),
}
const defaultPatchDeps: PatchDeps = {
readText: (file) => Filesystem.readText(file),
write: async (file, text) => {
await Filesystem.write(file, text)
},
exists: (file) => Filesystem.exists(file),
files: (dir, name) => ConfigPaths.fileInDirectory(dir, name),
}
function pluginSpec(item: unknown) {
if (typeof item === "string") return item
if (!Array.isArray(item)) return
if (typeof item[0] !== "string") return
return item[0]
}
function parseTarget(item: unknown): Target | undefined {
if (item === "server" || item === "tui") return { kind: item }
if (!Array.isArray(item)) return
if (item[0] !== "server" && item[0] !== "tui") return
if (item.length < 2) return { kind: item[0] }
const opt = item[1]
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
return {
kind: item[0],
opts: opt,
}
}
function parseTargets(raw: unknown) {
if (!Array.isArray(raw)) return []
const map = new Map<Kind, Target>()
for (const item of raw) {
const hit = parseTarget(item)
if (!hit) continue
map.set(hit.kind, hit)
}
return [...map.values()]
}
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
const pkg = parsePluginSpecifier(spec).pkg
const rows = list.map((item, i) => ({
item,
i,
spec: pluginSpec(item),
}))
const dup = rows.filter((item) => {
if (!item.spec) return false
if (item.spec === spec) return true
if (item.spec.startsWith("file://")) return false
return parsePluginSpecifier(item.spec).pkg === pkg
})
if (!dup.length) {
return {
mode: "add",
list: [...list, next],
}
}
if (!force) {
return {
mode: "noop",
list,
}
}
const keep = dup[0]
if (!keep) {
return {
mode: "noop",
list,
}
}
if (dup.length === 1 && keep.spec === spec) {
return {
mode: "noop",
list,
}
}
const idx = new Set(dup.map((item) => item.i))
return {
mode: "replace",
list: rows.flatMap((row) => {
if (!idx.has(row.i)) return [row.item]
if (row.i !== keep.i) return []
if (typeof row.item === "string") return [next]
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
return [[spec, ...row.item.slice(1)]]
}
return [row.item]
}),
}
}
export async function installPlugin(spec: string, dep: InstallDeps = defaultInstallDeps): Promise<InstallResult> {
const target = await dep.resolve(spec).then(
(item) => ({
ok: true as const,
item,
}),
(error: unknown) => ({
ok: false as const,
error,
}),
)
if (!target.ok) {
return {
ok: false,
code: "install_failed",
error: target.error,
}
}
return {
ok: true,
target: target.item,
}
}
export async function readPluginManifest(target: string): Promise<ManifestResult> {
const pkg = await readPluginPackage(target).then(
(item) => ({
ok: true as const,
item,
}),
(error: unknown) => ({
ok: false as const,
error,
}),
)
if (!pkg.ok) {
return {
ok: false,
code: "manifest_read_failed",
file: target,
error: pkg.error,
}
}
const targets = parseTargets(pkg.item.json["oc-plugin"])
if (!targets.length) {
return {
ok: false,
code: "manifest_no_targets",
file: pkg.item.pkg,
}
}
return {
ok: true,
targets,
}
}
function patchDir(input: PatchInput) {
if (input.global) return input.config ?? Global.Path.config
const git = input.vcs === "git" && input.worktree !== "/"
const root = git ? input.worktree : input.directory
return path.join(root, ".opencode")
}
function patchName(kind: Kind): "opencode" | "tui" {
if (kind === "server") return "opencode"
return "tui"
}
async function patchOne(dir: string, target: Target, spec: string, force: boolean, dep: PatchDeps): Promise<PatchOne> {
const name = patchName(target.kind)
await using _ = await Flock.acquire(`plug-config:${Filesystem.resolve(path.join(dir, name))}`)
const files = dep.files(dir, name)
let cfg = files[0]
for (const file of files) {
if (!(await dep.exists(file))) continue
cfg = file
break
}
const src = await dep.readText(cfg).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return "{}"
return err
})
if (src instanceof Error) {
return {
ok: false,
code: "patch_failed",
kind: target.kind,
error: src,
}
}
const text = src.trim() ? src : "{}"
const errs: JsoncParseError[] = []
const data = parseJsonc(text, errs, { allowTrailingComma: true })
if (errs.length) {
const err = errs[0]
const lines = text.substring(0, err.offset).split("\n")
return {
ok: false,
code: "invalid_json",
kind: target.kind,
file: cfg,
line: lines.length,
col: lines[lines.length - 1].length + 1,
parse: printParseErrorCode(err.error),
}
}
const list: unknown[] =
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
const item = target.opts ? [spec, target.opts] : spec
const out = patchPluginList(list, spec, item, force)
if (out.mode === "noop") {
return {
ok: true,
item: {
kind: target.kind,
mode: out.mode,
file: cfg,
},
}
}
const edits = modify(text, ["plugin"], out.list, {
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
})
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
if (write instanceof Error) {
return {
ok: false,
code: "patch_failed",
kind: target.kind,
error: write,
}
}
return {
ok: true,
item: {
kind: target.kind,
mode: out.mode,
file: cfg,
},
}
}
export async function patchPluginConfig(input: PatchInput, dep: PatchDeps = defaultPatchDeps): Promise<PatchResult> {
const dir = patchDir(input)
const items: PatchItem[] = []
for (const target of input.targets) {
const hit = await patchOne(dir, target, input.spec, Boolean(input.force), dep)
if (!hit.ok) {
return {
...hit,
dir,
}
}
items.push(hit.item)
}
return {
ok: true,
dir,
items,
}
}

View File

@@ -0,0 +1,165 @@
import path from "path"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { parsePluginSpecifier, pluginSource } from "./shared"
export namespace PluginMeta {
type Source = "file" | "npm"
export type Entry = {
id: string
source: Source
spec: string
target: string
requested?: string
version?: string
modified?: number
first_time: number
last_time: number
time_changed: number
load_count: number
fingerprint: string
}
export type State = "first" | "updated" | "same"
export type Touch = {
spec: string
target: string
id: string
}
type Store = Record<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
type Row = Touch & { core: Core }
function storePath() {
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
}
function lock(file: string) {
return `plugin-meta:${file}`
}
function fileTarget(spec: string, target: string) {
if (spec.startsWith("file://")) return fileURLToPath(spec)
if (target.startsWith("file://")) return fileURLToPath(target)
return
}
function modifiedAt(file: string) {
const stat = Filesystem.stat(file)
if (!stat) return
const value = stat.mtimeMs
return Math.floor(typeof value === "bigint" ? Number(value) : value)
}
function resolvedTarget(target: string) {
if (target.startsWith("file://")) return fileURLToPath(target)
return target
}
async function npmVersion(target: string) {
const resolved = resolvedTarget(target)
const stat = Filesystem.stat(resolved)
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
.then((item) => item.version)
.catch(() => undefined)
}
async function entryCore(item: Touch): Promise<Core> {
const spec = item.spec
const target = item.target
const source = pluginSource(spec)
if (source === "file") {
const file = fileTarget(spec, target)
return {
id: item.id,
source,
spec,
target,
modified: file ? modifiedAt(file) : undefined,
}
}
return {
id: item.id,
source,
spec,
target,
requested: parsePluginSpecifier(spec).version,
version: await npmVersion(target),
}
}
function fingerprint(value: Core) {
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
}
async function read(file: string): Promise<Store> {
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
}
async function row(item: Touch): Promise<Row> {
return {
...item,
core: await entryCore(item),
}
}
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
const entry: Entry = {
...core,
first_time: prev?.first_time ?? now,
last_time: now,
time_changed: prev?.time_changed ?? now,
load_count: (prev?.load_count ?? 0) + 1,
fingerprint: fingerprint(core),
}
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
if (state === "updated") entry.time_changed = now
return {
state,
entry,
}
}
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
if (!items.length) return []
const file = storePath()
const rows = await Promise.all(items.map((item) => row(item)))
return Flock.withLock(lock(file), async () => {
const store = await read(file)
const now = Date.now()
const out: Array<{ state: State; entry: Entry }> = []
for (const item of rows) {
const hit = next(store[item.id], item.core, now)
store[item.id] = hit.entry
out.push(hit)
}
await Filesystem.writeJson(file, store)
return out
})
}
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
return touchMany([{ spec, target, id }]).then((item) => {
const hit = item[0]
if (hit) return hit
throw new Error("Failed to touch plugin metadata.")
})
}
export async function list(): Promise<Store> {
const file = storePath()
return Flock.withLock(lock(file), async () => read(file))
}
}

View File

@@ -0,0 +1,149 @@
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import semver from "semver"
import { BunProc } from "@/bun"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
// Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
export function isDeprecatedPlugin(spec: string) {
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
}
export function parsePluginSpecifier(spec: string) {
const lastAt = spec.lastIndexOf("@")
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
return { pkg, version }
}
export type PluginSource = "file" | "npm"
export type PluginKind = "server" | "tui"
export function pluginSource(spec: string): PluginSource {
return spec.startsWith("file://") ? "file" : "npm"
}
function hasEntrypoint(json: Record<string, unknown>, kind: PluginKind) {
if (!isRecord(json.exports)) return false
return `./${kind}` in json.exports
}
function resolveExportPath(raw: string, dir: string) {
if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
if (raw.startsWith("file://")) return fileURLToPath(raw)
return raw
}
function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value
if (!isRecord(value)) return undefined
for (const key of ["import", "default"]) {
const nested = value[key]
if (typeof nested === "string") return nested
}
return undefined
}
export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) {
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return target
if (!hasEntrypoint(pkg.json, kind)) return target
const exports = pkg.json.exports
if (!isRecord(exports)) return target
const raw = extractExportValue(exports[`./${kind}`])
if (!raw) return target
const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
}
return pathToFileURL(next).href
}
export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec)
}
export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
const stat = await Filesystem.stat(file)
if (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href
}
const pkg = await Filesystem.readJson<Record<string, unknown>>(path.join(file, "package.json")).catch(() => undefined)
if (!pkg) throw new Error(`Plugin directory ${file} is missing package.json`)
if (typeof pkg.main !== "string" || !pkg.main.trim()) {
throw new Error(`Plugin directory ${file} must define package.json main`)
}
return pathToFileURL(path.resolve(file, pkg.main)).href
}
export async function checkPluginCompatibility(target: string, opencodeVersion: string) {
if (!semver.valid(opencodeVersion) || semver.major(opencodeVersion) === 0) return
const pkg = await readPluginPackage(target).catch(() => undefined)
if (!pkg) return
const engines = pkg.json.engines
if (!isRecord(engines)) return
const range = engines.opencode
if (typeof range !== "string") return
if (!semver.satisfies(opencodeVersion, range)) {
throw new Error(`Plugin requires opencode ${range} but running ${opencodeVersion}`)
}
}
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
return BunProc.install(parsed.pkg, parsed.version)
}
export async function readPluginPackage(target: string) {
const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.stat(file)
const dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
return { dir, pkg, json }
}
export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
const value = id.trim()
if (!value) throw new TypeError(`Plugin ${spec} has an empty id`)
return value
}
export async function resolvePluginId(source: PluginSource, spec: string, target: string, id: string | undefined) {
if (source === "file") {
if (id) return id
throw new TypeError(`Path plugin ${spec} must export id`)
}
if (id) return id
const pkg = await readPluginPackage(target)
if (typeof pkg.json.name !== "string" || !pkg.json.name.trim()) {
throw new TypeError(`Plugin package ${pkg.pkg} is missing name`)
}
return pkg.json.name.trim()
}
export function getDefaultPlugin(mod: Record<string, unknown>) {
// A single default object keeps v1 detection explicit and avoids scanning exports.
const value = mod.default
if (!isRecord(value)) return
const server = "server" in value ? value.server : undefined
const tui = "tui" in value ? value.tui : undefined
if (server !== undefined && typeof server !== "function") return
if (tui !== undefined && typeof tui !== "function") return
if (server === undefined && tui === undefined) return
return value
}