From dbdef23d29f370f1642758696320b02a1b91a949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 17:19:46 +0000 Subject: [PATCH] install what is missing and keep what is there, per package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/packages.sh, and section 2 wired to it. The rule it exists to enforce: `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 silently moves a version they chose, and a setup script is the last thing that should do that behind their back. pkg_install queries the package database first and names only the genuinely absent packages on the command line — a package already installed is never passed to apt at all. It also says so out loud, every time, because a provisioning run should not be opaque about what it is doing to the machine: :: Core packages — installs what is missing, keeps what you already have already here: curl ca-certificates gnupg git jq … to install: btop tmux Section 2's flat list of 19 is now pkgs_core(), split per package manager rather than through a canonical-name table with overrides. The names genuinely disagree (build-essential/base-devel, fd-find/fd) and three of them are not packages elsewhere at all — apt-transport-https, lsb-release and software-properties-common are apt concepts that exist to let later steps add the Docker repo and the fastfetch PPA. A `case $PM` shows what each system actually gets, in one place. Of those 19, six are load-bearing and the rest are the environment. Only build-essential reaches beyond itself: it is a meta-package, so on a box with a pinned gcc it pulls the distribution default alongside. Noted where it is declared; it is the first thing to move out of core if that ever bites. apt-get upgrade stays, but as its own announced step — it is the one place that deliberately moves versions, rather than something that happens as a side effect of asking for a tool. DEBIAN_FRONTEND=noninteractive and NEEDRESTART_MODE=a now live inside the helpers. needrestart has been on by default since Ubuntu 22.04 and stops to ask which services to restart, which is how an unattended run ends up silently waiting for a keypress. dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a package removed but leaving its config behind — that state would read as present and never be reinstalled. Verified against this host's real dpkg database: all 19 report present, and a mixed list correctly passes only the absent ones through. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/packages.sh | 165 +++++++++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 18 +- 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 scripts/setup/machine-setup/lib/packages.sh 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"