Files
ccs/src/commands/cleanup-command.ts
T
kaitranntt 45207b4e7f feat(cleanup): add age-based error log cleanup
- add --errors flag to target error-*.log files

- add --days=N for age filtering (default: 7 days)

- show preview of files to delete before confirmation

- keep recent logs for debugging, only delete old ones
2025-12-18 23:45:44 -05:00

384 lines
11 KiB
TypeScript

/**
* 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<void> {
// 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<void> {
// 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<string>((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<void> {
// 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<string>((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.');
}
}