From c611ea3d003392228516a94a33214ba4fda208aa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Feb 2026 11:56:05 +0900 Subject: [PATCH] fix(auto-update): revert config pin on install failure to prevent version mismatch When bun install fails after updating the config pin, the config now shows the new version but the actual package is the old one. Add revertPinnedVersion() to roll back the config entry on install failure, keeping config and installed version in sync. Ref #1472 --- src/hooks/auto-update-checker/checker.ts | 2 +- .../checker/pinned-version-updater.test.ts | 133 ++++++++++++++++++ .../checker/pinned-version-updater.ts | 13 +- .../hook/background-update-check.ts | 14 +- 4 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 src/hooks/auto-update-checker/checker/pinned-version-updater.test.ts diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 7f06e3626..014821db6 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -3,6 +3,6 @@ export { getLocalDevVersion } from "./checker/local-dev-version" export { findPluginEntry } from "./checker/plugin-entry" export type { PluginEntryInfo } from "./checker/plugin-entry" export { getCachedVersion } from "./checker/cached-version" -export { updatePinnedVersion } from "./checker/pinned-version-updater" +export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater" export { getLatestVersion } from "./checker/latest-version" export { checkForUpdate } from "./checker/check-for-update" diff --git a/src/hooks/auto-update-checker/checker/pinned-version-updater.test.ts b/src/hooks/auto-update-checker/checker/pinned-version-updater.test.ts new file mode 100644 index 000000000..5ae910b54 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/pinned-version-updater.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import * as fs from "node:fs" +import * as path from "node:path" +import * as os from "node:os" +import { updatePinnedVersion, revertPinnedVersion } from "./pinned-version-updater" + +describe("pinned-version-updater", () => { + let tmpDir: string + let configPath: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omo-updater-test-")) + configPath = path.join(tmpDir, "opencode.json") + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe("updatePinnedVersion", () => { + test("updates pinned version in config", () => { + //#given + const config = JSON.stringify({ + plugin: ["oh-my-opencode@3.1.8"], + }) + fs.writeFileSync(configPath, config) + + //#when + const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0") + + //#then + expect(result).toBe(true) + const updated = fs.readFileSync(configPath, "utf-8") + expect(updated).toContain("oh-my-opencode@3.4.0") + expect(updated).not.toContain("oh-my-opencode@3.1.8") + }) + + test("returns false when entry not found", () => { + //#given + const config = JSON.stringify({ + plugin: ["some-other-plugin"], + }) + fs.writeFileSync(configPath, config) + + //#when + const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0") + + //#then + expect(result).toBe(false) + }) + + test("returns false when no plugin array exists", () => { + //#given + const config = JSON.stringify({ agent: {} }) + fs.writeFileSync(configPath, config) + + //#when + const result = updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0") + + //#then + expect(result).toBe(false) + }) + }) + + describe("revertPinnedVersion", () => { + test("reverts from failed version back to original entry", () => { + //#given + const config = JSON.stringify({ + plugin: ["oh-my-opencode@3.4.0"], + }) + fs.writeFileSync(configPath, config) + + //#when + const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8") + + //#then + expect(result).toBe(true) + const reverted = fs.readFileSync(configPath, "utf-8") + expect(reverted).toContain("oh-my-opencode@3.1.8") + expect(reverted).not.toContain("oh-my-opencode@3.4.0") + }) + + test("reverts to unpinned entry", () => { + //#given + const config = JSON.stringify({ + plugin: ["oh-my-opencode@3.4.0"], + }) + fs.writeFileSync(configPath, config) + + //#when + const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode") + + //#then + expect(result).toBe(true) + const reverted = fs.readFileSync(configPath, "utf-8") + expect(reverted).toContain('"oh-my-opencode"') + expect(reverted).not.toContain("oh-my-opencode@3.4.0") + }) + + test("returns false when failed version not found", () => { + //#given + const config = JSON.stringify({ + plugin: ["oh-my-opencode@3.1.8"], + }) + fs.writeFileSync(configPath, config) + + //#when + const result = revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8") + + //#then + expect(result).toBe(false) + }) + }) + + describe("update then revert roundtrip", () => { + test("config returns to original state after update + revert", () => { + //#given + const originalConfig = JSON.stringify({ + plugin: ["oh-my-opencode@3.1.8"], + }) + fs.writeFileSync(configPath, originalConfig) + + //#when + updatePinnedVersion(configPath, "oh-my-opencode@3.1.8", "3.4.0") + revertPinnedVersion(configPath, "3.4.0", "oh-my-opencode@3.1.8") + + //#then + const finalConfig = fs.readFileSync(configPath, "utf-8") + expect(finalConfig).toContain("oh-my-opencode@3.1.8") + expect(finalConfig).not.toContain("oh-my-opencode@3.4.0") + }) + }) +}) diff --git a/src/hooks/auto-update-checker/checker/pinned-version-updater.ts b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts index 688767ede..c0c2320b3 100644 --- a/src/hooks/auto-update-checker/checker/pinned-version-updater.ts +++ b/src/hooks/auto-update-checker/checker/pinned-version-updater.ts @@ -2,10 +2,9 @@ import * as fs from "node:fs" import { log } from "../../../shared/logger" import { PACKAGE_NAME } from "../constants" -export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { +function replacePluginEntry(configPath: string, oldEntry: string, newEntry: string): boolean { try { const content = fs.readFileSync(configPath, "utf-8") - const newEntry = `${PACKAGE_NAME}@${newVersion}` const pluginMatch = content.match(/"plugin"\s*:\s*\[/) if (!pluginMatch || pluginMatch.index === undefined) { @@ -51,3 +50,13 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer return false } } + +export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean { + const newEntry = `${PACKAGE_NAME}@${newVersion}` + return replacePluginEntry(configPath, oldEntry, newEntry) +} + +export function revertPinnedVersion(configPath: string, failedVersion: string, originalEntry: string): boolean { + const failedEntry = `${PACKAGE_NAME}@${failedVersion}` + return replacePluginEntry(configPath, failedEntry, originalEntry) +} diff --git a/src/hooks/auto-update-checker/hook/background-update-check.ts b/src/hooks/auto-update-checker/hook/background-update-check.ts index 908b56349..743c95b7c 100644 --- a/src/hooks/auto-update-checker/hook/background-update-check.ts +++ b/src/hooks/auto-update-checker/hook/background-update-check.ts @@ -4,7 +4,7 @@ import { log } from "../../../shared/logger" import { invalidatePackage } from "../cache" import { PACKAGE_NAME } from "../constants" import { extractChannel } from "../version-channel" -import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion } from "../checker" +import { findPluginEntry, getCachedVersion, getLatestVersion, updatePinnedVersion, revertPinnedVersion } from "../checker" import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" async function runBunInstallSafe(): Promise { @@ -72,8 +72,14 @@ export async function runBackgroundUpdateCheck( if (installSuccess) { await showAutoUpdatedToast(ctx, currentVersion, latestVersion) log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`) - } else { - await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) - log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") + return } + + if (pluginInfo.isPinned) { + revertPinnedVersion(pluginInfo.configPath, latestVersion, pluginInfo.entry) + log("[auto-update-checker] Config reverted due to install failure") + } + + await showUpdateAvailableToast(ctx, latestVersion, getToastMessage) + log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)") }