mac-upkeep
Automated macOS maintenance CLI. Runs Homebrew updates, dev tool cache cleanup (gcloud, pnpm, uv), Fish plugin updates, system optimization, and Brewfile enforcement on boot + weekly via brew services — zero config required.
Install
brew install calvindotsg/tap/mac-upkeep
brew services start mac-upkeep # runs on boot + Monday 12 PM
Or via uv:
uv tool install mac-upkeep # persistent install
uvx mac-upkeep run # one-off without installing
Tasks
| Task | Description | Schedule |
|---|---|---|
brew_update |
Update Homebrew package database | Weekly |
brew_upgrade |
Upgrade outdated formulae and casks | Weekly |
gcloud |
Update Google Cloud SDK components | Monthly |
pnpm |
Prune pnpm content-addressable store | Monthly |
uv |
Prune uv package cache | Monthly |
fisher |
Update Fish shell plugins | Weekly |
mo_clean |
Clean user caches (Mole) | Weekly |
mo_optimize |
Optimize DNS, Spotlight, fonts, Dock (Mole) | Off by default |
mo_purge |
Remove old project artifacts (Mole) | Monthly |
brew_cleanup |
Remove old versions and cache files | Monthly |
brew_bundle |
Remove packages not in Brewfile | Weekly |
git_sync |
Pull configured git repositories | Daily |
Tasks auto-detect installed tools — missing tools are skipped. Use --force <task> to run a specific task on demand.
mac-upkeep tasks # See all tasks with status, frequency, and next run
Usage
mac-upkeep run # Run tasks (frequency-checked)
mac-upkeep run --dry-run # Preview without executing
mac-upkeep run --force brew_update # Run only brew_update
mac-upkeep run --force all # Run all, ignoring schedule
mac-upkeep run --debug # Verbose output
mac-upkeep tasks # List tasks with status and next run
mac-upkeep init # Generate config (detects your tools)
mac-upkeep show-config --default # Show all available task options
mac-upkeep show-config # Show your config overrides
mac-upkeep setup # Print log-rotation config
mac-upkeep status # Show scheduling dashboard
mac-upkeep logs # View last 20 log lines
mac-upkeep logs -f # Follow logs
mac-upkeep --version # Show version
Configuration
Works out of the box with zero configuration. To customize, generate a starter config:
mac-upkeep init
This probes your system, detects installed tools, and writes a commented config to ~/.config/mac-upkeep/config.toml. Only detected tasks are listed. Built-in defaults apply automatically — uncomment lines to override.
To see all available tasks and options:
mac-upkeep show-config --default
Override examples
# ~/.config/mac-upkeep/config.toml
# Disable a task
[tasks.gcloud]
enabled = false
# Change frequency (daily, weekly, or monthly)
[tasks.brew_update]
frequency = "monthly"
# Set Brewfile path explicitly
[paths]
brewfile = "~/.config/Brewfile"
Task fields are type-checked. TOML booleans are unquoted — enabled = false, not
enabled = "false" — and a quoted one is now rejected with an error rather than
silently leaving the task enabled. Task names are matched case- and space-insensitively
([tasks.Docker Prune] and [tasks.docker_prune] are the same task, and declaring both
is an error).
Brewfile discovery
With no [paths] brewfile and no MAC_UPKEEP_BREWFILE/HOMEBREW_BUNDLE_FILE, only two
absolute locations are searched: $XDG_CONFIG_HOME/Brewfile (default ~/.config/Brewfile)
and ~/.Brewfile. The current working directory is deliberately not searched.
brew bundle evaluates a Brewfile as Ruby and brew_bundle runs cleanup --force, so a
CWD-relative lookup made whichever project directory your shell happened to be in able to
run code and uninstall every package it did not list. If no Brewfile is found the task
skips; it no longer runs with an empty --file=, which Homebrew resolved back to
$PWD/Brewfile.
Custom tasks
Add your own tasks using the same format:
[tasks.docker_prune]
description = "Prune Docker system"
command = "docker system prune -f"
detect = "docker"
frequency = "monthly"
# Control execution order
[run]
order = ["brew_update", "brew_upgrade", "docker_prune", "brew_cleanup", "brew_bundle"]
git_sync
Pull configured git repositories daily with git pull --ff-only. Opt-in — list your repos explicitly:
[git_sync]
repos = [
"~/code/my-project",
"~/work/max-*", # glob patterns supported
]
skip_dirty = true # skip repos with uncommitted changes
Each repo is skipped with a reason if it's not a git repo, has no remote, has no upstream branch, or (when skip_dirty = true) has uncommitted changes.
Only enrol repositories you created
A git repository's own .git/config can make git execute commands. Point a repos glob only at directories you created yourself — never at a downloads, sync, backup, or vendor-drop directory. A tree that arrives by archive, restore, or file sync brings its .git/config and .git/hooks with it, and safe.directory does not help: it keys on ownership, and anything you unpacked is owned by you. A plain git clone does not carry these, so cloning is unaffected.
mac-upkeep neutralises the directives it can, on every git call — not just on pull, because git status --porcelain (the skip_dirty check itself) is enough to trigger some of them:
| Neutralised | How |
|---|---|
core.fsmonitor |
reset to empty |
.git/hooks/* |
core.hooksPath=/dev/null |
credential.helper |
list reset, then your own global/system helpers re-added |
core.sshCommand |
set to your own global/system value, else explicitly ssh |
ext:: transport |
protocol.ext.allow=never |
remote.<name>.uploadpack |
protocol.file.allow=never |
core.gitProxy |
protocol.git.allow=never |
Note the asymmetry in the middle two rows. credential.helper is multi-valued, so an empty entry resets git's accumulated list. core.sshCommand is single-valued, so an empty entry is not a reset — git would try to execute the empty string and every SSH remote would fail. Single-valued keys are therefore set, never blanked.
Three consequences worth knowing:
- Local-path remotes no longer work under git_sync (
fatal: transport 'file' not allowed). This is deliberate — it is what closes theuploadpackexecution path. Pull from a bare mirror on an external disk outside mac-upkeep. git://remotes no longer work (fatal: transport 'git' not allowed). Also deliberate:core.gitProxyruns an arbitrary command for that transport and cannot be neutralised any other way.git://is unauthenticated and unencrypted; use SSH or HTTPS.- Repository hooks do not run during git_sync, so a
post-mergehook that installs dependencies will not fire on an unattended pull.
This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch. The known remaining execution path is filter.<driver>.clean from a planted .gitattributes, which fires on git status: driver names are arbitrary, so no fixed override covers them. Enrolment discipline is the control that actually holds.
Authentication
Any of the following work under launchd without mac-upkeep-side configuration:
- SSH +
IdentityAgent(recommended under launchd): a path-based entry in~/.ssh/configpointing at any SSH agent's UNIX socket. Works because the directive is a file path, not theSSH_AUTH_SOCKenv var that launchd would strip. - HTTPS + credential helper:
gh auth setup-gitorgit config --global credential.helper osxkeychain. Requires the helper binary on the launchdPATH. [url].insteadOfrewrite: force SSH regardless of remote protocol by rewritinghttps://<host>/in~/.gitconfigto a matching SSHHostalias. Bypasses HTTPS auth entirely.
git_sync sets GIT_TERMINAL_PROMPT=0 and a no-op GIT_ASKPASS default (user-set GIT_ASKPASS is respected) so misconfigured auth fails in milliseconds instead of stalling to the 60 s subprocess timeout.
Environment variables
MAC_UPKEEP_GCLOUD=false mac-upkeep run # Disable a task
MAC_UPKEEP_GCLOUD_FREQUENCY=monthly mac-upkeep run # Override frequency
MAC_UPKEEP_<TASK> enables the task only for an explicitly truthy value —
true, 1, yes, or on. Anything else disables it, including off, disabled
and the empty string, which previously all enabled the task by falling through a
denylist of false/0/no.
Why there is no sudoers file
mac-upkeep runs every task as you. Nothing it ships needs root.
Releases before 4.0.0 ran mo clean and mo optimize under sudo -n and told you to
install a NOPASSWD rule naming $(brew --prefix)/bin/mo. That path is a bash script inside
a user-writable Homebrew prefix which transitively sources around thirty more user-owned
.sh files — so any code already running as you could rewrite what root would execute, and
then just wait for the weekly LaunchAgent run. A sha256 Digest_Spec does not fix it: the
digest covers the one entry script, and sudoers(5) documents digests as TOCTOU-racy when
the command's directory is user-writable.
⚠ Upgrading from < 4.0.0 — action required
The sudoers file was installed manually, so
brew upgradedoes not remove it. Delete it:sudo rm -f /etc/sudoers.d/mac-upkeep sudo visudo -c # must print "parsed OK"
What changes in practice. mo clean still runs weekly and still clears your user caches;
non-interactively it probes for an existing sudo ticket with sudo -n -v, which never
prompts, and simply skips the system-level half when there is none. The measured cost of
losing that half is about 7 MB per week of files under /private/var/log.
mo optimize is now off by default rather than merely unprivileged. Its work — DNS
flush, font database reset, route/ARP flush, Spotlight reindex, disk permissions — is
root-only, and bin/optimize.sh asks for admin access unconditionally with no
non-interactive guard, so under launchd it would raise a macOS password dialog that sits on
screen until the task times out. Run it by hand when you want it:
mo optimize
Re-enable it for interactive use if you prefer, with [tasks.mo_optimize] / enabled = true
in your config — but understand it will prompt.
Log file permissions
mac-upkeep setup now prints the newsyslog.d line with mode 640 instead of 644. The
log records git_sync failures by repository name, which enumerates your private and
employer-internal repositories, and $(brew --prefix)/var/log is world-traversable — unlike
~/Library, nothing else gates it. Owner plus the admin group keeps mac-upkeep logs
working.
⚠ Already installed?
/etc/newsyslog.d/mac-upkeep.confis installed manually, so it is not upgraded bybrew upgradeeither, and existing log files keep their 644 mode. Rewrite the conf frommac-upkeep setup, then:sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*
Contributing
See CONTRIBUTING.md for development setup and conventions.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mac_upkeep-4.0.0.tar.gz.
File metadata
- Download URL: mac_upkeep-4.0.0.tar.gz
- Upload date:
- Size: 342.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3b00ece1aaf51a894ee66ca5fd358458953a33f57bac905d81e5ffabce954afb
|
|
| MD5 |
02e7c566ff9802a38539ed0580b6ceb6
|
|
| BLAKE2b-256 |
b977da49c08fcf8ef8b6075ab1a76f45d9877f35c7035519f414f0ef8280148f
|
Provenance
The following attestation bundles were made for mac_upkeep-4.0.0.tar.gz:
Publisher:
release.yml on calvindotsg/mac-upkeep
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mac_upkeep-4.0.0.tar.gz -
Subject digest:
3b00ece1aaf51a894ee66ca5fd358458953a33f57bac905d81e5ffabce954afb - Sigstore transparency entry: 2527310871
- Sigstore integration time:
-
Permalink:
calvindotsg/mac-upkeep@c2d6b50d34fa8fe68c4ab330179a5067e3d374f7 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/calvindotsg
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c2d6b50d34fa8fe68c4ab330179a5067e3d374f7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file mac_upkeep-4.0.0-py3-none-any.whl.
File metadata
- Download URL: mac_upkeep-4.0.0-py3-none-any.whl
- Upload date:
- Size: 40.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
28f529628be643bb1e65c33d3b7582b9202469f1a2425affdb5e484deb71ddee
|
|
| MD5 |
ec3a05dd8f87d1f884cbbe9eb3ffb71a
|
|
| BLAKE2b-256 |
7c0c159541b91ce9a0f27ca0df839c732f961459d164546e964584b8ca9ab188
|
Provenance
The following attestation bundles were made for mac_upkeep-4.0.0-py3-none-any.whl:
Publisher:
release.yml on calvindotsg/mac-upkeep
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mac_upkeep-4.0.0-py3-none-any.whl -
Subject digest:
28f529628be643bb1e65c33d3b7582b9202469f1a2425affdb5e484deb71ddee - Sigstore transparency entry: 2527310947
- Sigstore integration time:
-
Permalink:
calvindotsg/mac-upkeep@c2d6b50d34fa8fe68c4ab330179a5067e3d374f7 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/calvindotsg
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c2d6b50d34fa8fe68c4ab330179a5067e3d374f7 -
Trigger Event:
push
-
Statement type: