mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
1336 lines
61 KiB
JavaScript
1336 lines
61 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 { normalizeInstallRequest } = require('../../scripts/lib/install/request');
|
|
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
|
|
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
|
|
const { repairInstalledStates, uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle');
|
|
|
|
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('/'))
|
|
));
|
|
}
|
|
|
|
const INERT_OPENCODE_PLUGIN = 'export default async () => ({});\n';
|
|
const OPENCODE_ENTRYPOINTS = ['ecc-hooks.ts', 'index.ts'];
|
|
|
|
function snapshotFiles(root) {
|
|
const entries = [];
|
|
function visit(directory, prefix = '') {
|
|
if (!fs.existsSync(directory)) return;
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const relativePath = path.join(prefix, entry.name);
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
entries.push([relativePath, 'directory']);
|
|
visit(fullPath, relativePath);
|
|
} else {
|
|
entries.push([relativePath, fs.readFileSync(fullPath).toString('base64')]);
|
|
}
|
|
}
|
|
}
|
|
visit(root);
|
|
return entries;
|
|
}
|
|
|
|
function createOpenCodePlan(homeDir, hookConsent = null, enabledRuntime = false) {
|
|
return createInstallPlanFromRequest(normalizeInstallRequest({
|
|
target: 'opencode',
|
|
moduleIds: enabledRuntime ? ['platform-configs', 'hooks-runtime'] : ['platform-configs'],
|
|
enableHooks: hookConsent === 'enabled',
|
|
noHooks: hookConsent === 'declined',
|
|
}), {
|
|
sourceRoot: REPO_ROOT, homeDir, projectRoot: homeDir,
|
|
exemptValidationCodes: ['opencode-plugin-not-built'],
|
|
});
|
|
}
|
|
|
|
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('rules', 'common', 'agents.md'), 'Use the `ecc:planner` agent.\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 installedAgentsRule = fs.readFileSync(
|
|
path.join(homeDir, '.claude', 'rules', 'ecc', 'common', 'agents.md'),
|
|
'utf8'
|
|
);
|
|
assert.strictEqual(installedAgentsRule, 'Use the `planner` agent.\n');
|
|
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('OpenCode home installs resolve bundled skills inside the config root', () => {
|
|
const tempDir = createTempDir('install-executor-opencode-skills-');
|
|
try {
|
|
const sourceRoot = path.join(tempDir, 'source');
|
|
writeManifestSourceFixture(sourceRoot);
|
|
const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json');
|
|
const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8'));
|
|
writeJson(sourceRoot, 'manifests/install-modules.json', {
|
|
...manifest,
|
|
modules: manifest.modules.map(module => ({
|
|
...module,
|
|
paths: ['.opencode', '.cursor', 'skills/demo'],
|
|
targets: ['opencode'],
|
|
})),
|
|
});
|
|
writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n');
|
|
writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n');
|
|
writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n');
|
|
writeFile(sourceRoot, '.cursor/foreign.txt', 'must not install\n');
|
|
const sourceConfig = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'opencode.json'), 'utf8'));
|
|
const configs = [
|
|
{
|
|
...sourceConfig,
|
|
skills: { ...sourceConfig.skills, paths: ['../skills', '/custom/skills', '../other-skills'] },
|
|
},
|
|
{ ...sourceConfig, skills: { paths: ['/custom/skills'] } },
|
|
{ plugin: ['./plugins'] },
|
|
];
|
|
|
|
for (const [index, config] of configs.entries()) {
|
|
const configSourcePath = path.join(sourceRoot, '.opencode', 'opencode.json');
|
|
writeJson(sourceRoot, '.opencode/opencode.json', config);
|
|
const originalContent = fs.readFileSync(configSourcePath, 'utf8');
|
|
const plan = createManifestInstallPlan({
|
|
sourceRoot,
|
|
projectRoot: path.join(tempDir, 'project'),
|
|
homeDir: path.join(tempDir, `home-${index}`),
|
|
target: 'opencode',
|
|
moduleIds: ['fixture-core'],
|
|
});
|
|
const configPath = path.join(plan.targetRoot, 'opencode.json');
|
|
assert.strictEqual(plan.operations.filter(operation => operation.destinationPath === configPath).length, 1);
|
|
applyInstallPlan(plan);
|
|
const installed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
const dormantConfig = { ...config, plugin: config.plugin.filter(plugin => plugin !== './plugins') };
|
|
const expected = index === 0
|
|
? { ...dormantConfig, skills: { ...config.skills, paths: ['./skills', '/custom/skills', '../other-skills'] } }
|
|
: dormantConfig;
|
|
assert.deepStrictEqual(installed, expected);
|
|
assert.strictEqual(fs.readFileSync(configSourcePath, 'utf8'), originalContent, 'repository template must stay unchanged');
|
|
assert.ok(fs.existsSync(path.join(plan.targetRoot, 'skills', 'demo', 'SKILL.md')));
|
|
assert.ok(!fs.existsSync(path.join(plan.targetRoot, '.cursor')));
|
|
if (index === 0) {
|
|
const lifecycleOptions = {
|
|
repoRoot: sourceRoot,
|
|
projectRoot: path.join(tempDir, 'project'),
|
|
homeDir: path.join(tempDir, `home-${index}`),
|
|
targets: ['opencode'],
|
|
};
|
|
fs.unlinkSync(configPath);
|
|
const repaired = repairInstalledStates(lifecycleOptions);
|
|
assert.strictEqual(repaired.results[0].status, 'repaired', repaired.results[0].error);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(configPath, 'utf8')), expected);
|
|
const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
|
|
const recordedConfig = state.operations.find(operation => operation.destinationPath === configPath);
|
|
assert.strictEqual(recordedConfig.contentSha256, crypto.createHash('sha256')
|
|
.update(fs.readFileSync(configPath)).digest('hex'));
|
|
const uninstalled = uninstallInstalledStates(lifecycleOptions);
|
|
assert.strictEqual(uninstalled.results[0].status, 'uninstalled', uninstalled.results[0].error);
|
|
assert.ok(!fs.existsSync(configPath));
|
|
}
|
|
}
|
|
} finally {
|
|
cleanup(tempDir);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('OpenCode pre-fix managed sources upgrade on repair and uninstall preserves user changes', () => {
|
|
const tempDir = createTempDir('install-executor-opencode-upgrade-');
|
|
try {
|
|
const sourceRoot = path.join(tempDir, 'source');
|
|
writeManifestSourceFixture(sourceRoot);
|
|
const modulesPath = path.join(sourceRoot, 'manifests', 'install-modules.json');
|
|
const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8'));
|
|
writeJson(sourceRoot, 'manifests/install-modules.json', {
|
|
...manifest,
|
|
modules: manifest.modules.map(module => ({
|
|
...module,
|
|
paths: ['.opencode'],
|
|
targets: ['opencode'],
|
|
})),
|
|
});
|
|
writeFile(sourceRoot, '.opencode/dist/index.js', 'export default () => ({});\n');
|
|
writeFile(sourceRoot, '.opencode/dist/plugins/index.js', 'export {};\n');
|
|
writeFile(sourceRoot, '.opencode/dist/tools/index.js', 'export {};\n');
|
|
writeJson(sourceRoot, '.opencode/opencode.json', { plugin: ['./plugins'] });
|
|
fs.cpSync(path.join(REPO_ROOT, '.opencode', 'tools'), path.join(sourceRoot, '.opencode', 'tools'), {
|
|
recursive: true,
|
|
});
|
|
const currentContent = fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'tools', 'index.ts'), 'utf8');
|
|
const legacyContent = [
|
|
'export { default as runTests } from "./run-tests.js"',
|
|
'export { default as checkCoverage } from "./check-coverage.js"',
|
|
'export { default as securityAudit } from "./security-audit.js"',
|
|
'export { default as formatCode } from "./format-code.js"',
|
|
'export { default as lintCheck } from "./lint-check.js"',
|
|
'export { default as gitSummary } from "./git-summary.js"',
|
|
'export { default as changedFiles } from "./changed-files.js"',
|
|
'export { default as dependencyAnalyzer } from "./dependency-analyzer.js"',
|
|
'',
|
|
].join('\n');
|
|
const digest = content => crypto.createHash('sha256').update(content).digest('hex');
|
|
|
|
for (const upgrade of [false, true]) {
|
|
writeFile(sourceRoot, '.opencode/tools/index.ts', legacyContent);
|
|
const homeDir = path.join(tempDir, upgrade ? 'repair-home' : 'uninstall-home');
|
|
const projectRoot = path.join(tempDir, 'project');
|
|
const plan = createManifestInstallPlan({
|
|
sourceRoot, projectRoot, homeDir, target: 'opencode', moduleIds: ['fixture-core'],
|
|
});
|
|
applyInstallPlan(plan);
|
|
const barrelPath = path.join(plan.targetRoot, 'tools', 'index.ts');
|
|
const toolPath = path.join(plan.targetRoot, 'tools', 'run-tests.ts');
|
|
const userPath = writeFile(plan.targetRoot, 'tools/user-note.txt', 'Keep my settings.\n');
|
|
const installedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
|
|
const recordedBarrel = installedState.operations.find(operation => operation.destinationPath === barrelPath);
|
|
assert.strictEqual(fs.readFileSync(barrelPath, 'utf8'), legacyContent);
|
|
assert.strictEqual(recordedBarrel.contentTransform, undefined, 'TypeScript imports are copied, not transformed');
|
|
assert.strictEqual(recordedBarrel.contentSha256, digest(legacyContent));
|
|
assert.ok(fs.existsSync(toolPath));
|
|
assert.ok(!fs.existsSync(path.join(plan.targetRoot, 'tools', 'run-tests.js')), 'legacy import has no adjacent JavaScript file');
|
|
|
|
// Upgrade the repository independently of the existing managed home.
|
|
writeFile(sourceRoot, '.opencode/tools/index.ts', currentContent);
|
|
const lifecycleOptions = { repoRoot: sourceRoot, projectRoot, homeDir, targets: ['opencode'] };
|
|
if (upgrade) {
|
|
const repaired = repairInstalledStates(lifecycleOptions);
|
|
assert.strictEqual(repaired.results.length, 1);
|
|
assert.strictEqual(repaired.results[0].status, 'repaired', JSON.stringify(repaired));
|
|
assert.ok(repaired.results[0].repairedPaths.includes(barrelPath));
|
|
const repairedContent = fs.readFileSync(barrelPath, 'utf8');
|
|
assert.strictEqual(repairedContent, currentContent);
|
|
assert.ok(repairedContent.includes('from "./run-tests.ts"'));
|
|
assert.ok(!repairedContent.includes('.js"'), 'repair replaces legacy JavaScript specifiers');
|
|
const repairedState = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8'));
|
|
const repairedBarrel = repairedState.operations.find(operation => operation.destinationPath === barrelPath);
|
|
assert.strictEqual(repairedBarrel.contentSha256, digest(currentContent));
|
|
assert.notStrictEqual(repairedBarrel.contentSha256, recordedBarrel.contentSha256);
|
|
assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n');
|
|
}
|
|
|
|
const modifiedContent = `${fs.readFileSync(toolPath, 'utf8')}\n// User test customisation.\n`;
|
|
fs.writeFileSync(toolPath, modifiedContent, 'utf8');
|
|
const uninstalled = uninstallInstalledStates(lifecycleOptions);
|
|
assert.strictEqual(uninstalled.results.length, 1);
|
|
assert.strictEqual(uninstalled.results[0].status, 'partial', JSON.stringify(uninstalled));
|
|
assert.ok(uninstalled.results[0].removedPaths.includes(barrelPath));
|
|
assert.ok(!fs.existsSync(barrelPath), 'both legacy and repaired managed barrels are recognised');
|
|
assert.deepStrictEqual(uninstalled.results[0].retainedPaths, [toolPath]);
|
|
assert.strictEqual(fs.readFileSync(toolPath, 'utf8'), modifiedContent);
|
|
assert.strictEqual(fs.readFileSync(userPath, 'utf8'), 'Keep my settings.\n');
|
|
assert.ok(fs.existsSync(plan.installStatePath), 'modified managed content retains its ownership record');
|
|
}
|
|
} 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('OpenCode profile keeps plugin source dormant until hook opt-in is consented', () => {
|
|
const homeDir = createTempDir('install-executor-opencode-boundary-');
|
|
try {
|
|
const planOptions = {
|
|
sourceRoot: REPO_ROOT,
|
|
homeDir,
|
|
projectRoot: homeDir,
|
|
exemptValidationCodes: ['opencode-plugin-not-built'],
|
|
};
|
|
const rawDefaultPlan = createManifestInstallPlan({
|
|
...planOptions,
|
|
target: 'opencode',
|
|
profileId: 'opencode',
|
|
});
|
|
assert.strictEqual(
|
|
rawDefaultPlan.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
)).contentTransform,
|
|
'opencode-disable-ecc-hooks'
|
|
);
|
|
const defaultPlan = createInstallPlanFromRequest(
|
|
normalizeInstallRequest({ target: 'opencode', profileId: 'opencode' }),
|
|
planOptions
|
|
);
|
|
const defaultConfig = defaultPlan.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
));
|
|
const defaultStateConfig = defaultPlan.statePreview.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
));
|
|
|
|
assert.strictEqual(defaultConfig.contentTransform, 'opencode-disable-ecc-hooks');
|
|
assert.strictEqual(defaultStateConfig.contentTransform, 'opencode-disable-ecc-hooks');
|
|
assert.ok(defaultPlan.operations.some(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/plugins/ecc-hooks.ts'
|
|
)), 'Default plan should still copy dormant plugin source');
|
|
|
|
for (const name of OPENCODE_ENTRYPOINTS) {
|
|
for (const operations of [defaultPlan.operations, defaultPlan.statePreview.operations]) {
|
|
const operation = operations.find(candidate => (
|
|
candidate.sourceRelativePath.split(path.sep).join('/') === `.opencode/plugins/${name}`
|
|
));
|
|
assert.ok(operation, `Plan includes ${name}`);
|
|
assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint');
|
|
}
|
|
}
|
|
applyInstallPlanDirect(defaultPlan);
|
|
for (const name of OPENCODE_ENTRYPOINTS) {
|
|
assert.strictEqual(fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'), INERT_OPENCODE_PLUGIN);
|
|
}
|
|
const installedConfig = JSON.parse(fs.readFileSync(
|
|
path.join(homeDir, '.config', 'opencode', 'opencode.json'),
|
|
'utf8'
|
|
));
|
|
assert.ok(!installedConfig.plugin.includes('./plugins'));
|
|
assert.deepStrictEqual(installedConfig.skills.paths, ['./skills']);
|
|
|
|
const enabledWithoutRuntime = createInstallPlanFromRequest(
|
|
normalizeInstallRequest({
|
|
target: 'opencode',
|
|
profileId: 'opencode',
|
|
enableHooks: true,
|
|
}),
|
|
planOptions
|
|
);
|
|
assert.strictEqual(
|
|
enabledWithoutRuntime.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
)).contentTransform,
|
|
'opencode-disable-ecc-hooks'
|
|
);
|
|
|
|
const declinedPlan = createInstallPlanFromRequest(
|
|
normalizeInstallRequest({ target: 'opencode', profileId: 'core', noHooks: true }),
|
|
planOptions
|
|
);
|
|
assert.strictEqual(
|
|
declinedPlan.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
)).contentTransform,
|
|
'opencode-disable-ecc-hooks'
|
|
);
|
|
assert.ok(!declinedPlan.operations.some(operation => operation.moduleId === 'hooks-runtime'));
|
|
|
|
const pendingPlan = createInstallPlanFromRequest(
|
|
normalizeInstallRequest({
|
|
target: 'opencode',
|
|
moduleIds: ['platform-configs', 'hooks-runtime'],
|
|
}),
|
|
planOptions
|
|
);
|
|
assert.throws(
|
|
() => applyInstallPlanDirect(pendingPlan, { writeInstallState() {} }),
|
|
/automatic hook runtime/
|
|
);
|
|
|
|
const enabledPlan = createInstallPlanFromRequest(
|
|
normalizeInstallRequest({
|
|
target: 'opencode',
|
|
moduleIds: ['platform-configs', 'hooks-runtime'],
|
|
enableHooks: true,
|
|
}),
|
|
planOptions
|
|
);
|
|
const enabledConfig = enabledPlan.operations.find(operation => (
|
|
operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json'
|
|
));
|
|
assert.strictEqual(enabledConfig.contentTransform, 'opencode-home-skills-path');
|
|
applyInstallPlanDirect(enabledPlan);
|
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(enabledConfig.destinationPath, 'utf8')).skills.paths, ['./skills']);
|
|
for (const name of OPENCODE_ENTRYPOINTS) {
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'),
|
|
name === 'index.ts' ? INERT_OPENCODE_PLUGIN : fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'plugins', name), 'utf8')
|
|
);
|
|
}
|
|
assert.strictEqual(
|
|
enabledPlan.statePreview.operations.find(operation => operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/plugins/index.ts').contentTransform,
|
|
'opencode-disable-plugin-entrypoint'
|
|
);
|
|
applyInstallPlanDirect(declinedPlan);
|
|
const declinedState = JSON.parse(fs.readFileSync(declinedPlan.installStatePath, 'utf8'));
|
|
assert.strictEqual(declinedState.request.hookConsent, 'declined');
|
|
assert.ok(!declinedState.resolution.selectedModules.includes('hooks-runtime'));
|
|
assert.ok(!JSON.parse(fs.readFileSync(path.join(declinedPlan.targetRoot, 'opencode.json'), 'utf8')).plugin.includes('./plugins'));
|
|
for (const name of OPENCODE_ENTRYPOINTS) {
|
|
const destinationPath = path.join(homeDir, '.config', 'opencode', 'plugins', name);
|
|
assert.strictEqual(fs.readFileSync(destinationPath, 'utf8'), INERT_OPENCODE_PLUGIN);
|
|
const operation = declinedState.operations.find(candidate => candidate.destinationPath === destinationPath);
|
|
assert.strictEqual(operation.contentTransform, 'opencode-disable-plugin-entrypoint');
|
|
assert.strictEqual(operation.contentSha256, crypto.createHash('sha256').update(INERT_OPENCODE_PLUGIN).digest('hex'));
|
|
}
|
|
} finally {
|
|
cleanup(homeDir);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('OpenCode disabled install refuses user-owned activation files before any writes', () => {
|
|
for (const relativePath of ['opencode.json', path.join('plugins', 'ecc-hooks.ts'), path.join('plugins', 'index.ts')]) {
|
|
const homeDir = createTempDir('install-opencode-user-owned-');
|
|
try {
|
|
const plan = createOpenCodePlan(homeDir, 'declined');
|
|
writeFile(plan.targetRoot, relativePath, relativePath === 'opencode.json'
|
|
? '{"plugin":["./plugins"],"theme":"user-owned"}\n'
|
|
: 'globalThis.userOwnedPlugin = true; export default async () => ({});\n');
|
|
const before = snapshotFiles(homeDir);
|
|
assert.throws(() => applyInstallPlanDirect(plan), /OpenCode hook|OpenCode.*activation|OpenCode.*plugin/i);
|
|
assert.deepStrictEqual(snapshotFiles(homeDir), before, `No writes when ${relativePath} is user-owned`);
|
|
} finally {
|
|
cleanup(homeDir);
|
|
}
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('OpenCode disabled install refuses modified or unverifiable managed activation files before writes', () => {
|
|
for (const scenario of ['modified-plugin', 'modified-config', 'missing-digest', 'stale-entrypoint']) {
|
|
const homeDir = createTempDir('install-opencode-managed-guard-');
|
|
try {
|
|
const enabledPlan = createOpenCodePlan(homeDir, 'enabled', true);
|
|
applyInstallPlanDirect(enabledPlan);
|
|
const state = JSON.parse(fs.readFileSync(enabledPlan.installStatePath, 'utf8'));
|
|
const pluginPath = path.join(enabledPlan.targetRoot, 'plugins', 'ecc-hooks.ts');
|
|
if (scenario === 'modified-plugin') {
|
|
fs.appendFileSync(pluginPath, '// user modification\n');
|
|
} else if (scenario === 'modified-config') {
|
|
fs.appendFileSync(path.join(enabledPlan.targetRoot, 'opencode.json'), ' \n');
|
|
} else if (scenario === 'missing-digest') {
|
|
delete state.operations.find(operation => operation.destinationPath === pluginPath).contentSha256;
|
|
writeJson(homeDir, path.relative(homeDir, enabledPlan.installStatePath), state);
|
|
} else {
|
|
const stalePath = writeFile(enabledPlan.targetRoot, path.join('plugins', 'legacy-hooks.js'), 'globalThis.legacyHook = true;\n');
|
|
state.operations.push({
|
|
kind: 'copy-file', moduleId: 'platform-configs', ownership: 'managed', scaffoldOnly: false,
|
|
sourceRelativePath: '.opencode/plugins/legacy-hooks.js', destinationPath: stalePath,
|
|
strategy: 'preserve-relative-path',
|
|
contentSha256: crypto.createHash('sha256').update(fs.readFileSync(stalePath)).digest('hex'),
|
|
});
|
|
writeJson(homeDir, path.relative(homeDir, enabledPlan.installStatePath), state);
|
|
}
|
|
const declinedPlan = createOpenCodePlan(homeDir, 'declined');
|
|
const before = snapshotFiles(homeDir);
|
|
assert.throws(() => applyInstallPlanDirect(declinedPlan), /OpenCode hook|OpenCode.*activation|OpenCode.*plugin/i);
|
|
assert.deepStrictEqual(snapshotFiles(homeDir), before, `No writes for ${scenario}`);
|
|
} finally {
|
|
cleanup(homeDir);
|
|
}
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('OpenCode decline preserves activation changed at the install write boundary', () => {
|
|
const homeDir = createTempDir('install-opencode-write-race-');
|
|
try {
|
|
const enabledPlan = createOpenCodePlan(homeDir, 'enabled', true);
|
|
applyInstallPlanDirect(enabledPlan);
|
|
const pluginPath = path.join(enabledPlan.targetRoot, 'plugins', 'ecc-hooks.ts');
|
|
const changedContent = 'globalThis.userChangedHook = true;\n';
|
|
const stateBefore = fs.readFileSync(enabledPlan.installStatePath);
|
|
let changed = false;
|
|
const declinedPlan = createOpenCodePlan(homeDir, 'declined');
|
|
assert.throws(() => applyInstallPlanDirect(declinedPlan, {
|
|
beforeOperationWrite({ operation }) {
|
|
if (operation.destinationPath === pluginPath) {
|
|
changed = true;
|
|
fs.writeFileSync(pluginPath, changedContent);
|
|
}
|
|
},
|
|
}), /OpenCode hook.*changed after preflight/i);
|
|
assert.strictEqual(changed, true);
|
|
assert.strictEqual(fs.readFileSync(pluginPath, 'utf8'), changedContent);
|
|
const previousState = JSON.parse(stateBefore.toString('utf8'));
|
|
const checkpointState = JSON.parse(fs.readFileSync(enabledPlan.installStatePath, 'utf8'));
|
|
const priorOperation = previousState.operations.find(operation => operation.destinationPath === pluginPath);
|
|
const checkpointOperation = checkpointState.operations.find(operation => operation.destinationPath === pluginPath);
|
|
assert.strictEqual(checkpointOperation.contentSha256, priorOperation.contentSha256, 'Failure checkpoint must not adopt raced activation bytes');
|
|
assert.strictEqual(checkpointState.request.hookConsent, 'enabled', 'Failed decline must retain the prior enabled decision');
|
|
} finally {
|
|
cleanup(homeDir);
|
|
}
|
|
})) 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();
|