mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-04 19:57:22 +00:00
Refactor plugin/config loading, add theme-only plugin package support (#20556)
This commit is contained in:
@@ -33,7 +33,7 @@ test("adds tui plugin at runtime from spec", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [],
|
||||
plugin_records: undefined,
|
||||
plugin_origins: undefined,
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
@@ -59,3 +59,49 @@ test("adds tui plugin at runtime from spec", async () => {
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
test("retries runtime add for file plugins after dependency wait", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "retry-plugin")
|
||||
const spec = pathToFileURL(mod).href
|
||||
const marker = path.join(dir, "retry-add.txt")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
return { mod, spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
||||
await Bun.write(
|
||||
path.join(tmp.extra.mod, "index.ts"),
|
||||
`export default {
|
||||
id: "demo.add.retry",
|
||||
tui: async () => {
|
||||
await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
})
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||
|
||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||
expect(wait).toHaveBeenCalledTimes(1)
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
get.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ test("installs plugin without loading it", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
||||
plugin: [],
|
||||
plugin_records: undefined,
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
const get = spyOn(TuiConfig, "get").mockImplementation(async () => cfg)
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
|
||||
@@ -46,9 +46,9 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -108,9 +108,9 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -171,9 +171,9 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -234,9 +234,9 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -293,9 +293,9 @@ test("does not use npm package main for tui entry", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -359,9 +359,9 @@ test("does not use directory package main for tui entry", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -407,9 +407,9 @@ test("uses directory index fallback for tui when package.json is missing", async
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: tmp.extra.spec,
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -465,9 +465,9 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
|
||||
@@ -39,9 +39,9 @@ test("skips external tui plugins in pure mode", async () => {
|
||||
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
|
||||
@@ -468,14 +468,14 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
||||
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
tmp.extra.bareSpec,
|
||||
],
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
{
|
||||
item: tmp.extra.bareSpec,
|
||||
spec: tmp.extra.bareSpec,
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
|
||||
@@ -44,9 +44,9 @@ test("toggles plugin runtime state by exported id", async () => {
|
||||
plugin_enabled: {
|
||||
"demo.toggle": false,
|
||||
},
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -122,9 +122,9 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||
plugin_enabled: {
|
||||
"demo.startup": false,
|
||||
},
|
||||
plugin_records: [
|
||||
plugin_origins: [
|
||||
{
|
||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
spec: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
||||
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../src/config/config"
|
||||
@@ -34,8 +34,13 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
beforeEach(async () => {
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
async function writeManagedSettings(settings: object, filename = "opencode.json") {
|
||||
@@ -169,7 +174,7 @@ test("loads JSONC config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("merges multiple config files with correct precedence", async () => {
|
||||
test("jsonc overrides json in the same directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(
|
||||
@@ -191,7 +196,7 @@ test("merges multiple config files with correct precedence", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.model).toBe("override")
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
})
|
||||
@@ -1174,6 +1179,51 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps plugin origins aligned with merged plugin list", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const project = path.join(dir, "project")
|
||||
const local = path.join(project, ".opencode")
|
||||
await fs.mkdir(local, { recursive: true })
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||
}),
|
||||
)
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(local, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
const plugins = cfg.plugin ?? []
|
||||
const origins = cfg.plugin_origins ?? []
|
||||
const names = plugins.map((item) => Config.pluginSpecifier(item))
|
||||
|
||||
expect(names).toContain("shared-plugin@2.0.0")
|
||||
expect(names).not.toContain("shared-plugin@1.0.0")
|
||||
expect(names).toContain("global-only@1.0.0")
|
||||
expect(names).toContain("local-only@1.0.0")
|
||||
|
||||
expect(origins.map((item) => item.spec)).toEqual(plugins)
|
||||
const hit = origins.find((item) => Config.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")
|
||||
expect(hit?.scope).toBe("local")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Legacy tools migration tests
|
||||
|
||||
test("migrates legacy tools config to permissions - allow", async () => {
|
||||
@@ -1550,7 +1600,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
init: async (dir) => {
|
||||
// Simulates a base config (like from remote .well-known) with disabled MCP
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
@@ -1569,7 +1619,7 @@ test("project config can override MCP server enabled status", async () => {
|
||||
)
|
||||
// Project config enables just jira
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
@@ -1608,7 +1658,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
init: async (dir) => {
|
||||
// Base config with full MCP definition
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
@@ -1625,7 +1675,7 @@ test("MCP config deep merges preserving base config properties", async () => {
|
||||
)
|
||||
// Override just enables it, should preserve other properties
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
@@ -1875,11 +1925,20 @@ describe("resolvePluginSpec", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("deduplicatePlugins", () => {
|
||||
describe("deduplicatePluginOrigins", () => {
|
||||
const dedupe = (plugins: Config.PluginSpec[]) =>
|
||||
Config.deduplicatePluginOrigins(
|
||||
plugins.map((spec) => ({
|
||||
spec,
|
||||
source: "",
|
||||
scope: "global" as const,
|
||||
})),
|
||||
).map((item) => item.spec)
|
||||
|
||||
test("removes duplicates keeping higher priority (later entries)", () => {
|
||||
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
const result = dedupe(plugins)
|
||||
|
||||
expect(result).toContain("global-plugin@1.0.0")
|
||||
expect(result).toContain("local-plugin@2.0.0")
|
||||
@@ -1891,7 +1950,7 @@ describe("deduplicatePlugins", () => {
|
||||
test("keeps path plugins separate from package plugins", () => {
|
||||
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
const result = dedupe(plugins)
|
||||
|
||||
expect(result).toEqual(plugins)
|
||||
})
|
||||
@@ -1899,7 +1958,7 @@ describe("deduplicatePlugins", () => {
|
||||
test("deduplicates direct path plugins by exact spec", () => {
|
||||
const plugins = ["file:///project/.opencode/plugin/demo.ts", "file:///project/.opencode/plugin/demo.ts"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
const result = dedupe(plugins)
|
||||
|
||||
expect(result).toEqual(["file:///project/.opencode/plugin/demo.ts"])
|
||||
})
|
||||
@@ -1907,7 +1966,7 @@ describe("deduplicatePlugins", () => {
|
||||
test("preserves order of remaining plugins", () => {
|
||||
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
|
||||
|
||||
const result = Config.deduplicatePlugins(plugins)
|
||||
const result = dedupe(plugins)
|
||||
|
||||
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
||||
})
|
||||
|
||||
@@ -1,20 +1,99 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
beforeEach(async () => {
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.OPENCODE_CONFIG
|
||||
delete process.env.OPENCODE_TUI_CONFIG
|
||||
await fs.rm(path.join(Global.Path.config, "opencode.json"), { force: true }).catch(() => {})
|
||||
await fs.rm(path.join(Global.Path.config, "opencode.jsonc"), { force: true }).catch(() => {})
|
||||
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
|
||||
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
|
||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||
await Config.invalidate(true)
|
||||
})
|
||||
|
||||
test("keeps server and tui plugin merge semantics aligned", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const local = path.join(dir, ".opencode")
|
||||
await fs.mkdir(local, { recursive: true })
|
||||
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(Global.Path.config, "tui.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
path.join(local, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(local, "tui.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await Config.get()
|
||||
const tui = await TuiConfig.get()
|
||||
const serverPlugins = (server.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||
const tuiPlugins = (tui.plugin ?? []).map((item) => Config.pluginSpecifier(item))
|
||||
|
||||
expect(serverPlugins).toEqual(tuiPlugins)
|
||||
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
||||
expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
|
||||
|
||||
const serverOrigins = server.plugin_origins ?? []
|
||||
const tuiOrigins = tui.plugin_origins ?? []
|
||||
expect(serverOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(serverPlugins)
|
||||
expect(tuiOrigins.map((item) => Config.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
|
||||
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads tui config with the same precedence order as server config paths", async () => {
|
||||
@@ -476,9 +555,9 @@ test("loads managed tui config and gives it highest precedence", async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.theme).toBe("managed-theme")
|
||||
expect(config.plugin).toEqual(["shared-plugin@2.0.0"])
|
||||
expect(config.plugin_records).toEqual([
|
||||
expect(config.plugin_origins).toEqual([
|
||||
{
|
||||
item: "shared-plugin@2.0.0",
|
||||
spec: "shared-plugin@2.0.0",
|
||||
scope: "global",
|
||||
source: path.join(managedConfigDir, "tui.json"),
|
||||
},
|
||||
@@ -540,9 +619,9 @@ test("supports tuple plugin specs with options in tui.json", async () => {
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||
expect(config.plugin_records).toEqual([
|
||||
expect(config.plugin_origins).toEqual([
|
||||
{
|
||||
item: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -580,14 +659,14 @@ test("deduplicates tuple plugin specs by name with higher precedence winning", a
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
])
|
||||
expect(config.plugin_records).toEqual([
|
||||
expect(config.plugin_origins).toEqual([
|
||||
{
|
||||
item: ["acme-plugin@2.0.0", { source: "project" }],
|
||||
spec: ["acme-plugin@2.0.0", { source: "project" }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
{
|
||||
item: ["second-plugin@3.0.0", { source: "project" }],
|
||||
spec: ["second-plugin@3.0.0", { source: "project" }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
@@ -619,14 +698,14 @@ test("tracks global and local plugin metadata in merged tui config", async () =>
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||
expect(config.plugin_records).toEqual([
|
||||
expect(config.plugin_origins).toEqual([
|
||||
{
|
||||
item: "global-plugin@1.0.0",
|
||||
spec: "global-plugin@1.0.0",
|
||||
scope: "global",
|
||||
source: path.join(Global.Path.config, "tui.json"),
|
||||
},
|
||||
{
|
||||
item: "local-plugin@2.0.0",
|
||||
spec: "local-plugin@2.0.0",
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
|
||||
@@ -6,14 +6,14 @@ type PluginSpec = string | [string, Record<string, unknown>]
|
||||
|
||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||
const plugin_records = plugin.map((item) => ({
|
||||
item,
|
||||
const plugin_origins = plugin.map((spec) => ({
|
||||
spec,
|
||||
scope: "local" as const,
|
||||
source: path.join(dir, "tui.json"),
|
||||
}))
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin,
|
||||
plugin_records,
|
||||
plugin_origins,
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||
|
||||
@@ -62,6 +62,7 @@ async function plugin(
|
||||
server?: Record<string, unknown>
|
||||
tui?: Record<string, unknown>
|
||||
},
|
||||
themes?: string[],
|
||||
) {
|
||||
const p = path.join(dir, "plugin")
|
||||
const server = kinds?.includes("server") ?? false
|
||||
@@ -92,6 +93,7 @@ async function plugin(
|
||||
version: "1.0.0",
|
||||
...(server ? { main: "./server.js" } : {}),
|
||||
...(Object.keys(exports).length ? { exports } : {}),
|
||||
...(themes?.length ? { "oc-themes": themes } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -438,6 +440,43 @@ describe("plugin.install.task", () => {
|
||||
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"])
|
||||
|
||||
@@ -9,6 +9,8 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Npm } = await import("../../src/npm")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
@@ -833,4 +835,302 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("reads oc-themes from package manifest", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: 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 }
|
||||
},
|
||||
})
|
||||
|
||||
const file = path.join(tmp.extra.mod, "package.json")
|
||||
const json = await Filesystem.readJson<Record<string, unknown>>(file)
|
||||
const list = readPackageThemes("acme-plugin", {
|
||||
dir: tmp.extra.mod,
|
||||
pkg: file,
|
||||
json,
|
||||
})
|
||||
|
||||
expect(list).toEqual([
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
||||
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
||||
])
|
||||
})
|
||||
|
||||
test("handles no-entrypoint tui packages via missing callback", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: 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 }
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
const missing: string[] = []
|
||||
|
||||
try {
|
||||
const loaded = await 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: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
expect(missing).toHaveLength(0)
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("passes package metadata for entrypoint tui plugins", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: 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 }
|
||||
},
|
||||
})
|
||||
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||
|
||||
try {
|
||||
const loaded = await 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: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects oc-themes path traversal", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: 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 }
|
||||
},
|
||||
})
|
||||
|
||||
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
|
||||
expect(() =>
|
||||
readPackageThemes("acme", {
|
||||
dir: tmp.extra.mod,
|
||||
pkg: tmp.extra.file,
|
||||
json,
|
||||
}),
|
||||
).toThrow("outside plugin directory")
|
||||
})
|
||||
|
||||
test("retries failed file plugins once after wait and keeps order", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: 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 }
|
||||
},
|
||||
})
|
||||
|
||||
let wait = 0
|
||||
const calls: Array<[string, boolean]> = []
|
||||
|
||||
const loaded = await 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])
|
||||
})
|
||||
|
||||
test("retries file plugins when finish returns undefined", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
await Bun.write(file, "export default {}\n")
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
let wait = 0
|
||||
let count = 0
|
||||
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: tmp.extra.spec,
|
||||
scope: "local" as const,
|
||||
source: tmp.path,
|
||||
},
|
||||
],
|
||||
kind: "tui",
|
||||
wait: async () => {
|
||||
wait += 1
|
||||
},
|
||||
finish: async (load, _item, retry) => {
|
||||
count += 1
|
||||
if (!retry) return
|
||||
return {
|
||||
retry,
|
||||
spec: load.spec,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
expect(wait).toBe(1)
|
||||
expect(count).toBe(2)
|
||||
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
|
||||
})
|
||||
|
||||
test("does not wait or retry npm plugin failures", async () => {
|
||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||
let wait = 0
|
||||
const errors: Array<[string, boolean]> = []
|
||||
|
||||
try {
|
||||
const loaded = await 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,95 @@ describe("filesystem", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("findUp()", () => {
|
||||
test("keeps previous nearest-first behavior for single target", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp("marker", child, tmp.path)
|
||||
|
||||
expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")])
|
||||
})
|
||||
|
||||
test("respects stop boundary", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp("marker", child, parent)
|
||||
|
||||
expect(result).toEqual([path.join(parent, "marker")])
|
||||
})
|
||||
|
||||
test("supports multiple targets with nearest-first default ordering", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path)
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(parent, "cfg.jsonc"),
|
||||
path.join(tmp.path, "cfg.json"),
|
||||
path.join(tmp.path, "cfg.jsonc"),
|
||||
])
|
||||
})
|
||||
|
||||
test("supports rootFirst ordering for multiple targets", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true })
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(tmp.path, "cfg.json"),
|
||||
path.join(tmp.path, "cfg.jsonc"),
|
||||
path.join(parent, "cfg.jsonc"),
|
||||
])
|
||||
})
|
||||
|
||||
test("rootFirst preserves json then jsonc order per directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "nested")
|
||||
await fs.mkdir(nested, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, {
|
||||
rootFirst: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
path.join(project, "opencode.json"),
|
||||
path.join(project, "opencode.jsonc"),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("readText()", () => {
|
||||
test("reads file content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
Reference in New Issue
Block a user