d48cda4081
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
219 lines
6.9 KiB
JavaScript
219 lines
6.9 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { writeInstallState } = require('../install-state');
|
|
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
|
|
const { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks } = require('./link-rewrite');
|
|
|
|
function isMarkdownPath(filePath) {
|
|
return /\.(md|mdx|markdown)$/i.test(String(filePath || ''));
|
|
}
|
|
|
|
// Map every copy-file operation to { sourceRel, destRel } so relative links in
|
|
// namespaced markdown can be rewritten to the file's actual installed location
|
|
// (issue #2340). Returns null when the plan lacks the data needed to do so.
|
|
function buildLinkIndexForPlan(plan) {
|
|
if (!plan || !plan.targetRoot || !Array.isArray(plan.operations)) {
|
|
return null;
|
|
}
|
|
const mappings = [];
|
|
for (const operation of plan.operations) {
|
|
if (operation.kind === 'copy-file' && operation.sourceRelativePath) {
|
|
mappings.push({
|
|
sourceRel: operation.sourceRelativePath,
|
|
destRel: path.relative(plan.targetRoot, operation.destinationPath),
|
|
});
|
|
}
|
|
}
|
|
return buildInstallIndex(mappings);
|
|
}
|
|
|
|
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' && plan.adapter.target !== 'claude-project')) {
|
|
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);
|
|
const linkIndex = buildLinkIndexForPlan(plan);
|
|
|
|
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;
|
|
}
|
|
|
|
// Namespaced markdown (e.g. skills/<id> -> skills/ecc/<id>) needs its
|
|
// relative cross-directory links rewritten so they resolve after install
|
|
// (issue #2340). Files whose install path is unchanged (no namespace
|
|
// injected) and all non-markdown files stay on the byte-for-byte copy path.
|
|
if (
|
|
linkIndex
|
|
&& operation.kind === 'copy-file'
|
|
&& operation.sourceRelativePath
|
|
&& isMarkdownPath(operation.destinationPath)
|
|
&& isNamespacedSource(operation.sourceRelativePath, linkIndex)
|
|
) {
|
|
const rewritten = rewriteRelativeLinks(
|
|
fs.readFileSync(operation.sourcePath, 'utf8'),
|
|
{ sourceRel: operation.sourceRelativePath, index: linkIndex }
|
|
);
|
|
fs.writeFileSync(operation.destinationPath, rewritten, '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,
|
|
};
|