mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 12:47:57 +02:00
fix: make Claude skill migration recoverable
This commit is contained in:
@@ -103,7 +103,7 @@ function printHumanPlan(plan, dryRun) {
|
||||
}
|
||||
}
|
||||
console.log(`${dryRun ? 'Operations' : 'Applied operations'}: ${plan.operations.length}`);
|
||||
if (!dryRun && Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) {
|
||||
if (Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) {
|
||||
console.log(`Skipped operations: ${plan.skippedOperations.length}`);
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ function printHumanPlan(plan, dryRun) {
|
||||
console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`);
|
||||
}
|
||||
|
||||
if (!dryRun && Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) {
|
||||
if (Array.isArray(plan.skippedOperations) && plan.skippedOperations.length > 0) {
|
||||
console.log('\nSkipped file operations:');
|
||||
for (const operation of plan.skippedOperations) {
|
||||
console.log(`- ${operation.sourceRelativePath} -> ${operation.destinationPath}`);
|
||||
@@ -145,7 +145,10 @@ function main() {
|
||||
findDefaultInstallConfigPath,
|
||||
loadInstallConfig,
|
||||
} = require('./lib/install/config');
|
||||
const { applyInstallPlan } = require('./lib/install-executor');
|
||||
const {
|
||||
applyInstallPlan,
|
||||
previewInstallPlan,
|
||||
} = require('./lib/install-executor');
|
||||
const { createInstallPlanFromRequest } = require('./lib/install/runtime');
|
||||
const defaultConfigPath = options.configPath || options.languages.length > 0
|
||||
? null
|
||||
@@ -157,13 +160,14 @@ function main() {
|
||||
...options,
|
||||
config,
|
||||
});
|
||||
const plan = createInstallPlanFromRequest(request, {
|
||||
const rawPlan = createInstallPlanFromRequest(request, {
|
||||
projectRoot: process.cwd(),
|
||||
homeDir: process.env.HOME || os.homedir(),
|
||||
claudeRulesDir: process.env.CLAUDE_RULES_DIR || null,
|
||||
});
|
||||
|
||||
if (options.dryRun) {
|
||||
const plan = previewInstallPlan(rawPlan);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ dryRun: true, plan }, null, 2));
|
||||
} else {
|
||||
@@ -172,7 +176,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = applyInstallPlan(plan);
|
||||
const result = applyInstallPlan(rawPlan);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ dryRun: false, result }, null, 2));
|
||||
} else {
|
||||
|
||||
@@ -123,6 +123,11 @@ function applyInstallPlan(plan) {
|
||||
return applyPlan(plan);
|
||||
}
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
const { previewInstallPlan: previewPlan } = require('./install/apply');
|
||||
return previewPlan(plan);
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
@@ -802,6 +807,7 @@ module.exports = {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
applyInstallPlan,
|
||||
previewInstallPlan,
|
||||
createLegacyCompatInstallPlan,
|
||||
createManifestInstallPlan,
|
||||
createLegacyInstallPlan,
|
||||
|
||||
@@ -7,6 +7,10 @@ const { resolveInstallPlan, loadInstallManifests } = require('./install-manifest
|
||||
const { readInstallState, writeInstallState } = require('./install-state');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { createManifestInstallPlan } = require('./install-executor');
|
||||
const {
|
||||
prepareClaudeSkillMigration,
|
||||
removeLegacyClaudeSkillFiles,
|
||||
} = require('./install/claude-skill-migration');
|
||||
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
|
||||
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
|
||||
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
|
||||
@@ -951,6 +955,22 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function prepareRepairMigration(plan) {
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
return {
|
||||
migration,
|
||||
plan: {
|
||||
...plan,
|
||||
operations: migration.appliedOperations,
|
||||
statePreview: migration.finalState,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function repairInstalledStates(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
@@ -988,9 +1008,10 @@ function repairInstalledStates(options = {}) {
|
||||
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
||||
|
||||
if (needsOpencodeBuild && options.dryRun) {
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context, {
|
||||
const rawPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
const { plan: desiredPlan } = prepareRepairMigration(rawPlan);
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
||||
@@ -1002,6 +1023,7 @@ function repairInstalledStates(options = {}) {
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
@@ -1021,7 +1043,11 @@ function repairInstalledStates(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const desiredPlan = createRepairPlanFromRecord(record, context);
|
||||
const rawPlan = createRepairPlanFromRecord(record, context);
|
||||
const {
|
||||
migration,
|
||||
plan: desiredPlan,
|
||||
} = prepareRepairMigration(rawPlan);
|
||||
const operationHealth = summarizeManagedOperationHealth(context.repoRoot, desiredPlan.operations);
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
@@ -1031,14 +1057,20 @@ function repairInstalledStates(options = {}) {
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
warnings: desiredPlan.warnings,
|
||||
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = needsOpencodeBuild
|
||||
? [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)]
|
||||
: repairOperations.map(operation => operation.destinationPath);
|
||||
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
||||
operation => operation.destinationPath
|
||||
);
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...repairOperations.map(operation => operation.destinationPath),
|
||||
...legacyMigrationPaths,
|
||||
])];
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
@@ -1048,26 +1080,36 @@ function repairInstalledStates(options = {}) {
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: plannedRepairs.length === 0,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
|
||||
writeInstallState(desiredPlan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
|
||||
if (repairOperations.length > 0) {
|
||||
for (const operation of repairOperations) {
|
||||
executeRepairOperation(context.repoRoot, operation, record.targetRoot);
|
||||
}
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
} else {
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
removeLegacyClaudeSkillFiles(migration, desiredPlan.targetRoot);
|
||||
}
|
||||
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild) ? 'repaired' : 'ok',
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
|
||||
? 'repaired'
|
||||
: 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: plannedRepairs,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -144,6 +144,22 @@ function buildResolvedClaudeHooks(plan) {
|
||||
};
|
||||
}
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
return {
|
||||
...plan,
|
||||
statePreview: migration.finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
],
|
||||
applied: false,
|
||||
};
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan, dependencies = {}) {
|
||||
const persistInstallState = dependencies.writeInstallState || writeInstallState;
|
||||
const migration = prepareClaudeSkillMigration(plan);
|
||||
@@ -157,15 +173,18 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
|
||||
if (migration.requiresBridgeState) {
|
||||
// Own planned flat skill files before the first copy. A later failure is
|
||||
// retryable and uninstall can clean any partial flat writes. During legacy
|
||||
// migration the bridge also retains every operation from the prior state.
|
||||
// Own every operation that may be written during a flat-skill migration
|
||||
// before the first copy. A later failure is retryable and uninstall can
|
||||
// clean the entire partial install, including non-skill files. During
|
||||
// legacy migration the bridge also retains the prior managed operations.
|
||||
persistInstallState(plan.installStatePath, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of appliedPlan.operations) {
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
|
||||
// The first check validates the existing chain; this second check validates
|
||||
// every directory created by mkdirSync before any file is written.
|
||||
assertSafeClaudeSkillOperation(appliedPlan, operation);
|
||||
|
||||
if (operation.kind === 'merge-json') {
|
||||
@@ -245,4 +264,5 @@ function applyInstallPlan(plan, dependencies = {}) {
|
||||
|
||||
module.exports = {
|
||||
applyInstallPlan,
|
||||
previewInstallPlan,
|
||||
};
|
||||
|
||||
@@ -61,18 +61,20 @@ function assertSafeSkillPath(targetPath, targetRoot, action) {
|
||||
let currentPath = resolvedRoot;
|
||||
for (const segment of relativePath.split(path.sep)) {
|
||||
currentPath = path.join(currentPath, segment);
|
||||
let stats;
|
||||
try {
|
||||
if (fs.lstatSync(currentPath).isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
|
||||
);
|
||||
}
|
||||
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)) {
|
||||
@@ -325,19 +327,9 @@ function prepareClaudeSkillMigration(plan) {
|
||||
)),
|
||||
...retainedLegacyOperations,
|
||||
];
|
||||
const appliedSkillDestinations = new Set(
|
||||
[...currentGroups.values()]
|
||||
.flat()
|
||||
.map(({ operation }) => operation)
|
||||
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)))
|
||||
.map(operation => comparablePath(operation.destinationPath))
|
||||
);
|
||||
const plannedFlatSkillOperations = plan.statePreview.operations.filter(operation => (
|
||||
appliedSkillDestinations.has(comparablePath(operation.destinationPath))
|
||||
));
|
||||
const bridgeOperations = [
|
||||
...((previousState && previousState.operations) || []),
|
||||
...plannedFlatSkillOperations,
|
||||
...appliedOperations,
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -348,7 +340,7 @@ function prepareClaudeSkillMigration(plan) {
|
||||
bridgeState: buildState(plan.statePreview, bridgeOperations),
|
||||
finalState: buildState(plan.statePreview, finalOperations),
|
||||
legacyOperationsToRemove,
|
||||
requiresBridgeState: plannedFlatSkillOperations.length > 0,
|
||||
requiresBridgeState: appliedOperations.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user