mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* fix(install): rewrite relative skill links for injected ecc namespace Skill and rule markdown is byte-copied during a claude install, but the home/project adapters inject an `ecc/` namespace segment (skills/<id> -> skills/ecc/<id>, rules/<x> -> rules/ecc/<x>). Source-relative links such as `../../rules/react/hooks.md` therefore broke after install: the extra level changed what `../..` resolved to, and the link target itself moved under rules/ecc/. Rewrite relative links in namespaced markdown so they resolve to the file's actual installed location, derived from the plan's own file placements (no hardcoded namespace literal, so the links cannot drift from where files land). Non-namespacing adapters and links to non-installed targets are left untouched; URLs, anchors, absolute paths, and fenced code blocks are never rewritten. Fixes #2340 * fix(install): keep non-namespaced markdown on the byte-for-byte copy path Address review feedback: the markdown branch in applyInstallPlan diverted every copy-file markdown operation through read+rewrite+write, so identity-mapped markdown (source path == install path, no namespace injected) lost byte-for-byte content and source mode bits even though no link rewrite was needed. Gate the rewrite on isNamespacedSource() so only files whose install path actually changed (e.g. skills/x -> skills/ecc/x) leave the copyFileSync path; everything else is copied verbatim as before. * test(install): emit failure stack in the link-rewrite test runner Address review feedback: the local test() harness logged only error.message, so a failing assertion lost its source line and diff. Print error.stack on stderr on failure so broken rewrite cases stay diagnosable.
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,
|
|
};
|