From 67b4665c28180c850d075f438058351bc793ba19 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 7f06e362..014821db 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 00000000..5ae910b5 --- /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 688767ed..c0c2320b 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 908b5634..743c95b7 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)") }