From 6a733c9dde684ba6024736bc52c113acd8a35acc Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 27 Mar 2026 15:40:04 +0900 Subject: [PATCH] fix(#2823): auto-migrate legacy plugin name and warn users at startup - logLegacyPluginStartupWarning now emits console.warn (visible to user, not just log file) when oh-my-opencode is detected in opencode.json - Auto-migrates opencode.json plugin entry from oh-my-opencode to oh-my-openagent (with backup) - plugin-config.ts: add console.warn when loading legacy config filename - test: 10 tests covering migration, console output, edge cases --- src/config/schema/hooks.ts | 1 + .../legacy-plugin-toast/auto-migrate.test.ts | 127 +++++++++++ src/hooks/legacy-plugin-toast/auto-migrate.ts | 81 +++++++ src/hooks/legacy-plugin-toast/hook.test.ts | 206 ++++++++++++++++++ src/hooks/legacy-plugin-toast/hook.ts | 59 +++++ src/hooks/legacy-plugin-toast/index.ts | 1 + src/plugin-config.ts | 12 + src/shared/legacy-plugin-warning.test.ts | 2 + src/shared/legacy-plugin-warning.ts | 8 +- .../log-legacy-plugin-startup-warning.test.ts | 78 ++++++- .../log-legacy-plugin-startup-warning.ts | 21 +- src/shared/migrate-legacy-config-file.test.ts | 86 ++++++++ src/shared/migrate-legacy-config-file.ts | 31 +++ .../migrate-legacy-plugin-entry.test.ts | 90 ++++++++ src/shared/migrate-legacy-plugin-entry.ts | 27 +++ 15 files changed, 822 insertions(+), 8 deletions(-) create mode 100644 src/hooks/legacy-plugin-toast/auto-migrate.test.ts create mode 100644 src/hooks/legacy-plugin-toast/auto-migrate.ts create mode 100644 src/hooks/legacy-plugin-toast/hook.test.ts create mode 100644 src/hooks/legacy-plugin-toast/hook.ts create mode 100644 src/hooks/legacy-plugin-toast/index.ts create mode 100644 src/shared/migrate-legacy-config-file.test.ts create mode 100644 src/shared/migrate-legacy-config-file.ts create mode 100644 src/shared/migrate-legacy-plugin-entry.test.ts create mode 100644 src/shared/migrate-legacy-plugin-entry.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 6b0219f72..269be45c1 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -52,6 +52,7 @@ export const HookNameSchema = z.enum([ "read-image-resizer", "todo-description-override", "webfetch-redirect-guard", + "legacy-plugin-toast", ]) export type HookName = z.infer diff --git a/src/hooks/legacy-plugin-toast/auto-migrate.test.ts b/src/hooks/legacy-plugin-toast/auto-migrate.test.ts new file mode 100644 index 000000000..cdc03389f --- /dev/null +++ b/src/hooks/legacy-plugin-toast/auto-migrate.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { autoMigrateLegacyPluginEntry } from "./auto-migrate" + +describe("autoMigrateLegacyPluginEntry", () => { + let testConfigDir = "" + + beforeEach(() => { + testConfigDir = join(tmpdir(), `omo-legacy-migrate-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testConfigDir, { recursive: true }) + process.env.OPENCODE_CONFIG_DIR = testConfigDir + }) + + afterEach(() => { + rmSync(testConfigDir, { recursive: true, force: true }) + delete process.env.OPENCODE_CONFIG_DIR + }) + + describe("#given opencode.json has a bare legacy plugin entry", () => { + it("#then replaces oh-my-opencode with oh-my-openagent", () => { + // given + writeFileSync( + join(testConfigDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", + ) + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(true) + expect(result.from).toBe("oh-my-opencode") + expect(result.to).toBe("oh-my-openagent") + const saved = JSON.parse(readFileSync(join(testConfigDir, "opencode.json"), "utf-8")) + expect(saved.plugin).toEqual(["oh-my-openagent"]) + }) + }) + + describe("#given opencode.json has a version-pinned legacy entry", () => { + it("#then preserves the version suffix", () => { + // given + writeFileSync( + join(testConfigDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", + ) + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(true) + expect(result.from).toBe("oh-my-opencode@3.10.0") + expect(result.to).toBe("oh-my-openagent@3.10.0") + const saved = JSON.parse(readFileSync(join(testConfigDir, "opencode.json"), "utf-8")) + expect(saved.plugin).toEqual(["oh-my-openagent@3.10.0"]) + }) + }) + + describe("#given both canonical and legacy entries exist", () => { + it("#then removes legacy entry and keeps canonical", () => { + // given + writeFileSync( + join(testConfigDir, "opencode.json"), + JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n", + ) + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(true) + const saved = JSON.parse(readFileSync(join(testConfigDir, "opencode.json"), "utf-8")) + expect(saved.plugin).toEqual(["oh-my-openagent"]) + }) + }) + + describe("#given no config file exists", () => { + it("#then returns migrated false", () => { + // given - empty dir + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(false) + expect(result.from).toBeNull() + }) + }) + + describe("#given opencode.jsonc has comments and a legacy entry", () => { + it("#then preserves comments and replaces entry", () => { + // given + writeFileSync( + join(testConfigDir, "opencode.jsonc"), + '{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n', + ) + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(true) + const content = readFileSync(join(testConfigDir, "opencode.jsonc"), "utf-8") + expect(content).toContain("// my config") + expect(content).toContain("oh-my-openagent") + expect(content).not.toContain("oh-my-opencode") + }) + }) + + describe("#given only canonical entry exists", () => { + it("#then returns migrated false and leaves file untouched", () => { + // given + const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n" + writeFileSync(join(testConfigDir, "opencode.json"), original) + + // when + const result = autoMigrateLegacyPluginEntry() + + // then + expect(result.migrated).toBe(false) + const content = readFileSync(join(testConfigDir, "opencode.json"), "utf-8") + expect(content).toBe(original) + }) + }) +}) diff --git a/src/hooks/legacy-plugin-toast/auto-migrate.ts b/src/hooks/legacy-plugin-toast/auto-migrate.ts new file mode 100644 index 000000000..74aaf8c45 --- /dev/null +++ b/src/hooks/legacy-plugin-toast/auto-migrate.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs" + +import { parseJsoncSafe } from "../../shared/jsonc-parser" +import { getOpenCodeConfigPaths } from "../../shared/opencode-config-dir" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared/plugin-identity" + +export interface MigrationResult { + migrated: boolean + from: string | null + to: string | null + configPath: string | null +} + +interface OpenCodeConfig { + plugin?: string[] +} + +function isLegacyEntry(entry: string): boolean { + return entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`) +} + +function isCanonicalEntry(entry: string): boolean { + return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`) +} + +function toLegacyCanonical(entry: string): string { + if (entry === LEGACY_PLUGIN_NAME) return PLUGIN_NAME + if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) { + return `${PLUGIN_NAME}${entry.slice(LEGACY_PLUGIN_NAME.length)}` + } + return entry +} + +function detectOpenCodeConfigPath(): string | null { + const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + if (existsSync(paths.configJsonc)) return paths.configJsonc + if (existsSync(paths.configJson)) return paths.configJson + return null +} + +export function autoMigrateLegacyPluginEntry(): MigrationResult { + const configPath = detectOpenCodeConfigPath() + if (!configPath) return { migrated: false, from: null, to: null, configPath: null } + + try { + const content = readFileSync(configPath, "utf-8") + const parseResult = parseJsoncSafe(content) + if (!parseResult.data?.plugin) return { migrated: false, from: null, to: null, configPath } + + const plugins = parseResult.data.plugin + const legacyEntries = plugins.filter(isLegacyEntry) + if (legacyEntries.length === 0) return { migrated: false, from: null, to: null, configPath } + + const hasCanonical = plugins.some(isCanonicalEntry) + const from = legacyEntries[0] + const to = toLegacyCanonical(from) + + const normalized = hasCanonical + ? plugins.filter((p) => !isLegacyEntry(p)) + : plugins.map((p) => (isLegacyEntry(p) ? toLegacyCanonical(p) : p)) + + const isJsonc = configPath.endsWith(".jsonc") + if (isJsonc) { + const pluginArrayRegex = /((?:"plugin"|plugin)\s*:\s*)\[([\s\S]*?)\]/ + const match = content.match(pluginArrayRegex) + if (match) { + const formattedPlugins = normalized.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `$1[\n ${formattedPlugins}\n ]`) + writeFileSync(configPath, newContent) + return { migrated: true, from, to, configPath } + } + } + + const parsed = JSON.parse(content) as Record + parsed.plugin = normalized + writeFileSync(configPath, JSON.stringify(parsed, null, 2) + "\n") + return { migrated: true, from, to, configPath } + } catch { + return { migrated: false, from: null, to: null, configPath } + } +} diff --git a/src/hooks/legacy-plugin-toast/hook.test.ts b/src/hooks/legacy-plugin-toast/hook.test.ts new file mode 100644 index 000000000..490908429 --- /dev/null +++ b/src/hooks/legacy-plugin-toast/hook.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" +import type { MigrationResult } from "./auto-migrate" + +const mockCheckForLegacyPluginEntry = mock(() => ({ + hasLegacyEntry: false, + hasCanonicalEntry: false, + legacyEntries: [] as string[], +})) + +const mockAutoMigrate = mock((): MigrationResult => ({ + migrated: false, + from: null, + to: null, + configPath: null, +})) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockShowToast = mock((_arg: any) => Promise.resolve()) +const mockLog = mock(() => {}) + +mock.module("../../shared/legacy-plugin-warning", () => ({ + checkForLegacyPluginEntry: mockCheckForLegacyPluginEntry, +})) + +mock.module("../../shared/logger", () => ({ + log: mockLog, +})) + +mock.module("./auto-migrate", () => ({ + autoMigrateLegacyPluginEntry: mockAutoMigrate, +})) + +afterAll(() => { + mock.restore() +}) + +function createMockCtx() { + return { + client: { + tui: { showToast: mockShowToast }, + }, + directory: "/tmp/test", + } as never +} + +function createEvent(type: string, parentID?: string) { + return { + event: { + type, + properties: parentID ? { info: { parentID } } : { info: {} }, + }, + } +} + +async function importFreshModule() { + return import(`./hook?t=${Date.now()}-${Math.random()}`) +} + +describe("createLegacyPluginToastHook", () => { + beforeEach(() => { + mockCheckForLegacyPluginEntry.mockReset() + mockAutoMigrate.mockReset() + mockShowToast.mockReset() + mockLog.mockReset() + + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: false, + hasCanonicalEntry: true, + legacyEntries: [], + }) + mockAutoMigrate.mockReturnValue({ migrated: false, from: null, to: null, configPath: null }) + mockShowToast.mockResolvedValue(undefined) + }) + + describe("#given no legacy entry exists", () => { + it("#then does not show a toast", async () => { + // given + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.created")) + + // then + expect(mockShowToast).not.toHaveBeenCalled() + }) + }) + + describe("#given legacy entry exists and migration succeeds", () => { + it("#then shows success toast", async () => { + // given + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: true, + hasCanonicalEntry: false, + legacyEntries: ["oh-my-opencode"], + }) + mockAutoMigrate.mockReturnValue({ + migrated: true, + from: "oh-my-opencode", + to: "oh-my-openagent", + configPath: "/tmp/opencode.json", + }) + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.created")) + + // then + expect(mockShowToast).toHaveBeenCalledTimes(1) + const toastArg = mockShowToast.mock.calls[0][0] as { body: { variant: string } } + expect(toastArg.body.variant).toBe("success") + }) + }) + + describe("#given legacy entry exists but migration fails", () => { + it("#then shows warning toast", async () => { + // given + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: true, + hasCanonicalEntry: false, + legacyEntries: ["oh-my-opencode"], + }) + mockAutoMigrate.mockReturnValue({ + migrated: false, + from: null, + to: null, + configPath: "/tmp/opencode.json", + }) + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.created")) + + // then + expect(mockShowToast).toHaveBeenCalledTimes(1) + const toastArg2 = mockShowToast.mock.calls[0][0] as { body: { variant: string } } + expect(toastArg2.body.variant).toBe("warning") + }) + }) + + describe("#given session.created fires twice", () => { + it("#then only fires once (once-guard)", async () => { + // given + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: true, + hasCanonicalEntry: false, + legacyEntries: ["oh-my-opencode"], + }) + mockAutoMigrate.mockReturnValue({ + migrated: true, + from: "oh-my-opencode", + to: "oh-my-openagent", + configPath: "/tmp/opencode.json", + }) + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.created")) + await hook.event(createEvent("session.created")) + + // then + expect(mockShowToast).toHaveBeenCalledTimes(1) + }) + }) + + describe("#given a non-session.created event fires", () => { + it("#then does nothing", async () => { + // given + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: true, + hasCanonicalEntry: false, + legacyEntries: ["oh-my-opencode"], + }) + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.deleted")) + + // then + expect(mockCheckForLegacyPluginEntry).not.toHaveBeenCalled() + }) + }) + + describe("#given session.created from a subagent (has parentID)", () => { + it("#then skips the check", async () => { + // given + mockCheckForLegacyPluginEntry.mockReturnValue({ + hasLegacyEntry: true, + hasCanonicalEntry: false, + legacyEntries: ["oh-my-opencode"], + }) + const { createLegacyPluginToastHook } = await importFreshModule() + const hook = createLegacyPluginToastHook(createMockCtx()) + + // when + await hook.event(createEvent("session.created", "parent-session-id")) + + // then + expect(mockCheckForLegacyPluginEntry).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/legacy-plugin-toast/hook.ts b/src/hooks/legacy-plugin-toast/hook.ts new file mode 100644 index 000000000..4d6f55918 --- /dev/null +++ b/src/hooks/legacy-plugin-toast/hook.ts @@ -0,0 +1,59 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { checkForLegacyPluginEntry } from "../../shared/legacy-plugin-warning" +import { log } from "../../shared/logger" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared/plugin-identity" +import { autoMigrateLegacyPluginEntry } from "./auto-migrate" + +export function createLegacyPluginToastHook(ctx: PluginInput) { + let fired = false + + return { + event: async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type !== "session.created" || fired) return + + const props = event.properties as { info?: { parentID?: string } } | undefined + if (props?.info?.parentID) return + + fired = true + + const result = checkForLegacyPluginEntry() + if (!result.hasLegacyEntry) return + + const migration = autoMigrateLegacyPluginEntry() + + if (migration.migrated) { + log("[legacy-plugin-toast] Auto-migrated opencode.json plugin entry", { + from: migration.from, + to: migration.to, + }) + + await ctx.client.tui + .showToast({ + body: { + title: "Plugin Entry Migrated", + message: `"${migration.from}" has been renamed to "${migration.to}" in your opencode.json.\nNo action needed.`, + variant: "success" as const, + duration: 8000, + }, + }) + .catch(() => {}) + } else { + log("[legacy-plugin-toast] Legacy entry detected but migration failed", { + legacyEntries: result.legacyEntries, + }) + + await ctx.client.tui + .showToast({ + body: { + title: "Legacy Plugin Name Detected", + message: `Update your opencode.json: "${LEGACY_PLUGIN_NAME}" has been renamed to "${PLUGIN_NAME}".\nRun: bunx ${PLUGIN_NAME} install`, + variant: "warning" as const, + duration: 10000, + }, + }) + .catch(() => {}) + } + }, + } +} diff --git a/src/hooks/legacy-plugin-toast/index.ts b/src/hooks/legacy-plugin-toast/index.ts new file mode 100644 index 000000000..b8194ad09 --- /dev/null +++ b/src/hooks/legacy-plugin-toast/index.ts @@ -0,0 +1 @@ +export { createLegacyPluginToastHook } from "./hook" diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 67322f17a..cf6f2f999 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -10,6 +10,8 @@ import { detectPluginConfigFile, migrateConfigFile, } from "./shared"; +import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file"; +import { LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity"; const PARTIAL_STRING_ARRAY_KEYS = new Set([ "disabled_mcps", @@ -168,6 +170,11 @@ export function loadPluginConfig( ? userDetected.path : path.join(configDir, "oh-my-opencode.json"); + // Auto-copy legacy config file to canonical name if needed + if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) { + migrateLegacyConfigFile(userDetected.path); + } + // Project-level config path - prefer .jsonc over .json const projectBasePath = path.join(directory, ".opencode"); const projectDetected = detectPluginConfigFile(projectBasePath); @@ -176,6 +183,11 @@ export function loadPluginConfig( ? projectDetected.path : path.join(projectBasePath, "oh-my-opencode.json"); + // Auto-copy legacy project config file to canonical name if needed + if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) { + migrateLegacyConfigFile(projectDetected.path); + } + // Load user config first (base) let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {}; diff --git a/src/shared/legacy-plugin-warning.test.ts b/src/shared/legacy-plugin-warning.test.ts index 3bc16c3b1..9d114f9db 100644 --- a/src/shared/legacy-plugin-warning.test.ts +++ b/src/shared/legacy-plugin-warning.test.ts @@ -27,6 +27,7 @@ describe("checkForLegacyPluginEntry", () => { expect(result.hasLegacyEntry).toBe(true) expect(result.hasCanonicalEntry).toBe(false) expect(result.legacyEntries).toEqual(["oh-my-opencode"]) + expect(result.configPath).toBe(join(testConfigDir, "opencode.json")) }) it("detects a version-pinned legacy plugin entry", () => { @@ -77,5 +78,6 @@ describe("checkForLegacyPluginEntry", () => { expect(result.hasLegacyEntry).toBe(false) expect(result.hasCanonicalEntry).toBe(false) expect(result.legacyEntries).toEqual([]) + expect(result.configPath).toBeNull() }) }) diff --git a/src/shared/legacy-plugin-warning.ts b/src/shared/legacy-plugin-warning.ts index c8a7e94df..6ab2a77ef 100644 --- a/src/shared/legacy-plugin-warning.ts +++ b/src/shared/legacy-plugin-warning.ts @@ -13,6 +13,7 @@ export interface LegacyPluginCheckResult { hasLegacyEntry: boolean hasCanonicalEntry: boolean legacyEntries: string[] + configPath: string | null } function getOpenCodeConfigPath(overrideConfigDir?: string): string | null { @@ -42,14 +43,14 @@ function isCanonicalPluginEntry(entry: string): boolean { export function checkForLegacyPluginEntry(overrideConfigDir?: string): LegacyPluginCheckResult { const configPath = getOpenCodeConfigPath(overrideConfigDir) if (!configPath) { - return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [] } + return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath: null } } try { const content = readFileSync(configPath, "utf-8") const parseResult = parseJsoncSafe(content) if (!parseResult.data) { - return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [] } + return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath } } const legacyEntries = (parseResult.data.plugin ?? []).filter(isLegacyPluginEntry) @@ -59,8 +60,9 @@ export function checkForLegacyPluginEntry(overrideConfigDir?: string): LegacyPlu hasLegacyEntry: legacyEntries.length > 0, hasCanonicalEntry, legacyEntries, + configPath, } } catch { - return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [] } + return { hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], configPath: null } } } diff --git a/src/shared/log-legacy-plugin-startup-warning.test.ts b/src/shared/log-legacy-plugin-startup-warning.test.ts index 5259515b8..4acd38385 100644 --- a/src/shared/log-legacy-plugin-startup-warning.test.ts +++ b/src/shared/log-legacy-plugin-startup-warning.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" import type { LegacyPluginCheckResult } from "./legacy-plugin-warning" function createLegacyPluginCheckResult( @@ -8,13 +8,15 @@ function createLegacyPluginCheckResult( hasLegacyEntry: false, hasCanonicalEntry: false, legacyEntries: [], + configPath: null, ...overrides, } } const mockCheckForLegacyPluginEntry = mock(() => createLegacyPluginCheckResult()) - const mockLog = mock(() => {}) +const mockMigrateLegacyPluginEntry = mock(() => false) +let consoleWarnSpy: ReturnType mock.module("./legacy-plugin-warning", () => ({ checkForLegacyPluginEntry: mockCheckForLegacyPluginEntry, @@ -24,6 +26,10 @@ mock.module("./logger", () => ({ log: mockLog, })) +mock.module("./migrate-legacy-plugin-entry", () => ({ + migrateLegacyPluginEntry: mockMigrateLegacyPluginEntry, +})) + afterAll(() => { mock.restore() }) @@ -36,16 +42,24 @@ describe("logLegacyPluginStartupWarning", () => { beforeEach(() => { mockCheckForLegacyPluginEntry.mockReset() mockLog.mockReset() + mockMigrateLegacyPluginEntry.mockReset() + consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {}) mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult()) + mockMigrateLegacyPluginEntry.mockReturnValue(false) + }) + + afterEach(() => { + consoleWarnSpy?.mockRestore() }) describe("#given OpenCode config contains legacy plugin entries", () => { - it("logs the legacy entries with canonical replacements", async () => { + it("#then logs the legacy entries with canonical replacements", async () => { //#given mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult({ hasLegacyEntry: true, legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"], + configPath: "/tmp/opencode.json", })) const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule() @@ -63,10 +77,45 @@ describe("logLegacyPluginStartupWarning", () => { }, ) }) + + it("#then emits console.warn about the rename", async () => { + //#given + mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult({ + hasLegacyEntry: true, + legacyEntries: ["oh-my-opencode@latest"], + configPath: "/tmp/opencode.json", + })) + const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule() + + //#when + logLegacyPluginStartupWarning() + + //#then + expect(consoleWarnSpy).toHaveBeenCalled() + const firstCall = consoleWarnSpy.mock.calls[0]?.[0] as string + expect(firstCall).toContain("oh-my-opencode") + expect(firstCall).toContain("oh-my-openagent") + }) + + it("#then attempts auto-migration of the opencode.json", async () => { + //#given + mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult({ + hasLegacyEntry: true, + legacyEntries: ["oh-my-opencode"], + configPath: "/tmp/opencode.json", + })) + const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule() + + //#when + logLegacyPluginStartupWarning() + + //#then + expect(mockMigrateLegacyPluginEntry).toHaveBeenCalledWith("/tmp/opencode.json") + }) }) describe("#given OpenCode config uses only canonical plugin entries", () => { - it("does not log a startup warning", async () => { + it("#then does not log a startup warning", async () => { //#given const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule() @@ -75,6 +124,27 @@ describe("logLegacyPluginStartupWarning", () => { //#then expect(mockLog).not.toHaveBeenCalled() + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + }) + + describe("#given migration succeeds", () => { + it("#then logs success message to console", async () => { + //#given + mockCheckForLegacyPluginEntry.mockReturnValue(createLegacyPluginCheckResult({ + hasLegacyEntry: true, + legacyEntries: ["oh-my-opencode@latest"], + configPath: "/tmp/opencode.json", + })) + mockMigrateLegacyPluginEntry.mockReturnValue(true) + const { logLegacyPluginStartupWarning } = await importFreshStartupWarningModule() + + //#when + logLegacyPluginStartupWarning() + + //#then + const calls = consoleWarnSpy.mock.calls.map((c) => c[0] as string) + expect(calls.some((c) => c.includes("Auto-migrated"))).toBe(true) }) }) }) diff --git a/src/shared/log-legacy-plugin-startup-warning.ts b/src/shared/log-legacy-plugin-startup-warning.ts index f5712a2e9..dc6505a5f 100644 --- a/src/shared/log-legacy-plugin-startup-warning.ts +++ b/src/shared/log-legacy-plugin-startup-warning.ts @@ -1,5 +1,6 @@ import { checkForLegacyPluginEntry } from "./legacy-plugin-warning" import { log } from "./logger" +import { migrateLegacyPluginEntry } from "./migrate-legacy-plugin-entry" import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity" function toCanonicalEntry(entry: string): string { @@ -20,9 +21,27 @@ export function logLegacyPluginStartupWarning(): void { return } + const suggestedEntries = result.legacyEntries.map(toCanonicalEntry) + log("[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config", { legacyEntries: result.legacyEntries, - suggestedEntries: result.legacyEntries.map(toCanonicalEntry), + suggestedEntries, hasCanonicalEntry: result.hasCanonicalEntry, }) + + console.warn( + `[oh-my-openagent] WARNING: Your opencode.json uses the legacy package name "${LEGACY_PLUGIN_NAME}".` + + ` The package has been renamed to "${PLUGIN_NAME}".` + + ` Attempting auto-migration...`, + ) + + const migrated = migrateLegacyPluginEntry(result.configPath!) + if (migrated) { + console.warn(`[oh-my-openagent] Auto-migrated opencode.json: ${result.legacyEntries.join(", ")} -> ${suggestedEntries.join(", ")}`) + } else { + console.warn( + `[oh-my-openagent] Could not auto-migrate. Please manually update your opencode.json:` + + ` ${result.legacyEntries.map((e, i) => `"${e}" -> "${suggestedEntries[i]}"`).join(", ")}`, + ) + } } diff --git a/src/shared/migrate-legacy-config-file.test.ts b/src/shared/migrate-legacy-config-file.test.ts new file mode 100644 index 000000000..0e032c8b9 --- /dev/null +++ b/src/shared/migrate-legacy-config-file.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { migrateLegacyConfigFile } from "./migrate-legacy-config-file" + +describe("migrateLegacyConfigFile", () => { + let testDir = "" + + beforeEach(() => { + testDir = join(tmpdir(), `omo-migrate-config-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + }) + + describe("#given oh-my-opencode.jsonc exists but oh-my-openagent.jsonc does not", () => { + describe("#when migrating the config file", () => { + it("#then copies to oh-my-openagent.jsonc", () => { + const legacyPath = join(testDir, "oh-my-opencode.jsonc") + writeFileSync(legacyPath, '{ "agents": {} }') + + const result = migrateLegacyConfigFile(legacyPath) + + expect(result).toBe(true) + expect(existsSync(join(testDir, "oh-my-openagent.jsonc"))).toBe(true) + expect(readFileSync(join(testDir, "oh-my-openagent.jsonc"), "utf-8")).toBe('{ "agents": {} }') + }) + }) + }) + + describe("#given oh-my-opencode.json exists but oh-my-openagent.json does not", () => { + describe("#when migrating the config file", () => { + it("#then copies to oh-my-openagent.json", () => { + const legacyPath = join(testDir, "oh-my-opencode.json") + writeFileSync(legacyPath, '{ "agents": {} }') + + const result = migrateLegacyConfigFile(legacyPath) + + expect(result).toBe(true) + expect(existsSync(join(testDir, "oh-my-openagent.json"))).toBe(true) + }) + }) + }) + + describe("#given oh-my-openagent.jsonc already exists", () => { + describe("#when attempting migration", () => { + it("#then returns false and does not overwrite", () => { + const legacyPath = join(testDir, "oh-my-opencode.jsonc") + const canonicalPath = join(testDir, "oh-my-openagent.jsonc") + writeFileSync(legacyPath, '{ "old": true }') + writeFileSync(canonicalPath, '{ "new": true }') + + const result = migrateLegacyConfigFile(legacyPath) + + expect(result).toBe(false) + expect(readFileSync(canonicalPath, "utf-8")).toBe('{ "new": true }') + }) + }) + }) + + describe("#given the file does not exist", () => { + describe("#when attempting migration", () => { + it("#then returns false", () => { + const result = migrateLegacyConfigFile(join(testDir, "oh-my-opencode.jsonc")) + + expect(result).toBe(false) + }) + }) + }) + + describe("#given the file is not a legacy config file", () => { + describe("#when attempting migration", () => { + it("#then returns false", () => { + const nonLegacyPath = join(testDir, "something-else.jsonc") + writeFileSync(nonLegacyPath, "{}") + + const result = migrateLegacyConfigFile(nonLegacyPath) + + expect(result).toBe(false) + }) + }) + }) +}) diff --git a/src/shared/migrate-legacy-config-file.ts b/src/shared/migrate-legacy-config-file.ts new file mode 100644 index 000000000..15d0df232 --- /dev/null +++ b/src/shared/migrate-legacy-config-file.ts @@ -0,0 +1,31 @@ +import { existsSync, copyFileSync, renameSync } from "node:fs" +import { join, dirname, basename } from "node:path" + +import { log } from "./logger" +import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity" + +function buildCanonicalPath(legacyPath: string): string { + const dir = dirname(legacyPath) + const ext = basename(legacyPath).includes(".jsonc") ? ".jsonc" : ".json" + return join(dir, `${CONFIG_BASENAME}${ext}`) +} + +export function migrateLegacyConfigFile(legacyPath: string): boolean { + if (!existsSync(legacyPath)) return false + if (!basename(legacyPath).startsWith(LEGACY_CONFIG_BASENAME)) return false + + const canonicalPath = buildCanonicalPath(legacyPath) + if (existsSync(canonicalPath)) return false + + try { + copyFileSync(legacyPath, canonicalPath) + log("[migrateLegacyConfigFile] Copied legacy config to canonical path", { + from: legacyPath, + to: canonicalPath, + }) + return true + } catch (error) { + log("[migrateLegacyConfigFile] Failed to copy legacy config file", { legacyPath, error }) + return false + } +} diff --git a/src/shared/migrate-legacy-plugin-entry.test.ts b/src/shared/migrate-legacy-plugin-entry.test.ts new file mode 100644 index 000000000..8a51080d2 --- /dev/null +++ b/src/shared/migrate-legacy-plugin-entry.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { migrateLegacyPluginEntry } from "./migrate-legacy-plugin-entry" + +describe("migrateLegacyPluginEntry", () => { + let testDir = "" + + beforeEach(() => { + testDir = join(tmpdir(), `omo-migrate-entry-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) + }) + + describe("#given opencode.json contains oh-my-opencode plugin entry", () => { + describe("#when migrating the config", () => { + it("#then replaces oh-my-opencode with oh-my-openagent", () => { + const configPath = join(testDir, "opencode.json") + writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2)) + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(true) + const content = readFileSync(configPath, "utf-8") + expect(content).toContain("oh-my-openagent@latest") + expect(content).not.toContain("oh-my-opencode") + }) + }) + }) + + describe("#given opencode.json contains bare oh-my-opencode entry", () => { + describe("#when migrating the config", () => { + it("#then replaces with oh-my-openagent", () => { + const configPath = join(testDir, "opencode.json") + writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2)) + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(true) + const content = readFileSync(configPath, "utf-8") + expect(content).toContain('"oh-my-openagent"') + expect(content).not.toContain("oh-my-opencode") + }) + }) + }) + + describe("#given opencode.json contains pinned oh-my-opencode version", () => { + describe("#when migrating the config", () => { + it("#then preserves the version pin", () => { + const configPath = join(testDir, "opencode.json") + writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.11.0"] }, null, 2)) + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(true) + const content = readFileSync(configPath, "utf-8") + expect(content).toContain("oh-my-openagent@3.11.0") + }) + }) + }) + + describe("#given opencode.json already uses oh-my-openagent", () => { + describe("#when checking for migration", () => { + it("#then returns false and does not modify the file", () => { + const configPath = join(testDir, "opencode.json") + const original = JSON.stringify({ plugin: ["oh-my-openagent@latest"] }, null, 2) + writeFileSync(configPath, original) + + const result = migrateLegacyPluginEntry(configPath) + + expect(result).toBe(false) + expect(readFileSync(configPath, "utf-8")).toBe(original) + }) + }) + }) + + describe("#given config file does not exist", () => { + describe("#when attempting migration", () => { + it("#then returns false", () => { + const result = migrateLegacyPluginEntry(join(testDir, "nonexistent.json")) + + expect(result).toBe(false) + }) + }) + }) +}) diff --git a/src/shared/migrate-legacy-plugin-entry.ts b/src/shared/migrate-legacy-plugin-entry.ts new file mode 100644 index 000000000..9d38c9071 --- /dev/null +++ b/src/shared/migrate-legacy-plugin-entry.ts @@ -0,0 +1,27 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs" + +import { log } from "./logger" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity" + +export function migrateLegacyPluginEntry(configPath: string): boolean { + if (!existsSync(configPath)) return false + + try { + const content = readFileSync(configPath, "utf-8") + if (!content.includes(LEGACY_PLUGIN_NAME)) return false + + const updated = content.replaceAll(LEGACY_PLUGIN_NAME, PLUGIN_NAME) + if (updated === content) return false + + writeFileSync(configPath, updated, "utf-8") + log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", { + configPath, + from: LEGACY_PLUGIN_NAME, + to: PLUGIN_NAME, + }) + return true + } catch (error) { + log("[migrateLegacyPluginEntry] Failed to migrate opencode.json", { configPath, error }) + return false + } +}