Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
98 changes: 79 additions & 19 deletions src/hooks/auto-update-checker/cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as fs from "node:fs"
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"

interface BunLockfile {
Expand All @@ -12,22 +12,36 @@ interface BunLockfile {
packages?: Record<string, unknown>
}

interface InvalidatePackageOptions {
acceptedPackageNames?: readonly string[]
cacheDir?: string
defaultPackageName?: string
userConfigDir?: string
}

function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1")
}

function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
function removeFromTextBunLock(lockPath: string, packageNames: readonly string[]): boolean {
try {
const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let removed = false

for (const packageName of packageNames) {
if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
removed = true
}
}

if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
if (removed) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true
}
return false

return removed
} catch {
return false
}
Expand All @@ -43,12 +57,12 @@ function deleteBinaryBunLock(lockPath: string): boolean {
}
}

function removeFromBunLock(packageName: string): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
function removeFromBunLock(cacheDir: string, packageNames: readonly string[]): boolean {
const textLockPath = path.join(cacheDir, "bun.lock")
const binaryLockPath = path.join(cacheDir, "bun.lockb")

if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName)
return removeFromTextBunLock(textLockPath, packageNames)
}

// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
Expand All @@ -59,16 +73,61 @@ function removeFromBunLock(packageName: string): boolean {
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 {
const userConfigDir = getUserConfigDir()
const pkgDirs = [
path.join(userConfigDir, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
const acceptedPackageNames = options.acceptedPackageNames ?? ACCEPTED_PACKAGE_NAMES
const cacheDir = options.cacheDir ?? CACHE_DIR
const defaultPackageName = options.defaultPackageName ?? PACKAGE_NAME
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 lockRemoved = false
let specifierRemoved = false

for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
Expand All @@ -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}`)
return false
}
Expand Down
39 changes: 14 additions & 25 deletions src/hooks/auto-update-checker/checker/cached-version.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { getCachedVersion } from "./cached-version"

// Hold mutable mock state so beforeEach can swap the cache root for each test.
const mockState: { candidates: string[] } = { candidates: [] }

mock.module("../constants", () => ({
INSTALLED_PACKAGE_JSON_CANDIDATES: new Proxy([], {
get(_, prop) {
const current = mockState.candidates
// Forward array methods/properties to the mutable candidates list
// so getCachedVersion's `for (... of ...)` sees fresh data per test.
const value = (current as unknown as Record<PropertyKey, unknown>)[prop]
if (typeof value === "function") {
return (value as (...args: unknown[]) => unknown).bind(current)
}
return value
},
}),
}))

mock.module("./package-json-locator", () => ({
findPackageJsonUp: () => null,
}))

import { getCachedVersion } from "./cached-version"
function getIsolatedCachedVersion(): string | null {
return getCachedVersion({
packageJsonCandidates: mockState.candidates,
findPackageJson: () => null,
currentDir: null,
execDir: null,
})
}

describe("getCachedVersion (GH-3257)", () => {
let cacheRoot: string
Expand All @@ -48,7 +37,7 @@ describe("getCachedVersion (GH-3257)", () => {
mkdirSync(pkgDir, { recursive: true })
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))

expect(getCachedVersion()).toBe("3.16.0")
expect(getIsolatedCachedVersion()).toBe("3.16.0")
})

it("returns the version when the package is installed under oh-my-openagent", () => {
Expand All @@ -59,7 +48,7 @@ describe("getCachedVersion (GH-3257)", () => {
mkdirSync(pkgDir, { recursive: true })
writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))

expect(getCachedVersion()).toBe("3.16.0")
expect(getIsolatedCachedVersion()).toBe("3.16.0")
})

it("prefers oh-my-opencode when both are installed", () => {
Expand All @@ -71,10 +60,10 @@ describe("getCachedVersion (GH-3257)", () => {
mkdirSync(aliasDir, { recursive: true })
writeFileSync(join(aliasDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.15.0" }))

expect(getCachedVersion()).toBe("3.16.0")
expect(getIsolatedCachedVersion()).toBe("3.16.0")
})

it("returns null when neither candidate exists and fallbacks find nothing", () => {
expect(getCachedVersion()).toBeNull()
expect(getIsolatedCachedVersion()).toBeNull()
})
})
34 changes: 24 additions & 10 deletions src/hooks/auto-update-checker/checker/cached-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ import type { PackageJson } from "../types"
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
import { findPackageJsonUp } from "./package-json-locator"

interface CachedVersionOptions {
packageJsonCandidates?: readonly string[]
findPackageJson?: (startPath: string) => string | null
currentDir?: string | null
execDir?: string | null
}

function readPackageVersion(packageJsonPath: string): string | null {
const content = fs.readFileSync(packageJsonPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
return pkg.version ?? null
}

export function getCachedVersion(): string | null {
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
export function getCachedVersion(options: CachedVersionOptions = {}): string | null {
const packageJsonCandidates = options.packageJsonCandidates ?? INSTALLED_PACKAGE_JSON_CANDIDATES
const findPackageJson = options.findPackageJson ?? findPackageJsonUp

for (const candidate of packageJsonCandidates) {
try {
if (fs.existsSync(candidate)) {
return readPackageVersion(candidate)
Expand All @@ -24,20 +34,24 @@ export function getCachedVersion(): string | null {
}

try {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pkgPath = findPackageJsonUp(currentDir)
if (pkgPath) {
return readPackageVersion(pkgPath)
const currentDir = options.currentDir === undefined ? path.dirname(fileURLToPath(import.meta.url)) : options.currentDir
if (currentDir) {
const pkgPath = findPackageJson(currentDir)
if (pkgPath) {
return readPackageVersion(pkgPath)
}
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from current directory:", err)
}

try {
const execDir = path.dirname(fs.realpathSync(process.execPath))
const pkgPath = findPackageJsonUp(execDir)
if (pkgPath) {
return readPackageVersion(pkgPath)
const execDir = options.execDir === undefined ? path.dirname(fs.realpathSync(process.execPath)) : options.execDir
if (execDir) {
const pkgPath = findPackageJson(execDir)
if (pkgPath) {
return readPackageVersion(pkgPath)
}
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from execPath:", err)
Expand Down
Loading
Loading