plugin meta

actual-tui-plugins
Sebastian Herrlinger 2026-03-09 13:49:48 +01:00
parent df44d87bf4
commit 3341dba46e
5 changed files with 299 additions and 7 deletions

View File

@ -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) => {

View File

@ -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

View File

@ -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<string, Entry>
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint">
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<Core> {
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<Store>(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<Store> {
await load()
return { ...cache.store }
}
}

View File

@ -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
}
})

View File

@ -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<string, { spec: string; load_count: number }>
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<string, { name: string; version?: string }>
expect(Object.values(saved).some((item) => item.name === "acme-plugin" && item.version === "1.1.0")).toBe(true)
})
})