Attempt to add backups (#123)

* Attempt to add backups

* fix: quiet invalid claude settings parse output in TUI

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
Rubén VarelaandMatthew Breedlove authored and GitHub committed 2026-03-04 10:31:30 -05:00
1 parent 5c0d211ddf
commit 2956842911
2 files changed
+219 -13

No files matched your search

+149 -2
View File
@@ -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);
});
});
+70 -11
View File
@@ -89,16 +89,44 @@ export function getClaudeSettingsPath(): string {
return path.join(getClaudeConfigDir(), 'settings.json');
}
export async function loadClaudeSettings(): Promise<ClaudeSettings> {
/**
* Creates a backup of the current Claude settings file.
*/
async function backupClaudeSettings(suffix = '.bak'): Promise<string | null> {
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<ClaudeSettings> {
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<void> {
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<boolean> {
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<void> {
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<void> {
}
export async function uninstallStatusLine(): Promise<void> {
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<void> {
}
export async function getExistingStatusLine(): Promise<string | null> {
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
}
}