Essay

Post-Mac-Setup: One Script to Bootstrap a New Mac

April 4, 202618 min read

An idempotent Bash script that bootstraps a complete macOS development environment — Homebrew, CLI tools, GUI apps, Oh My Zsh, and shell config from a fresh install.

Every time I set up a new Mac — or nuke and reinstall — I go through the same tedious ritual: install Homebrew, then brew install fifty packages one by one, download apps, configure the shell, set defaults. It takes hours and I always forget something.

So I wrote a single Bash script that does everything. One command, one file, fully idempotent.

Post-Mac-SetupMIT

01.Quick Start

Bash
chmod +x mac-setup.sh && ./mac-setup.sh

Rerunning is safe — already-installed packages are detected and skipped, and shell configs are updated in-place using marker-based injection.

02.Bootstrapping a Machine That Has Nothing

The interesting case is not the Mac that already has a clone of this repo. It is the one that came out of the box an hour ago: no Homebrew, no git, no gh, no git identity configured. The script is designed to run there, and the reason it can is that macOS ships curl in the base system. That is the whole bootstrap: one file, fetched by a tool Apple already installed.

Bash
curl -fsSL -o mac-setup.sh \
https://raw.githubusercontent.com/anirbanchakraborty-dev/Post-Mac-Setup/main/mac-setup.sh
less mac-setup.sh # read it before you run it
chmod +x mac-setup.sh
./mac-setup.sh

I deliberately do not publish a curl | bash one-liner. Piping a remote script straight into a shell means you execute it without ever seeing it, and this one asks for your sudo password in its first minute. Downloading, reading, then running costs one extra command and turns a leap of faith into a decision. If you would rather have the whole repo, curl -fsSL https://github.com/anirbanchakraborty-dev/Post-Mac-Setup/archive/refs/heads/main.tar.gz | tar -xz works too, and does not need git either.

The dependency chain resolves itself from there. The script installs the Xcode Command Line Tools before anything else, firing Apple's GUI installer and then polling every five seconds until the toolchain actually appears — xcode-select --install returns immediately and would otherwise let the run race ahead of a compiler that is not there yet. With that in place it installs Homebrew, adds brew shellenv to ~/.zprofile so new terminals inherit it, and from that point on every formula and cask is just a brew install. Nothing in the chain requires a package manager you were supposed to have already.

gh is in the formulae list, so the GitHub CLI lands on the machine as part of a normal run. Authentication is a different matter, and I want to be precise about it. The auth step delegates to a companion script that is not part of this repository. When that file is absent, the step reports itself as skipped and the run continues — it is counted as skipped, not failed, which is exactly the behaviour you want from an optional step. So on the public path you get the binary and you log in yourself afterwards:

Bash
gh auth login
gh auth setup-git

Both commands matter. gh auth login authenticates the CLI; gh auth setup-git registers gh as git's credential helper. Skip the second one and you end up with a machine that looks completely set up, passes every obvious check, and still cannot clone or push to a private repo over HTTPS. That gap cost me an afternoon once.

Two more things happen on a bare machine that will not happen on a rerun. The script prompts for your git user.name and user.email, since there is no existing identity to preserve, and it sets init.defaultBranch=main, pull.rebase=false and core.autocrlf=input only where you have not already chosen something else. And --dry-run is close to useless on the first run: without Homebrew there is no way to ask what is already installed, so the dry run says as much and exits. Preview mode is for the second run onward.

03.What It Installs

CLI Tools

73 Homebrew formulae covering hardware design, software development, and general terminal work:

CategoryPackages
Shellsbash, zsh (updated, replacing the macOS system versions)
Version Controlgit, git-lfs, gh, git-filter-repo
Languages & Runtimespython@3, node, pnpm, r, gcc, go, rustup
Python Toolinguv
EDA / Hardwareicarus-verilog, yosys, sby, verilator, surfer, graphviz
RISC-V Toolchainriscv64-elf-gcc, dtc
Terminal Utilitiestree, fzf, jq, eza, zoxide, ripgrep, coreutils, wget, curl, bat, fd, htop, tlrc, dust, bottom, hyperfine, difftastic
Media & Audioffmpeg, espeak-ng
Build Toolscmake, llvm, pandoc, plantuml, poppler, pkgconf, automake
Language Serverstexlab, marksman, basedpyright, typescript-language-server, bash-language-server, vscode-langservers-extracted, yaml-language-server, asm-lsp, dockerfile-language-server, neocmakelsp, lua-language-server, ruby-lsp, jdtls, elixir-ls, taplo
Linting, Formatting, Grammarruff, prettier, shfmt, shellcheck, harper, enchant
Debugging & Environmentsdelve, direnv
.NET SDKdotnet
Homebrew TUIbbrew

Two of these need a word of explanation. tlrc is the simplified-man-pages client that replaced tldr in my list — the tldr formula was disabled upstream in October 2025, and a list that still names it fails the moment Homebrew stops shipping it. gcc is listed even though it also arrives transitively by way of r and OpenBLAS, because I would rather declare a compiler I depend on than inherit it by accident.

Those four rows are what my Emacs configuration calls, and they are the part of this list that grew most recently. There are language servers for LaTeX, Markdown, Python, TypeScript, Bash, JSON, CSS, HTML, YAML, assembly, Dockerfile, CMake, Lua, Ruby, Java, Elixir and TOML, formatters for web files and shell scripts, a Go debugger, and the spell-checking library. Unlike Emacs itself, every one of them arrives as a prebuilt bottle, so they belong in the automated run. automake is there because the PDF viewer inside Emacs compiles a small helper program the first time it starts, and that build needs it.

Three of them are worth naming individually. shellcheck is on the list because bash-language-server shells out to it and reports almost nothing when it is absent, which is the worst kind of missing dependency: the server starts, the editor shows no error, and the diagnostics simply do not arrive. harper is a grammar checker that does all its work on the machine, which is the only reason it is here at all, because a grammar checker reads every word you write. dotnet is the .NET SDK, and it earns its place by carrying the dotnet tool installer rather than because anything on this machine builds C#.

Two runtimes are deliberately absent. elixir and ruby each arrive as a runtime dependency of the language server above them, so a bare machine gets both without being asked. Homebrew does not mark a dependency as installed on request, so declaring them would put two names in my list that a brew bundle dump never prints, which is the same kind of phantom drift the next paragraph describes.

Deliberately not listed: ghostscript. It is a declared dependency of the mactex cask, so a bare machine gets it for free. Homebrew marks cask-required formulae as installed on request, which means it shows up in a brew bundle dump and looks like undeclared drift on every audit. It is not drift, and adding it to the list to silence the audit would be fixing the wrong thing.

emacs-plus@31 is the other deliberate omission, and it is left out for a different reason than ghostscript. It is installed on this machine and it is not on the list, because it is the one package here that compiles from source instead of arriving as a prebuilt bottle. A source build turns a predictable setup run into an unpredictable one, and it leans on a compiler that the same run has only just installed — which is exactly the failure that sent me digging in the first place. So it is a documented manual step rather than an automated one: trust and add the d12frosted/emacs-plus tap, then brew install emacs-plus@31, once the automated run has finished and the toolchain is known good. Emacs needs one more pass after that, because the 31.1 release tarball ships prebuilt byte-code whose timestamps are newer than its own sources, so make decides there is nothing to compile and the ahead-of-time native compilation the build was configured for never runs. Utilities/emacs-native-warmup.sh in my hub repo does that pass and can tell you whether it is needed.

One more step is easy to miss, because nothing about it looks broken. emacs-plus puts Emacs.app and Emacs Client.app inside its own keg and leaves /Applications alone, so there is no icon anywhere to click. macOS 26 removed Launchpad, and the Spotlight Apps view that replaced it lists only what the system has indexed as a real application bundle: a symlink into the keg is not indexed at all, and a Finder alias is indexed under a type the Apps view skips. Copying each bundle across with ditto does work. The cost is that both copies hardcode the versioned path they were built against, so brew upgrade leaves two icons that look perfectly healthy and launch nothing.

pkgconf taught me to check that kind of reasoning rather than repeat it. It is the tool build scripts ask where a C library's headers live, and I had left it off the list on the same grounds as ghostscript, on the theory that the packages which need it would drag it in. They would not. It is needed only to build those packages, and Homebrew installs them prebuilt, skipping build-only dependencies. Nothing on my machine needs it at run time, so a fresh Mac would never have got it. It is on the list now.

GUI Apps

32 casks:

CategoryApps
Editors & IDEsvisual-studio-code, coteditor
AIclaude, claude-code, lm-studio
Productivitymicrosoft-office, setapp, notion
LaTeXmactex, texifier, skim
Research & Graphicszotero, inkscape
Photographynx-studio
Terminaliterm2
Networking & Securitytailscale-app, surfshark
Browser & Messagingwhatsapp
Window Managementloop
Menu Barblip
System Maintenancekeyboardcleantool
Fonts (prompt)font-meslo-lg-nerd-font, font-jetbrains-mono-nerd-font
Fonts (text & display)font-inter, font-poppins, font-archivo, font-archivo-black, font-anton, font-bebas-neue, font-caveat, font-patrick-hand, font-architects-daughter

Every one of these now comes from the main repositories. One used to arrive from a third-party tap — purge, from jithin-sabu/tap — and when I stopped using it I untapped the repository along with it, which emptied the script's tap list for the first time since I started keeping one. That is not tidiness. A tap you have added but install nothing from still costs you something on every brew command, for a reason worth its own section below. The Nerd Fonts are not decoration: Powerlevel10k draws its prompt out of glyphs that only exist in a patched font, and without one the prompt renders as a row of tofu boxes.

For casks, "already installed" means the .app is actually on disk rather than that Homebrew has a record of it. Drag an app to the Trash instead of running brew uninstall --cask and Homebrew will keep listing it forever, so the script checks /Applications and ~/Applications for the bundle and reinstalls anything that has gone missing.

Shell Environment

The script sets up Oh My Zsh with Powerlevel10k and the git, zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions and fzf plugins. Aliases point the everyday commands at the better versions of themselves: ls at eza, cat at bat, find at fd, du at dust, top at btm, diff at difft, plus the usual git shortcuts. Configuration is split across ~/.zshrc, ~/.zsh_paths and ~/.zsh_aliases so that paths, aliases and everything else stay separable.

04.Design Decisions

Why a Single File?

No framework, no dotfile manager, no symlinks. Just one Bash script you can download and run. The tradeoff is less modularity, but a single file is also what makes the bootstrap above possible — there is nothing to clone, and no second file that has to arrive first.

When a Tap Dies

A tap is a third-party repository of Homebrew package definitions, for software the main repositories do not carry. Adding one is cheap, which is why machines accumulate them, and why it took me a while to notice that one of mine had quietly become a liability.

The symptom had nothing to do with the tap. I ran brew install --cask notion, and Homebrew stopped with an error about Verible, a SystemVerilog linter I had not asked it to touch:

Plain Text
Error: Failed to import: .../chipsalliance/homebrew-verible/Formula/verible.rb
verible: Calling depends_on macos: :catalina is disabled! There is no replacement.

Homebrew does not load package definitions lazily. When it works out what is installable it evaluates every definition in every tap you have added, so one definition calling a method Homebrew has since removed takes down commands that have nothing to do with it. An abandoned tap is not dormant. It is a standing fault waiting for the next unrelated install.

This tap was abandoned in the strict sense. Its last commit is dated July 2025, my local copy was identical to the remote, and so there was no fix waiting to be pulled. Verible is not in Homebrew's main repository either, so there was nothing to migrate to. Pinning an older Homebrew or hand-patching the definition would both be undone by the next brew update.

What replaced it is the project's own release archive. Verible publishes a macOS build on GitHub for every release, so the script now asks the GitHub API for the latest one, downloads that archive, and copies the binaries into ~/.local/bin — no sudo and no tap, and as it turned out a build about eighteen months newer than the one the tap had pinned. It is a native Apple Silicon binary, which the script confirms after installing rather than assuming: it reads the architecture of one installed file with lipo -archs and warns if the answer does not include arm64. Removing the old tap happens in the same step, and running it again is harmless — if the tap is already gone, nothing happens.

The wider lesson went in as a check rather than a fix. Every run now lists the taps present on the machine and warns about any the script does not itself manage. That is how the second one surfaced: emrul/portal, tapped at some point, with nothing installed from it, emitting a deprecation warning on every brew command. A script that only ever adds taps is blind to the ones it did not add. It warns rather than removing them, because untapping can orphan a package that is genuinely in use, and that call belongs to a person.

The same reasoning is why the script declares no taps at all today. When I stopped using purge I could have left jithin-sabu/tap declared and dormant, and on a naive reading of the word it would have been dormant — nothing installed from it, nothing referring to it. But a tap is not a dependency you have; it is a directory Homebrew reads every time it works out what is installable. Leaving one added for a package you no longer want is taking on a small permanent risk in exchange for nothing at all, and the price of that risk is set by a maintainer who has already stopped maintaining. So it went, and the array that used to hold it is now a comment explaining why it is empty.

The machine itself still carries exactly one tap: d12frosted/emacs-plus, added by hand for the Emacs step above. That it is on the machine and not in the script is the same decision seen twice — a tap is worth its cost only while something you actually install comes out of it, and the thing that comes out of this one is a source build nobody should start unattended. The tap check described a moment ago is what keeps it visible, and what would flag the next one arriving unnoticed.

Emptying it also turned up a bug that only a clean machine could ever have hit. The script runs set -u, which aborts on an unset variable, and the system bash that ships with macOS — version 3.2, which is what /usr/bin/env bash finds before Homebrew has installed anything — treats "${ARRAY[@]}" on an empty array as exactly that: unset. Bash 4.4 fixed it in 2016. My machine has bash 5 in PATH, so the loop over an empty tap list ran without complaint here and would have killed the run on the bare Mac the script exists for. The fix is one character of syntax, ${ARRAY[@]+"${ARRAY[@]}"}, which expands to nothing when the array is empty. The general shape is the thing to take away: a setup script's whole job is to run somewhere you are not, so the environment it will actually meet is the one you cannot test in.

Marker-Based Injection

This is the part I'm most proud of. Every existing shell config is backed up with a timestamp, and managed content is written between markers:

Bash
### BEGIN BLOCK-ID (managed by mac-setup.sh - do not edit)
# ... managed content ...
### END BLOCK-ID

On a rerun the script replaces what is between the markers and leaves everything else untouched. Anything you add outside them survives. That means you can customize your .zshrc freely and still rerun the script without losing your changes, which is idempotence in the sense that actually matters: not "running it twice does nothing", but "running it twice cannot destroy your work".

CLI Flags

Code
--help, -h Show usage
--dry-run Preview what would be installed (no changes made)
--upgrade Also run 'brew upgrade' (off by default)
--skip-casks Skip GUI app installation
--skip-formulae Skip CLI tool installation
--skip-extras Skip direct-download installs
--skip-github Skip the GitHub CLI auth step
--skip-macos Skip macOS .DS_Store defaults
--skip-shell Skip shell configuration (.zshrc, etc.)
--no-log Don't save output to a log file

--upgrade is off by default on purpose. An unattended brew upgrade can pull a breaking version of a tool you were relying on that afternoon, and a setup script should not decide that for you.

Examples:

Bash
# Preview a rerun without making changes
./mac-setup.sh --dry-run
# Install only CLI tools (no GUI apps)
./mac-setup.sh --skip-casks
# Minimal run — just packages, no shell or macOS config
./mac-setup.sh --skip-shell --skip-macos

05.How It Works

A run moves through six phases, in this order, each assuming the one before it finished:

  • Preflight — checks connectivity, caches your sudo credentials up front (with a keep-alive, so a five-gigabyte MacTeX download does not expire the timestamp mid-run), then installs the Xcode Command Line Tools and Homebrew
  • Install — adds any taps the list declares, then the formulae, casks, and a small number of globals from npm, uv and dotnet tool, a few apps come as direct vendor downloads where the publisher's installer beats the cask
  • Configure — registers the Homebrew shells in /etc/shells, sets up Git and Git LFS, authenticates the GitHub CLI where a companion script is present, and initializes Rust, fzf, MacTeX, Oh My Zsh, Powerlevel10k and the zsh plugins
  • Shell Config — backs up and rewrites ~/.zshrc, ~/.zsh_paths, ~/.zsh_aliases, ~/.zshenv and ~/.zprofile using the marker-based injection above
  • macOS Defaults — stops macOS writing .DS_Store files onto network and USB volumes
  • Cleanup — runs brew cleanup and exports a Brewfile snapshot next to the script

Every run is timed per section and logged to ~/Library/Logs/mac-setup/, and a summary prints on exit whether the run finished, failed, or was interrupted with Ctrl-C.

06.Post-Install

A few things still need you. Start with a fresh terminal, since the rest assume the new shell config is loaded:

  • Restart your terminal (or exec zsh)
  • Run p10k configure to set up the Powerlevel10k prompt
  • Set your iTerm2 font to MesloLGS Nerd Font (Preferences → Profiles → Text → Font)
  • Verify the Homebrew versions won: which git, which python3, which zsh, which bash
  • Run gh auth login and gh auth setup-git if the auth step reported itself skipped
  • Sign in to apps: Setapp, Tailscale, Surfshark

Could these be automated too? Mostly not. They need GUI interaction, a browser, or credentials, and a script that pretends to handle credentials is worse than one that admits it cannot.

07.Try It

Clone it, swap in your own packages, and never manually set up a Mac again:

Bash
git clone https://github.com/anirbanchakraborty-dev/Post-Mac-Setup.git
cd Post-Mac-Setup
./mac-setup.sh --dry-run # preview first
./mac-setup.sh # full install
FeedbackBook mode
macosautomationbashtoolsproductivity