fix(installer): enforce minimum OpenCode version check during install

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-04-08 17:20:00 +09:00
parent 389b194fbf
commit 6d8d82d760
6 changed files with 214 additions and 7 deletions
+54 -5
View File
@@ -21,11 +21,12 @@ describe("runCliInstaller", () => {
console.error = originalConsoleError
})
it("completes installation without auth plugin or provider config steps", async () => {
//#given
it("blocks installation when OpenCode is below the minimum version", async () => {
// given
const restoreSpies = [
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
@@ -34,9 +35,56 @@ describe("runCliInstaller", () => {
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
]
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
const args: InstallArgs = {
tui: false,
claude: "no",
openai: "no",
gemini: "no",
copilot: "no",
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
}
// when
const result = await runCliInstaller(args, "3.16.0")
// then
expect(result).toBe(1)
expect(addPluginSpy).not.toHaveBeenCalled()
for (const spy of restoreSpies) {
spy.mockRestore()
}
addPluginSpy.mockRestore()
})
it("completes installation without auth plugin or provider config steps", async () => {
// given
const restoreSpies = [
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
@@ -56,12 +104,13 @@ describe("runCliInstaller", () => {
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
}
//#when
// when
const result = await runCliInstaller(args, "3.4.0")
//#then
// then
expect(result).toBe(0)
for (const spy of restoreSpies) {
+7
View File
@@ -22,6 +22,7 @@ import {
printWarning,
validateNonTuiArgs,
} from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
export async function runCliInstaller(args: InstallArgs, version: string): Promise<number> {
const validation = validateNonTuiArgs(args)
@@ -57,6 +58,12 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
printWarning(unsupportedVersionMessage)
return 1
}
}
if (isUpdate) {
+2 -2
View File
@@ -128,7 +128,7 @@ describe("install CLI - binary check behavior", () => {
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
// given OpenCode binary IS installed
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200")
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0")
// given mock npm fetch
globalThis.fetch = mock(() =>
@@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => {
// then should have printed success (OK symbol)
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
expect(allCalls).toContain("[OK]")
expect(allCalls).toContain("OpenCode 1.0.200")
expect(allCalls).toContain("OpenCode 1.4.0")
})
})
+14
View File
@@ -0,0 +1,14 @@
import { MIN_OPENCODE_VERSION } from "./doctor/constants"
import { compareVersions } from "../shared/opencode-version"
export function getUnsupportedOpenCodeVersionMessage(openCodeVersion: string | null): string | null {
if (!openCodeVersion) {
return null
}
if (compareVersions(openCodeVersion, MIN_OPENCODE_VERSION) >= 0) {
return null
}
return `Detected OpenCode ${openCodeVersion}, but ${MIN_OPENCODE_VERSION}+ is required. Update OpenCode, then rerun the installer.`
}
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import * as p from "@clack/prompts"
import * as configManager from "./config-manager"
import * as tuiInstallPrompts from "./tui-install-prompts"
import { runTuiInstaller } from "./tui-installer"
function createMockSpinner(): ReturnType<typeof p.spinner> {
return {
start: () => undefined,
stop: () => undefined,
message: () => undefined,
}
}
describe("runTuiInstaller", () => {
const originalIsStdinTty = process.stdin.isTTY
const originalIsStdoutTty = process.stdout.isTTY
beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true })
})
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty })
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty })
})
it("blocks installation when OpenCode is below the minimum version", async () => {
// given
const restoreSpies = [
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"),
]
const promptSpy = spyOn(tuiInstallPrompts, "promptInstallConfig")
const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig")
const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined)
// when
const result = await runTuiInstaller({ tui: true }, "3.16.0")
// then
expect(result).toBe(1)
expect(promptSpy).not.toHaveBeenCalled()
expect(addPluginSpy).not.toHaveBeenCalled()
expect(outroSpy).toHaveBeenCalled()
for (const spy of restoreSpies) {
spy.mockRestore()
}
promptSpy.mockRestore()
addPluginSpy.mockRestore()
outroSpy.mockRestore()
})
it("proceeds when OpenCode meets the minimum version", async () => {
// given
const restoreSpies = [
spyOn(p, "spinner").mockReturnValue(createMockSpinner()),
spyOn(p, "intro").mockImplementation(() => undefined),
spyOn(p.log, "info").mockImplementation(() => undefined),
spyOn(p.log, "warn").mockImplementation(() => undefined),
spyOn(p.log, "success").mockImplementation(() => undefined),
spyOn(p.log, "message").mockImplementation(() => undefined),
spyOn(p, "note").mockImplementation(() => undefined),
spyOn(p, "outro").mockImplementation(() => undefined),
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
installedVersion: null,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"),
spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
}),
spyOn(configManager, "writeOmoConfig").mockReturnValue({
success: true,
configPath: "/tmp/oh-my-opencode.jsonc",
}),
]
// when
const result = await runTuiInstaller({ tui: true }, "3.16.0")
// then
expect(result).toBe(0)
for (const spy of restoreSpies) {
spy.mockRestore()
}
})
})
+8
View File
@@ -10,6 +10,7 @@ import {
writeOmoConfig,
} from "./config-manager"
import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators"
import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version"
import { promptInstallConfig } from "./tui-install-prompts"
export async function runTuiInstaller(args: InstallArgs, version: string): Promise<number> {
@@ -39,6 +40,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`)
const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion)
if (unsupportedVersionMessage) {
p.log.warn(unsupportedVersionMessage)
p.outro(color.red("Installation blocked."))
return 1
}
}
const config = await promptInstallConfig(detected)