瀏覽代碼

plugin meta

Sebastian Herrlinger 1 月之前
父節點
當前提交
3341dba46e

+ 27 - 7
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) => {

+ 12 - 0
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

+ 160 - 0
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<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 }
+  }
+}

+ 13 - 0
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
   }
 })

+ 87 - 0
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<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)
+  })
+})