Several Macs and a folder of scripts: why I needed macsetup
I have several MacBooks that should share an environment without all of them having the same things. For over a year I handled it with Homebrew scripts, and the scripts failed at exactly that: they didn't know what was on the machine, didn't know what belonged there, and got things wrong without saying so.
I have MacBooks. They are not all the same and they shouldn’t be: the work one carries Slack and Docker, the personal one carries WhatsApp and Discord, and one I use for travel only needs a decent terminal. But they should all feel like the same computer: the same shell, the same shortcuts, the same git.
Every time I unbox or wipe one, the question is the same: what goes on this one?
What I had
Since April 2025 the answer lived in a private repo: a brew/ folder with four scripts (install_dev_tools.sh, install_productivity_tools.sh, install_cli_tools.sh, install_content_editor_tools.sh) and a zshrc/ folder with snippets to paste into .zshrc. Each script was a list of brew install, one per line:
brew install awscli
brew install clickup
brew install docker
brew install gh
...
It worked, in the sense that it installed things. But every time I used it on a different Mac I ran into the same handful of problems.
Why the scripts weren’t enough
They didn’t know what was on the machine. A brew install script doesn’t tell you what it will do before it does it. There was no way to ask “what is this Mac missing?” without running it. And on a Mac that had been in use for months, there was no way to know what I had installed by hand and never written into any script.
They were grouped by what an app is, not by where I want it. “Productivity” put Slack, which is for work, WhatsApp, which is personal, and 1Password, which goes everywhere, in the same file. I never wanted that whole group on any machine. So I ended up running the script and uninstalling afterwards, or copying lines into the terminal by hand, which is exactly what the script was supposed to spare me.
They got things wrong without saying so. When I finally checked every package against brew info, I found out that brew install docker had never installed Docker Desktop. It installs the formula: the Docker CLI, with no daemon and no app. The app is the docker-desktop cask. That script had been in the repo for over a year, and on every Mac where I ran it I assumed I had Docker Desktop. I didn’t. Homebrew has more names like that, which exist as both a formula and a cask, and a brew install without --cask quietly picks the formula.
Configuration was pasted by hand. The snippets in zshrc/ had to be copied into each machine’s .zshrc. If I changed one on one Mac, the others kept the old version until I remembered. I almost never remembered.
The first Mac has nothing. A MacBook fresh out of the box has no Homebrew, no modern Python (it ships a 3.9 from the Command Line Tools), and no git until you install those tools. Any solution had to start from there, with a single curl.
What I actually needed
When I wrote the list down, it wasn’t “a better script”. It was something else:
- See before touching. A command that says what it will install, which file it will change and what is already fine, without changing anything.
- Each Mac gets its own. Small groups, the size of one decision (“do I want work chat on this machine?”), and profiles that bundle them. And each machine remembers its own selection, so adding something to a profile doesn’t quietly install it on all of them.
- Adding software is editing a list, not a program. And that list makes me say what is a cask, so the Docker thing can’t happen again.
- Configuration arrives on its own. Shell and git snippets copied into place, and when it touches a file that isn’t its own, like
.zshrc, it only touches its part. - Running it twice does nothing. If the machine is up to date, it installs nothing and upgrades nothing. Running it again has to be safe, not a gamble.
- One line on an empty Mac. It installs whatever is missing to get started and ends by asking me what I want.
Why I didn’t use what already existed
I looked before writing it.
chezmoi is very good at dotfiles, but it doesn’t handle packages.
brew bundle does packages, and macsetup in fact uses it underneath to install. But a Brewfile is a list for one machine: no preview, no report of what drifted, and no notion that several machines share one part and differ in another. One Brewfile per Mac drifts apart, and a single one for all of them installs too much.
nix-darwin does all of this and more, if you are willing to learn Nix. For me it meant trading a problem with a few Macs for a new language.
I wanted something in the middle: plain YAML, Homebrew underneath, and a plan I read before anything happens.
macsetup
So I built it. macsetup started inside that same private repo, and later I split it into a public one, with the engine on one side and my catalog on the other. You describe in YAML what each module carries, pick in a wizard which modules go on this Mac, and then:
macsetup plan # what it would do, changing nothing
macsetup apply # do it
macsetup status # what moved since last time
The four scripts became modules, the mistyped docker became an explicit cask: true, and the .zshrc snippets now copy themselves.
In another post I’ll go through how it’s built. This one was about why it was needed. If you have more than one Mac and recognize yourself in any of these problems, it’s on GitHub and PyPI, under the MIT license. On an empty Mac you can try it with one line:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/neosergio/macsetup/main/install.sh)"
Comments