Files
ECC/tests/lib/install-executor.test.js
T

905 lines
38 KiB
JavaScript

/**
* Direct tests for scripts/lib/install-executor.js.
*/
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
applyInstallPlan,
createLegacyCompatInstallPlan,
createLegacyInstallPlan,
createManifestInstallPlan,
dedupeCopyFileOperations,
listAvailableLanguages,
} = require('../../scripts/lib/install-executor');
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeFile(root, relativePath, content = '') {
const filePath = path.join(root, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
return filePath;
}
function writeJson(root, relativePath, value) {
writeFile(root, relativePath, `${JSON.stringify(value, null, 2)}\n`);
}
function operationFor(plan, suffix) {
return plan.operations.find(operation => (
operation.destinationPath.endsWith(suffix)
|| operation.sourceRelativePath.split(path.sep).join('/').endsWith(suffix.split(path.sep).join('/'))
));
}
function writeLegacySourceFixture(root) {
writeJson(root, 'package.json', { version: '9.8.7' });
writeFile(root, path.join('rules', 'common', 'coding-style.md'), '# Common\n');
writeFile(root, path.join('rules', 'common', 'nested', 'shared.md'), '# Shared\n');
writeFile(root, path.join('rules', 'common', 'node_modules', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '.git', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyd'), 'ignored\n');
writeFile(root, path.join('rules', 'typescript', 'testing.md'), '# TS\n');
writeFile(root, path.join('rules', 'python', 'testing.md'), '# Python\n');
writeFile(root, path.join('.cursor', 'rules', 'common-style.md'), '# Cursor common\n');
writeFile(root, path.join('.cursor', 'rules', 'typescript-style.md'), '# Cursor TS\n');
writeFile(root, path.join('.cursor', 'rules', 'python-style.txt'), '# Not markdown\n');
writeFile(root, path.join('.cursor', 'agents', 'planner.md'), '# Planner\n');
writeFile(root, path.join('.cursor', 'skills', 'demo', 'SKILL.md'), '# Demo\n');
writeFile(root, path.join('.cursor', 'commands', 'plan.md'), '# Plan\n');
writeFile(root, path.join('.cursor', 'hooks', 'hook.js'), 'process.exit(0);\n');
writeJson(root, path.join('.cursor', 'hooks.json'), { version: 1, hooks: {} });
writeJson(root, '.mcp.json', { mcpServers: { github: { command: 'github-mcp' } } });
writeFile(root, path.join('commands', 'plan.md'), '# Plan\n');
writeFile(root, path.join('agents', 'architect.md'), '# Architect\n');
writeFile(root, path.join('skills', 'demo', 'SKILL.md'), '# Demo\n');
}
function writeManifestSourceFixture(root) {
writeJson(root, 'package.json', { version: '1.2.3' });
writeJson(root, path.join('manifests', 'install-modules.json'), {
version: 7,
modules: [
{
id: 'fixture-core',
kind: 'fixture',
description: 'Fixture module',
paths: [
'rules',
'src',
'standalone.txt',
'missing.txt',
'skills/demo',
path.join('runtime', 'ecc', 'install-state.json'),
'.claude-plugin',
],
targets: ['claude'],
dependencies: [],
defaultInstall: true,
cost: 'light',
stability: 'stable',
},
],
});
writeJson(root, path.join('manifests', 'install-profiles.json'), {
version: 1,
profiles: {
minimal: {
description: 'Minimal fixture profile',
modules: ['fixture-core'],
},
},
});
writeFile(root, path.join('src', 'app.js'), 'console.log("app");\n');
writeFile(root, path.join('src', 'nested', 'feature.js'), 'console.log("feature");\n');
writeFile(root, path.join('src', 'node_modules', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '.git', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('src', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('src', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyd'), 'ignored\n');
writeFile(root, path.join('src', 'nested', 'ecc-install-state.json'), '{}\n');
writeFile(root, path.join('rules', 'common', 'coding-style.md'), '# Common\n');
writeFile(root, path.join('skills', 'demo', 'SKILL.md'), '# Demo\n');
writeFile(root, 'standalone.txt', 'standalone\n');
writeFile(root, path.join('runtime', 'ecc', 'install-state.json'), '{}\n');
writeJson(root, path.join('.claude-plugin', 'plugin.json'), { name: 'fixture' });
}
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing install-executor.js ===\n');
let passed = 0;
let failed = 0;
if (test('lists legacy and local rule languages while ignoring common', () => {
const sourceRoot = createTempDir('install-executor-source-');
try {
fs.mkdirSync(path.join(sourceRoot, 'rules', 'common'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'rules', 'zig'), { recursive: true });
const languages = listAvailableLanguages(sourceRoot);
assert.ok(languages.includes('typescript'));
assert.ok(languages.includes('ruby'));
assert.ok(languages.includes('rails'));
assert.ok(languages.includes('zig'));
assert.ok(!languages.includes('common'));
assert.deepStrictEqual([...languages].sort(), languages);
} finally {
cleanup(sourceRoot);
}
})) passed++; else failed++;
if (test('Claude settings write preserves unrelated changes made after preflight', () => {
const tempDir = createTempDir('install-executor-settings-race-');
try {
const homeDir = path.join(tempDir, 'home');
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
const rawPlan = createManifestInstallPlan({
sourceRoot: REPO_ROOT,
homeDir,
projectRoot,
target: 'claude',
moduleIds: ['hooks-runtime'],
});
const plan = withHookConsent(rawPlan, 'enabled');
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
applyInstallPlanDirect(plan, {
beforeOperationWrite({ operation }) {
if (operation.kind === 'update-claude-settings') {
fs.writeFileSync(settingsPath, '{"theme":"added-after-preflight"}\n');
}
},
});
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.strictEqual(settings.theme, 'added-after-preflight');
assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start'));
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('failed hook disable checkpoints the previous enabled consent state', () => {
const tempDir = createTempDir('install-executor-disable-failure-');
try {
const homeDir = path.join(tempDir, 'home');
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
const enabledPlan = withHookConsent(createManifestInstallPlan({
sourceRoot: REPO_ROOT,
homeDir,
projectRoot,
target: 'claude',
profileId: 'core',
}), 'enabled');
applyInstallPlanDirect(enabledPlan);
const declinedPlan = withHookConsent(createManifestInstallPlan({
sourceRoot: REPO_ROOT,
homeDir,
projectRoot,
target: 'claude',
profileId: 'core',
}), 'declined');
let injectedFailure = false;
assert.throws(
() => applyInstallPlanDirect(declinedPlan, {
beforeOperationWrite({ operation }) {
if (!injectedFailure && operation.kind === 'copy-file') {
injectedFailure = true;
throw new Error('injected copy failure');
}
},
}),
/injected copy failure/
);
const state = JSON.parse(fs.readFileSync(declinedPlan.installStatePath, 'utf8'));
assert.strictEqual(state.request.hookConsent, 'enabled');
assert.ok(state.resolution.selectedModules.includes('hooks-runtime'));
assert.ok(state.operations.some(operation => (
operation.kind === 'update-claude-settings'
)));
const settings = JSON.parse(fs.readFileSync(
path.join(homeDir, '.claude', 'settings.json'),
'utf8'
));
assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start'));
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('rejects unknown legacy install targets before planning', () => {
assert.throws(
() => createLegacyInstallPlan({ target: 'not-a-target' }),
/Unknown install target: not-a-target/
);
})) passed++; else failed++;
if (test('plans Claude legacy rules with warnings and state preview', () => {
const sourceRoot = createTempDir('install-executor-source-');
const homeDir = createTempDir('install-executor-home-');
const projectRoot = createTempDir('install-executor-project-');
const claudeRulesDir = path.join(homeDir, 'custom-rules');
try {
writeLegacySourceFixture(sourceRoot);
writeFile(homeDir, path.join('custom-rules', 'existing.md'), '# Existing\n');
const plan = createLegacyInstallPlan({
sourceRoot,
homeDir,
projectRoot,
claudeRulesDir,
target: 'claude',
languages: ['typescript', 'missing-lang', '../bad'],
});
assert.strictEqual(plan.mode, 'legacy');
assert.strictEqual(plan.target, 'claude');
assert.strictEqual(plan.installRoot, claudeRulesDir);
assert.ok(plan.warnings.some(warning => warning.includes('files may be overwritten')));
assert.ok(plan.warnings.some(warning => warning.includes("rules/missing-lang/ does not exist")));
assert.ok(plan.warnings.some(warning => warning.includes("Invalid language name '../bad'")));
assert.ok(operationFor(plan, path.join('custom-rules', 'common', 'coding-style.md')));
assert.ok(operationFor(plan, path.join('custom-rules', 'common', 'nested', 'shared.md')));
assert.ok(operationFor(plan, path.join('custom-rules', 'typescript', 'testing.md')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('node_modules')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.git')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('__pycache__')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.pytest_cache')));
assert.ok(!plan.operations.some(operation => /\.(?:pyc|pyo|pyd)$/.test(operation.sourceRelativePath)));
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['typescript', 'missing-lang', '../bad']);
assert.strictEqual(plan.statePreview.request.legacyMode, true);
assert.strictEqual(plan.statePreview.source.repoVersion, '9.8.7');
assert.strictEqual(plan.statePreview.source.manifestVersion, 1);
} finally {
cleanup(sourceRoot);
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('plans Claude legacy rules under the default ECC-managed rules directory', () => {
const sourceRoot = createTempDir('install-executor-source-');
const homeDir = createTempDir('install-executor-home-');
const projectRoot = createTempDir('install-executor-project-');
try {
writeLegacySourceFixture(sourceRoot);
writeFile(homeDir, path.join('.claude', 'rules', 'common', 'coding-style.md'), '# User custom rule\n');
const plan = createLegacyInstallPlan({
sourceRoot,
homeDir,
projectRoot,
target: 'claude',
languages: ['typescript'],
});
const managedRulesDir = path.join(homeDir, '.claude', 'rules', 'ecc');
assert.strictEqual(plan.installRoot, managedRulesDir);
assert.ok(operationFor(plan, path.join('.claude', 'rules', 'ecc', 'common', 'coding-style.md')));
assert.ok(operationFor(plan, path.join('.claude', 'rules', 'ecc', 'typescript', 'testing.md')));
assert.ok(!operationFor(plan, path.join('.claude', 'rules', 'common', 'coding-style.md')));
assert.ok(!plan.warnings.some(warning => warning.includes('files may be overwritten')));
} finally {
cleanup(sourceRoot);
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('plans Cursor legacy assets and JSON merge payloads', () => {
const sourceRoot = createTempDir('install-executor-source-');
const projectRoot = createTempDir('install-executor-project-');
const homeDir = createTempDir('install-executor-home-');
try {
writeLegacySourceFixture(sourceRoot);
const plan = createLegacyInstallPlan({
sourceRoot,
projectRoot,
homeDir,
target: 'cursor',
languages: ['typescript', 'ruby', 'bad/name'],
});
const targetRoot = path.join(projectRoot, '.cursor');
assert.strictEqual(plan.installRoot, targetRoot);
assert.ok(operationFor(plan, path.join('.cursor', 'rules', 'common-style.md')));
assert.ok(operationFor(plan, path.join('.cursor', 'rules', 'typescript-style.md')));
assert.ok(operationFor(plan, path.join('.cursor', 'agents', 'ecc-planner.md')));
assert.ok(!plan.operations.some(operation => (
operation.destinationPath.endsWith(path.join('.cursor', 'agents', 'planner.md'))
)));
assert.ok(operationFor(plan, path.join('.cursor', 'skills', 'demo', 'SKILL.md')));
assert.ok(operationFor(plan, path.join('.cursor', 'commands', 'plan.md')));
assert.ok(operationFor(plan, path.join('.cursor', 'hooks', 'hook.js')));
assert.ok(operationFor(plan, path.join('.cursor', 'hooks.json')));
const mergeOperation = plan.operations.find(operation => operation.kind === 'merge-json');
assert.ok(mergeOperation, 'Should merge shared MCP config into Cursor');
assert.deepStrictEqual(mergeOperation.mergePayload.mcpServers.github.command, 'github-mcp');
assert.ok(plan.warnings.some(warning => warning.includes("No Cursor rules for 'ruby'")));
assert.ok(plan.warnings.some(warning => warning.includes("Invalid language name 'bad/name'")));
assert.strictEqual(plan.statePreview.target.id, 'cursor-project');
} finally {
cleanup(sourceRoot);
cleanup(projectRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('surfaces invalid Cursor MCP JSON while planning legacy install', () => {
const sourceRoot = createTempDir('install-executor-source-');
const projectRoot = createTempDir('install-executor-project-');
const homeDir = createTempDir('install-executor-home-');
try {
writeLegacySourceFixture(sourceRoot);
fs.writeFileSync(path.join(sourceRoot, '.mcp.json'), '[]\n', 'utf8');
assert.throws(
() => createLegacyInstallPlan({ sourceRoot, projectRoot, homeDir, target: 'cursor' }),
/Invalid \.mcp\.json/
);
} finally {
cleanup(sourceRoot);
cleanup(projectRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('plans Antigravity legacy files with flattened rule names', () => {
const sourceRoot = createTempDir('install-executor-source-');
const projectRoot = createTempDir('install-executor-project-');
const homeDir = createTempDir('install-executor-home-');
try {
writeLegacySourceFixture(sourceRoot);
writeFile(projectRoot, path.join('.agents', 'rules', 'existing.md'), '# Existing\n');
const plan = createLegacyInstallPlan({
sourceRoot,
projectRoot,
homeDir,
target: 'antigravity',
languages: ['typescript', 'missing-lang', 'bad/name'],
});
assert.strictEqual(plan.installRoot, path.join(projectRoot, '.agents'));
assert.ok(plan.warnings.some(warning => warning.includes('files may be overwritten')));
assert.ok(plan.warnings.some(warning => warning.includes("rules/missing-lang/ does not exist")));
assert.ok(plan.warnings.some(warning => warning.includes("Invalid language name 'bad/name'")));
assert.ok(operationFor(plan, path.join('.agents', 'rules', 'common-coding-style.md')));
assert.ok(operationFor(plan, path.join('.agents', 'rules', 'typescript-testing.md')));
assert.ok(operationFor(plan, path.join('.agents', 'workflows', 'plan.md')));
const agentOperation = plan.operations.find(operation => (
operation.destinationPath.endsWith(path.join('.agents', 'agents', 'architect.md'))
));
assert.ok(agentOperation);
assert.strictEqual(agentOperation.contentTransform, 'antigravity-agent-frontmatter');
assert.ok(plan.operations.some(operation => (
operation.destinationPath.endsWith(path.join('.agents', 'skills', 'demo', 'SKILL.md'))
)));
assert.strictEqual(plan.statePreview.target.id, 'antigravity-project');
} finally {
cleanup(sourceRoot);
cleanup(projectRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('materializes manifest scaffold operations and filters generated runtime state', () => {
const sourceRoot = createTempDir('install-executor-source-');
const homeDir = createTempDir('install-executor-home-');
try {
writeManifestSourceFixture(sourceRoot);
const plan = createManifestInstallPlan({
sourceRoot,
homeDir,
target: 'claude',
profileId: 'minimal',
requestIncludeComponentIds: ['capability:fixture'],
requestExcludeComponentIds: ['capability:skip'],
warnings: ['fixture warning'],
});
const normalizedSources = plan.operations.map(operation => (
operation.sourceRelativePath.split(path.sep).join('/')
));
assert.ok(normalizedSources.includes('src/app.js'));
assert.ok(normalizedSources.includes('src/nested/feature.js'));
assert.ok(normalizedSources.includes('rules/common/coding-style.md'));
assert.ok(normalizedSources.includes('skills/demo/SKILL.md'));
assert.ok(normalizedSources.includes('standalone.txt'));
assert.ok(normalizedSources.includes('.claude-plugin/plugin.json'));
assert.ok(!normalizedSources.includes('missing.txt'));
assert.ok(!normalizedSources.includes('runtime/ecc/install-state.json'));
assert.ok(!normalizedSources.includes('src/nested/ecc-install-state.json'));
assert.ok(!normalizedSources.some(source => source.includes('node_modules')));
assert.ok(!normalizedSources.some(source => source.includes('.git')));
assert.ok(!normalizedSources.some(source => source.includes('__pycache__')));
assert.ok(!normalizedSources.some(source => source.includes('.pytest_cache')));
assert.ok(!normalizedSources.some(source => /\.(?:pyc|pyo|pyd)$/.test(source)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('.claude-plugin', 'plugin.json')
&& operation.destinationPath === path.join(homeDir, '.claude', 'plugin.json')
)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('rules', 'common', 'coding-style.md')
&& operation.destinationPath === path.join(homeDir, '.claude', 'rules', 'ecc', 'common', 'coding-style.md')
)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('skills', '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');
assert.deepStrictEqual(plan.statePreview.request.includeComponents, ['capability:fixture']);
assert.deepStrictEqual(plan.statePreview.request.excludeComponents, ['capability:skip']);
assert.strictEqual(plan.statePreview.source.repoVersion, '1.2.3');
assert.strictEqual(plan.statePreview.source.manifestVersion, 7);
} finally {
cleanup(sourceRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('plans one resolved Claude settings hook registration for home and project targets', () => {
const tempDir = createTempDir('install-executor-claude-hooks-');
try {
for (const target of ['claude', 'claude-project']) {
const quoted = process.platform === 'win32' ? 'quoted' : '"quoted"';
const homeDir = path.join(tempDir, `${target} home ${quoted} $dollar %percent%`);
const projectRoot = path.join(tempDir, `${target} project ${quoted} $dollar %percent%`);
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
const plan = createManifestInstallPlan({
sourceRoot: REPO_ROOT,
homeDir,
projectRoot,
target,
moduleIds: ['hooks-runtime'],
});
const expectedRoot = target === 'claude'
? path.join(homeDir, '.claude')
: path.join(projectRoot, '.claude');
const settingsOperations = plan.operations.filter(operation => (
operation.kind === 'update-claude-settings'
));
assert.strictEqual(settingsOperations.length, 1, `${target} should plan one settings update`);
const operation = settingsOperations[0];
assert.strictEqual(operation.moduleId, 'hooks-runtime');
assert.strictEqual(
operation.sourceRelativePath.split(path.sep).join('/'),
'hooks/hooks.json'
);
assert.strictEqual(operation.destinationPath, path.join(expectedRoot, 'settings.json'));
assert.ok(operation.managedHooks);
assert.ok(operation.managedHooks.SessionStart.some(entry => (
entry.id === 'session:start'
)));
const commands = Object.values(operation.managedHooks)
.flat()
.flatMap(entry => entry.hooks || [])
.map(hook => hook.command)
.filter(command => typeof command === 'string');
const encodedRoot = Buffer.from(expectedRoot, 'utf8').toString('base64');
assert.ok(commands.some(command => command.includes(encodedRoot)));
assert.ok(commands.every(command => !command.includes(expectedRoot)));
assert.ok(
commands.every(command => !command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')),
`${target} commands should not depend on an unset CLAUDE_PLUGIN_ROOT`
);
if (process.platform !== 'win32') {
for (const command of commands) {
const syntaxCheck = spawnSync('/bin/sh', ['-n', '-c', command], {
encoding: 'utf8',
});
assert.strictEqual(
syntaxCheck.status,
0,
`${target} hook command should remain shell-safe: ${syntaxCheck.stderr}`
);
}
}
assert.ok(!plan.operations.some(candidate => (
candidate.kind === 'copy-file'
&& candidate.sourceRelativePath.split(path.sep).join('/') === 'hooks/hooks.json'
)));
const stateOperation = plan.statePreview.operations.find(candidate => (
candidate.kind === 'update-claude-settings'
));
assert.deepStrictEqual(stateOperation.managedHooks, operation.managedHooks);
}
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('Claude commit-attribution atomic write failures abort installation', () => {
const tempDir = createTempDir('install-executor-attribution-failure-');
const originalRenameSync = fs.renameSync;
try {
const homeDir = path.join(tempDir, 'home');
const projectRoot = path.join(tempDir, 'project');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
const rawPlan = createManifestInstallPlan({
sourceRoot: REPO_ROOT,
homeDir,
projectRoot,
target: 'claude',
moduleIds: ['hooks-runtime'],
});
const plan = withHookConsent(rawPlan, 'enabled');
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
let settingsCommitCount = 0;
fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) {
if (path.resolve(String(destinationPath)) === path.resolve(settingsPath)) {
settingsCommitCount += 1;
if (settingsCommitCount === 2) {
throw new Error('injected attribution rename failure');
}
}
return originalRenameSync.call(fs, sourcePath, destinationPath);
};
assert.throws(
() => applyInstallPlanDirect(plan),
/injected attribution rename failure/
);
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start'));
assert.strictEqual(Object.hasOwn(settings, 'includeCoAuthoredBy'), false);
} finally {
fs.renameSync = originalRenameSync;
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('creates legacy compatibility manifest plans from language selections', () => {
const projectRoot = createTempDir('install-executor-project-');
const homeDir = createTempDir('install-executor-home-');
try {
const plan = createLegacyCompatInstallPlan({
sourceRoot: REPO_ROOT,
projectRoot,
homeDir,
target: 'cursor',
legacyLanguages: ['rust'],
});
assert.strictEqual(plan.mode, 'legacy-compat');
assert.deepStrictEqual(plan.legacyLanguages, ['rust']);
assert.ok(plan.selectedModuleIds.includes('framework-language'));
assert.strictEqual(plan.statePreview.request.legacyMode, true);
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['rust']);
assert.deepStrictEqual(plan.statePreview.request.modules, []);
} finally {
cleanup(projectRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('applyInstallPlan re-export applies a manifest plan and writes install state', () => {
const sourceRoot = createTempDir('install-executor-source-');
const homeDir = createTempDir('install-executor-home-');
try {
writeManifestSourceFixture(sourceRoot);
const plan = createManifestInstallPlan({
sourceRoot,
homeDir,
target: 'claude',
profileId: 'minimal',
});
const applied = applyInstallPlan(plan);
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', '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')));
const state = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'), 'utf8'));
assert.strictEqual(state.request.profile, 'minimal');
assert.deepStrictEqual(state.resolution.selectedModules, ['fixture-core']);
for (const operation of state.operations) {
assert.strictEqual(
operation.contentSha256,
crypto.createHash('sha256')
.update(fs.readFileSync(operation.destinationPath))
.digest('hex')
);
}
} finally {
cleanup(sourceRoot);
cleanup(homeDir);
}
})) passed++; else failed++;
if (test('per-operation guard runs after mkdir and immediately before a copy write', () => {
const tempDir = createTempDir('install-executor-write-guard-');
try {
const targetRoot = path.join(tempDir, 'target');
const sourcePath = writeFile(tempDir, path.join('source', 'security.md'), 'ecc\n');
const destinationPath = path.join(targetRoot, 'rules', 'security.md');
const plan = {
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
operations: [{
kind: 'copy-file',
moduleId: 'core',
sourcePath,
sourceRelativePath: 'rules/security.md',
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
}],
statePreview: { operations: [] },
target: 'kimi',
targetRoot,
};
const events = [];
assert.throws(
() => applyInstallPlanDirect(plan, {
beforeOperationWrite({ operation }) {
events.push(operation.destinationPath);
assert.strictEqual(fs.existsSync(path.dirname(destinationPath)), true);
assert.strictEqual(fs.existsSync(destinationPath), false);
writeFile(targetRoot, path.join('rules', 'security.md'), 'user\n');
throw new Error('late unowned collision');
},
writeInstallState() {},
}),
/late unowned collision/
);
assert.deepStrictEqual(events, [destinationPath]);
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), 'user\n');
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('dedupeCopyFileOperations keeps the last writer per destination (issue #2414)', () => {
// Mirrors the OpenCode command scenario: a generic commands/<name>.md source
// (preserve-relative-path) and an override .opencode/commands/<name>.md source
// (sync-root-children) both write the same destination. Before the fix both
// ops were recorded, so `doctor` reported perpetual drift and `repair`
// clobbered the override. Only the last writer (the override) should survive.
const dest = '/home/.opencode/commands/build-fix.md';
const operations = [
{ kind: 'copy-file', sourceRelativePath: 'commands/build-fix.md', destinationPath: dest, strategy: 'preserve-relative-path' },
{ kind: 'copy-file', sourceRelativePath: '.opencode/commands/build-fix.md', destinationPath: dest, strategy: 'sync-root-children' },
{ kind: 'copy-file', sourceRelativePath: 'commands/other.md', destinationPath: '/home/.opencode/commands/other.md', strategy: 'preserve-relative-path' },
];
const deduped = dedupeCopyFileOperations(operations);
const destinations = deduped
.filter(operation => operation.kind === 'copy-file')
.map(operation => operation.destinationPath);
assert.deepStrictEqual(
destinations,
[dest, '/home/.opencode/commands/other.md'],
'each copy-file destination must appear exactly once'
);
const survivor = deduped.find(operation => operation.destinationPath === dest);
assert.strictEqual(
survivor.sourceRelativePath,
'.opencode/commands/build-fix.md',
'the last writer (override) must win, not the generic source'
);
})) passed++; else failed++;
if (test('dedupeCopyFileOperations leaves non copy-file operations and order intact', () => {
// merge-json operations legitimately accumulate into a shared config file, so
// multiple writes to one destination must be preserved; only redundant
// copy-file writes are collapsed, and surviving ops keep their relative order.
const mergeDest = '/home/.opencode/opencode.json';
const operations = [
{ kind: 'merge-json', sourceRelativePath: 'a.json', destinationPath: mergeDest },
{ kind: 'copy-file', sourceRelativePath: 'src/x.md', destinationPath: '/home/x.md' },
{ kind: 'merge-json', sourceRelativePath: 'b.json', destinationPath: mergeDest },
{ kind: 'copy-file', sourceRelativePath: 'other/x.md', destinationPath: '/home/x.md' },
{ kind: 'remove', destinationPath: '/home/legacy.md' },
];
const deduped = dedupeCopyFileOperations(operations);
assert.deepStrictEqual(
deduped.map(operation => `${operation.kind}:${operation.sourceRelativePath || operation.destinationPath}`),
['merge-json:a.json', 'merge-json:b.json', 'copy-file:other/x.md', 'remove:/home/legacy.md'],
'both merge-json writes and the remove op survive; only the shadowed copy-file is dropped, order preserved'
);
})) passed++; else failed++;
if (test('applyInstallPlan refuses generic install writes outside the target root', () => {
const tempDir = createTempDir('install-executor-safety-');
try {
const sourceRoot = path.join(tempDir, 'source');
const targetRoot = path.join(tempDir, 'project', '.kimi-code');
const outsidePath = path.join(tempDir, 'outside.txt');
const sourcePath = writeFile(sourceRoot, 'skills/demo/SKILL.md', '# Demo\n');
const plan = {
mode: 'manifest',
target: 'kimi',
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
sourceRoot,
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc-install-state.json'),
warnings: [],
statePreview: {
target: 'kimi',
adapter: { id: 'kimi-project', target: 'kimi', kind: 'project' },
root: targetRoot,
operations: [],
},
operations: [
{
kind: 'copy-file',
moduleId: 'fixture',
sourcePath,
sourceRelativePath: 'skills/demo/SKILL.md',
destinationPath: outsidePath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
},
],
};
assert.throws(
() => applyInstallPlanDirect(plan, { writeInstallState: () => {} }),
/outside the install root/
);
assert.strictEqual(fs.existsSync(outsidePath), false);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('Claude install without hooks-runtime leaves an existing hooks config untouched', () => {
const tempDir = createTempDir('install-executor-no-hooks-');
try {
const targetRoot = path.join(tempDir, 'home', '.claude');
const hooksPath = writeFile(
targetRoot,
'hooks/hooks.json',
'{"hooks":{"SessionStart":[{"command":"$CLAUDE_PLUGIN_ROOT/original.js"}]}}\n'
);
const before = fs.readFileSync(hooksPath, 'utf8');
const plan = {
mode: 'manifest',
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
sourceRoot: path.join(tempDir, 'source'),
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
warnings: [],
statePreview: {
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
root: targetRoot,
operations: [],
},
operations: [],
};
applyInstallPlanDirect(plan, { writeInstallState() {} });
assert.strictEqual(fs.readFileSync(hooksPath, 'utf8'), before);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('Claude hooks install refuses a symlinked hooks destination', () => {
if (process.platform === 'win32') return;
const tempDir = createTempDir('install-executor-hooks-symlink-');
try {
const sourceRoot = path.join(tempDir, 'source');
const targetRoot = path.join(tempDir, 'home', '.claude');
const outsideRoot = path.join(tempDir, 'outside');
const sourcePath = writeFile(
sourceRoot,
'hooks/hooks.json',
'{"hooks":{"SessionStart":[]}}\n'
);
fs.mkdirSync(targetRoot, { recursive: true });
fs.mkdirSync(outsideRoot, { recursive: true });
fs.symlinkSync(outsideRoot, path.join(targetRoot, 'hooks'), 'dir');
const plan = {
mode: 'manifest',
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
sourceRoot,
targetRoot,
installRoot: targetRoot,
installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'),
warnings: [],
hookConsent: 'enabled',
statePreview: {
target: 'claude',
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
root: targetRoot,
operations: [],
},
operations: [{
kind: 'copy-file',
moduleId: 'hooks-runtime',
sourcePath,
sourceRelativePath: 'hooks/hooks.json',
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
}],
};
assert.throws(
() => applyInstallPlanDirect(plan, { writeInstallState() {} }),
/outside the install root|symlinked path/
);
assert.strictEqual(fs.existsSync(path.join(outsideRoot, 'hooks.json')), false);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();