Syncing Homebrew Installs
Those of us who love the command line, have a tendency to install a lot of useful utilities, and want them available on all our computers. On macOS we tend to use Homebrew. This document serves to describe three ways to generate a useful file to solve that problem.
As I see it there are 3 basic approaches to syncing your homebrew utilities across machines.
- I don’t care about the details, just make it work.
- I would like a simple installation document that
- lists what I’ve installed
- what each is for
- can be used to install the same things on other machines
- I would like an efficient installation document that
- lists what I’ve installed
- what each is for
- can be used to install the same things on other machines
- won’t waste time trying to reinstall things already installed
- can handle post-install tasks, and make sure they only get run once.
This pots will address each of those, in order.
I don’t care about the details, just make it work
- run
brew bundle dump
- creates a
Brewfile
document in the current directory.
- creates a
- copy that to the other machine
cd
to the same directory as theBrewfile
on the new machine- run
brew bundle
That’s it. You’re done.
I’d like a simple installation document…
The following script will generate a documented bash script called brews.sh
which you can then sync across machines via whatever your normal method is. The downside to this is that rerunning it on a synced machine after adding one or two entries is going to be really slow. Also, there’s no good way to include post-install setup and have it still be idempotent and not break things. Post-install setups should generally only be run once, not every time you load the file.
The file will look like this:
#!/bin/sh
brew tap charmbracelet/tap
brew tap d12frosted/emacs-plus
# Strip or convert ANSI codes into HTML, (La)Tex, RTF, or BBCode
# http://www.andre-simon.de/doku/ansifilter/ansifilter.html
brew install ansifilter
# Official Amazon AWS command-line interface
# https://aws.amazon.com/cli/
brew install awscli
# Clone of cat(1) with syntax highlighting and Git integration
# https://github.com/sharkdp/bat
brew install bat
#... and so on
Here’s the script that generated it (presumes you’re runing in bash
/ zsh
shell)
echo '#!/bin/sh' > brews.sh;
brew leaves --installed-on-request \
| grep --color=none "/" \
| sed -e "s/\(.*\)\/.*/\1/" -e "s/^/brew tap /" \
| sort -u \
>> brews.sh;
brew info $(brew leaves --installed-on-request) 2>/dev/null \
| grep --color=none -A2 "==> [^[:space:]]\+:" \
| grep -v -- "--" \
| sed -e 's/==> \(.*\):.*/brew install \1/' \
-e '/^brew install/!s/\(.*\)/# \1/' \
| ruby -e 'lines=STDIN.each_line.to_a; (2..(lines.size - 1)).step(3){|n| puts "\n#{lines[n - 1]}"; puts lines[n]; puts lines[n - 2]}' \
>> brews.sh
chmod u+x brews.sh
I’d like an efficient installation document…
This is basically the same idea BUT it adds in 3 bash functions at the top, which make rerunning this much faster and allow you to do post-install setup if any of the things you’re installing need that.
Start by creating a new file called brews.sh
and adding this to the top of it. I’ll explain how to use them at the end of this section.
#!/usr/bin/env bash
# NOTE TO FUTURE ME
# run the following to extract the relevant info to manually append to this
# brew info <some_command> | head -n3
##############################
# BEGIN FUNCTIONS
function maybrew() {
if ! is_installed $1; then
install_or_die "$1"
else
echo "-- Skipping $1 (installed already)"
fi
}
function install_or_die() {
brew install $1
echo ""
echo ""
echo "-- Installing $1"
brew install $1
if [ $? -ne 0 ]; then
echo "problem installing $1"
echo "exiting"
exit 1
fi
}
function is_installed() {
if brew ls --versions $1 > /dev/null; then
# the package is installed
true
else
# the package is not installed
false
fi
}
# END FUNCTIONS
##############################
Next we’ll run a script that’s almost identical to the last one, except instead of calling brew install foo
it’s going to call maybrew foo
.
brew leaves --installed-on-request \
| grep --color=none "/" \
| sed -e "s/\(.*\)\/.*/\1/" -e "s/^/brew tap /" \
| sort -u \
>> brews.sh;
brew info $(brew leaves --installed-on-request) 2>/dev/null \
| grep --color=none -A2 "==> [^[:space:]]\+:" \
| grep -v -- "--" \
| sed -e 's/==> \(.*\):.*/maybrew "\1"/' \
-e '/^maybrew/!s/\(.*\)/# \1/' \
| ruby -e 'lines=STDIN.each_line.to_a; (2..(lines.size - 1)).step(3){|n| puts "\n#{lines[n - 1]}"; puts lines[n]; puts lines[n - 2]}' \
>> brews.sh
chmod u+x
Once again, you’ve got a brews.sh
file that you can sync, and append to over time.
The Three Functions
maybrew
takes a package name as an argument and asks homebrew if it’s installed. If not, it attempts to install it. The script will die if it fails, because you’ve got bigger problems to sort out at this point.install_or_die
takes a package name, and installs it or dies. called bymaybrew
is_installed
performs the fastest test i could find to determine if a homebrew package is installed. This is what you’re going to want to use if you need to do post install setup.
Usage
-
maybrew
Just call it with the name of a package:
maybrew foo
-
install_or_die
Just call it with the name of a package:
install_or_die foo
-
is_installed
Use this in an if statement to test if something’s been installed. If not, install it, and perform whatever post-install things the given script needs. For example, here’s how i use it to install
fzf
if ! is_installed "fzf"; then
# install it
brew install fzf
# now do post install setup for fzf
y | $(brew --prefix)/opt/fzf/install
else
echo "-- Skipping fzf (installed already)"
fi
An explanation
For those of you who think those scripts look like dark magic, and would like to understand how they work, here’s a breakdown. For everone else, you can stop here. Good luck.
# DO NOT RUN THIS ONE. IT WON'T WORK
# There isn't a way (that i know of), to continue a line ( \ ) AND
# have a comment before the next line.
# create a new file with a "shebang line". Unix reads the first line
# of the executable and if it starts with `#!` and a path it will pass
# the contents of the file to whatever executable lives at that path
# the `> brews.sh` tells it to send the output of echo to a new file
# named brews.sh
echo '#!/bin/sh' > brews.sh;
# `brew leaves` outputs the minimal list of things to install
# to regenerate your setup. Their dependencies will fill in the gaps
# BUT that's not necessarily the list of things you specifically
# told it to install. `--installed-on-request` should be just
# the list of things you specifically told it to install.
brew leaves --installed-on-request \
# if the line has a / in it, it means its a package installed from a tap
# and we need to run `brew tap` on them before we can install them
# so i'm trying to extract the taps. This isn't perfect, but it seems to work.
# `--color=none` is to compensate for anyone who has their grep configured to
# highlight matches (like me).
| grep --color=none "/" \
# strips off the last slash and everything after it
# foo/bar/baz becomes foo/bar
# then replaces the start of the line with `brew tap`
| sed -e "s/\(.*\)\/.*/\1/" -e "s/^/brew tap /" \
# sort the results and only include unique items
| sort -u \
# append the output to the brews.sh file
>> brews.sh;
# `brew info` optionally takes multiple lines of input.
# If you do this it's way faster than calling brew info
# on each item that `brew leaves` returns individually.
# so that's what we're doing. HOWEVER this always produces
# errors and warnings on my machine that you can't do anthing
# about so `2>/dev/null` instructs it to take everything printed
# to Standard Error and shove it into a black hole.
brew info $(brew leaves --installed-on-request) 2>/dev/null \
# this is a long piece of output, but we're looking for the
# lines with package names. Those can be identified by
# the fact that they all start with this (but with the appropriate
# package name)
# `==> package_name:`
# extract those lines with `grep` using a regexp that asks for
# lines with ==> followed by a single space, and 1 or more non-space
# characters and then a colon.
# we also want the 2 lines that follow.
# the first line afterwards is the short description of the package
# the 2nd line afterwards is the url where you can find it.
# for example:
# ➜ brew info git | head -n3
# ==> git: stable 2.37.3 (bottled), HEAD
# Distributed revision control system
# https://git-scm.com
| grep --color=none -A2 "==> [^[:space:]]\+:" \
# each section is separated by a `--` line.
# get rid of those lines with `grep -v`
# this is problematic because shells can't tell the
# difference between an argument of -- and "--" so
# it interprets the -- as the start of a command like
# `--color=none` from above. So, we use a common Unix
# idiom of passing JUST `--` which is generally interpreted
# to designate the end of the options to the command.
# now that it knows the next thing won't be an option,
# we can give it the thing we actually want it to search for "--"
| grep -v -- "--" \
# the line with the ==> is the one with the command name
# perform a regular expression capture for the stuff
# after the space and before the colon (the command name)
# then replace the line with `brew install <captured text>`
# `\1` just means "the first capture group".
| sed -e 's/==> \(.*\):.*/brew install \1/' \
# take every line that DOES NOT start with "brew install"
# The `/!s/` bit is the pice that makes this magic work
# capture the entire line, and then print out a #
# to make it into a comment, and then the whole captured line.
-e '/^brew install/!s/\(.*\)/# \1/' \
# things are in the wrong order.
# as you can see in the `brew info git` example above, its
# 1. command, 2. description, 3. url.
# I want the 2. description, 3. url, 1. command.
# this would probably be a nightmare in bash, so i just
# did a ruby onliner that reads everything from Standard input
# into an array, iterates over the array jumping 3 ahead each time
# (it's always a set of 3 lines), then prints out the lines
# in the order i want with an extra newline before the first one
| ruby -e 'lines=STDIN.each_line.to_a; (2..(lines.size - 1)).step(3){|n| puts "\n#{lines[n - 1]}"; puts lines[n]; puts lines[n - 2]}' \
# append it all to the brews.sh file
>> brews.sh
# make the file executable.
chmod u+x brews.sh