It stopped a real install. The guard covered the wrong half: a PPA that fails to ADD is caught and skipped, but one that adds cleanly while carrying no package for the running codename gets past that and dies on `pkg_install_now fastfetch`. It was also the only tool in the set with no source but a third-party PPA on Ubuntu 24.04 and older. A neofetch clone is not worth a branch in a script whose whole job is to survive machines nobody has seen. Removed from tools_default, the tool_command mapping and its installer. No shell config invoked it, so nothing is left calling a missing binary. software-properties-common stays in the core apt list for now, with a note: it provides add-apt-repository, the fastfetch PPA was its only caller, and Docker writes its own sources.list.d entry by hand — so it is now dead weight. Left as a separate decision rather than folded into this one. Verified: bash -n on all four scripts, and no live reference remains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
13 KiB
Bash
304 lines
13 KiB
Bash
#!/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 <present-package>` 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 and lsb-release are not tools — they are what lets a
|
|
# later step add the Docker repository. They have no counterpart on the
|
|
# other systems.
|
|
#
|
|
# software-properties-common is still here and is no longer needed by
|
|
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
|
|
# only caller until that was removed on 2026-08-14 (Docker writes its own
|
|
# sources.list.d entry by hand). Left in deliberately rather than dropped
|
|
# in the same change — it is one small package, and pulling it is a
|
|
# separate decision from removing the tool that wanted it.
|
|
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 — see xcode_clt_*.
|
|
echo gnupg git jq wget btop htop tree 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 <name> …" 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 || {
|
|
# Declining is a fact a reviewer wants: it explains a package being absent
|
|
# later without having to guess whether the script failed or was refused.
|
|
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
|
|
return 0
|
|
}
|
|
|
|
if pkg_install_now "${missing[@]}"; then
|
|
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
|
|
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
|
|
else
|
|
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The Xcode command line tools
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# macOS's build-essential, and not installable as a formula. It matters here for
|
|
# one specific reason: node-pty ships no prebuilt binary for any platform, so
|
|
# `bun install` always falls through to node-gyp and needs a working compiler.
|
|
# Without this the platform install fails deep inside a dependency tree with an
|
|
# error that names neither Xcode nor node-pty.
|
|
#
|
|
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
|
|
# not block until the download finishes. So this asks, and then says to come back,
|
|
# rather than pretending to have waited.
|
|
|
|
xcode_clt_installed() { xcode-select -p &>/dev/null; }
|
|
|
|
xcode_clt_install() {
|
|
xcode-select --install 2>/dev/null || true
|
|
}
|