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
65 changes: 64 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ The `.dmg` files are also on [Releases](https://github.com/guhcostan/mac-cleaner
| `system-cache` | Application caches in ~/Library/Caches |
| `system-logs` | System and application logs |
| `dev-cache` | npm, yarn, pip, Xcode DerivedData, CocoaPods |
| `node-modules` | Orphaned node_modules in old projects |
| `node-modules` | Stale dependencies beside `package.json` (moderate) |
| `rust-artifacts` | `target` beside `Cargo.toml` (moderate) |
| `node-build-artifacts` | Node build output beside `package.json` (risky) |
| `go-artifacts` | Ignored `bin`/`dist` beside `go.mod` (risky, explicit opt-in) |
| `homebrew` | Homebrew download cache **and old formula versions** (`brew cleanup --prune=all`) |
| `docker` | Unused images, stopped containers, build cache — **only the types you select** |

Expand Down Expand Up @@ -217,6 +220,66 @@ npx mac-cleaner-cli clean --all --yes

Risky categories (downloads, iOS backups, etc) are always skipped unless you pass `--unsafe`.

### Repository artifacts

```bash
# Scan only a workspace; repeat --root for additional workspaces.
npx mac-cleaner-cli scan --category rust-artifacts --root ~/dev --json --verbose

# Simulate validated Node build cleanup as one JSON document.
npx mac-cleaner-cli clean --categories node-build-artifacts --unsafe --root ~/dev --dry-run --json

# Go requires explicit inclusion or an explicit Go category, plus risky enablement.
npx mac-cleaner-cli clean --categories go-artifacts --unsafe --root ~/dev --dry-run --json

# Live review: selections start empty. A separate confirmation precedes cleanup.
npx mac-cleaner-cli --live --root ~/dev
npx mac-cleaner-cli scan --live --root ~/dev
```

All repository rules require a readable, regular, non-symlink marker in the **immediate parent**. Node build names are `.next`, `.turbo`, `.parcel-cache`, `coverage`, `dist`, `build`, `out`, and `.cache`. Generic Node names (`coverage`, `dist`, `build`, `out`, `.cache`) and Go `bin`/`dist` additionally require a positive Git ignore match. They are omitted in non-Git projects. A marker or ignore match does not prove that every untracked file is disposable.

Git's current index and HEAD both protect tracked content, including staged additions/removals, conflicts, symlinks and submodules. Nested `.git` files/directories also protect candidate ancestors. Git failures, unreadable contents and incomplete inspection prevent eligibility. Candidate symlinks and traversal through symlinks below canonical roots are rejected. Node wins a Node/Go `dist` collision when both categories are requested; a Go-only request assigns it to Go.

Default roots are `~/Projects`, `~/Developer`, `~/Code`, `~/dev`, `~/workspace`, and `~/repos`. `extraPaths.projects` adds roots for all repository categories; legacy `extraPaths.nodeModules` adds roots only for `node-modules`. A nonempty repeatable `--root` list replaces these roots for that command, without changing other scanners. Roots are canonicalized and restricted to directories below HOME, `/Users`, or `/Volumes`; broad HOME/system roots are rejected. Empty extra-path arrays add nothing.

Add these settings to the existing `~/.maccleanerrc` (or `~/.config/mac-cleaner-cli/config.json`):

```json
{
"extraPaths": { "projects": ["~/work"], "nodeModules": [] },
"excludePaths": ["**/keep.txt", "docs/build"],
"includeEcosystems": [],
"nodeModulesDaysOld": 30,
"projectScanMaxDepth": 4,
"parallelScans": true,
"concurrency": 4
}
```

`--exclude <pattern>` repeats and adds to configured keep paths. A basename matches any component; slash-containing patterns match root-relative prefixes on component boundaries. `*` and `?` match within a component, and `**` as a whole component matches zero or more components. Absolute paths, traversal, empty patterns, negation, braces and brackets are invalid. Any excluded descendant protects its entire artifact directory, including across overlapping roots. Malformed repository policy fails closed.

Node dependencies must be at least `nodeModulesDaysOld` days old (integer 0–365, default 30), using the newer of the project-directory mtime and **every candidate entry's newest mtime**, including directories and links. `projectScanMaxDepth` is 0–16 (default 4): children of parents at depth 0 through 4 are inspected. Artifact contents are fully inspected regardless of this discovery cap. Known hidden worktree containers are traversed; dependency contents and Git metadata are pruned during discovery.

Node and Rust are enabled by default. `--include go`, config `includeEcosystems: ["go"]`, or explicitly selecting `go-artifacts` opts into Go; `--all`, `--unsafe`, and `--risky` alone do not. `defaultCategories` narrows default category selection; `excludeCategories` always wins. Risky Node/Go categories start with no preselected files in interactive review. Scripted selection requires the existing explicit risky flag.

Selected repository items are revalidated against their original immutable scan context, current markers/config/keep rules, refreshed Git state, canonical roots and device/inode identity before cleanup. No new discoveries are added during clean. Dry-run exercises the same validation without creating backup sessions or changing files. Outside-HOME roots may be cleaned permanently, but backup mode rejects them with `BACKUP_UNSUPPORTED_PATH`; backup failure never falls back to deletion. Validated selected ancestors cover descendants once, while independently selectable scan rows remain available.

These checks reduce stale-result risk; they are not atomic with deletion, do not detect active builds, and cannot guarantee inactivity. Byte counts are **logical estimates**, including regular-file and symlink-entry sizes; they are not physical disk-block measurements.

### Clean JSON and scan events

`clean --json` requires `--all` or `--categories`; actual cleanup also requires `--yes`. Dry-run does not. Stdout contains one versioned document, including empty and failed runs. Diagnostics go to stderr; JSON mode does not prompt or open a browser. Exit status is 0 for a complete scan/plan/clean (including empty), 1 for scan/config/validation/execution failure; invalid CLI usage follows Commander's nonzero convention.

Per-filesystem-item outcomes include category, path, status, estimated size and rejection/skip reasons. Statuses are `deleted`, `backed-up`, `would-delete`, `would-back-up`, `rejected`, or `skipped`. Covered children report `SKIPPED_COVERED_BY_PARENT` with zero duplicate accounting. External tools such as Docker and Homebrew have action-level outcomes, not invented filesystem deletion records.

Existing aggregate fields remain. Actual `totalFreedSpace`, `totalBackedUpSize`, and `totalCleanedItems` are separate from `wouldFreeSpace`, `wouldBackUpSize`, and `wouldCleanItems`; dry-run actual counters are zero. Scan JSON retains `totalSize`, `totalItems`, category fields and verbose item `path`/`size`, adding optional `id`, `fileCount`, `newestEntryModifiedAt`, and artifact evidence. IDs hash category plus NUL plus canonical path; they identify rows and do not authorize cleanup.

Library callers can pass `onEvent` and `signal: AbortSignal` to scanner options. Events are `start`, `progress`, `candidate`, `warning`, and `done`, correlated by `runId` and category where applicable. The registry shares one repository discovery pass per request. Cancellation returns no usable cleanup plan. The Promise API remains; repository callers pass `scanResult.context` as the additive fourth argument to `clean(items, dryRun, backupDir, context)`. Bare repository cleanup without scan evidence is refused.

Live review requires a terminal and cannot be combined with JSON. Use `s`/`m`/`p` for size/newest-mtime/path sorting, `/` for search, `n`/`b` for pages, arrows to move, space to select, and `q` to cancel. Selection survives view changes. Enter during scanning cannot submit; after completion it finishes review before the ordinary final confirmation and validation. `scan --live` is read-only.


### Uninstall Apps

Remove applications completely with all their preferences, caches, and support files:
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ category, safety rule and protected-path check of the CLI applies here too.

### Safety

- Unattended runs (daily and Clean Now) can only use categories that are **not** marked risky. This is enforced in the main process, not just in the UI.
- Unattended runs (daily and Clean Now) can only use categories that are **not** marked risky, with the new repository artifact categories additionally excluded. This is enforced in the main process, not just in the UI.
- Automatic runs skip temp files touched in the last 24 hours (configurable) because running apps may still be using them.
- Deep Clean only deletes exactly what a scan preview showed and you confirmed. If the preview is older than 15 minutes, it refuses and asks you to scan again instead of rescanning on its own.
- All deletions go through the CLI's `removeItem`, which refuses system paths, `/` and your home folder, and never follows symlinks.
- The UI runs in a sandboxed renderer with a strict Content Security Policy and no Node.js access; it can only call the handful of actions exposed by the preload script.

### Default automatic categories

`temp-files`, `browser-cache`, `system-logs`. You can add any other non-risky category (Homebrew, User Cache, Trash, Development Cache, Docker…) in **Settings**.
`temp-files`, `browser-cache`, `system-logs`. You can add other allowed non-risky categories (Homebrew, User Cache, Trash, Development Cache, Docker…) in **Settings**.

## Install

Expand Down Expand Up @@ -153,3 +153,12 @@ Without them the build is ad-hoc signed (see the note in [Install](#install)).

Bump `version` in `desktop/package.json` before a release: it is the version shown in the
app and in the file names.


### Repository artifact cleanup

Deep scans share the CLI repository engine and configured roots/keep rules. `rust-artifacts` joins moderate deep defaults; `node-build-artifacts` is risky and `go-artifacts` requires explicit deep-category selection. None of these three new categories can enter scheduled or quick cleanup, even through stale settings or a direct controller request.

The controller retains immutable per-scan repository policy and identity evidence. Cleaning revalidates only the selected preview items, refreshes Git protection and rejects changed/excluded candidates; it never introduces newly discovered items. Independently selectable parent/child scan rows remain, with selected filesystem overlap removed after validation. Preview metadata preserves name/path/size and adds optional ID, regular-file count and newest-entry mtime.

Desktop deep cleanup remains permanent deletion and does not inherit CLI `backupEnabled`. Markers/ignore rules do not prove every untracked file disposable. There remains a filesystem/Git race window after final checks, and reported sizes are logical estimates. See the root README for rules, exclusions, root precedence and the API contract.
2 changes: 1 addition & 1 deletion desktop/src/main/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('CleanerController', () => {
const record = await controller.tick();
expect(record?.mode).toBe('auto');
// Fresh temp files are left alone by unattended runs.
expect(registry['temp-files'].clean).toHaveBeenCalledWith([expect.objectContaining({ name: 'stale' })], false);
expect(registry['temp-files'].clean).toHaveBeenCalledWith([expect.objectContaining({ name: 'stale' })], false, undefined, undefined);
expect(record?.freedSpace).toBe(110);
expect(onRunFinished).toHaveBeenCalledWith(record);
expect(store.lastAutoRunAt).toEqual(new Date(2026, 8, 30, 10, 5));
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/main/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ProgressEvent,
type ScannerRegistry,
} from './engine.js';
import { isAllowedInAutoClean } from './settings.js';
import { isAutoCleanDue, nextAutoCleanAt } from './schedule.js';
import type { Store } from './store.js';

Expand Down Expand Up @@ -154,7 +155,7 @@ export class CleanerController {

private async runLightClean(mode: RunMode): Promise<RunRecord> {
const { settings } = this.deps.store;
const ids = this.validIds(settings.autoClean.categories);
const ids = this.validIds(settings.autoClean.categories).filter(isAllowedInAutoClean);

const record = await this.exclusive(mode, async () => {
const results = await scanCategories(this.deps.registry, ids, (e) => this.setProgress(mode, e));
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/main/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('scanCategories', () => {
it('turns a scanner crash into an error result', async () => {
const registry = { trash: fakeScanner('trash', [], { failScan: true }) };
const [result] = await scanCategories(registry, ['trash']);
expect(result.error).toBe('boom');
expect(result.error).toContain('boom');
expect(result.items).toEqual([]);
});
});
Expand All @@ -57,7 +57,7 @@ describe('cleanScanResults', () => {
expect(record.freedSpace).toBe(115);
expect(record.cleanedItems).toBe(3);
expect(record.categories.map((c) => c.id)).toEqual(['system-logs', 'trash']);
expect(trash.clean).toHaveBeenCalledWith(results[0].items, false);
expect(trash.clean).toHaveBeenCalledWith(results[0].items, false, undefined, undefined);
});

it('applies the item filter and skips categories left empty', async () => {
Expand Down
68 changes: 16 additions & 52 deletions desktop/src/main/engine.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { runScans } from '../../../src/scanners/index.js';
import { cleanSelectedResults, scanTotals } from '../../../src/utils/cleanup.js';
import { randomUUID } from 'crypto';
import { CATEGORIES, type CategoryId, type CleanableItem, type ScanResult, type Scanner } from '../../../src/types.js';
import type {
Expand Down Expand Up @@ -41,25 +43,9 @@ export async function scanCategories(
onProgress?: (event: ProgressEvent) => void
): Promise<ScanResult[]> {
const ids = categoryIds.filter((id) => registry[id]);
const results: ScanResult[] = [];

for (let i = 0; i < ids.length; i++) {
const scanner = registry[ids[i]]!;
onProgress?.({ phase: 'scanning', completed: i, total: ids.length, current: scanner.category.name });
try {
results.push(await scanner.scan());
} catch (error) {
results.push({
category: scanner.category,
items: [],
totalSize: 0,
error: error instanceof Error ? error.message : String(error),
});
}
}

onProgress?.({ phase: 'scanning', completed: ids.length, total: ids.length, current: '' });
return results;
onProgress?.({ phase: 'scanning', completed: 0, total: ids.length, current: '' });
const summary = await runScans(ids, { onProgress: (completed, total, scanner) => onProgress?.({ phase: 'scanning', completed, total, current: scanner.category.name }) }, undefined, registry);
return summary.results;
}

export async function cleanScanResults(
Expand All @@ -77,37 +63,15 @@ export async function cleanScanResults(
const categories: CategoryRunResult[] = [];
const errors: string[] = [];

for (let i = 0; i < results.length; i++) {
const result = results[i];
const scanner = registry[result.category.id];
options.onProgress?.({ phase: 'cleaning', completed: i, total: results.length, current: result.category.name });

if (result.error) {
errors.push(`${result.category.name}: ${result.error}`);
}

const items = options.itemFilter ? options.itemFilter(result) : result.items;
if (!scanner || items.length === 0) continue;

try {
const cleaned = await scanner.clean(items, false);
categories.push({
id: result.category.id,
name: result.category.name,
freedSpace: cleaned.freedSpace,
cleanedItems: cleaned.cleanedItems,
errors: cleaned.errors,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
categories.push({
id: result.category.id,
name: result.category.name,
freedSpace: 0,
cleanedItems: 0,
errors: [message],
});
}
for (const result of results) if (result.error) errors.push(`${result.category.name}: ${result.error}`);
const selected = results.filter((r) => registry[r.category.id]).map((r) => ({ ...r, items: options.itemFilter ? options.itemFilter(r) : r.items }));
const cleaned = await cleanSelectedResults(selected, (id) => registry[id]!, {
dryRun: false,
onProgress: (completed, result) => options.onProgress?.({ phase: 'cleaning', completed, total: results.length, current: result.category.name }),
});
for (const result of cleaned.results) {
if (!selected.find((r) => r.category.id === result.category.id)?.items.length) continue;
categories.push({ id: result.category.id, name: result.category.name, freedSpace: result.freedSpace, cleanedItems: result.cleanedItems, errors: result.errors });
}

options.onProgress?.({ phase: 'cleaning', completed: results.length, total: results.length, current: '' });
Expand Down Expand Up @@ -138,15 +102,15 @@ export function toScanPreview(results: ScanResult[], scannedAt: Date, topN = 5):
topItems: [...result.items]
.sort((a, b) => b.size - a.size)
.slice(0, topN)
.map((item) => ({ name: item.name, path: item.path, size: item.size })),
.map((item) => ({ name: item.name, path: item.path, size: item.size, id: item.id, fileCount: item.fileCount, newestEntryModifiedAt: item.newestEntryModifiedAt?.toISOString() })),
error: result.error,
};
})
.sort((a, b) => b.totalSize - a.totalSize);

return {
scannedAt: scannedAt.toISOString(),
totalSize: categories.reduce((sum, c) => sum + c.totalSize, 0),
totalSize: scanTotals(results).totalSize,
categories,
};
}
Loading