From af05874510c9ef0351e8efebddd15541ce4837a5 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Wed, 28 Jan 2026 00:50:24 +0800 Subject: [PATCH] feat(skills): enhance moveDirectory function with rollback on failure and update config handling --- ccw/src/core/routes/mcp-routes.ts.backup | 549 ----------------------- ccw/src/core/routes/skills-routes.ts | 56 ++- 2 files changed, 43 insertions(+), 562 deletions(-) delete mode 100644 ccw/src/core/routes/mcp-routes.ts.backup diff --git a/ccw/src/core/routes/mcp-routes.ts.backup b/ccw/src/core/routes/mcp-routes.ts.backup deleted file mode 100644 index efb88da0..00000000 --- a/ccw/src/core/routes/mcp-routes.ts.backup +++ /dev/null @@ -1,549 +0,0 @@ -/** - * MCP Routes Module - * Handles all MCP-related API endpoints - */ -import type { IncomingMessage, ServerResponse } from 'http'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { homedir } from 'os'; - -// Claude config file path -const CLAUDE_CONFIG_PATH = join(homedir(), '.claude.json'); - -export interface RouteContext { - pathname: string; - url: URL; - req: IncomingMessage; - res: ServerResponse; - initialPath: string; - handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise) => void; - broadcastToClients: (data: unknown) => void; -} - -// ======================================== -// Helper Functions -// ======================================== - -/** - * Get enterprise managed MCP path (platform-specific) - */ -function getEnterpriseMcpPath(): string { - const platform = process.platform; - if (platform === 'darwin') { - return '/Library/Application Support/ClaudeCode/managed-mcp.json'; - } else if (platform === 'win32') { - return 'C:\\Program Files\\ClaudeCode\\managed-mcp.json'; - } else { - // Linux and WSL - return '/etc/claude-code/managed-mcp.json'; - } -} - -/** - * Safely read and parse JSON file - */ -function safeReadJson(filePath) { - try { - if (!existsSync(filePath)) return null; - const content = readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch { - return null; - } -} - -/** - * Get MCP servers from a JSON file (expects mcpServers key at top level) - * @param {string} filePath - * @returns {Object} mcpServers object or empty object - */ -function getMcpServersFromFile(filePath) { - const config = safeReadJson(filePath); - if (!config) return {}; - return config.mcpServers || {}; -} - -/** - * Get MCP configuration from multiple sources (per official Claude Code docs): - * - * Priority (highest to lowest): - * 1. Enterprise managed-mcp.json (cannot be overridden) - * 2. Local scope (project-specific private in ~/.claude.json) - * 3. Project scope (.mcp.json in project root) - * 4. User scope (mcpServers in ~/.claude.json) - * - * Note: ~/.claude/settings.json is for MCP PERMISSIONS, NOT definitions! - * - * @returns {Object} - */ -function getMcpConfig() { - try { - const result = { - projects: {}, - userServers: {}, // User-level servers from ~/.claude.json mcpServers - enterpriseServers: {}, // Enterprise managed servers (highest priority) - configSources: [] // Track where configs came from for debugging - }; - - // 1. Read Enterprise managed MCP servers (highest priority) - const enterprisePath = getEnterpriseMcpPath(); - if (existsSync(enterprisePath)) { - const enterpriseConfig = safeReadJson(enterprisePath); - if (enterpriseConfig?.mcpServers) { - result.enterpriseServers = enterpriseConfig.mcpServers; - result.configSources.push({ type: 'enterprise', path: enterprisePath, count: Object.keys(enterpriseConfig.mcpServers).length }); - } - } - - // 2. Read from ~/.claude.json - if (existsSync(CLAUDE_CONFIG_PATH)) { - const claudeConfig = safeReadJson(CLAUDE_CONFIG_PATH); - if (claudeConfig) { - // 2a. User-level mcpServers (top-level mcpServers key) - if (claudeConfig.mcpServers) { - result.userServers = claudeConfig.mcpServers; - result.configSources.push({ type: 'user', path: CLAUDE_CONFIG_PATH, count: Object.keys(claudeConfig.mcpServers).length }); - } - - // 2b. Project-specific configurations (projects[path].mcpServers) - if (claudeConfig.projects) { - result.projects = claudeConfig.projects; - } - } - } - - // 3. For each known project, check for .mcp.json (project-level config) - const projectPaths = Object.keys(result.projects); - for (const projectPath of projectPaths) { - const mcpJsonPath = join(projectPath, '.mcp.json'); - if (existsSync(mcpJsonPath)) { - const mcpJsonConfig = safeReadJson(mcpJsonPath); - if (mcpJsonConfig?.mcpServers) { - // Merge .mcp.json servers into project config - // Project's .mcp.json has lower priority than ~/.claude.json projects[path].mcpServers - const existingServers = result.projects[projectPath]?.mcpServers || {}; - result.projects[projectPath] = { - ...result.projects[projectPath], - mcpServers: { - ...mcpJsonConfig.mcpServers, // .mcp.json (lower priority) - ...existingServers // ~/.claude.json projects[path] (higher priority) - }, - mcpJsonPath: mcpJsonPath // Track source for debugging - }; - result.configSources.push({ type: 'project-mcp-json', path: mcpJsonPath, count: Object.keys(mcpJsonConfig.mcpServers).length }); - } - } - } - - // Build globalServers by merging user and enterprise servers - // Enterprise servers override user servers - result.globalServers = { - ...result.userServers, - ...result.enterpriseServers - }; - - return result; - } catch (error: unknown) { - console.error('Error reading MCP config:', error); - return { projects: {}, globalServers: {}, userServers: {}, enterpriseServers: {}, configSources: [], error: (error as Error).message }; - } -} - -/** - * Normalize project path for .claude.json (Windows backslash format) - * @param {string} path - * @returns {string} - */ -function normalizeProjectPathForConfig(path) { - // Convert forward slashes to backslashes for Windows .claude.json format - let normalized = path.replace(/\//g, '\\'); - - // Handle /d/path format -> D:\path - if (normalized.match(/^\\[a-zA-Z]\\/)) { - normalized = normalized.charAt(1).toUpperCase() + ':' + normalized.slice(2); - } - - return normalized; -} - -/** - * Toggle MCP server enabled/disabled - * @param {string} projectPath - * @param {string} serverName - * @param {boolean} enable - * @returns {Object} - */ -function toggleMcpServerEnabled(projectPath, serverName, enable) { - try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { error: '.claude.json not found' }; - } - - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - - const normalizedPath = normalizeProjectPathForConfig(projectPath); - - if (!config.projects || !config.projects[normalizedPath]) { - return { error: `Project not found: ${normalizedPath}` }; - } - - const projectConfig = config.projects[normalizedPath]; - - // Ensure disabledMcpServers array exists - if (!projectConfig.disabledMcpServers) { - projectConfig.disabledMcpServers = []; - } - - if (enable) { - // Remove from disabled list - projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); - } else { - // Add to disabled list if not already there - if (!projectConfig.disabledMcpServers.includes(serverName)) { - projectConfig.disabledMcpServers.push(serverName); - } - } - - // Write back to file - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - return { - success: true, - serverName, - enabled: enable, - disabledMcpServers: projectConfig.disabledMcpServers - }; - } catch (error: unknown) { - console.error('Error toggling MCP server:', error); - return { error: (error as Error).message }; - } -} - -/** - * Add MCP server to project - * @param {string} projectPath - * @param {string} serverName - * @param {Object} serverConfig - * @returns {Object} - */ -function addMcpServerToProject(projectPath, serverName, serverConfig) { - try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { error: '.claude.json not found' }; - } - - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - - const normalizedPath = normalizeProjectPathForConfig(projectPath); - - // Create project entry if it doesn't exist - if (!config.projects) { - config.projects = {}; - } - - if (!config.projects[normalizedPath]) { - config.projects[normalizedPath] = { - allowedTools: [], - mcpContextUris: [], - mcpServers: {}, - enabledMcpjsonServers: [], - disabledMcpjsonServers: [], - hasTrustDialogAccepted: false, - projectOnboardingSeenCount: 0, - hasClaudeMdExternalIncludesApproved: false, - hasClaudeMdExternalIncludesWarningShown: false - }; - } - - const projectConfig = config.projects[normalizedPath]; - - // Ensure mcpServers exists - if (!projectConfig.mcpServers) { - projectConfig.mcpServers = {}; - } - - // Add the server - projectConfig.mcpServers[serverName] = serverConfig; - - // Write back to file - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - return { - success: true, - serverName, - serverConfig - }; - } catch (error: unknown) { - console.error('Error adding MCP server:', error); - return { error: (error as Error).message }; - } -} - -/** - * Remove MCP server from project - * @param {string} projectPath - * @param {string} serverName - * @returns {Object} - */ -function removeMcpServerFromProject(projectPath, serverName) { - try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { error: '.claude.json not found' }; - } - - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - - const normalizedPath = normalizeProjectPathForConfig(projectPath); - - if (!config.projects || !config.projects[normalizedPath]) { - return { error: `Project not found: ${normalizedPath}` }; - } - - const projectConfig = config.projects[normalizedPath]; - - if (!projectConfig.mcpServers || !projectConfig.mcpServers[serverName]) { - return { error: `Server not found: ${serverName}` }; - } - - // Remove the server - delete projectConfig.mcpServers[serverName]; - - // Also remove from disabled list if present - if (projectConfig.disabledMcpServers) { - projectConfig.disabledMcpServers = projectConfig.disabledMcpServers.filter(s => s !== serverName); - } - - // Write back to file - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - return { - success: true, - serverName, - removed: true - }; - } catch (error: unknown) { - console.error('Error removing MCP server:', error); - return { error: (error as Error).message }; - } -} - -/** - * Add MCP server to global/user scope (top-level mcpServers in ~/.claude.json) - * @param {string} serverName - * @param {Object} serverConfig - * @returns {Object} - */ -function addGlobalMcpServer(serverName, serverConfig) { - try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { error: '.claude.json not found' }; - } - - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - - // Ensure top-level mcpServers exists - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Add the server to top-level mcpServers - config.mcpServers[serverName] = serverConfig; - - // Write back to file - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - return { - success: true, - serverName, - serverConfig, - scope: 'global' - }; - } catch (error: unknown) { - console.error('Error adding global MCP server:', error); - return { error: (error as Error).message }; - } -} - -/** - * Remove MCP server from global/user scope (top-level mcpServers) - * @param {string} serverName - * @returns {Object} - */ -function removeGlobalMcpServer(serverName) { - try { - if (!existsSync(CLAUDE_CONFIG_PATH)) { - return { error: '.claude.json not found' }; - } - - const content = readFileSync(CLAUDE_CONFIG_PATH, 'utf8'); - const config = JSON.parse(content); - - if (!config.mcpServers || !config.mcpServers[serverName]) { - return { error: `Global server not found: ${serverName}` }; - } - - // Remove the server from top-level mcpServers - delete config.mcpServers[serverName]; - - // Write back to file - writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - - return { - success: true, - serverName, - removed: true, - scope: 'global' - }; - } catch (error: unknown) { - console.error('Error removing global MCP server:', error); - return { error: (error as Error).message }; - } -} - -/** - * Read settings file safely - * @param {string} filePath - * @returns {Object} - */ -function readSettingsFile(filePath) { - try { - if (!existsSync(filePath)) { - return {}; - } - const content = readFileSync(filePath, 'utf8'); - return JSON.parse(content); - } catch (error: unknown) { - console.error(`Error reading settings file ${filePath}:`, error); - return {}; - } -} - -/** - * Write settings file safely - * @param {string} filePath - * @param {Object} settings - */ -function writeSettingsFile(filePath, settings) { - const dirPath = dirname(filePath); - // Ensure directory exists - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }); - } - writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf8'); -} - -/** - * Get project settings path - * @param {string} projectPath - * @returns {string} - */ -function getProjectSettingsPath(projectPath) { - const normalizedPath = projectPath.replace(/\//g, '\\').replace(/^\\([a-zA-Z])\\/, '$1:\\'); - return join(normalizedPath, '.claude', 'settings.json'); -} - -// ======================================== -// Route Handlers -// ======================================== - -/** - * Handle MCP routes - * @returns true if route was handled, false otherwise - */ -export async function handleMcpRoutes(ctx: RouteContext): Promise { - const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx; - - // API: Get MCP configuration - if (pathname === '/api/mcp-config') { - const mcpData = getMcpConfig(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(mcpData)); - return true; - } - - // API: Toggle MCP server enabled/disabled - if (pathname === '/api/mcp-toggle' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { projectPath, serverName, enable } = body; - if (!projectPath || !serverName) { - return { error: 'projectPath and serverName are required', status: 400 }; - } - return toggleMcpServerEnabled(projectPath, serverName, enable); - }); - return true; - } - - // API: Copy MCP server to project - if (pathname === '/api/mcp-copy-server' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { projectPath, serverName, serverConfig } = body; - if (!projectPath || !serverName || !serverConfig) { - return { error: 'projectPath, serverName, and serverConfig are required', status: 400 }; - } - return addMcpServerToProject(projectPath, serverName, serverConfig); - }); - return true; - } - - // API: Install CCW MCP server to project - if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { projectPath } = body; - if (!projectPath) { - return { error: 'projectPath is required', status: 400 }; - } - - // Generate CCW MCP server config - const ccwMcpConfig = { - command: "ccw-mcp", - args: [] - }; - - // Use existing addMcpServerToProject to install CCW MCP - return addMcpServerToProject(projectPath, 'ccw-mcp', ccwMcpConfig); - }); - return true; - } - - // API: Remove MCP server from project - if (pathname === '/api/mcp-remove-server' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { projectPath, serverName } = body; - if (!projectPath || !serverName) { - return { error: 'projectPath and serverName are required', status: 400 }; - } - return removeMcpServerFromProject(projectPath, serverName); - }); - return true; - } - - // API: Add MCP server to global scope (top-level mcpServers in ~/.claude.json) - if (pathname === '/api/mcp-add-global-server' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { serverName, serverConfig } = body; - if (!serverName || !serverConfig) { - return { error: 'serverName and serverConfig are required', status: 400 }; - } - return addGlobalMcpServer(serverName, serverConfig); - }); - return true; - } - - // API: Remove MCP server from global scope - if (pathname === '/api/mcp-remove-global-server' && req.method === 'POST') { - handlePostRequest(req, res, async (body) => { - const { serverName } = body; - if (!serverName) { - return { error: 'serverName is required', status: 400 }; - } - return removeGlobalMcpServer(serverName); - }); - return true; - } - - return false; -} diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts index 429374f2..45055ad7 100644 --- a/ccw/src/core/routes/skills-routes.ts +++ b/ccw/src/core/routes/skills-routes.ts @@ -94,7 +94,7 @@ function saveDisabledSkillsConfig(location: SkillLocation, projectPath: string, } /** - * Move directory with fallback to copy-delete + * Move directory with fallback to copy-delete and rollback on failure */ function moveDirectory(source: string, target: string): void { try { @@ -105,7 +105,17 @@ function moveDirectory(source: string, target: string): void { // If rename fails (cross-filesystem, permission issues), fallback to copy-delete if (err.code === 'EXDEV' || err.code === 'EPERM' || err.code === 'EBUSY') { cpSync(source, target, { recursive: true, force: true }); - rmSync(source, { recursive: true, force: true }); + try { + rmSync(source, { recursive: true, force: true }); + } catch (rmError) { + // Rollback: remove the copied target directory to avoid duplicates + try { + rmSync(target, { recursive: true, force: true }); + } catch { + // Ignore rollback errors + } + throw new Error(`Failed to remove source directory after copy: ${(rmError as Error).message}`); + } } else { throw error; } @@ -161,13 +171,23 @@ async function disableSkill( // Move skill to disabled directory moveDirectory(sourceDir, targetDir); - // Update config - const config = loadDisabledSkillsConfig(location, projectPath); - config.skills[skillName] = { - disabledAt: new Date().toISOString(), - reason - }; - saveDisabledSkillsConfig(location, projectPath, config); + // Update config with rollback on failure + try { + const config = loadDisabledSkillsConfig(location, projectPath); + config.skills[skillName] = { + disabledAt: new Date().toISOString(), + reason + }; + saveDisabledSkillsConfig(location, projectPath, config); + } catch (configError) { + // Rollback: move the skill back to original location + try { + moveDirectory(targetDir, sourceDir); + } catch { + // Ignore rollback errors - skill is in disabled directory but not in config + } + throw new Error(`Failed to update config: ${(configError as Error).message}`); + } return { success: true, message: 'Skill disabled', skillName, location }; } catch (error) { @@ -223,10 +243,20 @@ async function enableSkill( // Move skill back to skills directory moveDirectory(sourceDir, targetDir); - // Update config - const config = loadDisabledSkillsConfig(location, projectPath); - delete config.skills[skillName]; - saveDisabledSkillsConfig(location, projectPath, config); + // Update config with rollback on failure + try { + const config = loadDisabledSkillsConfig(location, projectPath); + delete config.skills[skillName]; + saveDisabledSkillsConfig(location, projectPath, config); + } catch (configError) { + // Rollback: move the skill back to disabled directory + try { + moveDirectory(targetDir, sourceDir); + } catch { + // Ignore rollback errors - skill is in skills directory but still in config + } + throw new Error(`Failed to update config: ${(configError as Error).message}`); + } return { success: true, message: 'Skill enabled', skillName, location }; } catch (error) {