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()