Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,36 @@ npx mac-cleaner-cli config --show

# Manage backups
npx mac-cleaner-cli backup --list
npx mac-cleaner-cli backup --restore <dir>
npx mac-cleaner-cli backup --clean
```

### Backups (opt-in)

By default, cleaning is **permanent**: files are removed with `rm -rf` and do
**not** go to the Trash.

You can opt into backups by setting `backupEnabled` in `~/.maccleanerrc`:

```json
{ "backupEnabled": true }
```

With backups on, selected items are **moved** to `~/.mac-cleaner-cli/backup/<timestamp>/`
instead of being deleted, and can be brought back with `backup --restore`.

Two honest caveats:

- **Moving does not free disk space.** The files still occupy the same volume.
Space is only reclaimed by `backup --clean`, which deletes backups older than
7 days permanently.
- **Docker and Homebrew cannot be backed up.** Their cleanup is performed by the
external tool (`docker system prune`, `brew cleanup`), so there is no file for
us to move. The CLI warns you before proceeding when these are selected.

If a backup fails, the item is **not** deleted — it stays where it is and the
failure is reported.

### Flags

```bash
Expand Down
113 changes: 113 additions & 0 deletions src/commands/clean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ vi.mock('@inquirer/checkbox', () => ({
default: vi.fn(),
}));

// loadConfig is mocked so tests never read the developer's real ~/.maccleanerrc
// (and never hit its module-level cache). Default: backups off, which is the
// shipped default.
vi.mock('../utils/config.js', async () => {
const actual = await vi.importActual<typeof import('../utils/config.js')>('../utils/config.js');
return { ...actual, loadConfig: vi.fn(async () => ({ backupEnabled: false })) };
});

vi.mock('child_process', () => ({
exec: vi.fn(),
spawn: vi.fn(() => ({
Expand All @@ -32,6 +40,8 @@ const inquirerPrompts = {
checkbox: inquirerCheckbox.default,
};

const config = await import('../utils/config.js');

const trashCategory: Category = {
id: 'trash',
name: 'Trash',
Expand Down Expand Up @@ -549,3 +559,106 @@ describe('clean command', () => {
consoleSpy.mockRestore();
});
});

describe('clean command backup handling', () => {
// The `clean` subcommand must honour `backupEnabled` exactly like the
// interactive flow. Backing up in one entry point and deleting permanently in
// the other is worse than not backing up at all: what happens to the user's
// files would depend on which command they happened to type.
const trashScanner = () => ({
category: trashCategory,
supportsBackup: true,
scan: vi.fn(),
clean: vi.fn().mockResolvedValue({
category: trashCategory,
cleanedItems: 1,
freedSpace: 0,
backedUpSize: 1000,
errors: [],
}),
});

const setup = (scanner: unknown) => {
vi.mocked(scanners.runAllScans).mockResolvedValue({
results: [
{
category: trashCategory,
items: [{ path: '/test', size: 1000, name: 'test', isDirectory: false }],
totalSize: 1000,
},
],
totalSize: 1000,
totalItems: 1,
});
vi.mocked(scanners.getScanner).mockReturnValue(
scanner as ReturnType<typeof scanners.getScanner>
);
vi.mocked(inquirerPrompts.confirm).mockResolvedValue(true);
};

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(config.loadConfig).mockResolvedValue({ backupEnabled: false });
});

it('deletes permanently when backupEnabled is off', async () => {
const scanner = trashScanner();
setup(scanner);
vi.mocked(config.loadConfig).mockResolvedValue({ backupEnabled: false });

const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

await cleanCommand({ all: true, yes: true });

expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined);

consoleSpy.mockRestore();
});

it('passes a backup directory to the scanner when backupEnabled is on', async () => {
const scanner = trashScanner();
setup(scanner);
vi.mocked(config.loadConfig).mockResolvedValue({ backupEnabled: true });

const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

await cleanCommand({ all: true, yes: true });

const backupDirArg = scanner.clean.mock.calls[0][2];
expect(backupDirArg).toEqual(expect.stringContaining('.mac-cleaner-cli'));

consoleSpy.mockRestore();
});

it('warns that moving does not free disk space', async () => {
const scanner = trashScanner();
setup(scanner);
vi.mocked(config.loadConfig).mockResolvedValue({ backupEnabled: true });

const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

await cleanCommand({ all: true, yes: true });

const output = consoleSpy.mock.calls.flat().join('\n');
expect(output).toContain('does NOT free disk space');

consoleSpy.mockRestore();
});

it('warns which categories cannot be backed up and will be deleted', async () => {
const scanner = { ...trashScanner(), supportsBackup: false };
setup(scanner);
vi.mocked(config.loadConfig).mockResolvedValue({ backupEnabled: true });

const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

await cleanCommand({ all: true, yes: true });

const output = consoleSpy.mock.calls.flat().join('\n');
expect(output).toContain('No backup possible');
// An external-tool scanner must never receive a backup directory.
expect(scanner.clean).toHaveBeenCalledWith(expect.anything(), undefined, undefined);

consoleSpy.mockRestore();
});
});
40 changes: 38 additions & 2 deletions src/commands/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import checkbox from '@inquirer/checkbox';
import { spawn } from 'child_process';
import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel } from '../types.js';
import { runAllScans, runScans, getScanner, getAllScanners } from '../scanners/index.js';
import { formatSize, createScanProgress, createCleanProgress } from '../utils/index.js';
import { formatSize, createScanProgress, createCleanProgress, loadConfig, ensureBackupDir, getBackupDir } from '../utils/index.js';

const DONATION_URL = 'https://ko-fi.com/guhcostan';

Expand Down Expand Up @@ -148,11 +148,45 @@ export async function cleanCommand(options: CleanCommandOptions): Promise<CleanS
return null;
}

// The `clean` subcommand honours `backupEnabled` exactly like the interactive
// flow does. Backing up in one entry point and deleting permanently in the
// other would be worse than not backing up at all: the user's expectation
// would depend on which command they happened to type.
const config = await loadConfig();
const backupEnabled = config.backupEnabled === true;

if (backupEnabled) {
const withoutBackup = selectedItems
.map(({ categoryId }) => getScanner(categoryId))
.filter((scanner) => scanner?.supportsBackup === false)
.map((scanner) => scanner.category.name);

console.log();
console.log(chalk.cyan(`Backup is ON — items will be MOVED to ${getBackupDir()}`));
console.log(
chalk.yellow('⚠ Moving does NOT free disk space. Run "mac-cleaner-cli backup --clean" to reclaim it.')
);
if (withoutBackup.length > 0) {
console.log(
chalk.red(
`⚠ No backup possible for: ${withoutBackup.join(', ')} (external tool does the cleanup) — these WILL be deleted.`
)
);
}
}

const backupDir =
backupEnabled && selectedItems.some(({ categoryId }) => getScanner(categoryId)?.supportsBackup !== false)
? await ensureBackupDir()
: undefined;

const cleanProgress = showProgress ? createCleanProgress(selectedItems.length) : null;

const cleanResults: CleanSummary = {
results: [],
totalFreedSpace: 0,
totalBackedUpSize: 0,
backupDir,
totalCleanedItems: 0,
totalErrors: 0,
};
Expand All @@ -162,9 +196,11 @@ export async function cleanCommand(options: CleanCommandOptions): Promise<CleanS
const scanner = getScanner(categoryId);
cleanProgress?.update(cleanedCount, `Cleaning ${scanner.category.name}...`);

const result = await scanner.clean(items, options.dryRun);
const scannerBackupDir = scanner.supportsBackup === false ? undefined : backupDir;
const result = await scanner.clean(items, options.dryRun, scannerBackupDir);
cleanResults.results.push(result);
cleanResults.totalFreedSpace += result.freedSpace;
cleanResults.totalBackedUpSize = (cleanResults.totalBackedUpSize ?? 0) + (result.backedUpSize ?? 0);
cleanResults.totalCleanedItems += result.cleanedItems;
cleanResults.totalErrors += result.errors.length;
cleanedCount++;
Expand Down
64 changes: 61 additions & 3 deletions src/commands/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import chalk from 'chalk';
import confirm from '@inquirer/confirm';
import type { CategoryId, CleanSummary, CleanableItem, ScanResult, SafetyLevel } from '../types.js';
import { runAllScans, getScanner, getAllScanners } from '../scanners/index.js';
import { formatSize, createScanProgress, createCleanProgress, hasFullDiskAccess, FULL_DISK_ACCESS_HINT } from '../utils/index.js';
import { formatSize, createScanProgress, createCleanProgress, hasFullDiskAccess, FULL_DISK_ACCESS_HINT, loadConfig, ensureBackupDir, getBackupDir } from '../utils/index.js';
import filePickerPrompt from '../pickers/file-picker.js';

const SAFETY_ICONS: Record<SafetyLevel, string> = {
Expand Down Expand Up @@ -92,10 +92,43 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom
const totalItems = selectedItems.reduce((sum, s) => sum + s.items.length, 0);

// Step 5: Confirm
// `backupEnabled` comes from ~/.maccleanerrc. Until now `loadConfig()` was
// only ever called by `config --show` — the file `config --init` wrote had no
// effect whatsoever on cleaning.
const config = await loadConfig();
const backupEnabled = config.backupEnabled === true;

const categoriesWithoutBackup = backupEnabled
? selectedItems
.map(({ categoryId }) => getScanner(categoryId))
.filter((scanner) => scanner?.supportsBackup === false)
.map((scanner) => scanner.category.name)
: [];

console.log();
console.log(chalk.bold('Summary:'));
console.log(` Items to delete: ${chalk.yellow(totalItems.toString())}`);
console.log(` Space to free: ${chalk.green(formatSize(totalToClean))}`);

if (backupEnabled) {
console.log();
console.log(chalk.cyan(` Backup is ON — items will be MOVED to ${getBackupDir()}`));
// The awkward part, said plainly: moving does not free space. Without this,
// the final report ("0 B freed") would look like a bug.
console.log(
chalk.yellow(
` ⚠ Moving does NOT free disk space. Run "mac-cleaner-cli backup --clean" to reclaim it.`
)
);
if (categoriesWithoutBackup.length > 0) {
console.log(
chalk.red(
` ⚠ No backup possible for: ${categoriesWithoutBackup.join(', ')} (external tool does the cleanup) — these WILL be deleted.`
)
);
}
}

console.log();

const proceed = await confirm({
Expand All @@ -109,11 +142,21 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom
}

// Step 6: Clean
// The backup directory is created ONCE per run, and only if some scanner in
// this run can actually back up — otherwise we would leave empty timestamped
// folders behind.
const backupDir =
backupEnabled && selectedItems.some(({ categoryId }) => getScanner(categoryId)?.supportsBackup !== false)
? await ensureBackupDir()
: undefined;

const cleanProgress = showProgress ? createCleanProgress(selectedItems.length) : null;

const cleanResults: CleanSummary = {
results: [],
totalFreedSpace: 0,
totalBackedUpSize: 0,
backupDir,
totalCleanedItems: 0,
totalErrors: 0,
};
Expand All @@ -123,9 +166,11 @@ export async function interactiveCommand(options: InteractiveOptions = {}): Prom
const scanner = getScanner(categoryId);
cleanProgress?.update(cleanedCount, `Cleaning ${scanner.category.name}...`);

const result = await scanner.clean(items);
const scannerBackupDir = scanner.supportsBackup === false ? undefined : backupDir;
const result = await scanner.clean(items, false, scannerBackupDir);
cleanResults.results.push(result);
cleanResults.totalFreedSpace += result.freedSpace;
cleanResults.totalBackedUpSize = (cleanResults.totalBackedUpSize ?? 0) + (result.backedUpSize ?? 0);
cleanResults.totalCleanedItems += result.cleanedItems;
cleanResults.totalErrors += result.errors.length;
cleanedCount++;
Expand Down Expand Up @@ -192,8 +237,11 @@ function printCleanResults(summary: CleanSummary): void {

for (const result of summary.results) {
if (result.cleanedItems > 0) {
const backedUp = (result.backedUpSize ?? 0) > 0;
console.log(
` ${result.category.name.padEnd(30)} ${chalk.green('✓')} ${formatSize(result.freedSpace)} freed`
backedUp
? ` ${result.category.name.padEnd(30)} ${chalk.cyan('↦')} ${formatSize(result.backedUpSize ?? 0)} moved to backup`
: ` ${result.category.name.padEnd(30)} ${chalk.green('✓')} ${formatSize(result.freedSpace)} freed`
);
}
for (const error of result.errors) {
Expand All @@ -206,6 +254,16 @@ function printCleanResults(summary: CleanSummary): void {
console.log(chalk.bold(`🎉 Freed ${chalk.green(formatSize(summary.totalFreedSpace))} of disk space!`));
console.log(chalk.dim(` Cleaned ${summary.totalCleanedItems} items`));

if ((summary.totalBackedUpSize ?? 0) > 0) {
console.log();
console.log(
chalk.cyan(` ${formatSize(summary.totalBackedUpSize ?? 0)} moved to backup (still on disk):`)
);
console.log(chalk.dim(` ${summary.backupDir}`));
console.log(chalk.dim(` restore: mac-cleaner-cli backup --restore "${summary.backupDir}"`));
console.log(chalk.dim(` reclaim: mac-cleaner-cli backup --clean`));
}

if (summary.totalErrors > 0) {
console.log(chalk.red(` Errors: ${summary.totalErrors}`));
}
Expand Down
Loading