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
This commit is contained in:
YeonGyu-Kim 2026-02-11 11:56:05 +09:00
parent b0c570e054
commit 67b4665c28
4 changed files with 155 additions and 7 deletions

View File

@ -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"

View File

@ -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")
})
})
})

View File

@ -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)
}

View File

@ -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<boolean> {
@ -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)")
}