feat: 添加更好的 SQLite3 模块加载和错误处理,更新相关组件以支持项目路径

This commit is contained in:
catlog22
2026-02-06 23:07:56 +08:00
parent 5cdbb43b3b
commit 3d862e6ed8
15 changed files with 541 additions and 38 deletions

22
.claude/settings.json Normal file
View 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
View File

@@ -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.

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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' })}

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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,

View 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);
});
});

View File

@@ -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);
}
};

View File

@@ -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) {

View File

@@ -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' })

View 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;
}
}

View 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'/
);
});
});

View File

@@ -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",