+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
1307 lines
38 KiB
TypeScript
1307 lines
38 KiB
TypeScript
import { afterEach, describe, expect, spyOn } from "bun:test"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { Effect, Layer } from "effect"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { pathToFileURL } from "url"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { Config } from "@/config/config"
|
|
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const { Plugin } = await import("../../src/plugin/index")
|
|
const { PluginLoader } = await import("../../src/plugin/loader")
|
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
|
const { Npm } = await import("@opencode-ai/core/npm")
|
|
const { TestConfig } = await import("../fixture/config")
|
|
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
const it = testEffect(
|
|
Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer),
|
|
)
|
|
|
|
function withTmp<T, A, E, R>(
|
|
init: (dir: string) => Promise<T>,
|
|
body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
|
|
) {
|
|
return Effect.gen(function* () {
|
|
const dir = yield* tmpdirScoped()
|
|
const extra = yield* Effect.promise(() => init(dir))
|
|
return yield* body({ path: dir, extra })
|
|
})
|
|
}
|
|
|
|
function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
|
|
const source = path.join(dir, "opencode.json")
|
|
return Effect.gen(function* () {
|
|
const config = yield* Effect.promise(
|
|
() => Bun.file(source).json() as Promise<{ plugin?: Array<string | [string, Record<string, unknown>]> }>,
|
|
)
|
|
const plugins = config.plugin ?? []
|
|
return yield* Effect.gen(function* () {
|
|
const plugin = yield* Plugin.Service
|
|
yield* plugin.list()
|
|
}).pipe(
|
|
Effect.provide(
|
|
LayerNode.compile(Plugin.node, [
|
|
[
|
|
Config.node,
|
|
TestConfig.layer({
|
|
get: () =>
|
|
Effect.succeed({
|
|
plugin: plugins,
|
|
plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
|
|
}),
|
|
directories: () => Effect.succeed([dir]),
|
|
}),
|
|
],
|
|
[RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })],
|
|
]),
|
|
),
|
|
provideInstance(dir),
|
|
)
|
|
})
|
|
}
|
|
|
|
describe("plugin.loader.shared", () => {
|
|
it.live("loads a file:// plugin function export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("deduplicates same function exported as default and named", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "count.txt")
|
|
await Bun.write(mark, "")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const run = async () => {",
|
|
` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`,
|
|
` await Bun.write(${JSON.stringify(mark)}, text + "1")`,
|
|
" return {}",
|
|
"}",
|
|
"export default run",
|
|
"export const named = run",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("uses only default v1 server plugin when present", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "count.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.v1-default",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export const named = async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "named")`,
|
|
" return {}",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects v1 file server plugin without id", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects v1 plugin that exports server and tui together", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.mixed",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "server")`,
|
|
" return {}",
|
|
" },",
|
|
" tui: async () => {},",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("resolves npm plugin specs with explicit and default versions", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const acme = path.join(dir, "node_modules", "acme-plugin")
|
|
const scope = path.join(dir, "node_modules", "scope-plugin")
|
|
await fs.mkdir(acme, { recursive: true })
|
|
await fs.mkdir(scope, { recursive: true })
|
|
await Bun.write(
|
|
path.join(acme, "package.json"),
|
|
JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
|
|
)
|
|
await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
|
|
await Bun.write(
|
|
path.join(scope, "package.json"),
|
|
JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
|
|
)
|
|
await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
|
|
)
|
|
|
|
return { acme, scope }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
|
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
|
|
return { directory: tmp.extra.scope, entrypoint: undefined }
|
|
})
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
|
|
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
|
|
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
|
|
} finally {
|
|
add.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package ./server export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const mark = path.join(dir, "server-called.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "./server.js",
|
|
"./tui": "./tui.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
|
await Bun.write(
|
|
path.join(mod, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package server export without leading dot", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const dist = path.join(mod, "dist")
|
|
const mark = path.join(dir, "server-called.txt")
|
|
await fs.mkdir(dist, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "dist/server.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
|
await Bun.write(
|
|
path.join(dist, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package main without leading dot", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const dist = path.join(mod, "dist")
|
|
const mark = path.join(dir, "main-called.txt")
|
|
await fs.mkdir(dist, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
main: "dist/index.js",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(
|
|
path.join(dist, "index.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not use npm package exports dot for server entry", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const mark = path.join(dir, "dot-server.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify({
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: { ".": "./index.js" },
|
|
}),
|
|
)
|
|
await Bun.write(
|
|
path.join(mod, "index.js"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.dot.server",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return { mod, mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects npm server export that resolves outside plugin directory", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const outside = path.join(dir, "outside")
|
|
const mark = path.join(dir, "outside-server.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
await fs.mkdir(outside, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "./escape/server.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
|
|
await Bun.write(
|
|
path.join(outside, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "outside")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
expect(called).toBe(false)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips legacy codex and copilot auth plugin specs", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify(
|
|
{
|
|
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
},
|
|
(_tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(_tmp.path)
|
|
|
|
const pkgs = install.mock.calls.map((call) => call[0])
|
|
expect(pkgs).toContain("regular-plugin@1.0.0")
|
|
expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
|
|
expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips broken plugin when install fails", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const ok = path.join(dir, "ok.ts")
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
ok,
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
|
)
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin init throws", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "throws.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.throws",',
|
|
" server: async () => {",
|
|
' throw new Error("explode")',
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin module has invalid export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "invalid.ts"),
|
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin import fails", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads object plugin via plugin.server", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "object-plugin.ts")
|
|
const mark = path.join(dir, "object-called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const plugin = {",
|
|
' id: "demo.object",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export default plugin",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("passes tuple plugin options into server plugin", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "options-plugin.ts")
|
|
const mark = path.join(dir, "options.json")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const plugin = {",
|
|
' id: "demo.options",',
|
|
" server: async (_input, options) => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export default plugin",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(
|
|
(yield* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
|
|
).toEqual({
|
|
source: "tuple",
|
|
enabled: true,
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("initializes server plugins in config order", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const a = path.join(dir, "a-plugin.ts")
|
|
const b = path.join(dir, "b-plugin.ts")
|
|
const marker = path.join(dir, "server-order.txt")
|
|
const aSpec = pathToFileURL(a).href
|
|
const bSpec = pathToFileURL(b).href
|
|
|
|
await Bun.write(
|
|
a,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.order.a",
|
|
server: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
|
|
await Bun.sleep(25)
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
|
|
return {}
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
await Bun.write(
|
|
b,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.order.b",
|
|
server: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
|
|
return {}
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
|
|
|
|
return { marker }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n")
|
|
expect(lines).toEqual(["a-start", "a-end", "b"])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips external plugins in pure mode", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.pure",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path, { pure: true })
|
|
const called = yield* Effect.promise(() =>
|
|
fs
|
|
.readFile(tmp.extra.mark, "utf8")
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
expect(called).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("reads oc-themes from package manifest", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mod")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
"oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const file = path.join(tmp.extra.mod, "package.json")
|
|
const fsys = yield* FSUtil.Service
|
|
const json = (yield* fsys.readJson(file)) as Record<string, unknown>
|
|
const list = readPackageThemes("acme-plugin", {
|
|
dir: tmp.extra.mod,
|
|
pkg: file,
|
|
json,
|
|
})
|
|
|
|
expect(list).toEqual([
|
|
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
|
FSUtil.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
|
])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("handles no-entrypoint tui packages via missing callback", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
"oc-themes": ["themes/night.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
const missing: string[] = []
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
missing: async (item) => {
|
|
if (!item.pkg) return
|
|
const themes = readPackageThemes(item.spec, item.pkg)
|
|
if (!themes.length) return
|
|
return {
|
|
spec: item.spec,
|
|
target: item.target,
|
|
themes,
|
|
}
|
|
},
|
|
report: {
|
|
missing(_candidate, _retry, message) {
|
|
missing.push(message)
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
target: tmp.extra.mod,
|
|
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
|
},
|
|
])
|
|
expect(missing).toHaveLength(0)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("passes package metadata for entrypoint tui plugins", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
exports: {
|
|
"./tui": "./tui.js",
|
|
},
|
|
"oc-themes": ["themes/night.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
|
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
finish: async (item) => {
|
|
if (!item.pkg) return
|
|
return {
|
|
spec: item.spec,
|
|
themes: readPackageThemes(item.spec, item.pkg),
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
themes: [FSUtil.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
|
},
|
|
])
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects oc-themes path traversal", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mod")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
const file = path.join(mod, "package.json")
|
|
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
|
|
return { mod, file }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const fsys = yield* FSUtil.Service
|
|
const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
|
|
expect(() =>
|
|
readPackageThemes("acme", {
|
|
dir: tmp.extra.mod,
|
|
pkg: tmp.extra.file,
|
|
json,
|
|
}),
|
|
).toThrow("outside plugin directory")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("retries failed file plugins once after wait and keeps order", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const a = path.join(dir, "a")
|
|
const b = path.join(dir, "b")
|
|
const aSpec = pathToFileURL(a).href
|
|
const bSpec = pathToFileURL(b).href
|
|
await fs.mkdir(a, { recursive: true })
|
|
await fs.mkdir(b, { recursive: true })
|
|
return { a, b, aSpec, bSpec }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
let wait = 0
|
|
const calls: Array<[string, boolean]> = []
|
|
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
|
|
spec,
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
})),
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
|
|
await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
|
|
},
|
|
report: {
|
|
start(candidate, retry) {
|
|
calls.push([candidate.plan.spec, retry])
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(wait).toBe(1)
|
|
expect(calls).toEqual([
|
|
[tmp.extra.aSpec, false],
|
|
[tmp.extra.bSpec, false],
|
|
[tmp.extra.aSpec, true],
|
|
[tmp.extra.bSpec, true],
|
|
])
|
|
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not retry permanent file plugin entry errors", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "bad-entry")
|
|
const spec = pathToFileURL(mod).href
|
|
await fs.mkdir(mod, { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2),
|
|
)
|
|
return { spec }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
let wait = 0
|
|
const errors: Array<[string, boolean]> = []
|
|
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: tmp.extra.spec,
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
},
|
|
report: {
|
|
error(_candidate, retry, stage) {
|
|
errors.push([stage, retry])
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([])
|
|
expect(wait).toBe(0)
|
|
expect(errors).toEqual([["entry", false]])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not retry file plugins when finish returns undefined", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
await Bun.write(file, "export default {}\n")
|
|
return { spec }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
let wait = 0
|
|
let count = 0
|
|
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: tmp.extra.spec,
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
},
|
|
finish: async () => {
|
|
count += 1
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(wait).toBe(0)
|
|
expect(count).toBe(1)
|
|
expect(loaded).toEqual([])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not wait or retry npm plugin failures", () =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
let wait = 0
|
|
const errors: Array<[string, boolean]> = []
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: "test",
|
|
},
|
|
],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
},
|
|
report: {
|
|
error(_candidate, retry, stage) {
|
|
errors.push([stage, retry])
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([])
|
|
expect(wait).toBe(0)
|
|
expect(errors).toEqual([["install", false]])
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
)
|
|
})
|