Bash + GNU Stow: take a walk while your new macbook is being set up
Tags: automatization, macos, bash, dotfiles
Published: 2021-12-29 14:06:43

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

![auto](https://media0.giphy.com/media/l1KtYG8BndKBmWrM4/200.gif)

Today we are going to learn how we can automatize all our macOS settings, apps and `homebrew` packages using `bash` and [stow](https://www.gnu.org/software/stow/manual/stow.html) (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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/48lpduzjm3xgmws21n9c.png) 

## 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:
```sh
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:
```sh
brew list
```

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

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

```sh
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?

```sh
# 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)](https://github.com/mas-cli/mas).
This cli tool needs the **IDs** of the apps. To check the apps you have installed simply run:
```sh
mas list
```
If not all your apps are listed you can also **search** for them and get their IDs:
```sh
mas search Goodnotes
```
So again, writing some **pretty code:**
```sh
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:
```sh
defaults domains | grep finder
```
Read all the **settings available** for `finder`
```sh
defaults read com.apple.finder
```
I want to change **ShowExternalHardDrivesOnDesktop** but I don't know its **type**
```sh
defaults read-type com.apple.finder ShowExternalHardDrivesOnDesktop
```
`Type is boolean`. So finally we can set this to false
```sh
  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](https://github.com/moretension/duti).

```sh
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
```sh
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
```sh
cd dotfiles
stow -nSv kitty
```
If the output looks correct, we symlink the package with
```sh
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:
```sh
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](https://github.com/ohmyzsh/ohmyzsh) along with [powerlevel10k](https://github.com/romkatv/powerlevel10k) and this is how my terminal looks
![terminal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qyblg89lvg992ftu1qlg.png)
pretty, right?

So I need to install this scripts on my system, for which I have more lovely code:
```sh
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
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ib1ygwkr2xd85m3goqyb.png)
 
Lastly, I also want to automatically install all my neovim plugins. First I need to install the **plugin manager** (I use [vim-plug](https://github.com/junegunn/vim-plug)) and then I want to install all the plugins defined on my dotfiles
```sh
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:
```sh
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
{% github protiumx/.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](https://www.ansible.com/) so stay tuned for a possible upgrade.

:alien: