fix(installer): strip stale auto-sync hooks on install and uninstall (#278)
Pre-0.8 installers wrote `codegraph mark-dirty` / `sync-if-dirty` hooks
to Claude Code's settings.json. Both subcommands were removed from the
CLI, so the Stop hook fails every turn ("unknown command
'sync-if-dirty'"). The cleanup that once removed them was lost when the
installer moved to the per-target architecture.
Add cleanupLegacyHooks(), wired into both install (upgrades self-heal)
and uninstall (so the npm preuninstall step fully reverses a legacy
install). Surgical at the command level: only codegraph's own hook
entries are dropped, so unrelated hooks sharing a matcher group or event
(e.g. GitKraken's `gk ai hook run`) survive, and a settings.json with no
legacy hooks is left byte-for-byte untouched.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4329a52bec
commit
b8aec39abd
@@ -20,6 +20,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
|
||||
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
|
||||
import { cleanupLegacyHooks } from '../src/installer/targets/claude';
|
||||
|
||||
function mkTmpDir(label: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
|
||||
@@ -433,6 +434,120 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
expect(legacy.mcpServers.codegraph).toBeUndefined();
|
||||
expect(legacy.mcpServers.other).toBeDefined();
|
||||
});
|
||||
|
||||
// ---- Legacy auto-sync hook cleanup ----
|
||||
// Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty`
|
||||
// hooks to settings.json. Both subcommands were removed from the CLI,
|
||||
// so the Stop hook fails every turn ("unknown command
|
||||
// 'sync-if-dirty'"). The installer must strip them on upgrade and
|
||||
// uninstall — without touching the user's unrelated hooks.
|
||||
|
||||
function seedSettings(loc: 'global' | 'local', settings: Record<string, any>): string {
|
||||
const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, 'settings.json');
|
||||
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
||||
return file;
|
||||
}
|
||||
|
||||
// Realistic pre-0.8 settings.json: our two auto-sync hooks plus an
|
||||
// unrelated GitKraken Stop hook the user added (matches the report).
|
||||
function legacyHookSettings(): Record<string, any> {
|
||||
return {
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] },
|
||||
],
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] },
|
||||
{ hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => {
|
||||
const claude = getTarget('claude')!;
|
||||
const file = seedSettings('global', legacyHookSettings());
|
||||
|
||||
claude.install('global', { autoAllow: true });
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
// The only PostToolUse group held mark-dirty → the event is gone.
|
||||
expect(after.hooks?.PostToolUse).toBeUndefined();
|
||||
const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) =>
|
||||
(g.hooks ?? []).map((h: any) => h.command),
|
||||
);
|
||||
expect(stopCommands).not.toContain('codegraph sync-if-dirty');
|
||||
// The unrelated GitKraken hook survives untouched.
|
||||
expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true);
|
||||
// Permissions still written as normal alongside the cleanup.
|
||||
expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search');
|
||||
});
|
||||
|
||||
it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => {
|
||||
const file = seedSettings('global', {
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'codegraph sync-if-dirty' },
|
||||
{ type: 'command', command: 'gk ai hook run --host claude-code' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(cleanupLegacyHooks('global').action).toBe('removed');
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([
|
||||
'gk ai hook run --host claude-code',
|
||||
]);
|
||||
});
|
||||
|
||||
it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => {
|
||||
const original =
|
||||
JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n';
|
||||
const file = seedSettings('global', JSON.parse(original));
|
||||
|
||||
expect(cleanupLegacyHooks('global').action).toBe('unchanged');
|
||||
expect(fs.readFileSync(file, 'utf-8')).toBe(original);
|
||||
});
|
||||
|
||||
it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => {
|
||||
expect(cleanupLegacyHooks('global').action).toBe('not-found');
|
||||
});
|
||||
|
||||
it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => {
|
||||
const claude = getTarget('claude')!;
|
||||
const file = seedSettings('global', legacyHookSettings());
|
||||
claude.install('global', { autoAllow: true });
|
||||
const firstPass = fs.readFileSync(file, 'utf-8');
|
||||
claude.install('global', { autoAllow: true });
|
||||
expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass);
|
||||
});
|
||||
|
||||
it('claude: uninstall strips stale hooks written in the npx form (local)', () => {
|
||||
const claude = getTarget('claude')!;
|
||||
const file = seedSettings('local', {
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] },
|
||||
],
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
claude.uninstall('local');
|
||||
|
||||
const after = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
// Both events emptied → the whole `hooks` object is removed.
|
||||
expect(after.hooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Installer targets — registry', () => {
|
||||
|
||||
Reference in New Issue
Block a user