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" function deps(global: string, target: string | Error): PlugDeps { return { spinner: () => ({ start() {}, stop() {}, }), log: { error() {}, info() {}, success() {}, }, resolve: async () => { if (target instanceof Error) throw target return target }, readText: (file) => Filesystem.readText(file), write: async (file, text) => { await Filesystem.write(file, text) }, exists: (file) => Filesystem.exists(file), files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)], global, } } function ctx(dir: string): PlugCtx { return { vcs: "git", worktree: dir, directory: dir, } } function ctxDir(dir: string, worktree: string): PlugCtx { return { vcs: "none", worktree, directory: dir, } } function ctxRoot(dir: string): PlugCtx { return { vcs: "git", worktree: "/", directory: dir, } } async function plugin( dir: string, kinds?: Array<"server" | "tui">, opts?: { server?: Record tui?: Record }, themes?: string[], ) { 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"), JSON.stringify( { name: "acme", version: "1.0.0", ...(server ? { main: "./server.js" } : {}), ...(Object.keys(exports).length ? { exports } : {}), ...(themes?.length ? { "oc-themes": themes } : {}), }, null, 2, ), ) return p } async function read(file: string) { return Filesystem.readJson<{ plugin?: unknown[] }>(file) } describe("plugin.install.task", () => { test("writes both server and tui config entries", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server", "tui"]) 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 server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) expect(server.plugin).toEqual(["acme@1.2.3"]) expect(tui.plugin).toEqual(["acme@1.2.3"]) }) test("writes default options from exports config metadata", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server", "tui"], { server: { custom: true, other: false }, tui: { compact: true }, }) 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 server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) expect(server.plugin).toEqual([["acme@1.2.3", { custom: true, other: false }]]) 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"]) const file = path.join(target, "index.js") await Bun.write(file, "export {}") const run = createPlugTask( { mod: "acme@1.2.3", }, deps(path.join(tmp.path, "global"), file), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) const server = await read(path.join(tmp.path, ".opencode", "opencode.jsonc")) expect(server.plugin).toEqual(["acme@1.2.3"]) }) test("does not change configured package version without force", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const cfg = path.join(tmp.path, ".opencode", "opencode.json") await fs.mkdir(path.dirname(cfg), { recursive: true }) await Bun.write(cfg, JSON.stringify({ plugin: ["acme@1.0.0"] }, null, 2)) const run = createPlugTask( { mod: "acme@2.0.0", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) const json = await read(cfg) expect(json.plugin).toEqual(["acme@1.0.0"]) }) test("does not change scoped package version without force", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const cfg = path.join(tmp.path, ".opencode", "opencode.json") await fs.mkdir(path.dirname(cfg), { recursive: true }) await Bun.write(cfg, JSON.stringify({ plugin: ["@scope/acme@1.0.0"] }, null, 2)) const run = createPlugTask( { mod: "@scope/acme@2.0.0", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) const json = await read(cfg) expect(json.plugin).toEqual(["@scope/acme@1.0.0"]) }) test("keeps file plugin entries and still adds npm plugin", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const cfg = path.join(tmp.path, ".opencode", "opencode.json") await fs.mkdir(path.dirname(cfg), { recursive: true }) await Bun.write(cfg, JSON.stringify({ plugin: ["file:///tmp/acme.ts"] }, null, 2)) 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 json = await read(cfg) expect(json.plugin).toEqual(["file:///tmp/acme.ts", "acme@1.2.3"]) }) test("force replaces configured package version and keeps tuple options", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const cfg = path.join(tmp.path, ".opencode", "opencode.json") await fs.mkdir(path.dirname(cfg), { recursive: true }) await Bun.write( cfg, JSON.stringify( { plugin: [["acme@1.0.0", { mode: "safe" }], "acme@1.1.0", "other@1.0.0"], }, null, 2, ), ) 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 json = await read(cfg) expect(json.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"]) }) test("writes to global scope when global flag is set", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const global = path.join(tmp.path, "global") const run = createPlugTask( { mod: "acme@1.2.3", global: true, }, deps(global, target), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(true) expect(await Filesystem.exists(path.join(global, "opencode.jsonc"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) test("writes local scope under directory when vcs is not git", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const directory = path.join(tmp.path, "dir") const worktree = path.join(tmp.path, "worktree") await fs.mkdir(directory, { recursive: true }) await fs.mkdir(worktree, { recursive: true }) const run = createPlugTask( { mod: "acme@1.2.3", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctxDir(directory, worktree)) expect(ok).toBe(true) expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true) expect(await Filesystem.exists(path.join(worktree, ".opencode", "opencode.jsonc"))).toBe(false) }) test("writes local scope under directory when worktree is root slash", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server"]) const directory = path.join(tmp.path, "dir") await fs.mkdir(directory, { recursive: true }) const run = createPlugTask( { mod: "acme@1.2.3", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctxRoot(directory)) expect(ok).toBe(true) expect(await Filesystem.exists(path.join(directory, ".opencode", "opencode.jsonc"))).toBe(true) }) test("writes tui local scope under directory when worktree is root slash", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["tui"]) const directory = path.join(tmp.path, "dir") await fs.mkdir(directory, { recursive: true }) const run = createPlugTask( { mod: "acme@1.2.3", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctxRoot(directory)) expect(ok).toBe(true) expect(await Filesystem.exists(path.join(directory, ".opencode", "tui.jsonc"))).toBe(true) }) test("writes only tui config for tui-only plugins", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["tui"]) 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) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) test("writes tui config for oc-themes-only packages", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, undefined, undefined, ["themes/forest.json"]) await fs.mkdir(path.join(target, "themes"), { recursive: true }) await Bun.write(path.join(target, "themes", "forest.json"), JSON.stringify({ theme: { text: "#fff" } }, null, 2)) 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) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) const tui = await read(path.join(tmp.path, ".opencode", "tui.jsonc")) expect(tui.plugin).toEqual(["acme@1.2.3"]) }) test("returns false for oc-themes outside plugin directory", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, undefined, undefined, ["../outside.json"]) 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(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) test("force replaces version in both server and tui configs", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path, ["server", "tui"]) const server = path.join(tmp.path, ".opencode", "opencode.json") const tui = path.join(tmp.path, ".opencode", "tui.json") await fs.mkdir(path.dirname(server), { recursive: true }) await Bun.write(server, JSON.stringify({ plugin: ["acme@1.0.0", "other@1.0.0"] }, null, 2)) await Bun.write(tui, JSON.stringify({ plugin: [["acme@1.0.0", { mode: "safe" }], "other@1.0.0"] }, null, 2)) 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 serverJson = await read(server) const tuiJson = await read(tui) expect(serverJson.plugin).toEqual(["acme@2.0.0", "other@1.0.0"]) expect(tuiJson.plugin).toEqual([["acme@2.0.0", { mode: "safe" }], "other@1.0.0"]) }) test("returns false and keeps config unchanged for invalid JSONC", 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 }) const bad = '{"plugin": ["acme@1.0.0",}' await Bun.write(cfg, bad) const run = createPlugTask( { mod: "acme@2.0.0", }, deps(path.join(tmp.path, "global"), target), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) expect(await fs.readFile(cfg, "utf8")).toBe(bad) }) test("returns false when manifest declares no supported targets", async () => { await using tmp = await tmpdir() const target = await plugin(tmp.path) 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(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "tui.jsonc"))).toBe(false) }) test("returns false when manifest cannot be read", async () => { await using tmp = await tmpdir() const target = path.join(tmp.path, "plugin") await fs.mkdir(target, { recursive: true }) 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(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) test("returns false when install fails", async () => { await using tmp = await tmpdir() const run = createPlugTask( { mod: "acme@9.9.9", }, deps(path.join(tmp.path, "global"), new Error("boom")), ) const ok = await run(ctx(tmp.path)) expect(ok).toBe(false) expect(await Filesystem.exists(path.join(tmp.path, ".opencode", "opencode.jsonc"))).toBe(false) }) })