fix(windows): resolve symlinked config paths for plugin detection (fixes #2271)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "bun:test"
|
import { afterEach, describe, expect, it } from "bun:test"
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
|
|
||||||
@@ -104,6 +104,31 @@ describe("system loaded version", () => {
|
|||||||
expect(loadedVersion.expectedVersion).toBe("2.3.4")
|
expect(loadedVersion.expectedVersion).toBe("2.3.4")
|
||||||
expect(loadedVersion.loadedVersion).toBe("2.3.4")
|
expect(loadedVersion.loadedVersion).toBe("2.3.4")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("resolves symlinked config directories before selecting install path", () => {
|
||||||
|
//#given
|
||||||
|
const realConfigDir = createTemporaryDirectory("omo-real-config-")
|
||||||
|
const symlinkBaseDir = createTemporaryDirectory("omo-symlink-base-")
|
||||||
|
const symlinkConfigDir = join(symlinkBaseDir, "config-link")
|
||||||
|
|
||||||
|
symlinkSync(realConfigDir, symlinkConfigDir, process.platform === "win32" ? "junction" : "dir")
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = symlinkConfigDir
|
||||||
|
|
||||||
|
writeJson(join(realConfigDir, "package.json"), {
|
||||||
|
dependencies: { [PACKAGE_NAME]: "4.5.6" },
|
||||||
|
})
|
||||||
|
writeJson(join(realConfigDir, "node_modules", PACKAGE_NAME, "package.json"), {
|
||||||
|
version: "4.5.6",
|
||||||
|
})
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const loadedVersion = getLoadedPluginVersion()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(loadedVersion.cacheDir).toBe(realpathSync(symlinkConfigDir))
|
||||||
|
expect(loadedVersion.expectedVersion).toBe("4.5.6")
|
||||||
|
expect(loadedVersion.loadedVersion).toBe("4.5.6")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getSuggestedInstallTag", () => {
|
describe("getSuggestedInstallTag", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs"
|
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
@@ -36,6 +36,16 @@ function resolveOpenCodeCacheDir(): string {
|
|||||||
return platformDefault
|
return platformDefault
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveExistingDir(dirPath: string): string {
|
||||||
|
if (!existsSync(dirPath)) return dirPath
|
||||||
|
|
||||||
|
try {
|
||||||
|
return realpathSync(dirPath)
|
||||||
|
} catch {
|
||||||
|
return dirPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readPackageJson(filePath: string): PackageJsonShape | null {
|
function readPackageJson(filePath: string): PackageJsonShape | null {
|
||||||
if (!existsSync(filePath)) return null
|
if (!existsSync(filePath)) return null
|
||||||
|
|
||||||
@@ -55,12 +65,13 @@ function normalizeVersion(value: string | undefined): string | null {
|
|||||||
|
|
||||||
export function getLoadedPluginVersion(): LoadedVersionInfo {
|
export function getLoadedPluginVersion(): LoadedVersionInfo {
|
||||||
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
|
||||||
const cacheDir = resolveOpenCodeCacheDir()
|
const configDir = resolveExistingDir(configPaths.configDir)
|
||||||
|
const cacheDir = resolveExistingDir(resolveOpenCodeCacheDir())
|
||||||
const candidates = [
|
const candidates = [
|
||||||
{
|
{
|
||||||
cacheDir: configPaths.configDir,
|
cacheDir: configDir,
|
||||||
cachePackagePath: configPaths.packageJson,
|
cachePackagePath: join(configDir, "package.json"),
|
||||||
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
|
installedPackagePath: join(configDir, "node_modules", PACKAGE_NAME, "package.json"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
cacheDir,
|
cacheDir,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
|
||||||
|
import { discoverInstalledPlugins } from "./discovery"
|
||||||
|
|
||||||
|
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
function createTemporaryDirectory(prefix: string): string {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), prefix))
|
||||||
|
temporaryDirectories.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("discoverInstalledPlugins", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const pluginsHome = createTemporaryDirectory("omo-claude-plugins-")
|
||||||
|
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalClaudePluginsHome === undefined) {
|
||||||
|
delete process.env.CLAUDE_PLUGINS_HOME
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("derives package name from file URL plugin keys", () => {
|
||||||
|
//#given
|
||||||
|
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
|
||||||
|
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "oh-my-opencode")
|
||||||
|
mkdirSync(installPath, { recursive: true })
|
||||||
|
|
||||||
|
const databasePath = join(pluginsHome, "installed_plugins.json")
|
||||||
|
writeFileSync(
|
||||||
|
databasePath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"file:///D:/configs/user-configs/.config/opencode/node_modules/oh-my-opencode@latest": [
|
||||||
|
{
|
||||||
|
scope: "user",
|
||||||
|
installPath,
|
||||||
|
version: "3.10.0",
|
||||||
|
installedAt: "2026-03-20T00:00:00Z",
|
||||||
|
lastUpdated: "2026-03-20T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const discovered = discoverInstalledPlugins()
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(discovered.errors).toHaveLength(0)
|
||||||
|
expect(discovered.plugins).toHaveLength(1)
|
||||||
|
expect(discovered.plugins[0]?.name).toBe("oh-my-opencode")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import { join } from "path"
|
import { basename, join } from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import type {
|
import type {
|
||||||
InstalledPluginsDatabase,
|
InstalledPluginsDatabase,
|
||||||
@@ -79,8 +80,23 @@ function loadPluginManifest(installPath: string): PluginManifest | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function derivePluginNameFromKey(pluginKey: string): string {
|
function derivePluginNameFromKey(pluginKey: string): string {
|
||||||
const atIndex = pluginKey.indexOf("@")
|
const keyWithoutSource = pluginKey.startsWith("npm:") ? pluginKey.slice(4) : pluginKey
|
||||||
return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey
|
const versionSeparator = keyWithoutSource.lastIndexOf("@")
|
||||||
|
const keyWithoutVersion = versionSeparator > 0 ? keyWithoutSource.slice(0, versionSeparator) : keyWithoutSource
|
||||||
|
|
||||||
|
if (keyWithoutVersion.startsWith("file://")) {
|
||||||
|
try {
|
||||||
|
return basename(fileURLToPath(keyWithoutVersion))
|
||||||
|
} catch {
|
||||||
|
return basename(keyWithoutVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyWithoutVersion.includes("/") || keyWithoutVersion.includes("\\")) {
|
||||||
|
return basename(keyWithoutVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyWithoutVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPluginEnabled(
|
function isPluginEnabled(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync } from "node:fs"
|
import { existsSync, realpathSync } from "node:fs"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
|
|
||||||
@@ -42,14 +42,25 @@ function getTauriConfigDir(identifier: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(pathValue: string): string {
|
||||||
|
const resolvedPath = resolve(pathValue)
|
||||||
|
if (!existsSync(resolvedPath)) return resolvedPath
|
||||||
|
|
||||||
|
try {
|
||||||
|
return realpathSync(resolvedPath)
|
||||||
|
} catch {
|
||||||
|
return resolvedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getCliConfigDir(): string {
|
function getCliConfigDir(): string {
|
||||||
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||||
if (envConfigDir) {
|
if (envConfigDir) {
|
||||||
return resolve(envConfigDir)
|
return resolveConfigPath(envConfigDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
||||||
return join(xdgConfig, "opencode")
|
return resolveConfigPath(join(xdgConfig, "opencode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string {
|
export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string {
|
||||||
@@ -60,7 +71,7 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||||
const tauriDir = getTauriConfigDir(identifier)
|
const tauriDir = resolveConfigPath(getTauriConfigDir(identifier))
|
||||||
|
|
||||||
if (checkExisting) {
|
if (checkExisting) {
|
||||||
const legacyDir = getCliConfigDir()
|
const legacyDir = getCliConfigDir()
|
||||||
@@ -92,7 +103,7 @@ export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: st
|
|||||||
|
|
||||||
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||||
if (envConfigDir) {
|
if (envConfigDir) {
|
||||||
locations.push(resolve(envConfigDir))
|
locations.push(resolveConfigPath(envConfigDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binary === "opencode-desktop") {
|
if (binary === "opencode-desktop") {
|
||||||
|
|||||||
Reference in New Issue
Block a user