Metadata-Version: 2.5
Name: macmaint
Version: 2026.10.0
Summary: macOS maintenance CLI — updates, cleanup, and system maintenance
Project-URL: Homepage, https://github.com/sussdorff/macmaint
Project-URL: Repository, https://github.com/sussdorff/macmaint
Project-URL: Changelog, https://github.com/sussdorff/macmaint/blob/main/CHANGELOG.md
Author: Malte Sussdorff
License-Expression: MIT
License-File: LICENSE
Keywords: cleanup,cli,homebrew,macos,maintenance
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.13
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0
Requires-Dist: typer>=0.12
Description-Content-Type: text/markdown

# macmaint

[![PyPI version](https://img.shields.io/pypi/v/macmaint.svg)](https://pypi.org/project/macmaint/)
[![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

A macOS maintenance CLI that keeps your system clean and up-to-date.
Run `macmaint` to update Homebrew packages, the Mac App Store, uv-managed tools,
and directly installed apps. Add `--all` to also reclaim developer cache space,
vacuum Mail.app, and flush DNS. Every step reports what it actually did in a
closing run summary.

Everything is idempotent and safe to run as a daily cron job. Use `--dry-run` to preview every
action before it executes.

## Install

```bash
uv tool install macmaint
```

Or with pipx:

```bash
pipx install macmaint
```

To update directly installed applications that publish Sparkle appcasts, build
the pinned official `sparkle-cli` helper:

```bash
macmaint install-sparkle-cli
```

The installer verifies Sparkle's release SHA-256 and source tag commit, builds
the official CLI source with a macmaint-specific bundle identifier, and installs
it under `~/.local/libexec/macmaint`.

## Prerequisites

| Requirement | Notes |
|-------------|-------|
| macOS 13 Ventura or later | macOS-only by design |
| Python 3.13+ | Installed automatically by uv/pipx |
| [Homebrew](https://brew.sh) | Optional — needed for `update` and `cleanup` brew tasks |
| [mas](https://github.com/mas-cli/mas) | Optional — `brew install mas` for App Store updates |
| [uv](https://docs.astral.sh/uv/) | Optional — for `uv tool upgrade --all` in `update` |
| [bun](https://bun.sh) | Optional — for `bun update -g --latest` and auditing the Bun-owned `~/package.json` dependency tree |
| npm | Optional — for `npm update -g` (global JS CLIs like `@openai/codex`) in `update` |
| [sparkle-cli](https://sparkle-project.org/documentation/sparkle-cli/) | Optional — official Sparkle helper for directly installed macOS applications |
| Docker | Optional — `cleanup` prunes this if available |

## Quick start

```bash
# Check which tools are wired up on your machine
macmaint doctor

# Preview software updates without executing anything
macmaint --dry-run

# Run software updates
macmaint

# Run updates, cleanup, and maintenance
macmaint --all
```

## Subcommands

| Command | What it does |
|---------|-------------|
| `macmaint update` | Homebrew update/upgrade/cleanup + Mac App Store upgrades + `uv tool upgrade --all` + `bun update -g --latest` + Bun audit on `~/package.json` + `npm update -g` + probe-first updates for direct Sparkle and electron-updater apps |
| `macmaint cleanup` | Prune the uv cache, remove Xcode DerivedData, prune Docker |
| `macmaint maintenance` | Flush DNS cache, vacuum Mail.app envelope index, thin local Time Machine snapshots when disk space is low |
| `macmaint apps` | Show all installed apps sorted by last used date (GUI apps, Homebrew formulae, Steam games) |
| `macmaint doctor` | Check which optional tools are available and what macmaint can do |

### Common flags

```bash
# Preview mode — show what would run, no changes
macmaint --dry-run
macmaint --all --dry-run
macmaint cleanup --dry-run

# Full run — updates + cleanup + maintenance
macmaint --all
macmaint --full              # alias for --all

# Skip specific tasks
macmaint update --no-brew          # skip Homebrew
macmaint update --no-mas           # skip App Store
macmaint update --no-uv-tools      # skip uv tool upgrade --all
macmaint update --no-bun           # skip bun update -g --latest
macmaint update --no-bun-audit     # skip bun audit on ~/package.json
macmaint update --no-npm-globals   # skip npm update -g
macmaint update --no-sparkle       # skip direct Sparkle applications
macmaint update --sparkle-probe-only # report Sparkle updates without installing
macmaint update --no-electron      # skip direct electron-updater applications
macmaint update --electron-probe-only # report electron-updater updates without installing
macmaint update --no-extras        # skip user-defined extras
macmaint cleanup --no-uv           # skip uv cache prune
macmaint cleanup --no-xcode        # skip Xcode DerivedData
macmaint cleanup --no-docker       # skip Docker prune
macmaint cleanup --deep-docker     # remove stopped stacks, unused images, and volumes
macmaint maintenance --repair-permissions  # opt-in repair: reset ownership and ACLs in $HOME
macmaint maintenance --reindex-spotlight   # opt-in repair: rebuild the Spotlight index

# App usage filtering
macmaint apps --days 90            # highlight apps unused for 90+ days
macmaint apps --unused             # show only unused apps
macmaint apps --size               # include disk footprint column
```

`cleanup --deep-docker` is intentionally opt-in. It first removes stopped
containers and all unused images and build cache, then prunes unused anonymous
and named Docker volumes. Running containers and resources they reference are
not pruned, but an otherwise unused named volume may still contain data you
intended to keep. Use `--dry-run --deep-docker` to inspect both commands first.

## What macmaint deliberately does not do

Five cleanup steps were removed because they reclaimed nothing, two
maintenance tasks became opt-in because they are repair tools rather than
routine maintenance, and the Backblaze updater was dropped because the product
is no longer installed. Reinstalling Backblaze does not bring the updater back;
its silent installer ran under `sudo` and verified its own download signature,
which is a maintenance burden macmaint no longer carries for a single vendor.

| Removed cleanup step | Why it went | What covers it instead |
|----------------------|-------------|------------------------|
| Emptying `~/.Trash` and mounted volume trashes | The path is TCC-protected, so the command failed with `Operation not permitted` unless macmaint had Full Disk Access; the volume variant also deleted other users' trashes | macOS: System Settings > General > Storage > "Empty Trash automatically". `cleanup` prints a hint when that setting is off |
| The `~/Library/Caches` allowlist (Safari, Chrome, IDEs) | Also TCC-protected, and APFS marks cache content purgeable | macOS reclaims purgeable cache space by itself under disk pressure |
| `pip cache purge` | Reported `No matching packages, 0 bytes` because Python here runs through uv | `uv cache prune`, which `cleanup` still runs |
| `npm cache clean --force` | npm prints `Recommended protections disabled` and only forces re-downloads | npm has treated its cache as self-healing since npm@5 |
| Deleting `*.log` files older than N days from `~/Library/Logs` | Missed rotated names such as `.log.gz`, for a few megabytes | macOS log rotation |

Removing all five means `cleanup` no longer needs Full Disk Access, and no
longer needs administrator rights at all.

| Opt-in maintenance task | Flag | Run it when |
|-------------------------|------|-------------|
| Reset ownership and default ACLs across `$HOME` (`diskutil resetUserPermissions`) | `--repair-permissions` | Permission errors on your own files, preferences that do not persist, or a Migration Assistant / Time Machine restore that left a foreign uid behind. It flattens deliberately set permissions such as group-writable shares or backup-tool ACLs |
| Erase and rebuild the Spotlight index (`mdutil -E /`) | `--reindex-spotlight` | Searches return nothing, or `mdutil -s /` reports indexing as disabled. A rebuild puts the volume under heavy load for hours |

The default `maintenance` flow checks both indicators and recommends the
matching flag only when an indicator actually fires. The ownership scan stays on
the filesystem `$HOME` lives on, so a separate mount below `$HOME` such as
`~/OrbStack` never triggers the recommendation for its own root-owned files. The
Spotlight indicator is switched off with `spotlight-indicator = false` under
`[maintenance]` on a machine where indexing is disabled deliberately. Local Time Machine
snapshots are thinned only when free space on `/` falls below 20 GB, and then
with the gentlest `tmutil` urgency, so recent recovery points survive.

## Exit codes and output streams

| Exit code | Meaning |
|-----------|---------|
| `0` | Every step succeeded or was skipped. The one step that runs without succeeding or failing is `bun audit`: a vulnerability it finds is a fact about the system, not a macmaint malfunction, and failing on it would hold the exit code at `1` until the advisory is fixed upstream. A `--dry-run` preview always exits `0`. |
| `1` | At least one step failed. That includes the direct updaters: if any Sparkle or electron-updater application fails to probe, verify or install, the step reports how many of how many failed and the run exits `1`. `macmaint --all` still executes every phase it would have executed and reports the combined status at the end, so one failing phase never suppresses the others. |
| `2` | Usage error from the argument parser. |

Failed steps are written to **stderr**; the run narrative and the rest of the
summary go to **stdout**. An unattended caller — a cron entry, a launchd job, a
wrapper script — can therefore watch one stream for trouble without parsing the
report, while `macmaint cleanup > run.log` keeps the readable log in order.

Redirect both with `> run.log 2>&1` when you want a single complete transcript.

## Run logs

Every invocation through the installed `macmaint` command writes one plain-text
transcript to `$XDG_STATE_HOME/macmaint/logs`. When `XDG_STATE_HOME` is unset,
blank, or relative, macmaint follows the XDG fallback and uses
`~/.local/state/macmaint/logs`.

Files are named
`YYYYMMDDTHHMMSS.ffffffZ-PID-RANDOM.log`. Each contains the start time, invoked
command, process ID, stdout and stderr in write order, end time, and final exit
code. ANSI colour remains visible in an interactive terminal but is removed
from the saved transcript. Automatic logging does not alter redirected output,
JSON output, or exit codes.

macmaint does not delete or rotate these files. Retention is owned by the user
or the scheduler that invokes it.

## Configuration

Create `~/.config/macmaint/config.toml` to override defaults. When
`$XDG_CONFIG_HOME/macmaint/config.toml` exists it is read instead; if it does
not, the `~/.config` location is used even with `XDG_CONFIG_HOME` set. All keys
are optional.

> The `[cleanup]` section with `cache-allowlist` and `log-retention-days` no
> longer exists. A config file that still contains it loads unchanged and those
> keys are ignored.

```toml
[apps]
# Glob patterns for Steam library volumes to scan in addition to ~/Library.
# Default: ["/Volumes/*"]
steam-volumes = ["/Volumes/*"]

[sparkle]
# Bundle-ID glob patterns. An empty allowlist includes every direct Sparkle app.
allowlist = []
denylist = [
    "com.example.problematic-app",
]

[electron]
# Bundle-ID glob patterns for apps that update through an electron-updater feed.
# An empty allowlist includes every discovered app.
allowlist = []
denylist = [
    "com.example.problematic-electron-app",
]

[maintenance]
# The default maintenance flow recommends `--reindex-spotlight` while
# `mdutil -s /` reports indexing as disabled. Set this to false on a machine
# where indexing is switched off deliberately. Default: true
spotlight-indicator = true

[[extras-preflight]]
# Run before built-in tasks for this phase: update | cleanup | maintenance
phase = "update"
name = "capture-state"
cmd = "capture-state"
when = "command_exists:capture-state"

[[extras]]
# Run after built-in tasks for this phase: update | cleanup | maintenance
phase = "update"
name = "dcg"
cmd = "curl -fsSL 'https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)' | bash -s -- --easy-mode --quiet"

[[extras]]
phase = "maintenance"
name = "custom-health-check"
cmd = "~/bin/health-check"
when = "command_exists:health-check"
```

### Extras hooks

`[[extras-preflight]]` and `[[extras]]` let you register commands for `update`,
`cleanup`, or `maintenance`. Preflight extras run in declaration order before
the built-in tasks; regular extras run in declaration order afterwards. Paired
hooks can persist any state they need in a file outside macmaint.

- `--dry-run` prints commands from both hook positions without executing them.
- `--no-extras` skips both hook positions for that invocation.
- A false `when = "command_exists:foo"` gate skips an extra cleanly.
- A failed extra is logged without aborting later extras or built-in tasks.

For the current `dcg` workaround, put the `update` extra above into
`~/.config/macmaint/config.toml` and run `macmaint update`. macmaint will keep handling
brew/mas/uv/bun/npm first, then reinstall `dcg` via the upstream `curl | bash` flow.

### Direct Sparkle applications

macmaint scans `/Applications` and `~/Applications` for application bundles with
a static HTTPS `SUFeedURL`. Mac App Store receipts and Homebrew Cask artifacts are
excluded because those package managers already own their updates.

Every eligible application is probed before installation. A normal update is
installed with the official helper using `--check-immediately`. macmaint never
passes `--interactive`, `--allow-major-upgrades`, or
`--grant-automatic-checks`; updates requiring those capabilities are reported
and left untouched. Use `--sparkle-probe-only` for a guaranteed check-only run.

Allowlist and denylist entries are case-sensitive bundle-ID glob patterns. If
the allowlist is non-empty, only matching applications are considered. The
denylist always wins.

### Direct electron-updater applications

Electron applications that ship their own updater declare it in
`Contents/Resources/app-update.yml`. macmaint scans `/Applications` and
`~/Applications` for those bundles and handles the `github` provider; other
providers (`generic`, `s3`, `bitbucket`, `spaces`) are reported and skipped.
Mac App Store, Setapp, and Homebrew Cask artifacts are excluded, because those
package managers already own their updates.

Each application is probed before anything is downloaded: macmaint reads the
release feed `latest-mac.yml` over HTTPS from github.com and compares its
version against the installed `CFBundleShortVersionString`. macmaint refuses to
install, without downloading, when

- the target is a major version bump,
- either version is a prerelease or is not a plain numeric release,
- the application is currently running, or
- the bundle or its parent directory is not writable.

Only after those checks does macmaint download the artifact matching this Mac's
architecture. The download is installed only if its sha512 matches the digest
published in the feed, and the extracted bundle must carry the same bundle
identifier as the installed application. If the installed application is signed
with a Team ID, the replacement must carry the same Team ID and pass
`codesign --verify`; many open-source Electron builds are ad-hoc signed, in
which case an ad-hoc replacement is accepted and the sha512 is the only trust
anchor. The installed bundle is moved aside and only removed once the
replacement is in place, so a failure at any step leaves the original app
working.

Use `--electron-probe-only` for a guaranteed check-only run and `--no-electron`
to skip the phase entirely. Allowlist and denylist entries are case-sensitive
bundle-ID glob patterns; if the allowlist is non-empty, only matching
applications are considered, and the denylist always wins.

## Caveats

- **Privileged steps authorize sudo once per run.** `macmaint --all` validates
  sudo once for the whole run and keeps that authorization alive; `update` and
  `maintenance` invoked directly open their own session and re-entering an
  active one neither prompts again nor starts a second keepalive. Every
  privileged command uses `sudo -n`, so a run without an active session fails
  immediately with its reason instead of blocking on an invisible password
  prompt. Before installing a direct Sparkle update, macmaint revalidates the
  session and prompts once more if a package-manager step invalidated it. A
  failed or cancelled prompt is reported once, then privileged direct updates
  are skipped without repeating the same password error for every app. The
  Sparkle and electron-updater steps print the failure per
  application and additionally report how many applications failed, so their
  failures set the exit code like any other step. `cleanup` needs no
  administrator rights at all.

- **`macmaint apps` uses the Screen Time database (`knowledgeC.db`) for last-used dates.**
  This database is protected by macOS privacy controls. For accurate results, grant
  **Full Disk Access** to the terminal app running macmaint
  (System Settings → Privacy & Security → Full Disk Access).
  Without access, macmaint falls back to Spotlight metadata, which is less accurate.

- **`macmaint apps --size` is slow** for large app libraries. It runs `du` on each
  app bundle plus associated Library directories. Expect 30–120 seconds for 100+ apps.

- **macOS-only.** macmaint uses macOS-specific tools (`mdls`, `tmutil`, `dscacheutil`,
  `diskutil`, `sqlite3`) and will not run on Linux or Windows.

- **Sparkle updates depend on application metadata.** Apps that set their feed
  dynamically instead of publishing `SUFeedURL` cannot be discovered. Custom
  version comparators or vendor-specific updater delegates may also be
  incompatible with an external helper; add such bundle IDs to the denylist.

- **electron-updater updates trust the release feed.** The sha512 in
  `latest-mac.yml` is the integrity anchor, so an application whose GitHub
  releases are compromised would ship a matching digest. macmaint additionally
  pins the Team ID for signed applications, but ad-hoc signed builds cannot be
  verified beyond the digest. Add such bundle IDs to the `[electron]` denylist
  if that trust model does not suit you.

- **electron-updater updates need the application to be quit.** macmaint will
  not replace a bundle that has running processes; quit the app and run the
  update again.

- **Major upgrades are never installed automatically.** Both direct update paths
  report a major version bump and leave the installed application untouched.

- **Bun owns the Home-level JavaScript dependency tree.** `~/package.json`,
  `~/bun.lock`, and `~/node_modules` are maintained with Bun. `macmaint update`
  upgrades those CLI packages across major versions and runs `bun audit` for
  high-severity advisories. npm is used only for packages installed in its
  separate active global prefix; macmaint never runs npm prune against the
  Bun-owned Home tree.

## License

MIT — see [LICENSE](LICENSE).
