Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .cursor/rules/electron-ipc.mdc
Original file line number Diff line number Diff line change
@@ -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, '/')`
50 changes: 50 additions & 0 deletions .cursor/rules/git-workflow.mdc
Original file line number Diff line number Diff line change
@@ -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 訊息格式

```
<type>: <簡述>
```

| 前綴 | 用途 |
|------|------|
| `feat` | 新功能 |
| `fix` | 修 bug |
| `docs` | 文件變更 |
| `refactor` | 重構 |
| `test` | 測試 |
| `chore` | 工具 / 設定 |

## PR 流程

1. 先開 GitHub Issue,等維護者確認方向
2. 在自己的 fork 分支開發
3. 跑完三個驗證指令後再 commit
4. 推送到 `origin`,開 PR 指向 `qiuzhi2046/Qclaw`
48 changes: 48 additions & 0 deletions .cursor/rules/qclaw-project.mdc
Original file line number Diff line number Diff line change
@@ -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
```
83 changes: 83 additions & 0 deletions CURSOR.md
Original file line number Diff line number Diff line change
@@ -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(已有)
50 changes: 50 additions & 0 deletions electron/main/__tests__/node-runtime-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@ 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')
})

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', () => {
Expand All @@ -55,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: {
Expand Down
58 changes: 55 additions & 3 deletions electron/main/__tests__/node-subprocess-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -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',
Expand Down Expand Up @@ -123,6 +124,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(
{
Expand Down
Loading