Skip to content

Commit 3682053

Browse files
unknownguazi04
authored andcommitted
fix(opencode): prevent plugin deduplication collision for index.js entry points
getPluginName() now uses a 3-tier resolution for file:// URLs: 1. Walk up to find package.json name (stops at .opencode boundary) 2. Use filename if not "index" 3. For index entry points, walk up skipping generic dirs (src/dist/lib/build/out/esm/cjs) Also uses fileURLToPath() for cross-platform correctness. Fixes #11159
1 parent 0541d75 commit 3682053

2 files changed

Lines changed: 116 additions & 6 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { LSPServer } from "../lsp/server"
2424
import { BunProc } from "@/bun"
2525
import { Installation } from "@/installation"
2626
import { ConfigMarkdown } from "./markdown"
27-
import { constants, existsSync } from "fs"
27+
import { constants, existsSync, readFileSync } from "fs"
2828
import { Bus } from "@/bus"
2929
import { GlobalBus } from "@/bus/global"
3030
import { Event } from "../server/event"
@@ -462,23 +462,67 @@ export namespace Config {
462462
return plugins
463463
}
464464

465+
function findPackageName(fp: string): string | undefined {
466+
let dir = path.dirname(fp)
467+
const root = path.parse(dir).root
468+
for (let i = 0; i < 5 && dir !== root; i++) {
469+
// Don't escape .opencode boundary — host project's package.json is irrelevant
470+
if (path.basename(dir) === ".opencode") return undefined
471+
const pkg = path.join(dir, "package.json")
472+
if (existsSync(pkg)) {
473+
try {
474+
const data = JSON.parse(readFileSync(pkg, "utf-8"))
475+
if (typeof data.name === "string") return data.name
476+
} catch {}
477+
}
478+
dir = path.dirname(dir)
479+
}
480+
return undefined
481+
}
482+
465483
/**
466484
* Extracts a canonical plugin name from a plugin specifier.
467-
* - For file:// URLs: extracts filename without extension
485+
* - For file:// URLs:
486+
* 1. Reads nearest package.json `name` field (walks up max 5 levels)
487+
* 2. Falls back to filename if not "index"
488+
* 3. For "index" entry points, walks up directories skipping
489+
* src/dist/lib/build/out/esm/cjs to find a meaningful name
468490
* - For npm packages: extracts package name without version
469491
*
470492
* @example
471493
* getPluginName("file:///path/to/plugin/foo.js") // "foo"
494+
* getPluginName("file:///path/to/my-plugin/src/index.ts") // "my-plugin" (via package.json or dir heuristic)
495+
* getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode"
472496
* getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode"
473497
* getPluginName("@scope/pkg@1.0.0") // "@scope/pkg"
474498
*/
475499
export function getPluginName(plugin: string): string {
476500
if (plugin.startsWith("file://")) {
477-
return path.parse(new URL(plugin).pathname).name
501+
const fp = fileURLToPath(plugin)
502+
503+
// Best: use package.json name
504+
const pkg = findPackageName(fp)
505+
if (pkg) return pkg
506+
507+
// Fallback: use filename, skip generic names
508+
const parsed = path.parse(fp)
509+
if (parsed.name !== "index") return parsed.name
510+
511+
// Walk up to find a meaningful directory name
512+
const skip = new Set(["src", "dist", "lib", "build", "out", "esm", "cjs"])
513+
let dir = parsed.dir
514+
const root = path.parse(dir).root
515+
for (let i = 0; i < 5 && dir !== root; i++) {
516+
const name = path.basename(dir)
517+
if (!skip.has(name)) return name
518+
dir = path.dirname(dir)
519+
}
520+
521+
return parsed.name
478522
}
479-
const lastAt = plugin.lastIndexOf("@")
480-
if (lastAt > 0) {
481-
return plugin.substring(0, lastAt)
523+
const last = plugin.lastIndexOf("@")
524+
if (last > 0) {
525+
return plugin.substring(0, last)
482526
}
483527
return plugin
484528
}

packages/opencode/test/config/config.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,59 @@ describe("getPluginName", () => {
16221622
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
16231623
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
16241624
})
1625+
1626+
test("uses directory heuristic for index.ts in src/", () => {
1627+
expect(Config.getPluginName("file:///path/to/my-plugin/src/index.ts")).toBe("my-plugin")
1628+
})
1629+
1630+
test("skips dist/ for index.js entry points", () => {
1631+
expect(Config.getPluginName("file:///path/to/plugin/dist/index.js")).toBe("plugin")
1632+
})
1633+
1634+
test("skips build/out/esm/cjs for index entry points", () => {
1635+
expect(Config.getPluginName("file:///path/to/mypkg/build/index.js")).toBe("mypkg")
1636+
expect(Config.getPluginName("file:///path/to/mypkg/out/index.js")).toBe("mypkg")
1637+
expect(Config.getPluginName("file:///path/to/mypkg/esm/index.js")).toBe("mypkg")
1638+
expect(Config.getPluginName("file:///path/to/mypkg/cjs/index.js")).toBe("mypkg")
1639+
})
1640+
1641+
test(".opencode/plugin scripts use filename, not host package.json", async () => {
1642+
await using tmp = await tmpdir({
1643+
init: async (dir) => {
1644+
await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" }))
1645+
const pluginDir = path.join(dir, ".opencode", "plugin")
1646+
await fs.mkdir(pluginDir, { recursive: true })
1647+
await Filesystem.write(path.join(pluginDir, "a.js"), "export default {}")
1648+
await Filesystem.write(path.join(pluginDir, "b.js"), "export default {}")
1649+
},
1650+
})
1651+
1652+
const urlA = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "a.js")).href
1653+
const urlB = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "b.js")).href
1654+
1655+
expect(Config.getPluginName(urlA)).toBe("a")
1656+
expect(Config.getPluginName(urlB)).toBe("b")
1657+
1658+
const result = Config.deduplicatePlugins([urlA, urlB])
1659+
expect(result.length).toBe(2)
1660+
})
1661+
1662+
test("real plugin with package.json resolves to package name", async () => {
1663+
await using tmp = await tmpdir({
1664+
init: async (dir) => {
1665+
const pluginDir = path.join(dir, "my-plugin", "src")
1666+
await fs.mkdir(pluginDir, { recursive: true })
1667+
await Filesystem.write(
1668+
path.join(dir, "my-plugin", "package.json"),
1669+
JSON.stringify({ name: "my-plugin" }),
1670+
)
1671+
await Filesystem.write(path.join(pluginDir, "index.ts"), "export default {}")
1672+
},
1673+
})
1674+
1675+
const url = pathToFileURL(path.join(tmp.path, "my-plugin", "src", "index.ts")).href
1676+
expect(Config.getPluginName(url)).toBe("my-plugin")
1677+
})
16251678
})
16261679

16271680
describe("deduplicatePlugins", () => {
@@ -1646,6 +1699,19 @@ describe("deduplicatePlugins", () => {
16461699
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
16471700
})
16481701

1702+
test("keeps all index.js plugins from different directories", () => {
1703+
const plugins = [
1704+
"file:///path/to/alpha/src/index.ts",
1705+
"file:///path/to/beta/dist/index.js",
1706+
"file:///path/to/gamma/lib/index.js",
1707+
]
1708+
1709+
const result = Config.deduplicatePlugins(plugins)
1710+
1711+
// Each has a distinct directory name, so all 3 should survive
1712+
expect(result.length).toBe(3)
1713+
})
1714+
16491715
test("preserves order of remaining plugins", () => {
16501716
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
16511717

0 commit comments

Comments
 (0)