'use strict'; const fs = require('fs'); const path = require('path'); const { writeInstallState } = require('../install-state'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); function readJsonObject(filePath, label) { let parsed; try { parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (error) { throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`); } return parsed; } function cloneJsonValue(value) { if (value === undefined) { return undefined; } return JSON.parse(JSON.stringify(value)); } function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function deepMergeJson(baseValue, patchValue) { if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { return cloneJsonValue(patchValue); } const merged = { ...baseValue }; for (const [key, value] of Object.entries(patchValue)) { if (isPlainObject(value) && isPlainObject(merged[key])) { merged[key] = deepMergeJson(merged[key], value); } else { merged[key] = cloneJsonValue(value); } } return merged; } function formatJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } function replacePluginRootPlaceholders(value, pluginRoot) { if (!pluginRoot) { return value; } if (typeof value === 'string') { return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot); } if (Array.isArray(value)) { return value.map(item => replacePluginRootPlaceholders(item, pluginRoot)); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, nestedValue]) => [ key, replacePluginRootPlaceholders(nestedValue, pluginRoot), ]) ); } return value; } function findHooksSourcePath(plan, hooksDestinationPath) { const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath); return operation ? operation.sourcePath : null; } function isMcpConfigPath(filePath) { const basename = path.basename(String(filePath || '')); return basename === '.mcp.json' || basename === 'mcp.json'; } function buildResolvedClaudeHooks(plan) { if (!plan.adapter || plan.adapter.target !== 'claude') { return null; } const pluginRoot = plan.targetRoot; const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json'); const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath; if (!fs.existsSync(hooksSourcePath)) { return null; } const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config'); const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot); if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) { throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`); } return { hooksDestinationPath, resolvedHooksConfig: { ...hooksConfig, hooks: resolvedHooks, }, }; } function applyInstallPlan(plan) { const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan); const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS); for (const operation of plan.operations) { fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); if (operation.kind === 'merge-json') { const payload = cloneJsonValue(operation.mergePayload); if (payload === undefined) { throw new Error(`Missing merge payload for ${operation.destinationPath}`); } const filteredPayload = ( isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0 ) ? filterMcpConfig(payload, disabledServers).config : payload; const currentValue = fs.existsSync(operation.destinationPath) ? readJsonObject(operation.destinationPath, 'existing JSON config') : {}; const mergedValue = deepMergeJson(currentValue, filteredPayload); fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); continue; } if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) { const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); continue; } fs.copyFileSync(operation.sourcePath, operation.destinationPath); } if (resolvedClaudeHooksPlan) { fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true }); fs.writeFileSync( resolvedClaudeHooksPlan.hooksDestinationPath, JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n', 'utf8' ); } writeInstallState(plan.installStatePath, plan.statePreview); return { ...plan, applied: true, }; } module.exports = { applyInstallPlan, };