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