mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-08 02:14:08 +08:00
feat: 添加更好的 SQLite3 模块加载和错误处理,更新相关组件以支持项目路径
This commit is contained in:
22
.claude/settings.json
Normal file
22
.claude/settings.json
Normal file
@@ -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\\\"})}\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
FAQ.md
26
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -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<ServerCheckboxItem[]>([]);
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<McpConfigType>('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<McpServer, 'name'>) => 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<McpServer> }) =>
|
||||
updateMcpServer(serverName, config),
|
||||
mutationFn: ({ serverName, config, configType }: { serverName: string; config: Partial<McpServer>; 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<boolean> => {
|
||||
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'}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{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'}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{formatMessage({ id: 'mcp.scope.global' })}
|
||||
|
||||
@@ -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<McpServer, 'scope'>[])) {
|
||||
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,
|
||||
|
||||
@@ -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<string, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -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<Record<string, any>>({});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
293
ccw/frontend/src/lib/api.mcp.test.ts
Normal file
293
ccw/frontend/src/lib/api.mcp.test.ts
Normal file
@@ -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<typeof vi.fn>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
54
ccw/src/utils/db-loader.ts
Normal file
54
ccw/src/utils/db-loader.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
37
ccw/tests/cli-manager-language-csrf.test.js
Normal file
37
ccw/tests/cli-manager-language-csrf.test.js
Normal file
@@ -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'/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user