mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
feat(config): deduplicate plugins by name with priority-based resolution (#5957)
This commit is contained in:
@@ -178,6 +178,8 @@ export namespace Config {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
@@ -332,6 +334,58 @@ export namespace Config {
|
||||
return plugins
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a canonical plugin name from a plugin specifier.
|
||||
* - For file:// URLs: extracts filename without extension
|
||||
* - For npm packages: extracts package name without version
|
||||
*
|
||||
* @example
|
||||
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
|
||||
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
|
||||
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
|
||||
*/
|
||||
export function getPluginName(plugin: string): string {
|
||||
if (plugin.startsWith("file://")) {
|
||||
return path.parse(new URL(plugin).pathname).name
|
||||
}
|
||||
const lastAt = plugin.lastIndexOf("@")
|
||||
if (lastAt > 0) {
|
||||
return plugin.substring(0, lastAt)
|
||||
}
|
||||
return plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates plugins by name, with later entries (higher priority) winning.
|
||||
* Priority order (highest to lowest):
|
||||
* 1. Local plugin/ directory
|
||||
* 2. Local opencode.json
|
||||
* 3. Global plugin/ directory
|
||||
* 4. Global opencode.json
|
||||
*
|
||||
* Since plugins are added in low-to-high priority order,
|
||||
* we reverse, deduplicate (keeping first occurrence), then restore order.
|
||||
*/
|
||||
export function deduplicatePlugins(plugins: string[]): string[] {
|
||||
// seenNames: canonical plugin names for duplicate detection
|
||||
// e.g., "oh-my-opencode", "@scope/pkg"
|
||||
const seenNames = new Set<string>()
|
||||
|
||||
// uniqueSpecifiers: full plugin specifiers to return
|
||||
// e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js"
|
||||
const uniqueSpecifiers: string[] = []
|
||||
|
||||
for (const specifier of plugins.toReversed()) {
|
||||
const name = getPluginName(specifier)
|
||||
if (!seenNames.has(name)) {
|
||||
seenNames.add(name)
|
||||
uniqueSpecifiers.push(specifier)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueSpecifiers.toReversed()
|
||||
}
|
||||
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, mock, afterEach } from "bun:test"
|
||||
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"
|
||||
@@ -1145,3 +1145,91 @@ test("project config overrides remote well-known config", async () => {
|
||||
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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user