fix: make Claude skill migration recoverable

This commit is contained in:
Affaan Mustafa
2026-07-26 04:38:35 -04:00
parent fbd45bbd77
commit 4a0e34fa7c
9 changed files with 366 additions and 45 deletions
+9 -5
View File
@@ -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 {
+6
View File
@@ -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,
+51 -9
View File
@@ -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) {
+23 -3
View File
@@ -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,
};
+9 -17
View File
@@ -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,
};
}