#!/bin/bash # ============================================================================= # machine-setup — distro packages # ============================================================================= # # Definitions only, like lib/base.sh. Sourcing this installs nothing. # # ── The rule: install what is missing, never touch what is there ── # # `apt-get install ` is NOT a no-op — it upgrades the package if # the repository has a newer one. On a machine somebody already uses, that can # move a version they chose deliberately, and the setup script is the last thing # that should be doing that behind their back. # # So every install here goes through pkg_install, which queries the package # database first, installs only the subset that is genuinely absent, and prints # both lists before doing it. A package already present is never named on a # command line at all. # # ── Why per-package-manager lists rather than a translation table ── # # The names disagree across distributions (build-essential/base-devel/fd/fd-find) # and some packages are not a package elsewhere at all: apt-transport-https, # lsb-release and software-properties-common are apt concepts. A canonical-name # table with per-manager overrides hides both of those behind indirection. A # plain `case $PM` says what each system actually gets, in one place, and matches # the shape scripts/setup-old/setup.sh already used. [[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0 MACHINE_SETUP_PACKAGES_LOADED=1 # What the last pkg_install/tools_install actually put on the machine, as opposed # to what it was asked for. Read by the caller to write an honest summary line: # without it every section reports its whole list as installed, including the # packages it deliberately left alone. LAST_INSTALLED=() LAST_KEPT=() LAST_SKIPPED=() # ----------------------------------------------------------------------------- # The sections # ----------------------------------------------------------------------------- # Core: what this script itself would break without, plus the command-line tools # that make a machine worth sitting at. # # The first six are load-bearing and each is used by a later step — curl fetches # in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker # keyring, git clones the Neovim config, unzip opens anything that arrives as an # archive, and ca-certificates is what makes any of the fetching work. The rest # are the environment: nothing calls them, they are here because a box you use # should have them. # # Four entries earn a note. # # python3 is not a tool anybody here calls — it is node-gyp's build dependency, # and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for # darwin and win32 ONLY, so on Linux its install script always falls through to # `node-gyp rebuild` and compiles from source. Without python3 that fails, and # the failure surfaces as a broken terminal sidecar rather than as a missing # package. build-essential below is the other half of the same requirement. # # unattended-upgrades installs updates on a timer with nobody watching. apt only: # it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman # has no equivalent at all, so it is not a name to translate. Installing the # package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades # is what the apt-daily timers read, and on this host no package owns that file. # The section makes sure it is there. # # fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu # ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`. # Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the # moment it installs, an address failing to log in five times in ten minutes is # blocked for ten, including yours. That is the point of it and it is worth # having by default, but it is why it belongs in this comment rather than being # thought of as one more binary. An existing install with its own jails is # untouched, because pkg_install never names a package that is already there. # # build-essential is the other: a meta-package (gcc, g++, make, libc6-dev, # dpkg-dev), so on a machine where a specific gcc was pinned it pulls the # distribution's default alongside it. It stays in core because anything that # compiles a native module needs it, but it is the one to move out first if that # ever bites. pkgs_core() { case "$PM" in apt) # apt-transport-https, lsb-release and software-properties-common are not # tools — they are what lets later steps add the Docker repository and the # fastfetch PPA. They have no counterpart on the other systems. echo curl ca-certificates gnupg git jq unzip \ apt-transport-https lsb-release software-properties-common \ wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \ fail2ban unattended-upgrades ;; pacman) echo curl ca-certificates gnupg git jq unzip \ wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \ fail2ban ;; dnf) echo curl ca-certificates gnupg2 git jq unzip \ wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \ fail2ban ;; brew) # curl, unzip and the TLS roots ship with macOS; the compilers come from # the Xcode command line tools, which is not a formula. echo gnupg git jq wget btop htop tree tmux ripgrep fd ;; esac } # ----------------------------------------------------------------------------- # Querying # ----------------------------------------------------------------------------- # Is this package installed right now? # # dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a # package that was removed but left its config behind — that state would be read # as "present" and the package would never be reinstalled. pkg_is_installed() { case "$PM" in apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;; pacman) pacman -Qi "$1" &>/dev/null ;; dnf) rpm -q "$1" &>/dev/null ;; brew) brew list --formula "$1" &>/dev/null ;; *) return 1 ;; esac } # ----------------------------------------------------------------------------- # Acting # ----------------------------------------------------------------------------- # Refresh the package index. # # DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to # draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since # Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here # rather than at each call site, because forgetting one turns an unattended run # into one that is silently waiting for a keypress. pkg_refresh() { case "$PM" in apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;; pacman) pacman -Sy --noconfirm ;; dnf) dnf makecache ;; brew) brew update ;; esac } # What an upgrade would actually move, one package name per line. # # Asked before the upgrade runs so the section can name what it is about to # change rather than asking to be trusted. Needs a refreshed index to be # accurate, which is why pkg_refresh runs first. # # `apt-get upgrade -s` simulates and prints an "Inst …" line per package, # which is the same calculation the real run does — as opposed to # `apt list --upgradable`, which also lists packages that are held back and # would not actually move. pkg_upgradable() { case "$PM" in apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;; pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;; dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;; brew) brew outdated --quiet 2>/dev/null ;; esac } # Upgrade everything already installed. Separate from pkg_install on purpose: # this one DOES move versions, so it is a deliberate step rather than something # that happens as a side effect of installing a tool. pkg_upgrade_all() { case "$PM" in apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;; pacman) pacman -Su --noconfirm ;; dnf) dnf upgrade -y ;; brew) brew upgrade ;; esac } # The raw install, with no presence check. Use pkg_install instead. pkg_install_now() { case "$PM" in apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;; pacman) pacman -S --noconfirm --needed "$@" ;; dnf) dnf install -y "$@" ;; brew) brew install "$@" ;; esac } # Announce a section, then install only what is absent from it. # # pkg_install "Core packages" $(pkgs_core) # # Prints both lists before touching anything, so the run says what it is about to # do to this machine and what it is deliberately leaving alone. Returns 0 when # there was nothing to do. pkg_install() { local label="$1" shift local pkg local -a missing=() present=() LAST_SKIPPED=() for pkg in "$@"; do if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi done LAST_INSTALLED=("${missing[@]}") LAST_KEPT=("${present[@]}") announce_plan "$label" present missing || return 0 pkg_install_now "${missing[@]}" } # Print what a section is about to do and ask permission for it. # # Takes the NAMES of the two arrays rather than their contents, because a list # passed by value cannot be told apart from an empty one once it has been through # word splitting. # # Returns non-zero when there is nothing to do, or when the answer was no — in # both cases the caller should skip its action. LAST_INSTALLED is cleared on a # refusal so the summary does not claim work that never happened. announce_plan() { local label="$1" local -n _present="$2" local -n _missing="$3" echo "" info "${label} — installs what is missing, keeps what you already have" ((${#_present[@]})) && echo " already here: ${_present[*]}" if ((${#_missing[@]} == 0)); then echo " to install: nothing, all present" return 1 fi echo " to install: ${_missing[*]}" if ! confirm "Proceed?"; then warn "skipped by request" LAST_INSTALLED=() LAST_SKIPPED=("${_missing[@]}") return 1 fi return 0 } # One summary line describing what a section actually did, from LAST_INSTALLED # and LAST_KEPT. Call straight after pkg_install or tools_install. summarise_last() { local label="$1" if ((${#LAST_SKIPPED[@]})); then SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}") elif ((${#LAST_INSTALLED[@]} == 0)); then SUMMARY+=("$label: already present, nothing installed") elif ((${#LAST_KEPT[@]} == 0)); then SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}") else SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)") fi }