mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
Flatten managed Claude skill destinations, preserve user-owned conflicts, and migrate legacy nested installs through the lifecycle tooling.
416 lines
12 KiB
JavaScript
416 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { readInstallState } = require('../install-state');
|
|
const { assertWithinTrustedRoot } = require('../path-safety');
|
|
|
|
const CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
|
|
|
|
function pathExists(filePath) {
|
|
try {
|
|
fs.lstatSync(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizeSourceRelativePath(sourceRelativePath) {
|
|
const slashNormalized = String(sourceRelativePath || '').replace(/\\/g, '/');
|
|
const normalized = path.posix.normalize(slashNormalized).replace(/^\.\//, '');
|
|
if (
|
|
!normalized
|
|
|| normalized === '.'
|
|
|| normalized === '..'
|
|
|| normalized.startsWith('../')
|
|
|| path.posix.isAbsolute(normalized)
|
|
) {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function comparablePath(filePath) {
|
|
const resolvedPath = path.resolve(filePath);
|
|
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
|
}
|
|
|
|
function samePath(leftPath, rightPath) {
|
|
return comparablePath(leftPath) === comparablePath(rightPath);
|
|
}
|
|
|
|
function assertSafeSkillPath(targetPath, targetRoot, action) {
|
|
const resolvedRoot = path.resolve(targetRoot);
|
|
const resolvedTarget = path.resolve(targetPath);
|
|
const relativePath = path.relative(resolvedRoot, resolvedTarget);
|
|
if (
|
|
relativePath === ''
|
|
|| relativePath.startsWith('..')
|
|
|| path.isAbsolute(relativePath)
|
|
) {
|
|
throw new Error(
|
|
`Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
|
|
);
|
|
}
|
|
|
|
let currentPath = resolvedRoot;
|
|
for (const segment of relativePath.split(path.sep)) {
|
|
currentPath = path.join(currentPath, segment);
|
|
let stats;
|
|
try {
|
|
stats = fs.lstatSync(currentPath);
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') {
|
|
break;
|
|
}
|
|
throw error;
|
|
}
|
|
if (stats.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (pathExists(targetRoot)) {
|
|
assertWithinTrustedRoot(targetPath, targetRoot, action);
|
|
}
|
|
}
|
|
|
|
function describeClaudeSkillOperation(targetRoot, operation) {
|
|
if (!operation || operation.kind !== 'copy-file') {
|
|
return null;
|
|
}
|
|
|
|
const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
|
|
if (!sourceRelativePath) {
|
|
return null;
|
|
}
|
|
|
|
const sourceParts = sourceRelativePath.split('/');
|
|
if (sourceParts[0] !== 'skills' || sourceParts.length < 3 || !sourceParts[1]) {
|
|
return null;
|
|
}
|
|
|
|
const skillName = sourceParts[1];
|
|
const relativeParts = sourceParts.slice(2);
|
|
const flatSkillRoot = path.join(targetRoot, 'skills', skillName);
|
|
const legacySkillRoot = path.join(targetRoot, 'skills', 'ecc', skillName);
|
|
|
|
return {
|
|
sourceKey: sourceRelativePath,
|
|
skillName,
|
|
flatSkillRoot,
|
|
flatDestinationPath: path.join(flatSkillRoot, ...relativeParts),
|
|
legacySkillRoot,
|
|
legacyDestinationPath: path.join(legacySkillRoot, ...relativeParts),
|
|
};
|
|
}
|
|
|
|
function assertSafeClaudeSkillOperation(plan, operation) {
|
|
const target = plan && plan.adapter && plan.adapter.target;
|
|
if (!CLAUDE_TARGETS.has(target)) {
|
|
return;
|
|
}
|
|
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
|
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
|
return;
|
|
}
|
|
assertSafeSkillPath(
|
|
operation.destinationPath,
|
|
plan.targetRoot,
|
|
'install Claude skill'
|
|
);
|
|
}
|
|
|
|
function isManagedOperation(operation) {
|
|
return operation && operation.ownership === 'managed';
|
|
}
|
|
|
|
function uniqueOperations(operations) {
|
|
const seen = new Set();
|
|
return operations.filter(operation => {
|
|
const key = [
|
|
operation.kind,
|
|
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
|
|
comparablePath(operation.destinationPath),
|
|
].join('\0');
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function buildState(statePreview, operations) {
|
|
return {
|
|
...statePreview,
|
|
operations: uniqueOperations(operations).map(operation => ({ ...operation })),
|
|
};
|
|
}
|
|
|
|
function groupCurrentSkillOperations(plan) {
|
|
const groups = new Map();
|
|
for (const operation of plan.operations) {
|
|
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
|
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
|
continue;
|
|
}
|
|
|
|
assertSafeSkillPath(
|
|
operation.destinationPath,
|
|
plan.targetRoot,
|
|
'install Claude skill'
|
|
);
|
|
|
|
const current = groups.get(descriptor.flatSkillRoot) || [];
|
|
current.push({ operation, descriptor });
|
|
groups.set(descriptor.flatSkillRoot, current);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function classifyPreviousOperations(plan, previousState) {
|
|
const flatByDestination = new Map();
|
|
const legacyBySource = new Map();
|
|
const legacyBySkillRoot = new Map();
|
|
|
|
for (const operation of (previousState && previousState.operations) || []) {
|
|
if (!isManagedOperation(operation)) {
|
|
continue;
|
|
}
|
|
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
|
|
if (!descriptor) {
|
|
continue;
|
|
}
|
|
|
|
if (samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
|
|
assertSafeSkillPath(
|
|
operation.destinationPath,
|
|
plan.targetRoot,
|
|
'inspect managed Claude skill'
|
|
);
|
|
flatByDestination.set(comparablePath(operation.destinationPath), operation);
|
|
continue;
|
|
}
|
|
|
|
if (!samePath(operation.destinationPath, descriptor.legacyDestinationPath)) {
|
|
continue;
|
|
}
|
|
|
|
assertSafeSkillPath(
|
|
operation.destinationPath,
|
|
plan.targetRoot,
|
|
'migrate managed Claude skill'
|
|
);
|
|
legacyBySource.set(descriptor.sourceKey, operation);
|
|
const current = legacyBySkillRoot.get(descriptor.legacySkillRoot) || [];
|
|
current.push({ operation, descriptor });
|
|
legacyBySkillRoot.set(descriptor.legacySkillRoot, current);
|
|
}
|
|
|
|
return {
|
|
flatByDestination,
|
|
legacyBySource,
|
|
legacyBySkillRoot,
|
|
};
|
|
}
|
|
|
|
function createConflictWarning(skillName, flatSkillRoot, retainsLegacy) {
|
|
const legacySuffix = retainsLegacy
|
|
? ' The existing ECC-managed nested copy was retained and remains tracked for uninstall.'
|
|
: '';
|
|
return `Skipped Claude skill '${skillName}' at ${flatSkillRoot}: the flat skill directory is user-owned because it is not recorded in ECC install-state.${legacySuffix}`;
|
|
}
|
|
|
|
function createFileConflictWarning(destinationPath, retainsLegacy) {
|
|
const legacySuffix = retainsLegacy
|
|
? ' The matching ECC-managed nested file was retained and remains tracked for uninstall.'
|
|
: '';
|
|
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
|
|
}
|
|
|
|
function createDisabledMigration(plan) {
|
|
return {
|
|
enabled: false,
|
|
appliedOperations: [...plan.operations],
|
|
skippedOperations: [],
|
|
warnings: [],
|
|
bridgeState: plan.statePreview,
|
|
finalState: plan.statePreview,
|
|
legacyOperationsToRemove: [],
|
|
requiresBridgeState: false,
|
|
};
|
|
}
|
|
|
|
function collectRetainedLegacyOperations(currentGroups, previous) {
|
|
const currentSourceKeys = new Set(
|
|
[...currentGroups.values()]
|
|
.flat()
|
|
.map(({ descriptor }) => descriptor.sourceKey)
|
|
);
|
|
return (
|
|
[...previous.legacyBySource.entries()]
|
|
.filter(([sourceKey]) => !currentSourceKeys.has(sourceKey))
|
|
.map(([_sourceKey, operation]) => operation)
|
|
);
|
|
}
|
|
|
|
function classifySkillGroup(flatSkillRoot, entries, previous) {
|
|
const hasManagedFlatFile = entries.some(({ operation }) => (
|
|
previous.flatByDestination.has(comparablePath(operation.destinationPath))
|
|
));
|
|
const legacyEntries = previous.legacyBySkillRoot.get(
|
|
entries[0].descriptor.legacySkillRoot
|
|
) || [];
|
|
|
|
if (pathExists(flatSkillRoot) && !hasManagedFlatFile) {
|
|
return {
|
|
skippedOperations: entries.map(({ operation }) => operation),
|
|
warnings: [createConflictWarning(
|
|
entries[0].descriptor.skillName,
|
|
flatSkillRoot,
|
|
legacyEntries.length > 0
|
|
)],
|
|
retainedLegacyOperations: legacyEntries.map(({ operation }) => operation),
|
|
};
|
|
}
|
|
|
|
const conflicts = entries.filter(({ operation }) => (
|
|
pathExists(operation.destinationPath)
|
|
&& !previous.flatByDestination.has(comparablePath(operation.destinationPath))
|
|
));
|
|
return {
|
|
skippedOperations: conflicts.map(({ operation }) => operation),
|
|
warnings: conflicts.map(({ operation, descriptor }) => createFileConflictWarning(
|
|
operation.destinationPath,
|
|
previous.legacyBySource.has(descriptor.sourceKey)
|
|
)),
|
|
retainedLegacyOperations: conflicts
|
|
.map(({ descriptor }) => previous.legacyBySource.get(descriptor.sourceKey))
|
|
.filter(Boolean),
|
|
};
|
|
}
|
|
|
|
function classifySkillConflicts(currentGroups, previous) {
|
|
const groupClassifications = [...currentGroups.entries()]
|
|
.map(([flatSkillRoot, entries]) => classifySkillGroup(
|
|
flatSkillRoot,
|
|
entries,
|
|
previous
|
|
));
|
|
const skippedOperations = groupClassifications
|
|
.flatMap(classification => classification.skippedOperations);
|
|
return {
|
|
skippedOperations,
|
|
skippedDestinations: new Set(
|
|
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
|
),
|
|
warnings: groupClassifications.flatMap(classification => classification.warnings),
|
|
retainedLegacyOperations: new Set([
|
|
...collectRetainedLegacyOperations(currentGroups, previous),
|
|
...groupClassifications.flatMap(
|
|
classification => classification.retainedLegacyOperations
|
|
),
|
|
]),
|
|
};
|
|
}
|
|
|
|
function buildMigrationStates(plan, previousState, previous, classification) {
|
|
const { skippedDestinations, retainedLegacyOperations } = classification;
|
|
const appliedOperations = plan.operations.filter(operation => (
|
|
!skippedDestinations.has(comparablePath(operation.destinationPath))
|
|
));
|
|
const legacyOperations = [...previous.legacyBySource.values()];
|
|
const legacyOperationsToRemove = legacyOperations.filter(operation => (
|
|
!retainedLegacyOperations.has(operation)
|
|
));
|
|
const finalOperations = [
|
|
...plan.statePreview.operations.filter(operation => (
|
|
!skippedDestinations.has(comparablePath(operation.destinationPath))
|
|
)),
|
|
...retainedLegacyOperations,
|
|
];
|
|
const bridgeOperations = [
|
|
...((previousState && previousState.operations) || []),
|
|
...appliedOperations,
|
|
];
|
|
|
|
return {
|
|
appliedOperations,
|
|
bridgeState: buildState(plan.statePreview, bridgeOperations),
|
|
finalState: buildState(plan.statePreview, finalOperations),
|
|
legacyOperationsToRemove,
|
|
requiresBridgeState: appliedOperations.length > 0,
|
|
};
|
|
}
|
|
|
|
function prepareClaudeSkillMigration(plan) {
|
|
const target = plan && plan.adapter && plan.adapter.target;
|
|
if (!CLAUDE_TARGETS.has(target)) {
|
|
return createDisabledMigration(plan);
|
|
}
|
|
|
|
const previousState = pathExists(plan.installStatePath)
|
|
? readInstallState(plan.installStatePath)
|
|
: null;
|
|
const currentGroups = groupCurrentSkillOperations(plan);
|
|
const previous = classifyPreviousOperations(plan, previousState);
|
|
const classification = classifySkillConflicts(currentGroups, previous);
|
|
const states = buildMigrationStates(
|
|
plan,
|
|
previousState,
|
|
previous,
|
|
classification
|
|
);
|
|
|
|
return {
|
|
enabled: true,
|
|
appliedOperations: states.appliedOperations,
|
|
skippedOperations: classification.skippedOperations,
|
|
warnings: classification.warnings,
|
|
bridgeState: states.bridgeState,
|
|
finalState: states.finalState,
|
|
legacyOperationsToRemove: states.legacyOperationsToRemove,
|
|
requiresBridgeState: states.requiresBridgeState,
|
|
};
|
|
}
|
|
|
|
function cleanupEmptyLegacyParents(filePath, targetRoot) {
|
|
const skillsRoot = path.join(targetRoot, 'skills');
|
|
let currentPath = path.dirname(filePath);
|
|
|
|
while (!samePath(currentPath, skillsRoot)) {
|
|
assertSafeSkillPath(currentPath, targetRoot, 'clean Claude skill migration');
|
|
if (!pathExists(currentPath) || fs.readdirSync(currentPath).length > 0) {
|
|
return;
|
|
}
|
|
fs.rmdirSync(currentPath);
|
|
currentPath = path.dirname(currentPath);
|
|
}
|
|
}
|
|
|
|
function removeLegacyClaudeSkillFiles(migration, targetRoot) {
|
|
for (const operation of migration.legacyOperationsToRemove) {
|
|
assertSafeSkillPath(
|
|
operation.destinationPath,
|
|
targetRoot,
|
|
'migrate managed Claude skill'
|
|
);
|
|
fs.rmSync(operation.destinationPath, { force: true });
|
|
cleanupEmptyLegacyParents(operation.destinationPath, targetRoot);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
assertSafeClaudeSkillOperation,
|
|
prepareClaudeSkillMigration,
|
|
removeLegacyClaudeSkillFiles,
|
|
};
|