Merge remote-tracking branch 'origin/dev' into fix/doctor-custom-providers-rebased
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config"
|
||||
|
||||
export type { BunInstallResult } from "./config-manager/bun-install"
|
||||
export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install"
|
||||
|
||||
export type { VersionCompatibility } from "./config-manager/version-compatibility"
|
||||
export {
|
||||
checkVersionCompatibility,
|
||||
extractVersionFromPluginEntry,
|
||||
} from "./config-manager/version-compatibility"
|
||||
|
||||
export type { BackupResult } from "./config-manager/backup-config"
|
||||
export { backupConfigFile } from "./config-manager/backup-config"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import type { ConfigMergeResult } from "../types"
|
||||
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
|
||||
import { backupConfigFile } from "./backup-config"
|
||||
import { getConfigDir } from "./config-context"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
|
||||
import { getPluginNameWithVersion } from "./plugin-name-with-version"
|
||||
import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility"
|
||||
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
@@ -52,14 +54,33 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
|
||||
&& !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`))
|
||||
)
|
||||
|
||||
const existingEntry = canonicalEntries[0] ?? legacyEntries[0]
|
||||
if (existingEntry) {
|
||||
const installedVersion = extractVersionFromPluginEntry(existingEntry)
|
||||
const compatibility = checkVersionCompatibility(installedVersion, currentVersion)
|
||||
|
||||
if (!compatibility.canUpgrade) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: path,
|
||||
error: compatibility.reason ?? "Version compatibility check failed",
|
||||
}
|
||||
}
|
||||
|
||||
const backupResult = backupConfigFile(path)
|
||||
if (!backupResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: path,
|
||||
error: `Failed to create backup: ${backupResult.error}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedPlugins = [...otherPlugins]
|
||||
|
||||
if (canonicalEntries.length > 0) {
|
||||
normalizedPlugins.push(canonicalEntries[0])
|
||||
} else if (legacyEntries.length > 0) {
|
||||
const versionMatch = legacyEntries[0].match(/@(.+)$/)
|
||||
const preservedVersion = versionMatch ? versionMatch[1] : null
|
||||
normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry)
|
||||
if (canonicalEntries.length > 0 || legacyEntries.length > 0) {
|
||||
normalizedPlugins.push(pluginEntry)
|
||||
} else {
|
||||
normalizedPlugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
|
||||
import { dirname } from "node:path"
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean
|
||||
backupPath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function backupConfigFile(configPath: string): BackupResult {
|
||||
if (!existsSync(configPath)) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const backupPath = `${configPath}.backup-${timestamp}`
|
||||
|
||||
try {
|
||||
const dir = dirname(backupPath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
copyFileSync(configPath, backupPath)
|
||||
return { success: true, backupPath }
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to create backup",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { DetectedConfig } from "../types"
|
||||
import { getOmoConfigPath } from "./config-context"
|
||||
import { detectConfigFormat } from "./opencode-config-format"
|
||||
import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file"
|
||||
import { extractVersionFromPluginEntry } from "./version-compatibility"
|
||||
|
||||
function detectProvidersFromOmoConfig(): {
|
||||
hasOpenAI: boolean
|
||||
@@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean {
|
||||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
|
||||
}
|
||||
|
||||
function findOurPluginEntry(plugins: string[]): string | null {
|
||||
return plugins.find(isOurPlugin) ?? null
|
||||
}
|
||||
|
||||
export function detectCurrentConfig(): DetectedConfig {
|
||||
const result: DetectedConfig = {
|
||||
isInstalled: false,
|
||||
installedVersion: null,
|
||||
hasClaude: true,
|
||||
isMax20: true,
|
||||
hasOpenAI: true,
|
||||
@@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
const openCodeConfig = parseResult.config
|
||||
const plugins = openCodeConfig.plugin ?? []
|
||||
result.isInstalled = plugins.some(isOurPlugin)
|
||||
const ourPluginEntry = findOurPluginEntry(plugins)
|
||||
result.isInstalled = !!ourPluginEntry
|
||||
|
||||
if (ourPluginEntry) {
|
||||
result.installedVersion = extractVersionFromPluginEntry(ourPluginEntry)
|
||||
}
|
||||
|
||||
if (!result.isInstalled) {
|
||||
return result
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
@@ -6,6 +6,7 @@ import { join } from "node:path"
|
||||
import { resetConfigContext } from "./config-context"
|
||||
import { detectCurrentConfig } from "./detect-current-config"
|
||||
import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config"
|
||||
import * as pluginNameWithVersion from "./plugin-name-with-version"
|
||||
|
||||
describe("detectCurrentConfig - single package detection", () => {
|
||||
let testConfigDir = ""
|
||||
@@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||
})
|
||||
|
||||
it("upgrades a version-pinned legacy entry to canonical", async () => {
|
||||
it("updates a version-pinned legacy entry to the requested version", async () => {
|
||||
// given
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.16.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.15.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
const result = await addPluginToOpenCodeConfig("3.16.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("removes stale legacy entry when canonical and legacy entries both exist", async () => {
|
||||
@@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent"])
|
||||
})
|
||||
|
||||
it("preserves a canonical entry when it already exists", async () => {
|
||||
it("preserves a canonical entry when the same version is re-installed", async () => {
|
||||
// given
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.10.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.11.0")
|
||||
const result = await addPluginToOpenCodeConfig("3.10.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(true)
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("blocks a downgrade for a version-pinned canonical entry", async () => {
|
||||
// given
|
||||
const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0")
|
||||
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.16.0"] }, null, 2) + "\n", "utf-8")
|
||||
|
||||
// when
|
||||
const result = await addPluginToOpenCodeConfig("3.15.0")
|
||||
|
||||
// then
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Downgrade")
|
||||
|
||||
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
|
||||
expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"])
|
||||
getPluginNameWithVersionSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("rewrites quoted jsonc plugin field in place", async () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
checkVersionCompatibility,
|
||||
extractVersionFromPluginEntry,
|
||||
} from "./version-compatibility"
|
||||
|
||||
describe("checkVersionCompatibility", () => {
|
||||
it("allows fresh install when no current version", () => {
|
||||
const result = checkVersionCompatibility(null, "3.15.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isDowngrade).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("detects same version as already installed", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.15.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.reason).toContain("already installed")
|
||||
})
|
||||
|
||||
it("blocks downgrade from higher to lower version", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.14.0")
|
||||
expect(result.canUpgrade).toBe(false)
|
||||
expect(result.isDowngrade).toBe(true)
|
||||
expect(result.reason).toContain("Downgrade")
|
||||
})
|
||||
|
||||
it("allows patch version upgrade", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.15.1")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("allows minor version upgrade", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(false)
|
||||
expect(result.requiresMigration).toBe(false)
|
||||
})
|
||||
|
||||
it("detects major version bump requiring migration", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "4.0.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isMajorBump).toBe(true)
|
||||
expect(result.requiresMigration).toBe(true)
|
||||
expect(result.reason).toContain("Major version upgrade")
|
||||
})
|
||||
|
||||
it("handles v prefix in versions", () => {
|
||||
const result = checkVersionCompatibility("v3.15.0", "v3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
expect(result.isDowngrade).toBe(false)
|
||||
})
|
||||
|
||||
it("handles mixed v prefix", () => {
|
||||
const result = checkVersionCompatibility("3.15.0", "v3.16.0")
|
||||
expect(result.canUpgrade).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractVersionFromPluginEntry", () => {
|
||||
it("extracts version from canonical plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent@3.15.0")
|
||||
expect(version).toBe("3.15.0")
|
||||
})
|
||||
|
||||
it("extracts version from legacy plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-opencode@3.14.0")
|
||||
expect(version).toBe("3.14.0")
|
||||
})
|
||||
|
||||
it("returns null for bare plugin entry", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent")
|
||||
expect(version).toBeNull()
|
||||
})
|
||||
|
||||
it("handles prerelease versions", () => {
|
||||
const version = extractVersionFromPluginEntry("oh-my-openagent@3.16.0-beta.1")
|
||||
expect(version).toBe("3.16.0-beta.1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export interface VersionCompatibility {
|
||||
canUpgrade: boolean
|
||||
reason?: string
|
||||
isDowngrade: boolean
|
||||
isMajorBump: boolean
|
||||
requiresMigration: boolean
|
||||
}
|
||||
|
||||
function parseVersion(version: string): number[] {
|
||||
const clean = version.replace(/^v/, "").split("-")[0]
|
||||
return clean.split(".").map(Number)
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const partsA = parseVersion(a)
|
||||
const partsB = parseVersion(b)
|
||||
const maxLen = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const numA = partsA[i] ?? 0
|
||||
const numB = partsB[i] ?? 0
|
||||
if (numA !== numB) {
|
||||
return numA - numB
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function checkVersionCompatibility(
|
||||
currentVersion: string | null,
|
||||
newVersion: string
|
||||
): VersionCompatibility {
|
||||
if (!currentVersion) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
const cleanCurrent = currentVersion.replace(/^v/, "")
|
||||
const cleanNew = newVersion.replace(/^v/, "")
|
||||
|
||||
try {
|
||||
const comparison = compareVersions(cleanNew, cleanCurrent)
|
||||
|
||||
if (comparison < 0) {
|
||||
return {
|
||||
canUpgrade: false,
|
||||
reason: `Downgrade from ${currentVersion} to ${newVersion} is not allowed`,
|
||||
isDowngrade: true,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (comparison === 0) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Version ${newVersion} is already installed`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
|
||||
const currentMajor = cleanCurrent.split(".")[0]
|
||||
const newMajor = cleanNew.split(".")[0]
|
||||
const isMajorBump = currentMajor !== newMajor
|
||||
|
||||
if (isMajorBump) {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Major version upgrade from ${currentVersion} to ${newVersion} - configuration migration may be required`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: true,
|
||||
requiresMigration: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canUpgrade: true,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
canUpgrade: true,
|
||||
reason: `Unable to compare versions ${currentVersion} and ${newVersion} - proceeding with caution`,
|
||||
isDowngrade: false,
|
||||
isMajorBump: false,
|
||||
requiresMigration: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractVersionFromPluginEntry(entry: string): string | null {
|
||||
const match = entry.match(/@(.+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
hasOpencodeGo: false,
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||
import { parseJsonc } from "../../shared"
|
||||
import type { ConfigMergeResult, InstallConfig } from "../types"
|
||||
import { backupConfigFile } from "./backup-config"
|
||||
import { getConfigDir, getOmoConfigPath } from "./config-context"
|
||||
import { deepMergeRecord } from "./deep-merge-record"
|
||||
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
|
||||
@@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(omoConfigPath)) {
|
||||
const backupResult = backupConfigFile(omoConfigPath)
|
||||
if (!backupResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
configPath: omoConfigPath,
|
||||
error: `Failed to create backup: ${backupResult.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(omoConfigPath)
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
|
||||
@@ -37,7 +37,7 @@ export const EXIT_CODES = {
|
||||
FAILURE: 1,
|
||||
} as const
|
||||
|
||||
export const MIN_OPENCODE_VERSION = "1.0.150"
|
||||
export const MIN_OPENCODE_VERSION = "1.4.0"
|
||||
|
||||
export const PACKAGE_NAME = PLUGIN_NAME
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface ConfigMergeResult {
|
||||
|
||||
export interface DetectedConfig {
|
||||
isInstalled: boolean
|
||||
installedVersion: string | null
|
||||
hasClaude: boolean
|
||||
isMax20: boolean
|
||||
hasOpenAI: boolean
|
||||
|
||||
Reference in New Issue
Block a user