From 25a2b739e68a98dd027aa3d5cef187ad4242d1ff Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 31 Mar 2026 17:14:03 +0200 Subject: [PATCH] warn only and ignore plugins without entrypoints, default config via exports (#20284) --- packages/opencode/specs/tui-plugins.md | 28 ++++--- packages/opencode/src/cli/cmd/plug.ts | 4 +- .../src/cli/cmd/tui/plugin/runtime.ts | 17 ++++- packages/opencode/src/config/config.ts | 5 +- packages/opencode/src/plugin/index.ts | 8 ++ packages/opencode/src/plugin/install.ts | 73 ++++++++++++++----- packages/opencode/src/plugin/loader.ts | 8 +- packages/opencode/src/plugin/shared.ts | 13 +--- .../test/cli/tui/plugin-install.test.ts | 21 ++---- .../cli/tui/plugin-loader-entrypoint.test.ts | 6 ++ packages/opencode/test/config/config.test.ts | 1 + .../test/plugin/install-concurrency.test.ts | 8 +- packages/opencode/test/plugin/install.test.ts | 41 +++++++++-- .../test/plugin/loader-shared.test.ts | 2 +- 14 files changed, 166 insertions(+), 69 deletions(-) diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index f979b1a103..c1c4f53082 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/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 `/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `/.opencode`. - Root-worktree fallback (`worktree === "/"` uses `/.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 `pkg@1.2.3`) 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. diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index ae2ea4ffde..0e24654233 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/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 } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 3fde4fc298..9df4e060bf 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -87,6 +87,11 @@ function fail(message: string, data: Record) { console.error(`[tui.plugin] ${text}`, next) } +function warn(message: string, data: Record) { + 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 { @@ -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`, } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ad804c892f..9e56c980fb 100644 --- a/packages/opencode/src/config/config.ts +++ b/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. diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6cecfaac73..b05dd86259 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/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) diff --git a/packages/opencode/src/plugin/install.ts b/packages/opencode/src/plugin/install.ts index 8c0a6ee274..1eed82624c 100644 --- a/packages/opencode/src/plugin/install.ts +++ b/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] } - return { - kind: item[0], - opts: opt, +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 parseTargets(raw: unknown) { - if (!Array.isArray(raw)) return [] - const map = new Map() - for (const item of raw) { - const hit = parseTarget(item) - if (!hit) continue - map.set(hit.kind, hit) +function exportOptions(value: unknown): Record | undefined { + if (!isRecord(value)) return + const config = value.config + if (!isRecord(config)) return + return config +} + +function exportTarget(pkg: Record, kind: Kind) { + const exports = pkg.exports + if (!isRecord(exports)) return + const value = exports[`./${kind}`] + const entry = exportValue(value) + if (!entry) return + return { + opts: exportOptions(value), } - return [...map.values()] +} + +function hasMainTarget(pkg: Record) { + const main = pkg.main + if (typeof main !== "string") return false + return Boolean(main.trim()) +} + +function packageTargets(pkg: Record) { + 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 targets } function patch(text: string, path: Array, value: unknown, insert = false) { @@ -260,7 +293,7 @@ export async function readPluginManifest(target: string): Promise { 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`, } } diff --git a/packages/opencode/src/plugin/shared.ts b/packages/opencode/src/plugin/shared.ts index 2c9edfb0a9..3ccb1f65d9 100644 --- a/packages/opencode/src/plugin/shared.ts +++ b/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 diff --git a/packages/opencode/test/cli/tui/plugin-install.test.ts b/packages/opencode/test/cli/tui/plugin-install.test.ts index b5cafe0466..c7f3615c62 100644 --- a/packages/opencode/test/cli/tui/plugin-install.test.ts +++ b/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> = { + const cfg: Awaited> = { 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, diff --git a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts b/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts index 6a3e679c66..5473a28a4d 100644 --- a/packages/opencode/test/cli/tui/plugin-loader-entrypoint.test.ts +++ b/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 } }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index d06bdf12a6..ef71ca8cf6 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/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() diff --git a/packages/opencode/test/plugin/install-concurrency.test.ts b/packages/opencode/test/plugin/install-concurrency.test.ts index d21d7ca35b..cf3e8692e1 100644 --- a/packages/opencode/test/plugin/install-concurrency.test.ts +++ b/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 = {} + 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, diff --git a/packages/opencode/test/plugin/install.test.ts b/packages/opencode/test/plugin/install.test.ts index 24440c10ea..20d71d3e18 100644 --- a/packages/opencode/test/plugin/install.test.ts +++ b/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 + tui?: Record + }, +) { const p = path.join(dir, "plugin") + const server = kinds?.includes("server") ?? false + const tui = kinds?.includes("tui") ?? false + const exports: Record = {} + 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(["acme@1.2.3"]) }) - 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: "acme@1.2.3", diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index b547979231..704c2e8e1f 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/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() }