From 3341dba46e1dc13561fbb3dde472ddb6141395be Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 9 Mar 2026 13:49:48 +0100 Subject: [PATCH] plugin meta --- packages/opencode/src/cli/cmd/tui/plugin.ts | 34 +++- packages/opencode/src/flag/flag.ts | 12 ++ packages/opencode/src/plugin/meta.ts | 160 ++++++++++++++++++ .../test/cli/tui/plugin-loader.test.ts | 13 ++ packages/opencode/test/plugin/meta.test.ts | 87 ++++++++++ 5 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/plugin/meta.ts create mode 100644 packages/opencode/test/plugin/meta.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts index 17f73bc9a6..d68e1c0202 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin.ts @@ -19,6 +19,7 @@ import { Log } from "@/util/log" import { isRecord } from "@/util/record" import { Instance } from "@/project/instance" import { resolvePluginTarget, uniqueModuleEntries } from "@/plugin/shared" +import { PluginMeta } from "@/plugin/meta" import { addTheme, hasTheme } from "./context/theme" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" @@ -200,6 +201,19 @@ export namespace TuiPlugin { return }) if (!target) return false + const meta = await PluginMeta.touch(spec, target).catch((error) => { + log.warn("failed to track tui plugin", { path: spec, retry, error }) + }) + if (meta && meta.state !== "same") { + log.info("tui plugin metadata updated", { + path: spec, + retry, + state: meta.state, + source: meta.entry.source, + version: meta.entry.version, + modified: meta.entry.modified, + }) + } const root = pluginRoot(spec, target) const install = makeInstallFn(getPluginMeta(config, item), root) @@ -249,15 +263,21 @@ export namespace TuiPlugin { return true } - for (const item of plugins) { - const ok = await loadOne(item) - if (ok) continue + try { + for (const item of plugins) { + const ok = await loadOne(item) + if (ok) continue - const spec = Config.pluginSpecifier(item) - if (!spec.startsWith("file://")) continue + const spec = Config.pluginSpecifier(item) + if (!spec.startsWith("file://")) continue - await wait() - await loadOne(item, true) + await wait() + await loadOne(item, true) + } + } finally { + await PluginMeta.persist().catch((error) => { + log.warn("failed to persist tui plugin metadata", { error }) + }) } }, }).catch((error) => { diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 1ac86b4fbf..b4b955108b 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -14,6 +14,7 @@ export namespace Flag { export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_TUI_CONFIG: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined + export declare const OPENCODE_PLUGIN_META_FILE: string | undefined export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") @@ -106,6 +107,17 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", { configurable: false, }) +// Dynamic getter for OPENCODE_PLUGIN_META_FILE +// This must be evaluated at access time, not module load time, +// because tests and external tooling may set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_PLUGIN_META_FILE", { + get() { + return process.env["OPENCODE_PLUGIN_META_FILE"] + }, + enumerable: true, + configurable: false, +}) + // Dynamic getter for OPENCODE_CLIENT // This must be evaluated at access time, not module load time, // because some commands override the client at runtime diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts new file mode 100644 index 0000000000..ef1712aa95 --- /dev/null +++ b/packages/opencode/src/plugin/meta.ts @@ -0,0 +1,160 @@ +import path from "path" +import { fileURLToPath } from "url" + +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Filesystem } from "@/util/filesystem" + +import { parsePluginSpecifier } from "./shared" + +export namespace PluginMeta { + type Source = "file" | "npm" + + export type Entry = { + name: 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 = "new" | "changed" | "same" + + type Store = Record + type Core = Omit + + const cache = { + ready: false, + path: "", + store: {} as Store, + dirty: false, + } + + function storePath() { + return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json") + } + + function sourceKind(spec: string): Source { + if (spec.startsWith("file://")) return "file" + return "npm" + } + + function entryKey(spec: string) { + if (spec.startsWith("file://")) return `file:${fileURLToPath(spec)}` + return `npm:${parsePluginSpecifier(spec).pkg}` + } + + function entryName(spec: string) { + if (spec.startsWith("file://")) return path.parse(fileURLToPath(spec)).name + return parsePluginSpecifier(spec).pkg + } + + 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(spec: string, target: string): Promise { + const source = sourceKind(spec) + if (source === "file") { + const file = fileTarget(spec, target) + return { + name: entryName(spec), + source, + spec, + target, + modified: file ? modifiedAt(file) : undefined, + } + } + + return { + name: entryName(spec), + 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 load() { + const next = storePath() + if (cache.ready && cache.path === next) return + cache.path = next + cache.store = await Filesystem.readJson(next).catch(() => ({}) as Store) + cache.dirty = false + cache.ready = true + } + + export async function touch(spec: string, target: string): Promise<{ state: State; entry: Entry }> { + await load() + const now = Date.now() + const id = entryKey(spec) + const prev = cache.store[id] + const core = await entryCore(spec, target) + 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 ? "new" : prev.fingerprint === entry.fingerprint ? "same" : "changed" + if (state === "changed") entry.time_changed = now + + cache.store[id] = entry + cache.dirty = true + return { + state, + entry, + } + } + + export async function persist() { + await load() + if (!cache.dirty) return + await Filesystem.writeJson(cache.path, cache.store) + cache.dirty = false + } + + export async function list(): Promise { + await load() + return { ...cache.store } + } +} diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 1e39fc9e05..3d3dbef7dd 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -209,9 +209,11 @@ export const object_plugin = { localMarker, globalMarker, preloadedMarker, + localPluginPath, } }, }) + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) let selected = "opencode" @@ -353,6 +355,16 @@ export const object_plugin = { expect(log).toContain("ignoring non-object tui plugin export") expect(log).toContain("name=default") expect(log).toContain("type=function") + + const meta = JSON.parse(await fs.readFile(path.join(tmp.path, "plugin-meta.json"), "utf8")) as Record< + string, + { spec: string; source: string; load_count: number } + > + const localSpec = pathToFileURL(tmp.extra.localPluginPath).href + const localRow = Object.values(meta).find((item) => item.spec === localSpec) + expect(localRow).toBeDefined() + expect(localRow?.source).toBe("file") + expect((localRow?.load_count ?? 0) > 0).toBe(true) } finally { cwd.mockRestore() if (backup === undefined) { @@ -361,5 +373,6 @@ export const object_plugin = { await Bun.write(globalConfigPath, backup) } await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {}) + delete process.env.OPENCODE_PLUGIN_META_FILE } }) diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts new file mode 100644 index 0000000000..441c62117b --- /dev/null +++ b/packages/opencode/test/plugin/meta.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" + +import { tmpdir } from "../fixture/fixture" + +const { PluginMeta } = await import("../../src/plugin/meta") + +afterEach(() => { + delete process.env.OPENCODE_PLUGIN_META_FILE +}) + +describe("plugin.meta", () => { + test("tracks file plugin loads and changes", async () => { + await using tmp = await tmpdir<{ file: string }>({ + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + await Bun.write(file, "export default async () => ({})\n") + return { file } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json") + const file = process.env.OPENCODE_PLUGIN_META_FILE! + const spec = pathToFileURL(tmp.extra.file).href + + const one = await PluginMeta.touch(spec, spec) + expect(one.state).toBe("new") + expect(one.entry.source).toBe("file") + expect(one.entry.modified).toBeDefined() + + const two = await PluginMeta.touch(spec, spec) + expect(two.state).toBe("same") + expect(two.entry.load_count).toBe(2) + + await Bun.sleep(20) + await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n") + + const three = await PluginMeta.touch(spec, spec) + expect(three.state).toBe("changed") + expect(three.entry.load_count).toBe(3) + expect((three.entry.modified ?? 0) >= (one.entry.modified ?? 0)).toBe(true) + + await expect(fs.readFile(file, "utf8")).rejects.toThrow() + await PluginMeta.persist() + + const all = await PluginMeta.list() + expect(Object.values(all).some((item) => item.spec === spec && item.source === "file")).toBe(true) + const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record + expect(Object.values(saved).some((item) => item.spec === spec && item.load_count === 3)).toBe(true) + }) + + test("tracks npm plugin versions", async () => { + await using tmp = await tmpdir<{ mod: string; pkg: string }>({ + init: async (dir) => { + const mod = path.join(dir, "node_modules", "acme-plugin") + const pkg = path.join(mod, "package.json") + await fs.mkdir(mod, { recursive: true }) + await Bun.write(pkg, JSON.stringify({ name: "acme-plugin", version: "1.0.0" }, null, 2)) + return { mod, pkg } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "state", "plugin-meta.json") + const file = process.env.OPENCODE_PLUGIN_META_FILE! + + const one = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod) + expect(one.state).toBe("new") + expect(one.entry.source).toBe("npm") + expect(one.entry.requested).toBe("latest") + expect(one.entry.version).toBe("1.0.0") + + await Bun.write(tmp.extra.pkg, JSON.stringify({ name: "acme-plugin", version: "1.1.0" }, null, 2)) + + const two = await PluginMeta.touch("acme-plugin@latest", tmp.extra.mod) + expect(two.state).toBe("changed") + expect(two.entry.version).toBe("1.1.0") + expect(two.entry.load_count).toBe(2) + await PluginMeta.persist() + + const all = await PluginMeta.list() + expect(Object.values(all).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true) + const saved = JSON.parse(await fs.readFile(file, "utf8")) as Record + expect(Object.values(saved).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true) + }) +})