diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 27ddde7..9108d38 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -7,14 +7,20 @@ import { beforeEach, describe, expect, - it + it, + vi } from 'vitest'; import { CCSTATUSLINE_COMMANDS, getClaudeSettingsPath, + getExistingStatusLine, installStatusLine, - isKnownCommand + isInstalled, + isKnownCommand, + loadClaudeSettings, + saveClaudeSettings, + uninstallStatusLine } from '../claude-settings'; import { initConfigPath } from '../config'; @@ -28,6 +34,12 @@ function readInstalledCommand(): string { return data.statusLine?.command ?? ''; } +function writeRawClaudeSettings(content: string): void { + const settingsPath = getClaudeSettingsPath(); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, content, 'utf-8'); +} + beforeEach(() => { testClaudeConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-settings-')); process.env.CLAUDE_CONFIG_DIR = testClaudeConfigDir; @@ -131,4 +143,139 @@ describe('buildCommand via installStatusLine', () => { await installStatusLine(true); expect(readInstalledCommand()).toBe(`${CCSTATUSLINE_COMMANDS.BUNX} --config '/my path/settings.json'`); }); +}); + +describe('backup and error handling behavior', () => { + it('saveClaudeSettings should create .bak backup before overwrite', async () => { + writeRawClaudeSettings(JSON.stringify({ + statusLine: { + type: 'command', + command: 'preexisting-command', + padding: 1 + } + })); + + await saveClaudeSettings({ + statusLine: { + type: 'command', + command: CCSTATUSLINE_COMMANDS.NPM, + padding: 0 + } + }); + + const settingsPath = getClaudeSettingsPath(); + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string } }; + expect(saved.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM); + expect(fs.existsSync(`${settingsPath}.bak`)).toBe(true); + + const backup = JSON.parse(fs.readFileSync(`${settingsPath}.bak`, 'utf-8')) as { statusLine?: { command?: string } }; + expect(backup.statusLine?.command).toBe('preexisting-command'); + }); + + it('installStatusLine should create .orig backup before updating settings', async () => { + writeRawClaudeSettings(JSON.stringify({ + statusLine: { + type: 'command', + command: 'old-command', + padding: 1 + } + })); + + await installStatusLine(false); + + const settingsPath = getClaudeSettingsPath(); + expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true); + + const orig = JSON.parse(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')) as { statusLine?: { command?: string } }; + expect(orig.statusLine?.command).toBe('old-command'); + }); + + it('loadClaudeSettings should return empty object when settings file is missing', async () => { + await expect(loadClaudeSettings()).resolves.toEqual({}); + }); + + it('loadClaudeSettings should log and throw when settings file is invalid JSON', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(loadClaudeSettings()).rejects.toThrow(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load Claude settings:', + expect.anything() + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('isInstalled should return false when settings cannot be loaded', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(isInstalled()).resolves.toBe(false); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('installStatusLine should warn and recover when existing settings are invalid', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await installStatusLine(false); + + const settingsPath = getClaudeSettingsPath(); + const installed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { statusLine?: { command?: string; padding?: number } }; + expect(installed.statusLine?.command).toBe(CCSTATUSLINE_COMMANDS.NPM); + expect(installed.statusLine?.padding).toBe(0); + expect(fs.existsSync(`${settingsPath}.orig`)).toBe(true); + expect(fs.readFileSync(`${settingsPath}.orig`, 'utf-8')).toBe('{ invalid json'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Warning: Could not read existing Claude settings. A backup exists at ${settingsPath}.orig.` + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('uninstallStatusLine should warn and return without modifying invalid settings', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await uninstallStatusLine(); + + const settingsPath = getClaudeSettingsPath(); + expect(fs.readFileSync(settingsPath, 'utf-8')).toBe('{ invalid json'); + expect(fs.existsSync(`${settingsPath}.bak`)).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Warning: Could not read existing Claude settings.' + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('getExistingStatusLine should return null when settings cannot be loaded', async () => { + writeRawClaudeSettings('{ invalid json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(getExistingStatusLine()).resolves.toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('isInstalled should accept known commands with --config and undefined padding', async () => { + await saveClaudeSettings({ + statusLine: { + type: 'command', + command: `${CCSTATUSLINE_COMMANDS.NPM} --config /tmp/settings.json` + } + }); + + await expect(isInstalled()).resolves.toBe(true); + }); }); \ No newline at end of file diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 76bfb30..07be371 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -89,16 +89,44 @@ export function getClaudeSettingsPath(): string { return path.join(getClaudeConfigDir(), 'settings.json'); } -export async function loadClaudeSettings(): Promise { +/** + * Creates a backup of the current Claude settings file. + */ +async function backupClaudeSettings(suffix = '.bak'): Promise { + const settingsPath = getClaudeSettingsPath(); + const backupPath = settingsPath + suffix; try { - const settingsPath = getClaudeSettingsPath(); - if (!fs.existsSync(settingsPath)) { - return {}; + if (fs.existsSync(settingsPath)) { + const content = await readFile(settingsPath, 'utf-8'); + await writeFile(backupPath, content, 'utf-8'); + return backupPath; } + } catch (error) { + console.error('Failed to backup Claude settings:', error); + } + + return null; +} + +interface LoadClaudeSettingsOptions { logErrors?: boolean } + +export async function loadClaudeSettings(options: LoadClaudeSettingsOptions = {}): Promise { + const { logErrors = true } = options; + const settingsPath = getClaudeSettingsPath(); + + // File doesn't exist - return empty object + if (!fs.existsSync(settingsPath)) { + return {}; + } + + try { const content = await readFile(settingsPath, 'utf-8'); return JSON.parse(content) as ClaudeSettings; - } catch { - return {}; + } catch (error) { + if (logErrors) { + console.error('Failed to load Claude settings:', error); + } + throw error; } } @@ -107,13 +135,24 @@ export async function saveClaudeSettings( ): Promise { const settingsPath = getClaudeSettingsPath(); const dir = path.dirname(settingsPath); + + // Backup settings before overwriting + await backupClaudeSettings(); + await mkdir(dir, { recursive: true }); await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); } export async function isInstalled(): Promise { - const settings = await loadClaudeSettings(); + let settings: ClaudeSettings; + + try { + settings = await loadClaudeSettings({ logErrors: false }); + } catch { + return false; // Can't determine if installed, assume not + } const command = settings.statusLine?.command ?? ''; + return ( isKnownCommand(command) && (settings.statusLine?.padding === 0 @@ -140,7 +179,16 @@ function buildCommand(baseCommand: string): string { } export async function installStatusLine(useBunx = false): Promise { - const settings = await loadClaudeSettings(); + let settings: ClaudeSettings; + + const backupPath = await backupClaudeSettings('.orig'); + try { + settings = await loadClaudeSettings({ logErrors: false }); + } catch { + const fallbackBackupPath = `${getClaudeSettingsPath()}.orig`; + console.error(`Warning: Could not read existing Claude settings. A backup exists at ${backupPath ?? fallbackBackupPath}.`); + settings = {}; + } const baseCommand = useBunx ? CCSTATUSLINE_COMMANDS.BUNX @@ -157,7 +205,14 @@ export async function installStatusLine(useBunx = false): Promise { } export async function uninstallStatusLine(): Promise { - const settings = await loadClaudeSettings(); + let settings: ClaudeSettings; + + try { + settings = await loadClaudeSettings({ logErrors: false }); + } catch { + console.error('Warning: Could not read existing Claude settings.'); + return; // if we can't read, return... what are we uninstalling? + } if (settings.statusLine) { delete settings.statusLine; @@ -166,6 +221,10 @@ export async function uninstallStatusLine(): Promise { } export async function getExistingStatusLine(): Promise { - const settings = await loadClaudeSettings(); - return settings.statusLine?.command ?? null; + try { + const settings = await loadClaudeSettings({ logErrors: false }); + return settings.statusLine?.command ?? null; + } catch { + return null; // Can't read settings, return null + } } \ No newline at end of file