From 7dd554a3cf8955b7f8c96c4d659fd71743162e1b Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Tue, 31 Mar 2026 23:35:55 +0800 Subject: [PATCH 1/6] feat: detect nvm-windows Node.js installations on Windows Made-with: Cursor --- .../__tests__/node-runtime-selection.test.ts | 18 +++++ .../__tests__/node-subprocess-runtime.test.ts | 51 ++++++++++++ .../main/__tests__/nvm-node-runtime.test.ts | 80 +++++++++++++++++++ electron/main/node-runtime-selection.ts | 4 +- electron/main/node-subprocess-runtime.ts | 56 +++++++++++-- electron/main/nvm-node-runtime.ts | 62 ++++++++++++++ 6 files changed, 263 insertions(+), 8 deletions(-) diff --git a/electron/main/__tests__/node-runtime-selection.test.ts b/electron/main/__tests__/node-runtime-selection.test.ts index 1e96c4e..c242da9 100644 --- a/electron/main/__tests__/node-runtime-selection.test.ts +++ b/electron/main/__tests__/node-runtime-selection.test.ts @@ -29,6 +29,24 @@ describe('resolveNodeInstallStrategy', () => { ) ).toBe('installer') }) + + it('recognizes nvm-windows version directories on Windows', () => { + expect( + resolveNodeInstallStrategy( + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v22.17.1', + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm' + ) + ).toBe('nvm') + }) + + it('treats non-nvm Windows paths as installer-managed', () => { + expect( + resolveNodeInstallStrategy( + 'C:\\Program Files\\nodejs', + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm' + ) + ).toBe('installer') + }) }) describe('selectPreferredNodeRuntime', () => { diff --git a/electron/main/__tests__/node-subprocess-runtime.test.ts b/electron/main/__tests__/node-subprocess-runtime.test.ts index df53226..2e1a4e7 100644 --- a/electron/main/__tests__/node-subprocess-runtime.test.ts +++ b/electron/main/__tests__/node-subprocess-runtime.test.ts @@ -123,6 +123,57 @@ describe('resolveQualifiedNodeRuntime', () => { }) }) + it('detects nvm-windows Node on Windows when NVM_HOME is set', async () => { + const result = await resolveQualifiedNodeRuntime( + { + env: { + ...TEST_ENV, + NVM_HOME: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + }, + platform: 'win32', + }, + { + probeCapability: vi.fn(async () => + makeNodeCapability({ + platform: 'win32', + available: false, + resolvedPath: undefined, + }) + ), + probeVersion: vi.fn(async (executablePath: string) => { + if ( + executablePath === + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.14.0\\node.exe' + ) + return 'v24.14.0' + return null + }), + resolveRequirement: vi.fn(async () => ({ + minVersion: '22.16.0', + source: 'bundled-fallback' as const, + })), + resolveInstallPlan: vi.fn(async () => + makeInstallPlan({ platform: 'win32', url: 'https://nodejs.org/dist/v24.14.0/node-v24.14.0-x64.msi', filename: 'node-v24.14.0-x64.msi' }) + ), + detectNvmWindowsDir: vi.fn(async () => 'C:\\Users\\Jason\\AppData\\Roaming\\nvm'), + listInstalledNvmWindowsNodeExePaths: vi.fn(async () => [ + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.14.0\\node.exe', + ]), + listExecutablePathCandidates: vi.fn(() => []), + } + ) + + expect(result).toEqual({ + ok: true, + runtime: expect.objectContaining({ + executablePath: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.14.0\\node.exe', + version: 'v24.14.0', + installStrategy: 'nvm', + source: 'nvm', + }), + }) + }) + it('returns a version failure when only unsupported Node runtimes are available', async () => { const result = await resolveQualifiedNodeRuntime( { diff --git a/electron/main/__tests__/nvm-node-runtime.test.ts b/electron/main/__tests__/nvm-node-runtime.test.ts index 38a15b8..5121995 100644 --- a/electron/main/__tests__/nvm-node-runtime.test.ts +++ b/electron/main/__tests__/nvm-node-runtime.test.ts @@ -4,7 +4,9 @@ import { buildNvmNodeBinDir, buildNvmUseCommand, detectNvmDir, + detectNvmWindowsDir, listInstalledNvmNodeBinDirs, + listInstalledNvmWindowsNodeExePaths, } from '../nvm-node-runtime' const TEST_ENV_BASE = { @@ -59,6 +61,84 @@ describe('buildNvmNodeBinDir', () => { }) }) +describe('detectNvmWindowsDir', () => { + it('prefers NVM_HOME from the environment', async () => { + await expect( + detectNvmWindowsDir({ + env: { + ...TEST_ENV_BASE, + NVM_HOME: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + }, + }) + ).resolves.toBe('C:\\Users\\Jason\\AppData\\Roaming\\nvm') + }) + + it('falls back to %APPDATA%\\nvm when NVM_HOME is absent and the directory exists', async () => { + await expect( + detectNvmWindowsDir({ + env: { + ...TEST_ENV_BASE, + APPDATA: 'C:\\Users\\Jason\\AppData\\Roaming', + }, + access: async () => undefined, + }) + ).resolves.toBe('C:\\Users\\Jason\\AppData\\Roaming\\nvm') + }) + + it('returns null when NVM_HOME is absent and %APPDATA%\\nvm does not exist', async () => { + await expect( + detectNvmWindowsDir({ + env: { + ...TEST_ENV_BASE, + APPDATA: 'C:\\Users\\Jason\\AppData\\Roaming', + }, + access: async () => { + throw new Error('ENOENT') + }, + }) + ).resolves.toBeNull() + }) + + it('returns null when both NVM_HOME and APPDATA are absent', async () => { + await expect(detectNvmWindowsDir({ env: TEST_ENV_BASE })).resolves.toBeNull() + }) +}) + +describe('listInstalledNvmWindowsNodeExePaths', () => { + it('returns node.exe paths sorted from newest to oldest version', async () => { + const paths = await listInstalledNvmWindowsNodeExePaths( + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + { + readdir: async () => [ + { name: 'v22.17.1', isDirectory: () => true }, + { name: 'v18.20.8', isDirectory: () => true }, + { name: 'v24.0.0', isDirectory: () => true }, + { name: 'settings.txt', isDirectory: () => false }, + ], + } + ) + + expect(paths).toEqual([ + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.0.0\\node.exe', + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v22.17.1\\node.exe', + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v18.20.8\\node.exe', + ]) + }) + + it('returns an empty array when the directory cannot be read', async () => { + const paths = await listInstalledNvmWindowsNodeExePaths( + 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + { + readdir: async () => { + throw new Error('ENOENT') + }, + } + ) + + expect(paths).toEqual([]) + }) +}) + describe('detectNvmDir', () => { it('prefers NVM_DIR from the environment', async () => { await expect( diff --git a/electron/main/node-runtime-selection.ts b/electron/main/node-runtime-selection.ts index 64eb987..79098ef 100644 --- a/electron/main/node-runtime-selection.ts +++ b/electron/main/node-runtime-selection.ts @@ -16,11 +16,11 @@ export function resolveNodeInstallStrategy( binDir: string | null | undefined, nvmDir: string | null | undefined ): 'nvm' | 'installer' { - const normalizedBinDir = String(binDir || '').trim() + const normalizedBinDir = String(binDir || '').trim().replace(/\\/g, '/') if (!normalizedBinDir) return 'installer' if (normalizedBinDir.includes('/.nvm/')) return 'nvm' - const normalizedNvmDir = String(nvmDir || '').trim().replace(/\/+$/, '') + const normalizedNvmDir = String(nvmDir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '') if (!normalizedNvmDir) return 'installer' return normalizedBinDir.startsWith(`${normalizedNvmDir}/`) ? 'nvm' : 'installer' } diff --git a/electron/main/node-subprocess-runtime.ts b/electron/main/node-subprocess-runtime.ts index a89710a..8a344d3 100644 --- a/electron/main/node-subprocess-runtime.ts +++ b/electron/main/node-subprocess-runtime.ts @@ -2,7 +2,12 @@ const childProcess = process.getBuiltinModule('node:child_process') as typeof im const path = process.getBuiltinModule('node:path') as typeof import('node:path') import { probePlatformCommandCapability } from './command-capabilities' -import { detectNvmDir, listInstalledNvmNodeBinDirs } from './nvm-node-runtime' +import { + detectNvmDir, + detectNvmWindowsDir, + listInstalledNvmNodeBinDirs, + listInstalledNvmWindowsNodeExePaths, +} from './nvm-node-runtime' import { DEFAULT_BUNDLED_NODE_REQUIREMENT, resolveNodeInstallPlan, @@ -82,7 +87,9 @@ interface ProbeNodeRuntimeDependencies { resolveRequirement?: typeof resolveOpenClawNodeRequirement resolveInstallPlan?: typeof resolveNodeInstallPlan detectNvmDir?: typeof detectNvmDir + detectNvmWindowsDir?: typeof detectNvmWindowsDir listInstalledNvmNodeBinDirs?: typeof listInstalledNvmNodeBinDirs + listInstalledNvmWindowsNodeExePaths?: typeof listInstalledNvmWindowsNodeExePaths listExecutablePathCandidates?: typeof listExecutablePathCandidates cwdResolver?: typeof resolveSafeWorkingDirectory } @@ -205,8 +212,11 @@ export async function resolveQualifiedNodeRuntime( (async () => ({ minVersion: DEFAULT_BUNDLED_NODE_REQUIREMENT, source: 'bundled-fallback' as const })) const resolveInstallPlan = dependencies.resolveInstallPlan || resolveNodeInstallPlan const detectNvmDirImpl = dependencies.detectNvmDir || detectNvmDir + const detectNvmWindowsDirImpl = dependencies.detectNvmWindowsDir || detectNvmWindowsDir const listInstalledNvmNodeBinDirsImpl = dependencies.listInstalledNvmNodeBinDirs || listInstalledNvmNodeBinDirs + const listInstalledNvmWindowsNodeExePathsImpl = + dependencies.listInstalledNvmWindowsNodeExePaths || listInstalledNvmWindowsNodeExePaths const listExecutablePathCandidatesImpl = dependencies.listExecutablePathCandidates || listExecutablePathCandidates @@ -233,20 +243,53 @@ export async function resolveQualifiedNodeRuntime( : null if (shellCandidate?.version) detectedVersions.add(shellCandidate.version) - const nvmDir = platform === 'win32' ? null : await detectNvmDirImpl({ env: lookupEnv }).catch(() => null) + const nvmDir = + platform !== 'win32' + ? await detectNvmDirImpl({ env: lookupEnv }).catch(() => null) + : null + const nvmWindowsDir = + platform === 'win32' + ? await detectNvmWindowsDirImpl({ env: lookupEnv }).catch(() => null) + : null let nvmCandidate: RuntimeCandidateWithPath | null = null + if (nvmDir) { const candidateBins = Array.from( new Set( [ - targetVersion ? path.join(nvmDir, 'versions', 'node', `v${targetVersion.replace(/^v/, '')}`, 'bin') : '', + targetVersion + ? path.join(nvmDir, 'versions', 'node', `v${targetVersion.replace(/^v/, '')}`, 'bin') + : '', ...(await listInstalledNvmNodeBinDirsImpl(nvmDir).catch(() => [])), ].filter(Boolean) ) ) for (const candidateBin of candidateBins) { - const executablePath = path.join(candidateBin, platform === 'win32' ? 'node.exe' : 'node') + const executablePath = path.join(candidateBin, 'node') + nvmCandidate = await probeRuntimeCandidate( + executablePath, + { timeoutMs, env: lookupEnv, cwd }, + probeVersion + ) + if (nvmCandidate) { + detectedVersions.add(nvmCandidate.version) + break + } + } + } else if (nvmWindowsDir) { + const candidateExePaths = Array.from( + new Set( + [ + targetVersion + ? path.join(nvmWindowsDir, `v${targetVersion.replace(/^v/, '')}`, 'node.exe') + : '', + ...(await listInstalledNvmWindowsNodeExePathsImpl(nvmWindowsDir).catch(() => [])), + ].filter(Boolean) + ) + ) + + for (const executablePath of candidateExePaths) { nvmCandidate = await probeRuntimeCandidate( executablePath, { timeoutMs, env: lookupEnv, cwd }, @@ -259,6 +302,7 @@ export async function resolveQualifiedNodeRuntime( } } + const effectiveNvmDir = nvmWindowsDir ?? nvmDir const preferred = selectPreferredNodeRuntime({ shellNode: shellCandidate ? { @@ -273,7 +317,7 @@ export async function resolveQualifiedNodeRuntime( } : null, requiredVersion, - nvmDir, + nvmDir: effectiveNvmDir, }) if (preferred) { @@ -325,7 +369,7 @@ export async function resolveQualifiedNodeRuntime( runtime: { executablePath: probedCandidate.executablePath, version: probedCandidate.version, - installStrategy: resolveNodeInstallStrategy(probedCandidate.binDir, nvmDir), + installStrategy: resolveNodeInstallStrategy(probedCandidate.binDir, effectiveNvmDir), source: 'candidate', requiredVersion, targetVersion, diff --git a/electron/main/nvm-node-runtime.ts b/electron/main/nvm-node-runtime.ts index 9e4d669..61b2dc1 100644 --- a/electron/main/nvm-node-runtime.ts +++ b/electron/main/nvm-node-runtime.ts @@ -27,6 +27,20 @@ interface DetectNvmDirOptions { pathModule?: typeof import('node:path') } +interface DetectNvmWindowsDirOptions { + env?: NodeJS.ProcessEnv + access?: (path: string) => Promise + pathModule?: typeof import('node:path') +} + +interface ListInstalledNvmWindowsNodeExePathsOptions { + readdir?: ( + path: string, + options: { withFileTypes: true } + ) => Promise> + pathModule?: typeof import('node:path') +} + function parseSemver(version: string): ParsedSemver | null { const matched = String(version || '').trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/) if (!matched) return null @@ -107,6 +121,54 @@ export async function listInstalledNvmNodeBinDirs( .map((entry) => pathModule.join(nvmDir, 'versions', 'node', entry.name, 'bin')) } +export async function detectNvmWindowsDir( + options: DetectNvmWindowsDirOptions = {} +): Promise { + const env = options.env || process.env + const access = + options.access || + (async (targetPath: string) => { + await fsPromises.access(targetPath) + }) + const pathModule = options.pathModule || path + + const nvmHome = String(env.NVM_HOME || '').trim() + if (nvmHome) return nvmHome + + const appData = String(env.APPDATA || '').trim() + if (!appData) return null + + const fallbackDir = pathModule.join(appData, 'nvm') + try { + await access(fallbackDir) + return fallbackDir + } catch { + return null + } +} + +export async function listInstalledNvmWindowsNodeExePaths( + nvmWindowsDir: string, + options: ListInstalledNvmWindowsNodeExePathsOptions = {} +): Promise { + const readdir = + options.readdir || + ((targetPath, readOptions) => fsPromises.readdir(targetPath, readOptions)) + const pathModule = options.pathModule || path + + let entries: Array = [] + try { + entries = await readdir(nvmWindowsDir, { withFileTypes: true }) + } catch { + return [] + } + + return entries + .filter((entry) => entry.isDirectory() && parseSemver(entry.name)) + .sort((left, right) => compareSemverDescending(left.name, right.name)) + .map((entry) => pathModule.join(nvmWindowsDir, entry.name, 'node.exe')) +} + export async function detectNvmDir( options: DetectNvmDirOptions = {} ): Promise { From 64ec37c796bcedb8c05b6d9b5600fcd8c37d3098 Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Wed, 1 Apr 2026 04:45:26 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=A7=A3=E6=B1=BAqclaw=E7=84=A1=E6=B3=95?= =?UTF-8?q?=E5=81=B5=E6=B8=AC=E5=88=B0nvm-windows=E7=94=A8=E6=88=B6?= =?UTF-8?q?=E7=9A=84Node=E7=9A=84=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/electron-ipc.mdc | 33 ++++++++++++++++++++++ .cursor/rules/git-workflow.mdc | 50 +++++++++++++++++++++++++++++++++ .cursor/rules/qclaw-project.mdc | 48 +++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 .cursor/rules/electron-ipc.mdc create mode 100644 .cursor/rules/git-workflow.mdc create mode 100644 .cursor/rules/qclaw-project.mdc diff --git a/.cursor/rules/electron-ipc.mdc b/.cursor/rules/electron-ipc.mdc new file mode 100644 index 0000000..5e09b25 --- /dev/null +++ b/.cursor/rules/electron-ipc.mdc @@ -0,0 +1,33 @@ +--- +description: Electron IPC 跨層開發順序與注意事項 +globs: electron/**/*.ts +alwaysApply: false +--- + +# Electron IPC 開發規範 + +## 跨層改動順序 + +新增或修改 IPC 功能時,**必須依照以下順序**: + +1. **型別定義** — `src/types/electron.d.ts` +2. **Preload 橋接** — `electron/preload/` +3. **IPC Handler** — `electron/main/ipc-handlers.ts` +4. **主程式邏輯** — `electron/main/`(新增服務檔案) +5. **UI 元件** — `src/pages/` 或 `src/components/` + +## 架構分層說明 + +| 層 | 路徑 | 說明 | +|----|------|------| +| UI | `src/pages/` | React 頁面、按鈕、互動 | +| 型別定義 | `src/types/electron.d.ts` | IPC API 型別 | +| Preload | `electron/preload/` | 安全橋接層(Context Bridge) | +| IPC | `electron/main/ipc-handlers.ts` | IPC 事件處理 | +| 業務邏輯 | `electron/main/` | 主程式服務 | + +## 注意事項 + +- Preload 只能使用 `contextBridge.exposeInMainWorld` 暴露 API +- 主程序使用 `process.getBuiltinModule('node:...')` 取得 Node 內建模組 +- 跨平台路徑比較前須正規化分隔符:`.replace(/\\/g, '/')` diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc new file mode 100644 index 0000000..726bf22 --- /dev/null +++ b/.cursor/rules/git-workflow.mdc @@ -0,0 +1,50 @@ +--- +description: Qclaw 開源貢獻的 Git 工作流程、Commit 格式與分支命名 +alwaysApply: true +--- + +# Git 工作流程 + +## Remote 設定 + +| Remote | 指向 | 用途 | +|--------|------|------| +| `origin` | `JasonYang318/Qclaw`(fork) | 推送改動 | +| `upstream` | `qiuzhi2046/Qclaw`(原始) | 同步最新版 | + +## 同步上游 + +```powershell +git fetch upstream +git checkout main +git merge upstream/main +git push origin main +``` + +## 分支命名 + +- 新功能:`feat/<功能名>` +- 修 bug:`fix/<問題描述>` +- 文件:`docs/<主題>` + +## Commit 訊息格式 + +``` +: <簡述> +``` + +| 前綴 | 用途 | +|------|------| +| `feat` | 新功能 | +| `fix` | 修 bug | +| `docs` | 文件變更 | +| `refactor` | 重構 | +| `test` | 測試 | +| `chore` | 工具 / 設定 | + +## PR 流程 + +1. 先開 GitHub Issue,等維護者確認方向 +2. 在自己的 fork 分支開發 +3. 跑完三個驗證指令後再 commit +4. 推送到 `origin`,開 PR 指向 `qiuzhi2046/Qclaw` diff --git a/.cursor/rules/qclaw-project.mdc b/.cursor/rules/qclaw-project.mdc new file mode 100644 index 0000000..90c0aec --- /dev/null +++ b/.cursor/rules/qclaw-project.mdc @@ -0,0 +1,48 @@ +--- +description: Qclaw 專案概述:技術棧、目錄結構與開發指令 +alwaysApply: true +--- + +# Qclaw 專案規範 + +## 技術棧 + +- 桌面框架:Electron +- 前端:React + TypeScript + Vite +- UI:Mantine 8 + Tailwind CSS 3 +- 打包:electron-builder +- 測試:Vitest +- 授權:Apache-2.0 + +## 目錄結構 + +``` +electron/ + main/ 主程序(窗口管理、CLI 調用、IPC 處理) + preload/ 預加載腳本(安全橋接) +src/ + pages/ 頁面元件(嚮導步驟、Dashboard、聊天等) + components/ UI 元件 + lib/ 業務邏輯 + shared/ 共享模組 + types/ TypeScript 型別定義(含 IPC API 介面) +``` + +## 開發指令 + +| 指令 | 用途 | +|------|------| +| `npm run dev` | 啟動開發伺服器 | +| `npm run typecheck` | TypeScript 型別檢查 | +| `npm test` | 執行測試套件(Vitest) | +| `npm run build:app` | 前端 + 主程式編譯 | + +> `npm run build` 因 `forceCodeSigning: true` 在無憑證環境會失敗,屬預期行為。 + +## 提交前必跑 + +```powershell +npm run typecheck +npm test +npm run build:app +``` From 12fdbbd1ca3e7b05b14da8d064e14b3849ca2b09 Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Wed, 1 Apr 2026 10:28:24 +0800 Subject: [PATCH 3/6] fix: make POSIX nvm tests cross-platform on Windows Made-with: Cursor --- electron/main/__tests__/node-subprocess-runtime.test.ts | 7 ++++--- electron/main/__tests__/nvm-node-runtime.test.ts | 6 +++++- electron/main/nvm-node-runtime.ts | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/electron/main/__tests__/node-subprocess-runtime.test.ts b/electron/main/__tests__/node-subprocess-runtime.test.ts index 2e1a4e7..06916b4 100644 --- a/electron/main/__tests__/node-subprocess-runtime.test.ts +++ b/electron/main/__tests__/node-subprocess-runtime.test.ts @@ -62,8 +62,9 @@ describe('resolveQualifiedNodeRuntime', () => { { probeCapability: vi.fn(async () => makeNodeCapability()), probeVersion: vi.fn(async (executablePath: string) => { - if (executablePath === '/usr/local/bin/node') return 'v20.11.1' - if (executablePath === '/Users/alice/.nvm/versions/node/v24.14.0/bin/node') return 'v24.14.0' + const p = executablePath.replace(/\\/g, '/') + if (p === '/usr/local/bin/node') return 'v20.11.1' + if (p === '/Users/alice/.nvm/versions/node/v24.14.0/bin/node') return 'v24.14.0' return null }), resolveRequirement: vi.fn(async () => ({ @@ -80,7 +81,7 @@ describe('resolveQualifiedNodeRuntime', () => { expect(result).toEqual({ ok: true, runtime: expect.objectContaining({ - executablePath: '/Users/alice/.nvm/versions/node/v24.14.0/bin/node', + executablePath: expect.stringMatching(/nvm[/\\]versions[/\\]node[/\\]v24\.14\.0[/\\]bin[/\\]node$/), version: 'v24.14.0', installStrategy: 'nvm', source: 'nvm', diff --git a/electron/main/__tests__/nvm-node-runtime.test.ts b/electron/main/__tests__/nvm-node-runtime.test.ts index 5121995..36c4ad0 100644 --- a/electron/main/__tests__/nvm-node-runtime.test.ts +++ b/electron/main/__tests__/nvm-node-runtime.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' + +const path = process.getBuiltinModule('node:path') as typeof import('node:path') import { buildNvmInstallCommand, buildNvmNodeBinDir, @@ -23,6 +25,7 @@ describe('listInstalledNvmNodeBinDirs', () => { { name: 'v24.14.0', isDirectory: () => true }, { name: 'v18.20.8', isDirectory: () => true }, ], + pathModule: path.posix, }) expect(dirs).toEqual([ @@ -55,7 +58,7 @@ describe('buildNvmUseCommand', () => { describe('buildNvmNodeBinDir', () => { it('normalizes versions to the nvm directory layout', () => { - expect(buildNvmNodeBinDir('/Users/alice/.nvm', '24.14.0')).toBe( + expect(buildNvmNodeBinDir('/Users/alice/.nvm', '24.14.0', path.posix)).toBe( '/Users/alice/.nvm/versions/node/v24.14.0/bin' ) }) @@ -168,6 +171,7 @@ describe('detectNvmDir', () => { env: TEST_ENV_BASE, homedir: () => '/Users/alice', access: async () => undefined, + pathModule: path.posix, }) ).resolves.toBe('/Users/alice/.nvm') }) diff --git a/electron/main/nvm-node-runtime.ts b/electron/main/nvm-node-runtime.ts index 61b2dc1..ae77ec2 100644 --- a/electron/main/nvm-node-runtime.ts +++ b/electron/main/nvm-node-runtime.ts @@ -86,7 +86,7 @@ export function buildNvmNodeBinDir( } export function buildNvmShellPrefix(nvmDir: string): string { - return `export NVM_DIR=${quotePosixShellArg(nvmDir)} && source ${quotePosixShellArg(path.join(nvmDir, 'nvm.sh'))}` + return `export NVM_DIR=${quotePosixShellArg(nvmDir)} && source ${quotePosixShellArg(`${nvmDir}/nvm.sh`)}` } export function buildNvmInstallCommand(nvmDir: string, targetVersion: string): string { From d1b3841a1dff65e5a37829d9d27c44d65a3f6925 Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Wed, 1 Apr 2026 11:23:38 +0800 Subject: [PATCH 4/6] fix: inject path.win32 in nvm-windows tests for cross-platform CI Made-with: Cursor --- electron/main/__tests__/nvm-node-runtime.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/electron/main/__tests__/nvm-node-runtime.test.ts b/electron/main/__tests__/nvm-node-runtime.test.ts index 36c4ad0..eb6ad65 100644 --- a/electron/main/__tests__/nvm-node-runtime.test.ts +++ b/electron/main/__tests__/nvm-node-runtime.test.ts @@ -84,6 +84,7 @@ describe('detectNvmWindowsDir', () => { APPDATA: 'C:\\Users\\Jason\\AppData\\Roaming', }, access: async () => undefined, + pathModule: path.win32, }) ).resolves.toBe('C:\\Users\\Jason\\AppData\\Roaming\\nvm') }) @@ -98,6 +99,7 @@ describe('detectNvmWindowsDir', () => { access: async () => { throw new Error('ENOENT') }, + pathModule: path.win32, }) ).resolves.toBeNull() }) @@ -118,6 +120,7 @@ describe('listInstalledNvmWindowsNodeExePaths', () => { { name: 'v24.0.0', isDirectory: () => true }, { name: 'settings.txt', isDirectory: () => false }, ], + pathModule: path.win32, } ) @@ -135,6 +138,7 @@ describe('listInstalledNvmWindowsNodeExePaths', () => { readdir: async () => { throw new Error('ENOENT') }, + pathModule: path.win32, } ) From 27cd75541cd6ada4e15efb6324737c8b6a8144df Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Thu, 2 Apr 2026 02:16:11 +0800 Subject: [PATCH 5/6] feat: connect checkNode() to nvm-windows detection and fix case-insensitive path comparison Made-with: Cursor --- .../__tests__/node-runtime-selection.test.ts | 32 +++++++++++ electron/main/cli.ts | 54 ++++++++++++++++--- electron/main/node-runtime-selection.ts | 4 +- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/electron/main/__tests__/node-runtime-selection.test.ts b/electron/main/__tests__/node-runtime-selection.test.ts index c242da9..83559f9 100644 --- a/electron/main/__tests__/node-runtime-selection.test.ts +++ b/electron/main/__tests__/node-runtime-selection.test.ts @@ -47,6 +47,15 @@ describe('resolveNodeInstallStrategy', () => { ) ).toBe('installer') }) + + it('recognizes nvm even when Windows path casing differs', () => { + expect( + resolveNodeInstallStrategy( + 'C:\\Users\\Jason\\AppData\\Roaming\\NVM\\v22.17.1', + 'c:\\users\\jason\\appdata\\roaming\\nvm' + ) + ).toBe('nvm') + }) }) describe('selectPreferredNodeRuntime', () => { @@ -73,6 +82,29 @@ describe('selectPreferredNodeRuntime', () => { }) }) + it('prefers nvm-windows node over an older shell node on Windows', () => { + const selected = selectPreferredNodeRuntime({ + shellNode: { + version: 'v20.11.1', + binDir: 'C:\\Program Files\\nodejs', + }, + nvmNode: { + version: 'v24.0.0', + binDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.0.0', + }, + requiredVersion: '22.16.0', + nvmDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + }) + + expect(selected).toEqual({ + candidate: { + version: 'v24.0.0', + binDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.0.0', + }, + installStrategy: 'nvm', + }) + }) + it('keeps a healthy shell runtime when nvm only has an older version', () => { const selected = selectPreferredNodeRuntime({ shellNode: { diff --git a/electron/main/cli.ts b/electron/main/cli.ts index 30f76dd..1271f4b 100644 --- a/electron/main/cli.ts +++ b/electron/main/cli.ts @@ -31,7 +31,9 @@ import { buildNvmInstallCommand, buildNvmNodeBinDir, buildNvmUseCommand, + detectNvmWindowsDir, listInstalledNvmNodeBinDirs, + listInstalledNvmWindowsNodeExePaths, } from './nvm-node-runtime' import { resolveNodeInstallStrategy, @@ -1330,15 +1332,20 @@ export async function checkNode(): Promise { const requiredVersion = installPlan?.requiredVersion || requirement.minVersion const targetVersion = installPlan?.version || '' const nvmDir = !isWin ? await detectNvmDir() : null + const nvmWindowsDir = isWin ? await detectNvmWindowsDir().catch(() => null) : null + const effectiveNvmDir = nvmWindowsDir ?? nvmDir - // 先尝试当前 shell / 已知候选目录中的 node const shellNode = await resolveNodeFromShell() - const nvmNode = nvmDir ? await resolveNodeFromInstalledNvmVersions(nvmDir, targetVersion) : null + const nvmNode = nvmDir + ? await resolveNodeFromInstalledNvmVersions(nvmDir, targetVersion) + : nvmWindowsDir + ? await resolveNodeFromInstalledNvmWindowsVersions(nvmWindowsDir, targetVersion) + : null const preferredNode = selectPreferredNodeRuntime({ shellNode, nvmNode, requiredVersion, - nvmDir, + nvmDir: effectiveNvmDir, }) if (preferredNode) { @@ -1352,11 +1359,9 @@ export async function checkNode(): Promise { ) } - // 再按统一发现策略遍历 PATH / manager env / 常见目录中的 node 可执行文件 for (const nodePath of listNodeExecutableCandidates(process.platform, process.env.PATH || '', detectedNodeBinDir)) { const r = await runDirect(nodePath, ['--version'], MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, 'env-setup') if (r.ok) { - // 记住这个 bin 目录,后续 npm 也在这里 const nodeBinDir = dirname(nodePath) detectedNodeBinDir = nodeBinDir return buildNodeCheckResult( @@ -1364,12 +1369,12 @@ export async function checkNode(): Promise { true, requiredVersion, targetVersion, - resolveNodeInstallStrategy(nodeBinDir, nvmDir) + resolveNodeInstallStrategy(nodeBinDir, effectiveNvmDir) ) } } - return buildNodeCheckResult('', false, requiredVersion, targetVersion, nvmDir ? 'nvm' : 'installer') + return buildNodeCheckResult('', false, requiredVersion, targetVersion, effectiveNvmDir ? 'nvm' : 'installer') } // ─── Node.js Auto Install ─── @@ -1456,6 +1461,41 @@ async function resolveNodeFromInstalledNvmVersions( return null } +async function resolveNodeFromInstalledNvmWindowsVersions( + nvmWindowsDir: string, + preferredVersion?: string | null +): Promise<{ version: string; binDir: string | null } | null> { + const candidateExePaths = Array.from( + new Set( + [ + preferredVersion + ? join(nvmWindowsDir, `v${preferredVersion.replace(/^v/, '')}`, 'node.exe') + : '', + ...(await listInstalledNvmWindowsNodeExePaths(nvmWindowsDir).catch(() => [])), + ].filter(Boolean) + ) + ) + + for (const exePath of candidateExePaths) { + const versionResult = await runDirect( + exePath, + ['--version'], + MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, + 'env-setup' + ) + if (!versionResult.ok) continue + + const binDir = dirname(exePath) + detectedNodeBinDir = binDir + return { + version: versionResult.stdout.trim(), + binDir, + } + } + + return null +} + function buildMacOpenClawInstallFallbackCommand(options: { version: string npmCommandOptions: OpenClawNpmCommandOptions diff --git a/electron/main/node-runtime-selection.ts b/electron/main/node-runtime-selection.ts index 79098ef..523ce76 100644 --- a/electron/main/node-runtime-selection.ts +++ b/electron/main/node-runtime-selection.ts @@ -16,11 +16,11 @@ export function resolveNodeInstallStrategy( binDir: string | null | undefined, nvmDir: string | null | undefined ): 'nvm' | 'installer' { - const normalizedBinDir = String(binDir || '').trim().replace(/\\/g, '/') + const normalizedBinDir = String(binDir || '').trim().replace(/\\/g, '/').toLowerCase() if (!normalizedBinDir) return 'installer' if (normalizedBinDir.includes('/.nvm/')) return 'nvm' - const normalizedNvmDir = String(nvmDir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedNvmDir = String(nvmDir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() if (!normalizedNvmDir) return 'installer' return normalizedBinDir.startsWith(`${normalizedNvmDir}/`) ? 'nvm' : 'installer' } From 3f654ef9f851ca63dac96ef403268990e8611fce Mon Sep 17 00:00:00 2001 From: Jason Yang Date: Thu, 2 Apr 2026 02:16:11 +0800 Subject: [PATCH 6/6] feat: connect checkNode() to nvm-windows detection and fix case-insensitive path comparison Made-with: Cursor --- CURSOR.md | 83 +++++++++++++++++++ .../__tests__/node-runtime-selection.test.ts | 32 +++++++ electron/main/cli.ts | 54 ++++++++++-- electron/main/node-runtime-selection.ts | 4 +- 4 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 CURSOR.md diff --git a/CURSOR.md b/CURSOR.md new file mode 100644 index 0000000..314fdec --- /dev/null +++ b/CURSOR.md @@ -0,0 +1,83 @@ +# CURSOR.md + +## Feature +nvm-windows Node.js 偵測支援 + +## Goal +讓 Windows 上使用 nvm-windows 的使用者,在 Qclaw 環境檢查與 Gateway 啟動時能正確偵測到其管理的 Node.js,而非回退至內建安裝。 + +## Scope (MVP) +- `checkNode()` 在 Windows 上能透過 `NVM_HOME` 或 `%APPDATA%\nvm` 偵測 nvm-windows 安裝的 Node +- `resolveNodeInstallStrategy()` 在 Windows 上能正確回傳 `'nvm'`(含路徑大小寫不一致的情況) +- `resolveQualifiedNodeRuntime()` 在 Windows 上能列舉 nvm-windows 版本(已完成) + +## Out of Scope (for now) +- 透過 Qclaw UI 自動安裝 Node 至 nvm-windows(nvm install) +- nvm-windows 版本切換 UI +- 支援其他 Windows Node 版本管理器(如 fnm、Volta) + +## UX +使用者操作路徑(top-down): + +``` +EnvCheck.tsx(掛載後延遲觸發 runChecks) + → window.api.checkNode() + → preload: ipcRenderer.invoke('env:checkNode') + → ipc-handlers.ts: ipcMain.handle('env:checkNode', () => checkNode()) + → cli.ts: checkNode() + ├─ 第 1332 行:const nvmDir = !isWin ? await detectNvmDir() : null + │ ⚠ Windows 上 nvmDir 恆為 null → nvmNode 恆為 null + │ ⚠ detectNvmDir() 是 cli.ts 內的 private 函式,非 nvm-node-runtime.ts 的 + ├─ resolveNodeFromShell() → where node / which node + ├─ selectPreferredNodeRuntime({ shellNode, nvmNode: null, nvmDir: null }) + └─ listNodeExecutableCandidates → 逐個嘗試 + +Gateway 啟動路徑: + ensureRuntimeReady() → checkNode() → 同上 + +子程序路徑(已修改,不經過 checkNode): + runNodeEvalWithQualifiedRuntime() → resolveQualifiedNodeRuntime() + ├─ win32: detectNvmWindowsDir + listInstalledNvmWindowsNodeExePaths ✅ + └─ 非 win32: detectNvmDir (from nvm-node-runtime.ts) ✅ +``` + +成功回饋:環境檢查頁 Node 版本顯示綠勾,installStrategy 為 'nvm' +失敗回饋:偵測不到 Node → 提示安裝 + +## Technical Notes + +### 問題 1:checkNode() 未接入 nvm-windows +- `cli.ts` 第 1332 行 `!isWin` 硬跳過 → 需改為 Windows 上呼叫 nvm-windows 偵測 +- cli.ts 內有私有 `detectNvmDir()`(第 1385 行),只處理 POSIX nvm +- 需在 checkNode() 加入:Windows 上呼叫 `detectNvmWindowsDir()`,並用結果掃描版本 + +### 問題 2:路徑比較大小寫敏感 +- `resolveNodeInstallStrategy()` 在 `node-runtime-selection.ts` +- 目前用 `.startsWith()` 比較正規化後的路徑 +- Windows 路徑大小寫不敏感:`C:\Users\` == `c:\users\` +- 需加 `.toLowerCase()` 再比較 + +### 檔案觸碰點 +| 檔案 | 改動 | +|------|------| +| `electron/main/cli.ts` | checkNode() 加入 nvm-windows 偵測 | +| `electron/main/node-runtime-selection.ts` | 路徑比較加 toLowerCase() | +| `electron/main/__tests__/nvm-node-runtime.test.ts` | 補大小寫邊界測試 | +| `electron/main/__tests__/node-runtime-selection.test.ts` | 補大小寫邊界測試 | + +## Test Checklist + +### 正常情況 +- [x] nvm-windows 安裝的 Node 能被 resolveQualifiedNodeRuntime 偵測(已有) +- [ ] nvm-windows 安裝的 Node 能被 checkNode() 偵測 +- [x] POSIX nvm 偵測不受影響(已有) + +### 邊界情況 +- [ ] Windows 路徑大小寫不一致時 resolveNodeInstallStrategy 仍回傳 'nvm' + 例:binDir='C:\Users\Jason\AppData\Roaming\NVM\v22\' vs nvmDir='c:\users\jason\appdata\roaming\nvm' +- [ ] NVM_HOME 環境變數不存在但 %APPDATA%\nvm 存在(已有,需驗證 checkNode 路徑) +- [ ] NVM_HOME 和 APPDATA 都不存在 → 回退 installer(已有) + +### 錯誤情況 +- [x] nvm-windows 目錄無法讀取 → 回傳空陣列(已有) +- [x] POSIX nvm 目錄不存在 → 回傳 null(已有) diff --git a/electron/main/__tests__/node-runtime-selection.test.ts b/electron/main/__tests__/node-runtime-selection.test.ts index c242da9..83559f9 100644 --- a/electron/main/__tests__/node-runtime-selection.test.ts +++ b/electron/main/__tests__/node-runtime-selection.test.ts @@ -47,6 +47,15 @@ describe('resolveNodeInstallStrategy', () => { ) ).toBe('installer') }) + + it('recognizes nvm even when Windows path casing differs', () => { + expect( + resolveNodeInstallStrategy( + 'C:\\Users\\Jason\\AppData\\Roaming\\NVM\\v22.17.1', + 'c:\\users\\jason\\appdata\\roaming\\nvm' + ) + ).toBe('nvm') + }) }) describe('selectPreferredNodeRuntime', () => { @@ -73,6 +82,29 @@ describe('selectPreferredNodeRuntime', () => { }) }) + it('prefers nvm-windows node over an older shell node on Windows', () => { + const selected = selectPreferredNodeRuntime({ + shellNode: { + version: 'v20.11.1', + binDir: 'C:\\Program Files\\nodejs', + }, + nvmNode: { + version: 'v24.0.0', + binDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.0.0', + }, + requiredVersion: '22.16.0', + nvmDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm', + }) + + expect(selected).toEqual({ + candidate: { + version: 'v24.0.0', + binDir: 'C:\\Users\\Jason\\AppData\\Roaming\\nvm\\v24.0.0', + }, + installStrategy: 'nvm', + }) + }) + it('keeps a healthy shell runtime when nvm only has an older version', () => { const selected = selectPreferredNodeRuntime({ shellNode: { diff --git a/electron/main/cli.ts b/electron/main/cli.ts index 30f76dd..1271f4b 100644 --- a/electron/main/cli.ts +++ b/electron/main/cli.ts @@ -31,7 +31,9 @@ import { buildNvmInstallCommand, buildNvmNodeBinDir, buildNvmUseCommand, + detectNvmWindowsDir, listInstalledNvmNodeBinDirs, + listInstalledNvmWindowsNodeExePaths, } from './nvm-node-runtime' import { resolveNodeInstallStrategy, @@ -1330,15 +1332,20 @@ export async function checkNode(): Promise { const requiredVersion = installPlan?.requiredVersion || requirement.minVersion const targetVersion = installPlan?.version || '' const nvmDir = !isWin ? await detectNvmDir() : null + const nvmWindowsDir = isWin ? await detectNvmWindowsDir().catch(() => null) : null + const effectiveNvmDir = nvmWindowsDir ?? nvmDir - // 先尝试当前 shell / 已知候选目录中的 node const shellNode = await resolveNodeFromShell() - const nvmNode = nvmDir ? await resolveNodeFromInstalledNvmVersions(nvmDir, targetVersion) : null + const nvmNode = nvmDir + ? await resolveNodeFromInstalledNvmVersions(nvmDir, targetVersion) + : nvmWindowsDir + ? await resolveNodeFromInstalledNvmWindowsVersions(nvmWindowsDir, targetVersion) + : null const preferredNode = selectPreferredNodeRuntime({ shellNode, nvmNode, requiredVersion, - nvmDir, + nvmDir: effectiveNvmDir, }) if (preferredNode) { @@ -1352,11 +1359,9 @@ export async function checkNode(): Promise { ) } - // 再按统一发现策略遍历 PATH / manager env / 常见目录中的 node 可执行文件 for (const nodePath of listNodeExecutableCandidates(process.platform, process.env.PATH || '', detectedNodeBinDir)) { const r = await runDirect(nodePath, ['--version'], MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, 'env-setup') if (r.ok) { - // 记住这个 bin 目录,后续 npm 也在这里 const nodeBinDir = dirname(nodePath) detectedNodeBinDir = nodeBinDir return buildNodeCheckResult( @@ -1364,12 +1369,12 @@ export async function checkNode(): Promise { true, requiredVersion, targetVersion, - resolveNodeInstallStrategy(nodeBinDir, nvmDir) + resolveNodeInstallStrategy(nodeBinDir, effectiveNvmDir) ) } } - return buildNodeCheckResult('', false, requiredVersion, targetVersion, nvmDir ? 'nvm' : 'installer') + return buildNodeCheckResult('', false, requiredVersion, targetVersion, effectiveNvmDir ? 'nvm' : 'installer') } // ─── Node.js Auto Install ─── @@ -1456,6 +1461,41 @@ async function resolveNodeFromInstalledNvmVersions( return null } +async function resolveNodeFromInstalledNvmWindowsVersions( + nvmWindowsDir: string, + preferredVersion?: string | null +): Promise<{ version: string; binDir: string | null } | null> { + const candidateExePaths = Array.from( + new Set( + [ + preferredVersion + ? join(nvmWindowsDir, `v${preferredVersion.replace(/^v/, '')}`, 'node.exe') + : '', + ...(await listInstalledNvmWindowsNodeExePaths(nvmWindowsDir).catch(() => [])), + ].filter(Boolean) + ) + ) + + for (const exePath of candidateExePaths) { + const versionResult = await runDirect( + exePath, + ['--version'], + MAIN_RUNTIME_POLICY.cli.lightweightProbeTimeoutMs, + 'env-setup' + ) + if (!versionResult.ok) continue + + const binDir = dirname(exePath) + detectedNodeBinDir = binDir + return { + version: versionResult.stdout.trim(), + binDir, + } + } + + return null +} + function buildMacOpenClawInstallFallbackCommand(options: { version: string npmCommandOptions: OpenClawNpmCommandOptions diff --git a/electron/main/node-runtime-selection.ts b/electron/main/node-runtime-selection.ts index 79098ef..523ce76 100644 --- a/electron/main/node-runtime-selection.ts +++ b/electron/main/node-runtime-selection.ts @@ -16,11 +16,11 @@ export function resolveNodeInstallStrategy( binDir: string | null | undefined, nvmDir: string | null | undefined ): 'nvm' | 'installer' { - const normalizedBinDir = String(binDir || '').trim().replace(/\\/g, '/') + const normalizedBinDir = String(binDir || '').trim().replace(/\\/g, '/').toLowerCase() if (!normalizedBinDir) return 'installer' if (normalizedBinDir.includes('/.nvm/')) return 'nvm' - const normalizedNvmDir = String(nvmDir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '') + const normalizedNvmDir = String(nvmDir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() if (!normalizedNvmDir) return 'installer' return normalizedBinDir.startsWith(`${normalizedNvmDir}/`) ? 'nvm' : 'installer' }