Merge pull request #2380 from acamq/fix/auto-updater-paths
fix(auto-update-checker): use OpenCode cache paths for updates
This commit is contained in:
43
src/cli/config-manager/bun-install.test.ts
Normal file
43
src/cli/config-manager/bun-install.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
import { runBunInstallWithDetails } from "./bun-install"
|
||||
|
||||
describe("runBunInstallWithDetails", () => {
|
||||
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let spawnWithWindowsHideSpy: ReturnType<typeof spyOn>
|
||||
let existsSyncSpy: 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: () => {},
|
||||
} as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>)
|
||||
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 () => {
|
||||
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,6 @@
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { existsSync } from "node:fs"
|
||||
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 +18,19 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
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"], {
|
||||
cwd: getConfigDir(),
|
||||
cwd: cacheDir,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
@@ -34,13 +46,13 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
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`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
87
src/hooks/auto-update-checker/cache.test.ts
Normal file
87
src/hooks/auto-update-checker/cache.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
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)) {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
resetTestCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
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")
|
||||
|
||||
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({})
|
||||
})
|
||||
})
|
||||
@@ -16,46 +16,57 @@ function stripTrailingCommas(json: string): string {
|
||||
return json.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
|
||||
function removeFromBunLock(packageName: string): boolean {
|
||||
const lockPath = path.join(USER_CONFIG_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.workspaces?.[""]?.dependencies?.[packageName]) {
|
||||
delete lock.workspaces[""].dependencies[packageName]
|
||||
modified = true
|
||||
}
|
||||
|
||||
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 = [
|
||||
path.join(USER_CONFIG_DIR, "node_modules", packageName),
|
||||
path.join(CACHE_DIR, "node_modules", packageName),
|
||||
]
|
||||
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
|
||||
|
||||
let packageRemoved = false
|
||||
let dependencyRemoved = false
|
||||
let lockRemoved = false
|
||||
|
||||
for (const pkgDir of pkgDirs) {
|
||||
@@ -66,20 +77,9 @@ export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
14
src/hooks/auto-update-checker/constants.test.ts
Normal file
14
src/hooks/auto-update-checker/constants.test.ts
Normal file
@@ -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 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"
|
||||
|
||||
Reference in New Issue
Block a user