Merge reviewed Claude hook registration and settings containment fixes

This commit is contained in:
haelyra
2026-09-07 16:38:41 -04:00
24 changed files with 3602 additions and 340 deletions
+135 -33
View File
@@ -699,7 +699,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
InvalidEventType: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo hi' }] }]
InvalidEventType: [{ id: 'test:invalid-event', matcher: 'test', hooks: [{ type: 'command', command: 'echo hi' }] }]
}
}));
@@ -714,7 +714,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ command: 'echo hi' }] }]
PreToolUse: [{ id: 'test:missing-type', matcher: 'test', hooks: [{ command: 'echo hi' }] }]
}
}));
@@ -729,7 +729,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command' }] }]
PreToolUse: [{ id: 'test:missing-command', matcher: 'test', hooks: [{ type: 'command' }] }]
}
}));
@@ -744,7 +744,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', async: 'yes' }] }]
PreToolUse: [{ id: 'test:invalid-async', matcher: 'test', hooks: [{ type: 'command', command: 'echo', async: 'yes' }] }]
}
}));
@@ -759,7 +759,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: -5 }] }]
PreToolUse: [{ id: 'test:negative-timeout', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: -5 }] }]
}
}));
@@ -774,7 +774,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "function {"' }] }]
PreToolUse: [{ id: 'test:invalid-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "function {"' }] }]
}
}));
@@ -789,7 +789,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(1+2)"' }] }]
PreToolUse: [{ id: 'test:valid-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(1+2)"' }] }]
}
}));
@@ -803,7 +803,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', '-e', 'console.log(1)'] }] }]
PreToolUse: [{ id: 'test:array-command', matcher: 'test', hooks: [{ type: 'command', command: ['node', '-e', 'console.log(1)'] }] }]
}
}));
@@ -829,7 +829,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test' }]
PreToolUse: [{ id: 'test:missing-hooks', matcher: 'test' }]
}
}));
@@ -1396,7 +1396,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ' \t ' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ' \t ' }] }]
}
}));
@@ -1411,7 +1411,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: null }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: null }] }]
}
}));
@@ -1426,7 +1426,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 42 }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 42 }] }]
}
}));
@@ -1605,7 +1605,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: '' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: '' }] }]
}
}));
@@ -1620,7 +1620,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: [] }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: [] }] }]
}
}));
@@ -1635,7 +1635,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', 123, null] }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ['node', 123, null] }] }]
}
}));
@@ -1650,7 +1650,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 42, command: 'echo hi' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 42, command: 'echo hi' }] }]
}
}));
@@ -1665,7 +1665,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 'fast' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 'fast' }] }]
}
}));
@@ -1680,7 +1680,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 0 }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 0 }] }]
}
}));
@@ -1694,7 +1694,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
// data.hooks is undefined, so fallback to data itself
fs.writeFileSync(hooksFile, JSON.stringify({
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo ok' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo ok' }] }]
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
@@ -1796,7 +1796,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', '', 'script.js'] }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ['node', '', 'script.js'] }] }]
}
}));
@@ -1811,7 +1811,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo hi', timeout: -5 }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo hi', timeout: -5 }] }]
}
}));
@@ -1826,7 +1826,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PostToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo ok', async: 'yes' }] }]
PostToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo ok', async: 'yes' }] }]
}
}));
@@ -1847,7 +1847,7 @@ function runTests() {
manyHooks.push({ type: 'command', command: '' });
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: manyHooks }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: manyHooks }]
}
}));
@@ -1862,7 +1862,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "const x = 1 + 2; process.exit(0)"' }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "const x = 1 + 2; process.exit(0)"' }] }]
}
}));
@@ -1876,9 +1876,9 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo pre' }] }],
PostToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo post' }] }],
Stop: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo stop' }] }]
PreToolUse: [{ id: 'test:multi-event-pre', matcher: 'test', hooks: [{ type: 'command', command: 'echo pre' }] }],
PostToolUse: [{ id: 'test:multi-event-post', matcher: 'test', hooks: [{ type: 'command', command: 'echo post' }] }],
Stop: [{ id: 'test:multi-event-stop', matcher: 'test', hooks: [{ type: 'command', command: 'echo stop' }] }]
}
}));
@@ -2227,7 +2227,7 @@ function runTests() {
// After unescape chain: var a = "ok"\nconsole.log(a) (real newline) — valid JS
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command',
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command',
command: 'node -e "var a = \\"ok\\"\\nconsole.log(a)"' }] }]
}
}));
@@ -2243,7 +2243,7 @@ function runTests() {
// After unescape this becomes: var x = { — missing closing brace
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command',
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command',
command: 'node -e "var x = {"' }] }]
}
}));
@@ -2427,7 +2427,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: { run: 'echo hi' } }] }]
PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: { run: 'echo hi' } }] }]
}
}));
@@ -2446,7 +2446,7 @@ function runTests() {
// Object format: matcher entry has hooks array but NO matcher field
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{ hooks: [{ type: 'command', command: 'echo ok' }] }]
PreToolUse: [{ id: 'test:missing-matcher', hooks: [{ type: 'command', command: 'echo ok' }] }]
}
}));
@@ -2554,6 +2554,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
PreToolUse: [{
id: 'test:round72-async',
matcher: 'Write',
hooks: [{
type: 'command',
@@ -2574,6 +2575,7 @@ function runTests() {
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
PostToolUse: [{
id: 'test:round72-timeout',
matcher: 'Edit',
hooks: [{
type: 'command',
@@ -2661,8 +2663,8 @@ function runTests() {
fs.writeFileSync(hooksFile, JSON.stringify({
"$schema": "https://json.schemastore.org/claude-code-settings.json",
hooks: {
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo ok' }] }],
PostToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'echo done' }] }]
PreToolUse: [{ id: 'test:wrapped-pre', matcher: 'Write', hooks: [{ type: 'command', command: 'echo ok' }] }],
PostToolUse: [{ id: 'test:wrapped-post', matcher: 'Read', hooks: [{ type: 'command', command: 'echo done' }] }]
}
}));
@@ -2674,6 +2676,105 @@ function runTests() {
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry missing id', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{
matcher: 'Write',
hooks: [{ type: 'command', command: 'echo missing id' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1, 'Should reject wrapped matcher entries without an id');
assert.ok(result.stderr.includes('id'), `Should report missing id, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry missing a required matcher', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
SessionStart: [{
id: 'test:missing-matcher',
hooks: [{ type: 'command', command: 'echo start' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('matcher'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with an empty handlers array', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: { Stop: [{ id: 'test:empty-handlers', hooks: [] }] }
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1);
assert.ok(result.stderr.includes('hooks'), result.stderr);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects wrapped matcher entry with whitespace-only id', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{
id: ' \t',
matcher: 'Write',
hooks: [{ type: 'command', command: 'echo blank id' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1, 'Should reject whitespace-only matcher ids');
assert.ok(result.stderr.includes('id'), `Should report invalid id, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects duplicate wrapped matcher ids across events', () => {
const testDir = createTestDir();
const hooksFile = path.join(testDir, 'hooks.json');
fs.writeFileSync(hooksFile, JSON.stringify({
hooks: {
PreToolUse: [{
id: 'shared:matcher',
matcher: 'Write',
hooks: [{ type: 'command', command: 'echo pre' }]
}],
PostToolUse: [{
id: 'shared:matcher',
matcher: 'Write',
hooks: [{ type: 'command', command: 'echo post' }]
}]
}
}));
const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile);
assert.strictEqual(result.code, 1, 'Should reject matcher ids reused by another event');
assert.ok(
result.stderr.includes("duplicate id 'shared:matcher'"),
`Should report the duplicate id, got: ${result.stderr}`
);
assert.ok(
result.stderr.includes('PreToolUse[0]') && result.stderr.includes('PostToolUse[0]'),
`Should report both matcher locations, got: ${result.stderr}`
);
cleanupTestDir(testDir);
})) passed++; else failed++;
// ── Round 79: validate-commands.js warnings count suffix in output ──
console.log('\nRound 79: validate-commands.js (warnings count in output):');
@@ -2756,6 +2857,7 @@ function runTests() {
hooks: {
UserPromptSubmit: [
{
id: 'test:user-prompt-submit',
hooks: [
{ type: 'prompt', prompt: 'Summarize the request.' },
{ type: 'agent', prompt: 'Review for security issues.', model: 'gpt-5.4' },
+766
View File
@@ -0,0 +1,766 @@
/**
* Focused coverage for safely managing ECC hook entries in Claude settings.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
runWithSettingsLock,
materializeManagedHooks,
inspectManagedHooks,
mergeManagedHooks,
parseSettings,
readSettings,
repairManagedHooks,
replacePluginRootPlaceholders,
uninstallManagedHooks,
updateSettingsAtomic,
validateManagedHooks,
} = require('../../scripts/lib/install/claude-settings');
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.stack || error.message}`);
return false;
}
}
function entry(id, command, extra = {}) {
return {
matcher: '.*',
hooks: [{ type: 'command', command }],
id,
...extra,
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function assertAtomicParentReplacementRejected(stage) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-parent-race-'));
const targetRoot = path.join(tempDir, 'target');
const parkedRoot = path.join(tempDir, 'parked');
const victimRoot = path.join(tempDir, 'victim');
const settingsPath = path.join(targetRoot, 'settings.json');
const victimPath = path.join(victimRoot, 'settings.json');
const originalOpen = fs.openSync;
const originalFsync = fs.fsyncSync;
const targetContent = '{"target":true}\n';
const victimContent = '{"victim":"preserve"}\n';
let tempDescriptor;
let tempBasename;
let replaced = false;
const replaceParent = () => {
replaced = true;
fs.renameSync(targetRoot, parkedRoot);
fs.symlinkSync(victimRoot, targetRoot, process.platform === 'win32' ? 'junction' : 'dir');
// A colliding path in the replacement directory must survive error cleanup.
fs.writeFileSync(path.join(victimRoot, tempBasename), 'unrelated replacement file');
};
try {
fs.mkdirSync(targetRoot);
fs.mkdirSync(victimRoot);
fs.writeFileSync(settingsPath, targetContent);
fs.writeFileSync(victimPath, victimContent);
fs.openSync = function(file, flags, ...args) {
const isTemp = typeof file === 'string'
&& path.basename(file).startsWith('.settings.json.') && file.endsWith('.tmp');
if (isTemp) tempBasename = path.basename(file);
if (isTemp && !replaced && stage === 'open') {
// Replace immediately after the temporary descriptor has been created.
const descriptor = originalOpen.call(fs, file, flags, ...args);
tempDescriptor = descriptor;
replaceParent();
return descriptor;
}
const descriptor = originalOpen.call(fs, file, flags, ...args);
if (isTemp) tempDescriptor = descriptor;
return descriptor;
};
fs.fsyncSync = function(descriptor) {
const result = originalFsync.call(fs, descriptor);
if (!replaced && stage === 'rename' && descriptor === tempDescriptor) replaceParent();
return result;
};
assert.throws(
() => updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } })),
/parent.*changed|changed.*parent/i
);
assert.ok(replaced, 'must exercise a replacement inside the atomic writer');
assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), victimContent);
assert.strictEqual(fs.readFileSync(path.join(parkedRoot, 'settings.json'), 'utf8'), targetContent);
assert.strictEqual(fs.readFileSync(path.join(victimRoot, tempBasename), 'utf8'), 'unrelated replacement file');
} finally {
fs.openSync = originalOpen;
fs.fsyncSync = originalFsync;
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
function runTests() {
console.log('\n=== Testing install/claude-settings.js ===\n');
let passed = 0;
let failed = 0;
if (test('validates and clones a managed hook map without mutating it', () => {
const managed = {
SessionStart: [entry('session:start', 'node start.js')],
Stop: [entry('session:stop', 'node stop.js')],
};
const validated = validateManagedHooks(managed);
assert.deepStrictEqual(validated, managed);
assert.notStrictEqual(validated, managed);
assert.notStrictEqual(validated.SessionStart[0], managed.SessionStart[0]);
})) passed++; else failed++;
if (test('strictly rejects invalid managed hook maps and globally duplicate ids', () => {
const invalidValues = [
null,
[],
{},
{ SessionStart: [] },
{ SessionStart: {} },
{ SessionStart: [null] },
{ SessionStart: [[]] },
{ SessionStart: [{}] },
{ SessionStart: [{ id: ' ' }] },
{ BogusEvent: [entry('bad:event', 'bad')] },
{ SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] },
{ SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] },
];
for (const invalid of invalidValues) {
assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i);
}
assert.throws(
() => validateManagedHooks({
Stop: [entry('shared', 'a')],
SubagentStop: [entry('shared', 'b')],
}),
/expected globally unique id "shared"/
);
})) passed++; else failed++;
if (test('materializes hook roots and rejects unresolved environment references', () => {
const source = {
hooks: {
Stop: [entry(
'ecc:stop',
'var e=process.env.CLAUDE_PLUGIN_ROOT; '
+ 'process.env.CLAUDE_PLUGIN_ROOT=r; ${CLAUDE_PLUGIN_ROOT}'
)],
},
};
const before = clone(source);
const materialized = materializeManagedHooks(source, '/opt/ecc');
const command = materialized.Stop[0].hooks[0].command;
const encodedRoot = command.match(/Buffer\.from\('([^']+)','base64'\)/)[1];
assert.deepStrictEqual(source, before);
assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}'));
assert.strictEqual(Buffer.from(encodedRoot, 'base64').toString('utf8'), '/opt/ecc');
assert.throws(() => materializeManagedHooks({}, '/opt/ecc'), /hooks object/);
assert.throws(() => materializeManagedHooks(source, ''), /target root/);
assert.throws(
() => materializeManagedHooks({
hooks: {
Stop: [entry('ecc:stop', 'node -e "const e=process.env.CLAUDE_PLUGIN_ROOT"')],
},
}, '/opt/ecc'),
/Unable to resolve CLAUDE_PLUGIN_ROOT/
);
})) passed++; else failed++;
if (test('replaces every plugin-root placeholder recursively and immutably', () => {
const source = {
SessionStart: [{
id: 'session:start',
command: '${CLAUDE_PLUGIN_ROOT}/start.js:${CLAUDE_PLUGIN_ROOT}',
nested: ['${CLAUDE_PLUGIN_ROOT}/nested.js', 3, null],
}],
};
const before = clone(source);
const resolved = replacePluginRootPlaceholders(source, '/opt/ecc');
assert.deepStrictEqual(source, before);
assert.deepStrictEqual(resolved, {
SessionStart: [{
id: 'session:start',
command: '/opt/ecc/start.js:/opt/ecc',
nested: ['/opt/ecc/nested.js', 3, null],
}],
});
})) passed++; else failed++;
if (test('parseSettings accepts an object and validates every hooks event array', () => {
assert.deepStrictEqual(
parseSettings('{"theme":"dark","hooks":{"Stop":[]}}', 'memory settings'),
{ theme: 'dark', hooks: { Stop: [] } }
);
assert.throws(() => parseSettings('{', 'memory settings'), /Failed to parse memory settings/);
assert.throws(() => parseSettings('null', 'memory settings'), /expected a JSON object/);
assert.throws(() => parseSettings('[]', 'memory settings'), /expected a JSON object/);
assert.throws(
() => parseSettings('{"hooks":{"Stop":{}}}', 'memory settings'),
/hooks\.Stop.*array/
);
assert.throws(
() => parseSettings('{"hooks":[]}', 'memory settings'),
/"hooks".*object/
);
})) passed++; else failed++;
if (test('readSettings returns an empty object for ENOENT and rejects bad files', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-'));
try {
assert.deepStrictEqual(readSettings(path.join(tempDir, 'missing.json')), {});
const malformedPath = path.join(tempDir, 'malformed.json');
fs.writeFileSync(malformedPath, '{', 'utf8');
assert.throws(() => readSettings(malformedPath), /Failed to parse Claude settings/);
const invalidPath = path.join(tempDir, 'invalid.json');
fs.writeFileSync(invalidPath, '{"hooks":{"Stop":false}}', 'utf8');
assert.throws(() => readSettings(invalidPath), /hooks\.Stop.*array/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('readSettings propagates non-ENOENT read errors without converting them to empty settings', () => {
const denied = new Error('denied');
denied.code = 'EACCES';
assert.throws(
() => readSettings('/private/settings.json', {
readFileSync() {
throw denied;
},
}),
error => error === denied
);
})) passed++; else failed++;
if (test('atomic settings updates retry after a concurrent change and preserve secure mode', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-atomic-'));
const settingsPath = path.join(tempDir, 'settings.json');
let commitAttempts = 0;
try {
const result = updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, managed: true } }),
{
beforeCommit() {
commitAttempts += 1;
if (commitAttempts === 1) {
fs.writeFileSync(settingsPath, '{"theme":"concurrent"}\n', { mode: 0o600 });
}
},
}
);
assert.deepStrictEqual(result.settings, { theme: 'concurrent', managed: true });
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), result.settings);
assert.strictEqual(commitAttempts, 2);
if (process.platform !== 'win32') {
assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
for (const stage of ['open', 'rename']) {
if (test(`atomic settings updates reject parent replacement at ${stage} without touching its files`, () => {
assertAtomicParentReplacementRejected(stage);
})) passed++; else failed++;
}
if (test('atomic settings updates preserve edits made while the replacement file is staged', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-late-edit-'));
const settingsPath = path.join(tempDir, 'settings.json');
const originalFsync = fs.fsyncSync;
let changed = false;
let fsyncCalls = 0;
try {
fs.writeFileSync(settingsPath, '{"theme":"initial"}\n');
fs.fsyncSync = function(descriptor) {
const result = originalFsync.call(fs, descriptor);
// Lock creation is the first fsync; only change settings after the
// atomic writer has staged its first replacement payload.
fsyncCalls += 1;
if (!changed && fsyncCalls === 2) {
changed = true;
fs.writeFileSync(settingsPath, '{"theme":"late-edit"}\n');
}
return result;
};
updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } }));
assert.ok(changed);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { theme: 'late-edit', managed: true });
} finally {
fs.fsyncSync = originalFsync;
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('atomic settings updates recover a stale invalid lock after its lease', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-stale-lock-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
fs.writeFileSync(lockPath, '', { mode: 0o600 });
const stale = new Date(Date.now() - (10 * 60 * 1000));
fs.utimesSync(lockPath, stale, stale);
updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, recovered: true } })
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
recovered: true,
});
assert.ok(!fs.existsSync(lockPath));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('atomic settings updates serialize nested ECC writers and release the lock', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
updateSettingsAtomic(settingsPath, settings => {
assert.throws(
() => updateSettingsAtomic(
settingsPath,
nested => ({ settings: { ...nested, nested: true } })
),
/Another ECC process is updating Claude settings/
);
return { settings: { ...settings, outer: true } };
});
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
outer: true,
});
assert.ok(!fs.existsSync(lockPath));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('atomic settings updates honor an already-held lock', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-held-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, held: true } }),
{ lockHeld: true }
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { held: true });
assert.ok(fs.existsSync(lockPath));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('settings lock release failures do not replace the primary update error', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-error-'));
const settingsPath = path.join(tempDir, 'settings.json');
const lockPath = `${settingsPath}.ecc.lock`;
try {
let caught;
try {
runWithSettingsLock(settingsPath, () => {
fs.rmSync(lockPath, { force: true });
throw new Error('primary settings failure');
});
} catch (error) {
caught = error;
}
assert.ok(caught);
assert.strictEqual(caught.message, 'primary settings failure');
assert.ok(caught.releaseError);
assert.strictEqual(caught.releaseError.code, 'ENOENT');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('atomic settings updates refuse a symlinked destination', () => {
if (process.platform === 'win32') {
console.log(' (file symlink support is environment-dependent on Windows; skipping)');
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-symlink-'));
const realPath = path.join(tempDir, 'real.json');
const settingsPath = path.join(tempDir, 'settings.json');
try {
fs.writeFileSync(realPath, '{"theme":"dark"}\n', { mode: 0o600 });
fs.symlinkSync(realPath, settingsPath);
assert.throws(
() => updateSettingsAtomic(
settingsPath,
settings => ({ settings: { ...settings, managed: true } })
),
error => error.code === 'ELOOP' || error.code === 'ECC_SETTINGS_CHANGED'
);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(realPath, 'utf8')), { theme: 'dark' });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => {
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] };
const settings = {
theme: 'dark',
hooks: {
SessionStart: [userEntry],
Notification: [{ id: 'user:notification', command: 'notify' }],
},
};
const managed = {
SessionStart: [entry('ecc:start', 'node /opt/ecc/start.js')],
Stop: [entry('ecc:stop', 'node /opt/ecc/stop.js')],
};
const settingsBefore = clone(settings);
const managedBefore = clone(managed);
const result = mergeManagedHooks(settings, managed);
assert.deepStrictEqual(settings, settingsBefore);
assert.deepStrictEqual(managed, managedBefore);
assert.deepStrictEqual(result.settings, {
theme: 'dark',
hooks: {
SessionStart: [userEntry, managed.SessionStart[0]],
Notification: settings.hooks.Notification,
Stop: managed.Stop,
},
});
assert.deepStrictEqual(result.added, [
{ event: 'SessionStart', id: 'ecc:start' },
{ event: 'Stop', id: 'ecc:stop' },
]);
assert.deepStrictEqual(result.updated, []);
})) passed++; else failed++;
if (test('fresh merge treats a different entry with the same event and id as a conflict', () => {
const settings = {
hooks: {
Stop: [entry('ecc:stop', 'user-modified')],
},
};
const managed = {
Stop: [entry('ecc:stop', 'managed')],
};
assert.throws(
() => mergeManagedHooks(settings, managed),
/Refusing to overwrite.*Stop.*ecc:stop/
);
assert.deepStrictEqual(settings.hooks.Stop[0], entry('ecc:stop', 'user-modified'));
})) passed++; else failed++;
if (test('fresh merge adopts an identical existing event and id without duplicating it', () => {
const managed = { Stop: [entry('ecc:stop', 'managed')] };
const result = mergeManagedHooks({ hooks: clone(managed) }, managed);
assert.deepStrictEqual(result.settings.hooks.Stop, managed.Stop);
assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]);
})) passed++; else failed++;
if (test('upgrade replaces an entry only while it still equals previous managed content', () => {
const previousManagedHooks = { Stop: [entry('ecc:stop', 'version-1')] };
const managedHooks = { Stop: [entry('ecc:stop', 'version-2')] };
const result = mergeManagedHooks(
{ hooks: { Stop: [entry('ecc:stop', 'version-1')] } },
managedHooks,
{ previousManagedHooks }
);
assert.deepStrictEqual(result.settings.hooks.Stop, managedHooks.Stop);
assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]);
})) passed++; else failed++;
if (test('upgrade fails closed when previous managed content has drifted', () => {
const settings = { hooks: { Stop: [entry('ecc:stop', 'customer-edit')] } };
const before = clone(settings);
assert.throws(
() => mergeManagedHooks(
settings,
{ Stop: [entry('ecc:stop', 'version-2')] },
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
),
/drifted|Refusing to overwrite/
);
assert.deepStrictEqual(settings, before);
})) passed++; else failed++;
if (test('upgrade is idempotent when the desired entry is already installed', () => {
const desired = { Stop: [entry('ecc:stop', 'version-2')] };
const result = mergeManagedHooks(
{ hooks: clone(desired) },
desired,
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
);
assert.deepStrictEqual(result.settings.hooks, desired);
assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]);
})) passed++; else failed++;
if (test('upgrade removes unchanged entries that are no longer managed', () => {
const previousManagedHooks = {
Stop: [
entry('ecc:keep', 'version-1'),
entry('ecc:removed', 'old-command'),
],
};
const desired = { Stop: [entry('ecc:keep', 'version-2')] };
const userEntry = entry('user:stop', 'keep-user');
const result = mergeManagedHooks({
hooks: { Stop: [userEntry, ...previousManagedHooks.Stop] },
}, desired, { previousManagedHooks });
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry, desired.Stop[0]]);
assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:removed' }]);
})) passed++; else failed++;
if (test('upgrade removes multiple retired hooks without deleting their neighbor', () => {
const previousManagedHooks = {
Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')],
};
const userEntry = entry('user:c', 'keep-user');
const result = mergeManagedHooks({
hooks: { Stop: [...previousManagedHooks.Stop, userEntry] },
}, { SessionStart: [entry('ecc:start', 'start')] }, { previousManagedHooks });
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]);
assert.deepStrictEqual(result.removed, [
{ event: 'Stop', id: 'ecc:a' },
{ event: 'Stop', id: 'ecc:b' },
]);
})) passed++; else failed++;
if (test('upgrade refuses to remove a retired entry after user drift', () => {
const previousManagedHooks = { Stop: [entry('ecc:removed', 'old-command')] };
const settings = { hooks: { Stop: [entry('ecc:removed', 'user-edited')] } };
assert.throws(
() => mergeManagedHooks(settings, { SessionStart: [entry('ecc:start', 'start')] }, {
previousManagedHooks,
}),
/Refusing to remove.*ecc:removed.*drifted/
);
})) passed++; else failed++;
if (test('merge fails closed when settings contains ambiguous duplicate event ids', () => {
assert.throws(
() => mergeManagedHooks(
{
hooks: {
Stop: [
entry('ecc:stop', 'version-1'),
entry('ecc:stop', 'another-copy'),
],
},
},
{ Stop: [entry('ecc:stop', 'version-2')] },
{ previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } }
),
/multiple.*ecc:stop/i
);
})) passed++; else failed++;
if (test('merge treats the same id under another event as a separate user entry', () => {
const result = mergeManagedHooks(
{ hooks: { SessionStart: [entry('shared:id', 'existing')] } },
{ Stop: [entry('shared:id', 'desired')] }
);
assert.deepStrictEqual(result.settings.hooks, {
SessionStart: [entry('shared:id', 'existing')],
Stop: [entry('shared:id', 'desired')],
});
})) passed++; else failed++;
if (test('repair mode overwrites a drifted same-event managed id and preserves neighbors', () => {
const userEntry = { id: 'user:hook', command: 'keep-me' };
const result = repairManagedHooks(
{ hooks: { Stop: [userEntry, entry('ecc:stop', 'drifted')] } },
{ Stop: [entry('ecc:stop', 'repaired')] }
);
assert.deepStrictEqual(result.settings.hooks.Stop, [
userEntry,
entry('ecc:stop', 'repaired'),
]);
assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]);
})) passed++; else failed++;
if (test('inspect reports exact, missing, and drifted managed entries plus the actual subset', () => {
const expected = {
SessionStart: [entry('ecc:start', 'start')],
Stop: [
entry('ecc:stop', 'expected'),
entry('ecc:missing', 'missing'),
],
};
const actualStart = entry('ecc:start', 'start');
const actualDrift = entry('ecc:stop', 'changed');
const result = inspectManagedHooks({
hooks: {
SessionStart: [actualStart, { id: 'user:start', command: 'user' }],
Stop: [actualDrift],
},
}, expected);
assert.strictEqual(result.status, 'missing');
assert.deepStrictEqual(result.matched, [{ event: 'SessionStart', id: 'ecc:start' }]);
assert.deepStrictEqual(result.missing, [{ event: 'Stop', id: 'ecc:missing' }]);
assert.deepStrictEqual(result.drifted, [{
event: 'Stop',
id: 'ecc:stop',
expected: expected.Stop[0],
actual: actualDrift,
}]);
assert.deepStrictEqual(result.managedHooks, {
SessionStart: [actualStart],
Stop: [actualDrift],
});
})) passed++; else failed++;
if (test('inspect fails closed on duplicate matching ids in one settings event', () => {
assert.throws(
() => inspectManagedHooks(
{ hooks: { Stop: [entry('ecc:stop', 'a'), entry('ecc:stop', 'b')] } },
{ Stop: [entry('ecc:stop', 'expected')] }
),
/multiple.*ecc:stop/i
);
})) passed++; else failed++;
if (test('inspect and uninstall key ownership by event plus id', () => {
const recorded = { Stop: [entry('ecc:stop', 'managed')] };
const moved = { hooks: { SessionStart: [entry('ecc:stop', 'managed')] } };
const inspection = inspectManagedHooks(moved, recorded);
assert.strictEqual(inspection.status, 'missing');
assert.deepStrictEqual(inspection.missing, [{ event: 'Stop', id: 'ecc:stop' }]);
const uninstall = uninstallManagedHooks(moved, recorded);
assert.deepStrictEqual(uninstall.settings, moved);
assert.deepStrictEqual(uninstall.missing, [{ event: 'Stop', id: 'ecc:stop' }]);
})) passed++; else failed++;
if (test('uninstall removes exact recorded entries, retains drift, and cleans empty events', () => {
const recorded = {
SessionStart: [entry('ecc:start', 'start')],
Stop: [entry('ecc:stop', 'recorded')],
Notification: [entry('ecc:notify', 'notify')],
};
const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] };
const driftedStop = entry('ecc:stop', 'customer-edit');
const settings = {
theme: 'dark',
hooks: {
SessionStart: [recorded.SessionStart[0]],
Stop: [userEntry, driftedStop],
Notification: [recorded.Notification[0]],
},
};
const before = clone(settings);
const result = uninstallManagedHooks(settings, recorded);
assert.deepStrictEqual(settings, before);
assert.deepStrictEqual(result.settings, {
theme: 'dark',
hooks: {
Stop: [userEntry, driftedStop],
},
});
assert.deepStrictEqual(result.removed, [
{ event: 'SessionStart', id: 'ecc:start' },
{ event: 'Notification', id: 'ecc:notify' },
]);
assert.deepStrictEqual(result.retained, [{
event: 'Stop',
id: 'ecc:stop',
expected: recorded.Stop[0],
actual: driftedStop,
reason: 'modified',
}]);
})) passed++; else failed++;
if (test('uninstall removes consecutive managed hooks without deleting a user neighbor', () => {
const recorded = { Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')] };
const userEntry = entry('user:c', 'keep-user');
const result = uninstallManagedHooks({
hooks: { Stop: [...recorded.Stop, userEntry] },
}, recorded);
assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]);
assert.deepStrictEqual(result.removed, [
{ event: 'Stop', id: 'ecc:a' },
{ event: 'Stop', id: 'ecc:b' },
]);
})) passed++; else failed++;
if (test('uninstall removes hooks entirely after the final managed event is emptied', () => {
const recorded = { Stop: [entry('ecc:stop', 'recorded')] };
const result = uninstallManagedHooks({ theme: 'dark', hooks: clone(recorded) }, recorded);
assert.deepStrictEqual(result.settings, { theme: 'dark' });
assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:stop' }]);
assert.deepStrictEqual(result.retained, []);
})) passed++; else failed++;
if (test('uninstall accepts structurally valid hooks from an older runtime contract', () => {
const recorded = {
LegacyEvent: [{
id: 'ecc:legacy',
hooks: [{ type: 'legacy-handler', payload: { version: 1 } }],
}],
};
const result = uninstallManagedHooks({ hooks: clone(recorded) }, recorded);
assert.deepStrictEqual(result.settings, {});
assert.deepStrictEqual(result.removed, [{ event: 'LegacyEvent', id: 'ecc:legacy' }]);
})) passed++; else failed++;
if (test('all settings transforms reject non-array hook events before changing data', () => {
const settings = { hooks: { Stop: 'invalid' } };
const managed = { Stop: [entry('ecc:stop', 'expected')] };
assert.throws(() => mergeManagedHooks(settings, managed), /hooks\.Stop.*array/);
assert.throws(() => repairManagedHooks(settings, managed), /hooks\.Stop.*array/);
assert.throws(() => inspectManagedHooks(settings, managed), /hooks\.Stop.*array/);
assert.throws(() => uninstallManagedHooks(settings, managed), /hooks\.Stop.*array/);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+22 -2
View File
@@ -27,10 +27,23 @@ function test(name, fn) {
}
function buildHookPlan() {
const managedHooks = {
SessionStart: [{
id: 'session:start',
matcher: '.*',
hooks: [{ type: 'command', command: 'node /target/scripts/hooks/session-start.js' }],
}],
};
return {
operations: [
{ kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' },
{ kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' },
{
kind: 'update-claude-settings',
moduleId: 'hooks-runtime',
sourceRelativePath: 'hooks/hooks.json',
destinationPath: '/target/settings.json',
managedHooks,
},
{ kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'scripts/hooks/session-start.js', destinationPath: '/target/scripts/hooks/session-start.js' },
],
selectedModuleIds: ['rules-core', 'hooks-runtime'],
@@ -46,7 +59,13 @@ function buildHookPlan() {
},
operations: [
{ kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' },
{ kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' },
{
kind: 'update-claude-settings',
moduleId: 'hooks-runtime',
sourceRelativePath: 'hooks/hooks.json',
destinationPath: '/target/settings.json',
managedHooks,
},
],
resolution: { selectedModules: ['rules-core', 'hooks-runtime'], skippedModules: [] },
},
@@ -69,6 +88,7 @@ function runTests() {
if (test('matches hook runtime operations by module id and source path', () => {
assert.strictEqual(isHookRuntimeOperation({ moduleId: 'hooks-runtime' }), true);
assert.strictEqual(isHookRuntimeOperation({ kind: 'update-claude-settings' }), true);
assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'hooks/hooks.json' }), true);
assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: '.cursor/hooks.json' }), true);
assert.strictEqual(isHookRuntimeOperation({ destinationPath: '/root/.claude/hooks/hooks.json' }), true);
+203
View File
@@ -9,6 +9,7 @@ const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
applyInstallPlan,
@@ -19,6 +20,7 @@ const {
listAvailableLanguages,
} = require('../../scripts/lib/install-executor');
const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply');
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
@@ -166,6 +168,91 @@ function runTests() {
}
})) 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' }),
@@ -400,6 +487,122 @@ function runTests() {
}
})) 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-');
+521 -2
View File
@@ -23,6 +23,10 @@ const {
readInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
const {
assertClaudeSettingsPath,
materializeManagedHooks,
} = require('../../scripts/lib/install/claude-settings');
const REPO_ROOT = path.join(__dirname, '..', '..');
const CURRENT_PACKAGE_VERSION = JSON.parse(
@@ -52,6 +56,10 @@ function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function writeState(filePath, options) {
const state = createInstallState(options);
writeInstallState(filePath, state);
@@ -100,6 +108,61 @@ function writeCursorState(projectRoot, overrides = {}) {
};
}
function writeClaudeState(homeDir, overrides = {}) {
const targetRoot = overrides.targetRoot || path.join(homeDir, '.claude');
const installStatePath = overrides.installStatePath
|| path.join(targetRoot, 'ecc', 'install-state.json');
const options = {
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
targetRoot,
installStatePath,
request: {
profile: null,
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: true,
hookConsent: 'enabled',
...(overrides.request || {}),
},
resolution: {
selectedModules: ['legacy-claude-install'],
skippedModules: [],
...(overrides.resolution || {}),
},
operations: overrides.operations || [],
source: {
repoVersion: CURRENT_PACKAGE_VERSION,
repoCommit: 'abc123',
manifestVersion: CURRENT_MANIFEST_VERSION,
...(overrides.source || {}),
},
};
writeState(installStatePath, options);
return {
targetRoot,
installStatePath,
state: options,
};
}
function managedHookEntry(id, command) {
return {
id,
matcher: '.*',
hooks: [{ type: 'command', command }],
};
}
function currentManagedHooks(targetRoot) {
return materializeManagedHooks(
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'hooks', 'hooks.json'), 'utf8')),
targetRoot
);
}
function createOpencodeStateOptions(homeDir, overrides = {}) {
const targetRoot = overrides.targetRoot || path.join(homeDir, '.config', 'opencode');
const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json');
@@ -171,8 +234,10 @@ function withTemporarilyMovedPath(filePath, callback) {
function managedOperation(kind, destinationPath, overrides = {}) {
const operation = {
kind,
moduleId: 'test-module',
sourceRelativePath: 'rules/common/coding-style.md',
moduleId: kind === 'update-claude-settings' ? 'hooks-runtime' : 'test-module',
sourceRelativePath: kind === 'update-claude-settings'
? 'hooks/hooks.json'
: 'rules/common/coding-style.md',
destinationPath,
strategy: kind,
ownership: 'managed',
@@ -3231,6 +3296,460 @@ function runTests() {
}
})) passed++; else failed++;
if (test('doctor inspects update-claude-settings hooks by event and id', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = currentManagedHooks(targetRoot);
const stopEntry = managedHooks.Stop[0];
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: {
Stop: [
{ id: 'user:stop', matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] },
...managedHooks.Stop,
],
...Object.fromEntries(Object.entries(managedHooks).filter(([event]) => event !== 'Stop')),
},
}));
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
let report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(report.results[0].status, 'ok');
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: {
...managedHooks,
Stop: [
{ id: 'user:stop', matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] },
{ ...stopEntry, description: 'drifted' },
...managedHooks.Stop.slice(1),
],
},
}));
report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(report.results[0].status, 'warning');
assert.ok(report.results[0].issues.some(issue => issue.code === 'drifted-managed-files'));
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: {
...managedHooks,
Stop: managedHooks.Stop.filter(entry => entry.id !== stopEntry.id),
},
}));
report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(report.results[0].status, 'error');
assert.ok(report.results[0].issues.some(issue => issue.code === 'missing-managed-files'));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('doctor and repair surface malformed Claude settings errors', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = currentManagedHooks(targetRoot);
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, '{ invalid json\n');
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const report = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
const issue = report.results[0].issues.find(candidate => (
candidate.code === 'invalid-claude-settings'
));
assert.strictEqual(report.results[0].status, 'error');
assert.ok(issue, 'doctor should report an invalid Claude settings issue');
assert.match(issue.message, /Failed to inspect Claude settings/);
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(repair.results[0].status, 'error');
assert.match(repair.results[0].error, /Failed to inspect Claude settings/);
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair restores managed Claude hooks while preserving user settings and hooks', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const userHook = {
id: 'user:stop',
matcher: 'Bash',
hooks: [{ type: 'command', command: 'user-command' }],
};
const managedHooks = currentManagedHooks(targetRoot);
const stopEntry = managedHooks.Stop[0];
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: {
...managedHooks,
Stop: [
userHook,
{ ...stopEntry, description: 'drifted' },
...managedHooks.Stop.slice(1),
],
},
}));
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'repaired');
assert.ok(result.results[0].repairedPaths.includes(settingsPath));
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
theme: 'dark',
hooks: {
...managedHooks,
Stop: [userHook, ...managedHooks.Stop],
},
});
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair creates missing Claude settings with private permissions', () => {
if (process.platform === 'win32') {
console.log(' (POSIX file modes unsupported on this platform; skipping)');
return;
}
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = currentManagedHooks(targetRoot);
fs.mkdirSync(targetRoot, { recursive: true });
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'repaired');
assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks, managedHooks);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('repair removes retired managed hooks using the recorded ownership snapshot', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const currentHooks = currentManagedHooks(targetRoot);
const retiredHook = managedHookEntry('ecc:retired', 'node retired.js');
const recordedHooks = {
...currentHooks,
Stop: [...currentHooks.Stop, retiredHook],
};
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: recordedHooks,
}));
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
managedHooks: recordedHooks,
}),
],
});
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'repaired');
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.ok(!repaired.hooks.Stop.some(entry => entry.id === 'ecc:retired'));
assert.deepStrictEqual(repaired.hooks, currentHooks);
const state = readInstallState(path.join(targetRoot, 'ecc', 'install-state.json'));
const settingsOperation = state.operations.find(operation => (
operation.kind === 'update-claude-settings'
));
assert.deepStrictEqual(settingsOperation.managedHooks, currentHooks);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall removes only unchanged managed Claude hooks and reports drift as partial', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = {
SessionStart: [managedHookEntry('ecc:start', 'node managed-start.js')],
Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')],
};
const userHook = {
id: 'user:stop',
matcher: 'Bash',
hooks: [{ type: 'command', command: 'user-command' }],
};
const driftedHook = managedHookEntry('ecc:stop', 'node user-edited-stop.js');
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: {
SessionStart: managedHooks.SessionStart,
Stop: [userHook, driftedHook],
},
}));
const { installStatePath } = writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'partial');
assert.deepStrictEqual(result.results[0].retainedPaths, [settingsPath]);
assert.ok(fs.existsSync(installStatePath));
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
theme: 'dark',
hooks: {
Stop: [userHook, driftedHook],
},
});
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('uninstall clears empty hook containers but preserves unrelated Claude settings', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = {
Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')],
};
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(settingsPath, formatJson({
theme: 'dark',
hooks: managedHooks,
}));
const { installStatePath } = writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const result = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(result.results[0].status, 'uninstalled');
assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), {
theme: 'dark',
});
assert.ok(!fs.existsSync(installStatePath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('Claude settings lifecycle refuses a final-symlink destination', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const victimPath = path.join(targetRoot, 'victim.json');
const settingsPath = path.join(targetRoot, 'settings.json');
const managedHooks = {
Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')],
};
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(victimPath, formatJson({ sentinel: true, hooks: managedHooks }));
try {
fs.symlinkSync(victimPath, settingsPath, 'file');
} catch {
console.log(' (file symlink unsupported on this platform; skipping)');
return;
}
writeClaudeState(homeDir, {
operations: [
managedOperation('update-claude-settings', settingsPath, {
sourceRelativePath: 'hooks/hooks.json',
strategy: 'update-claude-settings',
managedHooks,
}),
],
});
const doctor = buildDoctorReport({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
const repair = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['claude'],
});
const uninstall = uninstallInstalledStates({
homeDir,
projectRoot,
targets: ['claude'],
});
assert.strictEqual(doctor.results[0].status, 'error');
assert.ok(doctor.results[0].issues.some(issue => (
issue.code === 'unsafe-managed-destination'
)));
assert.strictEqual(repair.results[0].status, 'error');
assert.match(repair.results[0].error, /final symlink/);
assert.strictEqual(uninstall.results[0].status, 'error');
assert.match(uninstall.results[0].error, /final symlink/);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(victimPath, 'utf8')), {
sentinel: true,
hooks: managedHooks,
});
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
if (test('Claude settings path validation refuses a non-canonical destination', () => {
const homeDir = createTempDir('install-lifecycle-claude-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
try {
const targetRoot = path.join(homeDir, '.claude');
const destinationPath = path.join(targetRoot, 'settings.local.json');
fs.mkdirSync(targetRoot, { recursive: true });
assert.throws(
() => assertClaudeSettingsPath(destinationPath, targetRoot),
/outside the canonical settings file/
);
assert.ok(!fs.existsSync(destinationPath));
} finally {
cleanup(homeDir);
cleanup(projectRoot);
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
+99
View File
@@ -84,6 +84,105 @@ function runTests() {
assert.strictEqual(state.operations.length, 1);
})) passed++; else failed++;
if (test('validates managed hook metadata for Claude settings operations', () => {
const baseOptions = {
adapter: { id: 'claude-home', target: 'claude', kind: 'home' },
targetRoot: '/home/test/.claude',
installStatePath: '/home/test/.claude/ecc/install-state.json',
request: {
profile: 'core',
modules: ['hooks-runtime'],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
hookConsent: 'enabled',
},
resolution: { selectedModules: ['hooks-runtime'], skippedModules: [] },
source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: 1 },
};
const operation = {
kind: 'update-claude-settings',
moduleId: 'hooks-runtime',
sourceRelativePath: 'hooks/hooks.json',
destinationPath: '/home/test/.claude/settings.json',
strategy: 'merge-hook-ids',
ownership: 'managed',
scaffoldOnly: false,
managedHooks: {
SessionStart: [{
id: 'session:start',
matcher: '.*',
hooks: [{ type: 'command', command: 'node start.js' }],
}],
},
};
assert.doesNotThrow(() => createInstallState({ ...baseOptions, operations: [operation] }));
assert.throws(
() => createInstallState({
...baseOptions,
operations: [{ ...operation, moduleId: 'not-hooks-runtime' }],
}),
/moduleId.*hooks-runtime/
);
assert.throws(
() => createInstallState({
...baseOptions,
operations: [{ ...operation, sourceRelativePath: 'attacker.json' }],
}),
/sourceRelativePath.*hooks\/hooks\.json/
);
assert.throws(
() => createInstallState({
...baseOptions,
operations: [{
...operation,
destinationPath: '/home/test/.claude/settings.local.json',
}],
}),
/destinationPath.*canonical Claude settings path/
);
assert.throws(
() => createInstallState({
...baseOptions,
adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' },
targetRoot: '/repo/.cursor',
installStatePath: '/repo/.cursor/ecc-install-state.json',
operations: [{
...operation,
destinationPath: '/repo/.cursor/settings.json',
}],
}),
/only valid for Claude targets/
);
assert.throws(
() => createInstallState({
...baseOptions,
operations: [{
...operation,
managedHooks: {
SessionStart: [{
matcher: '.*',
hooks: [{ type: 'command', command: 'node start.js' }],
}],
},
}],
}),
/managedHooks.*Invalid hook entry/
);
assert.doesNotThrow(() => createInstallState({
...baseOptions,
operations: [{
...operation,
managedHooks: {
SessionStart: [{ id: 'shared', hooks: [] }],
LegacyEvent: [{ id: 'shared', hooks: [{ type: 'legacy' }] }],
},
}],
}));
})) passed++; else failed++;
if (test('writes and reads install-state from disk', () => {
const testDir = createTestDir();
const statePath = path.join(testDir, 'ecc-install-state.json');
+200 -123
View File
@@ -115,6 +115,31 @@ function runTests() {
assert.ok(result.stdout.includes('--modules <id,id,...>'));
})) passed++; else failed++;
if (test('Claude hook dry-run validates settings without mutating malformed input', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
const claudeRoot = path.join(homeDir, '.claude');
const settingsPath = path.join(claudeRoot, 'settings.json');
try {
fs.mkdirSync(claudeRoot, { recursive: true });
fs.writeFileSync(settingsPath, '{ malformed\n');
const result = run(
['--profile', 'core', '--enable-hooks', '--dry-run', '--json'],
{ cwd: projectDir, homeDir }
);
assert.notStrictEqual(result.code, 0);
assert.match(result.stderr, /Failed to parse Claude settings/);
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ malformed\n');
assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('guided dispatcher reports sanitized load and rejection failures', () => {
for (const failureMode of ['load', 'reject']) {
const result = runWithGuidedDispatcherFailure(failureMode);
@@ -487,6 +512,19 @@ function runTests() {
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.dryRun, true);
assert.ok(parsed.plan.selectedModuleIds.includes('workflow-quality'));
const settingsOperations = parsed.plan.operations.filter(operation => (
operation.kind === 'update-claude-settings'
));
assert.strictEqual(settingsOperations.length, 1);
assert.strictEqual(
settingsOperations[0].destinationPath,
path.join(homeDir, '.claude', 'settings.json')
);
assert.ok(settingsOperations[0].managedHooks.SessionStart);
assert.ok(!parsed.plan.operations.some(operation => (
operation.kind === 'copy-file'
&& String(operation.sourceRelativePath || '').replace(/\\/g, '/') === 'hooks/hooks.json'
)));
assert.ok(
parsed.plan.operations.some(operation => (
String(operation.sourceRelativePath || '').replace(/\\/g, '/').startsWith('skills/delivery-gate/')
@@ -532,7 +570,8 @@ function runTests() {
assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')));
assert.ok(readJson(path.join(claudeRoot, 'settings.json')).hooks.SessionStart);
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json')));
@@ -747,7 +786,7 @@ function runTests() {
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
})) passed++; else failed++;
if (test('installs claude hooks and defaults commit attribution off', () => {
if (test('registers Claude hooks in settings and defaults commit attribution off', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -756,58 +795,87 @@ function runTests() {
assert.strictEqual(result.code, 0, result.stderr);
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
assert.deepStrictEqual(
readJson(path.join(claudeRoot, 'settings.json')),
{ includeCoAuthoredBy: false }
assert.strictEqual(
fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')),
false,
'hooks.json should not be copied for Claude targets'
);
const settings = readJson(path.join(claudeRoot, 'settings.json'));
assert.strictEqual(settings.includeCoAuthoredBy, false);
assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start'));
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
const settingsOperation = state.operations.find(operation => (
operation.kind === 'update-claude-settings'
));
assert.ok(settingsOperation, 'state should record the settings update operation');
assert.deepStrictEqual(settingsOperation.managedHooks, settings.hooks);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('installs claude hooks with the safe plugin bootstrap contract', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
if (test('resolves Claude home and project hook commands to their installed roots', () => {
for (const target of ['claude', 'claude-project']) {
const homeDir = createTempDir(`install-apply-${target}-home-`);
const projectDir = createTempDir(`install-apply-${target}-project-`);
try {
const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
try {
const result = run(
['--target', target, '--profile', 'core', '--enable-hooks'],
{ cwd: projectDir, homeDir }
);
assert.strictEqual(result.code, 0, result.stderr);
const claudeRoot = path.join(homeDir, '.claude');
const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json'));
const claudeRoot = target === 'claude'
? path.join(homeDir, '.claude')
: path.join(projectDir, '.claude');
const settings = readJson(path.join(claudeRoot, 'settings.json'));
const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
const installedRoot = state.target.root;
assert.strictEqual(fs.realpathSync(installedRoot), fs.realpathSync(claudeRoot));
const installedBashDispatcherEntry = settings.hooks.PreToolUse.find(
entry => entry.id === 'pre:bash:dispatcher'
);
assert.ok(installedBashDispatcherEntry);
const command = installedBashDispatcherEntry.hooks[0].command;
assert.ok(command.startsWith('node -e '));
assert.ok(command.includes('plugin-hook-bootstrap.js'));
assert.ok(command.includes('pre-bash-dispatcher.js'));
assert.ok(
command.includes(Buffer.from(installedRoot, 'utf8').toString('base64')),
`${target} command should encode its absolute root without shell interpolation`
);
assert.ok(!command.includes(claudeRoot));
assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;'));
assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}'));
const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher');
assert.ok(installedBashDispatcherEntry, 'hooks/hooks.json should include the consolidated Bash dispatcher hook');
assert.strictEqual(typeof installedBashDispatcherEntry.hooks[0].command, 'string', 'hooks/hooks.json should install string-form commands for Claude Code schema compatibility');
assert.ok(
installedBashDispatcherEntry.hooks[0].command.startsWith('node -e '),
'hooks/hooks.json should use the inline node bootstrap contract'
);
assert.ok(
installedBashDispatcherEntry.hooks[0].command.includes('plugin-hook-bootstrap.js'),
'hooks/hooks.json should route plugin-managed hooks through the shared bootstrap'
);
assert.ok(
installedBashDispatcherEntry.hooks[0].command.includes('CLAUDE_PLUGIN_ROOT'),
'hooks/hooks.json should still consult CLAUDE_PLUGIN_ROOT for runtime resolution'
);
assert.ok(
installedBashDispatcherEntry.hooks[0].command.includes('pre-bash-dispatcher.js'),
'hooks/hooks.json should point the Bash preflight contract at the consolidated dispatcher'
);
assert.ok(
!installedBashDispatcherEntry.hooks[0].command.includes('\\"'),
'hooks/hooks.json should avoid escaped double quotes that break Windows Git Bash parsing'
);
assert.ok(
!installedBashDispatcherEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'),
'hooks/hooks.json should not retain raw CLAUDE_PLUGIN_ROOT shell placeholders after install'
);
} finally {
cleanup(homeDir);
cleanup(projectDir);
const smokeEntry = settings.hooks.PreToolUse.find(
entry => entry.id === 'pre:write:doc-file-warning'
);
const smokeResult = spawnSync(smokeEntry.hooks[0].command, {
input: JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Write',
tool_input: { file_path: 'README.md' },
}),
encoding: 'utf8',
cwd: projectDir,
env: {
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning',
},
shell: true,
timeout: DEFAULT_INSTALL_APPLY_TIMEOUT_MS,
});
assert.strictEqual(smokeResult.status, 0, smokeResult.stderr);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
}
})) passed++; else failed++;
@@ -840,12 +908,16 @@ function runTests() {
assert.deepStrictEqual(
settings.hooks.UserPromptSubmit,
[{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
'existing hooks should be left untouched'
'unrelated existing hooks should be preserved'
);
assert.deepStrictEqual(
settings.hooks.PreToolUse,
[{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
'managed Claude hooks should not be injected into settings.json'
settings.hooks.PreToolUse[0],
{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] },
'existing event entries should retain their order and content'
);
assert.ok(
settings.hooks.PreToolUse.some(entry => entry.id === 'pre:bash:dispatcher'),
'managed Claude hooks should be registered alongside user hooks'
);
} finally {
cleanup(homeDir);
@@ -927,7 +999,7 @@ function runTests() {
}
})) passed++; else failed++;
if (test('reinstall keeps commit attribution disabled when only managed hooks are installed', () => {
if (test('reinstall is idempotent for managed hooks and keeps commit attribution disabled', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -938,17 +1010,17 @@ function runTests() {
const secondInstall = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
assert.deepStrictEqual(
readJson(path.join(homeDir, '.claude', 'settings.json')),
{ includeCoAuthoredBy: false }
);
const settings = readJson(path.join(homeDir, '.claude', 'settings.json'));
assert.strictEqual(settings.includeCoAuthoredBy, false);
const ids = Object.values(settings.hooks).flat().map(entry => entry.id);
assert.strictEqual(ids.length, new Set(ids).size, 'managed hook IDs should not duplicate');
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('reinstall leaves pre-existing hook-based settings.json untouched apart from co-author preference', () => {
if (test('reinstall preserves pre-existing hook entries while registering managed hooks', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -967,10 +1039,9 @@ function runTests() {
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
const afterSecondInstall = readJson(settingsPath);
assert.deepStrictEqual(afterSecondInstall, {
...legacySettings,
includeCoAuthoredBy: false,
});
assert.strictEqual(afterSecondInstall.includeCoAuthoredBy, false);
assert.deepStrictEqual(afterSecondInstall.hooks.PreToolUse[0], legacySettings.hooks.PreToolUse[0]);
assert.ok(afterSecondInstall.hooks.PreToolUse.some(entry => entry.id === 'pre:bash:dispatcher'));
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -995,7 +1066,9 @@ function runTests() {
assert.strictEqual(install.code, 0, install.stderr);
const afterInstall = readJson(settingsPath);
assert.deepStrictEqual(afterInstall, customSettings);
assert.strictEqual(afterInstall.includeCoAuthoredBy, true);
assert.strictEqual(afterInstall.theme, 'dark');
assert.ok(afterInstall.hooks.SessionStart.some(entry => entry.id === 'session:start'));
} finally {
cleanup(homeDir);
cleanup(projectDir);
@@ -1022,14 +1095,17 @@ function runTests() {
assert.strictEqual(install.code, 0, install.stderr);
const afterInstall = readJson(settingsPath);
assert.deepStrictEqual(afterInstall, customSettings);
assert.deepStrictEqual(afterInstall.attribution, customSettings.attribution);
assert.strictEqual(afterInstall.theme, 'dark');
assert.ok(!Object.hasOwn(afterInstall, 'includeCoAuthoredBy'));
assert.ok(afterInstall.hooks.SessionStart.some(entry => entry.id === 'session:start'));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('ignores malformed existing settings.json during claude install', () => {
if (test('malformed Claude settings aborts before any install mutation', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -1040,17 +1116,17 @@ function runTests() {
fs.writeFileSync(settingsPath, '{ invalid json\n');
const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.notStrictEqual(result.code, 0);
assert.match(result.stderr, /Failed to parse Claude settings/);
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('ignores non-object existing settings.json during claude install', () => {
if (test('non-object Claude settings aborts before any install mutation', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
@@ -1061,76 +1137,44 @@ function runTests() {
fs.writeFileSync(settingsPath, '[]\n');
const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
assert.notStrictEqual(result.code, 0);
assert.match(result.stderr, /expected a JSON object/);
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied');
assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written');
assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('fails when source hooks.json root is not an object before copying files', () => {
const tempDir = createTempDir('install-apply-invalid-hooks-');
const targetRoot = path.join(tempDir, '.claude');
const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json');
const sourceHooksPath = path.join(tempDir, 'hooks.json');
if (test('same-id Claude hook conflict aborts before any install mutation', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
fs.writeFileSync(sourceHooksPath, '[]\n');
assert.throws(() => {
applyInstallPlan({
targetRoot,
installStatePath,
hookConsent: 'enabled',
statePreview: {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
target: {
id: 'claude-home',
kind: 'home',
root: targetRoot,
installStatePath,
},
request: {
profile: 'core',
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
},
resolution: {
selectedModules: ['hooks-runtime'],
skippedModules: [],
},
source: {
repoVersion: null,
repoCommit: null,
manifestVersion: 1,
},
operations: [],
},
adapter: { target: 'claude' },
operations: [{
kind: 'copy-file',
moduleId: 'hooks-runtime',
sourcePath: sourceHooksPath,
sourceRelativePath: 'hooks/hooks.json',
destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'),
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
const claudeRoot = path.join(homeDir, '.claude');
fs.mkdirSync(claudeRoot, { recursive: true });
const settingsPath = path.join(claudeRoot, 'settings.json');
const existing = {
theme: 'dark',
hooks: {
PreToolUse: [{
id: 'pre:bash:dispatcher',
matcher: 'Bash',
hooks: [{ type: 'command', command: 'echo user-owned' }],
}],
});
}, /Invalid hooks config at .*expected a JSON object/);
},
};
fs.writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`);
assert.ok(!fs.existsSync(path.join(targetRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
assert.ok(!fs.existsSync(installStatePath), 'install state should not be written when source hooks are invalid');
const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.notStrictEqual(result.code, 0);
assert.match(result.stderr, /Refusing to overwrite.*pre:bash:dispatcher/);
assert.deepStrictEqual(readJson(settingsPath), existing);
assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']);
} finally {
cleanup(tempDir);
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
@@ -1254,6 +1298,39 @@ function runTests() {
assert.strictEqual(state.request.hookConsent, 'declined');
assert.ok(!state.resolution.selectedModules.includes('hooks-runtime'));
assert.ok(state.resolution.selectedModules.includes('rules-core'));
assert.ok(!state.operations.some(operation => (
operation.kind === 'update-claude-settings'
)));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('--no-hooks removes hooks registered by a previous enabled install', () => {
const projectDir = createTempDir('install-apply-disable-hooks-');
const homeDir = createTempDir('install-apply-disable-hooks-home-');
try {
const enabled = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(enabled.code, 0, enabled.stderr);
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
const settings = readJson(settingsPath);
settings.theme = 'dark';
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
const disabled = run(['--profile', 'core', '--no-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(disabled.code, 0, disabled.stderr);
assert.deepStrictEqual(readJson(settingsPath), {
includeCoAuthoredBy: false,
theme: 'dark',
});
const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json'));
assert.strictEqual(state.request.hookConsent, 'declined');
assert.ok(!state.operations.some(operation => (
operation.kind === 'update-claude-settings'
)));
} finally {
cleanup(homeDir);
cleanup(projectDir);
+15 -1
View File
@@ -8,6 +8,12 @@ const path = require('path');
const README = path.join(__dirname, '..', '..', 'README.md');
const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md');
const HOOK_REGISTRATION_PHRASE =
'registers the resolved hook entries in `~/.claude/settings.json`';
function normalizeWhitespace(text) {
return text.replace(/\s+/g, ' ');
}
function test(name, fn) {
try {
@@ -44,9 +50,13 @@ function runTests() {
'README should document the supported PowerShell hook install path'
);
assert.ok(
readme.includes('%USERPROFILE%\\\\.claude'),
readme.includes('%USERPROFILE%\\.claude'),
'README should call out the correct Windows Claude config root'
);
assert.ok(
normalizeWhitespace(readme).includes(HOOK_REGISTRATION_PHRASE),
'README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;
if (test('hooks/README mirrors supported manual install guidance', () => {
@@ -62,6 +72,10 @@ function runTests() {
hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime --enable-hooks'),
'hooks/README should document the supported PowerShell hook install path'
);
assert.ok(
normalizeWhitespace(hooksReadme).includes(HOOK_REGISTRATION_PHRASE),
'hooks/README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);