mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 13:05:18 +02:00
Preserve the contributor change and current installer contracts. Make automatically discovered plugin entrypoints inert without explicit runtime consent, verify managed ownership before deactivation, and retain prior consent on incomplete migration.
Source-PR: https://github.com/affaan-m/ECC/pull/3008
Source-Head: f8c0c2c182
1155 lines
50 KiB
JavaScript
1155 lines
50 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 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('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('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'));
|
|
|
|
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, undefined);
|
|
applyInstallPlanDirect(enabledPlan);
|
|
for (const name of OPENCODE_ENTRYPOINTS) {
|
|
assert.strictEqual(
|
|
fs.readFileSync(path.join(homeDir, '.config', 'opencode', 'plugins', name), 'utf8'),
|
|
fs.readFileSync(path.join(REPO_ROOT, '.opencode', 'plugins', name), 'utf8')
|
|
);
|
|
}
|
|
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();
|