Automate Everything

Posted on Jan 14, 2024

If you’re anything like me, buying a new laptop means HOURS of setup: installing apps, signing in to things, configuring dev environments, adjusting personalization options and system settings to be just right. Even weeks later, I still find myself tweaking one or two things that just feel… off. Wouldn’t it be nice if you could get everything set up by running a single command?

Well, that’s exactly what I did, falling into the classic trope of “I’m a software developer, I’ll just write a program to automate this!” before diving headfirst into a rabbit hole to the center of the Earth. As always, there’s a relevant xkcd comic :

xkcd-automation-comic

So after countless hours of “ongoing development”, was it really worth it? 1000%, yes! The bulk of my OS automations really took me only a couple weekends to finish; Everything after that was occasional maintenance and updates that I have accumulated over time.

What took a long time to perfect were my dotfiles . Looking to more seasoned developers, many have personal configurations that are reproducible and highly customized for their specific needs. I believe this is no coincidence. Thinking deeply about and investing in your own tooling is essential to growing as a developer. But I digress, those principles are more relevant for developer configs, though I think they extend somewhat to system configuration as well. While this guide focuses mostly on my macOS setup automation, these automations were originally an extension of my dotfiles!

If you can, it’s very helpful to write these setup scripts starting from a computer with factory defaults. That way, you can update your automations as you modify your system. Since I use a Macbook for most of my development tasks, this guide will be geared toward macOS. Also, it assumes at least intermediate knowledge of shell scripting.

Step 1: Blazing fast parallel app installation

The first thing I’d start with is writing a script for installing all the applications you need using Homebrew. Here’s my brew.sh script for reference. I’ll be walking through what each section does. The following code blocks are for explanatory purposes only! Please view and modify the full script, if you’d like to replicate this.

First, we can define 2 lists of apps, casks (apps with user interface) and command line tools. Some Homebrew packages have conflicting names and command line packages tend to depend on other ones, so it’s important to handle these two lists separately.

casks=(
  firefox
  spotify
  # Your graphical applications here...
)

cli=(
  wget
  openssh
  # Your command line tools here...
)

# Check to see if Homebrew is installed, and install it if it is not
command -v brew >/dev/null 2>&1 || /bin/bash -c \
  "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"


# Temporarily add brew to path in case it isn't already
export PATH="/opt/homebrew/bin:$PATH"

Homebrew is painfully slow since it installs apps serially. We can parallelize downloads and installations using pueue . Here’s the basic structure we follow:

# Cleanup function
finish() {
  if pueue >/dev/null 2>&1; then
    pueue reset
    pueue group | grep "${pueue_group}" > /dev/null &&
      pueue group remove "${pueue_group}"
  fi
}
trap finish EXIT

# Trigger exit on interrupt
ctrlc() {
  printf "Exiting...\n"
  exit
}
trap ctrlc INT

# Install pueue if not already installed
command -v pueue >/dev/null 2>&1 || brew install pueue || exit

# Start pueue in background if it isn't already running
pueue >/dev/null 2>&1 || pueued --daemonize

pueue_group="brew_install"
pueue group add "${pueue_group}"
pueue parallel 4 --group "${pueue_group}"

for app_name in "${casks[@]}"; do
  pueue add -g "${pueue_group}" "brew install --cask $app_name"
done

for app_name in "${cli[@]}"; do
  pueue add -g "${pueue_group}" "brew fetch $app_name"
done

pueue wait -g "${pueue_group}"

for app_name in "${cli[@]}"; do
  brew install "${app_name}"
done

We make a new pueue group, then set parallel to 4. This limits pueue to run 4 brew installs in parallel at a time. Notice that for casks we can simply use brew install, since they are standalone .app files. However, we need to use brew fetch for cli applications. This will download but not install them, since it is unsafe to install them in parallel due to potential dependency conflicts. After using pueue wait to ensure all brew tasks are done, we install the command line tools one by one. There’s also a trap to cleanup pueue processes when the script exits.

There’s one last thing I do to ensure the script is idempotent , meaning we can run it multiple times without breaking. This is a very useful property to maintain, and I go out of my way to ensure this for all my scripts. Before our script runs, we can define a list of already-installed apps, so that we skip over those when running multiple times. Now, we can wrap our pueue commands with a check to ensure we skip packages that are already installed. If no apps need to be installed, we skip the pueue wait, since no installation tasks were added.

# Save set of already-installed packages
declare -A already_installed && \
  for i in $(brew list); do already_installed["$i"]=1; done

# Set to 1 if any cask/cli package needs to be installed
cli_flag=0; cask_flag=0

for app_name in "${casks[@]}"; do
  if [[ "${already_installed["$app_name"]}" -ne 1 ]]; then
    pueue add -g "${pueue_group}" "brew install --cask $app_name"
    cask_flag=1
  else
    printf "Package already installed: %s\n" "${app_name}"
  fi
done

for app_name in "${cli[@]}"; do
  if [[ "${already_installed["$app_name"]}" -ne 1 ]]; then
    pueue add -g "${pueue_group}" "brew fetch $app_name"
    cli_flag=1
  else
    printf "Package already installed: %s\n" "${app_name}"
  fi
done

if [[ "$cli_flag" -eq 1 || "$cask_flag" -eq 1 ]]; then
  sleep 3 && pueue status
  pueue wait -g "${pueue_group}"
fi

Finally, as a cherry on top, I add this little hack to get around the annoying macOS “Are you sure you want to open…?” dialog on newly installed casks.

[[ "$cask_flag" -eq 1 ]] && \
  printf "Disabling Gatekeeper quarantine on all installed applications...\n"; \
  sudo xattr -dr com.apple.quarantine /Applications 2>/dev/null

And voila! We’ve automated our app installations. Now we just need to keep the app lists up to date.

Step 2: Font installation

Fonts are super easy to automate. On macOS, system fonts are pulled from the $HOME/Library/Fonts directory. I use a very short and simple font.sh script to copy my custom fonts into this directory:

#!/usr/bin/env zsh

fonts_dir="../assets/fonts"
install_location="$HOME/Library/Fonts"

# Navigate to current directory
cd "$(dirname "${0}")" || exit

fonts=$( find "${fonts_dir}" -name '*.[o,t]tf' )

printf "\nCopying fonts...\n\n%s\n" "${fonts}"
printf "%s" "${fonts}" | xargs -I % cp "%" "${install_location}/"
printf "\nFonts have been installed\n"

We use the find tool to get a list of otf/ttf font files using a short regular expression. Piping these paths into xargs, we can use cp to copy each one into the font install location.

Step 3: Customizing icons

This step is purely for looks, but I like all my icons to be consistent with the standard sizes introduced in Big Sur. You can find custom icons at macosicons.com (not sponsored lol), and this script will take care of replacing them for you! My script pulls the location of the app icon from each application’s info file and copies the replacement icon to its respective location.

#!/usr/bin/env zsh

apps=(
  "Deluge"
  "Firefox"
  "Flux"
  "ImageOptim"
  "Ledger Live"
  "LuLu"
  "Obsidian"
  "Spotify"
  "Synology Drive Client"
  "Tor Browser"
  "VLC"
)

# Ask for the administrator password upfront
sudo --validate

# Keep-alive: update existing `sudo` time stamp until `macos.sh` has finished
while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null &

# Navigate to current directory
cd "$(dirname "${0}")" || exit

icon_dir="../assets/icons"
for app_name in "${apps[@]}"; do
  printf "Setting app icon: %s\n" "${app_name}"

  # Get name of icon to replace and add .icns extension if it does not have one
  app_info="/Applications/${app_name}.app/Contents/Info"
  icon_name=$( defaults read "${app_info}" CFBundleIconFile )
  [[ "${icon_name: -5}" != ".icns" ]] && icon_name+=".icns"

  # Overwrite with preferred icon, and touch file to update icon db
  sudo cp "${icon_dir}/${app_name}.icns" \
    "/Applications/${app_name}.app/Contents/Resources/${icon_name}" && \
    sudo touch "/Applications/${app_name}.app"
done

Step 4: Configuring the dock

This is my dock.sh script for configuring app shortcuts in the macOS dock. Again, I won’t be going into much detail on this one, since it’s a lot of Apple-specific jargon. It dumps the macOS launch services register, and greps for the paths of applications. Then using Apple’s defaults tool, we write to the com.apple.dock persistent app array in property list format. Killing the Dock process restarts it with the new app list.

#!/usr/bin/env zsh

apps=(
  "System Settings"
  "Bitwarden"
  "Obsidian"
  "Signal"
  "Messages"
  "Discord"
  "Spotify"
  "Arc"
  "Firefox"
  "Tor Browser"
  "kitty"
  "Calendar"
)

launchservices_path="/System/Library/Frameworks/CoreServices.framework"
launchservices_path+="/Versions/A/Frameworks/LaunchServices.framework"
launchservices_path+="/Versions/A/Support/lsregister"

printf "Parsing launchservices dump for application paths...\n"
path_dump=$( "${launchservices_path}" -dump | grep -o "/.*\.app" | \
  grep -v -E "Backups|Caches|TimeMachine|Temporary|/Volumes/" | uniq | sort )

# Remove all persistent icons from macOS Dock
defaults write com.apple.dock persistent-apps -array

for app_name in "${apps[@]}"; do
  # Adds an application to macOS Dock
  app_path=$(printf "${path_dump}" | grep "${app_name}.app" | head -n1)

  if open -Ra "${app_path}"; then
    defaults write com.apple.dock persistent-apps -array-add \
    "<dict>
      <key>tile-data</key>
      <dict>
        <key>file-data</key>
        <dict>
          <key>_CFURLString</key>
          <string>${app_path}</string>
          <key>_CFURLStringType</key>
          <integer>0</integer>
        </dict>
      </dict>
    </dict>"
    printf "Added to dock: %s\n" "${app_name}"
  else
    printf "ERROR: %s not found.\n" "${app_name}" 1>&2
  fi
done

killall Dock

Step 5: Configuring system settings

This monster of a script configures all of the macOS system settings I could figure out how to automate. This is admittedly the most fragile of my scripts, since a lot of these automations are undocumented and subject to change. However, I can confirm they are working as of macOS Sonoma.

When I first started building my configs, I took much inspiration from Mathias Bynens&rsquo; legendary dotfiles . A lot of the macOS configs came from his .macos script (some of these don’t work anymore, beware), and I’ve figured out how to configure some additional ones over time. I’ve also stuck some other miscellaneous personalization settings in post.sh , which I run after all of my other scripts. Finally, I handle settings that require some user intervention in manual.sh .

For this part, I would suggest picking and choosing what you need from the options documented in my scripts (or if you’d like, going down the trial-and-error rabbit hole using Apple’s defaults tool).

macos-defaults.com is a good resource for some basic configs, but figuring out more specific settings may require some tinkering with defaults and osascript. It really depends how much automation is “good enough” for your needs, and I’ll leave that as an exercise for the reader ;)

Step 6: Dotfiles!

Dotfiles are something to build up over time, but a simple .zshrc would be a great place to start. While this is a guide on automating macOS configurations, dotfiles are a great way to automate your application-specific settings! I track my dotfiles separately in this repository , and in my automated OS configurations, I have a dot.sh script to pull my dotfiles and run a sync script.

I highly recommend this split approach, since it results in two nicely complementary modules. This setup allows you to write automated configurations for multiple operating systems, which act as a wrapper around your dotfiles. For instance, I have OpenSuse configs as well, which install the same underlying dotfiles repository.

While this guide doesn’t cover dotfile setup in detail, there’s plenty of great guides online that cover this. I may write one in the future, time permitting.

Putting it all together

You may have noticed throughout this guide, that I’ve been referencing my macos-configs repository, which has the following structure:

├── assets
│   ├── files
│   │   └── user.js
│   ├── fonts
│   │   └── hack-nf
│   │       ├── hacknf-bold-italic.ttf
│   │       ├── ...
│   ├── icons
│   │   ├── Firefox.icns
│   │   ├── ...
│   └── images
│       ├── wallpaper.jpg
│       ├── ...
├── run
│   ├── auto.sh
│   ├── manual.sh
│   └── run.sh
└── scripts
    ├── brew.sh
    ├── dock.sh
    ├── dot.sh
    ├── font.sh
    ├── icon.sh
    ├── macos.sh
    └── post.sh

Having our scripts and assets composed like this allows for each one to be run individually, in case we need to modify specific configurations down the line. I also keep these files versioned in a git repository. The final setup would be to add a one line install to run it all!

mkdir ~/Desktop/macos-configs && cd ~/Desktop/macos-configs && curl -#L https://github.com/yuzhoumo/macos-configs/tarball/main --silent | tar -xzv --strip-components 1 --exclude={README.md,LICENSE} && ./run/run.sh

I have a single run.sh script that runs all the other ones. Plugging this into the above command, we have our full system configuration all in one line! For me, running through this setup takes about 15 minutes. Good luck and happy hacking!