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 1/3] 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" From f89cc969ecc9b129c0ad460560ef0d56da6b74c0 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:13:51 -0700 Subject: [PATCH 2/3] test(auto-update-checker): stabilize cache invalidation module isolation Mock constants directly in cache.test to avoid transitive module-cache reuse when importing cache.ts. This removes Date.now query cache-busting and makes the test deterministic across runs. --- src/hooks/auto-update-checker/cache.test.ts | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/hooks/auto-update-checker/cache.test.ts b/src/hooks/auto-update-checker/cache.test.ts index 902850545..4e7e9ba49 100644 --- a/src/hooks/auto-update-checker/cache.test.ts +++ b/src/hooks/auto-update-checker/cache.test.ts @@ -1,11 +1,20 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" +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 * 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") +const TEST_USER_CONFIG_DIR = "/tmp/opencode-config" + +mock.module("./constants", () => ({ + CACHE_DIR: TEST_OPENCODE_CACHE_DIR, + USER_CONFIG_DIR: TEST_USER_CONFIG_DIR, + PACKAGE_NAME: "oh-my-opencode", +})) + +mock.module("../../shared/logger", () => ({ + log: () => {}, +})) function resetTestCache(): void { if (existsSync(TEST_CACHE_DIR)) { @@ -42,25 +51,18 @@ function resetTestCache(): void { } 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 { invalidatePackage } = await import("./cache") const result = invalidatePackage() From c4112f80db595caadc557b3cefdf4f773dce8273 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Sun, 8 Mar 2026 09:15:13 -0600 Subject: [PATCH 3/3] fix(auto-updater): handle bun.lockb and add workspace validation - Support binary bun.lockb format by deleting entire file (cannot parse) - Add workspace check: verify package.json exists before bun install - Quote paths in error messages for Windows/paths with spaces --- src/cli/config-manager/bun-install.test.ts | 8 +++-- src/cli/config-manager/bun-install.ts | 11 +++++- src/hooks/auto-update-checker/cache.ts | 40 ++++++++++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/cli/config-manager/bun-install.test.ts b/src/cli/config-manager/bun-install.test.ts index 28afea8d3..97cd4585a 100644 --- a/src/cli/config-manager/bun-install.test.ts +++ b/src/cli/config-manager/bun-install.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test" +import * as fs from "node:fs" import * as dataPath from "../../shared/data-path" import * as logger from "../../shared/logger" import * as spawnHelpers from "../../shared/spawn-with-windows-hide" @@ -8,6 +9,7 @@ describe("runBunInstallWithDetails", () => { let getOpenCodeCacheDirSpy: ReturnType let logSpy: ReturnType let spawnWithWindowsHideSpy: ReturnType + let existsSyncSpy: ReturnType beforeEach(() => { getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache") @@ -15,14 +17,16 @@ describe("runBunInstallWithDetails", () => { spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({ exited: Promise.resolve(0), exitCode: 0, - kill: mock(() => {}), + kill: () => {}, } as ReturnType) + existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true) }) afterEach(() => { getOpenCodeCacheDirSpy.mockRestore() logSpy.mockRestore() spawnWithWindowsHideSpy.mockRestore() + existsSyncSpy.mockRestore() }) it("runs bun install in the OpenCode cache directory", async () => { diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index ab3a9c27e..230b03eae 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs" import { getOpenCodeCacheDir } from "../../shared/data-path" import { log } from "../../shared/logger" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" @@ -18,6 +19,14 @@ export async function runBunInstall(): Promise { export async function runBunInstallWithDetails(): Promise { const cacheDir = getOpenCodeCacheDir() + const packageJsonPath = `${cacheDir}/package.json` + + if (!existsSync(packageJsonPath)) { + return { + success: false, + error: `Workspace not initialized: ${packageJsonPath} not found. OpenCode should create this on first run.`, + } + } try { const proc = spawnWithWindowsHide(["bun", "install"], { @@ -43,7 +52,7 @@ export async function runBunInstallWithDetails(): Promise { return { success: false, timedOut: true, - error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${cacheDir} && 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.ts b/src/hooks/auto-update-checker/cache.ts index 14853e78f..2235bbadd 100644 --- a/src/hooks/auto-update-checker/cache.ts +++ b/src/hooks/auto-update-checker/cache.ts @@ -16,31 +16,49 @@ function stripTrailingCommas(json: string): string { return json.replace(/,(\s*[}\]])/g, "$1") } -function removeFromBunLock(packageName: string): boolean { - const lockPath = path.join(CACHE_DIR, "bun.lock") - if (!fs.existsSync(lockPath)) return false - +function removeFromTextBunLock(lockPath: string, packageName: string): boolean { try { const content = fs.readFileSync(lockPath, "utf-8") const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile - let modified = false if (lock.packages?.[packageName]) { delete lock.packages[packageName] - modified = true - } - - if (modified) { fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2)) log(`[auto-update-checker] Removed from bun.lock: ${packageName}`) + return true } - - return modified + return false } catch { return false } } +function deleteBinaryBunLock(lockPath: string): boolean { + try { + fs.unlinkSync(lockPath) + log(`[auto-update-checker] Removed bun.lockb to force re-resolution`) + return true + } catch { + return false + } +} + +function removeFromBunLock(packageName: string): boolean { + const textLockPath = path.join(CACHE_DIR, "bun.lock") + const binaryLockPath = path.join(CACHE_DIR, "bun.lockb") + + if (fs.existsSync(textLockPath)) { + return removeFromTextBunLock(textLockPath, packageName) + } + + // Binary lockfiles cannot be parsed; deletion forces bun to re-resolve + if (fs.existsSync(binaryLockPath)) { + return deleteBinaryBunLock(binaryLockPath) + } + + return false +} + export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { try { const pkgDirs = [