-
-
Notifications
You must be signed in to change notification settings - Fork 15.3k
fix(installer): preserve existing Claude hook settings #964
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,8 @@ function readJson(filePath) { | |
| return JSON.parse(fs.readFileSync(filePath, 'utf8')); | ||
| } | ||
|
|
||
| const REPO_ROOT = path.join(__dirname, '..', '..'); | ||
|
|
||
| function run(args = [], options = {}) { | ||
| const env = { | ||
| ...process.env, | ||
|
|
@@ -326,6 +328,170 @@ function runTests() { | |
| assert.ok(result.stderr.includes('Unknown install module: ghost-module')); | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('merges hooks into settings.json for claude target install', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
| try { | ||
| const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| 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'); | ||
|
|
||
| const settingsPath = path.join(claudeRoot, 'settings.json'); | ||
| assert.ok(fs.existsSync(settingsPath), 'settings.json should exist after install'); | ||
|
|
||
| const settings = readJson(settingsPath); | ||
| assert.ok(settings.hooks, 'settings.json should contain hooks key'); | ||
| assert.ok(settings.hooks.PreToolUse, 'hooks should include PreToolUse'); | ||
| assert.ok(Array.isArray(settings.hooks.PreToolUse), 'PreToolUse should be an array'); | ||
| assert.ok(settings.hooks.PreToolUse.length > 0, 'PreToolUse should have entries'); | ||
| } finally { | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('preserves existing settings fields and hook entries when merging hooks', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
| try { | ||
| const claudeRoot = path.join(homeDir, '.claude'); | ||
| fs.mkdirSync(claudeRoot, { recursive: true }); | ||
| fs.writeFileSync( | ||
| path.join(claudeRoot, 'settings.json'), | ||
| JSON.stringify({ | ||
| effortLevel: 'high', | ||
| env: { MY_VAR: '1' }, | ||
| hooks: { | ||
| PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }], | ||
| UserPromptSubmit: [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }], | ||
| }, | ||
| }, null, 2) | ||
| ); | ||
|
|
||
| const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(result.code, 0, result.stderr); | ||
|
|
||
| const settings = readJson(path.join(claudeRoot, 'settings.json')); | ||
| assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved'); | ||
| assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved'); | ||
| assert.ok(settings.hooks, 'hooks should be merged in'); | ||
| assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should exist'); | ||
| assert.ok( | ||
| settings.hooks.PreToolUse.some(entry => JSON.stringify(entry).includes('echo custom-pretool')), | ||
| 'existing PreToolUse entries should be preserved' | ||
| ); | ||
| assert.ok(settings.hooks.PreToolUse.length > 1, 'ECC PreToolUse hooks should be appended'); | ||
| assert.deepStrictEqual( | ||
| settings.hooks.UserPromptSubmit, | ||
| [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }], | ||
| 'user-defined hook event types should be preserved' | ||
| ); | ||
| } finally { | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('reinstall does not duplicate managed hook entries', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
| try { | ||
| const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(firstInstall.code, 0, firstInstall.stderr); | ||
|
|
||
| const settingsPath = path.join(homeDir, '.claude', 'settings.json'); | ||
| const afterFirstInstall = readJson(settingsPath); | ||
| const preToolUseLength = afterFirstInstall.hooks.PreToolUse.length; | ||
|
|
||
| const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); | ||
|
|
||
| const afterSecondInstall = readJson(settingsPath); | ||
| assert.strictEqual( | ||
| afterSecondInstall.hooks.PreToolUse.length, | ||
| preToolUseLength, | ||
| 'managed hook entries should not duplicate on reinstall' | ||
| ); | ||
| } finally { | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('fails when existing settings.json is malformed', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
| try { | ||
| const claudeRoot = path.join(homeDir, '.claude'); | ||
| fs.mkdirSync(claudeRoot, { recursive: true }); | ||
| const settingsPath = path.join(claudeRoot, 'settings.json'); | ||
| fs.writeFileSync(settingsPath, '{ invalid json\n'); | ||
|
|
||
| const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(result.code, 1); | ||
| assert.ok(result.stderr.includes('Failed to parse existing settings at')); | ||
| assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure'); | ||
| } finally { | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('fails when existing settings.json root is not an object', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
| try { | ||
| const claudeRoot = path.join(homeDir, '.claude'); | ||
| fs.mkdirSync(claudeRoot, { recursive: true }); | ||
| const settingsPath = path.join(claudeRoot, 'settings.json'); | ||
| fs.writeFileSync(settingsPath, '[]\n'); | ||
|
|
||
| const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(result.code, 1); | ||
| assert.ok(result.stderr.includes('Invalid existing settings at')); | ||
| assert.ok(result.stderr.includes('expected a JSON object')); | ||
| assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure'); | ||
| } finally { | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
|
||
| if (test('fails when source hooks.json root is not an object before copying files', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
| const sourceHooksPath = path.join(REPO_ROOT, 'hooks', 'hooks.json'); | ||
| const originalHooks = fs.readFileSync(sourceHooksPath, 'utf8'); | ||
|
|
||
| try { | ||
| fs.writeFileSync(sourceHooksPath, '[]\n'); | ||
|
|
||
| const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); | ||
| assert.strictEqual(result.code, 1); | ||
| assert.ok(result.stderr.includes('Invalid hooks config at')); | ||
| assert.ok(result.stderr.includes('expected a JSON object')); | ||
|
|
||
| const claudeRoot = path.join(homeDir, '.claude'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid'); | ||
| assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written when source hooks are invalid'); | ||
| } finally { | ||
| fs.writeFileSync(sourceHooksPath, originalHooks); | ||
| cleanup(homeDir); | ||
| cleanup(projectDir); | ||
| } | ||
| })) passed++; else failed++; | ||
|
Comment on lines
+471
to
+493
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This test writes Because the installer is invoked as a child process ( A safer pattern is to use a temporary directory to stage a modified copy of the hooks source and point the installer at it via an environment variable or a plan fixture, keeping the real source file untouched: // Instead of modifying REPO_ROOT/hooks/hooks.json in-place:
const fakeSourceDir = createTempDir('fake-hooks-source-');
const fakeHooksPath = path.join(fakeSourceDir, 'hooks', 'hooks.json');
fs.mkdirSync(path.dirname(fakeHooksPath), { recursive: true });
fs.writeFileSync(fakeHooksPath, '[]\n');
// Pass fakeSourceDir to the installer via env / config
// ...
cleanup(fakeSourceDir);Alternatively, copying the entire hooks source tree to a temp fixture directory for the test suite would eliminate the dependency on the live repo file entirely. |
||
|
|
||
| if (test('installs from ecc-install.json and persists component selections', () => { | ||
| const homeDir = createTempDir('install-apply-home-'); | ||
| const projectDir = createTempDir('install-apply-project-'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
findHooksSourcePathreturns a non-null path (because an operation forhooks.jsonexists in the plan), but that source file is absent on disk,fs.existsSyncreturnsfalseandbuildMergedSettingssilently returnsnull. As a result:settings.json— the operation the plan explicitly requested is skipped with no diagnostic.copyFileSynccall in the operations loop will throw a generic "no such file" error instead of a clear, actionable message.Consider raising an explicit error when the source path comes from the plan (meaning it is expected to exist):