fix(cli): write version-aware plugin entry during installation

Previously, the installer always wrote 'oh-my-opencode' without a version,
causing users who installed beta versions (e.g., bunx oh-my-opencode@beta)
to unexpectedly load the stable version on next OpenCode startup.

Now the installer queries npm dist-tags and writes:
- @latest when current version matches the latest tag
- @beta when current version matches the beta tag
- @<version> when no tag matches (pins to specific version)

This ensures:
- bunx oh-my-opencode install → @latest (tracks stable)
- bunx oh-my-opencode@beta install → @beta (tracks beta tag)
- bunx oh-my-opencode@3.0.0-beta.2 install → @3.0.0-beta.2 (pinned)
This commit is contained in:
aw338WoWmUI 2026-01-11 12:53:41 +08:00 committed by Spark
parent 1bbb61b1c2
commit c29e6f0213
3 changed files with 199 additions and 12 deletions

View File

@ -1,6 +1,156 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager"
import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags } from "./config-manager"
describe("getPluginNameWithVersion", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns @latest when current version matches latest tag", async () => {
// #given npm dist-tags with latest=2.14.0
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 2.14.0
const result = await getPluginNameWithVersion("2.14.0")
// #then should use @latest tag
expect(result).toBe("oh-my-opencode@latest")
})
test("returns @beta when current version matches beta tag", async () => {
// #given npm dist-tags with beta=3.0.0-beta.3
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 3.0.0-beta.3
const result = await getPluginNameWithVersion("3.0.0-beta.3")
// #then should use @beta tag
expect(result).toBe("oh-my-opencode@beta")
})
test("returns @next when current version matches next tag", async () => {
// #given npm dist-tags with next=3.1.0-next.1
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3", next: "3.1.0-next.1" }),
} as Response)
) as unknown as typeof fetch
// #when current version is 3.1.0-next.1
const result = await getPluginNameWithVersion("3.1.0-next.1")
// #then should use @next tag
expect(result).toBe("oh-my-opencode@next")
})
test("returns pinned version when no tag matches", async () => {
// #given npm dist-tags with beta=3.0.0-beta.3
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when current version is old beta 3.0.0-beta.2
const result = await getPluginNameWithVersion("3.0.0-beta.2")
// #then should pin to specific version
expect(result).toBe("oh-my-opencode@3.0.0-beta.2")
})
test("returns pinned version when fetch fails", async () => {
// #given network failure
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
// #when current version is 3.0.0-beta.3
const result = await getPluginNameWithVersion("3.0.0-beta.3")
// #then should fall back to pinned version
expect(result).toBe("oh-my-opencode@3.0.0-beta.3")
})
test("returns pinned version when npm returns non-ok response", async () => {
// #given npm returns 404
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
// #when current version is 2.14.0
const result = await getPluginNameWithVersion("2.14.0")
// #then should fall back to pinned version
expect(result).toBe("oh-my-opencode@2.14.0")
})
})
describe("fetchNpmDistTags", () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
test("returns dist-tags on success", async () => {
// #given npm returns dist-tags
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
} as Response)
) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return the tags
expect(result).toEqual({ latest: "2.14.0", beta: "3.0.0-beta.3" })
})
test("returns null on network failure", async () => {
// #given network failure
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return null
expect(result).toBeNull()
})
test("returns null on non-ok response", async () => {
// #given npm returns 404
globalThis.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
} as Response)
) as unknown as typeof fetch
// #when fetching dist-tags
const result = await fetchNpmDistTags("oh-my-opencode")
// #then should return null
expect(result).toBeNull()
})
})
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
test("Gemini models include full spec (limit + modalities)", () => {

View File

@ -109,6 +109,40 @@ export async function fetchLatestVersion(packageName: string): Promise<string |
}
}
interface NpmDistTags {
latest?: string
beta?: string
next?: string
[tag: string]: string | undefined
}
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
try {
const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`)
if (!res.ok) return null
const data = await res.json() as NpmDistTags
return data
} catch {
return null
}
}
const PACKAGE_NAME = "oh-my-opencode"
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
if (distTags) {
for (const [tag, tagVersion] of Object.entries(distTags)) {
if (tagVersion === currentVersion) {
return `${PACKAGE_NAME}@${tag}`
}
}
}
return `${PACKAGE_NAME}@${currentVersion}`
}
type ConfigFormat = "json" | "jsonc" | "none"
interface OpenCodeConfig {
@ -179,7 +213,7 @@ function ensureConfigDir(): void {
}
}
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
@ -187,11 +221,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
}
const { format, path } = detectConfigFormat()
const pluginName = "oh-my-opencode"
const pluginEntry = await getPluginNameWithVersion(currentVersion)
try {
if (format === "none") {
const config: OpenCodeConfig = { plugin: [pluginName] }
const config: OpenCodeConfig = { plugin: [pluginEntry] }
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
return { success: true, configPath: path }
}
@ -203,11 +237,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
const config = parseResult.config
const plugins = config.plugin ?? []
if (plugins.some((p) => p.startsWith(pluginName))) {
if (plugins.some((p) => p.startsWith(PACKAGE_NAME))) {
return { success: true, configPath: path }
}
config.plugin = [...plugins, pluginName]
config.plugin = [...plugins, pluginEntry]
if (format === "jsonc") {
const content = readFileSync(path, "utf-8")
@ -217,12 +251,12 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
if (match) {
const arrayContent = match[1].trim()
const newArrayContent = arrayContent
? `${arrayContent},\n "${pluginName}"`
: `"${pluginName}"`
? `${arrayContent},\n "${pluginEntry}"`
: `"${pluginEntry}"`
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`)
writeFileSync(path, newContent)
} else {
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`)
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
writeFileSync(path, newContent)
}
} else {

View File

@ -11,6 +11,9 @@ import {
detectCurrentConfig,
} from "./config-manager"
const packageJson = await import("../../package.json")
const VERSION = packageJson.version
const SYMBOLS = {
check: color.green("✓"),
cross: color.red("✗"),
@ -250,7 +253,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
const config = argsToConfig(args)
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
const pluginResult = addPluginToOpenCodeConfig()
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
printError(`Failed: ${pluginResult.error}`)
return 1
@ -360,7 +363,7 @@ export async function install(args: InstallArgs): Promise<number> {
if (!config) return 1
s.start("Adding oh-my-opencode to OpenCode config")
const pluginResult = addPluginToOpenCodeConfig()
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
if (!pluginResult.success) {
s.stop(`Failed to add plugin: ${pluginResult.error}`)
p.outro(color.red("Installation failed."))