mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-18 15:50:25 +02:00
Merge reviewed Claude hook registration and settings containment fixes
This commit is contained in:
+135
-33
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user