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

protium - Dec 29 '21 - - Dev Community

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

auto

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

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

brew tap
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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[@]}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

mas search Goodnotes
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Read all the settings available for finder

defaults read com.apple.finder
Enter fullscreen mode Exit fullscreen mode

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

defaults read-type com.apple.finder ShowExternalHardDrivesOnDesktop
Enter fullscreen mode Exit fullscreen mode

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

  defaults write com.apple.finder ShowExternalHardDrivesOnDesktop -bool false
Enter fullscreen mode Exit fullscreen mode

(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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

cd dotfiles
stow -nSv kitty
Enter fullscreen mode Exit fullscreen mode

If the output looks correct, we symlink the package with

stow --target $HOME kitty
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
terminal
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
}
Enter fullscreen mode Exit fullscreen mode

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

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

And here my dotfiles repo

GitHub logo protiumx / .dotfiles

My automatic dev machine setup scripts and configs

dotfiles

shell

Set up dev environment in a macOS machine. This script installs all the packages and apps I use, stow my dotfiles and sets all my preffered macOS configurations.

Check my Medium article.

Installing

Run the dotfiles script:

curl -sO https://raw.githubusercontent.com/protiumx/.dotfiles/main/dotfiles
Enter fullscreen mode Exit fullscreen mode

Reusing

In order to reuse these scripts, here a summary of files you can change/adapt to your needs:

  • scripts/packages.sh: all the homebrew taps and packages to install
  • scripts/fonts.sh: homebrew fonts to install
  • scripts/apps.sh: homebrew casks to install
  • scripts/osx.sh: macOS settings
  • scripts/config.sh: general settings and dotfiles

Testing Stow

To double checks that the dot files will be correctly linked, you can use stow to simulate the result. E.g.

stow -nSv vim
Enter fullscreen mode Exit fullscreen mode

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.

👽

. . . . . . . . . . . . . . . . . . . . . . . . . . . .