chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user