My setup.sh in CachyOS, btw
I moved my main machine to CachyOS (Arch-based, btw) with Hyprland, and I didn't want the next fresh install to cost me a weekend. So the whole setup lives in my dotfiles as a few shell scripts: clone, run one command, and the machine ends up the way I like it.
One command
There's no single setup.sh file; the entry point is run_all.sh, which runs three scripts in order. Each one also works on its own, so I can re-run just the part I changed:
git clone https://gitlab.com/bolanosdev/dotfiles.git ~/Projects/dotfiles cd ~/Projects/dotfiles/bootstrap ./run_all.sh # install_software.sh → os_changes.sh → dev_env_changes.sh
- install_software.sh: pacman and AUR packages, Homebrew and its formulas, bun, fnm, sdkman with Java 25, hyprmod, herdr and Claude Code, and it wires their paths into my fish config. No prompts.
- os_changes.sh: creates
~/Projects, sets my git identity, makes Neovim the default editor, generates an SSH key if there isn't one, uploads it to GitHub and GitLab, installs my fish functions, and sets up the docker group and service. - dev_env_changes.sh: clones the dotfiles and symlinks my Neovim config, my Hyprland overrides and my wallpapers into place.
Everything in one list
What a fresh machine gets is a set of arrays in common.sh, split by where it comes from: official repos, the AUR (through yay) and Homebrew for the few tools I prefer from there:
# common.sh — one place to edit what a fresh machine gets PACMAN_PKGS=( docker docker-compose go rust neovim fzf ffmpeg zig dotnet-sdk github-cli glab lazygit steam faugus-launcher lutris wine winetricks obs-studio ) AUR_PKGS=( ghostty terraform-bin 1password 1password-cli kubectl k9s kubie minikube zennotes-bin ) BREW_PKGS=( gnupg dopplerhq/cli/doppler go-task/tap/go-task )
Adding a tool is one line. Removing one is the same idea: packages I don't want anymore go into a REMOVE_PKGS list, and a separate uninstall_software.sh prunes them when I decide to, so the main run never deletes anything by surprise.
Safe to run again
The rule for every function in these scripts is the same: check first, act only if something is missing. That makes the whole thing safe to run any number of times, on a brand-new install or on the machine I'm using right now:
install_pacman_pkgs() {
log "Installing pacman packages: ${PACMAN_PKGS[*]}"
# --needed makes this a no-op for anything already installed.
sudo pacman -Syu --needed --noconfirm "${PACMAN_PKGS[@]}"
}
install_brew() {
if command -v brew &>/dev/null; then
log "Homebrew already installed"
return
fi
log "Installing Homebrew"
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
}The same idea shows up everywhere:
- My fish config gets marked blocks (
# bootstrap: sdkman (java), …), and a block is only added if its marker isn't already there. - Config symlinks are skipped when they already point at the right place. If something else is in the way, it's moved to a timestamped
.bakinstead of being deleted. - The SSH key is only uploaded if GitHub or GitLab doesn't already have it. The first run opens the browser to log in; later runs reuse the stored credentials:
upload_ssh_key_gitlab() {
if ! glab auth status &>/dev/null; then
glab auth login --hostname gitlab.com --git-protocol ssh --web
fi
local local_key
local_key=$(awk '{print $2}' "${SSH_KEY}.pub")
if glab ssh-key list 2>/dev/null | grep -qF "$local_key"; then
log "SSH key already registered on GitLab — skipping upload"
else
glab ssh-key add "${SSH_KEY}.pub" --title "$(hostname)-$(date +%Y%m%d)"
fi
}Small things that bit me
- Homebrew on Linux and
xattr. Some formulae (like the go-task tap) call/usr/bin/xattr, which only exists on macOS. The script drops a tiny no-op shim there so they install cleanly. - Hyprland config I don't own. I use caelestia, which manages
~/.config/hypritself. Instead of editing it, I keep two override files (keybinds and variables, plus which app each special workspace launches) in my dotfiles and symlink only those. - Gaming without dual boot. Steam, Faugus Launcher, Lutris and Wine are in the package list, so a fresh install can play my Windows games from the first boot.
Why bother
Reinstalling used to mean a day of remembering what I had installed and why. Now it's a clone, one command, and a coffee while it runs. And because it's safe to re-run, the scripts double as the documentation of my machine: if it isn't in common.sh, I don't need it.