mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-25 07:15:19 +00:00
532 lines
16 KiB
TypeScript
532 lines
16 KiB
TypeScript
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>
|
|
},
|
|
) {
|
|
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 } : {}),
|
|
},
|
|
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("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)
|
|
})
|
|
})
|