diff --git a/scripts/setup/machine-setup/lib/packages.sh b/scripts/setup/machine-setup/lib/packages.sh new file mode 100644 index 00000000..09b3d2b2 --- /dev/null +++ b/scripts/setup/machine-setup/lib/packages.sh @@ -0,0 +1,165 @@ +#!/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 + +# ----------------------------------------------------------------------------- +# 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, unzip opens ssh-keys.zip, +# gnupg dearmors the Docker keyring, git clones the Neovim config, 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. +# +# build-essential is the one entry with any reach beyond itself: it is 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 btop htop tree tmux ripgrep fd-find net-tools + ;; + pacman) + echo curl ca-certificates gnupg git jq unzip \ + wget zip base-devel btop htop tree tmux ripgrep fd net-tools + ;; + dnf) + echo curl ca-certificates gnupg2 git jq unzip \ + wget zip btop htop tree tmux ripgrep fd-find net-tools + ;; + 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 +} + +# 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=() + for pkg in "$@"; do + if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi + done + + 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 0 + fi + + echo " to install: ${missing[*]}" + pkg_install_now "${missing[@]}" +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index 1d409429..0bffe285 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -18,6 +18,8 @@ PROGRESS_FILE="$SCRIPT_DIR/.setup-progress" # whole script in. Definitions only; nothing in there acts. # shellcheck source=lib/base.sh source "$SCRIPT_DIR/lib/base.sh" +# shellcheck source=lib/packages.sh +source "$SCRIPT_DIR/lib/packages.sh" # Trap errors with context. Installed here rather than in lib/base.sh, because # that file is definitions only and a trap is a side effect on whoever sources it. @@ -76,14 +78,16 @@ USER_HOME="/home/$USERNAME" step "System Update & Essentials" if ! skip; then - info "Updating system packages..." - apt-get update -y && apt-get upgrade -y + info "Refreshing the package index..." + pkg_refresh - info "Installing essential packages..." - apt-get install -y \ - curl wget git zip unzip build-essential btop net-tools \ - software-properties-common jq htop tree ripgrep fd-find tmux \ - apt-transport-https ca-certificates gnupg lsb-release + # The one place that deliberately moves versions of things already installed. + # Everything else in this script only ever adds what is absent. + info "Upgrading installed packages..." + pkg_upgrade_all + + # shellcheck disable=SC2046 # word splitting is how the list is passed + pkg_install "Core packages" $(pkgs_core) ok "System updated and essentials installed"