Merge pull request #3258 from code-yeongyu/fix/auto-update-openagent-name-match

fix(auto-update): resolve cached version when installed as oh-my-openagent (#3257)
This commit is contained in:
YeonGyu-Kim
2026-04-09 07:34:01 +09:00
committed by GitHub
7 changed files with 204 additions and 17 deletions
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
// 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"
describe("getCachedVersion (GH-3257)", () => {
let cacheRoot: string
beforeEach(() => {
cacheRoot = mkdtempSync(join(tmpdir(), "omo-cached-version-"))
mockState.candidates = [
join(cacheRoot, "node_modules", "oh-my-opencode", "package.json"),
join(cacheRoot, "node_modules", "oh-my-openagent", "package.json"),
]
})
afterEach(() => {
rmSync(cacheRoot, { recursive: true, force: true })
mockState.candidates = []
})
it("returns the version when the package is installed under oh-my-opencode", () => {
const pkgDir = join(cacheRoot, "node_modules", "oh-my-opencode")
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")
})
it("returns the version when the package is installed under oh-my-openagent", () => {
// GH-3257: npm users who install the aliased `oh-my-openagent` package get
// node_modules/oh-my-openagent/package.json, not the canonical oh-my-opencode
// path. The cached version resolver must check both.
const pkgDir = join(cacheRoot, "node_modules", "oh-my-openagent")
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")
})
it("prefers oh-my-opencode when both are installed", () => {
const legacyDir = join(cacheRoot, "node_modules", "oh-my-opencode")
mkdirSync(legacyDir, { recursive: true })
writeFileSync(join(legacyDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
const aliasDir = join(cacheRoot, "node_modules", "oh-my-openagent")
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")
})
it("returns null when neither candidate exists and fallbacks find nothing", () => {
expect(getCachedVersion()).toBeNull()
})
})
@@ -3,18 +3,20 @@ import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { log } from "../../../shared/logger"
import type { PackageJson } from "../types"
import { INSTALLED_PACKAGE_JSON } from "../constants"
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
import { findPackageJsonUp } from "./package-json-locator"
export function getCachedVersion(): string | null {
try {
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
try {
if (fs.existsSync(candidate)) {
const content = fs.readFileSync(candidate, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
}
} catch {
// ignore; try next candidate
}
} catch {
// ignore
}
try {
@@ -1,7 +1,7 @@
import * as fs from "node:fs"
import { fileURLToPath } from "node:url"
import type { OpencodeConfig } from "../types"
import { PACKAGE_NAME } from "../constants"
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
import { getConfigPaths } from "./config-paths"
import { stripJsonComments } from "./jsonc-strip"
@@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null {
const plugins = config.plugin ?? []
for (const entry of plugins) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
if (!entry.startsWith("file://")) continue
if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue
try {
return fileURLToPath(entry)
} catch {
return entry.replace("file://", "")
}
}
} catch {
@@ -0,0 +1,65 @@
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 { findPackageJsonUp } from "./package-json-locator"
describe("findPackageJsonUp", () => {
let workdir: string
beforeEach(() => {
workdir = mkdtempSync(join(tmpdir(), "omo-pkg-locator-"))
})
afterEach(() => {
rmSync(workdir, { recursive: true, force: true })
})
it("finds a package.json whose name is the canonical oh-my-opencode", () => {
const pkgPath = join(workdir, "package.json")
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" }))
const found = findPackageJsonUp(workdir)
expect(found).toBe(pkgPath)
})
it("finds a package.json whose name is the aliased oh-my-openagent (GH-3257)", () => {
// A user who installed `oh-my-openagent` from npm gets a node_modules entry
// whose package.json has `name: "oh-my-openagent"`. The auto-update-checker
// must still resolve it so the startup toast shows a real version instead
// of "unknown".
const pkgPath = join(workdir, "package.json")
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
const found = findPackageJsonUp(workdir)
expect(found).toBe(pkgPath)
})
it("walks up directories to find the matching package.json", () => {
const nested = join(workdir, "dist", "checker")
mkdirSync(nested, { recursive: true })
const pkgPath = join(workdir, "package.json")
writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" }))
const found = findPackageJsonUp(nested)
expect(found).toBe(pkgPath)
})
it("ignores unrelated package.json files", () => {
const pkgPath = join(workdir, "package.json")
writeFileSync(pkgPath, JSON.stringify({ name: "some-other-package", version: "1.0.0" }))
const found = findPackageJsonUp(workdir)
expect(found).toBeNull()
})
it("returns null when no package.json exists", () => {
const found = findPackageJsonUp(workdir)
expect(found).toBeNull()
})
})
@@ -1,7 +1,9 @@
import * as fs from "node:fs"
import * as path from "node:path"
import type { PackageJson } from "../types"
import { PACKAGE_NAME } from "../constants"
import { ACCEPTED_PACKAGE_NAMES } from "../constants"
const ACCEPTED_NAME_SET = new Set<string>(ACCEPTED_PACKAGE_NAMES)
export function findPackageJsonUp(startPath: string): string | null {
try {
@@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null {
try {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.name === PACKAGE_NAME) return pkgPath
if (pkg.name && ACCEPTED_NAME_SET.has(pkg.name)) return pkgPath
} catch {
// ignore
}
@@ -26,4 +26,24 @@ describe("auto-update-checker constants", () => {
// then PACKAGE_NAME equals the actually published package name
expect(PACKAGE_NAME).toBe(repoPackageJson.name)
})
it("ACCEPTED_PACKAGE_NAMES contains both the canonical and aliased npm names (GH-3257)", async () => {
const { ACCEPTED_PACKAGE_NAMES } = await import(`./constants?test=${Date.now()}`)
expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-opencode")
expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-openagent")
})
it("INSTALLED_PACKAGE_JSON_CANDIDATES covers every accepted package name (GH-3257)", async () => {
const { ACCEPTED_PACKAGE_NAMES, INSTALLED_PACKAGE_JSON_CANDIDATES, CACHE_DIR } = await import(
`./constants?test=${Date.now()}`
)
expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toHaveLength(ACCEPTED_PACKAGE_NAMES.length)
for (const name of ACCEPTED_PACKAGE_NAMES) {
expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toContain(
join(CACHE_DIR, "node_modules", name, "package.json")
)
}
})
})
@@ -4,6 +4,16 @@ import { getOpenCodeCacheDir } from "../../shared/data-path"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
export const PACKAGE_NAME = "oh-my-opencode"
/**
* All package names the canonical plugin may be published under.
*
* The package is published to npm as both `oh-my-opencode` (legacy canonical)
* and `oh-my-openagent` (current canonical). Any code that *reads* an
* installed package.json or walks up from an import path must accept both,
* because the installed name depends on which package the user added to
* their config. Code that *writes* continues to use {@link PACKAGE_NAME}.
*/
export const ACCEPTED_PACKAGE_NAMES = ["oh-my-opencode", "oh-my-openagent"] as const
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
@@ -34,3 +44,11 @@ export const INSTALLED_PACKAGE_JSON = path.join(
PACKAGE_NAME,
"package.json"
)
/**
* Candidate paths where the installed package.json may live, in priority order.
* Readers should try each path in order and stop on the first success.
*/
export const INSTALLED_PACKAGE_JSON_CANDIDATES = ACCEPTED_PACKAGE_NAMES.map(
name => path.join(CACHE_DIR, "node_modules", name, "package.json")
)