Procházet zdrojové kódy

warn only and ignore plugins without entrypoints, default config via exports (#20284)

Sebastian před 2 týdny
rodič
revize
25a2b739e6

+ 19 - 9
packages/opencode/specs/tui-plugins.md

@@ -88,6 +88,7 @@ export default plugin
 - If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
 - For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
 - `package.json` `main` is only used for server plugin entrypoint resolution.
+- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
 - If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
 - File/path plugins must export a non-empty `id`.
 - npm plugins may omit `id`; package `name` is used.
@@ -100,7 +101,10 @@ export default plugin
 
 ## Package manifest and install
 
-Package manifest is read from `package.json` field `oc-plugin`.
+Install target detection is inferred from `package.json` entrypoints:
+
+- `server` target when `exports["./server"]` exists or `main` is set.
+- `tui` target when `exports["./tui"]` exists.
 
 Example:
 
@@ -108,14 +112,20 @@ Example:
 {
   "name": "@acme/opencode-plugin",
   "type": "module",
-  "main": "./dist/index.js",
+  "main": "./dist/server.js",
+  "exports": {
+    "./server": {
+      "import": "./dist/server.js",
+      "config": { "custom": true }
+    },
+    "./tui": {
+      "import": "./dist/tui.js",
+      "config": { "compact": true }
+    }
+  },
   "engines": {
     "opencode": "^1.0.0"
-  },
-  "oc-plugin": [
-    ["server", { "custom": true }],
-    ["tui", { "compact": true }]
-  ]
+  }
 }
 ```
 
@@ -144,11 +154,12 @@ npm plugins can declare a version compatibility range in `package.json` using th
 - Local installs resolve target dir inside `patchPluginConfig`.
 - For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
 - Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
-- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
+- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
 - `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
 - `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
 - `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
 - npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
+- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
 - Without `--force`, an already-configured npm package name is a no-op.
 - With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
 - Explicit npm specs with a version suffix (for example `[email protected]`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
@@ -320,7 +331,6 @@ Slot notes:
 - `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
 - `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
 - `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
-- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
 - If activation fails, the plugin can remain `enabled=true` and `active=false`.
 - `api.lifecycle.signal` is aborted before cleanup runs.
 - `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.

+ 2 - 2
packages/opencode/src/cli/cmd/plug.ts

@@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
 
       if (manifest.code === "manifest_no_targets") {
         inspect.stop("No plugin targets found", 1)
-        dep.log.error(`"${mod}" does not declare supported targets in package.json`)
-        dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
+        dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
+        dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
         return false
       }
 

+ 15 - 2
packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

@@ -87,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
   console.error(`[tui.plugin] ${text}`, next)
 }
 
+function warn(message: string, data: Record<string, unknown>) {
+  log.warn(message, data)
+  console.warn(`[tui.plugin] ${message}`, data)
+}
+
 type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
 
 function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -229,6 +234,15 @@ async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): P
   log.info("loading tui plugin", { path: plan.spec, retry })
   const resolved = await PluginLoader.resolve(plan, "tui")
   if (!resolved.ok) {
+    if (resolved.stage === "missing") {
+      warn("tui plugin has no entrypoint", {
+        path: plan.spec,
+        retry,
+        message: resolved.message,
+      })
+      return
+    }
+
     if (resolved.stage === "install") {
       fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
       return
@@ -753,7 +767,6 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
     return [] as PluginLoad[]
   })
   if (!ready.length) {
-    fail("failed to add tui plugin", { path: next })
     return false
   }
 
@@ -824,7 +837,7 @@ async function installPluginBySpec(
     if (manifest.code === "manifest_no_targets") {
       return {
         ok: false,
-        message: `"${spec}" does not declare supported targets in package.json`,
+        message: `"${spec}" does not expose plugin entrypoints in package.json`,
       }
     }
 

+ 4 - 1
packages/opencode/src/config/config.ts

@@ -121,7 +121,10 @@ export namespace Config {
     const gitignore = path.join(dir, ".gitignore")
     const ignore = await Filesystem.exists(gitignore)
     if (!ignore) {
-      await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
+      await Filesystem.write(
+        gitignore,
+        ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
+      )
     }
 
     // Bun can race cache writes on Windows when installs run in parallel across dirs.

+ 8 - 0
packages/opencode/src/plugin/index.ts

@@ -157,6 +157,14 @@ export namespace Plugin {
 
                 const resolved = await PluginLoader.resolve(plan, "server")
                 if (!resolved.ok) {
+                  if (resolved.stage === "missing") {
+                    log.warn("plugin has no server entrypoint", {
+                      path: plan.spec,
+                      message: resolved.message,
+                    })
+                    return
+                  }
+
                   const cause =
                     resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
                   const message = errorMessage(cause)

+ 52 - 19
packages/opencode/src/plugin/install.ts

@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { Flock } from "@/util/flock"
+import { isRecord } from "@/util/record"
 
 import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
 
@@ -101,28 +102,60 @@ function pluginList(data: unknown) {
   return item.plugin
 }
 
-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] }
+function exportValue(value: unknown): string | undefined {
+  if (typeof value === "string") {
+    const next = value.trim()
+    if (next) return next
+    return
+  }
+  if (!isRecord(value)) return
+  for (const key of ["import", "default"]) {
+    const next = value[key]
+    if (typeof next !== "string") continue
+    const hit = next.trim()
+    if (!hit) continue
+    return hit
+  }
+}
+
+function exportOptions(value: unknown): Record<string, unknown> | undefined {
+  if (!isRecord(value)) return
+  const config = value.config
+  if (!isRecord(config)) return
+  return config
+}
+
+function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
+  const exports = pkg.exports
+  if (!isRecord(exports)) return
+  const value = exports[`./${kind}`]
+  const entry = exportValue(value)
+  if (!entry) return
   return {
-    kind: item[0],
-    opts: opt,
+    opts: exportOptions(value),
   }
 }
 
-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)
+function hasMainTarget(pkg: Record<string, unknown>) {
+  const main = pkg.main
+  if (typeof main !== "string") return false
+  return Boolean(main.trim())
+}
+
+function packageTargets(pkg: Record<string, unknown>) {
+  const targets: Target[] = []
+  const server = exportTarget(pkg, "server")
+  if (server) {
+    targets.push({ kind: "server", opts: server.opts })
+  } else if (hasMainTarget(pkg)) {
+    targets.push({ kind: "server" })
+  }
+
+  const tui = exportTarget(pkg, "tui")
+  if (tui) {
+    targets.push({ kind: "tui", opts: tui.opts })
   }
-  return [...map.values()]
+  return targets
 }
 
 function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
@@ -260,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
     }
   }
 
-  const targets = parseTargets(pkg.item.json["oc-plugin"])
+  const targets = packageTargets(pkg.item.json)
   if (!targets.length) {
     return {
       ok: false,
@@ -330,7 +363,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
   }
 
   const list = pluginList(data)
-  const item = target.opts ? [spec, target.opts] : spec
+  const item = target.opts ? ([spec, target.opts] as const) : spec
   const out = patchPluginList(text, list, spec, item, force)
   if (out.mode === "noop") {
     return {

+ 5 - 3
packages/opencode/src/plugin/loader.ts

@@ -43,7 +43,9 @@ export namespace PluginLoader {
     plan: Plan,
     kind: PluginKind,
   ): Promise<
-    { ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
+    | { ok: true; value: Resolved }
+    | { ok: false; stage: "missing"; message: string }
+    | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
   > {
     let target = ""
     try {
@@ -77,8 +79,8 @@ export namespace PluginLoader {
     if (!base.entry) {
       return {
         ok: false,
-        stage: "entry",
-        error: new Error(`Plugin ${plan.spec} entry is empty`),
+        stage: "missing",
+        message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
       }
     }
 

+ 4 - 9
packages/opencode/src/plugin/shared.ts

@@ -34,7 +34,7 @@ export type PluginEntry = {
   source: PluginSource
   target: string
   pkg?: PluginPackage
-  entry: string
+  entry?: string
 }
 
 const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
@@ -128,13 +128,8 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
       if (index) return pathToFileURL(index).href
     }
 
-    if (source === "npm") {
-      throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
-    }
-
-    if (dir) {
-      throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
-    }
+    if (source === "npm") return
+    if (dir) return
 
     return target
   }
@@ -145,7 +140,7 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
       if (index) return pathToFileURL(index).href
     }
 
-    throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
+    return
   }
 
   return target

+ 7 - 14
packages/opencode/test/cli/tui/plugin-install.test.ts

@@ -21,8 +21,12 @@ test("installs plugin without loading it", async () => {
           {
             name: "demo-install-plugin",
             type: "module",
-            main: "./install-plugin.ts",
-            "oc-plugin": [["tui", { marker }]],
+            exports: {
+              "./tui": {
+                import: "./install-plugin.ts",
+                config: { marker },
+              },
+            },
           },
           null,
           2,
@@ -46,7 +50,7 @@ test("installs plugin without loading it", async () => {
   })
 
   process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
-  let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
+  const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
     plugin: [],
     plugin_records: undefined,
   }
@@ -66,17 +70,6 @@ test("installs plugin without loading it", async () => {
 
   try {
     await TuiPluginRuntime.init(api)
-    cfg = {
-      plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
-      plugin_records: [
-        {
-          item: [tmp.extra.spec, { marker: tmp.extra.marker }],
-          scope: "local",
-          source: path.join(tmp.path, "tui.json"),
-        },
-      ],
-    }
-
     const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
     expect(out).toMatchObject({
       ok: true,

+ 6 - 0
packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts

@@ -304,17 +304,23 @@ test("does not use npm package main for tui entry", async () => {
   const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
   const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
   const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
+  const warn = spyOn(console, "warn").mockImplementation(() => {})
+  const error = spyOn(console, "error").mockImplementation(() => {})
 
   try {
     await TuiPluginRuntime.init(createTuiPluginApi())
     await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
     expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
+    expect(error).not.toHaveBeenCalled()
+    expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
   } finally {
     await TuiPluginRuntime.dispose()
     install.mockRestore()
     cwd.mockRestore()
     get.mockRestore()
     wait.mockRestore()
+    warn.mockRestore()
+    error.mockRestore()
     delete process.env.OPENCODE_PLUGIN_META_FILE
   }
 })

+ 1 - 0
packages/opencode/test/config/config.test.ts

@@ -792,6 +792,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
 
     expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
     expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
+    expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
   } finally {
     online.mockRestore()
     run.mockRestore()

+ 7 - 1
packages/opencode/test/plugin/install-concurrency.test.ts

@@ -25,6 +25,11 @@ function run(msg: Msg) {
 
 async function plugin(dir: string, kinds: Array<"server" | "tui">) {
   const p = path.join(dir, "plugin")
+  const server = kinds.includes("server")
+  const tui = kinds.includes("tui")
+  const exports: Record<string, string> = {}
+  if (server) exports["./server"] = "./server.js"
+  if (tui) exports["./tui"] = "./tui.js"
   await fs.mkdir(p, { recursive: true })
   await Bun.write(
     path.join(p, "package.json"),
@@ -32,7 +37,8 @@ async function plugin(dir: string, kinds: Array<"server" | "tui">) {
       {
         name: "acme",
         version: "1.0.0",
-        "oc-plugin": kinds,
+        ...(server ? { main: "./server.js" } : {}),
+        ...(Object.keys(exports).length ? { exports } : {}),
       },
       null,
       2,

+ 34 - 7
packages/opencode/test/plugin/install.test.ts

@@ -55,8 +55,34 @@ function ctxRoot(dir: string): PlugCtx {
   }
 }
 
-async function plugin(dir: string, kinds?: unknown) {
+async function plugin(
+  dir: string,
+  kinds?: Array<"server" | "tui">,
+  opts?: {
+    server?: Record<string, unknown>
+    tui?: Record<string, unknown>
+  },
+) {
   const p = path.join(dir, "plugin")
+  const server = kinds?.includes("server") ?? false
+  const tui = kinds?.includes("tui") ?? false
+  const exports: Record<string, unknown> = {}
+  if (server) {
+    exports["./server"] = opts?.server
+      ? {
+          import: "./server.js",
+          config: opts.server,
+        }
+      : "./server.js"
+  }
+  if (tui) {
+    exports["./tui"] = opts?.tui
+      ? {
+          import: "./tui.js",
+          config: opts.tui,
+        }
+      : "./tui.js"
+  }
   await fs.mkdir(p, { recursive: true })
   await Bun.write(
     path.join(p, "package.json"),
@@ -64,7 +90,8 @@ async function plugin(dir: string, kinds?: unknown) {
       {
         name: "acme",
         version: "1.0.0",
-        ...(kinds === undefined ? {} : { "oc-plugin": kinds }),
+        ...(server ? { main: "./server.js" } : {}),
+        ...(Object.keys(exports).length ? { exports } : {}),
       },
       null,
       2,
@@ -99,12 +126,12 @@ describe("plugin.install.task", () => {
     expect(tui.plugin).toEqual(["[email protected]"])
   })
 
-  test("writes default options from tuple manifest targets", async () => {
+  test("writes default options from exports config metadata", async () => {
     await using tmp = await tmpdir()
-    const target = await plugin(tmp.path, [
-      ["server", { custom: true, other: false }],
-      ["tui", { compact: true }],
-    ])
+    const target = await plugin(tmp.path, ["server", "tui"], {
+      server: { custom: true, other: false },
+      tui: { compact: true },
+    })
     const run = createPlugTask(
       {
         mod: "[email protected]",

+ 1 - 1
packages/opencode/test/plugin/loader-shared.test.ts

@@ -487,7 +487,7 @@ describe("plugin.loader.shared", () => {
         .catch(() => false)
 
       expect(called).toBe(false)
-      expect(errors.some((x) => x.includes('exports["./server"]') && x.includes("package.json main"))).toBe(true)
+      expect(errors).toHaveLength(0)
     } finally {
       install.mockRestore()
     }