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"