159 lines
6.9 KiB
TypeScript
159 lines
6.9 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest'
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { existsSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { runAction } from '../src/act/apply.js'
|
|
import { readRecords } from '../src/act/journal.js'
|
|
import {
|
|
buildInstall, buildUninstall, inspectInstall, settingsPathFor,
|
|
GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND,
|
|
} from '../src/guard/settings.js'
|
|
|
|
const roots: string[] = []
|
|
async function makeRoot(): Promise<{ settings: string; actionsDir: string }> {
|
|
const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-'))
|
|
roots.push(root)
|
|
await mkdir(join(root, '.claude'), { recursive: true })
|
|
return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') }
|
|
}
|
|
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
|
|
|
|
function canonical(obj: unknown): string {
|
|
return JSON.stringify(obj, null, 2) + '\n'
|
|
}
|
|
async function readJson(path: string): Promise<any> {
|
|
return JSON.parse(await readFile(path, 'utf-8'))
|
|
}
|
|
|
|
describe('settingsPathFor', () => {
|
|
it('resolves project (default) and global scopes', () => {
|
|
expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json'))
|
|
expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json'))
|
|
expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json'))
|
|
})
|
|
})
|
|
|
|
describe('guard install', () => {
|
|
it('creates settings with all three hook events when none exists', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await rm(settings, { force: true })
|
|
const built = buildInstall(settings)
|
|
expect(built.plan).not.toBeNull()
|
|
expect(built.plan!.kind).toBe('guard-install')
|
|
await runAction(built.plan!, actionsDir)
|
|
|
|
const doc = await readJson(settings)
|
|
expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
|
|
expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`)
|
|
expect(doc.hooks.SessionStart[0].matcher).toBe('startup')
|
|
expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher
|
|
// journaled + undoable
|
|
const records = await readRecords(actionsDir)
|
|
expect(records[0]!.kind).toBe('guard-install')
|
|
})
|
|
|
|
it('appends to pre-existing user hooks without disturbing them', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
const original = canonical({
|
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
|
})
|
|
await writeFile(settings, original)
|
|
|
|
await runAction(buildInstall(settings).plan!, actionsDir)
|
|
const doc = await readJson(settings)
|
|
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
|
|
expect(commands).toContain('my-own-hook.sh')
|
|
expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`)
|
|
})
|
|
|
|
it('is idempotent: re-install adds nothing', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await rm(settings, { force: true })
|
|
await runAction(buildInstall(settings).plan!, actionsDir)
|
|
const again = buildInstall(settings)
|
|
expect(again.plan).toBeNull()
|
|
expect(again.notes.join(' ')).toContain('already present')
|
|
})
|
|
|
|
it('configures the statusline only when none exists', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await rm(settings, { force: true })
|
|
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
|
|
expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND)
|
|
|
|
// A pre-existing statusline is refused, not overwritten.
|
|
const { settings: s2, actionsDir: a2 } = await makeRoot()
|
|
await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } }))
|
|
const built = buildInstall(s2, { statusline: true })
|
|
expect(built.notes.join(' ')).toContain('statusline is already configured')
|
|
await runAction(built.plan!, a2)
|
|
expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh')
|
|
})
|
|
|
|
it('refuses to apply a plan when the settings file changed after it was built', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await writeFile(settings, canonical({ permissions: { allow: [] } }))
|
|
const built = buildInstall(settings)
|
|
expect(built.plan).not.toBeNull()
|
|
|
|
const concurrent = canonical({ permissions: { allow: ['Bash(ls:*)'] } })
|
|
await writeFile(settings, concurrent)
|
|
|
|
await expect(runAction(built.plan!, actionsDir)).rejects.toThrow(/changed since the plan was built/)
|
|
expect(await readFile(settings, 'utf-8')).toBe(concurrent) // the concurrent edit survives
|
|
expect(await readRecords(actionsDir)).toEqual([]) // nothing journaled
|
|
})
|
|
})
|
|
|
|
describe('guard uninstall', () => {
|
|
it('removes exactly our entries and restores byte-identical settings', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
const original = canonical({
|
|
permissions: { allow: [] },
|
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
|
})
|
|
await writeFile(settings, original)
|
|
|
|
await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir)
|
|
// ours are present now
|
|
const mid = inspectInstall(settings)
|
|
expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop'])
|
|
expect(mid.statusline).toBe(true)
|
|
|
|
await runAction(buildUninstall(settings).plan!, actionsDir)
|
|
expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical
|
|
const after = inspectInstall(settings)
|
|
expect(after.hooks).toEqual([])
|
|
expect(after.statusline).toBe(false)
|
|
})
|
|
|
|
it('preserves a user hook that shares the PreToolUse array', async () => {
|
|
const { settings, actionsDir } = await makeRoot()
|
|
await writeFile(settings, canonical({
|
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] },
|
|
}))
|
|
await runAction(buildInstall(settings).plan!, actionsDir)
|
|
await runAction(buildUninstall(settings).plan!, actionsDir)
|
|
const doc = await readJson(settings)
|
|
const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command))
|
|
expect(commands).toEqual(['my-own-hook.sh'])
|
|
})
|
|
|
|
it('reports nothing to do on a settings file without our hooks', async () => {
|
|
const { settings } = await makeRoot()
|
|
await writeFile(settings, canonical({ hooks: {} }))
|
|
const built = buildUninstall(settings)
|
|
expect(built.plan).toBeNull()
|
|
expect(built.notes.join(' ')).toContain('no codeburn guard hooks')
|
|
})
|
|
|
|
it('reports nothing to do when the settings file is absent', async () => {
|
|
const { settings } = await makeRoot()
|
|
await rm(settings, { force: true })
|
|
expect(existsSync(settings)).toBe(false)
|
|
const built = buildUninstall(settings)
|
|
expect(built.plan).toBeNull()
|
|
})
|
|
})
|