/** * Cleanup Command Handler * * Removes old CLIProxy logs to free up disk space. * Supports both main logs and error request logs with age-based filtering. * Logs can accumulate to several GB without user awareness. */ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir } from '../cliproxy/config-generator'; import { info, ok, warn } from '../utils/ui'; /** Default age in days for error log cleanup */ const DEFAULT_ERROR_LOG_AGE_DAYS = 7; /** Get the CLIProxy logs directory */ function getLogsDir(): string { return path.join(getCliproxyDir(), 'logs'); } /** Format bytes to human-readable size */ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } /** Calculate total size of a directory */ function getDirSize(dirPath: string): number { if (!fs.existsSync(dirPath)) return 0; let totalSize = 0; const files = fs.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); try { const stats = fs.lstatSync(filePath); // Use lstat to detect symlinks if (stats.isFile() && !stats.isSymbolicLink()) { totalSize += stats.size; } else if (stats.isDirectory() && !stats.isSymbolicLink()) { totalSize += getDirSize(filePath); } // Skip symlinks for safety } catch { // File may have been deleted between readdir and stat - skip } } return totalSize; } /** Count files in a directory */ function countFiles(dirPath: string): number { if (!fs.existsSync(dirPath)) return 0; let count = 0; const entries = fs.readdirSync(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); try { const stats = fs.lstatSync(filePath); if (stats.isFile() && !stats.isSymbolicLink()) { count++; } } catch { // File may have been deleted - skip } } return count; } /** Delete all regular files in a directory (skips symlinks for safety) */ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number } { if (!fs.existsSync(dirPath)) return { deleted: 0, freedBytes: 0 }; let deleted = 0; let freedBytes = 0; const files = fs.readdirSync(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); try { const stats = fs.lstatSync(filePath); // Only delete regular files, skip symlinks for security if (stats.isFile() && !stats.isSymbolicLink()) { freedBytes += stats.size; fs.unlinkSync(filePath); deleted++; } } catch { // File may have been deleted or inaccessible - skip } } return { deleted, freedBytes }; } /** Error log file info */ interface ErrorLogInfo { name: string; path: string; size: number; mtime: Date; ageInDays: number; } /** Get error log files with metadata */ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { if (!fs.existsSync(logsDir)) return []; const now = Date.now(); const files: ErrorLogInfo[] = []; const entries = fs.readdirSync(logsDir); for (const entry of entries) { // Only process error-*.log files if (!entry.startsWith('error-') || !entry.endsWith('.log')) continue; const filePath = path.join(logsDir, entry); try { const stats = fs.lstatSync(filePath); if (stats.isFile() && !stats.isSymbolicLink()) { const ageMs = now - stats.mtime.getTime(); files.push({ name: entry, path: filePath, size: stats.size, mtime: stats.mtime, ageInDays: Math.floor(ageMs / (1000 * 60 * 60 * 24)), }); } } catch { // File may have been deleted - skip } } // Sort by age, oldest first return files.sort((a, b) => b.ageInDays - a.ageInDays); } /** Delete error logs older than specified days */ function cleanErrorLogs( logsDir: string, maxAgeDays: number ): { deleted: number; freedBytes: number; kept: number } { const files = getErrorLogFiles(logsDir); let deleted = 0; let freedBytes = 0; let kept = 0; for (const file of files) { if (file.ageInDays >= maxAgeDays) { try { fs.unlinkSync(file.path); deleted++; freedBytes += file.size; } catch { // File may be locked or already deleted } } else { kept++; } } return { deleted, freedBytes, kept }; } /** Print help for cleanup command */ function printHelp(): void { console.log(''); console.log('Usage: ccs cleanup [options]'); console.log(''); console.log('Remove old CLIProxy logs to free up disk space.'); console.log(''); console.log('Options:'); console.log(' --errors Clean error request logs (error-*.log files)'); console.log(' --days=N Delete error logs older than N days (default: 7)'); console.log(' --dry-run Show what would be deleted without deleting'); console.log(' --force Skip confirmation prompt'); console.log(' --help, -h Show this help message'); console.log(''); console.log('Examples:'); console.log(' ccs cleanup Interactive main log cleanup'); console.log(' ccs cleanup --errors Clean error logs older than 7 days'); console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days'); console.log(' ccs cleanup --errors --dry-run Preview error log cleanup'); console.log(' ccs cleanup --dry-run Preview main log cleanup'); console.log(' ccs cleanup --force Clean main logs without confirmation'); console.log(''); } /** * Handle cleanup command */ export async function handleCleanupCommand(args: string[]): Promise { // Handle help if (args.includes('--help') || args.includes('-h')) { printHelp(); return; } const dryRun = args.includes('--dry-run'); const force = args.includes('--force'); const cleanErrors = args.includes('--errors'); const logsDir = getLogsDir(); // Parse --days=N option let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS; const daysArg = args.find((arg) => arg.startsWith('--days=')); if (daysArg) { const parsed = parseInt(daysArg.split('=')[1], 10); if (isNaN(parsed) || parsed < 1) { console.log(warn('Invalid --days value. Must be a positive integer.')); return; } maxAgeDays = parsed; } // Route to error log cleanup or main log cleanup if (cleanErrors) { await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force); } else { await handleMainLogCleanup(logsDir, dryRun, force); } } /** * Handle error log cleanup (error-*.log files) */ async function handleErrorLogCleanup( logsDir: string, maxAgeDays: number, dryRun: boolean, force: boolean ): Promise { // Check if logs directory exists if (!fs.existsSync(logsDir)) { console.log(info('No CLIProxy logs directory found.')); return; } // Get error log files const errorLogs = getErrorLogFiles(logsDir); if (errorLogs.length === 0) { console.log(info('No error logs found.')); return; } // Calculate what would be deleted const toDelete = errorLogs.filter((f) => f.ageInDays >= maxAgeDays); const toKeep = errorLogs.filter((f) => f.ageInDays < maxAgeDays); const totalDeleteSize = toDelete.reduce((sum, f) => sum + f.size, 0); console.log(''); console.log(`Error Logs: ${logsDir}`); console.log(` Total: ${errorLogs.length} files`); console.log( ` To delete: ${toDelete.length} files older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})` ); console.log(` To keep: ${toKeep.length} files newer than ${maxAgeDays} days`); console.log(''); if (toDelete.length === 0) { console.log(info(`No error logs older than ${maxAgeDays} days.`)); return; } // Show oldest files in dry-run or verbose mode if (dryRun || toDelete.length <= 5) { console.log('Files to delete:'); for (const file of toDelete.slice(0, 10)) { console.log(` ${file.name} (${file.ageInDays}d old, ${formatBytes(file.size)})`); } if (toDelete.length > 10) { console.log(` ... and ${toDelete.length - 10} more`); } console.log(''); } if (dryRun) { console.log(info('Dry run - no files deleted.')); return; } // Confirm unless --force if (!force) { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const answer = await new Promise((resolve) => { rl.question( `Delete ${toDelete.length} error logs older than ${maxAgeDays} days (${formatBytes(totalDeleteSize)})? [y/N] `, resolve ); }); rl.close(); if (answer.toLowerCase() !== 'y') { console.log('Cancelled.'); return; } } // Perform cleanup const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays); console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`)); if (kept > 0) { console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`)); } } /** * Handle main log cleanup (main.log and rotated files) */ async function handleMainLogCleanup( logsDir: string, dryRun: boolean, force: boolean ): Promise { // Check if logs directory exists if (!fs.existsSync(logsDir)) { console.log(info('No CLIProxy logs found.')); return; } // Calculate current size const currentSize = getDirSize(logsDir); const fileCount = countFiles(logsDir); if (fileCount === 0) { console.log(info('No log files to clean.')); return; } console.log(''); console.log(`CLIProxy Logs: ${logsDir}`); console.log(` Files: ${fileCount}`); console.log(` Size: ${formatBytes(currentSize)}`); console.log(''); if (dryRun) { console.log(info('Dry run - no files deleted.')); console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`); return; } // Confirm unless --force if (!force) { const readline = await import('readline'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const answer = await new Promise((resolve) => { rl.question(`Delete ${fileCount} log files (${formatBytes(currentSize)})? [y/N] `, resolve); }); rl.close(); if (answer.toLowerCase() !== 'y') { console.log('Cancelled.'); return; } } // Perform cleanup const { deleted, freedBytes } = cleanDirectory(logsDir); console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`)); // Suggest disabling logging if it was enabled if (deleted > 0) { console.log(''); console.log(warn('Tip: CLIProxy logging is now disabled by default.')); console.log(' Run `ccs doctor --fix` to update your config.'); } }