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 0000000000..97cd4585a4 --- /dev/null +++ b/src/cli/config-manager/bun-install.test.ts @@ -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 + let logSpy: ReturnType + let spawnWithWindowsHideSpy: ReturnType + let existsSyncSpy: 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: () => {}, + } 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 () => { + 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 6b32255474..230b03eaee 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -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 { } 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"], { - cwd: getConfigDir(), + cwd: cacheDir, stdout: "inherit", stderr: "inherit", }) @@ -34,13 +46,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 0000000000..4e7e9ba493 --- /dev/null +++ b/src/hooks/auto-update-checker/cache.test.ts @@ -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 + } + 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 9cf312a36d..2235bbaddf 100644 --- a/src/hooks/auto-update-checker/cache.ts +++ b/src/hooks/auto-update-checker/cache.ts @@ -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 false + } catch { + return false + } +} - return modified +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 } 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 0000000000..30ff1eab3a --- /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 7382463b0f..9babbde488 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"