From 094bcc8ef2ec20cbdc1d3550f1601d6a3848c78d Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:15:15 -0600 Subject: [PATCH] fix(auto-update): sync cache package.json to opencode.json intent When users switch opencode.json from pinned version to tag (e.g., 3.10.0 -> @latest), the cache package.json still contains the pinned version. This causes bun install to reinstall the old version instead of resolving the new tag. This adds syncCachePackageJsonToIntent() which updates the cache package.json to match the user's declared intent in opencode.json before running bun install. Also fixes mock.module in test files to include all exported constants, preventing module pollution across parallel tests. --- src/hooks/auto-update-checker/cache.test.ts | 7 + src/hooks/auto-update-checker/checker.ts | 1 + .../checker/sync-package-json.test.ts | 193 ++++++++++++++++++ .../checker/sync-package-json.ts | 88 ++++++++ .../hook/background-update-check.ts | 6 +- 5 files changed, 294 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/cache.test.ts b/src/hooks/auto-update-checker/cache.test.ts index 4e7e9ba49..7019745f2 100644 --- a/src/hooks/auto-update-checker/cache.test.ts +++ b/src/hooks/auto-update-checker/cache.test.ts @@ -10,6 +10,13 @@ mock.module("./constants", () => ({ CACHE_DIR: TEST_OPENCODE_CACHE_DIR, USER_CONFIG_DIR: TEST_USER_CONFIG_DIR, PACKAGE_NAME: "oh-my-opencode", + NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags", + NPM_FETCH_TIMEOUT: 5000, + VERSION_FILE: join(TEST_OPENCODE_CACHE_DIR, "version"), + USER_OPENCODE_CONFIG: join(TEST_USER_CONFIG_DIR, "opencode.json"), + USER_OPENCODE_CONFIG_JSONC: join(TEST_USER_CONFIG_DIR, "opencode.jsonc"), + INSTALLED_PACKAGE_JSON: join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + getWindowsAppdataDir: () => null, })) mock.module("../../shared/logger", () => ({ 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..12f66e213 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/sync-package-json.test.ts @@ -0,0 +1,193 @@ +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", + NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags", + NPM_FETCH_TIMEOUT: 5000, + VERSION_FILE: join(TEST_CACHE_DIR, "version"), + USER_CONFIG_DIR: "/tmp/opencode-config", + USER_OPENCODE_CONFIG: "/tmp/opencode-config/opencode.json", + USER_OPENCODE_CONFIG_JSONC: "/tmp/opencode-config/opencode.jsonc", + INSTALLED_PACKAGE_JSON: join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + getWindowsAppdataDir: () => null, +})) + +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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + 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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + 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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + 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 () => { + 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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result).toBe(false) + expect(readCachePackageJsonVersion()).toBe("latest") + }) + }) + + describe("#given cache package.json does not exist", () => { + it("#then returns false", async () => { + cleanupTestCache() + const { syncCachePackageJsonToIntent } = await import("./sync-package-json") + + const pluginInfo: PluginEntryInfo = { + entry: "oh-my-opencode@latest", + isPinned: false, + pinnedVersion: "latest", + configPath: "/tmp/opencode.json", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result).toBe(false) + }) + }) + + describe("#given plugin not in cache package.json dependencies", () => { + it("#then returns false", async () => { + 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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result).toBe(false) + }) + }) + + describe("#given user explicitly changed from one semver to another", () => { + it("#then updates package.json to new version", async () => { + 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", + } + + const result = syncCachePackageJsonToIntent(pluginInfo) + + expect(result).toBe(true) + expect(readCachePackageJsonVersion()).toBe("3.10.0") + }) + }) +}) 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..db8d5c1e1 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/sync-package-json.ts @@ -0,0 +1,88 @@ +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 +} + +/** + * Determine the version specifier to use in cache package.json based on opencode.json intent. + * + * - "oh-my-opencode" (no version) → "latest" + * - "oh-my-opencode@latest" → "latest" + * - "oh-my-opencode@next" → "next" + * - "oh-my-opencode@3.10.0" → "3.10.0" (pinned, use as-is) + */ +function getIntentVersion(pluginInfo: PluginEntryInfo): string { + if (!pluginInfo.pinnedVersion) { + // No version specified in opencode.json, default to latest + return "latest" + } + return pluginInfo.pinnedVersion +} + +/** + * Sync the cache package.json to match the opencode.json plugin intent. + * + * OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest"). + * This causes issues when users switch from pinned to tag in opencode.json: + * - User changes opencode.json from "oh-my-opencode@3.10.0" to "oh-my-opencode@latest" + * - Cache package.json still has "3.10.0" + * - bun install reinstalls 3.10.0 instead of resolving @latest + * + * This function updates cache package.json to match the user's intent before bun install. + * + * @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 + } + + // Check if this is a meaningful change: + // - If intent is a tag (latest, next, beta) and current is semver, we need to update + // - If both are semver but different, user explicitly changed versions + const intentIsTag = !/^\d+\.\d+\.\d+/.test(intentVersion) + const currentIsSemver = /^\d+\.\d+\.\d+/.test(currentVersion) + + if (intentIsTag && currentIsSemver) { + log( + `[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}" (opencode.json intent)` + ) + } else { + log( + `[auto-update-checker] Updating 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..daff89786 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,10 @@ export async function runBackgroundUpdateCheck( return } + // Sync cache package.json to match opencode.json intent before updating + // This handles the case where user switched from pinned version to tag (e.g., 3.10.0 -> @latest) + syncCachePackageJsonToIntent(pluginInfo) + invalidatePackage(PACKAGE_NAME) const installSuccess = await runBunInstallSafe()