#!/usr/bin/env bash log() { printf '[setup] %s\n' "$@" } die() { printf 'Error: %s\n' "$@" >&2 exit 1 } # Homebrew's copy cannot `mise self-update`, so this is the standalone # installation. Called by absolute path so nothing depends on `PATH`. readonly MISE="$HOME/.local/bin/mise" readonly DOTFILES_DIR="$HOME/.dotfiles" readonly REPO_URL="https://tangled.org/mjj.io/dotfiles" # The DID form survives a handle change. Only reachable once the Tangled key is # in place, so the clone stays on HTTPS. readonly SSH_REPO_URL="git@tangled.org:did:plc:6333c325pq7b6dn6xp5lksym" failed_casks=0 # Downloaded in full before it runs. A command substitution executes whatever # bytes arrived when the transfer fails partway, and a pipe starts executing # before it completes at all. run_remote_installer() { local url="$1" local interpreter="$2" local script status=0 script="$(mktemp)" curl --proto '=https' --tlsv1.2 -fsSL "$url" -o "$script" || { rm -f "$script" die "Failed to download $url" } "$interpreter" "$script" || status=$? rm -f "$script" return "$status" } ensure_homebrew() { if ! command -v brew >/dev/null 2>&1; then log "Installing Homebrew" # The installer also installs the Command Line Tools, which is where `git` # comes from. run_remote_installer \ https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh \ /bin/bash export PATH="/opt/homebrew/bin:$PATH" fi command -v brew >/dev/null 2>&1 || die "Homebrew installed but brew is not on PATH." # An existing Homebrew is not repaired, so what it would otherwise have # brought is checked for separately. command -v git >/dev/null 2>&1 || die "git is missing. Run 'xcode-select --install', then rerun." } ensure_mise() { if [[ ! -x "$MISE" ]]; then log "Installing mise" run_remote_installer https://mise.run /bin/sh fi [[ -x "$MISE" ]] || die "mise is not at $MISE after installation." } # Fatal, because `stow` and `git` come from this list. brew_install_formulae() { log "Installing formulae with Homebrew Bundle" brew bundle --file=/dev/stdin <<'EOF' brew "git" brew "gnupg" brew "pinentry-mac" brew "stow" EOF } # Best effort, so a cask awaiting approval cannot cost the run the `stow` and # `mise install` steps that follow it. brew_install_casks() { log "Installing casks with Homebrew Bundle" brew bundle --file=/dev/stdin <<'EOF' cask "1password@7" cask "firefox@developer-edition" cask "flux-app" cask "font-maple-mono-nf" cask "font-sf-pro" cask "ghostty" cask "hammerspoon" cask "hazeover" cask "libreoffice" cask "markedit" cask "qlmarkdown" cask "raycast" cask "syntax-highlight" cask "thaw" cask "the-unarchiver" cask "todoist-app" EOF } main() { # Set here rather than at the top of the file, so that sourcing the script # leaves the caller's shell options as it found them. set -euo pipefail ensure_homebrew ensure_mise if [[ ! -d "$DOTFILES_DIR/.git" ]]; then log "Cloning the dotfiles into $DOTFILES_DIR" git clone "$REPO_URL" "$DOTFILES_DIR" fi cd "$DOTFILES_DIR" || die "Failed to enter $DOTFILES_DIR" # The clone is already done, so the remote matters only at the next fetch, by # which time the Tangled key from the instructions below is in place. log "Pointing the dotfiles remote at Tangled over SSH" git remote set-url origin "$SSH_REPO_URL" brew_install_formulae if ! brew_install_casks; then failed_casks=1 log "Some casks did not install. Their output is above." fi log "Trusting the dotfiles directory with mise" "$MISE" trust "$DOTFILES_DIR" if [[ -f .gitmodules ]]; then log "Updating the git submodules" git submodule update --init --recursive fi # Every tool below is pointed at its path by the dotfiles and none of them # creates it. Zsh reports a locking failure and drops the history, `compinit` # writes no dump at all and says nothing, and IRB warns that the folder has to # exist. Pry and Codex do create their own, so they are absent here. log "Ensuring the XDG and private directories" mkdir -p -- \ "${XDG_STATE_HOME:-$HOME/.local/state}/irb" \ "${XDG_STATE_HOME:-$HOME/.local/state}/zsh" \ "${XDG_CACHE_HOME:-$HOME/.cache}/zsh" \ "$HOME/.ssh" \ "$HOME/.gnupg" chmod 700 "$HOME/.ssh" "$HOME/.gnupg" log "Stowing the dotfiles" stow --restow . # `--locked` installs from the URLs the tracked lockfile already resolved, so # no GitHub API calls and no token to avoid being rate limited. A tool the # lockfile misses fails here and belongs in a deliberate relock in the # dotfiles. log "Installing the mise tools" "$MISE" install --locked # `user` converges `[bootstrap.user].login_shell`, which is Apple's # `/bin/zsh`. A login shell has to resolve at every login, including before # Homebrew is installed and after it breaks, so no formula backs it. `mise` # takes `sudo` for `/etc/shells` alone and runs `chsh` only when the value # differs, so rerunning this changes nothing. log "Applying the macOS defaults and login shell" "$MISE" bootstrap --only macos-defaults,user --yes # The signing key is checked at a temporary path before it replaces the # working one, so an empty clipboard or the wrong item cannot destroy a key # that already signs. The temporary name also has no sibling `.pub`, # which `ssh-keygen -lf` answers from when one exists, and which would # otherwise compare the stowed public key with itself and accept any key. # `mktemp` creates at mode 600 and `mv` preserves it, so no `chmod` is # needed to satisfy OpenSSH. cat <<'EOF' == Credentials == The first two keys are in 1Password, under Passwords. 1. Commit signing key. Copy its password, then: key=$(mktemp ~/.ssh/.identity.XXXXXX) { pbpaste; printf '\n'; } >"$key" diff <(ssh-keygen -lf "$key" | cut -d' ' -f2) \ <(ssh-keygen -lf ~/.ssh/identity.pub | cut -d' ' -f2) && mv "$key" ~/.ssh/identity && ssh-add --apple-use-keychain ~/.ssh/identity 2. GPG key. Copy its password, then: pbpaste | gpg --show-keys Must print 9B72011AAA5A6EADCFC34FACA3CDD4CCFF4E55AC and nothing else. Then: pbpaste | gpg --import 3. Tangled key. A passphrase is advised: ssh-keygen -t ed25519 -C "$(git config --global user.email)" \ -f ~/.ssh/tangled pbcopy <~/.ssh/tangled.pub open https://tangled.org/settings/keys 4. GitHub key, the same way: ssh-keygen -t ed25519 -C "$(git config --global user.email)" \ -f ~/.ssh/github pbcopy <~/.ssh/github.pub open https://github.com/settings/ssh/new == Post-installation == 1. Configure Firefox Developer Edition: - Open about:config - Set toolkit.legacyUserProfileCustomizations.stylesheets = true - Close Firefox - Create chrome/userChrome.css in ~/Library/Application Support/Firefox/Profiles/*/ - Add the following to userChrome.css: /* Hides the native tabs */ #TabsToolbar { visibility: collapse; } EOF if ((failed_casks == 1)); then printf '\n%s\n%s\n' \ "Setup ran to the end, but some casks did not install." \ "Install them by hand, then rerun setup to confirm." exit 1 fi log "Setup complete." } # Nothing above touches the machine, so a copy that stops short of this line # provisions nothing, and one cut inside the body fails to parse before any of # it runs. if [[ "${BASH_SOURCE[0]:-}" == "$0" ]]; then main "$@" fi