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.
This commit is contained in:
acamq
2026-03-07 15:56:21 -07:00
parent 63ed7a5448
commit a7d5e683c7
6 changed files with 155 additions and 38 deletions
@@ -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<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
let spawnWithWindowsHideSpy: ReturnType<typeof spyOn>
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<typeof spawnHelpers.spawnWithWindowsHide>)
})
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",
})
})
})
+8 -5
View File
@@ -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" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60 const BUN_INSTALL_TIMEOUT_SECONDS = 60
@@ -16,9 +17,11 @@ export async function runBunInstall(): Promise<boolean> {
} }
export async function runBunInstallWithDetails(): Promise<BunInstallResult> { export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const cacheDir = getOpenCodeCacheDir()
try { try {
const proc = spawnWithWindowsHide(["bun", "install"], { const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(), cwd: cacheDir,
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
}) })
@@ -34,13 +37,13 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
if (result === "timeout") { if (result === "timeout") {
try { try {
proc.kill() proc.kill()
} catch { } catch (err) {
/* intentionally empty - process may have already exited */ log("[cli/install] Failed to kill timed out bun install process:", err)
} }
return { return {
success: false, success: false,
timedOut: true, 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`,
} }
} }
@@ -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<typeof spyOn>
let getOpenCodeConfigDirSpy: ReturnType<typeof spyOn>
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<string, string>
}
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<string, string> } }
packages?: Record<string, unknown>
}
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({})
})
})
+5 -23
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs" import * as fs from "node:fs"
import * as path from "node:path" 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" import { log } from "../../shared/logger"
interface BunLockfile { interface BunLockfile {
@@ -17,18 +17,13 @@ function stripTrailingCommas(json: string): string {
} }
function removeFromBunLock(packageName: string): boolean { 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 if (!fs.existsSync(lockPath)) return false
try { try {
const content = fs.readFileSync(lockPath, "utf-8") const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let modified = false let modified = false
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
delete lock.workspaces[""].dependencies[packageName]
modified = true
}
if (lock.packages?.[packageName]) { if (lock.packages?.[packageName]) {
delete lock.packages[packageName] delete lock.packages[packageName]
@@ -48,11 +43,9 @@ function removeFromBunLock(packageName: string): boolean {
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try { try {
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName) const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
let packageRemoved = false let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false let lockRemoved = false
if (fs.existsSync(pkgDir)) { if (fs.existsSync(pkgDir)) {
@@ -61,20 +54,9 @@ export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
packageRemoved = true 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) lockRemoved = removeFromBunLock(packageName)
if (!packageRemoved && !dependencyRemoved && !lockRemoved) { if (!packageRemoved && !lockRemoved) {
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`) log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
return false return false
} }
@@ -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")
)
})
})
+4 -10
View File
@@ -1,19 +1,13 @@
import * as path from "node:path" import * as path from "node:path"
import * as os from "node:os" 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 PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags` export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000 export const NPM_FETCH_TIMEOUT = 5000
function getCacheDir(): string { export const CACHE_DIR = getOpenCodeCacheDir()
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 VERSION_FILE = path.join(CACHE_DIR, "version") export const VERSION_FILE = path.join(CACHE_DIR, "version")
export function getWindowsAppdataDir(): string | null { 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 USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")
export const INSTALLED_PACKAGE_JSON = path.join( export const INSTALLED_PACKAGE_JSON = path.join(
USER_CONFIG_DIR, CACHE_DIR,
"node_modules", "node_modules",
PACKAGE_NAME, PACKAGE_NAME,
"package.json" "package.json"