Refactor plugin/config loading, add theme-only plugin package support (#20556)

This commit is contained in:
Sebastian
2026-04-02 01:50:22 +02:00
committed by GitHub
parent 854484babf
commit f6fd43e574
24 changed files with 1246 additions and 539 deletions

View File

@@ -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"])
})

View File

@@ -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"),
},