From 3d66a30406bcf6465d9005876e1dbaecfc2afe75 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 3 Mar 2026 00:31:00 +0900 Subject: [PATCH] fix(doctor): quote paths and respect version channels in fix messages --- .../checks/system-loaded-version.test.ts | 18 +++ .../doctor/checks/system-loaded-version.ts | 4 + src/cli/doctor/checks/system.test.ts | 104 ++++++++++++++++++ src/cli/doctor/checks/system.ts | 7 +- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/cli/doctor/checks/system-loaded-version.test.ts create mode 100644 src/cli/doctor/checks/system.test.ts diff --git a/src/cli/doctor/checks/system-loaded-version.test.ts b/src/cli/doctor/checks/system-loaded-version.test.ts new file mode 100644 index 000000000..ecf232f30 --- /dev/null +++ b/src/cli/doctor/checks/system-loaded-version.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test" + +import { getSuggestedInstallTag } from "./system-loaded-version" + +describe("system loaded version", () => { + describe("getSuggestedInstallTag", () => { + it("returns prerelease channel when current version is prerelease", () => { + //#given + const currentVersion = "3.2.0-beta.4" + + //#when + const installTag = getSuggestedInstallTag(currentVersion) + + //#then + expect(installTag).toBe("beta") + }) + }) +}) diff --git a/src/cli/doctor/checks/system-loaded-version.ts b/src/cli/doctor/checks/system-loaded-version.ts index 968766ec4..bbf02516c 100644 --- a/src/cli/doctor/checks/system-loaded-version.ts +++ b/src/cli/doctor/checks/system-loaded-version.ts @@ -77,3 +77,7 @@ export async function getLatestPluginVersion(currentVersion: string | null): Pro const channel = extractChannel(currentVersion) return getLatestVersion(channel) } + +export function getSuggestedInstallTag(currentVersion: string | null): string { + return extractChannel(currentVersion) +} diff --git a/src/cli/doctor/checks/system.test.ts b/src/cli/doctor/checks/system.test.ts new file mode 100644 index 000000000..74e34038f --- /dev/null +++ b/src/cli/doctor/checks/system.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +const mockFindOpenCodeBinary = mock(async () => ({ path: "/usr/local/bin/opencode" })) +const mockGetOpenCodeVersion = mock(async () => "1.0.200") +const mockCompareVersions = mock(() => true) +const mockGetPluginInfo = mock(() => ({ + registered: true, + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: null, + isLocalDev: false, +})) +const mockGetLoadedPluginVersion = mock(() => ({ + cacheDir: "/Users/test/Library/Caches/opencode with spaces", + cachePackagePath: "/tmp/package.json", + installedPackagePath: "/tmp/node_modules/oh-my-opencode/package.json", + expectedVersion: "3.0.0", + loadedVersion: "3.1.0", +})) +const mockGetLatestPluginVersion = mock(async () => null) + +mock.module("./system-binary", () => ({ + findOpenCodeBinary: mockFindOpenCodeBinary, + getOpenCodeVersion: mockGetOpenCodeVersion, + compareVersions: mockCompareVersions, +})) + +mock.module("./system-plugin", () => ({ + getPluginInfo: mockGetPluginInfo, +})) + +mock.module("./system-loaded-version", () => ({ + getLoadedPluginVersion: mockGetLoadedPluginVersion, + getLatestPluginVersion: mockGetLatestPluginVersion, +})) + +const { checkSystem } = await import("./system?test") + +describe("system check", () => { + beforeEach(() => { + mockFindOpenCodeBinary.mockReset() + mockGetOpenCodeVersion.mockReset() + mockCompareVersions.mockReset() + mockGetPluginInfo.mockReset() + mockGetLoadedPluginVersion.mockReset() + mockGetLatestPluginVersion.mockReset() + + mockFindOpenCodeBinary.mockResolvedValue({ path: "/usr/local/bin/opencode" }) + mockGetOpenCodeVersion.mockResolvedValue("1.0.200") + mockCompareVersions.mockReturnValue(true) + mockGetPluginInfo.mockReturnValue({ + registered: true, + entry: "oh-my-opencode", + isPinned: false, + pinnedVersion: null, + configPath: null, + isLocalDev: false, + }) + mockGetLoadedPluginVersion.mockReturnValue({ + cacheDir: "/Users/test/Library/Caches/opencode with spaces", + cachePackagePath: "/tmp/package.json", + installedPackagePath: "/tmp/node_modules/oh-my-opencode/package.json", + expectedVersion: "3.0.0", + loadedVersion: "3.1.0", + }) + mockGetLatestPluginVersion.mockResolvedValue(null) + }) + + describe("#given cache directory contains spaces", () => { + it("uses a quoted cache directory in mismatch fix command", async () => { + //#when + const result = await checkSystem() + + //#then + const mismatchIssue = result.issues.find((issue) => issue.title === "Loaded plugin version mismatch") + expect(mismatchIssue?.fix).toBe('Reinstall: cd "/Users/test/Library/Caches/opencode with spaces" && bun install') + }) + + it("uses the loaded version channel for update fix command", async () => { + //#given + mockGetLoadedPluginVersion.mockReturnValue({ + cacheDir: "/Users/test/Library/Caches/opencode with spaces", + cachePackagePath: "/tmp/package.json", + installedPackagePath: "/tmp/node_modules/oh-my-opencode/package.json", + expectedVersion: "3.0.0-canary.1", + loadedVersion: "3.0.0-canary.1", + }) + mockGetLatestPluginVersion.mockResolvedValue("3.0.0-canary.2") + mockCompareVersions.mockImplementation((leftVersion: string, rightVersion: string) => { + return !(leftVersion === "3.0.0-canary.1" && rightVersion === "3.0.0-canary.2") + }) + + //#when + const result = await checkSystem() + + //#then + const outdatedIssue = result.issues.find((issue) => issue.title === "Loaded plugin is outdated") + expect(outdatedIssue?.fix).toBe( + 'Update: cd "/Users/test/Library/Caches/opencode with spaces" && bun add oh-my-opencode@canary' + ) + }) + }) +}) diff --git a/src/cli/doctor/checks/system.ts b/src/cli/doctor/checks/system.ts index 01fa162d1..4b41dd673 100644 --- a/src/cli/doctor/checks/system.ts +++ b/src/cli/doctor/checks/system.ts @@ -4,7 +4,7 @@ import { MIN_OPENCODE_VERSION, CHECK_IDS, CHECK_NAMES } from "../constants" import type { CheckResult, DoctorIssue, SystemInfo } from "../types" import { findOpenCodeBinary, getOpenCodeVersion, compareVersions } from "./system-binary" import { getPluginInfo } from "./system-plugin" -import { getLatestPluginVersion, getLoadedPluginVersion } from "./system-loaded-version" +import { getLatestPluginVersion, getLoadedPluginVersion, getSuggestedInstallTag } from "./system-loaded-version" import { parseJsonc } from "../../../shared" function isConfigValid(configPath: string | null): boolean { @@ -54,6 +54,7 @@ export async function checkSystem(): Promise { const [systemInfo, pluginInfo] = await Promise.all([gatherSystemInfo(), Promise.resolve(getPluginInfo())]) const loadedInfo = getLoadedPluginVersion() const latestVersion = await getLatestPluginVersion(systemInfo.loadedVersion) + const installTag = getSuggestedInstallTag(systemInfo.loadedVersion) const issues: DoctorIssue[] = [] if (!systemInfo.opencodePath) { @@ -93,7 +94,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin version mismatch", description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`, - fix: `Reinstall: cd ${loadedInfo.cacheDir} && bun install`, + fix: `Reinstall: cd "${loadedInfo.cacheDir}" && bun install`, severity: "warning", affects: ["plugin loading"], }) @@ -107,7 +108,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin is outdated", description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`, - fix: `Update: cd ${loadedInfo.cacheDir} && bun add oh-my-opencode@latest`, + fix: `Update: cd "${loadedInfo.cacheDir}" && bun add oh-my-opencode@${installTag}`, severity: "warning", affects: ["plugin features"], })