mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +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
|
## 🔧 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
|
### "No active session found" error
|
||||||
|
|
||||||
**Cause**: No workflow session is currently active.
|
**Cause**: No workflow session is currently active.
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ npm install -g claude-code-workflow
|
|||||||
ccw install -m Global
|
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
|
### Choose Your Workflow Level
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
import { Checkbox } from '@/components/ui/Checkbox';
|
import { Checkbox } from '@/components/ui/Checkbox';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { useMcpServers } from '@/hooks';
|
import { useMcpServers } from '@/hooks';
|
||||||
import { crossCliCopy } from '@/lib/api';
|
import { crossCliCopy, fetchCodexMcpServers } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -69,13 +70,11 @@ export function CrossCliCopyButton({
|
|||||||
const [serverItems, setServerItems] = useState<ServerCheckboxItem[]>([]);
|
const [serverItems, setServerItems] = useState<ServerCheckboxItem[]>([]);
|
||||||
|
|
||||||
const { servers } = useMcpServers();
|
const { servers } = useMcpServers();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
const [isCopying, setIsCopying] = useState(false);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
|
|
||||||
// Initialize server items when dialog opens
|
const loadServerItems = async (nextDirection: CopyDirection) => {
|
||||||
const handleOpenChange = (open: boolean) => {
|
if (nextDirection === 'claude-to-codex') {
|
||||||
setIsOpen(open);
|
|
||||||
if (open) {
|
|
||||||
setDirection(currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude');
|
|
||||||
setServerItems(
|
setServerItems(
|
||||||
servers.map((s) => ({
|
servers.map((s) => ({
|
||||||
name: s.name,
|
name: s.name,
|
||||||
@@ -84,6 +83,34 @@ export function CrossCliCopyButton({
|
|||||||
selected: false,
|
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
|
// Toggle direction
|
||||||
const handleToggleDirection = () => {
|
const handleToggleDirection = () => {
|
||||||
setDirection((prev) =>
|
const next = direction === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex';
|
||||||
prev === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex'
|
setDirection(next);
|
||||||
);
|
void loadServerItems(next);
|
||||||
setServerItems((prev) => prev.map((item) => ({ ...item, selected: false })));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle server selection
|
// Toggle server selection
|
||||||
@@ -124,10 +150,15 @@ export function CrossCliCopyButton({
|
|||||||
|
|
||||||
setIsCopying(true);
|
setIsCopying(true);
|
||||||
try {
|
try {
|
||||||
|
if (targetCli === 'claude' && !projectPath) {
|
||||||
|
throw new Error('Project path is required to copy servers into Claude project');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await crossCliCopy({
|
const result = await crossCliCopy({
|
||||||
source: sourceCli,
|
source: sourceCli,
|
||||||
target: targetCli,
|
target: targetCli,
|
||||||
serverNames: selectedServers,
|
serverNames: selectedServers,
|
||||||
|
projectPath: projectPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ import {
|
|||||||
updateMcpServer,
|
updateMcpServer,
|
||||||
fetchMcpServers,
|
fetchMcpServers,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
|
type McpProjectConfigType,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
|
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle';
|
import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -73,6 +75,7 @@ export function McpServerDialog({
|
|||||||
}: McpServerDialogProps) {
|
}: McpServerDialogProps) {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
// Fetch templates from backend
|
// Fetch templates from backend
|
||||||
const { templates, isLoading: templatesLoading } = useMcpTemplates();
|
const { templates, isLoading: templatesLoading } = useMcpTemplates();
|
||||||
@@ -92,6 +95,7 @@ export function McpServerDialog({
|
|||||||
const [argsInput, setArgsInput] = useState('');
|
const [argsInput, setArgsInput] = useState('');
|
||||||
const [envInput, setEnvInput] = useState('');
|
const [envInput, setEnvInput] = useState('');
|
||||||
const [configType, setConfigType] = useState<McpConfigType>('mcp-json');
|
const [configType, setConfigType] = useState<McpConfigType>('mcp-json');
|
||||||
|
const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp';
|
||||||
|
|
||||||
// Initialize form from server prop (edit mode)
|
// Initialize form from server prop (edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -129,7 +133,8 @@ export function McpServerDialog({
|
|||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: Omit<McpServer, 'name'>) => createMcpServer(data),
|
mutationFn: ({ server, configType }: { server: McpServer; configType?: McpProjectConfigType }) =>
|
||||||
|
createMcpServer(server, { projectPath: projectPath ?? undefined, configType }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -138,8 +143,8 @@ export function McpServerDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ serverName, config }: { serverName: string; config: Partial<McpServer> }) =>
|
mutationFn: ({ serverName, config, configType }: { serverName: string; config: Partial<McpServer>; configType?: McpProjectConfigType }) =>
|
||||||
updateMcpServer(serverName, config),
|
updateMcpServer(serverName, config, { projectPath: projectPath ?? undefined, configType }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -234,7 +239,7 @@ export function McpServerDialog({
|
|||||||
|
|
||||||
const checkNameExists = async (name: string): Promise<boolean> => {
|
const checkNameExists = async (name: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchMcpServers();
|
const data = await fetchMcpServers(projectPath ?? undefined);
|
||||||
const allServers = [...data.project, ...data.global];
|
const allServers = [...data.project, ...data.global];
|
||||||
// In edit mode, exclude current server
|
// In edit mode, exclude current server
|
||||||
return allServers.some(
|
return allServers.some(
|
||||||
@@ -258,11 +263,15 @@ export function McpServerDialog({
|
|||||||
|
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
command: formData.command,
|
server: {
|
||||||
args: formData.args,
|
name: formData.name,
|
||||||
env: formData.env,
|
command: formData.command,
|
||||||
scope: formData.scope,
|
args: formData.args,
|
||||||
enabled: formData.enabled,
|
env: formData.env,
|
||||||
|
scope: formData.scope,
|
||||||
|
enabled: formData.enabled,
|
||||||
|
},
|
||||||
|
configType: formData.scope === 'project' ? projectConfigType : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
@@ -274,6 +283,7 @@ export function McpServerDialog({
|
|||||||
scope: formData.scope,
|
scope: formData.scope,
|
||||||
enabled: formData.enabled,
|
enabled: formData.enabled,
|
||||||
},
|
},
|
||||||
|
configType: formData.scope === 'project' ? projectConfigType : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -441,6 +451,7 @@ export function McpServerDialog({
|
|||||||
checked={formData.scope === 'project'}
|
checked={formData.scope === 'project'}
|
||||||
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{formatMessage({ id: 'mcp.scope.project' })}
|
{formatMessage({ id: 'mcp.scope.project' })}
|
||||||
@@ -454,6 +465,7 @@ export function McpServerDialog({
|
|||||||
checked={formData.scope === 'global'}
|
checked={formData.scope === 'global'}
|
||||||
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
onChange={(e) => handleFieldChange('scope', e.target.value as 'project' | 'global')}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{formatMessage({ id: 'mcp.scope.global' })}
|
{formatMessage({ id: 'mcp.scope.global' })}
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ export function OtherProjectsSection({
|
|||||||
|
|
||||||
for (const [path, serverList] of Object.entries(response.servers)) {
|
for (const [path, serverList] of Object.entries(response.servers)) {
|
||||||
const projectName = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
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({
|
servers.push({
|
||||||
...server,
|
...server,
|
||||||
|
scope: 'project',
|
||||||
projectPath: path,
|
projectPath: path,
|
||||||
projectName,
|
projectName,
|
||||||
});
|
});
|
||||||
@@ -88,6 +89,7 @@ export function OtherProjectsSection({
|
|||||||
const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-');
|
const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
await createServer({
|
await createServer({
|
||||||
|
name: uniqueName,
|
||||||
command: server.command,
|
command: server.command,
|
||||||
args: server.args,
|
args: server.args,
|
||||||
env: server.env,
|
env: server.env,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { mcpServersKeys } from '@/hooks';
|
import { mcpServersKeys } from '@/hooks';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||||
|
|
||||||
// Icon map for MCP definitions
|
// Icon map for MCP definitions
|
||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@@ -96,6 +97,7 @@ export function RecommendedMcpWizard({
|
|||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success: showSuccess, error: showError } = useNotifications();
|
const { success: showSuccess, error: showError } = useNotifications();
|
||||||
|
const projectPath = useWorkflowStore(selectProjectPath);
|
||||||
|
|
||||||
// State for field values
|
// State for field values
|
||||||
const [fieldValues, setFieldValues] = useState<Record<string, any>>({});
|
const [fieldValues, setFieldValues] = useState<Record<string, any>>({});
|
||||||
@@ -138,7 +140,10 @@ export function RecommendedMcpWizard({
|
|||||||
if (selectedScope === 'global') {
|
if (selectedScope === 'global') {
|
||||||
return addGlobalMcpServer(mcpDefinition.id, serverConfig);
|
return addGlobalMcpServer(mcpDefinition.id, serverConfig);
|
||||||
} else {
|
} 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) => {
|
onSuccess: (result) => {
|
||||||
|
|||||||
@@ -524,8 +524,18 @@ export function useProjectOperations(): UseProjectOperationsReturn {
|
|||||||
isLoading: projectsQuery.isLoading,
|
isLoading: projectsQuery.isLoading,
|
||||||
error: projectsQuery.error,
|
error: projectsQuery.error,
|
||||||
refetch,
|
refetch,
|
||||||
copyToCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'claude', target: 'codex' }),
|
copyToCodex: (request) => copyMutation.mutateAsync({
|
||||||
copyFromCodex: (request) => copyMutation.mutateAsync({ ...request, source: 'codex', target: 'claude' }),
|
...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,
|
isCopying: copyMutation.isPending,
|
||||||
fetchOtherServers,
|
fetchOtherServers,
|
||||||
isFetchingServers: serversQuery.isFetching,
|
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;
|
onToggleExpand: () => void;
|
||||||
onToggle: (serverName: string, enabled: boolean) => void;
|
onToggle: (serverName: string, enabled: boolean) => void;
|
||||||
onEdit: (server: McpServer) => void;
|
onEdit: (server: McpServer) => void;
|
||||||
onDelete: (serverName: string) => void;
|
onDelete: (server: McpServer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete }: McpServerCardProps) {
|
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"
|
className="h-8 w-8 p-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(server.name);
|
onDelete(server);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-destructive" />
|
<Trash2 className="w-4 h-4 text-destructive" />
|
||||||
@@ -269,9 +269,9 @@ export function McpManagerPage() {
|
|||||||
toggleServer(serverName, enabled);
|
toggleServer(serverName, enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (serverName: string) => {
|
const handleDelete = (server: McpServer) => {
|
||||||
if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: serverName }))) {
|
if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: server.name }))) {
|
||||||
deleteServer(serverName);
|
deleteServer(server.name, server.scope);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* MCP Templates Database Module
|
* MCP Templates Database Module
|
||||||
* Stores MCP server configurations as reusable templates
|
* Stores MCP server configurations as reusable templates
|
||||||
*/
|
*/
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { existsSync, mkdirSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { StoragePaths, ensureStorageDir } from '../../config/storage-paths.js';
|
import { StoragePaths, ensureStorageDir } from '../../config/storage-paths.js';
|
||||||
|
import { createDatabase } from '../../utils/db-loader.js';
|
||||||
|
|
||||||
// Database path - uses centralized storage
|
// Database path - uses centralized storage
|
||||||
const DB_DIR = StoragePaths.global.databases();
|
const DB_DIR = StoragePaths.global.databases();
|
||||||
@@ -16,14 +16,15 @@ const DB_PATH = StoragePaths.global.mcpTemplates();
|
|||||||
ensureStorageDir(DB_DIR);
|
ensureStorageDir(DB_DIR);
|
||||||
|
|
||||||
// Initialize database connection
|
// Initialize database connection
|
||||||
let db: Database.Database | null = null;
|
let db: any | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create database connection
|
* Get or create database connection
|
||||||
*/
|
*/
|
||||||
function getDb(): Database.Database {
|
function getDb(): any {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
db = new Database(DB_PATH);
|
db = createDatabase(DB_PATH);
|
||||||
|
if (!db) return null;
|
||||||
initDatabase();
|
initDatabase();
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
@@ -33,7 +34,7 @@ function getDb(): Database.Database {
|
|||||||
* Initialize database schema
|
* Initialize database schema
|
||||||
*/
|
*/
|
||||||
function initDatabase() {
|
function initDatabase() {
|
||||||
const db = getDb();
|
if (!db) return;
|
||||||
|
|
||||||
// Create templates table
|
// Create templates table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -83,6 +84,7 @@ export interface McpTemplate {
|
|||||||
export function saveTemplate(template: McpTemplate): { success: boolean; id?: number; error?: string } {
|
export function saveTemplate(template: McpTemplate): { success: boolean; id?: number; error?: string } {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (!db) return { success: false, error: 'Database unavailable (native module issue)' };
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
@@ -125,6 +127,7 @@ export function saveTemplate(template: McpTemplate): { success: boolean; id?: nu
|
|||||||
export function getAllTemplates(): McpTemplate[] {
|
export function getAllTemplates(): McpTemplate[] {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
const rows = db.prepare('SELECT * FROM mcp_templates ORDER BY name').all();
|
const rows = db.prepare('SELECT * FROM mcp_templates ORDER BY name').all();
|
||||||
|
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
@@ -149,6 +152,7 @@ export function getAllTemplates(): McpTemplate[] {
|
|||||||
export function getTemplateByName(name: string): McpTemplate | null {
|
export function getTemplateByName(name: string): McpTemplate | null {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (!db) return null;
|
||||||
const row = db.prepare('SELECT * FROM mcp_templates WHERE name = ?').get(name);
|
const row = db.prepare('SELECT * FROM mcp_templates WHERE name = ?').get(name);
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -175,6 +179,7 @@ export function getTemplateByName(name: string): McpTemplate | null {
|
|||||||
export function getTemplatesByCategory(category: string): McpTemplate[] {
|
export function getTemplatesByCategory(category: string): McpTemplate[] {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
const rows = db.prepare('SELECT * FROM mcp_templates WHERE category = ? ORDER BY name').all(category);
|
const rows = db.prepare('SELECT * FROM mcp_templates WHERE category = ? ORDER BY name').all(category);
|
||||||
|
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
@@ -199,6 +204,7 @@ export function getTemplatesByCategory(category: string): McpTemplate[] {
|
|||||||
export function deleteTemplate(name: string): { success: boolean; error?: string } {
|
export function deleteTemplate(name: string): { success: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
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);
|
const result = db.prepare('DELETE FROM mcp_templates WHERE name = ?').run(name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -219,6 +225,7 @@ export function deleteTemplate(name: string): { success: boolean; error?: string
|
|||||||
export function searchTemplates(keyword: string): McpTemplate[] {
|
export function searchTemplates(keyword: string): McpTemplate[] {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
if (!db) return [];
|
||||||
const searchPattern = `%${keyword}%`;
|
const searchPattern = `%${keyword}%`;
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT * FROM mcp_templates
|
SELECT * FROM mcp_templates
|
||||||
@@ -248,6 +255,7 @@ export function searchTemplates(keyword: string): McpTemplate[] {
|
|||||||
export function getAllCategories(): string[] {
|
export function getAllCategories(): string[] {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
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();
|
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);
|
return rows.map((row: any) => row.category);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -1734,7 +1734,7 @@ async function toggleChineseResponse(enabled, target) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var response = await fetch('/api/language/chinese-response', {
|
var response = await csrfFetch('/api/language/chinese-response', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled: enabled, target: target })
|
body: JSON.stringify({ enabled: enabled, target: target })
|
||||||
@@ -1799,7 +1799,7 @@ async function toggleWindowsPlatform(enabled) {
|
|||||||
windowsPlatformLoading = true;
|
windowsPlatformLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var response = await fetch('/api/language/windows-platform', {
|
var response = await csrfFetch('/api/language/windows-platform', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled: enabled })
|
body: JSON.stringify({ enabled: enabled })
|
||||||
@@ -1848,7 +1848,7 @@ async function toggleCodexCliEnhancement(enabled) {
|
|||||||
codexCliEnhancementLoading = true;
|
codexCliEnhancementLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var response = await fetch('/api/language/codex-cli-enhancement', {
|
var response = await csrfFetch('/api/language/codex-cli-enhancement', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled: enabled, action: 'toggle' })
|
body: JSON.stringify({ enabled: enabled, action: 'toggle' })
|
||||||
@@ -1888,7 +1888,7 @@ async function refreshCodexCliEnhancement() {
|
|||||||
codexCliEnhancementLoading = true;
|
codexCliEnhancementLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var response = await fetch('/api/language/codex-cli-enhancement', {
|
var response = await csrfFetch('/api/language/codex-cli-enhancement', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'refresh' })
|
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",
|
"docs:build": "npm run build --workspace=ccw/docs-site",
|
||||||
"ws:install": "npm install",
|
"ws:install": "npm install",
|
||||||
"ws:all": "concurrently \"npm run frontend\" \"npm run docs\" --names \"FRONTEND,DOCS\" --prefix-colors \"blue,green\"",
|
"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": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
Reference in New Issue
Block a user