diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index bae4e192..20c23cfa 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -2,6 +2,7 @@ * 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. */ @@ -10,6 +11,9 @@ 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'); @@ -95,6 +99,76 @@ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number 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(''); @@ -103,20 +177,19 @@ function printHelp(): void { 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 cleanup with confirmation'); - console.log(' ccs cleanup --dry-run Preview cleanup without deleting'); - console.log(' ccs cleanup --force Clean without confirmation'); - console.log(''); - console.log('Note: CLIProxy logging is disabled by default.'); - console.log('To enable logging, edit ~/.ccs/config.yaml:'); - console.log(' cliproxy:'); - console.log(' logging:'); - console.log(' enabled: true'); + 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(''); } @@ -132,8 +205,125 @@ export async function handleCleanupCommand(args: string[]): Promise { 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.'));