feat: wizard remediation — password mask, hooks preview, headless (IUH-M02) (#431)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #431.
This commit is contained in:
2026-04-05 17:47:53 +00:00
parent 8fa5995bde
commit cd8b1f666d
11 changed files with 1098 additions and 43 deletions

View File

@@ -28,11 +28,20 @@ describe('registerConfigCommand', () => {
expect(names).toContain('config');
});
it('registers exactly the five required subcommands', () => {
it('registers exactly the required subcommands', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const subs = config.commands.map((c) => c.name()).sort();
expect(subs).toEqual(['edit', 'get', 'path', 'set', 'show']);
expect(subs).toEqual(['edit', 'get', 'hooks', 'path', 'set', 'show']);
});
it('registers hooks sub-subcommands: list, enable, disable', () => {
const program = buildProgram();
const config = getConfigCmd(program);
const hooks = config.commands.find((c) => c.name() === 'hooks');
expect(hooks).toBeDefined();
const hookSubs = hooks!.commands.map((c) => c.name()).sort();
expect(hookSubs).toEqual(['disable', 'enable', 'list']);
});
});
@@ -264,6 +273,142 @@ describe('config edit', () => {
});
});
// ── config hooks ─────────────────────────────────────────────────────────────
const MOCK_HOOKS_CONFIG = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [
{
matcher: 'Write|Edit',
hooks: [{ type: 'command', command: 'bash', args: ['-c', 'echo'] }],
},
],
},
});
const MOCK_HOOKS_WITH_DISABLED = JSON.stringify({
name: 'Test Hooks',
hooks: {
PostToolUse: [{ matcher: 'Write|Edit', hooks: [] }],
_disabled_PreToolUse: [{ matcher: 'Bash', hooks: [] }],
},
});
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
}));
async function getFsMock() {
const fs = await import('node:fs');
return {
existsSync: fs.existsSync as ReturnType<typeof vi.fn>,
readFileSync: fs.readFileSync as ReturnType<typeof vi.fn>,
writeFileSync: fs.writeFileSync as ReturnType<typeof vi.fn>,
};
}
describe('config hooks list', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
// Ensure CLAUDE_HOME is set to a stable value for tests
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('lists hooks with enabled/disabled status', async () => {
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('PostToolUse');
expect(output).toContain('enabled');
});
it('shows disabled hooks from MOCK_HOOKS_WITH_DISABLED', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('disabled');
expect(output).toContain('PreToolUse');
});
it('prints a message when hooks-config.json is missing', async () => {
const fs = await getFsMock();
fs.existsSync.mockReturnValue(false);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'list']);
const output = consoleSpy.mock.calls.map((c) => c[0] as string).join('\n');
expect(output).toContain('No hooks-config.json');
});
});
describe('config hooks disable / enable', () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.clearAllMocks();
mockSvc.isInitialized.mockReturnValue(true);
const fs = await getFsMock();
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(MOCK_HOOKS_CONFIG);
process.env['CLAUDE_HOME'] = '/tmp/claude-test';
});
afterEach(() => {
consoleSpy.mockRestore();
delete process.env['CLAUDE_HOME'];
});
it('disables a hook by event name and writes updated config', async () => {
const fs = await getFsMock();
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'disable', 'PostToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['_disabled_PostToolUse']).toBeDefined();
expect(written.hooks['PostToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});
it('enables a disabled hook and writes updated config', async () => {
const fs = await getFsMock();
fs.readFileSync.mockReturnValue(MOCK_HOOKS_WITH_DISABLED);
const program = buildProgram();
await program.parseAsync(['node', 'mosaic', 'config', 'hooks', 'enable', 'PreToolUse']);
expect(fs.writeFileSync).toHaveBeenCalled();
const written = JSON.parse((fs.writeFileSync.mock.calls[0] as [string, string])[1]) as {
hooks: Record<string, unknown>;
};
expect(written.hooks['PreToolUse']).toBeDefined();
expect(written.hooks['_disabled_PreToolUse']).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('enabled'));
});
});
// ── not-initialized guard ────────────────────────────────────────────────────
describe('not-initialized guard', () => {