Skip to content

Commit 0b1018f

Browse files
authored
plugins installs should preserve jsonc comments (anomalyco#19938)
1 parent afb6abf commit 0b1018f

3 files changed

Lines changed: 159 additions & 28 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ npm plugins can declare a version compatibility range in `package.json` using th
140140
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
141141
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
142142
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
143+
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
144+
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
143145
- Without `--force`, an already-configured npm package name is a no-op.
144146
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
145147
- Tuple targets in `oc-plugin` provide default options written into config.
@@ -164,7 +166,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
164166
- `api.app.version`
165167
- `api.command.register(cb)` / `api.command.trigger(value)`
166168
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
167-
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
169+
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
168170
- `api.keybind.match`, `print`, `create`
169171
- `api.tuiConfig`
170172
- `api.kv.get`, `set`, `ready`
@@ -210,6 +212,7 @@ Command behavior:
210212

211213
- `ui.Dialog` is the base dialog wrapper.
212214
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
215+
- `ui.Prompt` renders the same prompt component used by the host app.
213216
- `ui.toast(...)` shows a toast.
214217
- `ui.dialog` exposes the host dialog stack:
215218
- `replace(render, onClose?)`
@@ -277,6 +280,7 @@ Current host slot names:
277280

278281
- `app`
279282
- `home_logo`
283+
- `home_prompt` with props `{ workspace_id? }`
280284
- `home_bottom`
281285
- `sidebar_title` with props `{ session_id, title, share_url? }`
282286
- `sidebar_content` with props `{ session_id }`
@@ -289,7 +293,7 @@ Slot notes:
289293
- `api.slots.register(plugin)` does not return an unregister function.
290294
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
291295
- Plugin-provided `id` is not allowed.
292-
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
296+
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
293297
- Plugins cannot define new slot names in this branch.
294298

295299
### Plugin control and lifecycle

packages/opencode/src/plugin/install.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ function pluginSpec(item: unknown) {
9494
return item[0]
9595
}
9696

97+
function pluginList(data: unknown) {
98+
if (!data || typeof data !== "object" || Array.isArray(data)) return
99+
const item = data as { plugin?: unknown }
100+
if (!Array.isArray(item.plugin)) return
101+
return item.plugin
102+
}
103+
97104
function parseTarget(item: unknown): Target | undefined {
98105
if (item === "server" || item === "tui") return { kind: item }
99106
if (!Array.isArray(item)) return
@@ -118,9 +125,28 @@ function parseTargets(raw: unknown) {
118125
return [...map.values()]
119126
}
120127

121-
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
128+
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
129+
return applyEdits(
130+
text,
131+
modify(text, path, value, {
132+
formattingOptions: {
133+
tabSize: 2,
134+
insertSpaces: true,
135+
},
136+
isArrayInsertion: insert,
137+
}),
138+
)
139+
}
140+
141+
function patchPluginList(
142+
text: string,
143+
list: unknown[] | undefined,
144+
spec: string,
145+
next: unknown,
146+
force = false,
147+
): { mode: Mode; text: string } {
122148
const pkg = parsePluginSpecifier(spec).pkg
123-
const rows = list.map((item, i) => ({
149+
const rows = (list ?? []).map((item, i) => ({
124150
item,
125151
i,
126152
spec: pluginSpec(item),
@@ -133,46 +159,60 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
133159
})
134160

135161
if (!dup.length) {
162+
if (!list) {
163+
return {
164+
mode: "add",
165+
text: patch(text, ["plugin"], [next]),
166+
}
167+
}
136168
return {
137169
mode: "add",
138-
list: [...list, next],
170+
text: patch(text, ["plugin", list.length], next, true),
139171
}
140172
}
141173

142174
if (!force) {
143175
return {
144176
mode: "noop",
145-
list,
177+
text,
146178
}
147179
}
148180

149181
const keep = dup[0]
150182
if (!keep) {
151183
return {
152184
mode: "noop",
153-
list,
185+
text,
154186
}
155187
}
156188

157189
if (dup.length === 1 && keep.spec === spec) {
158190
return {
159191
mode: "noop",
160-
list,
192+
text,
161193
}
162194
}
163195

164-
const idx = new Set(dup.map((item) => item.i))
196+
let out = text
197+
if (typeof keep.item === "string") {
198+
out = patch(out, ["plugin", keep.i], next)
199+
}
200+
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
201+
out = patch(out, ["plugin", keep.i, 0], spec)
202+
}
203+
204+
const del = dup
205+
.map((item) => item.i)
206+
.filter((i) => i !== keep.i)
207+
.sort((a, b) => b - a)
208+
209+
for (const i of del) {
210+
out = patch(out, ["plugin", i], undefined)
211+
}
212+
165213
return {
166214
mode: "replace",
167-
list: rows.flatMap((row) => {
168-
if (!idx.has(row.i)) return [row.item]
169-
if (row.i !== keep.i) return []
170-
if (typeof row.item === "string") return [next]
171-
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
172-
return [[spec, ...row.item.slice(1)]]
173-
}
174-
return [row.item]
175-
}),
215+
text: out,
176216
}
177217
}
178218

@@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
289329
}
290330
}
291331

292-
const list: unknown[] =
293-
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
332+
const list = pluginList(data)
294333
const item = target.opts ? [spec, target.opts] : spec
295-
const out = patchPluginList(list, spec, item, force)
334+
const out = patchPluginList(text, list, spec, item, force)
296335
if (out.mode === "noop") {
297336
return {
298337
ok: true,
@@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
304343
}
305344
}
306345

307-
const edits = modify(text, ["plugin"], out.list, {
308-
formattingOptions: {
309-
tabSize: 2,
310-
insertSpaces: true,
311-
},
312-
})
313-
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
346+
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
314347
if (write instanceof Error) {
315348
return {
316349
ok: false,

packages/opencode/test/plugin/install.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import fs from "fs/promises"
33
import path from "path"
4+
import { parse as parseJsonc } from "jsonc-parser"
45
import { Filesystem } from "../../src/util/filesystem"
56
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
67
import { tmpdir } from "../fixture/fixture"
@@ -120,6 +121,99 @@ describe("plugin.install.task", () => {
120121
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
121122
})
122123

124+
test("preserves JSONC comments when adding plugins to server and tui config", async () => {
125+
await using tmp = await tmpdir()
126+
const target = await plugin(tmp.path, ["server", "tui"])
127+
const cfg = path.join(tmp.path, ".opencode")
128+
const server = path.join(cfg, "opencode.jsonc")
129+
const tui = path.join(cfg, "tui.jsonc")
130+
await fs.mkdir(cfg, { recursive: true })
131+
await Bun.write(
132+
server,
133+
`{
134+
// server head
135+
"plugin": [
136+
// server keep
137+
"seed@1.0.0"
138+
],
139+
// server tail
140+
"model": "x"
141+
}
142+
`,
143+
)
144+
await Bun.write(
145+
tui,
146+
`{
147+
// tui head
148+
"plugin": [
149+
// tui keep
150+
"seed@1.0.0"
151+
],
152+
// tui tail
153+
"theme": "opencode"
154+
}
155+
`,
156+
)
157+
158+
const run = createPlugTask(
159+
{
160+
mod: "acme@1.2.3",
161+
},
162+
deps(path.join(tmp.path, "global"), target),
163+
)
164+
165+
const ok = await run(ctx(tmp.path))
166+
expect(ok).toBe(true)
167+
168+
const serverText = await fs.readFile(server, "utf8")
169+
const tuiText = await fs.readFile(tui, "utf8")
170+
expect(serverText).toContain("// server head")
171+
expect(serverText).toContain("// server keep")
172+
expect(serverText).toContain("// server tail")
173+
expect(tuiText).toContain("// tui head")
174+
expect(tuiText).toContain("// tui keep")
175+
expect(tuiText).toContain("// tui tail")
176+
177+
const serverJson = parseJsonc(serverText) as { plugin?: unknown[] }
178+
const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] }
179+
expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
180+
expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
181+
})
182+
183+
test("preserves JSONC comments when force replacing plugin version", async () => {
184+
await using tmp = await tmpdir()
185+
const target = await plugin(tmp.path, ["server"])
186+
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
187+
await fs.mkdir(path.dirname(cfg), { recursive: true })
188+
await Bun.write(
189+
cfg,
190+
`{
191+
"plugin": [
192+
// keep this note
193+
"acme@1.0.0"
194+
]
195+
}
196+
`,
197+
)
198+
199+
const run = createPlugTask(
200+
{
201+
mod: "acme@2.0.0",
202+
force: true,
203+
},
204+
deps(path.join(tmp.path, "global"), target),
205+
)
206+
207+
const ok = await run(ctx(tmp.path))
208+
expect(ok).toBe(true)
209+
210+
const text = await fs.readFile(cfg, "utf8")
211+
expect(text).toContain("// keep this note")
212+
213+
const json = parseJsonc(text) as { plugin?: unknown[] }
214+
expect(json.plugin).toEqual(["acme@2.0.0"])
215+
})
216+
123217
test("supports resolver target pointing to a file", async () => {
124218
await using tmp = await tmpdir()
125219
const target = await plugin(tmp.path, ["server"])

0 commit comments

Comments
 (0)