Skip to content

Commit 25a2b73

Browse files
authored
warn only and ignore plugins without entrypoints, default config via exports (#20284)
1 parent 85c1692 commit 25a2b73

14 files changed

Lines changed: 165 additions & 68 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export default plugin
8888
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
8989
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
9090
- `package.json` `main` is only used for server plugin entrypoint resolution.
91+
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
9192
- 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.
9293
- File/path plugins must export a non-empty `id`.
9394
- npm plugins may omit `id`; package `name` is used.
@@ -100,22 +101,31 @@ export default plugin
100101

101102
## Package manifest and install
102103

103-
Package manifest is read from `package.json` field `oc-plugin`.
104+
Install target detection is inferred from `package.json` entrypoints:
105+
106+
- `server` target when `exports["./server"]` exists or `main` is set.
107+
- `tui` target when `exports["./tui"]` exists.
104108

105109
Example:
106110

107111
```json
108112
{
109113
"name": "@acme/opencode-plugin",
110114
"type": "module",
111-
"main": "./dist/index.js",
115+
"main": "./dist/server.js",
116+
"exports": {
117+
"./server": {
118+
"import": "./dist/server.js",
119+
"config": { "custom": true }
120+
},
121+
"./tui": {
122+
"import": "./dist/tui.js",
123+
"config": { "compact": true }
124+
}
125+
},
112126
"engines": {
113127
"opencode": "^1.0.0"
114-
},
115-
"oc-plugin": [
116-
["server", { "custom": true }],
117-
["tui", { "compact": true }]
118-
]
128+
}
119129
}
120130
```
121131

@@ -144,11 +154,12 @@ npm plugins can declare a version compatibility range in `package.json` using th
144154
- Local installs resolve target dir inside `patchPluginConfig`.
145155
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
146156
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
147-
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
157+
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
148158
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
149159
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
150160
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
151161
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
162+
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
152163
- Without `--force`, an already-configured npm package name is a no-op.
153164
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
154165
- 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:
320331
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
321332
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
322333
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
323-
- 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.
324334
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
325335
- `api.lifecycle.signal` is aborted before cleanup runs.
326336
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.

packages/opencode/src/cli/cmd/plug.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
114114

115115
if (manifest.code === "manifest_no_targets") {
116116
inspect.stop("No plugin targets found", 1)
117-
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
118-
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
117+
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
118+
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
119119
return false
120120
}
121121

packages/opencode/src/cli/cmd/tui/plugin/runtime.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
8787
console.error(`[tui.plugin] ${text}`, next)
8888
}
8989

90+
function warn(message: string, data: Record<string, unknown>) {
91+
log.warn(message, data)
92+
console.warn(`[tui.plugin] ${message}`, data)
93+
}
94+
9095
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
9196

9297
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -229,6 +234,15 @@ async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): P
229234
log.info("loading tui plugin", { path: plan.spec, retry })
230235
const resolved = await PluginLoader.resolve(plan, "tui")
231236
if (!resolved.ok) {
237+
if (resolved.stage === "missing") {
238+
warn("tui plugin has no entrypoint", {
239+
path: plan.spec,
240+
retry,
241+
message: resolved.message,
242+
})
243+
return
244+
}
245+
232246
if (resolved.stage === "install") {
233247
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
234248
return
@@ -753,7 +767,6 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
753767
return [] as PluginLoad[]
754768
})
755769
if (!ready.length) {
756-
fail("failed to add tui plugin", { path: next })
757770
return false
758771
}
759772

@@ -824,7 +837,7 @@ async function installPluginBySpec(
824837
if (manifest.code === "manifest_no_targets") {
825838
return {
826839
ok: false,
827-
message: `"${spec}" does not declare supported targets in package.json`,
840+
message: `"${spec}" does not expose plugin entrypoints in package.json`,
828841
}
829842
}
830843

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ export namespace Config {
121121
const gitignore = path.join(dir, ".gitignore")
122122
const ignore = await Filesystem.exists(gitignore)
123123
if (!ignore) {
124-
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
124+
await Filesystem.write(
125+
gitignore,
126+
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
127+
)
125128
}
126129

127130
// Bun can race cache writes on Windows when installs run in parallel across dirs.

packages/opencode/src/plugin/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ export namespace Plugin {
157157

158158
const resolved = await PluginLoader.resolve(plan, "server")
159159
if (!resolved.ok) {
160+
if (resolved.stage === "missing") {
161+
log.warn("plugin has no server entrypoint", {
162+
path: plan.spec,
163+
message: resolved.message,
164+
})
165+
return
166+
}
167+
160168
const cause =
161169
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
162170
const message = errorMessage(cause)

packages/opencode/src/plugin/install.ts

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
1111
import { Global } from "@/global"
1212
import { Filesystem } from "@/util/filesystem"
1313
import { Flock } from "@/util/flock"
14+
import { isRecord } from "@/util/record"
1415

1516
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
1617

@@ -101,28 +102,60 @@ function pluginList(data: unknown) {
101102
return item.plugin
102103
}
103104

104-
function parseTarget(item: unknown): Target | undefined {
105-
if (item === "server" || item === "tui") return { kind: item }
106-
if (!Array.isArray(item)) return
107-
if (item[0] !== "server" && item[0] !== "tui") return
108-
if (item.length < 2) return { kind: item[0] }
109-
const opt = item[1]
110-
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
105+
function exportValue(value: unknown): string | undefined {
106+
if (typeof value === "string") {
107+
const next = value.trim()
108+
if (next) return next
109+
return
110+
}
111+
if (!isRecord(value)) return
112+
for (const key of ["import", "default"]) {
113+
const next = value[key]
114+
if (typeof next !== "string") continue
115+
const hit = next.trim()
116+
if (!hit) continue
117+
return hit
118+
}
119+
}
120+
121+
function exportOptions(value: unknown): Record<string, unknown> | undefined {
122+
if (!isRecord(value)) return
123+
const config = value.config
124+
if (!isRecord(config)) return
125+
return config
126+
}
127+
128+
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
129+
const exports = pkg.exports
130+
if (!isRecord(exports)) return
131+
const value = exports[`./${kind}`]
132+
const entry = exportValue(value)
133+
if (!entry) return
111134
return {
112-
kind: item[0],
113-
opts: opt,
135+
opts: exportOptions(value),
114136
}
115137
}
116138

117-
function parseTargets(raw: unknown) {
118-
if (!Array.isArray(raw)) return []
119-
const map = new Map<Kind, Target>()
120-
for (const item of raw) {
121-
const hit = parseTarget(item)
122-
if (!hit) continue
123-
map.set(hit.kind, hit)
139+
function hasMainTarget(pkg: Record<string, unknown>) {
140+
const main = pkg.main
141+
if (typeof main !== "string") return false
142+
return Boolean(main.trim())
143+
}
144+
145+
function packageTargets(pkg: Record<string, unknown>) {
146+
const targets: Target[] = []
147+
const server = exportTarget(pkg, "server")
148+
if (server) {
149+
targets.push({ kind: "server", opts: server.opts })
150+
} else if (hasMainTarget(pkg)) {
151+
targets.push({ kind: "server" })
152+
}
153+
154+
const tui = exportTarget(pkg, "tui")
155+
if (tui) {
156+
targets.push({ kind: "tui", opts: tui.opts })
124157
}
125-
return [...map.values()]
158+
return targets
126159
}
127160

128161
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
@@ -260,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
260293
}
261294
}
262295

263-
const targets = parseTargets(pkg.item.json["oc-plugin"])
296+
const targets = packageTargets(pkg.item.json)
264297
if (!targets.length) {
265298
return {
266299
ok: false,
@@ -330,7 +363,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
330363
}
331364

332365
const list = pluginList(data)
333-
const item = target.opts ? [spec, target.opts] : spec
366+
const item = target.opts ? ([spec, target.opts] as const) : spec
334367
const out = patchPluginList(text, list, spec, item, force)
335368
if (out.mode === "noop") {
336369
return {

packages/opencode/src/plugin/loader.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export namespace PluginLoader {
4343
plan: Plan,
4444
kind: PluginKind,
4545
): Promise<
46-
{ ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
46+
| { ok: true; value: Resolved }
47+
| { ok: false; stage: "missing"; message: string }
48+
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
4749
> {
4850
let target = ""
4951
try {
@@ -77,8 +79,8 @@ export namespace PluginLoader {
7779
if (!base.entry) {
7880
return {
7981
ok: false,
80-
stage: "entry",
81-
error: new Error(`Plugin ${plan.spec} entry is empty`),
82+
stage: "missing",
83+
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
8284
}
8385
}
8486

packages/opencode/src/plugin/shared.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export type PluginEntry = {
3434
source: PluginSource
3535
target: string
3636
pkg?: PluginPackage
37-
entry: string
37+
entry?: string
3838
}
3939

4040
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
128128
if (index) return pathToFileURL(index).href
129129
}
130130

131-
if (source === "npm") {
132-
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
133-
}
134-
135-
if (dir) {
136-
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
137-
}
131+
if (source === "npm") return
132+
if (dir) return
138133

139134
return target
140135
}
@@ -145,7 +140,7 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
145140
if (index) return pathToFileURL(index).href
146141
}
147142

148-
throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
143+
return
149144
}
150145

151146
return target

packages/opencode/test/cli/tui/plugin-install.test.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ test("installs plugin without loading it", async () => {
2121
{
2222
name: "demo-install-plugin",
2323
type: "module",
24-
main: "./install-plugin.ts",
25-
"oc-plugin": [["tui", { marker }]],
24+
exports: {
25+
"./tui": {
26+
import: "./install-plugin.ts",
27+
config: { marker },
28+
},
29+
},
2630
},
2731
null,
2832
2,
@@ -46,7 +50,7 @@ test("installs plugin without loading it", async () => {
4650
})
4751

4852
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
49-
let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
53+
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
5054
plugin: [],
5155
plugin_records: undefined,
5256
}
@@ -66,17 +70,6 @@ test("installs plugin without loading it", async () => {
6670

6771
try {
6872
await TuiPluginRuntime.init(api)
69-
cfg = {
70-
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
71-
plugin_records: [
72-
{
73-
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
74-
scope: "local",
75-
source: path.join(tmp.path, "tui.json"),
76-
},
77-
],
78-
}
79-
8073
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
8174
expect(out).toMatchObject({
8275
ok: true,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,17 +304,23 @@ test("does not use npm package main for tui entry", async () => {
304304
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
305305
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
306306
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
307+
const warn = spyOn(console, "warn").mockImplementation(() => {})
308+
const error = spyOn(console, "error").mockImplementation(() => {})
307309

308310
try {
309311
await TuiPluginRuntime.init(createTuiPluginApi())
310312
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
311313
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
314+
expect(error).not.toHaveBeenCalled()
315+
expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
312316
} finally {
313317
await TuiPluginRuntime.dispose()
314318
install.mockRestore()
315319
cwd.mockRestore()
316320
get.mockRestore()
317321
wait.mockRestore()
322+
warn.mockRestore()
323+
error.mockRestore()
318324
delete process.env.OPENCODE_PLUGIN_META_FILE
319325
}
320326
})

0 commit comments

Comments
 (0)