diff --git a/src/cli/config-manager.test.ts b/src/cli/config-manager.test.ts index 1df99d9c..b6d0fc1d 100644 --- a/src/cli/config-manager.test.ts +++ b/src/cli/config-manager.test.ts @@ -1,8 +1,174 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test" -import { ANTIGRAVITY_PROVIDER_CONFIG, generateOmoConfig } from "./config-manager" +import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags, generateOmoConfig } from "./config-manager" import type { InstallConfig } from "./types" +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") + }) + + test("prioritizes latest over other tags when version matches multiple", async () => { + // #given version matches both latest and beta (during release promotion) + globalThis.fetch = mock(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ beta: "3.0.0", latest: "3.0.0", next: "3.1.0-alpha.1" }), + } as Response) + ) as unknown as typeof fetch + + // #when current version matches both + const result = await getPluginNameWithVersion("3.0.0") + + // #then should prioritize @latest + expect(result).toBe("oh-my-opencode@latest") + }) +}) + +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)", () => { const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index c889653b..37a96dc7 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -1,5 +1,4 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs" -import { join } from "node:path" import { parseJsonc, getOpenCodeConfigPaths, @@ -109,6 +108,47 @@ export async function fetchLatestVersion(packageName: string): Promise { + try { + const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, { + signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS), + }) + if (!res.ok) return null + const data = await res.json() as NpmDistTags + return data + } catch { + return null + } +} + +const PACKAGE_NAME = "oh-my-opencode" + +const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const + +export async function getPluginNameWithVersion(currentVersion: string): Promise { + const distTags = await fetchNpmDistTags(PACKAGE_NAME) + + if (distTags) { + const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)]) + for (const tag of allTags) { + if (distTags[tag] === currentVersion) { + return `${PACKAGE_NAME}@${tag}` + } + } + } + + return `${PACKAGE_NAME}@${currentVersion}` +} + type ConfigFormat = "json" | "jsonc" | "none" interface OpenCodeConfig { @@ -179,7 +219,7 @@ function ensureConfigDir(): void { } } -export function addPluginToOpenCodeConfig(): ConfigMergeResult { +export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { ensureConfigDir() } catch (err) { @@ -187,11 +227,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 +243,18 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { const config = parseResult.config const plugins = config.plugin ?? [] - if (plugins.some((p) => p.startsWith(pluginName))) { - 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, pluginName] + config.plugin = plugins if (format === "jsonc") { const content = readFileSync(path, "utf-8") @@ -215,14 +262,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult { const match = content.match(pluginArrayRegex) if (match) { - const arrayContent = match[1].trim() - const newArrayContent = arrayContent - ? `${arrayContent},\n "${pluginName}"` - : `"${pluginName}"` - 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": ["${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 8e648ae2..e677a9a3 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("✗"), @@ -274,7 +277,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 @@ -380,7 +383,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."))