mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
fix: flatten Claude skill installs (#2582)
Flatten managed Claude skill destinations, preserve user-owned conflicts, and migrate legacy nested installs through the lifecycle tooling.
This commit is contained in:
parent
71438391e8
commit
f3afd59045
18 changed files with 1620 additions and 119 deletions
811
tests/lib/install-claude-skill-migration.test.js
Normal file
811
tests/lib/install-claude-skill-migration.test.js
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function createOperation(moduleId, sourceRoot, sourceRelativePath, destinationPath) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId,
|
||||
sourcePath: path.join(sourceRoot, sourceRelativePath),
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(options = {}) {
|
||||
const tempDir = createTempDir('claude-skill-migration-');
|
||||
const homeDir = path.join(tempDir, 'home');
|
||||
const projectRoot = path.join(tempDir, 'project');
|
||||
const sourceRoot = path.join(tempDir, 'source');
|
||||
const target = options.target || 'claude';
|
||||
const targetRoot = target === 'claude'
|
||||
? path.join(homeDir, '.claude')
|
||||
: path.join(projectRoot, '.claude');
|
||||
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
|
||||
const skillFiles = options.skillFiles || {
|
||||
'SKILL.md': '# Current ECC skill\n',
|
||||
'references/guide.md': '# Current ECC guide\n',
|
||||
};
|
||||
|
||||
for (const [relativePath, content] of Object.entries(skillFiles)) {
|
||||
const sourcePath = path.join(sourceRoot, 'skills', 'demo-skill', relativePath);
|
||||
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
||||
fs.writeFileSync(sourcePath, content);
|
||||
}
|
||||
|
||||
const operations = Object.keys(skillFiles).map(relativePath => createOperation(
|
||||
'workflow-quality',
|
||||
sourceRoot,
|
||||
path.join('skills', 'demo-skill', relativePath),
|
||||
path.join(targetRoot, 'skills', 'demo-skill', relativePath)
|
||||
));
|
||||
const statePreview = {
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: new Date().toISOString(),
|
||||
target: {
|
||||
id: target === 'claude' ? 'claude-home' : 'claude-project',
|
||||
target,
|
||||
kind: target === 'claude' ? 'home' : 'project',
|
||||
root: targetRoot,
|
||||
installStatePath,
|
||||
},
|
||||
request: {
|
||||
profile: null,
|
||||
modules: ['workflow-quality'],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: ['workflow-quality'],
|
||||
skippedModules: [],
|
||||
},
|
||||
source: {
|
||||
repoVersion: null,
|
||||
repoCommit: null,
|
||||
manifestVersion: 1,
|
||||
},
|
||||
operations: operations.map(operation => ({ ...operation })),
|
||||
};
|
||||
|
||||
return {
|
||||
tempDir,
|
||||
homeDir,
|
||||
projectRoot,
|
||||
sourceRoot,
|
||||
target,
|
||||
targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
plan: {
|
||||
mode: 'manifest',
|
||||
target,
|
||||
adapter: {
|
||||
id: target === 'claude' ? 'claude-home' : 'claude-project',
|
||||
target,
|
||||
kind: target === 'claude' ? 'home' : 'project',
|
||||
},
|
||||
targetRoot,
|
||||
installRoot: targetRoot,
|
||||
installStatePath,
|
||||
operations,
|
||||
statePreview,
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function legacyDestinationPath(targetRoot, operation) {
|
||||
const sourceParts = operation.sourceRelativePath.split(path.sep);
|
||||
return path.join(targetRoot, 'skills', 'ecc', ...sourceParts.slice(1));
|
||||
}
|
||||
|
||||
function seedLegacyInstall(fixture, options = {}) {
|
||||
const legacyOperations = fixture.operations.map((operation, index) => {
|
||||
const destinationPath = legacyDestinationPath(fixture.targetRoot, operation);
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.writeFileSync(destinationPath, `# Legacy managed file ${index}\n`);
|
||||
return {
|
||||
...operation,
|
||||
sourceRelativePath: options.windowsSourcePaths
|
||||
? operation.sourceRelativePath.split(path.sep).join('\\')
|
||||
: operation.sourceRelativePath,
|
||||
destinationPath,
|
||||
};
|
||||
});
|
||||
|
||||
writeInstallState(fixture.installStatePath, {
|
||||
...fixture.plan.statePreview,
|
||||
operations: legacyOperations,
|
||||
});
|
||||
return legacyOperations;
|
||||
}
|
||||
|
||||
function runUninstall(fixture) {
|
||||
return uninstallInstalledStates({
|
||||
homeDir: fixture.homeDir,
|
||||
projectRoot: fixture.projectRoot,
|
||||
targets: [fixture.target],
|
||||
});
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.stack || error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing Claude flat-skill migration ===\n');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const target of ['claude', 'claude-project']) {
|
||||
if (test(`migrates state-managed nested skills for ${target} without deleting untracked files`, () => {
|
||||
const fixture = createFixture({ target });
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture, {
|
||||
windowsSourcePaths: target === 'claude-project',
|
||||
});
|
||||
const untrackedPath = path.join(
|
||||
fixture.targetRoot,
|
||||
'skills',
|
||||
'ecc',
|
||||
'demo-skill',
|
||||
'user-notes.md'
|
||||
);
|
||||
fs.writeFileSync(untrackedPath, '# User notes\n');
|
||||
|
||||
applyInstallPlan(fixture.plan);
|
||||
|
||||
for (const operation of fixture.operations) {
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(operation.destinationPath, 'utf8'),
|
||||
fs.readFileSync(operation.sourcePath, 'utf8')
|
||||
);
|
||||
}
|
||||
for (const operation of legacyOperations) {
|
||||
assert.ok(!fs.existsSync(operation.destinationPath), operation.destinationPath);
|
||||
}
|
||||
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
|
||||
|
||||
const state = readInstallState(fixture.installStatePath);
|
||||
assert.ok(state.operations.some(operation => (
|
||||
operation.destinationPath === fixture.operations[0].destinationPath
|
||||
)));
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
|
||||
)));
|
||||
|
||||
const rerun = applyInstallPlan(fixture.plan);
|
||||
assert.deepStrictEqual(rerun.skippedOperations, []);
|
||||
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(!fs.existsSync(fixture.operations[0].destinationPath));
|
||||
assert.strictEqual(fs.readFileSync(untrackedPath, 'utf8'), '# User notes\n');
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
if (test('selective migration preserves unrelated legacy skills and uninstall ownership', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
const otherSourceRelativePath = path.join('skills', 'other-skill', 'SKILL.md');
|
||||
const otherSourcePath = path.join(fixture.sourceRoot, otherSourceRelativePath);
|
||||
const otherLegacyPath = path.join(
|
||||
fixture.targetRoot,
|
||||
'skills',
|
||||
'ecc',
|
||||
'other-skill',
|
||||
'SKILL.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(otherSourcePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(otherLegacyPath), { recursive: true });
|
||||
fs.writeFileSync(otherSourcePath, '# Other source\n');
|
||||
fs.writeFileSync(otherLegacyPath, '# Other legacy managed skill\n');
|
||||
const otherLegacyOperation = createOperation(
|
||||
'other-module',
|
||||
fixture.sourceRoot,
|
||||
otherSourceRelativePath,
|
||||
otherLegacyPath
|
||||
);
|
||||
writeInstallState(fixture.installStatePath, {
|
||||
...fixture.plan.statePreview,
|
||||
operations: [...legacyOperations, otherLegacyOperation],
|
||||
});
|
||||
|
||||
applyInstallPlan(fixture.plan);
|
||||
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(otherLegacyPath, 'utf8'),
|
||||
'# Other legacy managed skill\n'
|
||||
);
|
||||
const state = readInstallState(fixture.installStatePath);
|
||||
assert.ok(state.operations.some(operation => (
|
||||
operation.destinationPath === otherLegacyPath
|
||||
)));
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.ok(!fs.existsSync(otherLegacyPath));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reruns a completed migration idempotently and remains uninstallable', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
applyInstallPlan(fixture.plan);
|
||||
const stateAfterMigration = readInstallState(fixture.installStatePath);
|
||||
|
||||
const rerun = applyInstallPlan(fixture.plan);
|
||||
const stateAfterRerun = readInstallState(fixture.installStatePath);
|
||||
|
||||
assert.deepStrictEqual(rerun.skippedOperations, []);
|
||||
assert.ok(!rerun.warnings.some(warning => (
|
||||
warning.includes('user-owned') || warning.includes('nested copy')
|
||||
)));
|
||||
assert.deepStrictEqual(stateAfterRerun, stateAfterMigration);
|
||||
assert.ok(fixture.operations.every(operation => (
|
||||
fs.readFileSync(operation.destinationPath, 'utf8')
|
||||
=== fs.readFileSync(operation.sourcePath, 'utf8')
|
||||
)));
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(!fs.existsSync(path.join(fixture.targetRoot, 'skills', 'ecc')));
|
||||
|
||||
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('preserves a user-owned flat skill and keeps legacy ownership for uninstall', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
const userSkillPath = fixture.operations[0].destinationPath;
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User-owned flat skill\n');
|
||||
|
||||
const result = applyInstallPlan(fixture.plan);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(result.warnings.some(warning => (
|
||||
warning.includes('demo-skill') && warning.includes('user-owned')
|
||||
)), JSON.stringify(result.warnings));
|
||||
assert.strictEqual(result.operations.length, 0);
|
||||
assert.strictEqual(result.skippedOperations.length, fixture.operations.length);
|
||||
|
||||
const state = readInstallState(fixture.installStatePath);
|
||||
assert.ok(legacyOperations.every(legacyOperation => (
|
||||
state.operations.some(operation => operation.destinationPath === legacyOperation.destinationPath)
|
||||
)));
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath === fixture.operations[0].destinationPath
|
||||
)));
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not claim or merge into a user-owned flat skill on first install', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const userSkillPath = fixture.operations[0].destinationPath;
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User-owned flat skill\n');
|
||||
|
||||
const result = applyInstallPlan(fixture.plan);
|
||||
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
|
||||
assert.ok(result.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.strictEqual(result.operations.length, 0);
|
||||
assert.strictEqual(result.skippedOperations.length, fixture.operations.length);
|
||||
assert.deepStrictEqual(readInstallState(fixture.installStatePath).operations, []);
|
||||
|
||||
const uninstall = runUninstall(fixture);
|
||||
assert.strictEqual(uninstall.summary.errorCount, 0);
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User-owned flat skill\n');
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('updates recorded flat files but preserves conflicting unrecorded files', () => {
|
||||
const initial = createFixture({
|
||||
skillFiles: {
|
||||
'SKILL.md': '# Initial ECC skill\n',
|
||||
},
|
||||
});
|
||||
let expanded;
|
||||
try {
|
||||
applyInstallPlan(initial.plan);
|
||||
expanded = createFixture({
|
||||
skillFiles: {
|
||||
'SKILL.md': '# Updated ECC skill\n',
|
||||
'references/guide.md': '# ECC guide\n',
|
||||
'references/new.md': '# New managed file\n',
|
||||
},
|
||||
});
|
||||
const expandedOriginalTargetRoot = expanded.targetRoot;
|
||||
expanded.homeDir = initial.homeDir;
|
||||
expanded.projectRoot = initial.projectRoot;
|
||||
expanded.targetRoot = initial.targetRoot;
|
||||
expanded.installStatePath = initial.installStatePath;
|
||||
expanded.operations = expanded.operations.map(operation => ({
|
||||
...operation,
|
||||
destinationPath: path.join(
|
||||
initial.targetRoot,
|
||||
path.relative(expandedOriginalTargetRoot, operation.destinationPath)
|
||||
),
|
||||
}));
|
||||
expanded.plan = {
|
||||
...expanded.plan,
|
||||
targetRoot: initial.targetRoot,
|
||||
installRoot: initial.targetRoot,
|
||||
installStatePath: initial.installStatePath,
|
||||
operations: expanded.operations,
|
||||
statePreview: {
|
||||
...expanded.plan.statePreview,
|
||||
target: {
|
||||
...expanded.plan.statePreview.target,
|
||||
root: initial.targetRoot,
|
||||
installStatePath: initial.installStatePath,
|
||||
},
|
||||
operations: expanded.operations,
|
||||
},
|
||||
};
|
||||
|
||||
const userGuidePath = expanded.operations[1].destinationPath;
|
||||
fs.mkdirSync(path.dirname(userGuidePath), { recursive: true });
|
||||
fs.writeFileSync(userGuidePath, '# User guide\n');
|
||||
|
||||
const result = applyInstallPlan(expanded.plan);
|
||||
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(expanded.operations[0].destinationPath, 'utf8'),
|
||||
'# Updated ECC skill\n'
|
||||
);
|
||||
assert.strictEqual(fs.readFileSync(userGuidePath, 'utf8'), '# User guide\n');
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(expanded.operations[2].destinationPath, 'utf8'),
|
||||
'# New managed file\n'
|
||||
);
|
||||
assert.ok(result.warnings.some(warning => warning.includes('guide.md')));
|
||||
|
||||
const state = readInstallState(initial.installStatePath);
|
||||
assert.ok(state.operations.some(operation => (
|
||||
operation.destinationPath === expanded.operations[0].destinationPath
|
||||
)));
|
||||
assert.ok(!state.operations.some(operation => (
|
||||
operation.destinationPath === userGuidePath
|
||||
)));
|
||||
assert.ok(state.operations.some(operation => (
|
||||
operation.destinationPath === expanded.operations[2].destinationPath
|
||||
)));
|
||||
} finally {
|
||||
cleanup(initial.tempDir);
|
||||
if (expanded) {
|
||||
cleanup(expanded.tempDir);
|
||||
}
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('tracks a partial migration so retry and uninstall remain safe', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
const missingSourcePlan = {
|
||||
...fixture.plan,
|
||||
operations: fixture.operations.map((operation, index) => (
|
||||
index === 1
|
||||
? { ...operation, sourcePath: path.join(fixture.sourceRoot, 'missing.md') }
|
||||
: operation
|
||||
)),
|
||||
};
|
||||
|
||||
assert.throws(() => applyInstallPlan(missingSourcePlan), /ENOENT/);
|
||||
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(fs.existsSync(fixture.operations[0].destinationPath));
|
||||
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(legacyOperations.every(legacyOperation => (
|
||||
bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === legacyOperation.destinationPath
|
||||
))
|
||||
)));
|
||||
assert.ok(fixture.operations.every(flatOperation => (
|
||||
bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === flatOperation.destinationPath
|
||||
))
|
||||
)));
|
||||
|
||||
const retry = applyInstallPlan(fixture.plan);
|
||||
assert.deepStrictEqual(retry.skippedOperations, []);
|
||||
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
|
||||
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('tracks a partial first install so retry does not misclassify it as user-owned', () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const missingSourcePlan = {
|
||||
...fixture.plan,
|
||||
operations: fixture.operations.map((operation, index) => (
|
||||
index === 1
|
||||
? { ...operation, sourcePath: path.join(fixture.sourceRoot, 'missing.md') }
|
||||
: operation
|
||||
)),
|
||||
};
|
||||
|
||||
assert.throws(() => applyInstallPlan(missingSourcePlan), /ENOENT/);
|
||||
assert.ok(fs.existsSync(fixture.operations[0].destinationPath));
|
||||
assert.ok(!fs.existsSync(fixture.operations[1].destinationPath));
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(fixture.operations.every(flatOperation => (
|
||||
bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === flatOperation.destinationPath
|
||||
))
|
||||
)));
|
||||
|
||||
const retry = applyInstallPlan(fixture.plan);
|
||||
assert.deepStrictEqual(retry.skippedOperations, []);
|
||||
assert.ok(!retry.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
|
||||
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('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 {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
const failingStateWriter = filePath => {
|
||||
assert.strictEqual(
|
||||
path.resolve(filePath),
|
||||
path.resolve(fixture.installStatePath)
|
||||
);
|
||||
throw new Error('injected install-state write failure');
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallPlan(fixture.plan, { writeInstallState: failingStateWriter }),
|
||||
/injected install-state write failure/
|
||||
);
|
||||
|
||||
assert.ok(legacyOperations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
const state = readInstallState(fixture.installStatePath);
|
||||
assert.ok(state.operations.every(operation => (
|
||||
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
|
||||
)));
|
||||
|
||||
const retry = applyInstallPlan(fixture.plan);
|
||||
assert.deepStrictEqual(retry.skippedOperations, []);
|
||||
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('keeps both layouts represented if the final state write fails', () => {
|
||||
const fixture = createFixture();
|
||||
let stateWriteCount = 0;
|
||||
try {
|
||||
const legacyOperations = seedLegacyInstall(fixture);
|
||||
const failFinalStateWrite = (filePath, state) => {
|
||||
assert.strictEqual(
|
||||
path.resolve(filePath),
|
||||
path.resolve(fixture.installStatePath)
|
||||
);
|
||||
stateWriteCount += 1;
|
||||
if (stateWriteCount === 2) {
|
||||
throw new Error('injected final install-state write failure');
|
||||
}
|
||||
return writeInstallState(fixture.installStatePath, state);
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallPlan(fixture.plan, { writeInstallState: failFinalStateWrite }),
|
||||
/injected final install-state write failure/
|
||||
);
|
||||
assert.ok(fixture.operations.every(operation => fs.existsSync(operation.destinationPath)));
|
||||
assert.ok(legacyOperations.every(operation => !fs.existsSync(operation.destinationPath)));
|
||||
|
||||
const bridgeState = readInstallState(fixture.installStatePath);
|
||||
assert.ok(fixture.operations.every(flatOperation => (
|
||||
bridgeState.operations.some(operation => (
|
||||
operation.destinationPath === flatOperation.destinationPath
|
||||
))
|
||||
)));
|
||||
assert.ok(bridgeState.operations.some(operation => (
|
||||
operation.destinationPath.includes(path.join('skills', 'ecc', 'demo-skill'))
|
||||
)));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
const outsideRoot = path.join(fixture.tempDir, 'outside');
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
const flatSkillRoot = path.join(fixture.targetRoot, 'skills', 'demo-skill');
|
||||
fs.mkdirSync(path.dirname(flatSkillRoot), { recursive: true });
|
||||
fs.symlinkSync(outsideRoot, flatSkillRoot, 'dir');
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallPlan(fixture.plan),
|
||||
/symlinked Claude skill path/
|
||||
);
|
||||
assert.deepStrictEqual(fs.readdirSync(outsideRoot), []);
|
||||
assert.ok(!fs.existsSync(fixture.installStatePath));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rechecks skill directories created between validation and copy', () => {
|
||||
if (process.platform === 'win32') {
|
||||
console.log(' ↷ skipped on Windows: symlink privileges vary');
|
||||
return;
|
||||
}
|
||||
|
||||
const fixture = createFixture({
|
||||
skillFiles: {
|
||||
'SKILL.md': '# Current ECC skill\n',
|
||||
},
|
||||
});
|
||||
const destinationDirectory = path.dirname(fixture.operations[0].destinationPath);
|
||||
const outsideRoot = path.join(fixture.tempDir, 'outside');
|
||||
const originalMkdirSync = fs.mkdirSync;
|
||||
|
||||
try {
|
||||
originalMkdirSync(outsideRoot, { recursive: true });
|
||||
let injectedSymlink = false;
|
||||
fs.mkdirSync = function mkdirAndReplaceWithSymlink(directoryPath, options) {
|
||||
const result = originalMkdirSync(directoryPath, options);
|
||||
if (!injectedSymlink && path.resolve(directoryPath) === path.resolve(destinationDirectory)) {
|
||||
fs.rmSync(destinationDirectory, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideRoot, destinationDirectory, 'dir');
|
||||
injectedSymlink = true;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallPlan(fixture.plan, { writeInstallState() {} }),
|
||||
/symlinked Claude skill path/
|
||||
);
|
||||
assert.strictEqual(injectedSymlink, true);
|
||||
assert.deepStrictEqual(fs.readdirSync(outsideRoot), []);
|
||||
} finally {
|
||||
fs.mkdirSync = originalMkdirSync;
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const fixture = createFixture({
|
||||
skillFiles: {
|
||||
'SKILL.md': '# Current ECC skill\n',
|
||||
},
|
||||
});
|
||||
try {
|
||||
const outsideRoot = path.join(fixture.tempDir, 'outside');
|
||||
const outsideTarget = path.join(outsideRoot, 'not-created.md');
|
||||
fs.mkdirSync(outsideRoot, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(fixture.operations[0].destinationPath), { recursive: true });
|
||||
fs.symlinkSync(outsideTarget, fixture.operations[0].destinationPath, 'file');
|
||||
assert.strictEqual(fs.existsSync(fixture.operations[0].destinationPath), false);
|
||||
|
||||
assert.throws(
|
||||
() => applyInstallPlan(fixture.plan),
|
||||
/symlinked Claude skill path/
|
||||
);
|
||||
assert.ok(!fs.existsSync(outsideTarget));
|
||||
assert.ok(!fs.existsSync(fixture.installStatePath));
|
||||
} finally {
|
||||
cleanup(fixture.tempDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
|
|
@ -362,7 +362,7 @@ function runTests() {
|
|||
)));
|
||||
assert.ok(plan.operations.some(operation => (
|
||||
operation.sourceRelativePath === path.join('skills', 'demo', 'SKILL.md')
|
||||
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'ecc', 'demo', 'SKILL.md')
|
||||
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'demo', 'SKILL.md')
|
||||
)));
|
||||
assert.deepStrictEqual(plan.warnings, ['fixture warning']);
|
||||
assert.strictEqual(plan.statePreview.request.profile, 'minimal');
|
||||
|
|
@ -416,7 +416,7 @@ function runTests() {
|
|||
|
||||
assert.strictEqual(applied.applied, true);
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'rules', 'ecc', 'common', 'coding-style.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'demo', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'demo', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'src', 'app.js')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'standalone.txt')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'plugin.json')));
|
||||
|
|
|
|||
|
|
@ -634,6 +634,103 @@ 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.strictEqual(repaired.results[0].status, 'repaired');
|
||||
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'),
|
||||
fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'skills', 'tdd-workflow', 'SKILL.md'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
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-');
|
||||
|
|
|
|||
|
|
@ -10,20 +10,19 @@ const path = require('path');
|
|||
|
||||
const {
|
||||
buildInstallIndex,
|
||||
isNamespacedSource,
|
||||
rewriteRelativeLinks,
|
||||
} = require('../../scripts/lib/install/link-rewrite');
|
||||
const { createManifestInstallPlan } = require('../../scripts/lib/install-executor');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
// A claude-style namespace placement: skills/<id> -> skills/ecc/<id> and
|
||||
// A claude-style namespace placement: skills/<id> -> skills/<id> and
|
||||
// rules/<x> -> rules/ecc/<x>. Mirrors what the real adapter emits.
|
||||
function claudeNamespaceMappings() {
|
||||
return [
|
||||
{ sourceRel: 'skills/react-patterns/SKILL.md', destRel: 'skills/ecc/react-patterns/SKILL.md' },
|
||||
{ sourceRel: 'skills/react-patterns/other.md', destRel: 'skills/ecc/react-patterns/other.md' },
|
||||
{ sourceRel: 'skills/react-patterns/sub/NOTE.md', destRel: 'skills/ecc/react-patterns/sub/NOTE.md' },
|
||||
{ sourceRel: 'skills/react-patterns/SKILL.md', destRel: 'skills/react-patterns/SKILL.md' },
|
||||
{ sourceRel: 'skills/react-patterns/other.md', destRel: 'skills/react-patterns/other.md' },
|
||||
{ sourceRel: 'skills/react-patterns/sub/NOTE.md', destRel: 'skills/react-patterns/sub/NOTE.md' },
|
||||
{ sourceRel: 'rules/react/hooks.md', destRel: 'rules/ecc/react/hooks.md' },
|
||||
{ sourceRel: 'rules/react/testing.md', destRel: 'rules/ecc/react/testing.md' },
|
||||
{ sourceRel: 'rules/react/coding-style.md', destRel: 'rules/ecc/react/coding-style.md' },
|
||||
|
|
@ -64,17 +63,17 @@ function runTests() {
|
|||
for (const skill of ['react-patterns', 'react-performance', 'react-testing']) {
|
||||
if (test(`rewrites ../../rules file link for ${skill}`, () => {
|
||||
const idx = buildInstallIndex([
|
||||
{ sourceRel: `skills/${skill}/SKILL.md`, destRel: `skills/ecc/${skill}/SKILL.md` },
|
||||
{ sourceRel: `skills/${skill}/SKILL.md`, destRel: `skills/${skill}/SKILL.md` },
|
||||
{ sourceRel: 'rules/react/hooks.md', destRel: 'rules/ecc/react/hooks.md' },
|
||||
]);
|
||||
const before = 'See [rules](../../rules/react/hooks.md) for details.';
|
||||
const after = rewriteRelativeLinks(before, { sourceRel: `skills/${skill}/SKILL.md`, index: idx });
|
||||
assert.notStrictEqual(after, before, 'rewrite must change the broken link (not vacuous)');
|
||||
assert.ok(
|
||||
after.includes('](../../../rules/ecc/react/hooks.md)'),
|
||||
after.includes('](../../rules/ecc/react/hooks.md)'),
|
||||
`expected corrected link, got: ${after}`
|
||||
);
|
||||
assert.ok(!after.includes('](../../rules/'), 'broken depth must be gone');
|
||||
assert.ok(!after.includes('](../../rules/react/'), 'un-namespaced rules link must be gone');
|
||||
})) passed++; else failed++;
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +81,7 @@ function runTests() {
|
|||
const before = '- Rules: [rules/react/](../../rules/react/)';
|
||||
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
|
||||
assert.notStrictEqual(after, before);
|
||||
assert.ok(after.includes('](../../../rules/ecc/react/)'), `got: ${after}`);
|
||||
assert.ok(after.includes('](../../rules/ecc/react/)'), `got: ${after}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('leaves an intra-skill sibling link unchanged', () => {
|
||||
|
|
@ -111,7 +110,7 @@ function runTests() {
|
|||
if (test('preserves a #fragment on a rewritten link', () => {
|
||||
const before = '[hooks](../../rules/react/hooks.md#use-effect)';
|
||||
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
|
||||
assert.ok(after.includes('](../../../rules/ecc/react/hooks.md#use-effect)'), `got: ${after}`);
|
||||
assert.ok(after.includes('](../../rules/ecc/react/hooks.md#use-effect)'), `got: ${after}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not rewrite links inside fenced code blocks', () => {
|
||||
|
|
@ -123,16 +122,16 @@ function runTests() {
|
|||
].join('\n');
|
||||
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/SKILL.md', index });
|
||||
assert.ok(after.includes('[code](../../rules/react/hooks.md)'), 'code-fence link must be untouched');
|
||||
assert.ok(after.includes('[prose](../../../rules/ecc/react/hooks.md)'), 'prose link must be rewritten');
|
||||
assert.ok(after.includes('[prose](../../rules/ecc/react/hooks.md)'), 'prose link must be rewritten');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('computes depth from path math for a nested skill file', () => {
|
||||
// skills/react-patterns/sub/NOTE.md -> skills/ecc/react-patterns/sub/NOTE.md
|
||||
// skills/react-patterns/sub/NOTE.md -> skills/react-patterns/sub/NOTE.md
|
||||
// Source link is ../../../rules/react/hooks.md (3 up from sub/).
|
||||
const before = '[r](../../../rules/react/hooks.md)';
|
||||
const after = rewriteRelativeLinks(before, { sourceRel: 'skills/react-patterns/sub/NOTE.md', index });
|
||||
assert.notStrictEqual(after, before, 'nested depth must be recomputed, not hardcoded');
|
||||
assert.ok(after.includes('](../../../../rules/ecc/react/hooks.md)'), `got: ${after}`);
|
||||
assert.ok(after.includes('](../../../rules/ecc/react/hooks.md)'), `got: ${after}`);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('is a no-op for a non-namespacing (identity) placement', () => {
|
||||
|
|
@ -148,24 +147,6 @@ function runTests() {
|
|||
assert.strictEqual(after, before);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Guards the apply-layer gate: only namespaced files leave the byte-copy
|
||||
// path, so non-namespaced markdown is still copied verbatim.
|
||||
if (test('isNamespacedSource flags only files whose install path changed', () => {
|
||||
assert.strictEqual(
|
||||
isNamespacedSource('skills/react-patterns/SKILL.md', index), true,
|
||||
'a namespaced skill file must be flagged'
|
||||
);
|
||||
const identity = buildInstallIndex(identityMappings());
|
||||
assert.strictEqual(
|
||||
isNamespacedSource('skills/react-patterns/SKILL.md', identity), false,
|
||||
'an identity-mapped file must stay on the byte-copy path'
|
||||
);
|
||||
assert.strictEqual(
|
||||
isNamespacedSource('skills/not-in-plan/SKILL.md', index), false,
|
||||
'a file the plan does not install is not namespaced'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
// Integration: real repo content + real claude plan. Every rewritten link in
|
||||
// the three React skills must resolve to a destination the SAME plan installs.
|
||||
if (test('real React skills: rewritten rules links resolve to installed targets', () => {
|
||||
|
|
@ -201,13 +182,16 @@ function runTests() {
|
|||
const content = fs.readFileSync(path.join(REPO_ROOT, sourceRel), 'utf8');
|
||||
assert.ok(content.includes('](../../rules/'), `${sourceRel} should have a broken link pre-fix`);
|
||||
const rewritten = rewriteRelativeLinks(content, { sourceRel, index: realIndex });
|
||||
assert.ok(!rewritten.includes('](../../rules/'), `${sourceRel} still has the broken depth`);
|
||||
assert.ok(
|
||||
!rewritten.includes('](../../rules/react/'),
|
||||
`${sourceRel} still links to un-namespaced rules`
|
||||
);
|
||||
|
||||
// Only links we actually changed are validated here; cross-skill links to
|
||||
// skills outside this module subset are legitimately left untouched.
|
||||
const before = extractLinks(content);
|
||||
const after = extractLinks(rewritten);
|
||||
const installedSkillDir = path.posix.dirname(`skills/ecc/${skill}/SKILL.md`);
|
||||
const installedSkillDir = path.posix.dirname(`skills/${skill}/SKILL.md`);
|
||||
for (let i = 0; i < after.length; i += 1) {
|
||||
if (after[i] === before[i]) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ function runTests() {
|
|||
assert.strictEqual(statePath, path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans claude rules and skills under ECC-managed subdirectories', () => {
|
||||
if (test('plans namespaced Claude rules and flat discoverable skills', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const homeDir = '/Users/example';
|
||||
|
||||
|
|
@ -101,9 +101,9 @@ function runTests() {
|
|||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
|
||||
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'ecc', 'tdd-workflow')
|
||||
&& operation.destinationPath === path.join(homeDir, '.claude', 'skills', 'tdd-workflow')
|
||||
)),
|
||||
'Should install bundled Claude skills under skills/ecc'
|
||||
'Should install bundled Claude skills under skills'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
|
@ -884,7 +884,7 @@ function runTests() {
|
|||
assert.ok(byTarget.supports('claude-project'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('plans claude-project rules and skills under project-scope ECC-managed subdirectories', () => {
|
||||
if (test('plans project-scoped namespaced Claude rules and flat skills', () => {
|
||||
const repoRoot = path.join(__dirname, '..', '..');
|
||||
const projectRoot = '/workspace/app';
|
||||
|
||||
|
|
@ -917,9 +917,9 @@ function runTests() {
|
|||
assert.ok(
|
||||
plan.operations.some(operation => (
|
||||
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
|
||||
&& operation.destinationPath === path.join(projectRoot, '.claude', 'skills', 'ecc', 'tdd-workflow')
|
||||
&& operation.destinationPath === path.join(projectRoot, '.claude', 'skills', 'tdd-workflow')
|
||||
)),
|
||||
'Should install bundled skills under project-scope skills/ecc'
|
||||
'Should install bundled skills under project-scope skills'
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ function runTests() {
|
|||
'Should install Japanese README under docs/ja-JP'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'configure-ecc', 'SKILL.md')),
|
||||
!fs.existsSync(path.join(claudeRoot, 'skills', 'configure-ecc', 'SKILL.md')),
|
||||
'Locale-only install should not install English skills'
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -658,7 +658,7 @@ function runTests() {
|
|||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// Security skill should be installed (from --with)
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'security-review', 'SKILL.md')),
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'security-review', 'SKILL.md')),
|
||||
'Should install security-review skill from --with');
|
||||
// Core profile modules should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
|
||||
|
|
@ -697,12 +697,12 @@ function runTests() {
|
|||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// Orchestration skills should NOT be installed (from --without)
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')),
|
||||
assert.ok(!fs.existsSync(path.join(claudeRoot, 'skills', 'dmux-workflows', 'SKILL.md')),
|
||||
'Should not install orchestration skills');
|
||||
// Developer profile base modules should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
|
||||
'Should install core rules');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')),
|
||||
'Should install workflow skills');
|
||||
|
||||
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
|
|
@ -735,7 +735,7 @@ function runTests() {
|
|||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
// framework-language skill (from lang:typescript) should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')),
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')),
|
||||
'Should install framework-language skills');
|
||||
// Its dependencies should be installed
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')),
|
||||
|
|
@ -771,11 +771,11 @@ function runTests() {
|
|||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'continuous-learning-v2', 'SKILL.md')),
|
||||
fs.existsSync(path.join(claudeRoot, 'skills', 'continuous-learning-v2', 'SKILL.md')),
|
||||
'Should install continuous-learning-v2'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')),
|
||||
!fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')),
|
||||
'Should not install unrelated workflow-quality skills'
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@ function runTests() {
|
|||
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'coding-standards', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
|
||||
|
||||
const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json');
|
||||
|
|
@ -133,23 +133,23 @@ function runTests() {
|
|||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md');
|
||||
const skillPath = path.join(claudeRoot, 'skills', 'react-patterns', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed');
|
||||
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('../../../rules/ecc/react/'),
|
||||
content.includes('../../rules/ecc/react/'),
|
||||
'source-relative rules link should be rewritten for the ecc/ namespace'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('](../../rules/'),
|
||||
'no un-namespaced ](../../rules/ links should remain'
|
||||
!content.includes('](../../rules/react/'),
|
||||
'no un-namespaced ](../../rules/react/ links should remain'
|
||||
);
|
||||
|
||||
// The rewritten link must resolve to a file that actually exists on disk.
|
||||
const linkTarget = path.join(
|
||||
path.dirname(skillPath),
|
||||
'../../../rules/ecc/react/hooks.md'
|
||||
'../../rules/ecc/react/hooks.md'
|
||||
);
|
||||
assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist');
|
||||
} finally {
|
||||
|
|
@ -468,11 +468,101 @@ function runTests() {
|
|||
|
||||
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes('user-owned'), result.stdout);
|
||||
assert.ok(result.stdout.includes('Skipped operations:'), result.stdout);
|
||||
|
||||
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', 'ecc', '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'))
|
||||
)));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports applied and skipped user-owned Claude skill operations in JSON', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const userSkillPath = path.join(
|
||||
homeDir,
|
||||
'.claude',
|
||||
'skills',
|
||||
'tdd-workflow',
|
||||
'SKILL.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(userSkillPath), { recursive: true });
|
||||
fs.writeFileSync(userSkillPath, '# User custom skill\n');
|
||||
|
||||
const result = run(['--skills', 'tdd-workflow', '--json'], {
|
||||
cwd: projectDir,
|
||||
homeDir,
|
||||
});
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.strictEqual(payload.dryRun, false);
|
||||
assert.ok(payload.result.plannedOperations.length > 0);
|
||||
assert.ok(payload.result.operations.length > 0);
|
||||
assert.ok(payload.result.skippedOperations.length > 0);
|
||||
assert.strictEqual(
|
||||
payload.result.operations.length + payload.result.skippedOperations.length,
|
||||
payload.result.plannedOperations.length
|
||||
);
|
||||
assert.ok(payload.result.skippedOperations.every(operation => (
|
||||
operation.destinationPath.startsWith(path.dirname(userSkillPath))
|
||||
)));
|
||||
assert.ok(!payload.result.operations.some(operation => (
|
||||
operation.destinationPath.startsWith(path.dirname(userSkillPath))
|
||||
)));
|
||||
assert.ok(payload.result.warnings.some(warning => warning.includes('user-owned')));
|
||||
assert.strictEqual(fs.readFileSync(userSkillPath, 'utf8'), '# User custom skill\n');
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) 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);
|
||||
|
|
@ -893,8 +983,8 @@ function runTests() {
|
|||
const result = run(['--config', configPath], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
|
|
@ -925,8 +1015,8 @@ function runTests() {
|
|||
const result = run([], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'ecc', 'dmux-workflows', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md')));
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'skills', 'dmux-workflows', 'SKILL.md')));
|
||||
|
||||
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
|
||||
assert.strictEqual(state.request.profile, 'developer');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue