2
0
Эх сурвалжийг харах

plugins installs should preserve jsonc comments (#19938)

Sebastian 3 долоо хоног өмнө
parent
commit
0b1018f6dd

+ 6 - 2
packages/opencode/specs/tui-plugins.md

@@ -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

+ 59 - 26
packages/opencode/src/plugin/install.ts

@@ -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,

+ 94 - 0
packages/opencode/test/plugin/install.test.ts

@@ -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([["[email protected]", { 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
+    "[email protected]"
+  ],
+  // server tail
+  "model": "x"
+}
+`,
+    )
+    await Bun.write(
+      tui,
+      `{
+  // tui head
+  "plugin": [
+    // tui keep
+    "[email protected]"
+  ],
+  // tui tail
+  "theme": "opencode"
+}
+`,
+    )
+
+    const run = createPlugTask(
+      {
+        mod: "[email protected]",
+      },
+      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(["[email protected]", "[email protected]"])
+    expect(tuiJson.plugin).toEqual(["[email protected]", "[email protected]"])
+  })
+
+  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
+    "[email protected]"
+  ]
+}
+`,
+    )
+
+    const run = createPlugTask(
+      {
+        mod: "[email protected]",
+        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(["[email protected]"])
+  })
+
   test("supports resolver target pointing to a file", async () => {
     await using tmp = await tmpdir()
     const target = await plugin(tmp.path, ["server"])