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:
@@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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({})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user