From 4b04c3c8c02aa02256666902499fdc2d207ad299 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Apr 2026 01:48:43 +0900 Subject: [PATCH] Fix local plugin registration for OpenCode 1.4 --- .../add-plugin-to-opencode-config.ts | 33 ++++---- .../config-manager/detect-current-config.ts | 10 ++- .../config-manager/plugin-detection.test.ts | 59 +++++++++++-- .../config-manager/preferred-plugin-entry.ts | 51 ++++++++++++ src/cli/doctor/checks/system-plugin.test.ts | 71 ++++++++++++++++ src/cli/doctor/checks/system-plugin.ts | 10 ++- src/shared/index.ts | 1 + src/shared/local-plugin-entry.ts | 83 +++++++++++++++++++ 8 files changed, 292 insertions(+), 26 deletions(-) create mode 100644 src/cli/config-manager/preferred-plugin-entry.ts create mode 100644 src/cli/doctor/checks/system-plugin.test.ts create mode 100644 src/shared/local-plugin-entry.ts diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 8cb7d0838..1d885fa85 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -1,15 +1,27 @@ import { readFileSync, writeFileSync } from "node:fs" import type { ConfigMergeResult } from "../types" -import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared" +import { + PLUGIN_NAME, + LEGACY_PLUGIN_NAME, + isAcceptedLocalPluginEntry, +} from "../../shared" import { backupConfigFile } from "./backup-config" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" import { formatErrorWithSuggestion } from "./format-error-with-suggestion" import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" -import { getPluginNameWithVersion } from "./plugin-name-with-version" +import { getPreferredPluginEntry } from "./preferred-plugin-entry" import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility" +function isOurPluginEntry(entry: string): boolean { + return entry === PLUGIN_NAME || + entry.startsWith(`${PLUGIN_NAME}@`) || + entry === LEGACY_PLUGIN_NAME || + entry.startsWith(`${LEGACY_PLUGIN_NAME}@`) || + isAcceptedLocalPluginEntry(entry) +} + export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { ensureConfigDirectoryExists() @@ -22,7 +34,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise } const { format, path } = detectConfigFormat() - const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME) + const pluginEntry = await getPreferredPluginEntry(currentVersion) try { if (format === "none") { @@ -43,18 +55,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const config = parseResult.config const plugins = config.plugin ?? [] - const canonicalEntries = plugins.filter( - (plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) - ) - const legacyEntries = plugins.filter( - (plugin) => plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) - ) - const otherPlugins = plugins.filter( - (plugin) => !(plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`)) - && !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)) - ) - - const existingEntry = canonicalEntries[0] ?? legacyEntries[0] + const existingEntry = plugins.find(isOurPluginEntry) if (existingEntry) { const installedVersion = extractVersionFromPluginEntry(existingEntry) const compatibility = checkVersionCompatibility(installedVersion, currentVersion) @@ -77,7 +78,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise } } - const normalizedPlugins = [...otherPlugins] + const normalizedPlugins = plugins.filter((plugin) => !isOurPluginEntry(plugin)) normalizedPlugins.push(pluginEntry) diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts index f158e18e2..c7ace3392 100644 --- a/src/cli/config-manager/detect-current-config.ts +++ b/src/cli/config-manager/detect-current-config.ts @@ -1,5 +1,10 @@ import { existsSync, readFileSync } from "node:fs" -import { parseJsonc, LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared" +import { + parseJsonc, + LEGACY_PLUGIN_NAME, + PLUGIN_NAME, + isAcceptedLocalPluginEntry, +} from "../../shared" import type { DetectedConfig } from "../types" import { getOmoConfigPath } from "./config-context" import { detectConfigFormat } from "./opencode-config-format" @@ -58,7 +63,8 @@ function detectProvidersFromOmoConfig(): { function isOurPlugin(plugin: string): boolean { return plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) || - plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) + plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) || + isAcceptedLocalPluginEntry(plugin) } function findOurPluginEntry(plugins: string[]): string | null { diff --git a/src/cli/config-manager/plugin-detection.test.ts b/src/cli/config-manager/plugin-detection.test.ts index fcd6109f9..5d443a5a6 100644 --- a/src/cli/config-manager/plugin-detection.test.ts +++ b/src/cli/config-manager/plugin-detection.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" +import { pathToFileURL } from "node:url" import { resetConfigContext } from "./config-context" import { detectCurrentConfig } from "./detect-current-config" @@ -40,6 +41,25 @@ describe("detectCurrentConfig - single package detection", () => { expect(result.isInstalled).toBe(true) }) + it("detects a local file URL plugin entry", () => { + // given + const pluginDir = join(testConfigDir, "plugin-root") + mkdirSync(pluginDir, { recursive: true }) + writeFileSync( + join(pluginDir, "package.json"), + JSON.stringify({ name: "oh-my-opencode" }, null, 2) + "\n", + "utf-8", + ) + const localPluginEntry = pathToFileURL(pluginDir).href + writeFileSync(testConfigPath, JSON.stringify({ plugin: [localPluginEntry] }, null, 2) + "\n", "utf-8") + + // when + const result = detectCurrentConfig() + + // then + expect(result.isInstalled).toBe(true) + }) + it("returns false when plugin not present with similar name", () => { // given writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent-extra"] }, null, 2) + "\n", "utf-8") @@ -94,7 +114,8 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) }) it("upgrades a bare legacy plugin entry to canonical", async () => { @@ -107,7 +128,8 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) }) it("updates a version-pinned legacy entry to the requested version", async () => { @@ -121,7 +143,8 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"]) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) getPluginNameWithVersionSpy.mockRestore() }) @@ -135,7 +158,8 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) }) it("preserves a canonical entry when the same version is re-installed", async () => { @@ -149,10 +173,33 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"]) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) getPluginNameWithVersionSpy.mockRestore() }) + it("replaces an existing local file URL entry without duplicating it", async () => { + // given + const pluginDir = join(testConfigDir, "plugin-root") + mkdirSync(pluginDir, { recursive: true }) + writeFileSync( + join(pluginDir, "package.json"), + JSON.stringify({ name: "oh-my-opencode" }, null, 2) + "\n", + "utf-8", + ) + const existingLocalEntry = pathToFileURL(pluginDir).href + writeFileSync(testConfigPath, JSON.stringify({ plugin: [existingLocalEntry] }, null, 2) + "\n", "utf-8") + + // when + const result = await addPluginToOpenCodeConfig("3.10.0") + + // then + expect(result.success).toBe(true) + const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) + expect(savedConfig.plugin).toHaveLength(1) + expect(savedConfig.plugin[0]).toMatch(/^file:\/\//) + }) + it("blocks a downgrade for a version-pinned canonical entry", async () => { // given const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0") @@ -181,7 +228,7 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { // then expect(result.success).toBe(true) const savedContent = readFileSync(testConfigPath, "utf-8") - expect(savedContent.includes('"plugin": [\n "oh-my-openagent"\n ]')).toBe(true) + expect(savedContent.includes('"plugin": [\n "file://')).toBe(true) expect(savedContent.includes("oh-my-opencode")).toBe(false) }) }) diff --git a/src/cli/config-manager/preferred-plugin-entry.ts b/src/cli/config-manager/preferred-plugin-entry.ts new file mode 100644 index 000000000..940d2c4a8 --- /dev/null +++ b/src/cli/config-manager/preferred-plugin-entry.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +import { ACCEPTED_PACKAGE_NAMES, PLUGIN_NAME } from "../../shared" +import { getPluginNameWithVersion } from "./plugin-name-with-version" + +const ACCEPTED_PACKAGE_NAME_SET = new Set(ACCEPTED_PACKAGE_NAMES) +const PACKAGE_JSON_SEARCH_DEPTH = 10 + +type PackageJsonShape = { + name?: string +} + +function findInstalledPluginRoot(startPath: string): string | null { + let directory = dirname(startPath) + + for (let depth = 0; depth < PACKAGE_JSON_SEARCH_DEPTH; depth += 1) { + const packageJsonPath = join(directory, "package.json") + if (existsSync(packageJsonPath)) { + try { + const content = readFileSync(packageJsonPath, "utf-8") + const packageJson = JSON.parse(content) as PackageJsonShape + if (packageJson.name && ACCEPTED_PACKAGE_NAME_SET.has(packageJson.name)) { + return directory + } + } catch { + // Ignore malformed package.json files while searching upward. + } + } + + const parentDirectory = dirname(directory) + if (parentDirectory === directory) { + break + } + directory = parentDirectory + } + + return null +} + +export async function getPreferredPluginEntry(currentVersion: string): Promise { + const installedPluginRoot = findInstalledPluginRoot(fileURLToPath(import.meta.url)) + if (installedPluginRoot) { + return pathToFileURL(installedPluginRoot).href + } + + return getPluginNameWithVersion(currentVersion, PLUGIN_NAME) +} + +export { findInstalledPluginRoot } diff --git a/src/cli/doctor/checks/system-plugin.test.ts b/src/cli/doctor/checks/system-plugin.test.ts new file mode 100644 index 000000000..4c51720a2 --- /dev/null +++ b/src/cli/doctor/checks/system-plugin.test.ts @@ -0,0 +1,71 @@ +/// + +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { pathToFileURL } from "node:url" + +import { findPluginEntry, getPluginInfo } from "./system-plugin" + +describe("system-plugin", () => { + let testConfigDir = "" + let testConfigPath = "" + + beforeEach(() => { + testConfigDir = join(tmpdir(), `omo-system-plugin-${Date.now()}-${Math.random().toString(36).slice(2)}`) + testConfigPath = join(testConfigDir, "opencode.json") + + mkdirSync(testConfigDir, { recursive: true }) + process.env.OPENCODE_CONFIG_DIR = testConfigDir + }) + + afterEach(() => { + rmSync(testConfigDir, { recursive: true, force: true }) + delete process.env.OPENCODE_CONFIG_DIR + }) + + it("treats file URL plugin entries as local-dev installs", () => { + // given + const entry = pathToFileURL(join(testConfigDir, "node_modules", "oh-my-openagent")).href + + // when + const result = findPluginEntry([entry]) + + // then + expect(result).toEqual({ entry, isLocalDev: true }) + }) + + it("treats absolute plugin paths as local-dev installs", () => { + // given + const entry = "/home/test/.config/opencode/node_modules/oh-my-opencode" + + // when + const result = findPluginEntry([entry]) + + // then + expect(result).toEqual({ entry, isLocalDev: true }) + }) + + it("reports local path plugin entries as registered", () => { + // given + const pluginDir = join(testConfigDir, "plugin-root") + mkdirSync(pluginDir, { recursive: true }) + writeFileSync( + join(pluginDir, "package.json"), + JSON.stringify({ name: "oh-my-opencode" }, null, 2) + "\n", + "utf-8", + ) + const entry = pathToFileURL(pluginDir).href + writeFileSync(testConfigPath, JSON.stringify({ plugin: [entry] }, null, 2) + "\n", "utf-8") + + // when + const pluginInfo = getPluginInfo() + + // then + expect(pluginInfo.registered).toBe(true) + expect(pluginInfo.entry).toBe(entry) + expect(pluginInfo.isLocalDev).toBe(true) + expect(pluginInfo.isPinned).toBe(false) + }) +}) diff --git a/src/cli/doctor/checks/system-plugin.ts b/src/cli/doctor/checks/system-plugin.ts index 531d8bf1d..58361006c 100644 --- a/src/cli/doctor/checks/system-plugin.ts +++ b/src/cli/doctor/checks/system-plugin.ts @@ -1,6 +1,12 @@ import { existsSync, readFileSync } from "node:fs" -import { LEGACY_PLUGIN_NAME, PLUGIN_NAME, getOpenCodeConfigPaths, parseJsonc } from "../../../shared" +import { + LEGACY_PLUGIN_NAME, + PLUGIN_NAME, + getOpenCodeConfigPaths, + parseJsonc, + isAcceptedLocalPluginEntry, +} from "../../../shared" export interface PluginInfo { registered: boolean @@ -44,7 +50,7 @@ function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolea if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) { return { entry, isLocalDev: false } } - if (entry.startsWith("file://") && (entry.includes(PLUGIN_NAME) || entry.includes(LEGACY_PLUGIN_NAME))) { + if (isAcceptedLocalPluginEntry(entry)) { return { entry, isLocalDev: true } } } diff --git a/src/shared/index.ts b/src/shared/index.ts index 485926bfd..8265becfe 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -73,5 +73,6 @@ export * from "./internal-initiator-marker" export * from "./plugin-command-discovery" export { SessionCategoryRegistry } from "./session-category-registry" export * from "./plugin-identity" +export * from "./local-plugin-entry" export * from "./log-legacy-plugin-startup-warning" export * from "./task-system-enabled" diff --git a/src/shared/local-plugin-entry.ts b/src/shared/local-plugin-entry.ts new file mode 100644 index 000000000..84ca33ae1 --- /dev/null +++ b/src/shared/local-plugin-entry.ts @@ -0,0 +1,83 @@ +import { existsSync, readFileSync, statSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +import { ACCEPTED_PACKAGE_NAMES } from "./plugin-identity" + +const ACCEPTED_PACKAGE_NAME_SET = new Set(ACCEPTED_PACKAGE_NAMES) +const PACKAGE_JSON_SEARCH_DEPTH = 10 + +type PackageJsonShape = { + name?: string +} + +export function isLocalPluginPath(entry: string): boolean { + return entry.startsWith("file://") || entry.startsWith("/") || /^[A-Za-z]:[\\/]/.test(entry) +} + +function toFilePath(entry: string): string | null { + if (entry.startsWith("file://")) { + try { + return fileURLToPath(entry) + } catch { + return null + } + } + + if (entry.startsWith("/") || /^[A-Za-z]:[\\/]/.test(entry)) { + return entry + } + + return null +} + +export function resolveLocalPluginPackageName(entry: string): string | null { + const filePath = toFilePath(entry) + if (!filePath || !existsSync(filePath)) { + return null + } + + let directory = filePath + try { + const stat = statSync(filePath) + directory = stat.isDirectory() ? filePath : dirname(filePath) + } catch { + directory = dirname(filePath) + } + + for (let depth = 0; depth < PACKAGE_JSON_SEARCH_DEPTH; depth += 1) { + const packageJsonPath = join(directory, "package.json") + if (existsSync(packageJsonPath)) { + try { + const content = readFileSync(packageJsonPath, "utf-8") + const packageJson = JSON.parse(content) as PackageJsonShape + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + return packageJson.name + } + } catch { + // Ignore malformed package.json files while searching upward. + } + } + + const parentDirectory = dirname(directory) + if (parentDirectory === directory) { + break + } + directory = parentDirectory + } + + return null +} + +export function isAcceptedLocalPluginEntry(entry: string): boolean { + if (!isLocalPluginPath(entry)) { + return false + } + + const packageName = resolveLocalPluginPackageName(entry) + if (packageName && ACCEPTED_PACKAGE_NAME_SET.has(packageName)) { + return true + } + + return ACCEPTED_PACKAGE_NAMES.some((name) => entry.includes(name)) +}