tui plugins (#19347)
This commit is contained in:
@@ -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
|
||||
|
||||
351
packages/opencode/src/plugin/install.ts
Normal file
351
packages/opencode/src/plugin/install.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
165
packages/opencode/src/plugin/meta.ts
Normal file
165
packages/opencode/src/plugin/meta.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
149
packages/opencode/src/plugin/shared.ts
Normal file
149
packages/opencode/src/plugin/shared.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user