Bash + GNU Stow: take a walk while your new macbook is being set up

Brian Mayo
6 min readDec 29, 2021

--

I’m addicted to automation. I said it. Okay?

Today we are going to learn how we can automatize all our macOS settings, apps and homebrew packages using bash and stow (and a few other cli tools).

Intro

As developers we might encounter ourselves setting up a new macOS environment quite often (I had to do it 2 times so far this year). We tend to accumulate dotfiles pretty easily and keeping them in sync across multiple machines becomes painful.
And not only that. We also accumulate a galaxy of different packages, tools and apps that we use on daily bases to work.

Oh and all the macOS settings that we surely need? our poor souls.

In order to reduce the pain and enjoy life we can make use of different tools and our coding skills to automatize all this process.

The objective

When I receive a new Macbook at a new job, all I want to do is:

  • Login in the app store
  • Download and run a magic script
  • Go out and enjoy the sun while the macbook is being set up

Bootstraping

So first we need a few tools to start setting up everything. As we already know xcode, brew and git are a must. Here we start with:

xcode-select --install
sudo xcodebuild -license accept
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"brew install git

Brew taps, casks and formulas

We want to install all the packages and apps we need.
To list all your currently installed packages (formulae) and apps (casks) you can run:

brew list

we also need to list all the taps (Third-Party Repositories) we use:

brew tap

Perfect. With this information we can code some for loops to iterate on the taps, casks and formulas:

apply_brew_taps() {
local tap_packages=$*
for tap in $tap_packages; do
if brew tap | grep "$tap" > /dev/null; then
warn "Tap $tap is already applied"
else
brew tap "$tap"
done
}
install_brew_formulas() {
local formulas=$*
for formula in $formulas; do
if brew list --formula | grep "$formula" > /dev/null; then
warn "Formula $formula is already installed"
else
info "Installing package < $formula >"
brew install "$formula"
fi
done
}
install_brew_casks() {
local casks=$*
for cask in $casks; do
if brew list --casks | grep "$cask" > /dev/null; then
warn "Cask $cask is already installed"
else
info "Installing cask < $cask >"
brew install --cask "$cask"
fi
done
}

Note: in order to keep our scripts some how idempotent, we want to check if the formulas/casks/taps are already installed.

These functions will take a list and run brew install. How do we use them?

# apply the taps first
taps=(homebrew/cask)
apply_brew_taps "${taps[@]}"
# install casks
apps=(firefox docker)
install_brew_casks "${apps[@]}"
# install formulas
packages=(curl go)
install_brew_formulas "${packages[@]}"

OK. But some of my apps are not available in hombrew 😡

Installing App Store apps

For all the apps that cannot be found as brew casks we can use mas (Mac App Store cli).
This cli tool needs the IDs of the apps. To check the apps you have installed simply run:

mas list

If not all your apps are listed you can also search for them and get their IDs:

mas search Goodnotes

So again, writing some pretty code:

masApps=(
"937984704" # Amphetamine
"1444383602" # Good Notes 5
"768053424" # Gappling (svg viewer)
)
install_masApps() {
info "Installing App Store apps..."
for app in $masApps; do
mas install $app
done
}

Neat!

So far we have installed all our apps and cli tools.
Let’s see how we can automatize our macOS settings.

defaults: macOS settings from the terminal

This tool will help us to read and write settings. For that, you need to know the domain, key and type of the setting. Usually a google/duckduckgo/ecosia search should help you to find this information. If not, defaults provides some commands to help you search for the settings.
Let's say I want to change some finder setting. First, find its domain:

defaults domains | grep finder

Read all the settings available for finder

defaults read com.apple.finder

I want to change ShowExternalHardDrivesOnDesktop but I don’t know its type

defaults read-type com.apple.finder ShowExternalHardDrivesOnDesktop

Type is boolean. So finally we can set this to false

defaults write com.apple.finder ShowExternalHardDrivesOnDesktop -bool false

(You can see all my settings in my dotfiles repo,link below)

Lastly, and this is a personal preference, I want to set vscode as default application for all my source code files. To do so we will use duti.

code_as_default_text_editor() {
local extensions=(
".c"
".cpp"
".js"
".jsx"
".ts"
".tsx"
".json"
".md"
".sql"
".html"
".css"
".scss"
".sass"
".py"
".sum"
".rs"
".go"
".sh"
".log"
".toml"
".yml"
".yaml"
"public.plain-text"
"public.unix-executable"
"public.data"
)
for ext in $extensions; do
duti -s com.microsoft.VSCode $ext all
done
}

Settings: check.

Managing dotfiles with GNU Stow

stow is simply a symlink manager that let us define a folder structure (package) for our dotfiles and then symlink them following that structure. So let’s say one of your dotfiles is ~/.config/kitty/kitty.conf, this will be our kitty package. In order to place this file in the correct path, we define the package as as

dotfiles/kitty/.config/kitty/kitty.conf

(assuming all our dotfiles are in a folder called dotfiles)
We can simulate stow to see the resulting symlinks

cd dotfiles
stow -nSv kitty

If the output looks correct, we symlink the package with

stow --target $HOME kitty

Note: we want to use stow only for files, since a folder like .config will always contain many other files/folders, we don't want to symlink this folder. Therefor we need to first remove existing files (the ones we want to stow) and check that the needed directories exist. So, some lovely code for that:

stow_dotfiles() {
local files=(
".zprofile"
".gitconfig"
".zshrc"
".vimrc"
)
local folders=(
".config/nvim"
".config/kitty"
)
info "Removing existing config files"
for f in $files; do
rm -f "$HOME/$f" || true
done
for d in $folders; do
rm -rf "$HOME/$d" || true
mkdir -p "$HOME/$d"
done
local dotfiles="kitty nvim"
info "Stowing: $dotfiles"
stow -d stow --verbose 1 --target $HOME $dotfiles
}

So far so good, all our dotfiles are versioned and any change on them can be pushed and synced in all our macOS machines with git.
What else did I want to do?

Setting up Oh My Zsh

I use oh my zsh along with powerlevel10k and this is how my terminal looks

pretty, right?

So I need to install this scripts on my system, for which I have more lovely code:

install_oh_my_zsh() {
if [[ ! -f ~/.zshrc ]]; then
info "Installing oh my zsh..."
ZSH=~/.oh-my-zsh ZSH_DISABLE_COMPFIX=true sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
chmod 744 ~/.oh-my-zsh/oh-my-zsh.sh info "Installing powerlevel10k"
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ~/.oh-my-zsh/custom/themes/powerlevel10k
else
warn "oh-my-zsh already installed"
fi
}

Note: my powerlevel10k configuration is part of my dotfiles. If you don't have any, you can run p10k configure and follow the configuration process.

Installing (Neo)Vim plugins

Lastly, I also want to automatically install all my neovim plugins. First I need to install the plugin manager (I use vim-plug) and then I want to install all the plugins defined on my dotfiles

install_neovim() {
info "Installing NeoVim"
install_brew_formulas neovim
info "Installing Vim Plugged"
sh -c 'curl -fLo "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/autoload/plug.vim --create-dirs https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim'
}
# Install plugins and quit
nvim +PlugInstall +qall

Eureka. My dev environment is (almost) fully automated. So coming back to the objectives, this is the magic script:

curl -sO https://raw.githubusercontent.com/protiumx/.dotfiles/main/dotfiles

That’s it. My life quality has been improved by 2Π%.

And here my dotfiles repo

.dotfiles

Note: I have added a lot of read -p on my script to debug each section but they can all be removed so there is no need for interaction. Prompting for the sudo password could be than an enable it for the rest of command.

What I’m missing?

  • configuration for different apps like clipy. defaults show some configuration but not all of them.
  • some macOS config like disable autocapitalization don't seem to be available through defaults

I’d love to hear some ideas to improve this current setup!
Currently I’m checking ansible so stay tuned for a possible upgrade.

👽

--

--

Brian Mayo

I’m a software engineer currently working with Go and playing around with Rust