import path from "path" import { fileURLToPath, pathToFileURL } from "url" 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, kind: PluginKind) { if (!isRecord(json.exports)) return false return `./${kind}` in json.exports } function readEntrypointName(source: PluginSource, spec: string, json: Record) { if (source === "npm") return parsePluginSpecifier(spec).pkg if (typeof json.name !== "string") return const value = json.name.trim() if (!value) return return value } function readEntrypointPath(file: string) { if (file.startsWith("file://")) return fileURLToPath(file) if (path.isAbsolute(file) || /^[A-Za-z]:[\\/]/.test(file)) return file throw new TypeError(`Plugin entry "${file}" is not a file path`) } export async function resolvePluginEntrypoint(spec: string, target: string, kind: PluginKind) { const source = pluginSource(spec) const pkg = await readPluginPackage(target).catch(() => undefined) if (!pkg) return target if (!hasEntrypoint(pkg.json, kind)) return target const name = readEntrypointName(source, spec, pkg.json) if (!name) { throw new TypeError(`Plugin package ${pkg.pkg} must define package.json name to export ./${kind}`) } const ref = pathToFileURL(pkg.pkg).href const entry = import.meta.resolve!(`${name}/${kind}`, ref) const root = Filesystem.resolve(pkg.dir) const next = Filesystem.resolve(readEntrypointPath(entry)) 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>(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 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>(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) { // 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 }