From a7d5e683c7f281ba0650ebfb3b3df9716dc9c0f9 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:56:21 -0700 Subject: [PATCH] fix(auto-update-checker): use OpenCode cache paths for updates Align version lookup, invalidation, and bun install with OpenCode's cache directory so updates target the loaded plugin location. Keep dependency declarations intact during invalidation so auto-update can reinstall instead of converging to uninstall. --- src/cli/config-manager/bun-install.test.ts | 39 +++++++++ src/cli/config-manager/bun-install.ts | 13 +-- src/hooks/auto-update-checker/cache.test.ts | 85 +++++++++++++++++++ src/hooks/auto-update-checker/cache.ts | 28 ++---- .../auto-update-checker/constants.test.ts | 14 +++ src/hooks/auto-update-checker/constants.ts | 14 +-- 6 files changed, 155 insertions(+), 38 deletions(-) create mode 100644 src/cli/config-manager/bun-install.test.ts create mode 100644 src/hooks/auto-update-checker/cache.test.ts create mode 100644 src/hooks/auto-update-checker/constants.test.ts diff --git a/src/cli/config-manager/bun-install.test.ts b/src/cli/config-manager/bun-install.test.ts new file mode 100644 index 000000000..28afea8d3 --- /dev/null +++ b/src/cli/config-manager/bun-install.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import * as dataPath from "../../shared/data-path" +import * as logger from "../../shared/logger" +import * as spawnHelpers from "../../shared/spawn-with-windows-hide" +import { runBunInstallWithDetails } from "./bun-install" + +describe("runBunInstallWithDetails", () => { + let getOpenCodeCacheDirSpy: ReturnType + let logSpy: ReturnType + let spawnWithWindowsHideSpy: ReturnType + + beforeEach(() => { + getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache") + logSpy = spyOn(logger, "log").mockImplementation(() => {}) + spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({ + exited: Promise.resolve(0), + exitCode: 0, + kill: mock(() => {}), + } as ReturnType) + }) + + afterEach(() => { + getOpenCodeCacheDirSpy.mockRestore() + logSpy.mockRestore() + spawnWithWindowsHideSpy.mockRestore() + }) + + it("runs bun install in the OpenCode cache directory", async () => { + const result = await runBunInstallWithDetails() + + expect(result).toEqual({ success: true }) + expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1) + expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { + cwd: "/tmp/opencode-cache", + stdout: "inherit", + stderr: "inherit", + }) + }) +}) diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index 6b3225547..ab3a9c27e 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -1,4 +1,5 @@ -import { getConfigDir } from "./config-context" +import { getOpenCodeCacheDir } from "../../shared/data-path" +import { log } from "../../shared/logger" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" const BUN_INSTALL_TIMEOUT_SECONDS = 60 @@ -16,9 +17,11 @@ export async function runBunInstall(): Promise { } export async function runBunInstallWithDetails(): Promise { + const cacheDir = getOpenCodeCacheDir() + try { const proc = spawnWithWindowsHide(["bun", "install"], { - cwd: getConfigDir(), + cwd: cacheDir, stdout: "inherit", stderr: "inherit", }) @@ -34,13 +37,13 @@ export async function runBunInstallWithDetails(): Promise { if (result === "timeout") { try { proc.kill() - } catch { - /* intentionally empty - process may have already exited */ + } catch (err) { + log("[cli/install] Failed to kill timed out bun install process:", err) } return { success: false, timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`, + error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${cacheDir} && bun i`, } } diff --git a/src/hooks/auto-update-checker/cache.test.ts b/src/hooks/auto-update-checker/cache.test.ts new file mode 100644 index 000000000..902850545 --- /dev/null +++ b/src/hooks/auto-update-checker/cache.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import * as dataPath from "../../shared/data-path" +import * as opencodeConfigDir from "../../shared/opencode-config-dir" + +const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__") +const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode") + +function resetTestCache(): void { + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } + + mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true }) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "package.json"), + JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2) + ) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), + JSON.stringify( + { + workspaces: { + "": { + dependencies: { "oh-my-opencode": "latest", other: "1.0.0" }, + }, + }, + packages: { + "oh-my-opencode": {}, + other: {}, + }, + }, + null, + 2 + ) + ) + writeFileSync( + join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"), + '{"name":"oh-my-opencode"}' + ) +} + +describe("invalidatePackage", () => { + let getOpenCodeCacheDirSpy: ReturnType + let getOpenCodeConfigDirSpy: ReturnType + + beforeEach(() => { + getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue(TEST_OPENCODE_CACHE_DIR) + getOpenCodeConfigDirSpy = spyOn(opencodeConfigDir, "getOpenCodeConfigDir").mockReturnValue("/tmp/opencode-config") + resetTestCache() + }) + + afterEach(() => { + getOpenCodeCacheDirSpy.mockRestore() + getOpenCodeConfigDirSpy.mockRestore() + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true, force: true }) + } + }) + + it("invalidates the installed package from the OpenCode cache directory", async () => { + const { invalidatePackage } = await import(`./cache?test=${Date.now()}`) + + const result = invalidatePackage() + + expect(result).toBe(true) + expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false) + + const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as { + dependencies?: Record + } + expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest") + expect(packageJson.dependencies?.other).toBe("1.0.0") + + const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as { + workspaces?: { ""?: { dependencies?: Record } } + packages?: Record + } + expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest") + expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0") + expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined() + expect(bunLock.packages?.other).toEqual({}) + }) +}) diff --git a/src/hooks/auto-update-checker/cache.ts b/src/hooks/auto-update-checker/cache.ts index e5df33df7..93778fedb 100644 --- a/src/hooks/auto-update-checker/cache.ts +++ b/src/hooks/auto-update-checker/cache.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs" import * as path from "node:path" -import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants" +import { CACHE_DIR, PACKAGE_NAME } from "./constants" import { log } from "../../shared/logger" interface BunLockfile { @@ -17,18 +17,13 @@ function stripTrailingCommas(json: string): string { } function removeFromBunLock(packageName: string): boolean { - const lockPath = path.join(USER_CONFIG_DIR, "bun.lock") + const lockPath = path.join(CACHE_DIR, "bun.lock") if (!fs.existsSync(lockPath)) return false try { const content = fs.readFileSync(lockPath, "utf-8") const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile - let modified = false - - if (lock.workspaces?.[""]?.dependencies?.[packageName]) { - delete lock.workspaces[""].dependencies[packageName] - modified = true - } + let modified = false if (lock.packages?.[packageName]) { delete lock.packages[packageName] @@ -48,11 +43,9 @@ function removeFromBunLock(packageName: string): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { try { - const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName) - const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json") + const pkgDir = path.join(CACHE_DIR, "node_modules", packageName) let packageRemoved = false - let dependencyRemoved = false let lockRemoved = false if (fs.existsSync(pkgDir)) { @@ -61,20 +54,9 @@ export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { packageRemoved = true } - if (fs.existsSync(pkgJsonPath)) { - const content = fs.readFileSync(pkgJsonPath, "utf-8") - const pkgJson = JSON.parse(content) - if (pkgJson.dependencies?.[packageName]) { - delete pkgJson.dependencies[packageName] - fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) - log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`) - dependencyRemoved = true - } - } - lockRemoved = removeFromBunLock(packageName) - if (!packageRemoved && !dependencyRemoved && !lockRemoved) { + if (!packageRemoved && !lockRemoved) { log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`) return false } diff --git a/src/hooks/auto-update-checker/constants.test.ts b/src/hooks/auto-update-checker/constants.test.ts new file mode 100644 index 000000000..30ff1eab3 --- /dev/null +++ b/src/hooks/auto-update-checker/constants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test" +import { join } from "node:path" +import { getOpenCodeCacheDir } from "../../shared/data-path" + +describe("auto-update-checker constants", () => { + it("uses the OpenCode cache directory for installed package metadata", async () => { + const { CACHE_DIR, INSTALLED_PACKAGE_JSON, PACKAGE_NAME } = await import(`./constants?test=${Date.now()}`) + + expect(CACHE_DIR).toBe(getOpenCodeCacheDir()) + expect(INSTALLED_PACKAGE_JSON).toBe( + join(getOpenCodeCacheDir(), "node_modules", PACKAGE_NAME, "package.json") + ) + }) +}) diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts index 7382463b0..9babbde48 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -1,19 +1,13 @@ import * as path from "node:path" import * as os from "node:os" -import { getOpenCodeConfigDir } from "../../shared" +import { getOpenCodeCacheDir } from "../../shared/data-path" +import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" export const PACKAGE_NAME = "oh-my-opencode" export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags` export const NPM_FETCH_TIMEOUT = 5000 -function getCacheDir(): string { - if (process.platform === "win32") { - return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode") - } - return path.join(os.homedir(), ".cache", "opencode") -} - -export const CACHE_DIR = getCacheDir() +export const CACHE_DIR = getOpenCodeCacheDir() export const VERSION_FILE = path.join(CACHE_DIR, "version") export function getWindowsAppdataDir(): string | null { @@ -26,7 +20,7 @@ export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json") export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc") export const INSTALLED_PACKAGE_JSON = path.join( - USER_CONFIG_DIR, + CACHE_DIR, "node_modules", PACKAGE_NAME, "package.json"