From 3d862e6ed8c2f07cdf3977db3903f9c7d2d33eec Mon Sep 17 00:00:00 2001 From: catlog22 Date: Fri, 6 Feb 2026 23:07:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9B=B4=E5=A5=BD?= =?UTF-8?q?=E7=9A=84=20SQLite3=20=E6=A8=A1=E5=9D=97=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=92=8C=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=A1=B9=E7=9B=AE=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 22 ++ FAQ.md | 26 ++ README.md | 2 + .../src/components/mcp/CrossCliCopyButton.tsx | 51 ++- .../src/components/mcp/McpServerDialog.tsx | 30 +- .../components/mcp/OtherProjectsSection.tsx | 4 +- .../components/mcp/RecommendedMcpWizard.tsx | 7 +- ccw/frontend/src/hooks/useMcpServers.ts | 14 +- ccw/frontend/src/lib/api.mcp.test.ts | 293 ++++++++++++++++++ ccw/frontend/src/pages/McpManagerPage.tsx | 10 +- ccw/src/core/routes/mcp-templates-db.ts | 18 +- .../dashboard-js/views/cli-manager.js | 8 +- ccw/src/utils/db-loader.ts | 54 ++++ ccw/tests/cli-manager-language-csrf.test.js | 37 +++ package.json | 3 +- 15 files changed, 541 insertions(+), 38 deletions(-) create mode 100644 .claude/settings.json create mode 100644 ccw/frontend/src/lib/api.mcp.test.ts create mode 100644 ccw/src/utils/db-loader.ts create mode 100644 ccw/tests/cli-manager-language-csrf.test.js diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..850d3f28 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,22 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=JSON.parse(process.env.HOOK_INPUT||\\\"{}\\\");const prompt=(p.user_prompt||\\\"\\\").trim();if(/^ccw\\s+session\\s+init/i.test(prompt)||/^\\/workflow:session:start/i.test(prompt)||/^\\/workflow:session\\s+init/i.test(prompt)){const cp=require(\\\"child_process\\\");const payload=JSON.stringify({type:\\\"SESSION_CREATED\\\",prompt:prompt,timestamp:Date.now(),project:process.env.CLAUDE_PROJECT_DIR||process.cwd()});cp.spawnSync(\\\"curl\\\",[\\\"-s\\\",\\\"-X\\\",\\\"POST\\\",\\\"-H\\\",\\\"Content-Type: application/json\\\",\\\"-d\\\",payload,\\\"http://localhost:3456/api/hook\\\"],{stdio:\\\"inherit\\\",shell:true})}\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "node -e \"const p=JSON.parse(process.env.HOOK_INPUT||\\\"{}\\\");const prompt=(p.user_prompt||\\\"\\\").toLowerCase();if(prompt===\\\"status\\\"||prompt===\\\"ccw status\\\"||prompt.startsWith(\\\"/status\\\")){const cp=require(\\\"child_process\\\");cp.spawnSync(\\\"curl\\\",[\\\"-s\\\",\\\"http://localhost:3456/api/status/all\\\"],{stdio:\\\"inherit\\\"})}\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/FAQ.md b/FAQ.md index de71b7d5..b868014a 100644 --- a/FAQ.md +++ b/FAQ.md @@ -520,6 +520,32 @@ dist/ ## 🔧 Troubleshooting +### `better-sqlite3` NODE_MODULE_VERSION mismatch + +**Error message**: +``` +Error: The module '.../better_sqlite3.node' was compiled against a different Node.js version +using NODE_MODULE_VERSION XX. This version of Node.js requires NODE_MODULE_VERSION YY. +``` + +**Cause**: The `better-sqlite3` native module was compiled for a different Node.js version than the one you're running. This commonly happens when: +- You installed dependencies with one Node.js version and later switched versions +- Prebuilt binaries don't match your Node.js version + +**Solution**: +```bash +# Option 1: Rebuild the native module (recommended) +npm rebuild better-sqlite3 + +# Option 2: Rebuild from source +npm install better-sqlite3 --build-from-source + +# Option 3: Reinstall all dependencies +rm -rf node_modules && npm install +``` + +> **Note**: Building from source requires C++ build tools. On macOS run `xcode-select --install`, on Ubuntu run `sudo apt install build-essential`, on Windows install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). + ### "No active session found" error **Cause**: No workflow session is currently active. diff --git a/README.md b/README.md index 98ba80ef..208cb6f0 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ npm install -g claude-code-workflow ccw install -m Global ``` +> **Troubleshooting**: If you see `NODE_MODULE_VERSION` mismatch errors for `better-sqlite3`, run `npm rebuild better-sqlite3`. See [FAQ - Troubleshooting](FAQ.md#better-sqlite3-node_module_version-mismatch) for details. + ### Choose Your Workflow Level
diff --git a/ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx b/ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx index 563376b2..878f4cd8 100644 --- a/ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx +++ b/ccw/frontend/src/components/mcp/CrossCliCopyButton.tsx @@ -17,8 +17,9 @@ import { import { Checkbox } from '@/components/ui/Checkbox'; import { Badge } from '@/components/ui/Badge'; import { useMcpServers } from '@/hooks'; -import { crossCliCopy } from '@/lib/api'; +import { crossCliCopy, fetchCodexMcpServers } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // ========== Types ========== @@ -69,13 +70,11 @@ export function CrossCliCopyButton({ const [serverItems, setServerItems] = useState([]); const { servers } = useMcpServers(); + const projectPath = useWorkflowStore(selectProjectPath); const [isCopying, setIsCopying] = useState(false); - // Initialize server items when dialog opens - const handleOpenChange = (open: boolean) => { - setIsOpen(open); - if (open) { - setDirection(currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude'); + const loadServerItems = async (nextDirection: CopyDirection) => { + if (nextDirection === 'claude-to-codex') { setServerItems( servers.map((s) => ({ name: s.name, @@ -84,6 +83,34 @@ export function CrossCliCopyButton({ selected: false, })) ); + return; + } + + try { + const codex = await fetchCodexMcpServers(); + setServerItems( + (codex.servers ?? []).map((s) => ({ + name: s.name, + command: s.command, + enabled: s.enabled, + selected: false, + })) + ); + } catch (error) { + console.error('Failed to load Codex MCP servers:', error); + setServerItems([]); + } + }; + + // Initialize server items when dialog opens + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) { + const nextDirection = currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude'; + setDirection(nextDirection); + void loadServerItems(nextDirection); + } else { + setServerItems([]); } }; @@ -93,10 +120,9 @@ export function CrossCliCopyButton({ // Toggle direction const handleToggleDirection = () => { - setDirection((prev) => - prev === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex' - ); - setServerItems((prev) => prev.map((item) => ({ ...item, selected: false }))); + const next = direction === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex'; + setDirection(next); + void loadServerItems(next); }; // Toggle server selection @@ -124,10 +150,15 @@ export function CrossCliCopyButton({ setIsCopying(true); try { + if (targetCli === 'claude' && !projectPath) { + throw new Error('Project path is required to copy servers into Claude project'); + } + const result = await crossCliCopy({ source: sourceCli, target: targetCli, serverNames: selectedServers, + projectPath: projectPath ?? undefined, }); if (result.success) { diff --git a/ccw/frontend/src/components/mcp/McpServerDialog.tsx b/ccw/frontend/src/components/mcp/McpServerDialog.tsx index aea3aaa5..a164fe0a 100644 --- a/ccw/frontend/src/components/mcp/McpServerDialog.tsx +++ b/ccw/frontend/src/components/mcp/McpServerDialog.tsx @@ -28,10 +28,12 @@ import { updateMcpServer, fetchMcpServers, type McpServer, + type McpProjectConfigType, } from '@/lib/api'; import { mcpServersKeys, useMcpTemplates } from '@/hooks'; import { cn } from '@/lib/utils'; import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // ========== Types ========== @@ -73,6 +75,7 @@ export function McpServerDialog({ }: McpServerDialogProps) { const { formatMessage } = useIntl(); const queryClient = useQueryClient(); + const projectPath = useWorkflowStore(selectProjectPath); // Fetch templates from backend const { templates, isLoading: templatesLoading } = useMcpTemplates(); @@ -92,6 +95,7 @@ export function McpServerDialog({ const [argsInput, setArgsInput] = useState(''); const [envInput, setEnvInput] = useState(''); const [configType, setConfigType] = useState('mcp-json'); + const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp'; // Initialize form from server prop (edit mode) useEffect(() => { @@ -129,7 +133,8 @@ export function McpServerDialog({ // Mutations const createMutation = useMutation({ - mutationFn: (data: Omit) => createMcpServer(data), + mutationFn: ({ server, configType }: { server: McpServer; configType?: McpProjectConfigType }) => + createMcpServer(server, { projectPath: projectPath ?? undefined, configType }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: mcpServersKeys.all }); handleClose(); @@ -138,8 +143,8 @@ export function McpServerDialog({ }); const updateMutation = useMutation({ - mutationFn: ({ serverName, config }: { serverName: string; config: Partial }) => - updateMcpServer(serverName, config), + mutationFn: ({ serverName, config, configType }: { serverName: string; config: Partial; configType?: McpProjectConfigType }) => + updateMcpServer(serverName, config, { projectPath: projectPath ?? undefined, configType }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: mcpServersKeys.all }); handleClose(); @@ -234,7 +239,7 @@ export function McpServerDialog({ const checkNameExists = async (name: string): Promise => { try { - const data = await fetchMcpServers(); + const data = await fetchMcpServers(projectPath ?? undefined); const allServers = [...data.project, ...data.global]; // In edit mode, exclude current server return allServers.some( @@ -258,11 +263,15 @@ export function McpServerDialog({ if (mode === 'add') { createMutation.mutate({ - command: formData.command, - args: formData.args, - env: formData.env, - scope: formData.scope, - enabled: formData.enabled, + server: { + name: formData.name, + command: formData.command, + args: formData.args, + env: formData.env, + scope: formData.scope, + enabled: formData.enabled, + }, + configType: formData.scope === 'project' ? projectConfigType : undefined, }); } else { updateMutation.mutate({ @@ -274,6 +283,7 @@ export function McpServerDialog({ scope: formData.scope, enabled: formData.enabled, }, + configType: formData.scope === 'project' ? projectConfigType : undefined, }); } }; @@ -441,6 +451,7 @@ export function McpServerDialog({ checked={formData.scope === 'project'} onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')} className="w-4 h-4" + disabled={mode === 'edit'} /> {formatMessage({ id: 'mcp.scope.project' })} @@ -454,6 +465,7 @@ export function McpServerDialog({ checked={formData.scope === 'global'} onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')} className="w-4 h-4" + disabled={mode === 'edit'} /> {formatMessage({ id: 'mcp.scope.global' })} diff --git a/ccw/frontend/src/components/mcp/OtherProjectsSection.tsx b/ccw/frontend/src/components/mcp/OtherProjectsSection.tsx index 8093ada5..74ef15db 100644 --- a/ccw/frontend/src/components/mcp/OtherProjectsSection.tsx +++ b/ccw/frontend/src/components/mcp/OtherProjectsSection.tsx @@ -63,9 +63,10 @@ export function OtherProjectsSection({ for (const [path, serverList] of Object.entries(response.servers)) { const projectName = path.split(/[/\\]/).filter(Boolean).pop() || path; - for (const server of (serverList as McpServer[])) { + for (const server of (serverList as Omit[])) { servers.push({ ...server, + scope: 'project', projectPath: path, projectName, }); @@ -88,6 +89,7 @@ export function OtherProjectsSection({ const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-'); await createServer({ + name: uniqueName, command: server.command, args: server.args, env: server.env, diff --git a/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx b/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx index 05e957f0..1cdfc307 100644 --- a/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx +++ b/ccw/frontend/src/components/mcp/RecommendedMcpWizard.tsx @@ -25,6 +25,7 @@ import { import { mcpServersKeys } from '@/hooks'; import { useNotifications } from '@/hooks/useNotifications'; import { cn } from '@/lib/utils'; +import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // Icon map for MCP definitions const ICON_MAP: Record> = { @@ -96,6 +97,7 @@ export function RecommendedMcpWizard({ const { formatMessage } = useIntl(); const queryClient = useQueryClient(); const { success: showSuccess, error: showError } = useNotifications(); + const projectPath = useWorkflowStore(selectProjectPath); // State for field values const [fieldValues, setFieldValues] = useState>({}); @@ -138,7 +140,10 @@ export function RecommendedMcpWizard({ if (selectedScope === 'global') { return addGlobalMcpServer(mcpDefinition.id, serverConfig); } else { - return copyMcpServerToProject(mcpDefinition.id, serverConfig); + if (!projectPath) { + throw new Error('Project path is required to install to project scope'); + } + return copyMcpServerToProject(mcpDefinition.id, serverConfig, projectPath); } }, onSuccess: (result) => { diff --git a/ccw/frontend/src/hooks/useMcpServers.ts b/ccw/frontend/src/hooks/useMcpServers.ts index 01108efb..8bd49d92 100644 --- a/ccw/frontend/src/hooks/useMcpServers.ts +++ b/ccw/frontend/src/hooks/useMcpServers.ts @@ -524,8 +524,18 @@ export function useProjectOperations(): UseProjectOperationsReturn { isLoading: projectsQuery.isLoading, error: projectsQuery.error, refetch, - copyToCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'claude', target: 'codex' }), - copyFromCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'codex', target: 'claude' }), + copyToCodex: (request) => copyMutation.mutateAsync({ + ...request, + source: 'claude', + target: 'codex', + projectPath: request.projectPath ?? projectPath ?? undefined, + }), + copyFromCodex: (request) => copyMutation.mutateAsync({ + ...request, + source: 'codex', + target: 'claude', + projectPath: request.projectPath ?? projectPath ?? undefined, + }), isCopying: copyMutation.isPending, fetchOtherServers, isFetchingServers: serversQuery.isFetching, diff --git a/ccw/frontend/src/lib/api.mcp.test.ts b/ccw/frontend/src/lib/api.mcp.test.ts new file mode 100644 index 00000000..546a3d37 --- /dev/null +++ b/ccw/frontend/src/lib/api.mcp.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + fetchMcpServers, + toggleMcpServer, + deleteMcpServer, + createMcpServer, + updateMcpServer, + fetchCodexMcpServers, + crossCliCopy, + fetchAllProjects, + fetchOtherProjectsServers, + type McpServer, +} from './api'; + +function jsonResponse(body: unknown, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +function getLastFetchCall(fetchMock: ReturnType) { + const calls = fetchMock.mock.calls; + return calls[calls.length - 1] as [RequestInfo | URL, RequestInit | undefined]; +} + +describe('MCP API (frontend ↔ backend contract)', () => { + it('fetchMcpServers derives lists from /api/mcp-config and computes enabled from disabledMcpServers', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + projects: { + 'D:/ws': { + mcpServers: { + projOnly: { command: 'node', args: ['x'], env: { A: '1' } }, + globalDup: { command: 'should-not-appear-in-project' }, + entDup: { command: 'should-not-appear-in-project' }, + }, + disabledMcpServers: ['global1'], + }, + }, + userServers: { + global1: { command: 'npx', args: ['-y', 'foo'] }, + globalDup: { command: 'npx', args: ['-y', 'bar'] }, + }, + enterpriseServers: { + entDup: { command: 'enterprise-tool' }, + }, + globalServers: {}, + configSources: [], + }) + ); + + const result = await fetchMcpServers('D:\\ws'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/mcp-config'); + + expect(result.global.map((s) => s.name).sort()).toEqual(['global1', 'globalDup']); + expect(result.project.map((s) => s.name)).toEqual(['projOnly']); + + const global1 = result.global.find((s) => s.name === 'global1'); + expect(global1?.enabled).toBe(false); + expect(global1?.scope).toBe('global'); + + const projOnly = result.project[0]; + expect(projOnly?.command).toBe('node'); + expect(projOnly?.enabled).toBe(true); + expect(projOnly?.scope).toBe('project'); + expect(projOnly?.env).toEqual({ A: '1' }); + expect(projOnly?.args).toEqual(['x']); + }); + + it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input, init) => { + if (input === '/api/mcp-toggle') { + return jsonResponse({ success: true, serverName: 'global1', enabled: false }); + } + if (input === '/api/mcp-config') { + return jsonResponse({ + projects: { + 'D:/ws': { mcpServers: {}, disabledMcpServers: ['global1'] }, + }, + userServers: { + global1: { command: 'npx', args: ['-y', 'foo'] }, + }, + enterpriseServers: {}, + globalServers: {}, + configSources: [], + }); + } + throw new Error(`Unexpected fetch: ${String(input)}`); + }); + + const updated = await toggleMcpServer('global1', false, { projectPath: 'D:/ws' }); + + const toggleCall = fetchMock.mock.calls.find((c) => c[0] === '/api/mcp-toggle'); + expect(toggleCall).toBeTruthy(); + const [, init] = toggleCall!; + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ projectPath: 'D:/ws', serverName: 'global1', enable: false }); + + expect(updated.enabled).toBe(false); + expect(updated.name).toBe('global1'); + }); + + it('deleteMcpServer calls the correct backend endpoint for project/global scopes', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + if (input === '/api/mcp-remove-global-server') { + return jsonResponse({ success: true }); + } + if (input === '/api/mcp-remove-server') { + return jsonResponse({ success: true }); + } + throw new Error(`Unexpected fetch: ${String(input)}`); + }); + + await deleteMcpServer('g1', 'global'); + expect(getLastFetchCall(fetchMock)[0]).toBe('/api/mcp-remove-global-server'); + + await deleteMcpServer('p1', 'project', { projectPath: 'D:/ws' }); + expect(getLastFetchCall(fetchMock)[0]).toBe('/api/mcp-remove-server'); + }); + + it('createMcpServer (project) uses /api/mcp-copy-server and includes serverName + serverConfig', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => { + if (input === '/api/mcp-copy-server') { + return jsonResponse({ success: true }); + } + if (input === '/api/mcp-config') { + return jsonResponse({ + projects: { + 'D:/ws': { + mcpServers: { s1: { command: 'node', args: ['a'], env: { K: 'V' } } }, + disabledMcpServers: [], + }, + }, + userServers: {}, + enterpriseServers: {}, + globalServers: {}, + configSources: [], + }); + } + throw new Error(`Unexpected fetch: ${String(input)}`); + }); + + const inputServer: McpServer = { + name: 's1', + command: 'node', + args: ['a'], + env: { K: 'V' }, + enabled: true, + scope: 'project', + }; + + const created = await createMcpServer(inputServer, { projectPath: 'D:/ws', configType: 'mcp' }); + + const copyCall = fetchMock.mock.calls.find((c) => c[0] === '/api/mcp-copy-server'); + expect(copyCall).toBeTruthy(); + const [, init] = copyCall!; + expect(init?.method).toBe('POST'); + expect(JSON.parse(String(init?.body))).toEqual({ + projectPath: 'D:/ws', + serverName: 's1', + serverConfig: { command: 'node', args: ['a'], env: { K: 'V' } }, + configType: 'mcp', + }); + + expect(created.name).toBe('s1'); + expect(created.scope).toBe('project'); + expect(created.enabled).toBe(true); + }); + + it('updateMcpServer (global) upserts via /api/mcp-add-global-server', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + if (input === '/api/mcp-add-global-server') { + return jsonResponse({ success: true }); + } + return jsonResponse({ + projects: {}, + userServers: { g1: { command: 'npx' } }, + enterpriseServers: {}, + globalServers: {}, + configSources: [], + }); + }); + + const updated = await updateMcpServer( + 'g1', + { scope: 'global', command: 'npx', args: ['-y', 'x'], env: { A: '1' }, enabled: true }, + { projectPath: 'D:/ws' } + ); + + const addCall = fetchMock.mock.calls.find((c) => c[0] === '/api/mcp-add-global-server'); + expect(addCall).toBeTruthy(); + const [, init] = addCall!; + expect(JSON.parse(String(init?.body))).toEqual({ + serverName: 'g1', + serverConfig: { command: 'npx', args: ['-y', 'x'], env: { A: '1' } }, + }); + + expect(updated.name).toBe('g1'); + }); + + it('fetchCodexMcpServers maps /api/codex-mcp-config servers record into array', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + servers: { + s1: { command: 'node', args: ['a'], env: { K: 'V' }, enabled: true }, + s2: { command: 'python', enabled: false }, + }, + configPath: 'C:/Users/me/.codex/config.toml', + exists: true, + }) + ); + + const result = await fetchCodexMcpServers(); + expect(fetchMock).toHaveBeenCalledWith('/api/codex-mcp-config', expect.anything()); + expect(result.configPath).toContain('config.toml'); + + const s2 = result.servers.find((s) => s.name === 's2'); + expect(s2?.enabled).toBe(false); + }); + + it('crossCliCopy codex->claude copies via /api/mcp-copy-server per server', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + if (input === '/api/codex-mcp-config') { + return jsonResponse({ servers: { s1: { command: 'node' } }, configPath: 'x', exists: true }); + } + if (input === '/api/mcp-copy-server') { + return jsonResponse({ success: true }); + } + throw new Error(`Unexpected fetch: ${String(input)}`); + }); + + const res = await crossCliCopy({ + source: 'codex', + target: 'claude', + serverNames: ['s1'], + projectPath: 'D:/ws', + }); + + expect(res.success).toBe(true); + expect(res.copied).toEqual(['s1']); + expect(res.failed).toEqual([]); + + const copyCall = fetchMock.mock.calls.find((c) => c[0] === '/api/mcp-copy-server'); + expect(copyCall).toBeTruthy(); + }); + + it('fetchAllProjects derives project list from /api/mcp-config (no /api/projects/all)', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + projects: { 'D:/a': { mcpServers: {} }, 'D:/b': { mcpServers: {} } }, + userServers: {}, + enterpriseServers: {}, + globalServers: {}, + configSources: [], + }) + ); + + const res = await fetchAllProjects(); + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/mcp-config'); + expect(res.projects).toEqual(['D:/a', 'D:/b']); + }); + + it('fetchOtherProjectsServers derives per-project servers from /api/mcp-config', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + projects: { + 'D:/a': { + mcpServers: { p1: { command: 'node' } }, + disabledMcpServers: ['p1'], + }, + }, + userServers: { g1: { command: 'npx' } }, + enterpriseServers: {}, + globalServers: {}, + configSources: [], + }) + ); + + const res = await fetchOtherProjectsServers(['D:/a']); + expect(Object.keys(res.servers)).toEqual(['D:/a']); + expect(res.servers['D:/a']?.[0]?.name).toBe('p1'); + expect(res.servers['D:/a']?.[0]?.enabled).toBe(false); + }); +}); + diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx index 389bc927..33bec8e3 100644 --- a/ccw/frontend/src/pages/McpManagerPage.tsx +++ b/ccw/frontend/src/pages/McpManagerPage.tsx @@ -56,7 +56,7 @@ interface McpServerCardProps { onToggleExpand: () => void; onToggle: (serverName: string, enabled: boolean) => void; onEdit: (server: McpServer) => void; - onDelete: (serverName: string) => void; + onDelete: (server: McpServer) => void; } function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: McpServerCardProps) { @@ -132,7 +132,7 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o className="h-8 w-8 p-0" onClick={(e) => { e.stopPropagation(); - onDelete(server.name); + onDelete(server); }} > @@ -269,9 +269,9 @@ export function McpManagerPage() { toggleServer(serverName, enabled); }; - const handleDelete = (serverName: string) => { - if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: serverName }))) { - deleteServer(serverName); + const handleDelete = (server: McpServer) => { + if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: server.name }))) { + deleteServer(server.name, server.scope); } }; diff --git a/ccw/src/core/routes/mcp-templates-db.ts b/ccw/src/core/routes/mcp-templates-db.ts index 046a75d7..eeaff9ef 100644 --- a/ccw/src/core/routes/mcp-templates-db.ts +++ b/ccw/src/core/routes/mcp-templates-db.ts @@ -2,11 +2,11 @@ * MCP Templates Database Module * Stores MCP server configurations as reusable templates */ -import Database from 'better-sqlite3'; import { existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { homedir } from 'os'; import { StoragePaths, ensureStorageDir } from '../../config/storage-paths.js'; +import { createDatabase } from '../../utils/db-loader.js'; // Database path - uses centralized storage const DB_DIR = StoragePaths.global.databases(); @@ -16,14 +16,15 @@ const DB_PATH = StoragePaths.global.mcpTemplates(); ensureStorageDir(DB_DIR); // Initialize database connection -let db: Database.Database | null = null; +let db: any | null = null; /** * Get or create database connection */ -function getDb(): Database.Database { +function getDb(): any { if (!db) { - db = new Database(DB_PATH); + db = createDatabase(DB_PATH); + if (!db) return null; initDatabase(); } return db; @@ -33,7 +34,7 @@ function getDb(): Database.Database { * Initialize database schema */ function initDatabase() { - const db = getDb(); + if (!db) return; // Create templates table db.exec(` @@ -83,6 +84,7 @@ export interface McpTemplate { export function saveTemplate(template: McpTemplate): { success: boolean; id?: number; error?: string } { try { const db = getDb(); + if (!db) return { success: false, error: 'Database unavailable (native module issue)' }; const now = Date.now(); const stmt = db.prepare(` @@ -125,6 +127,7 @@ export function saveTemplate(template: McpTemplate): { success: boolean; id?: nu export function getAllTemplates(): McpTemplate[] { try { const db = getDb(); + if (!db) return []; const rows = db.prepare('SELECT * FROM mcp_templates ORDER BY name').all(); return rows.map((row: any) => ({ @@ -149,6 +152,7 @@ export function getAllTemplates(): McpTemplate[] { export function getTemplateByName(name: string): McpTemplate | null { try { const db = getDb(); + if (!db) return null; const row = db.prepare('SELECT * FROM mcp_templates WHERE name = ?').get(name); if (!row) return null; @@ -175,6 +179,7 @@ export function getTemplateByName(name: string): McpTemplate | null { export function getTemplatesByCategory(category: string): McpTemplate[] { try { const db = getDb(); + if (!db) return []; const rows = db.prepare('SELECT * FROM mcp_templates WHERE category = ? ORDER BY name').all(category); return rows.map((row: any) => ({ @@ -199,6 +204,7 @@ export function getTemplatesByCategory(category: string): McpTemplate[] { export function deleteTemplate(name: string): { success: boolean; error?: string } { try { const db = getDb(); + if (!db) return { success: false, error: 'Database unavailable (native module issue)' }; const result = db.prepare('DELETE FROM mcp_templates WHERE name = ?').run(name); return { @@ -219,6 +225,7 @@ export function deleteTemplate(name: string): { success: boolean; error?: string export function searchTemplates(keyword: string): McpTemplate[] { try { const db = getDb(); + if (!db) return []; const searchPattern = `%${keyword}%`; const rows = db.prepare(` SELECT * FROM mcp_templates @@ -248,6 +255,7 @@ export function searchTemplates(keyword: string): McpTemplate[] { export function getAllCategories(): string[] { try { const db = getDb(); + if (!db) return []; const rows = db.prepare('SELECT DISTINCT category FROM mcp_templates WHERE category IS NOT NULL ORDER BY category').all(); return rows.map((row: any) => row.category); } catch (error: unknown) { diff --git a/ccw/src/templates/dashboard-js/views/cli-manager.js b/ccw/src/templates/dashboard-js/views/cli-manager.js index d87f0d05..c6bd21e1 100644 --- a/ccw/src/templates/dashboard-js/views/cli-manager.js +++ b/ccw/src/templates/dashboard-js/views/cli-manager.js @@ -1734,7 +1734,7 @@ async function toggleChineseResponse(enabled, target) { } try { - var response = await fetch('/api/language/chinese-response', { + var response = await csrfFetch('/api/language/chinese-response', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled, target: target }) @@ -1799,7 +1799,7 @@ async function toggleWindowsPlatform(enabled) { windowsPlatformLoading = true; try { - var response = await fetch('/api/language/windows-platform', { + var response = await csrfFetch('/api/language/windows-platform', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled }) @@ -1848,7 +1848,7 @@ async function toggleCodexCliEnhancement(enabled) { codexCliEnhancementLoading = true; try { - var response = await fetch('/api/language/codex-cli-enhancement', { + var response = await csrfFetch('/api/language/codex-cli-enhancement', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: enabled, action: 'toggle' }) @@ -1888,7 +1888,7 @@ async function refreshCodexCliEnhancement() { codexCliEnhancementLoading = true; try { - var response = await fetch('/api/language/codex-cli-enhancement', { + var response = await csrfFetch('/api/language/codex-cli-enhancement', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'refresh' }) diff --git a/ccw/src/utils/db-loader.ts b/ccw/src/utils/db-loader.ts new file mode 100644 index 00000000..489eae63 --- /dev/null +++ b/ccw/src/utils/db-loader.ts @@ -0,0 +1,54 @@ +/** + * Database Loader - Centralized better-sqlite3 loading with native module error handling + * Catches NODE_MODULE_VERSION mismatch errors and provides actionable fix instructions + */ + +let warningShown = false; + +function showNativeModuleWarning(error: Error): void { + if (warningShown) return; + warningShown = true; + + const isVersionMismatch = error.message?.includes('NODE_MODULE_VERSION') || + (error as any).code === 'ERR_DLOPEN_FAILED'; + + if (isVersionMismatch) { + console.error( + '\n[CCW] better-sqlite3 native module version mismatch.\n' + + ' The module was compiled for a different Node.js version.\n' + + ' Fix: run one of the following commands:\n' + + ' npm rebuild better-sqlite3\n' + + ' npm install better-sqlite3 --build-from-source\n' + ); + } +} + +/** + * Load better-sqlite3 Database constructor with error handling. + * Returns the Database class or null if loading fails. + */ +export function loadDatabase(): typeof import('better-sqlite3') | null { + try { + // Use dynamic import via require for native module + const Database = require('better-sqlite3'); + return Database; + } catch (error: any) { + showNativeModuleWarning(error); + return null; + } +} + +/** + * Create a database instance with error handling. + * Returns the database instance or null if creation fails. + */ +export function createDatabase(dbPath: string, options?: any): any | null { + const Database = loadDatabase(); + if (!Database) return null; + try { + return new Database(dbPath, options); + } catch (error: any) { + showNativeModuleWarning(error); + return null; + } +} diff --git a/ccw/tests/cli-manager-language-csrf.test.js b/ccw/tests/cli-manager-language-csrf.test.js new file mode 100644 index 00000000..f3d413a5 --- /dev/null +++ b/ccw/tests/cli-manager-language-csrf.test.js @@ -0,0 +1,37 @@ +/** + * Regression test: language settings toggles must use csrfFetch() + * (otherwise /api/language/* POSTs will fail with 403 CSRF validation failed). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +describe('cli-manager language settings (CSRF)', () => { + const source = readFileSync( + new URL('../src/templates/dashboard-js/views/cli-manager.js', import.meta.url), + 'utf8' + ); + + it('uses csrfFetch() for /api/language/* POST requests', () => { + assert.match(source, /await csrfFetch\('\/api\/language\/chinese-response',\s*\{/); + assert.match(source, /await csrfFetch\('\/api\/language\/windows-platform',\s*\{/); + assert.match(source, /await csrfFetch\('\/api\/language\/codex-cli-enhancement',\s*\{/); + }); + + it('does not use bare fetch() for /api/language/* POST requests', () => { + assert.doesNotMatch( + source, + /await fetch\('\/api\/language\/chinese-response',\s*\{[\s\S]*?method:\s*'POST'/ + ); + assert.doesNotMatch( + source, + /await fetch\('\/api\/language\/windows-platform',\s*\{[\s\S]*?method:\s*'POST'/ + ); + assert.doesNotMatch( + source, + /await fetch\('\/api\/language\/codex-cli-enhancement',\s*\{[\s\S]*?method:\s*'POST'/ + ); + }); +}); + diff --git a/package.json b/package.json index e85e85b6..416dcc55 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "docs:build": "npm run build --workspace=ccw/docs-site", "ws:install": "npm install", "ws:all": "concurrently \"npm run frontend\" \"npm run docs\" --names \"FRONTEND,DOCS\" --prefix-colors \"blue,green\"", - "ws:build-all": "npm run build && npm run frontend:build && npm run docs:build" + "ws:build-all": "npm run build && npm run frontend:build && npm run docs:build", + "postinstall": "npm rebuild better-sqlite3 || echo [CCW] better-sqlite3 rebuild skipped" }, "keywords": [ "claude",