1236 lines
33 KiB
TypeScript
1236 lines
33 KiB
TypeScript
import { test, expect, describe, mock } from "bun:test"
|
|
import { Config } from "../../src/config/config"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { Auth } from "../../src/auth"
|
|
import { tmpdir } from "../fixture/fixture"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
import { pathToFileURL } from "url"
|
|
|
|
test("loads config with defaults when no files exist", async () => {
|
|
await using tmp = await tmpdir()
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.username).toBeDefined()
|
|
},
|
|
})
|
|
})
|
|
|
|
test("loads JSON config file", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
model: "test/model",
|
|
username: "testuser",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.model).toBe("test/model")
|
|
expect(config.username).toBe("testuser")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("loads JSONC config file", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.jsonc"),
|
|
`{
|
|
// This is a comment
|
|
"$schema": "https://opencode.ai/config.json",
|
|
"model": "test/model",
|
|
"username": "testuser"
|
|
}`,
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.model).toBe("test/model")
|
|
expect(config.username).toBe("testuser")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("merges multiple config files with correct precedence", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.jsonc"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
model: "base",
|
|
username: "base",
|
|
}),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
model: "override",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.model).toBe("override")
|
|
expect(config.username).toBe("base")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles environment variable substitution", async () => {
|
|
const originalEnv = process.env["TEST_VAR"]
|
|
process.env["TEST_VAR"] = "test_theme"
|
|
|
|
try {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
theme: "{env:TEST_VAR}",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.theme).toBe("test_theme")
|
|
},
|
|
})
|
|
} finally {
|
|
if (originalEnv !== undefined) {
|
|
process.env["TEST_VAR"] = originalEnv
|
|
} else {
|
|
delete process.env["TEST_VAR"]
|
|
}
|
|
}
|
|
})
|
|
|
|
test("handles file inclusion substitution", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
theme: "{file:included.txt}",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.theme).toBe("test_theme")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("validates config schema and throws on invalid fields", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
invalid_field: "should cause error",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
// Strict schema should throw an error for invalid fields
|
|
await expect(Config.get()).rejects.toThrow()
|
|
},
|
|
})
|
|
})
|
|
|
|
test("throws error for invalid JSON", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
await expect(Config.get()).rejects.toThrow()
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles agent configuration", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test_agent: {
|
|
model: "test/model",
|
|
temperature: 0.7,
|
|
description: "test agent",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test_agent"]).toEqual(
|
|
expect.objectContaining({
|
|
model: "test/model",
|
|
temperature: 0.7,
|
|
description: "test agent",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles command configuration", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
command: {
|
|
test_command: {
|
|
template: "test template",
|
|
description: "test command",
|
|
agent: "test_agent",
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.command?.["test_command"]).toEqual({
|
|
template: "test template",
|
|
description: "test command",
|
|
agent: "test_agent",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates autoshare to share field", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
autoshare: true,
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.share).toBe("auto")
|
|
expect(config.autoshare).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates mode field to agent field", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mode: {
|
|
test_mode: {
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test_mode"]).toEqual({
|
|
model: "test/model",
|
|
temperature: 0.5,
|
|
mode: "primary",
|
|
options: {},
|
|
permission: {},
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("loads config from .opencode directory", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const opencodeDir = path.join(dir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
const agentDir = path.join(opencodeDir, "agent")
|
|
await fs.mkdir(agentDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(agentDir, "test.md"),
|
|
`---
|
|
model: test/model
|
|
---
|
|
Test agent prompt`,
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]).toEqual(
|
|
expect.objectContaining({
|
|
name: "test",
|
|
model: "test/model",
|
|
prompt: "Test agent prompt",
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("updates config and writes to file", async () => {
|
|
await using tmp = await tmpdir()
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const newConfig = { model: "updated/model" }
|
|
await Config.update(newConfig as any)
|
|
|
|
const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
|
|
expect(writtenConfig.model).toBe("updated/model")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("gets config directories", async () => {
|
|
await using tmp = await tmpdir()
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const dirs = await Config.directories()
|
|
expect(dirs.length).toBeGreaterThanOrEqual(1)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("resolves scoped npm plugins in config", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
|
|
await fs.mkdir(pluginDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(dir, "package.json"),
|
|
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "@scope/plugin",
|
|
version: "1.0.0",
|
|
type: "module",
|
|
main: "./index.js",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
|
|
)
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const pluginEntries = config.plugin ?? []
|
|
|
|
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
|
|
const expected = import.meta.resolve("@scope/plugin", baseUrl)
|
|
|
|
expect(pluginEntries.includes(expected)).toBe(true)
|
|
|
|
const scopedEntry = pluginEntries.find((entry) => entry === expected)
|
|
expect(scopedEntry).toBeDefined()
|
|
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("merges plugin arrays from global and local configs", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Create a nested project structure with local .opencode config
|
|
const projectDir = path.join(dir, "project")
|
|
const opencodeDir = path.join(projectDir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
|
|
// Global config with plugins
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
plugin: ["global-plugin-1", "global-plugin-2"],
|
|
}),
|
|
)
|
|
|
|
// Local .opencode config with different plugins
|
|
await Bun.write(
|
|
path.join(opencodeDir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
plugin: ["local-plugin-1"],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: path.join(tmp.path, "project"),
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const plugins = config.plugin ?? []
|
|
|
|
// Should contain both global and local plugins
|
|
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
|
|
|
// Should have all 3 plugins (not replaced, but merged)
|
|
const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
|
|
expect(pluginNames.length).toBeGreaterThanOrEqual(3)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("does not error when only custom agent is a subagent", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const opencodeDir = path.join(dir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
const agentDir = path.join(opencodeDir, "agent")
|
|
await fs.mkdir(agentDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(agentDir, "helper.md"),
|
|
`---
|
|
model: test/model
|
|
mode: subagent
|
|
---
|
|
Helper subagent prompt`,
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["helper"]).toMatchObject({
|
|
name: "helper",
|
|
model: "test/model",
|
|
mode: "subagent",
|
|
prompt: "Helper subagent prompt",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("merges instructions arrays from global and local configs", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const projectDir = path.join(dir, "project")
|
|
const opencodeDir = path.join(projectDir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
instructions: ["global-instructions.md", "shared-rules.md"],
|
|
}),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(opencodeDir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
instructions: ["local-instructions.md"],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: path.join(tmp.path, "project"),
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const instructions = config.instructions ?? []
|
|
|
|
expect(instructions).toContain("global-instructions.md")
|
|
expect(instructions).toContain("shared-rules.md")
|
|
expect(instructions).toContain("local-instructions.md")
|
|
expect(instructions.length).toBe(3)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("deduplicates duplicate instructions from global and local configs", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const projectDir = path.join(dir, "project")
|
|
const opencodeDir = path.join(projectDir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
instructions: ["duplicate.md", "global-only.md"],
|
|
}),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(opencodeDir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
instructions: ["duplicate.md", "local-only.md"],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: path.join(tmp.path, "project"),
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const instructions = config.instructions ?? []
|
|
|
|
expect(instructions).toContain("global-only.md")
|
|
expect(instructions).toContain("local-only.md")
|
|
expect(instructions).toContain("duplicate.md")
|
|
|
|
const duplicates = instructions.filter((i) => i === "duplicate.md")
|
|
expect(duplicates.length).toBe(1)
|
|
expect(instructions.length).toBe(3)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("deduplicates duplicate plugins from global and local configs", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Create a nested project structure with local .opencode config
|
|
const projectDir = path.join(dir, "project")
|
|
const opencodeDir = path.join(projectDir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
|
|
// Global config with plugins
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
plugin: ["duplicate-plugin", "global-plugin-1"],
|
|
}),
|
|
)
|
|
|
|
// Local .opencode config with some overlapping plugins
|
|
await Bun.write(
|
|
path.join(opencodeDir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
plugin: ["duplicate-plugin", "local-plugin-1"],
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: path.join(tmp.path, "project"),
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const plugins = config.plugin ?? []
|
|
|
|
// Should contain all unique plugins
|
|
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
|
|
expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
|
|
|
|
// Should deduplicate the duplicate plugin
|
|
const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
|
|
expect(duplicatePlugins.length).toBe(1)
|
|
|
|
// Should have exactly 3 unique plugins
|
|
const pluginNames = plugins.filter(
|
|
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
|
|
)
|
|
expect(pluginNames.length).toBe(3)
|
|
},
|
|
})
|
|
})
|
|
|
|
// Legacy tools migration tests
|
|
|
|
test("migrates legacy tools config to permissions - allow", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
bash: true,
|
|
read: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "allow",
|
|
read: "allow",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates legacy tools config to permissions - deny", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
bash: false,
|
|
webfetch: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "deny",
|
|
webfetch: "deny",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates legacy write tool to edit permission", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
write: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
edit: "allow",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates legacy edit tool to edit permission", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
edit: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
edit: "deny",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates legacy patch tool to edit permission", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
patch: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
edit: "allow",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates legacy multiedit tool to edit permission", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
multiedit: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
edit: "deny",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("migrates mixed legacy tools config", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
tools: {
|
|
bash: true,
|
|
write: true,
|
|
read: false,
|
|
webfetch: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
bash: "allow",
|
|
edit: "allow",
|
|
read: "deny",
|
|
webfetch: "allow",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("merges legacy tools with existing permission config", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
agent: {
|
|
test: {
|
|
permission: {
|
|
glob: "allow",
|
|
},
|
|
tools: {
|
|
bash: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.agent?.["test"]?.permission).toEqual({
|
|
glob: "allow",
|
|
bash: "allow",
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("permission config preserves key order", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
permission: {
|
|
"*": "deny",
|
|
edit: "ask",
|
|
write: "ask",
|
|
external_directory: "ask",
|
|
read: "allow",
|
|
todowrite: "allow",
|
|
todoread: "allow",
|
|
"thoughts_*": "allow",
|
|
"reasoning_model_*": "allow",
|
|
"tools_*": "allow",
|
|
"pr_comments_*": "allow",
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(Object.keys(config.permission!)).toEqual([
|
|
"*",
|
|
"edit",
|
|
"write",
|
|
"external_directory",
|
|
"read",
|
|
"todowrite",
|
|
"todoread",
|
|
"thoughts_*",
|
|
"reasoning_model_*",
|
|
"tools_*",
|
|
"pr_comments_*",
|
|
])
|
|
},
|
|
})
|
|
})
|
|
|
|
// MCP config merging tests
|
|
|
|
test("project config can override MCP server enabled status", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Simulates a base config (like from remote .well-known) with disabled MCP
|
|
await Bun.write(
|
|
path.join(dir, "opencode.jsonc"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
wiki: {
|
|
type: "remote",
|
|
url: "https://wiki.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
// Project config enables just jira
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
// jira should be enabled (overridden by project config)
|
|
expect(config.mcp?.jira).toEqual({
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: true,
|
|
})
|
|
// wiki should still be disabled (not overridden)
|
|
expect(config.mcp?.wiki).toEqual({
|
|
type: "remote",
|
|
url: "https://wiki.example.com/mcp",
|
|
enabled: false,
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("MCP config deep merges preserving base config properties", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Base config with full MCP definition
|
|
await Bun.write(
|
|
path.join(dir, "opencode.jsonc"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
myserver: {
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: false,
|
|
headers: {
|
|
"X-Custom-Header": "value",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
// Override just enables it, should preserve other properties
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
myserver: {
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.mcp?.myserver).toEqual({
|
|
type: "remote",
|
|
url: "https://myserver.example.com/mcp",
|
|
enabled: true,
|
|
headers: {
|
|
"X-Custom-Header": "value",
|
|
},
|
|
})
|
|
},
|
|
})
|
|
})
|
|
|
|
test("local .opencode config can override MCP from project config", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
// Project config with disabled MCP
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
docs: {
|
|
type: "remote",
|
|
url: "https://docs.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
// Local .opencode directory config enables it
|
|
const opencodeDir = path.join(dir, ".opencode")
|
|
await fs.mkdir(opencodeDir, { recursive: true })
|
|
await Bun.write(
|
|
path.join(opencodeDir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
docs: {
|
|
type: "remote",
|
|
url: "https://docs.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
expect(config.mcp?.docs?.enabled).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("project config overrides remote well-known config", async () => {
|
|
const originalFetch = globalThis.fetch
|
|
let fetchedUrl: string | undefined
|
|
const mockFetch = mock((url: string | URL | Request) => {
|
|
const urlStr = url.toString()
|
|
if (urlStr.includes(".well-known/opencode")) {
|
|
fetchedUrl = urlStr
|
|
return Promise.resolve(
|
|
new Response(
|
|
JSON.stringify({
|
|
config: {
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
)
|
|
}
|
|
return originalFetch(url)
|
|
})
|
|
globalThis.fetch = mockFetch as unknown as typeof fetch
|
|
|
|
const originalAuthAll = Auth.all
|
|
Auth.all = mock(() =>
|
|
Promise.resolve({
|
|
"https://example.com": {
|
|
type: "wellknown" as const,
|
|
key: "TEST_TOKEN",
|
|
token: "test-token",
|
|
},
|
|
}),
|
|
)
|
|
|
|
try {
|
|
await using tmp = await tmpdir({
|
|
git: true,
|
|
init: async (dir) => {
|
|
// Project config enables jira (overriding remote default)
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
mcp: {
|
|
jira: {
|
|
type: "remote",
|
|
url: "https://jira.example.com/mcp",
|
|
enabled: true,
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
},
|
|
})
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
// Verify fetch was called for wellknown config
|
|
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
|
// Project config (enabled: true) should override remote (enabled: false)
|
|
expect(config.mcp?.jira?.enabled).toBe(true)
|
|
},
|
|
})
|
|
} finally {
|
|
globalThis.fetch = originalFetch
|
|
Auth.all = originalAuthAll
|
|
}
|
|
})
|
|
|
|
describe("getPluginName", () => {
|
|
test("extracts name from file:// URL", () => {
|
|
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
|
|
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
|
|
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
|
|
})
|
|
|
|
test("extracts name from npm package with version", () => {
|
|
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
|
|
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
|
|
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
|
|
})
|
|
|
|
test("extracts name from scoped npm package", () => {
|
|
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
|
|
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
|
|
})
|
|
|
|
test("returns full string for package without version", () => {
|
|
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
|
|
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
|
|
})
|
|
})
|
|
|
|
describe("deduplicatePlugins", () => {
|
|
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)
|
|
|
|
expect(result).toContain("global-plugin@1.0.0")
|
|
expect(result).toContain("local-plugin@2.0.0")
|
|
expect(result).toContain("shared-plugin@2.0.0")
|
|
expect(result).not.toContain("shared-plugin@1.0.0")
|
|
expect(result.length).toBe(3)
|
|
})
|
|
|
|
test("prefers local file over npm package with same name", () => {
|
|
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
|
|
|
|
const result = Config.deduplicatePlugins(plugins)
|
|
|
|
expect(result.length).toBe(1)
|
|
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
|
|
})
|
|
|
|
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)
|
|
|
|
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
|
|
})
|
|
|
|
test("local plugin directory overrides global opencode.json plugin", async () => {
|
|
await using tmp = await tmpdir({
|
|
init: async (dir) => {
|
|
const projectDir = path.join(dir, "project")
|
|
const opencodeDir = path.join(projectDir, ".opencode")
|
|
const pluginDir = path.join(opencodeDir, "plugin")
|
|
await fs.mkdir(pluginDir, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({
|
|
$schema: "https://opencode.ai/config.json",
|
|
plugin: ["my-plugin@1.0.0"],
|
|
}),
|
|
)
|
|
|
|
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
|
|
},
|
|
})
|
|
|
|
await Instance.provide({
|
|
directory: path.join(tmp.path, "project"),
|
|
fn: async () => {
|
|
const config = await Config.get()
|
|
const plugins = config.plugin ?? []
|
|
|
|
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
|
|
expect(myPlugins.length).toBe(1)
|
|
expect(myPlugins[0].startsWith("file://")).toBe(true)
|
|
},
|
|
})
|
|
})
|
|
})
|