mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 12:14:18 +00:00
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:
1 parent
5c0d211ddf
commit
2956842911
2 files changed
+219
-13
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user