Fix local plugin registration for OpenCode 1.4
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user