Skip to main content

mac-upkeep

PyPI CI Python License macOS

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.

mac-upkeep demo

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"

# Pin a weekly task to a day of the week. Plain "weekly" means "6 days since the
# last success", which drifts: a run that slips to Sunday is next due on Saturday.
# With a weekday it means "not yet this week": due at the first run on or after
# that day's midnight, and never twice between two of them.
[tasks.brew_bundle]
weekday = "monday"

# 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
merge.verifySignatures + gpg.<format>.program pinned off; all four format variants pinned to /usr/bin/false
http.proxy set to your own global/system value, else no proxy
http.sslVerify set to your own global/system value, else true
ext:: transport protocol.ext.allow=never
remote.<name>.uploadpack protocol.file.allow=never
core.gitProxy protocol.git.allow=never

Note the asymmetry between the multi-valued and single-valued rows. credential.helper is multi-valued, so an empty entry resets git's accumulated list — and because git folds credential.<url>.helper into that same list, the reset reaches the per-URL form too. Every single-valued key (core.sshCommand, http.proxy, http.sslVerify) is set, never blanked, for two different reasons that both bite: an empty core.sshCommand is not a reset but a command git tries to execute, and blanking http.proxy would work but would throw away a legitimate global proxy. The gpg.* rows are the exception that proves it — those are pinned flat precisely because an unattended --ff-only pull has no legitimate signature to verify, so there is no value of yours to preserve.

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 the uploadpack execution 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.gitProxy runs 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-merge hook that installs dependencies will not fire on an unattended pull.
  • A repository that sets any per-URL http.<url>.* key in its own config is skipped, not pulled. If that is a repository you configured deliberately, move the setting to your global config, where it is trusted and honoured.

Some keys cannot be neutralised by an override at all, because the URL or remote name is part of the key name — http.<url>.proxy, http.<url>.sslVerify and remote.<name>.proxy are all more specific than the generic keys above and therefore beat them. Two things handle those.

A repository declaring one is refused. Before any network call, git_sync reads the repository's own configuration and skips the repository entirely if it declares any per-URL http.<url>.* key or a remote.<name>.proxy* key, with a reason naming the key. The read is git config --list, which parses configuration files and executes nothing. The whole per-URL namespace is matched rather than a list of specific keys, because a list of "the dangerous ones" is the thing that was already wrong twice — sslCAInfo, which swaps in an attacker's CA, would have been the next omission. Two-component keys such as http.postBuffer are unaffected. filter.* is deliberately not matched, since that would refuse every git-lfs repository.

The check covers the local and worktree scopes and any file they include, so it cannot be sidestepped by extensions.worktreeConfig. It does not look at your own global or system configuration — a proxy you configured is yours to configure. If it cannot read the repository's config at all, the repository is skipped rather than entered.

Proxying is also blocked structurally. When you have no proxy configured anywhere — no http.proxy in your global or system config, and no http_proxy/https_proxy/all_proxy in the environment — git_sync runs git with NO_PROXY=*, which a repository cannot override because it is an environment variable rather than a config key. If you do use a proxy, yours is left alone and the refusal above is what protects you.

This is defence in depth, not a sandbox. Git has no "ignore this repository's config" switch, and the table above is an enumeration — it was wrong once already, and the gpg.<format>.program rows are what it was missing. One residual is known and open: filter.<driver>.clean from a planted .gitattributes, which fires on git status. Driver names are arbitrary, so no fixed override covers them, and refusing every repository that declares one would refuse every git-lfs repository.

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/config pointing at any SSH agent's UNIX socket. Works because the directive is a file path, not the SSH_AUTH_SOCK env var that launchd would strip.
  • HTTPS + credential helper: gh auth setup-git or git config --global credential.helper osxkeychain. Requires the helper binary on the launchd PATH.
  • [url].insteadOf rewrite: force SSH regardless of remote protocol by rewriting https://<host>/ in ~/.gitconfig to a matching SSH Host alias. 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 upgrade does 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.conf is installed manually, so it is not upgraded by brew upgrade either, and existing log files keep their 644 mode. Rewrite the conf from mac-upkeep setup, then:

sudo chmod 640 "$(brew --prefix)"/var/log/mac-upkeep.log*

Contributing

See CONTRIBUTING.md for development setup and conventions.

License

MIT

Metadata

Release files for mac-upkeep 4.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mac-upkeep 4.1.0
File Size Uploaded
mac_upkeep-4.1.0.tar.gz 371.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mac-upkeep 4.1.0
File Interpreter ABI Platform
mac_upkeep-4.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 419.7 kB

Release files / mac_upkeep-4.1.0.tar.gz

Download URL mac_upkeep-4.1.0.tar.gz
Size 371.3 kB
Tags Source
SHA-256 checksum
How to use checksums
de79fa510b4449aa4563e0ca57ef0cdb78be056590274ba33c5646623f9482a0
BLAKE2b-256 checksum
How to use checksums
4ce17471c9a6a3979e07a876828381f87b503d00eded95cee2401cacaa58f262
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release files / mac_upkeep-4.1.0-py3-none-any.whl

Download URL mac_upkeep-4.1.0-py3-none-any.whl
Size 48.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
74dc61ddb74d86fa1abb8355cd188c9a7c3a7781ce4711f1b1926b696d235668
BLAKE2b-256 checksum
How to use checksums
bbd72502bd64101e03f25f3586e9b767a0b9c84d9c7882d517d8714d8a60146c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.

Transparency log

Release history Release notifications | RSS feed

4.2.0

2 release files

This release

4.1.0 This release

2 release files

4.0.2

2 release files

4.0.1

2 release files

4.0.0

2 release files

3.0.4

2 release files

3.0.3

2 release files

3.0.2

2 release files

3.0.1

2 release files

3.0.0

2 release files

2.5.1

2 release files

2.5.0

2 release files

2.4.2

2 release files

2.4.1

2 release files

2.4.0

2 release files

2.3.0

2 release files

2.2.1

2 release files

2.2.0

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page