fix(auto-update): clean stale OMO cache roots

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
deopa0402
2026-04-29 00:53:11 +09:00
committed by 전나맛있는어쩌고힙한하루보내세요
parent babee921b2
commit 37d9d613b6
2 changed files with 128 additions and 56 deletions
+80 -20
View File
@@ -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 { CACHE_DIR, PACKAGE_NAME, getUserConfigDir } from "./constants" import { ACCEPTED_PACKAGE_NAMES, CACHE_DIR, PACKAGE_NAME, getUserConfigDir } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
interface BunLockfile { interface BunLockfile {
@@ -12,22 +12,36 @@ interface BunLockfile {
packages?: Record<string, unknown> packages?: Record<string, unknown>
} }
interface InvalidatePackageOptions {
acceptedPackageNames?: readonly string[]
cacheDir?: string
defaultPackageName?: string
userConfigDir?: string
}
function stripTrailingCommas(json: string): string { function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1") return json.replace(/,(\s*[}\]])/g, "$1")
} }
function removeFromTextBunLock(lockPath: string, packageName: string): boolean { function removeFromTextBunLock(lockPath: string, packageNames: readonly string[]): boolean {
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 removed = false
if (lock.packages?.[packageName]) { for (const packageName of packageNames) {
delete lock.packages[packageName] if (lock.packages?.[packageName]) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2)) delete lock.packages[packageName]
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`) log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true removed = true
}
} }
return false
if (removed) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
}
return removed
} catch { } catch {
return false return false
} }
@@ -43,12 +57,12 @@ function deleteBinaryBunLock(lockPath: string): boolean {
} }
} }
function removeFromBunLock(packageName: string): boolean { function removeFromBunLock(cacheDir: string, packageNames: readonly string[]): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock") const textLockPath = path.join(cacheDir, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb") const binaryLockPath = path.join(cacheDir, "bun.lockb")
if (fs.existsSync(textLockPath)) { if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName) return removeFromTextBunLock(textLockPath, packageNames)
} }
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve // Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
@@ -59,16 +73,61 @@ function removeFromBunLock(packageName: string): boolean {
return false return false
} }
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { function getInvalidationPackageNames(
packageName: string,
defaultPackageName: string,
acceptedPackageNames: readonly string[]
): readonly string[] {
if (packageName === defaultPackageName) {
return acceptedPackageNames
}
return [packageName]
}
function removeSpecifierRootDirs(cacheDir: string, packageNames: readonly string[]): boolean {
const parentDirs = [cacheDir, path.join(cacheDir, "packages")]
const prefixes = packageNames.map(packageName => `${packageName}@`)
let removed = false
for (const parentDir of parentDirs) {
if (!fs.existsSync(parentDir)) {
continue
}
for (const entry of fs.readdirSync(parentDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !prefixes.some(prefix => entry.name.startsWith(prefix))) {
continue
}
const specifierDir = path.join(parentDir, entry.name)
fs.rmSync(specifierDir, { recursive: true, force: true })
log(`[auto-update-checker] Specifier cache removed: ${specifierDir}`)
removed = true
}
}
return removed
}
export function invalidatePackage(
packageName: string = PACKAGE_NAME,
options: InvalidatePackageOptions = {}
): boolean {
try { try {
const userConfigDir = getUserConfigDir() const acceptedPackageNames = options.acceptedPackageNames ?? ACCEPTED_PACKAGE_NAMES
const pkgDirs = [ const cacheDir = options.cacheDir ?? CACHE_DIR
path.join(userConfigDir, "node_modules", packageName), const defaultPackageName = options.defaultPackageName ?? PACKAGE_NAME
path.join(CACHE_DIR, "node_modules", packageName), const userConfigDir = options.userConfigDir ?? getUserConfigDir()
] const packageNames = getInvalidationPackageNames(packageName, defaultPackageName, acceptedPackageNames)
const pkgDirs = packageNames.flatMap(name => [
path.join(userConfigDir, "node_modules", name),
path.join(cacheDir, "node_modules", name),
])
let packageRemoved = false let packageRemoved = false
let lockRemoved = false let lockRemoved = false
let specifierRemoved = false
for (const pkgDir of pkgDirs) { for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) { if (fs.existsSync(pkgDir)) {
@@ -78,9 +137,10 @@ export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
} }
} }
lockRemoved = removeFromBunLock(packageName) specifierRemoved = removeSpecifierRootDirs(cacheDir, packageNames)
lockRemoved = removeFromBunLock(cacheDir, packageNames)
if (!packageRemoved && !lockRemoved) { if (!packageRemoved && !specifierRemoved && !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
} }
+48 -36
View File
@@ -1,38 +1,19 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { invalidatePackage } from "../auto-update-checker/cache"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__") const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode") const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode")
const TEST_USER_CONFIG_DIR = "/tmp/opencode-config" const TEST_USER_CONFIG_DIR = "/tmp/opencode-config"
let importCounter = 0 function testInvalidatePackage(packageName?: string): boolean {
return invalidatePackage(packageName, {
// Capture real modules BEFORE mocking acceptedPackageNames: ["oh-my-opencode", "oh-my-openagent"],
const _realConstants = require("../auto-update-checker/constants") cacheDir: TEST_OPENCODE_CACHE_DIR,
const _realLogger = require("../../shared/logger") defaultPackageName: "oh-my-opencode",
userConfigDir: TEST_USER_CONFIG_DIR,
async function importFreshCacheModule(): Promise<typeof import("../auto-update-checker/cache")> { })
mock.module("../auto-update-checker/constants", () => ({
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
NPM_REGISTRY_URL: "https://registry.npmjs.org/-/package/oh-my-opencode/dist-tags",
NPM_FETCH_TIMEOUT: 5000,
VERSION_FILE: join(TEST_OPENCODE_CACHE_DIR, "version"),
INSTALLED_PACKAGE_JSON: join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
getUserConfigDir: () => TEST_USER_CONFIG_DIR,
getUserOpencodeConfig: () => join(TEST_USER_CONFIG_DIR, "opencode.json"),
getUserOpencodeConfigJsonc: () => join(TEST_USER_CONFIG_DIR, "opencode.jsonc"),
getWindowsAppdataDir: () => null,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
const cacheModule = await import(`../auto-update-checker/cache?test=${importCounter++}`)
mock.restore()
return cacheModule
} }
function resetTestCache(): void { function resetTestCache(): void {
@@ -56,6 +37,8 @@ function resetTestCache(): void {
}, },
packages: { packages: {
"oh-my-opencode": {}, "oh-my-opencode": {},
"oh-my-openagent": {},
"some-other-package": {},
other: {}, other: {},
}, },
}, },
@@ -81,12 +64,28 @@ describe("invalidatePackage", () => {
}) })
it("invalidates the installed package from the OpenCode cache directory", async () => { it("invalidates the installed package from the OpenCode cache directory", async () => {
const { invalidatePackage } = await importFreshCacheModule() const rootSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "oh-my-opencode@latest")
const rootAcceptedSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "oh-my-openagent@latest")
const packagesSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "packages", "oh-my-opencode@latest")
const packagesAcceptedSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "packages", "oh-my-openagent@latest")
const otherSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "packages", "other@latest")
mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-openagent"), { recursive: true })
mkdirSync(join(rootSpecifierDir, "node_modules", "oh-my-opencode"), { recursive: true })
mkdirSync(join(rootAcceptedSpecifierDir, "node_modules", "oh-my-openagent"), { recursive: true })
mkdirSync(join(packagesSpecifierDir, "node_modules", "oh-my-opencode"), { recursive: true })
mkdirSync(join(packagesAcceptedSpecifierDir, "node_modules", "oh-my-openagent"), { recursive: true })
mkdirSync(otherSpecifierDir, { recursive: true })
const result = invalidatePackage() const result = testInvalidatePackage()
expect(result).toBe(true) expect(result).toBe(true)
expect(existsSync(rootSpecifierDir)).toBe(false)
expect(existsSync(rootAcceptedSpecifierDir)).toBe(false)
expect(existsSync(packagesSpecifierDir)).toBe(false)
expect(existsSync(packagesAcceptedSpecifierDir)).toBe(false)
expect(existsSync(otherSpecifierDir)).toBe(true)
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false) expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false)
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-openagent"))).toBe(false)
const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as { const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as {
dependencies?: Record<string, string> dependencies?: Record<string, string>
@@ -101,12 +100,25 @@ describe("invalidatePackage", () => {
expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest") expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0") expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0")
expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined() expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined()
expect(bunLock.packages?.["oh-my-openagent"]).toBeUndefined()
expect(bunLock.packages?.["some-other-package"]).toEqual({})
expect(bunLock.packages?.other).toEqual({}) expect(bunLock.packages?.other).toEqual({})
const explicitSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "some-other-package@latest")
const acceptedSpecifierDir = join(TEST_OPENCODE_CACHE_DIR, "oh-my-openagent@beta")
mkdirSync(explicitSpecifierDir, { recursive: true })
mkdirSync(acceptedSpecifierDir, { recursive: true })
const explicitResult = testInvalidatePackage("some-other-package")
expect(explicitResult).toBe(true)
expect(existsSync(explicitSpecifierDir)).toBe(false)
expect(existsSync(acceptedSpecifierDir)).toBe(true)
const explicitBunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as {
packages?: Record<string, unknown>
}
expect(explicitBunLock.packages?.["some-other-package"]).toBeUndefined()
expect(explicitBunLock.packages?.other).toEqual({})
}) })
}) })
afterAll(() => {
mock.module("../auto-update-checker/constants", () => _realConstants)
mock.module("../../shared/logger", () => _realLogger)
mock.restore()
})