Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/cli/config-manager/bun-install.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
13 changes: 8 additions & 5 deletions src/cli/config-manager/bun-install.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,9 +17,11 @@ export async function runBunInstall(): Promise<boolean> {
}

export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const cacheDir = getOpenCodeCacheDir()

try {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(),
cwd: cacheDir,
stdout: "inherit",
stderr: "inherit",
})
Expand All @@ -34,13 +37,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`,
}
}

Expand Down
85 changes: 85 additions & 0 deletions src/hooks/auto-update-checker/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
22 changes: 2 additions & 20 deletions src/hooks/auto-update-checker/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,14 @@ 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
}

if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
modified = true
Expand All @@ -52,10 +47,8 @@ export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
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) {
Expand All @@ -66,20 +59,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
}
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/auto-update-checker/constants.test.ts
Original file line number Diff line number Diff line change
@@ -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")
)
})
})
14 changes: 4 additions & 10 deletions src/hooks/auto-update-checker/constants.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"
Expand Down