plugins installs should preserve jsonc comments (#19938)
parent
afb6abff73
commit
0b1018f6dd
|
|
@ -140,6 +140,8 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
|||
- 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` 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.
|
||||
- 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.
|
||||
- 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)`:
|
|||
- `api.app.version`
|
||||
- `api.command.register(cb)` / `api.command.trigger(value)`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
|
||||
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
|
||||
- `api.keybind.match`, `print`, `create`
|
||||
- `api.tuiConfig`
|
||||
- `api.kv.get`, `set`, `ready`
|
||||
|
|
@ -210,6 +212,7 @@ Command behavior:
|
|||
|
||||
- `ui.Dialog` is the base dialog wrapper.
|
||||
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
|
||||
- `ui.Prompt` renders the same prompt component used by the host app.
|
||||
- `ui.toast(...)` shows a toast.
|
||||
- `ui.dialog` exposes the host dialog stack:
|
||||
- `replace(render, onClose?)`
|
||||
|
|
@ -277,6 +280,7 @@ Current host slot names:
|
|||
|
||||
- `app`
|
||||
- `home_logo`
|
||||
- `home_prompt` with props `{ workspace_id? }`
|
||||
- `home_bottom`
|
||||
- `sidebar_title` with props `{ session_id, title, share_url? }`
|
||||
- `sidebar_content` with props `{ session_id }`
|
||||
|
|
@ -289,7 +293,7 @@ Slot notes:
|
|||
- `api.slots.register(plugin)` does not return an unregister function.
|
||||
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
|
||||
- Plugin-provided `id` is not allowed.
|
||||
- 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.
|
||||
- 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.
|
||||
- Plugins cannot define new slot names in this branch.
|
||||
|
||||
### Plugin control and lifecycle
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ function pluginSpec(item: unknown) {
|
|||
return item[0]
|
||||
}
|
||||
|
||||
function pluginList(data: unknown) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return
|
||||
const item = data as { plugin?: unknown }
|
||||
if (!Array.isArray(item.plugin)) return
|
||||
return item.plugin
|
||||
}
|
||||
|
||||
function parseTarget(item: unknown): Target | undefined {
|
||||
if (item === "server" || item === "tui") return { kind: item }
|
||||
if (!Array.isArray(item)) return
|
||||
|
|
@ -118,9 +125,28 @@ function parseTargets(raw: unknown) {
|
|||
return [...map.values()]
|
||||
}
|
||||
|
||||
function patchPluginList(list: unknown[], spec: string, next: unknown, force = false): { mode: Mode; list: unknown[] } {
|
||||
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, path, value, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
isArrayInsertion: insert,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function patchPluginList(
|
||||
text: string,
|
||||
list: unknown[] | undefined,
|
||||
spec: string,
|
||||
next: unknown,
|
||||
force = false,
|
||||
): { mode: Mode; text: string } {
|
||||
const pkg = parsePluginSpecifier(spec).pkg
|
||||
const rows = list.map((item, i) => ({
|
||||
const rows = (list ?? []).map((item, i) => ({
|
||||
item,
|
||||
i,
|
||||
spec: pluginSpec(item),
|
||||
|
|
@ -133,16 +159,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
|||
})
|
||||
|
||||
if (!dup.length) {
|
||||
if (!list) {
|
||||
return {
|
||||
mode: "add",
|
||||
text: patch(text, ["plugin"], [next]),
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: "add",
|
||||
list: [...list, next],
|
||||
text: patch(text, ["plugin", list.length], next, true),
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,29 +182,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
|
|||
if (!keep) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
if (dup.length === 1 && keep.spec === spec) {
|
||||
return {
|
||||
mode: "noop",
|
||||
list,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
const idx = new Set(dup.map((item) => item.i))
|
||||
let out = text
|
||||
if (typeof keep.item === "string") {
|
||||
out = patch(out, ["plugin", keep.i], next)
|
||||
}
|
||||
if (Array.isArray(keep.item) && typeof keep.item[0] === "string") {
|
||||
out = patch(out, ["plugin", keep.i, 0], spec)
|
||||
}
|
||||
|
||||
const del = dup
|
||||
.map((item) => item.i)
|
||||
.filter((i) => i !== keep.i)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
for (const i of del) {
|
||||
out = patch(out, ["plugin", i], undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "replace",
|
||||
list: rows.flatMap((row) => {
|
||||
if (!idx.has(row.i)) return [row.item]
|
||||
if (row.i !== keep.i) return []
|
||||
if (typeof row.item === "string") return [next]
|
||||
if (Array.isArray(row.item) && typeof row.item[0] === "string") {
|
||||
return [[spec, ...row.item.slice(1)]]
|
||||
}
|
||||
return [row.item]
|
||||
}),
|
||||
text: out,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,10 +329,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||
}
|
||||
}
|
||||
|
||||
const list: unknown[] =
|
||||
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : []
|
||||
const list = pluginList(data)
|
||||
const item = target.opts ? [spec, target.opts] : spec
|
||||
const out = patchPluginList(list, spec, item, force)
|
||||
const out = patchPluginList(text, list, spec, item, force)
|
||||
if (out.mode === "noop") {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
@ -304,13 +343,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||
}
|
||||
}
|
||||
|
||||
const edits = modify(text, ["plugin"], out.list, {
|
||||
formattingOptions: {
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
},
|
||||
})
|
||||
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
|
||||
const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
|
||||
if (write instanceof Error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { parse as parseJsonc } from "jsonc-parser"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
|
@ -120,6 +121,99 @@ describe("plugin.install.task", () => {
|
|||
expect(tui.plugin).toEqual([["acme@1.2.3", { compact: true }]])
|
||||
})
|
||||
|
||||
test("preserves JSONC comments when adding plugins to server and tui config", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server", "tui"])
|
||||
const cfg = path.join(tmp.path, ".opencode")
|
||||
const server = path.join(cfg, "opencode.jsonc")
|
||||
const tui = path.join(cfg, "tui.jsonc")
|
||||
await fs.mkdir(cfg, { recursive: true })
|
||||
await Bun.write(
|
||||
server,
|
||||
`{
|
||||
// server head
|
||||
"plugin": [
|
||||
// server keep
|
||||
"seed@1.0.0"
|
||||
],
|
||||
// server tail
|
||||
"model": "x"
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
tui,
|
||||
`{
|
||||
// tui head
|
||||
"plugin": [
|
||||
// tui keep
|
||||
"seed@1.0.0"
|
||||
],
|
||||
// tui tail
|
||||
"theme": "opencode"
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: "acme@1.2.3",
|
||||
},
|
||||
deps(path.join(tmp.path, "global"), target),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(tmp.path))
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const serverText = await fs.readFile(server, "utf8")
|
||||
const tuiText = await fs.readFile(tui, "utf8")
|
||||
expect(serverText).toContain("// server head")
|
||||
expect(serverText).toContain("// server keep")
|
||||
expect(serverText).toContain("// server tail")
|
||||
expect(tuiText).toContain("// tui head")
|
||||
expect(tuiText).toContain("// tui keep")
|
||||
expect(tuiText).toContain("// tui tail")
|
||||
|
||||
const serverJson = parseJsonc(serverText) as { plugin?: unknown[] }
|
||||
const tuiJson = parseJsonc(tuiText) as { plugin?: unknown[] }
|
||||
expect(serverJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
|
||||
expect(tuiJson.plugin).toEqual(["seed@1.0.0", "acme@1.2.3"])
|
||||
})
|
||||
|
||||
test("preserves JSONC comments when force replacing plugin version", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server"])
|
||||
const cfg = path.join(tmp.path, ".opencode", "opencode.jsonc")
|
||||
await fs.mkdir(path.dirname(cfg), { recursive: true })
|
||||
await Bun.write(
|
||||
cfg,
|
||||
`{
|
||||
"plugin": [
|
||||
// keep this note
|
||||
"acme@1.0.0"
|
||||
]
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: "acme@2.0.0",
|
||||
force: true,
|
||||
},
|
||||
deps(path.join(tmp.path, "global"), target),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(tmp.path))
|
||||
expect(ok).toBe(true)
|
||||
|
||||
const text = await fs.readFile(cfg, "utf8")
|
||||
expect(text).toContain("// keep this note")
|
||||
|
||||
const json = parseJsonc(text) as { plugin?: unknown[] }
|
||||
expect(json.plugin).toEqual(["acme@2.0.0"])
|
||||
})
|
||||
|
||||
test("supports resolver target pointing to a file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = await plugin(tmp.path, ["server"])
|
||||
|
|
|
|||
Loading…
Reference in New Issue