From c29e6f02139d1a64b7f6093225cae171e8e36f87 Mon Sep 17 00:00:00 2001 From: aw338WoWmUI <121638634+aw338WoWmUI@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:53:41 +0800 Subject: [PATCH 1/2] fix(cli): write version-aware plugin entry during installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - @ 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) --- src/cli/config-manager.test.ts | 154 ++++++++++++++++++++++++++++++++- src/cli/config-manager.ts | 50 +++++++++-- src/cli/install.ts | 7 +- 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index cd95438d..765b7532 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -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)", () => { diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 6db09de0..fcbcfdc8 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -109,6 +109,40 @@ export async function fetchLatestVersion(packageName: string): Promise { + 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 { + 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 { 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 { diff --git a/src/cli/install.ts b/src/cli/install.ts index 58452118..aafdd148 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -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 { 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 { 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.")) From 1a5fdb33389e1813be7116dfd83e5e6e160befe6 Mon Sep 17 00:00:00 2001 From: aw338WoWmUI <121638634+aw338WoWmUI@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:01:50 +0800 Subject: [PATCH 2/2] fix(cli): update existing plugin entry instead of skipping Addresses cubic review feedback: installer now replaces existing oh-my-opencode entries with the new version-aware entry, allowing users to switch between @latest, @beta, or pinned versions. --- src/cli/config-manager.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index fcbcfdc8..a2a96fa1 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -237,11 +237,18 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const config = parseResult.config const plugins = config.plugin ?? [] - if (plugins.some((p) => p.startsWith(PACKAGE_NAME))) { - return { success: true, configPath: path } + const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`)) + + if (existingIndex !== -1) { + if (plugins[existingIndex] === pluginEntry) { + return { success: true, configPath: path } + } + plugins[existingIndex] = pluginEntry + } else { + plugins.push(pluginEntry) } - config.plugin = [...plugins, pluginEntry] + config.plugin = plugins if (format === "jsonc") { const content = readFileSync(path, "utf-8") @@ -249,11 +256,8 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const match = content.match(pluginArrayRegex) if (match) { - const arrayContent = match[1].trim() - const newArrayContent = arrayContent - ? `${arrayContent},\n "${pluginEntry}"` - : `"${pluginEntry}"` - const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`) + const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ") + const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`) writeFileSync(path, newContent) } else { const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)