diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225a..a9b17b9ac6a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -24,7 +24,7 @@ import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" -import { constants, existsSync } from "fs" +import { constants, existsSync, readFileSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" @@ -462,23 +462,69 @@ export namespace Config { return plugins } + function findPackageName(fp: string): string | undefined { + let dir = path.dirname(fp) + const root = path.parse(dir).root + for (let i = 0; i < 5 && dir !== root; i++) { + // Don't escape .opencode boundary — host project's package.json is irrelevant + if (path.basename(dir) === ".opencode") return undefined + const pkg = path.join(dir, "package.json") + if (existsSync(pkg)) { + try { + const data = JSON.parse(readFileSync(pkg, "utf-8")) + if (typeof data.name === "string") return data.name + } catch {} + } + dir = path.dirname(dir) + } + return undefined + } + /** * Extracts a canonical plugin name from a plugin specifier. - * - For file:// URLs: extracts filename without extension + * - For file:// URLs: + * 1. Reads nearest package.json `name` field (walks up max 5 levels) + * 2. Falls back to filename if not "index" + * 3. For "index" entry points, walks up directories skipping + * src/dist/lib/build/out/esm/cjs to find a meaningful name * - For npm packages: extracts package name without version * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" + * getPluginName("file:///path/to/my-plugin/src/index.ts") // "my-plugin" (via package.json or dir heuristic) + * getPluginName("file:///path/to/oh-my-opencode/dist/index.js") // "oh-my-opencode" * 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 fp = fileURLToPath(plugin) + + // Best: use package.json name + const pkg = findPackageName(fp) + if (pkg) return pkg + + // Fallback: use filename, skip generic names + // Use URL pathname for name extraction (always posix-style /) + const pathname = new URL(plugin).pathname + const parsed = path.posix.parse(pathname) + if (parsed.name !== "index") return parsed.name + + // Walk up to find a meaningful directory name + const skip = new Set(["src", "dist", "lib", "build", "out", "esm", "cjs"]) + let dir = parsed.dir + const root = path.posix.parse(dir).root + for (let i = 0; i < 5 && dir !== root; i++) { + const name = path.posix.basename(dir) + if (!skip.has(name)) return name + dir = path.posix.dirname(dir) + } + + return parsed.name } - const lastAt = plugin.lastIndexOf("@") - if (lastAt > 0) { - return plugin.substring(0, lastAt) + const last = plugin.lastIndexOf("@") + if (last > 0) { + return plugin.substring(0, last) } return plugin } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 40ab97449fb..256689678d5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,10 +8,17 @@ import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" import { Filesystem } from "../../src/util/filesystem" - // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +// Generate a platform-correct file:// URL for testing +// On Windows: file:///C:/path/to/... On Unix: file:///path/to/... +function testFileURL(...segments: string[]): string { + return pathToFileURL(path.resolve(path.sep, ...segments)).href +} + + + afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) }) @@ -1602,9 +1609,9 @@ test("wellknown URL with trailing slash is normalized", async () => { 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") + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "foo.js"))).toBe("foo") + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "bar.ts"))).toBe("bar") + expect(Config.getPluginName(testFileURL("some", "path", "my-plugin.js"))).toBe("my-plugin") }) test("extracts name from npm package with version", () => { @@ -1622,6 +1629,59 @@ describe("getPluginName", () => { expect(Config.getPluginName("some-plugin")).toBe("some-plugin") expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg") }) + + test("uses directory heuristic for index.ts in src/", () => { + expect(Config.getPluginName(testFileURL("path", "to", "my-plugin", "src", "index.ts"))).toBe("my-plugin") + }) + + test("skips dist/ for index.js entry points", () => { + expect(Config.getPluginName(testFileURL("path", "to", "plugin", "dist", "index.js"))).toBe("plugin") + }) + + test("skips build/out/esm/cjs for index entry points", () => { + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "build", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "out", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "esm", "index.js"))).toBe("mypkg") + expect(Config.getPluginName(testFileURL("path", "to", "mypkg", "cjs", "index.js"))).toBe("mypkg") + }) + + test(".opencode/plugin scripts use filename, not host package.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "package.json"), JSON.stringify({ name: "my-app" })) + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + await Filesystem.write(path.join(pluginDir, "a.js"), "export default {}") + await Filesystem.write(path.join(pluginDir, "b.js"), "export default {}") + }, + }) + + const urlA = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "a.js")).href + const urlB = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "b.js")).href + + expect(Config.getPluginName(urlA)).toBe("a") + expect(Config.getPluginName(urlB)).toBe("b") + + const result = Config.deduplicatePlugins([urlA, urlB]) + expect(result.length).toBe(2) + }) + + test("real plugin with package.json resolves to package name", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "my-plugin", "src") + await fs.mkdir(pluginDir, { recursive: true }) + await Filesystem.write( + path.join(dir, "my-plugin", "package.json"), + JSON.stringify({ name: "my-plugin" }), + ) + await Filesystem.write(path.join(pluginDir, "index.ts"), "export default {}") + }, + }) + + const url = pathToFileURL(path.join(tmp.path, "my-plugin", "src", "index.ts")).href + expect(Config.getPluginName(url)).toBe("my-plugin") + }) }) describe("deduplicatePlugins", () => { @@ -1638,12 +1698,25 @@ describe("deduplicatePlugins", () => { }) 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 plugins = ["oh-my-opencode@2.4.3", testFileURL("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") + expect(result[0]).toBe(testFileURL("project", ".opencode", "plugin", "oh-my-opencode.js")) + }) + + test("keeps all index.js plugins from different directories", () => { + const plugins = [ + testFileURL("path", "to", "alpha", "src", "index.ts"), + testFileURL("path", "to", "beta", "dist", "index.js"), + testFileURL("path", "to", "gamma", "lib", "index.js"), + ] + + const result = Config.deduplicatePlugins(plugins) + + // Each has a distinct directory name, so all 3 should survive + expect(result.length).toBe(3) }) test("preserves order of remaining plugins", () => {