From 07e05764dd5461fa85a2336181e0d7e7cc7b2426 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Wed, 11 Mar 2026 17:16:58 +0900 Subject: [PATCH] Sync cache package.json to opencode.json intent before auto-update bun install --- src/hooks/auto-update-checker/checker.ts | 1 + .../checker/sync-package-json.test.ts | 226 ++++++++++++++++++ .../checker/sync-package-json.ts | 63 +++++ .../hook/background-update-check.ts | 3 +- 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/hooks/auto-update-checker/checker/sync-package-json.test.ts create mode 100644 src/hooks/auto-update-checker/checker/sync-package-json.ts diff --git a/src/hooks/auto-update-checker/checker.ts b/src/hooks/auto-update-checker/checker.ts index 014821db6..0d3fd310d 100644 --- a/src/hooks/auto-update-checker/checker.ts +++ b/src/hooks/auto-update-checker/checker.ts @@ -6,3 +6,4 @@ export { getCachedVersion } from "./checker/cached-version" export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater" export { getLatestVersion } from "./checker/latest-version" export { checkForUpdate } from "./checker/check-for-update" +export { syncCachePackageJsonToIntent } from "./checker/sync-package-json" diff --git a/src/hooks/auto-update-checker/checker/sync-package-json.test.ts b/src/hooks/auto-update-checker/checker/sync-package-json.test.ts new file mode 100644 index 000000000..99de183f1 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/sync-package-json.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import type { PluginEntryInfo } from "./plugin-entry" + +const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__") + +mock.module("../constants", () => ({ + CACHE_DIR: TEST_CACHE_DIR, + PACKAGE_NAME: "oh-my-opencode", +})) + +mock.module("../../../shared/logger", () => ({ + log: () => {}, +})) + +function resetTestCache(currentVersion = "3.10.0"): void { + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } + + mkdirSync(TEST_CACHE_DIR, { recursive: true }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2) + ) +} + +function cleanupTestCache(): void { + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } +} + +function readCachePackageJsonVersion(): string | undefined { + const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8") + const pkg = JSON.parse(content) as { dependencies?: Record } + return pkg.dependencies?.["oh-my-opencode"] +} + +describe("syncCachePackageJsonToIntent", () => { + beforeEach(() => { + resetTestCache() + }) + + afterEach(() => { + cleanupTestCache() + }) + + describe("#given cache package.json with pinned semver version", () => { + describe("#when opencode.json intent is latest tag", () => { + it("#then updates package.json to use latest", async () => { + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(true) + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + + describe("#when opencode.json intent is next tag", () => { + it("#then updates package.json to use next", async () => { + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@next", + isPinned: false, + pinnedVersion: "next", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(true) + expect(readCachePackageJsonVersion()).toBe("next") + }) + }) + + describe("#when opencode.json has no version (implies latest)", () => { + it("#then updates package.json to use latest", async () => { + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(true) + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + }) + + describe("#given cache package.json already matches intent", () => { + it("#then returns false without modifying package.json", async () => { + //#given + resetTestCache("latest") + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(false) + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + + describe("#given cache package.json does not exist", () => { + it("#then returns false", async () => { + //#given + cleanupTestCache() + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given plugin not in cache package.json dependencies", () => { + it("#then returns false", async () => { + //#given + cleanupTestCache() + mkdirSync(TEST_CACHE_DIR, { recursive: true }) + writeFileSync( + join(TEST_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2) + ) + + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given user explicitly pinned a different semver", () => { + it("#then updates package.json to new version", async () => { + //#given + resetTestCache("3.9.0") + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@3.10.0", + isPinned: true, + pinnedVersion: "3.10.0", + configPath: "/tmp/opencode.json", + } + + //#when + const result = syncCachePackageJsonToIntent(pluginInfo) + + //#then + expect(result).toBe(true) + expect(readCachePackageJsonVersion()).toBe("3.10.0") + }) + }) + + describe("#given other dependencies exist in cache package.json", () => { + it("#then preserves other dependencies while updating the plugin", async () => { + //#given + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + //#when + syncCachePackageJsonToIntent(pluginInfo) + + //#then + const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8") + const pkg = JSON.parse(content) as { dependencies?: Record } + expect(pkg.dependencies?.other).toBe("1.0.0") + expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest") + }) + }) +}) diff --git a/src/hooks/auto-update-checker/checker/sync-package-json.ts b/src/hooks/auto-update-checker/checker/sync-package-json.ts new file mode 100644 index 000000000..1e384b322 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/sync-package-json.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { CACHE_DIR, PACKAGE_NAME } from "../constants" +import { log } from "../../../shared/logger" +import type { PluginEntryInfo } from "./plugin-entry" + +interface CachePackageJson { + dependencies?: Record +} + +function getIntentVersion(pluginInfo: PluginEntryInfo): string { + if (!pluginInfo.pinnedVersion) { + return "latest" + } + return pluginInfo.pinnedVersion +} + +/** + * Sync cache package.json to match opencode.json plugin intent before bun install. + * + * OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest"). + * When auto-update detects a newer version and runs `bun install`, it re-resolves the pinned + * version instead of the user's declared tag, causing updates to silently fail. + * + * @returns true if package.json was updated, false otherwise + */ +export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): boolean { + const cachePackageJsonPath = path.join(CACHE_DIR, "package.json") + + if (!fs.existsSync(cachePackageJsonPath)) { + log("[auto-update-checker] Cache package.json not found, nothing to sync") + return false + } + + try { + const content = fs.readFileSync(cachePackageJsonPath, "utf-8") + const pkgJson = JSON.parse(content) as CachePackageJson + + if (!pkgJson.dependencies?.[PACKAGE_NAME]) { + log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync") + return false + } + + const currentVersion = pkgJson.dependencies[PACKAGE_NAME] + const intentVersion = getIntentVersion(pluginInfo) + + if (currentVersion === intentVersion) { + log("[auto-update-checker] Cache package.json already matches intent:", intentVersion) + return false + } + + log( + `[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}"` + ) + + pkgJson.dependencies[PACKAGE_NAME] = intentVersion + fs.writeFileSync(cachePackageJsonPath, JSON.stringify(pkgJson, null, 2)) + return true + } catch (err) { + log("[auto-update-checker] Failed to sync cache package.json:", err) + return false + } +} 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 161031839..3ef762e55 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, revertPinnedVersion } from "../checker" +import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion, syncCachePackageJsonToIntent } from "../checker" import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts" function getPinnedVersionToastMessage(latestVersion: string): string { @@ -65,6 +65,7 @@ export async function runBackgroundUpdateCheck( return } + syncCachePackageJsonToIntent(pluginInfo) invalidatePackage(PACKAGE_NAME) const installSuccess = await runBunInstallSafe()