mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 18:57:55 +02:00
fix: make Claude skill migration recoverable
This commit is contained in:
+6
-9
@@ -757,16 +757,13 @@ cp -r rules/golang ~/.claude/rules/ecc/
|
||||
cp -r rules/php ~/.claude/rules/ecc/
|
||||
cp -r rules/arkts ~/.claude/rules/ecc/
|
||||
|
||||
# Copiar skills primero (superficie principal de flujo de trabajo)
|
||||
# Recomendado (nuevos usuarios): solo skills generales/básicas
|
||||
mkdir -p ~/.claude/skills
|
||||
cp -r .agents/skills/* ~/.claude/skills/
|
||||
cp -r skills/search-first ~/.claude/skills/
|
||||
# Instalar skills con el instalador consciente de migraciones.
|
||||
# Conserva skills del usuario, informa conflictos y evita sobrescribirlos.
|
||||
node scripts/install-apply.js --target claude --modules workflow-quality
|
||||
|
||||
# Opcional: añadir skills específicas de framework solo cuando las necesites
|
||||
# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do
|
||||
# cp -r skills/$s ~/.claude/skills/
|
||||
# done
|
||||
# Opcional: instalar skills concretas solo cuando las necesites.
|
||||
node scripts/install-apply.js --target claude --skills search-first
|
||||
# node scripts/install-apply.js --target claude --skills django-patterns,django-tdd
|
||||
|
||||
# Opcional: mantener compatibilidad con entradas slash durante la migración
|
||||
mkdir -p ~/.claude/commands
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -510,6 +510,129 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('tracks non-skill files written before a partial flat-skill install fails', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const ruleSourceRelativePath = path.join('rules', 'common', 'coding.md');
|
||||
const ruleSourcePath = path.join(fixture.sourceRoot, ruleSourceRelativePath);
|
||||
const ruleDestinationPath = path.join(
|
||||
fixture.targetRoot,
|
||||
'rules',
|
||||
'ecc',
|
||||
'common',
|
||||
'coding.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(ruleSourcePath), { recursive: true });
|
||||
fs.writeFileSync(ruleSourcePath, '# Managed rule\n');
|
||||
|
||||
const ruleOperation = createOperation(
|
||||
'workflow-quality',
|
||||
fixture.sourceRoot,
|
||||
ruleSourceRelativePath,
|
||||
ruleDestinationPath
|
||||
);
|
||||
const missingOperation = createOperation(
|
||||
'workflow-quality',
|
||||
fixture.sourceRoot,
|
||||
path.join('commands', 'missing.md'),
|
||||
path.join(fixture.targetRoot, 'commands', 'missing.md')
|
||||
);
|
||||
const operations = [
|
||||
fixture.operations[0],
|
||||
ruleOperation,
|
||||
missingOperation,
|
||||
];
|
||||
const partialPlan = {
|
||||
...fixture.plan,
|
||||
operations,
|
||||
statePreview: {
|
||||
...fixture.plan.statePreview,
|
||||
operations: operations.map(operation => ({ ...operation })),
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(() => applyInstallPlan(partialPlan), /ENOENT/);
|
||||
assert.ok(fs.existsSync(ruleDestinationPath));
|
||||
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === ruleDestinationPath
|
||||
)));
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(!fs.existsSync(ruleDestinationPath));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('tracks partial non-skill writes when every flat skill is user-owned', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const userSkillPath = fixture.operations[0].destinationPath;
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User skill\n');
|
||||
|
||||
const ruleSourceRelativePath = path.join('rules', 'common', 'coding.md');
|
||||
const ruleSourcePath = path.join(fixture.sourceRoot, ruleSourceRelativePath);
|
||||
const ruleDestinationPath = path.join(
|
||||
fixture.targetRoot,
|
||||
'rules',
|
||||
'ecc',
|
||||
'common',
|
||||
'coding.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(ruleSourcePath), { recursive: true });
|
||||
fs.writeFileSync(ruleSourcePath, '# Managed rule\n');
|
||||
|
||||
const ruleOperation = createOperation(
|
||||
'workflow-quality',
|
||||
fixture.sourceRoot,
|
||||
ruleSourceRelativePath,
|
||||
ruleDestinationPath
|
||||
);
|
||||
const missingOperation = createOperation(
|
||||
'workflow-quality',
|
||||
fixture.sourceRoot,
|
||||
path.join('commands', 'missing.md'),
|
||||
path.join(fixture.targetRoot, 'commands', 'missing.md')
|
||||
);
|
||||
const operations = [
|
||||
...fixture.operations,
|
||||
ruleOperation,
|
||||
missingOperation,
|
||||
];
|
||||
const partialPlan = {
|
||||
...fixture.plan,
|
||||
operations,
|
||||
statePreview: {
|
||||
...fixture.plan.statePreview,
|
||||
operations: operations.map(operation => ({ ...operation })),
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(() => applyInstallPlan(partialPlan), /ENOENT/);
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User skill\n');
|
||||
assert.ok(fs.existsSync(ruleDestinationPath));
|
||||
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(!bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === userSkillPath
|
||||
)));
|
||||
assert.ok(bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === ruleDestinationPath
|
||||
)));
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User skill\n');
|
||||
assert.ok(!fs.existsSync(ruleDestinationPath));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('keeps legacy files tracked when the bridge state write fails', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
@@ -567,7 +690,7 @@ function runTests() {
|
||||
);
|
||||
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
} finally {
|
||||
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(fixture.operations.every(flatOperation => (
|
||||
bridgeState.operations.some(operation => (
|
||||
@@ -581,12 +704,14 @@ function runTests() {
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects a flat skill symlink that escapes the Claude install root', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' ↷ skipped on Windows: symlink privileges vary');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -611,6 +736,7 @@ function runTests() {
|
||||
|
||||
if (test('rejects a dangling destination symlink before copying a Claude skill file', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' ↷ skipped on Windows: symlink privileges vary');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -634,6 +634,100 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('Claude repair and dry-run preserve user-owned flat skills during legacy migration', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
try {
|
||||
const targetRoot = path.join(homeDir, '.claude');
|
||||
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const flatSkillPath = path.join(targetRoot, 'skills', 'tdd-workflow', 'SKILL.md');
|
||||
const legacySkillPath = path.join(
|
||||
targetRoot,
|
||||
'skills',
|
||||
'ecc',
|
||||
'tdd-workflow',
|
||||
'SKILL.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(flatSkillPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(legacySkillPath), { recursive: true });
|
||||
fs.writeFileSync(flatSkillPath, '# User-owned flat skill\n');
|
||||
fs.writeFileSync(legacySkillPath, '# Previously managed nested skill\n');
|
||||
|
||||
writeState(installStatePath, {
|
||||
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['workflow-quality'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['platform-configs', 'workflow-quality'],
|
||||
skippedModules: [],
|
||||
},
|
||||
operations: [{
|
||||
kind: 'copy-file',
|
||||
moduleId: 'workflow-quality',
|
||||
sourcePath: path.join(REPO_ROOT, 'skills', 'tdd-workflow', 'SKILL.md'),
|
||||
sourceRelativePath: path.join('skills', 'tdd-workflow', 'SKILL.md'),
|
||||
destinationPath: legacySkillPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
}],
|
||||
source: {
|
||||
repoVersion: CURRENT_PACKAGE_VERSION,
|
||||
repoCommit: 'abc123',
|
||||
manifestVersion: CURRENT_MANIFEST_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
const dryRun = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude'],
|
||||
dryRun: true,
|
||||
});
|
||||
assert.ok(!dryRun.results[0].plannedRepairs.includes(flatSkillPath));
|
||||
assert.ok(dryRun.results[0].warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.strictEqual(fs.readFileSync(flatSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(legacySkillPath, 'utf8'),
|
||||
'# Previously managed nested skill\n'
|
||||
);
|
||||
|
||||
const repaired = repairInstalledStates({
|
||||
repoRoot: REPO_ROOT,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
targets: ['claude'],
|
||||
});
|
||||
assert.notStrictEqual(repaired.results[0].status, 'error');
|
||||
assert.ok(repaired.results[0].warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.strictEqual(fs.readFileSync(flatSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(legacySkillPath, 'utf8'),
|
||||
'# Previously managed nested skill\n'
|
||||
);
|
||||
const repairedState = JSON.parse(fs.readFileSync(installStatePath, 'utf8'));
|
||||
assert.ok(repairedState.operations.some(operation => (
|
||||
operation.destinationPath === legacySkillPath
|
||||
)));
|
||||
assert.ok(!repairedState.operations.some(operation => (
|
||||
operation.destinationPath === flatSkillPath
|
||||
)));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectRoot);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('repair copies missing managed files from recorded source paths', () => {
|
||||
const homeDir = createTempDir('install-lifecycle-home-');
|
||||
const projectRoot = createTempDir('install-lifecycle-project-');
|
||||
|
||||
@@ -468,7 +468,7 @@ function runTests() {
|
||||
assert.strictEqual(fs.readFileSync(userRulePath, 'utf8'), '# User custom rule\n');
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'verification-loop', 'SKILL.md')));
|
||||
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(path.join(claudeRoot, 'skills', 'tdd-workflow'))
|
||||
@@ -523,6 +523,46 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('dry-run reports the same user-owned Claude skill conflicts as apply', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const userSkillRoot = path.join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'skills',
|
||||
'tdd-workflow'
|
||||
);
|
||||
const userSkillPath = path.join(userSkillRoot, 'SKILL.md');
|
||||
fs.mkdirSync(userSkillRoot, { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User custom skill\n');
|
||||
|
||||
const result = run(
|
||||
['--skills', 'tdd-workflow', '--dry-run', '--json'],
|
||||
{ cwd: projectDir, homeDir }
|
||||
);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, true);
|
||||
assert.ok(payload.plan.plannedOperations.length > 0);
|
||||
assert.ok(payload.plan.skippedOperations.length > 0);
|
||||
assert.ok(payload.plan.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.ok(payload.plan.skippedOperations.every(operation => (
|
||||
operation.destinationPath.startsWith(userSkillRoot)
|
||||
)));
|
||||
assert.ok(!payload.plan.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(userSkillRoot)
|
||||
)));
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('installs antigravity manifest profiles while skipping only unsupported modules', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
Reference in New Issue
Block a user