From ec2618bba54ec77708c9f49295ac6685c236df27 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 15:13:16 +0800 Subject: [PATCH 1/2] feat: support China edition CLI (qoderclicn) with settings toggle Add a Qoder CLI edition setting (international qodercli / China qoderclicn) so users can switch between the two CLI builds, whose binary names and config roots differ (~/.qoder vs ~/.qoder-cn). - New cli-edition module with per-edition binary names, config roots, login commands, and an active-edition registry synced on settings load/save - Edition-aware CLI discovery, including the CN installer layout (~/.qoder-cn/bin/qoderclicn/qoderclicn-); resolver cache keyed on edition - Edition-aware SDK projects path (session history), global plugin discovery, login hint, and CLI/Node missing error messages - Settings dropdown (declarative + imperative) that resets the resolver, reloads plugins, restarts open tabs, and refreshes the runtime catalog on switch - i18n keys in all 10 locales; unit tests for edition module and CN discovery; CHANGELOG entry Co-authored-by: QoderAI (Qwen 3.8 Max) --- CHANGELOG.md | 8 ++ src/core/types/settings.ts | 12 ++ .../chat/ui/toolbar/toolbar-selectors.ts | 4 +- src/features/settings/settings-tab.ts | 8 ++ .../settings/ui/qoder-settings-tab.ts | 43 +++++++ src/i18n/locales/de.json | 6 + src/i18n/locales/en.json | 6 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/ko.json | 6 + src/i18n/locales/pt.json | 6 + src/i18n/locales/ru.json | 6 + src/i18n/locales/zh-CN.json | 6 + src/i18n/locales/zh-TW.json | 6 + src/i18n/types.ts | 4 + src/main.ts | 9 ++ src/qoder/commands/probe-runtime-commands.ts | 13 +- src/qoder/config/cli-edition.ts | 56 ++++++++ src/qoder/config/settings.ts | 3 + src/qoder/history/sdk-session-paths.ts | 5 +- src/qoder/plugins/plugin-manager.ts | 21 ++- src/qoder/runtime/find-qoder-cli-path.ts | 120 ++++++++++++++---- src/qoder/runtime/qoder-chat-runtime.ts | 3 +- src/qoder/runtime/qoder-cli-resolver.ts | 19 ++- .../settings/ui/qoder-settings-tab.test.ts | 9 +- tests/unit/qoder/config/cli-edition.test.ts | 92 ++++++++++++++ .../qoder/runtime/find-qoder-cli-path.test.ts | 70 ++++++++++ 28 files changed, 511 insertions(+), 48 deletions(-) create mode 100644 src/qoder/config/cli-edition.ts create mode 100644 tests/unit/qoder/config/cli-edition.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f739b6c..8007b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- Qoder CLI edition switch in settings (Setup section): choose the + international build (`qodercli`, config under `~/.qoder`) or the + China build (`qoderclicn`, config under `~/.qoder-cn`). Auto- + detection, session history, global plugins, and login hints all + follow the selected edition. + ### Fixed - The composer now adapts to narrow sidebars: context chips that do diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts index 7952e85..972ed30 100644 --- a/src/core/types/settings.ts +++ b/src/core/types/settings.ts @@ -63,10 +63,22 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'plan' | 'yolo /** Opaque device-keyed CLI paths for per-device configuration. */ export type HostnameCliPaths = Record; +/** Supported Qoder CLI distribution builds. */ +export const QODER_CLI_EDITIONS = ['global', 'cn'] as const; + +/** + * Qoder CLI distribution edition: the international build (`qodercli`, + * config under `~/.qoder`) or the China build (`qoderclicn`, config under + * `~/.qoder-cn`). + */ +export type QoderCliEdition = typeof QODER_CLI_EDITIONS[number]; + /** Qoder CLI settings stored alongside Qoderian's general preferences. */ export interface QoderSettings { cliPath: string; cliPathsByHost: HostnameCliPaths; + /** Selected Qoder CLI distribution build. */ + edition: QoderCliEdition; loadUserSettings: boolean; enableBangBash: boolean; discoveredModels: Array<{ diff --git a/src/features/chat/ui/toolbar/toolbar-selectors.ts b/src/features/chat/ui/toolbar/toolbar-selectors.ts index f5b7b60..a1bebfc 100644 --- a/src/features/chat/ui/toolbar/toolbar-selectors.ts +++ b/src/features/chat/ui/toolbar/toolbar-selectors.ts @@ -2,6 +2,7 @@ import { Notice } from 'obsidian'; import type { QoderRuntimeStatus } from '../../../../core/types/services'; import type { PermissionMode } from '../../../../core/types/settings'; +import { getActiveQoderCliEdition, getQoderCliLoginCommand } from '../../../../qoder/config/cli-edition'; import type { QoderModelConfig } from '../../../../qoder/models/qoder-model-config'; import { createIconSvg, QODER_ICON } from '../../../../shared/icons'; import { ClickPopover } from './click-popover'; @@ -34,7 +35,6 @@ const DEFAULT_RUNTIME_STATUS: QoderRuntimeStatus = { kind: 'ready', message: 'Qoder CLI is ready.', }; -const QODER_LOGIN_COMMAND = 'qodercli login'; function getRuntimeStatusLabel(status: QoderRuntimeStatus): string { switch (status.kind) { @@ -137,7 +137,7 @@ export class ModelSelector { if (status.kind === 'authRequired') { statusEl.createEl('code', { cls: 'qoderian-model-runtime-command', - text: QODER_LOGIN_COMMAND, + text: getQoderCliLoginCommand(getActiveQoderCliEdition()), }); } if (status.details) { diff --git a/src/features/settings/settings-tab.ts b/src/features/settings/settings-tab.ts index 21b6d4c..096c6f5 100644 --- a/src/features/settings/settings-tab.ts +++ b/src/features/settings/settings-tab.ts @@ -13,6 +13,7 @@ import { renderBangBashControl, renderMcpSection, renderPluginsSection, + renderQoderCliEditionControl, renderQoderCliPathControl, renderQoderCliPathSetting, renderQoderSettingsTab, @@ -66,6 +67,13 @@ export class QoderianSettingTab extends PluginSettingTab { type: 'group', heading: t('settings.setup'), items: [ + { + name: t('settings.cliEdition.name'), + desc: t('settings.cliEdition.desc'), + render: (setting) => { + renderQoderCliEditionControl(setting, { plugin: this.plugin }); + }, + }, { name: t('settings.cliPath.name'), desc: getCliPathDescription(), diff --git a/src/features/settings/ui/qoder-settings-tab.ts b/src/features/settings/ui/qoder-settings-tab.ts index bfdab6f..4a25d3f 100644 --- a/src/features/settings/ui/qoder-settings-tab.ts +++ b/src/features/settings/ui/qoder-settings-tab.ts @@ -6,6 +6,7 @@ import { expandHomePath } from '../../../core/fs/path'; import type { AppMcpStorage } from '../../../core/types/services'; import { t } from '../../../i18n/i18n'; import type QoderianPlugin from '../../../main'; +import { normalizeQoderCliEdition } from '../../../qoder/config/cli-edition'; import { getQoderSettings, updateQoderSettings, @@ -23,6 +24,10 @@ export interface QoderCliPathSettingContext { plugin: QoderianPlugin; } +export interface QoderCliEditionSettingContext { + plugin: QoderianPlugin; +} + export interface SlashCommandsSectionContext { plugin: QoderianPlugin; } @@ -155,12 +160,50 @@ export function renderQoderCliPathControl( return validationEl; } +/** + * Attaches the CLI edition dropdown to `setting`. Switching editions changes + * the executable name and the CLI's user config root, so the resolver cache + * is dropped and every tab restarts, mirroring a CLI path change. + */ +export function renderQoderCliEditionControl( + setting: Setting, + context: QoderCliEditionSettingContext, +): void { + const qoderWorkspace = context.plugin.qoderServices; + const settingsBag = context.plugin.settings as unknown as Record; + + setting.addDropdown((dropdown) => { + dropdown + .addOption('global', t('settings.cliEdition.global')) + .addOption('cn', t('settings.cliEdition.cn')) + .setValue(getQoderSettings(settingsBag).edition) + .onChange(async (value) => { + const edition = normalizeQoderCliEdition(value); + updateQoderSettings(settingsBag, { edition }); + await context.plugin.saveSettings(); + qoderWorkspace.cliResolver.reset(); + await qoderWorkspace.pluginManager.loadPlugins(); + const view = context.plugin.getView(); + await view?.getTabManager()?.broadcastToAllTabs( + (service) => Promise.resolve(service.cleanup()) + ); + void qoderWorkspace.agentCatalog.refresh(); + }); + }); +} + /** Renders the "Setup" heading plus the CLI path row (imperative fallback). */ export function renderQoderCliPathSetting( container: HTMLElement, context: QoderCliPathSettingContext, ): void { new Setting(container).setName(t('settings.setup')).setHeading(); + + const editionSetting = new Setting(container) + .setName(t('settings.cliEdition.name')) + .setDesc(t('settings.cliEdition.desc')); + renderQoderCliEditionControl(editionSetting, context); + const cliPathSetting = new Setting(container) .setName(t('settings.cliPath.name')) .setDesc(getCliPathDescription()); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c0c0a27..a2a0d2f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -254,6 +254,12 @@ "leftSidebar": "Linke Seitenleiste", "mainTab": "Haupteditor-Tab" }, + "cliEdition": { + "name": "Qoder CLI-Ausgabe", + "desc": "Wählen Sie die internationale Version (qodercli, ~/.qoder) oder die China-Version (qoderclicn, ~/.qoder-cn). Geöffnete Tabs werden nach dem Wechsel sofort neu gestartet, laufende Unterhaltungen werden unterbrochen. Der Gesprächsverlauf wird pro Ausgabe getrennt gespeichert (~/.qoder und ~/.qoder-cn) und nicht gemeinsam genutzt.", + "global": "International (qodercli)", + "cn": "China (qoderclicn)" + }, "cliPath": { "name": "Qoder CLI-Pfad", "desc": "Benutzerdefinierter Pfad zum Qoder CLI. Leer lassen für automatische Erkennung.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4015d6b..f6e8654 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -254,6 +254,12 @@ "leftSidebar": "Left sidebar", "mainTab": "Main editor tab" }, + "cliEdition": { + "name": "Qoder CLI edition", + "desc": "Choose the international build (qodercli, ~/.qoder) or the China build (qoderclicn, ~/.qoder-cn). Open tabs restart immediately after switching, interrupting in-progress conversations. Session history is stored separately per edition (~/.qoder and ~/.qoder-cn) and is not shared between editions.", + "global": "International (qodercli)", + "cn": "China (qoderclicn)" + }, "cliPath": { "name": "Qoder CLI path", "desc": "Custom path to Qoder CLI. Leave empty for auto-detection.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5b1a205..d4bc9da 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -254,6 +254,12 @@ "leftSidebar": "Barra lateral izquierda", "mainTab": "Pestaña del editor principal" }, + "cliEdition": { + "name": "Edición de Qoder CLI", + "desc": "Elija la versión internacional (qodercli, ~/.qoder) o la versión de China (qoderclicn, ~/.qoder-cn). Las pestañas abiertas se reinician inmediatamente tras el cambio, interrumpiendo las conversaciones en curso. El historial de conversaciones se almacena por separado en cada edición (~/.qoder y ~/.qoder-cn) y no se comparte entre ediciones.", + "global": "Internacional (qodercli)", + "cn": "China (qoderclicn)" + }, "cliPath": { "name": "Ruta CLI Qoder", "desc": "Ruta personalizada a Qoder CLI. Dejar vacío para detección automática.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5458cf0..be4cc7e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -254,6 +254,12 @@ "leftSidebar": "Barre latérale gauche", "mainTab": "Onglet principal de l'éditeur" }, + "cliEdition": { + "name": "Édition de Qoder CLI", + "desc": "Choisissez la version internationale (qodercli, ~/.qoder) ou la version Chine (qoderclicn, ~/.qoder-cn). Les onglets ouverts redémarrent immédiatement après le changement, interrompant les conversations en cours. L'historique des conversations est stocké séparément par édition (~/.qoder et ~/.qoder-cn) et n'est pas partagé entre les éditions.", + "global": "Internationale (qodercli)", + "cn": "Chine (qoderclicn)" + }, "cliPath": { "name": "Chemin CLI Qoder", "desc": "Chemin personnalisé vers Qoder CLI. Laisser vide pour la détection automatique.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 73f15d0..9236bdc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -254,6 +254,12 @@ "leftSidebar": "左サイドバー", "mainTab": "メインエディタタブ" }, + "cliEdition": { + "name": "Qoder CLI エディション", + "desc": "国際版(qodercli、~/.qoder)または中国版(qoderclicn、~/.qoder-cn)を選択します。切り替え後、開いているタブは直ちに再起動し、進行中の会話は中断されます。各エディションの会話履歴は ~/.qoder と ~/.qoder-cn にそれぞれ保存され、相互に表示されません。", + "global": "国際版(qodercli)", + "cn": "中国版(qoderclicn)" + }, "cliPath": { "name": "Qoder CLI パス", "desc": "Qoder CLI のカスタムパス。空欄で自動検出を使用。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index e9241b6..ac73874 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -254,6 +254,12 @@ "leftSidebar": "왼쪽 사이드바", "mainTab": "메인 편집기 탭" }, + "cliEdition": { + "name": "Qoder CLI 에디션", + "desc": "글로벌 버전(qodercli, ~/.qoder) 또는 중국 버전(qoderclicn, ~/.qoder-cn)을 선택하세요. 전환 후 열려 있는 탭은 즉시 다시 시작되며 진행 중인 대화가 중단됩니다. 각 에디션의 대화 기록은 ~/.qoder와 ~/.qoder-cn에 별도로 저장되어 서로 표시되지 않습니다.", + "global": "글로벌 (qodercli)", + "cn": "중국 (qoderclicn)" + }, "cliPath": { "name": "Qoder CLI 경로", "desc": "Qoder CLI의 사용자 정의 경로. 비워두면 자동 감지 사용.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8508415..92848bc 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -254,6 +254,12 @@ "leftSidebar": "Barra lateral esquerda", "mainTab": "Aba do editor principal" }, + "cliEdition": { + "name": "Edição do Qoder CLI", + "desc": "Escolha a versão internacional (qodercli, ~/.qoder) ou a versão China (qoderclicn, ~/.qoder-cn). As abas abertas são reiniciadas imediatamente após a troca, interrompendo conversas em andamento. O histórico de conversas é armazenado separadamente por edição (~/.qoder e ~/.qoder-cn) e não é compartilhado entre edições.", + "global": "Internacional (qodercli)", + "cn": "China (qoderclicn)" + }, "cliPath": { "name": "Caminho CLI Qoder", "desc": "Caminho personalizado para Qoder CLI. Deixe vazio para detecção automática.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d0860a5..1fbddc5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -254,6 +254,12 @@ "leftSidebar": "Левая боковая панель", "mainTab": "Основная вкладка редактора" }, + "cliEdition": { + "name": "Редакция Qoder CLI", + "desc": "Выберите международную сборку (qodercli, ~/.qoder) или китайскую сборку (qoderclicn, ~/.qoder-cn). После переключения открытые вкладки немедленно перезапускаются, прерывая текущие диалоги. История бесед хранится отдельно для каждой редакции (~/.qoder и ~/.qoder-cn) и не является общей между редакциями.", + "global": "Международная (qodercli)", + "cn": "Китайская (qoderclicn)" + }, "cliPath": { "name": "Путь к CLI Qoder", "desc": "Пользовательский путь к Qoder CLI. Оставьте пустым для автоматического определения.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6d0b6e2..c7a4936 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -254,6 +254,12 @@ "leftSidebar": "左侧边栏", "mainTab": "主编辑器标签页" }, + "cliEdition": { + "name": "Qoder CLI 版本", + "desc": "选择国际版(qodercli,配置目录 ~/.qoder)或国内版(qoderclicn,配置目录 ~/.qoder-cn)。切换后已打开的标签页将立即重启,进行中的对话会中断。两个版本的历史会话分别保存在 ~/.qoder 和 ~/.qoder-cn,互相不可见。", + "global": "国际版(qodercli)", + "cn": "国内版(qoderclicn)" + }, "cliPath": { "name": "Qoder CLI 路径", "desc": "Qoder CLI 的自定义路径。留空使用自动检测。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f477271..cc0da4e 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -254,6 +254,12 @@ "leftSidebar": "左側邊欄", "mainTab": "主編輯器分頁" }, + "cliEdition": { + "name": "Qoder CLI 版本", + "desc": "選擇國際版(qodercli,設定目錄 ~/.qoder)或中國大陸版(qoderclicn,設定目錄 ~/.qoder-cn)。切換後已開啟的標籤頁將立即重新啟動,進行中的對話會中斷。各版本的歷史對話分別儲存在 ~/.qoder 和 ~/.qoder-cn,互不可見。", + "global": "國際版(qodercli)", + "cn": "中國大陸版(qoderclicn)" + }, "cliPath": { "name": "Qoder CLI 路徑", "desc": "Qoder CLI 的自訂路徑。留空使用自動偵測。", diff --git a/src/i18n/types.ts b/src/i18n/types.ts index a48487c..585d19d 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -221,6 +221,10 @@ export type TranslationKey = | 'settings.cliPath.descUnix' | 'settings.cliPath.validation.notExist' | 'settings.cliPath.validation.isDirectory' + | 'settings.cliEdition.name' + | 'settings.cliEdition.desc' + | 'settings.cliEdition.global' + | 'settings.cliEdition.cn' // Settings - Language | 'settings.language.name' diff --git a/src/main.ts b/src/main.ts index a670497..d2855b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,9 @@ import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ import { QoderianSettingTab } from './features/settings/settings-tab'; import { setLocale, t } from './i18n/i18n'; import type { Locale } from './i18n/types'; +import { setActiveQoderCliEdition } from './qoder/config/cli-edition'; import { normalizeQoderSettings } from './qoder/config/qoder-settings-reconciler'; +import { getQoderSettings } from './qoder/config/settings'; import { extractUserDisplayContent } from './qoder/prompt/context/prompt-context'; import { createQoderServices, @@ -401,6 +403,7 @@ export default class QoderianPlugin extends Plugin { this.lastKnownTabManagerState = await this.storage.getTabManagerState(); this.settings = qoderian; + this.syncActiveQoderCliEdition(); // Plan mode is ephemeral — return to the SDK's prompting mode on restart. if (this.settings.permissionMode === 'plan') { @@ -443,9 +446,15 @@ export default class QoderianPlugin extends Plugin { } async saveSettings() { + this.syncActiveQoderCliEdition(); await this.storage.saveQoderianSettings(this.settings); } + /** Keeps the edition-aware path helpers aligned with the persisted settings. */ + private syncActiveQoderCliEdition() { + setActiveQoderCliEdition(getQoderSettings(this.settings).edition); + } + getResolvedQoderCliPath(): string | null { return this.qoderServices.cliResolver.resolveFromSettings(this.settings); } diff --git a/src/qoder/commands/probe-runtime-commands.ts b/src/qoder/commands/probe-runtime-commands.ts index 7e69f25..e561af9 100644 --- a/src/qoder/commands/probe-runtime-commands.ts +++ b/src/qoder/commands/probe-runtime-commands.ts @@ -5,6 +5,11 @@ import { getEnhancedPath, getMissingNodeError } from '../../core/env/environment import { getVaultPath } from '../../core/fs/path'; import type { SlashCommand } from '../../core/types'; import type { QoderRuntimeStatus } from '../../core/types/services'; +import { + getActiveQoderCliEdition, + getQoderCliBinaryBaseName, + getQoderCliLoginCommand, +} from '../config/cli-edition'; import { getQoderSettings, type QoderDiscoveredAgent, @@ -179,14 +184,14 @@ export function classifyQoderProbeError(error: unknown, timedOut = false): Qoder if (/not logged in|login required|please (?:log|sign) in|please run \/?login|sign[- ]?in required|unauthori[sz]ed|authentication required|invalid (?:api key|credential|token)|expired.*(?:credential|token)|credentials? (?:not found|missing)|\b401\b/i.test(details)) { return { kind: 'authRequired', - message: 'Qoder CLI is not signed in. Run `qodercli login` in a terminal, then retry.', + message: `Qoder CLI is not signed in. Run \`${getQoderCliLoginCommand(getActiveQoderCliEdition())}\` in a terminal, then retry.`, details, }; } if (/protocol version|incompatible|unsupported (?:cli|sdk|version)|version mismatch/i.test(details)) { return { kind: 'incompatible', - message: 'This Qoder CLI version is incompatible. Update qodercli, then retry.', + message: `This Qoder CLI version is incompatible. Update ${getQoderCliBinaryBaseName(getActiveQoderCliEdition())}, then retry.`, details, }; } @@ -224,7 +229,7 @@ export async function probeRuntimeCatalog( return { error: { kind: 'cliMissing', - message: 'Qoder CLI was not found. Install qodercli or configure its path in Qoderian settings.', + message: `Qoder CLI was not found. Install ${getQoderCliBinaryBaseName(getActiveQoderCliEdition())} or configure its path in Qoderian settings.`, }, }; } @@ -243,7 +248,7 @@ export async function probeRuntimeCatalog( return { error: { kind: 'nodeMissing', - message: `${missingNodeError} Install Node.js or use the native qodercli executable, then retry.`, + message: `${missingNodeError} Install Node.js or use the native ${getQoderCliBinaryBaseName(getActiveQoderCliEdition())} executable, then retry.`, details: missingNodeError, }, }; diff --git a/src/qoder/config/cli-edition.ts b/src/qoder/config/cli-edition.ts new file mode 100644 index 0000000..a6970b4 --- /dev/null +++ b/src/qoder/config/cli-edition.ts @@ -0,0 +1,56 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { QODER_CLI_EDITIONS, type QoderCliEdition } from '../../core/types/settings'; + +/** User-level config directory name under the home directory, per edition. */ +export const QODER_CLI_HOME_DIRS: Record = { + global: '.qoder', + cn: '.qoder-cn', +}; + +/** Executable base name (without platform extension), per edition. */ +export const QODER_CLI_BINARY_NAMES: Record = { + global: 'qodercli', + cn: 'qoderclicn', +}; + +/** Coerces an arbitrary stored value into a valid edition, defaulting to the global build. */ +export function normalizeQoderCliEdition(value: unknown): QoderCliEdition { + return typeof value === 'string' && (QODER_CLI_EDITIONS as readonly string[]).includes(value) + ? value as QoderCliEdition + : 'global'; +} + +/** Absolute user-level config root for an edition (e.g. `~/.qoder-cn`). */ +export function getQoderCliHomeDir( + edition: QoderCliEdition, + homeDir: string = os.homedir(), +): string { + return path.join(homeDir, QODER_CLI_HOME_DIRS[edition]); +} + +export function getQoderCliBinaryBaseName(edition: QoderCliEdition): string { + return QODER_CLI_BINARY_NAMES[edition]; +} + +/** Terminal login hint shown when the CLI reports missing credentials. */ +export function getQoderCliLoginCommand(edition: QoderCliEdition): string { + return `${QODER_CLI_BINARY_NAMES[edition]} login`; +} + +/* + * Active-edition registry. The plugin is a singleton per Obsidian instance and + * updates this value whenever settings load or save, so path helpers that are + * called as free functions (history files, global plugin discovery) can stay + * edition-aware without threading settings through every signature. + */ +let activeEdition: QoderCliEdition = 'global'; + +export function setActiveQoderCliEdition(edition: QoderCliEdition): void { + activeEdition = normalizeQoderCliEdition(edition); +} + +export function getActiveQoderCliEdition(): QoderCliEdition { + return activeEdition; +} diff --git a/src/qoder/config/settings.ts b/src/qoder/config/settings.ts index 18accba..01140e1 100644 --- a/src/qoder/config/settings.ts +++ b/src/qoder/config/settings.ts @@ -1,4 +1,5 @@ import type { HostnameCliPaths, QoderSettings } from '../../core/types/settings'; +import { normalizeQoderCliEdition } from './cli-edition'; export type QoderSettingSource = 'user' | 'project' | 'local'; @@ -28,6 +29,7 @@ export interface QoderDiscoveredAgent { export const DEFAULT_QODER_SETTINGS: Readonly = Object.freeze({ cliPath: '', cliPathsByHost: {}, + edition: 'global', loadUserSettings: true, enableBangBash: false, discoveredModels: [], @@ -152,6 +154,7 @@ export function getQoderSettings( return { cliPath: (config.cliPath as string | undefined) ?? DEFAULT_QODER_SETTINGS.cliPath, cliPathsByHost: normalizeHostnameCliPaths(config.cliPathsByHost), + edition: normalizeQoderCliEdition(config.edition), loadUserSettings: (config.loadUserSettings as boolean | undefined) ?? DEFAULT_QODER_SETTINGS.loadUserSettings, enableBangBash: (config.enableBangBash as boolean | undefined) diff --git a/src/qoder/history/sdk-session-paths.ts b/src/qoder/history/sdk-session-paths.ts index e822e8e..76e8456 100644 --- a/src/qoder/history/sdk-session-paths.ts +++ b/src/qoder/history/sdk-session-paths.ts @@ -1,8 +1,8 @@ import { existsSync } from 'fs'; import * as fs from 'fs/promises'; -import * as os from 'os'; import * as path from 'path'; +import { getActiveQoderCliEdition, getQoderCliHomeDir } from '../config/cli-edition'; import type { SDKNativeMessage, SDKSessionReadResult } from './sdk-history-types'; /** @@ -16,7 +16,8 @@ export function encodeVaultPathForSDK(vaultPath: string): string { } export function getSDKProjectsPath(): string { - return path.join(os.homedir(), '.qoder', 'projects'); + // Sessions live under the active edition's config root, e.g. `~/.qoder-cn/projects`. + return path.join(getQoderCliHomeDir(getActiveQoderCliEdition()), 'projects'); } /** Validates an identifier for safe use in filesystem paths (no traversal, bounded length). */ diff --git a/src/qoder/plugins/plugin-manager.ts b/src/qoder/plugins/plugin-manager.ts index 4b4916b..14fc632 100644 --- a/src/qoder/plugins/plugin-manager.ts +++ b/src/qoder/plugins/plugin-manager.ts @@ -7,16 +7,27 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { resolvePathForComparison } from '../../core/fs/path'; import type { PluginInfo, PluginScope } from '../../core/types'; +import { getActiveQoderCliEdition, getQoderCliHomeDir } from '../config/cli-edition'; import type { QoderCliSettingsStorage } from '../storage/qoder-cli-settings-storage'; import type { InstalledPluginEntry, InstalledPluginsFile } from '../types/plugins'; -const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.qoder', 'plugins', 'installed_plugins.json'); -const GLOBAL_SETTINGS_PATH = path.join(os.homedir(), '.qoder', 'settings.json'); +// Global plugin state lives under the active edition's config root +// (e.g. `~/.qoder-cn/plugins`), so resolve lazily on each read. +function getInstalledPluginsPath(): string { + return path.join( + getQoderCliHomeDir(getActiveQoderCliEdition()), + 'plugins', + 'installed_plugins.json', + ); +} + +function getGlobalSettingsPath(): string { + return path.join(getQoderCliHomeDir(getActiveQoderCliEdition()), 'settings.json'); +} interface SettingsFile { enabledPlugins?: Record; @@ -82,8 +93,8 @@ export class PluginManager { } async loadPlugins(): Promise { - const installedPlugins = readJsonFile(INSTALLED_PLUGINS_PATH); - const globalSettings = readJsonFile(GLOBAL_SETTINGS_PATH); + const installedPlugins = readJsonFile(getInstalledPluginsPath()); + const globalSettings = readJsonFile(getGlobalSettingsPath()); const projectSettings = await this.loadProjectSettings(); const globalEnabled = globalSettings?.enabledPlugins ?? {}; diff --git a/src/qoder/runtime/find-qoder-cli-path.ts b/src/qoder/runtime/find-qoder-cli-path.ts index 3b8d0bc..1ca0cca 100644 --- a/src/qoder/runtime/find-qoder-cli-path.ts +++ b/src/qoder/runtime/find-qoder-cli-path.ts @@ -3,6 +3,8 @@ import * as os from 'os'; import * as path from 'path'; import { parsePathEntries, resolveNvmDefaultBin } from '../../core/fs/path'; +import type { QoderCliEdition } from '../../core/types/settings'; +import { QODER_CLI_BINARY_NAMES, QODER_CLI_HOME_DIRS } from '../config/cli-edition'; const QODERCLI_PACKAGE_SEGMENTS = ['node_modules', '@qoder-ai', 'qodercli']; const QODERCLI_NODE_ENTRYPOINT = 'cli.js'; @@ -84,20 +86,53 @@ function resolveQoderCliEntrypointFromPathEntries(entries: string[], isWindows: return null; } +function getBinaryCandidates(binaryBase: string, isWindows: boolean): string[] { + return isWindows ? [`${binaryBase}.exe`, binaryBase] : [binaryBase]; +} + +/** + * The native installer keeps versioned binaries in a subdirectory, e.g. + * `~/.qoder-cn/bin/qoderclicn/qoderclicn-1.1.21`, with a plain symlink in + * `~/.local/bin`. Pick the newest file whose name starts with the binary + * base name when such a directory exists. + */ +function findVersionedBinaryInDir(dir: string, binaryBase: string, isWindows: boolean): string | null { + let names: string[]; + try { + names = fs.readdirSync(dir); + } catch { + return null; + } + if (!Array.isArray(names)) { + return null; + } + + const candidates = names + .filter((name) => name === binaryBase + || (isWindows && name === `${binaryBase}.exe`) + || name.startsWith(`${binaryBase}-`)) + .map((name) => path.join(dir, name)) + .filter((candidate) => isExistingFile(candidate)) + .sort((a, b) => path.basename(b).localeCompare(path.basename(a), undefined, { numeric: true })); + + return candidates[0] ?? null; +} + function resolveQoderFromPathEntries( entries: string[], - isWindows: boolean + isWindows: boolean, + binaryBase: string, ): string | null { if (entries.length === 0) { return null; } if (!isWindows) { - const unixCandidate = findFirstExistingPath(entries, ['qodercli']); + const unixCandidate = findFirstExistingPath(entries, [binaryBase]); return unixCandidate; } - const exeCandidate = findFirstExistingPath(entries, ['qodercli.exe', 'qodercli']); + const exeCandidate = findFirstExistingPath(entries, getBinaryCandidates(binaryBase, true)); if (exeCandidate) { return exeCandidate; } @@ -163,14 +198,19 @@ function getNpmQoderCliEntrypointPaths(): string[] { return entrypointPaths; } -export function findQoderCLIPath(pathValue?: string): string | null { +export function findQoderCLIPath( + pathValue?: string, + edition: QoderCliEdition = 'global', +): string | null { const homeDir = os.homedir(); const isWindows = process.platform === 'win32'; + const binaryBase = QODER_CLI_BINARY_NAMES[edition]; + const homeDirName = QODER_CLI_HOME_DIRS[edition]; const customEntries = dedupePaths(parsePathEntries(pathValue)); if (customEntries.length > 0) { - const customResolution = resolveQoderFromPathEntries(customEntries, isWindows); + const customResolution = resolveQoderFromPathEntries(customEntries, isWindows, binaryBase); if (customResolution) { return customResolution; } @@ -180,23 +220,39 @@ export function findQoderCLIPath(pathValue?: string): string | null { // because it requires shell: true and breaks SDK stdio streaming. if (isWindows) { const exePaths: string[] = [ - path.join(homeDir, '.qoder', 'bin', 'qodercli.exe'), - path.join(homeDir, 'AppData', 'Local', 'Qoder', 'qodercli.exe'), - path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Qoder', 'qodercli.exe'), - path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Qoder', 'qodercli.exe'), - path.join(homeDir, '.local', 'bin', 'qodercli.exe'), + path.join(homeDir, homeDirName, 'bin', `${binaryBase}.exe`), + path.join(homeDir, '.local', 'bin', `${binaryBase}.exe`), ]; + if (edition === 'global') { + exePaths.push( + path.join(homeDir, 'AppData', 'Local', 'Qoder', 'qodercli.exe'), + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Qoder', 'qodercli.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Qoder', 'qodercli.exe'), + ); + } + for (const p of exePaths) { if (isExistingFile(p)) { return p; } } - const packageEntrypointPaths = getNpmQoderCliEntrypointPaths(); - for (const p of packageEntrypointPaths) { - if (isExistingFile(p)) { - return p; + const versionedExe = findVersionedBinaryInDir( + path.join(homeDir, homeDirName, 'bin', binaryBase), + binaryBase, + true, + ); + if (versionedExe) { + return versionedExe; + } + + if (edition === 'global') { + const packageEntrypointPaths = getNpmQoderCliEntrypointPaths(); + for (const p of packageEntrypointPaths) { + if (isExistingFile(p)) { + return p; + } } } @@ -204,26 +260,26 @@ export function findQoderCLIPath(pathValue?: string): string | null { const commonPaths: string[] = [ // Qoder CLI paths - path.join(homeDir, '.local', 'bin', 'qodercli'), - path.join(homeDir, '.qoder', 'bin', 'qodercli'), - path.join(homeDir, '.volta', 'bin', 'qodercli'), - path.join(homeDir, '.asdf', 'shims', 'qodercli'), - path.join(homeDir, '.asdf', 'bin', 'qodercli'), - path.join(homeDir, 'bin', 'qodercli'), - path.join(homeDir, '.npm-global', 'bin', 'qodercli'), - '/usr/local/bin/qodercli', - '/opt/homebrew/bin/qodercli', + path.join(homeDir, '.local', 'bin', binaryBase), + path.join(homeDir, homeDirName, 'bin', binaryBase), + path.join(homeDir, '.volta', 'bin', binaryBase), + path.join(homeDir, '.asdf', 'shims', binaryBase), + path.join(homeDir, '.asdf', 'bin', binaryBase), + path.join(homeDir, 'bin', binaryBase), + path.join(homeDir, '.npm-global', 'bin', binaryBase), + `/usr/local/bin/${binaryBase}`, + `/opt/homebrew/bin/${binaryBase}`, ]; const npmPrefix = getNpmGlobalPrefix(); if (npmPrefix) { - commonPaths.push(path.join(npmPrefix, 'bin', 'qodercli')); + commonPaths.push(path.join(npmPrefix, 'bin', binaryBase)); } // NVM: resolve default version bin when NVM_BIN env var is not available (GUI apps) const nvmBin = resolveNvmDefaultBin(homeDir); if (nvmBin) { - commonPaths.push(path.join(nvmBin, 'qodercli')); + commonPaths.push(path.join(nvmBin, binaryBase)); } for (const p of commonPaths) { @@ -232,7 +288,17 @@ export function findQoderCLIPath(pathValue?: string): string | null { } } - if (!isWindows) { + // Native installers keep versioned builds under `/bin//`. + const versionedBinary = findVersionedBinaryInDir( + path.join(homeDir, homeDirName, 'bin', binaryBase), + binaryBase, + false, + ); + if (versionedBinary) { + return versionedBinary; + } + + if (!isWindows && edition === 'global') { const packageEntrypointPaths = getNpmQoderCliEntrypointPaths(); for (const p of packageEntrypointPaths) { if (isExistingFile(p)) { @@ -243,7 +309,7 @@ export function findQoderCLIPath(pathValue?: string): string | null { const envEntries = dedupePaths(parsePathEntries(getEnvValue('PATH'))); if (envEntries.length > 0) { - const envResolution = resolveQoderFromPathEntries(envEntries, isWindows); + const envResolution = resolveQoderFromPathEntries(envEntries, isWindows, binaryBase); if (envResolution) { return envResolution; } diff --git a/src/qoder/runtime/qoder-chat-runtime.ts b/src/qoder/runtime/qoder-chat-runtime.ts index e6c6e5e..5da9b1c 100644 --- a/src/qoder/runtime/qoder-chat-runtime.ts +++ b/src/qoder/runtime/qoder-chat-runtime.ts @@ -55,6 +55,7 @@ import type { AppPluginManager, } from '../../core/types/services'; import type { PermissionMode, QoderianSettings } from '../../core/types/settings'; +import { getActiveQoderCliEdition, getQoderCliBinaryBaseName } from '../config/cli-edition'; import { loadSubagentFinalResult, loadSubagentToolCalls } from '../history/qoder-history-store'; import type { McpServerManager } from '../mcp/mcp-server-manager'; import { toQoderRuntimeModelId } from '../models/model-selection'; @@ -757,7 +758,7 @@ export class QoderChatRuntime implements ChatRuntime { if (!resolvedQoderPath) { yield { type: 'error', - content: 'Qoder CLI not found. Install qodercli or configure its path in Qoderian settings, then retry.', + content: `Qoder CLI not found. Install ${getQoderCliBinaryBaseName(getActiveQoderCliEdition())} or configure its path in Qoderian settings, then retry.`, }; return; } diff --git a/src/qoder/runtime/qoder-cli-resolver.ts b/src/qoder/runtime/qoder-cli-resolver.ts index e775a20..2e29517 100644 --- a/src/qoder/runtime/qoder-cli-resolver.ts +++ b/src/qoder/runtime/qoder-cli-resolver.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import { getHostnameKey } from '../../core/env/environment'; import { expandHomePath } from '../../core/fs/path'; -import type { HostnameCliPaths } from '../../core/types/settings'; +import type { HostnameCliPaths, QoderCliEdition } from '../../core/types/settings'; import { getQoderSettings } from '../config/settings'; import { findQoderCLIPath } from './find-qoder-cli-path'; @@ -10,6 +10,7 @@ export class QoderCliResolver { private resolvedPath: string | null = null; private lastHostnamePath = ''; private lastLegacyPath = ''; + private lastEdition: QoderCliEdition = 'global'; private readonly cachedHostname = getHostnameKey(); /** @@ -23,34 +24,38 @@ export class QoderCliResolver { const hostnamePath = (qoderSettings.cliPathsByHost[hostnameKey] ?? '').trim(); const normalizedLegacy = qoderSettings.cliPath.trim(); - return this.resolveConfiguredPaths(hostnamePath, normalizedLegacy); + return this.resolveConfiguredPaths(hostnamePath, normalizedLegacy, qoderSettings.edition); } resolve( hostnamePaths: HostnameCliPaths | undefined, legacyPath: string | undefined, + edition: QoderCliEdition = 'global', ): string | null { const hostnamePath = (hostnamePaths?.[this.cachedHostname] ?? '').trim(); const normalizedLegacy = (legacyPath ?? '').trim(); - return this.resolveConfiguredPaths(hostnamePath, normalizedLegacy); + return this.resolveConfiguredPaths(hostnamePath, normalizedLegacy, edition); } private resolveConfiguredPaths( hostnamePath: string, normalizedLegacy: string, + edition: QoderCliEdition, ): string | null { if ( this.resolvedPath && hostnamePath === this.lastHostnamePath && - normalizedLegacy === this.lastLegacyPath + normalizedLegacy === this.lastLegacyPath && + edition === this.lastEdition ) { return this.resolvedPath; } this.lastHostnamePath = hostnamePath; this.lastLegacyPath = normalizedLegacy; + this.lastEdition = edition; - this.resolvedPath = resolveQoderCliPath(hostnamePath, normalizedLegacy); + this.resolvedPath = resolveQoderCliPath(hostnamePath, normalizedLegacy, edition); return this.resolvedPath; } @@ -58,6 +63,7 @@ export class QoderCliResolver { this.resolvedPath = null; this.lastHostnamePath = ''; this.lastLegacyPath = ''; + this.lastEdition = 'global'; } } @@ -78,10 +84,11 @@ function resolveConfiguredPath(rawPath: string | undefined): string | null { export function resolveQoderCliPath( hostnamePath: string | undefined, legacyPath: string | undefined, + edition: QoderCliEdition = 'global', ): string | null { return ( resolveConfiguredPath(hostnamePath) ?? resolveConfiguredPath(legacyPath) ?? - findQoderCLIPath() + findQoderCLIPath(undefined, edition) ); } diff --git a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts index 3a41719..b4cb86f 100644 --- a/tests/unit/features/settings/ui/qoder-settings-tab.test.ts +++ b/tests/unit/features/settings/ui/qoder-settings-tab.test.ts @@ -373,10 +373,17 @@ describe('QoderSettingsTab', () => { const cliPathSetting = findSetting('settings.cliPath.name'); const cliPathInput = cliPathSetting.textComponents[0]; - expect(createdSettings.slice(0, 2).map(setting => setting.name)).toEqual([ + expect(createdSettings.slice(0, 3).map(setting => setting.name)).toEqual([ 'settings.setup', + 'settings.cliEdition.name', 'settings.cliPath.name', ]); + + const editionSetting = findSetting('settings.cliEdition.name'); + const editionDropdown = editionSetting.dropdownComponents[0]; + expect(editionDropdown.options.map(option => option.value)).toEqual(['global', 'cn']); + expect(editionDropdown.value).toBe('global'); + expect(cliPathInput.placeholder).toContain('qodercli'); }); diff --git a/tests/unit/qoder/config/cli-edition.test.ts b/tests/unit/qoder/config/cli-edition.test.ts new file mode 100644 index 0000000..3d97daa --- /dev/null +++ b/tests/unit/qoder/config/cli-edition.test.ts @@ -0,0 +1,92 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { + getActiveQoderCliEdition, + getQoderCliBinaryBaseName, + getQoderCliHomeDir, + getQoderCliLoginCommand, + normalizeQoderCliEdition, + setActiveQoderCliEdition, +} from '@/qoder/config/cli-edition'; +import { getQoderSettings } from '@/qoder/config/settings'; +import { getSDKProjectsPath } from '@/qoder/history/sdk-session-paths'; + +describe('cli-edition', () => { + afterEach(() => { + setActiveQoderCliEdition('global'); + }); + + describe('normalizeQoderCliEdition', () => { + it('keeps valid editions', () => { + expect(normalizeQoderCliEdition('global')).toBe('global'); + expect(normalizeQoderCliEdition('cn')).toBe('cn'); + }); + + it('falls back to the global build for unknown values', () => { + expect(normalizeQoderCliEdition(undefined)).toBe('global'); + expect(normalizeQoderCliEdition('')).toBe('global'); + expect(normalizeQoderCliEdition('qoderclicn')).toBe('global'); + expect(normalizeQoderCliEdition(42)).toBe('global'); + }); + }); + + describe('edition-specific names and paths', () => { + it('maps binary names per edition', () => { + expect(getQoderCliBinaryBaseName('global')).toBe('qodercli'); + expect(getQoderCliBinaryBaseName('cn')).toBe('qoderclicn'); + }); + + it('maps config roots per edition', () => { + expect(getQoderCliHomeDir('global', '/home/test')).toBe('/home/test/.qoder'); + expect(getQoderCliHomeDir('cn', '/home/test')).toBe('/home/test/.qoder-cn'); + }); + + it('builds the login command from the binary name', () => { + expect(getQoderCliLoginCommand('global')).toBe('qodercli login'); + expect(getQoderCliLoginCommand('cn')).toBe('qoderclicn login'); + }); + }); + + describe('active edition registry', () => { + it('defaults to the global build', () => { + expect(getActiveQoderCliEdition()).toBe('global'); + }); + + it('normalizes updates', () => { + setActiveQoderCliEdition('cn'); + expect(getActiveQoderCliEdition()).toBe('cn'); + + setActiveQoderCliEdition('bogus' as never); + expect(getActiveQoderCliEdition()).toBe('global'); + }); + }); + + describe('getQoderSettings edition handling', () => { + it('defaults to the global build when absent', () => { + expect(getQoderSettings({}).edition).toBe('global'); + }); + + it('round-trips a stored cn edition', () => { + const settings = { qoder: { edition: 'cn' } }; + expect(getQoderSettings(settings).edition).toBe('cn'); + }); + + it('coerces invalid stored values back to global', () => { + const settings = { qoder: { edition: 'enterprise' } }; + expect(getQoderSettings(settings).edition).toBe('global'); + }); + }); + + describe('edition-aware SDK session paths', () => { + it('points at ~/.qoder/projects for the global build', () => { + setActiveQoderCliEdition('global'); + expect(getSDKProjectsPath()).toBe(path.join(os.homedir(), '.qoder', 'projects')); + }); + + it('points at ~/.qoder-cn/projects for the China build', () => { + setActiveQoderCliEdition('cn'); + expect(getSDKProjectsPath()).toBe(path.join(os.homedir(), '.qoder-cn', 'projects')); + }); + }); +}); diff --git a/tests/unit/qoder/runtime/find-qoder-cli-path.test.ts b/tests/unit/qoder/runtime/find-qoder-cli-path.test.ts index 23a1eb2..98fa924 100644 --- a/tests/unit/qoder/runtime/find-qoder-cli-path.test.ts +++ b/tests/unit/qoder/runtime/find-qoder-cli-path.test.ts @@ -373,3 +373,73 @@ describe('findQoderCLIPath (platform resolution)', () => { }); }); }); + +describe('findQoderCLIPath (cn edition)', () => { + const originalPlatform = process.platform; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.PATH = ''; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = originalEnv; + }); + + function mockExistingFile(...paths: string[]) { + const pathSet = new Set(paths); + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => pathSet.has(p)); + jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ + isFile: () => pathSet.has(String(p)), + }) as fsType.Stats); + // No versioned bin directories exist by default. + jest.spyOn(fs, 'readdirSync').mockImplementation((() => { + throw new Error('ENOENT'); + }) as typeof fs.readdirSync); + } + + it('finds qoderclicn from common paths', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + const cnPath = path.join('/home/test', '.local', 'bin', 'qoderclicn'); + mockExistingFile(cnPath); + + expect(findQoderCLIPath(undefined, 'cn')).toBe(cnPath); + }); + + it('does not fall back to the global qodercli binary', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + const globalPath = path.join('/home/test', '.local', 'bin', 'qodercli'); + mockExistingFile(globalPath); + + expect(findQoderCLIPath(undefined, 'cn')).toBeNull(); + }); + + it('resolves the versioned installer layout under ~/.qoder-cn/bin', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + const versionedDir = path.join('/home/test', '.qoder-cn', 'bin', 'qoderclicn'); + const versionedBinary = path.join(versionedDir, 'qoderclicn-1.1.21'); + + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => p === versionedBinary); + jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ + isFile: () => String(p) === versionedBinary, + }) as fsType.Stats); + jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { + if (String(p) === versionedDir) return ['qoderclicn-1.1.20', 'qoderclicn-1.1.21']; + throw new Error('ENOENT'); + }) as typeof fs.readdirSync); + + // Newest versioned build wins. + expect(findQoderCLIPath(undefined, 'cn')).toBe(versionedBinary); + }); + + it('resolves qoderclicn from custom PATH entries', () => { + const cnPath = path.join('/custom', 'bin', 'qoderclicn'); + mockExistingFile(cnPath); + + expect(findQoderCLIPath('/custom/bin', 'cn')).toBe(cnPath); + }); +}); From 45e58df9a655cdce682f2b35430f543f5dccc8c8 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Fri, 14 Aug 2026 15:29:19 +0800 Subject: [PATCH 2/2] test: make cli-edition config-root assertions path-separator agnostic The config-root test hardcoded POSIX separators while getQoderCliHomeDir builds paths with path.join, which fails on the Windows CI runner. Build expectations with path.join as well. Co-authored-by: QoderAI (Qwen 3.8 Max) --- tests/unit/qoder/config/cli-edition.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/qoder/config/cli-edition.test.ts b/tests/unit/qoder/config/cli-edition.test.ts index 3d97daa..3e3eef6 100644 --- a/tests/unit/qoder/config/cli-edition.test.ts +++ b/tests/unit/qoder/config/cli-edition.test.ts @@ -38,8 +38,8 @@ describe('cli-edition', () => { }); it('maps config roots per edition', () => { - expect(getQoderCliHomeDir('global', '/home/test')).toBe('/home/test/.qoder'); - expect(getQoderCliHomeDir('cn', '/home/test')).toBe('/home/test/.qoder-cn'); + expect(getQoderCliHomeDir('global', '/home/test')).toBe(path.join('/home/test', '.qoder')); + expect(getQoderCliHomeDir('cn', '/home/test')).toBe(path.join('/home/test', '.qoder-cn')); }); it('builds the login command from the binary name', () => {