Fix local plugin registration for OpenCode 1.4

This commit is contained in:
YeonGyu-Kim
2026-04-13 01:48:43 +09:00
parent 3d2eb6e471
commit 4b04c3c8c0
8 changed files with 292 additions and 26 deletions
@@ -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<ConfigMergeResult> {
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)
@@ -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 {
@@ -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)
})
})
@@ -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<string>(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<string> {
const installedPluginRoot = findInstalledPluginRoot(fileURLToPath(import.meta.url))
if (installedPluginRoot) {
return pathToFileURL(installedPluginRoot).href
}
return getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
}
export { findInstalledPluginRoot }
@@ -0,0 +1,71 @@
/// <reference types="bun-types" />
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)
})
})
+8 -2
View File
@@ -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 }
}
}
+1
View File
@@ -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"
+83
View File
@@ -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<string>(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))
}