| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- 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<string, unknown>
- tui?: Record<string, unknown>
- },
- themes?: string[],
- ) {
- const p = path.join(dir, "plugin")
- const server = kinds?.includes("server") ?? false
- const tui = kinds?.includes("tui") ?? false
- const exports: Record<string, unknown> = {}
- 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: "[email protected]",
- },
- 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(["[email protected]"])
- expect(tui.plugin).toEqual(["[email protected]"])
- })
- 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: "[email protected]",
- },
- 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([["[email protected]", { custom: true, other: false }]])
- 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"])
- const file = path.join(target, "index.js")
- await Bun.write(file, "export {}")
- const run = createPlugTask(
- {
- mod: "[email protected]",
- },
- 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(["[email protected]"])
- })
- 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: ["[email protected]"] }, null, 2))
- 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 json = await read(cfg)
- expect(json.plugin).toEqual(["[email protected]"])
- })
- 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/[email protected]"] }, null, 2))
- const run = createPlugTask(
- {
- mod: "@scope/[email protected]",
- },
- 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/[email protected]"])
- })
- 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: "[email protected]",
- },
- 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", "[email protected]"])
- })
- 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: [["[email protected]", { mode: "safe" }], "[email protected]", "[email protected]"],
- },
- null,
- 2,
- ),
- )
- 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 json = await read(cfg)
- expect(json.plugin).toEqual([["[email protected]", { mode: "safe" }], "[email protected]"])
- })
- 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: "[email protected]",
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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(["[email protected]"])
- })
- 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: "[email protected]",
- },
- 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: ["[email protected]", "[email protected]"] }, null, 2))
- await Bun.write(tui, JSON.stringify({ plugin: [["[email protected]", { mode: "safe" }], "[email protected]"] }, null, 2))
- 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 serverJson = await read(server)
- const tuiJson = await read(tui)
- expect(serverJson.plugin).toEqual(["[email protected]", "[email protected]"])
- expect(tuiJson.plugin).toEqual([["[email protected]", { mode: "safe" }], "[email protected]"])
- })
- 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": ["[email protected]",}'
- await Bun.write(cfg, bad)
- const run = createPlugTask(
- {
- mod: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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: "[email protected]",
- },
- 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)
- })
- })
|