Three sections where there were two, and none of them touches the machine until you say so: 2. System update upgrades what is already installed 3. Core utils what the distribution provides 4. Command-line tools lazydocker, lazygit, starship, fastfetch The split matters because these are different kinds of change and deserve separate answers. System update is the only step in the whole script that moves versions of software already on the machine; core utils only ever adds what is absent; and the four tools are upstream binaries the distribution does not ship at all. Previously the update and the core packages were one step and the tools were tacked onto the end of it, so agreeing to "essentials" meant agreeing to all three at once. Every section now prints what it will install and what it is leaving alone, then asks. Enter means yes — unlike the machine-role question, which has no default, because these are "do the thing you already asked for" and making twenty of them require a deliberate keystroke would train people to hold the y key down. ASSUME_YES=1 answers all of them for an unattended run, and EOF fails with that named rather than spinning. Refusing is recorded rather than glossed: LAST_SKIPPED feeds the summary, so a declined section reads "Core utils: SKIPPED by request — cowsay neofetch" instead of quietly reporting nothing installed. Nothing to install means no prompt at all — there is nothing to agree to. announce_plan takes the array NAMES rather than their contents, because once a list has been through word splitting an empty one cannot be told from a missing one. Verified all three paths: accept, refuse, and nothing-to-do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
308 lines
11 KiB
Bash
308 lines
11 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# machine-setup — shared foundation
|
|
# =============================================================================
|
|
#
|
|
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
|
|
# declares state and functions and must never install, write or restart
|
|
# anything. Sourcing it has to be safe at any point, including from a step that
|
|
# is only being read for its variables.
|
|
#
|
|
# The one thing it expects from its caller, because they are facts about the
|
|
# entry point rather than about this library:
|
|
#
|
|
# SCRIPT_DIR directory of the script being run
|
|
# PROGRESS_FILE where completed step names are recorded
|
|
#
|
|
# Everything else below is owned here.
|
|
|
|
# Guard against being sourced twice — steps will eventually source this
|
|
# directly so they can be run on their own, and re-running it would reset
|
|
# SUMMARY and lose everything recorded so far.
|
|
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
|
|
MACHINE_SETUP_BASE_LOADED=1
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Shared state
|
|
# -----------------------------------------------------------------------------
|
|
|
|
SUMMARY=() # what was done, printed at the end
|
|
ERRORS=() # non-fatal failures, printed at the end
|
|
CURRENT_STEP=""
|
|
SKIP_STEP=false
|
|
|
|
# What machine this is. Filled in by detect_os() before any step runs; every step
|
|
# after that branches on these rather than assuming apt on x86_64.
|
|
OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | …
|
|
OS_NAME="" # pretty name, for the banner
|
|
OS_VERSION="" # version id; empty on rolling releases
|
|
PM="" # apt | pacman | dnf | brew
|
|
ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling
|
|
IS_WSL=false
|
|
|
|
# What this box is FOR. Asked once in pre-flight and consulted by the steps
|
|
# afterwards, because several of them have a different right answer per role and
|
|
# no way to work it out on their own:
|
|
#
|
|
# homelab a machine you physically control on a network you own
|
|
# vps rented, public IP, someone else's DHCP and console
|
|
# dev a laptop or desktop you sit at
|
|
#
|
|
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
|
|
# :- default rather than a plain assignment, which would wipe what the caller
|
|
# passed in before ask_machine_role ever looked at it.
|
|
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Output
|
|
# -----------------------------------------------------------------------------
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
info() { echo -e "${CYAN}::${NC} $*"; }
|
|
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
|
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
|
fail() {
|
|
echo -e " ${RED}FAIL${NC}: $*"
|
|
exit 1
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Steps and resume
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# A step announces itself, and is skipped when its name is already in the
|
|
# progress file. step_ok records it. The pattern at each call site is:
|
|
#
|
|
# step "Name"
|
|
# if ! skip; then
|
|
# …
|
|
# step_ok
|
|
# fi
|
|
|
|
step() {
|
|
CURRENT_STEP="$1"
|
|
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
|
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
|
SKIP_STEP=true
|
|
return
|
|
fi
|
|
SKIP_STEP=false
|
|
echo ""
|
|
echo -e "${BOLD}── $1 ──${NC}"
|
|
}
|
|
|
|
skip() { [[ "$SKIP_STEP" == true ]]; }
|
|
|
|
step_ok() {
|
|
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
|
}
|
|
|
|
# Try a command, log error but don't exit
|
|
try() {
|
|
local label="$1"
|
|
shift
|
|
if "$@" 2>&1; then
|
|
ok "$label"
|
|
else
|
|
warn "$label — failed (non-critical, continuing)"
|
|
ERRORS+=("$label")
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Input
|
|
# -----------------------------------------------------------------------------
|
|
|
|
prompt_value() {
|
|
local varname="$1" message="$2" default="$3"
|
|
# If env var already set, use it silently
|
|
if [[ -n "${!varname:-}" ]]; then
|
|
return
|
|
fi
|
|
local input
|
|
if [[ -n "$default" ]]; then
|
|
read -rp "$message [$default]: " input
|
|
eval "$varname=\"\${input:-$default}\""
|
|
else
|
|
read -rp "$message: " input
|
|
eval "$varname=\"\$input\""
|
|
fi
|
|
}
|
|
|
|
# Ask before acting. Every section that changes the machine goes through this, so
|
|
# a run is a sequence of things you agreed to rather than a wall of output you
|
|
# read afterwards to find out what happened.
|
|
#
|
|
# Enter means yes — unlike the machine-role question, which has no default. These
|
|
# are "do the thing you already asked for", and making twenty of them require a
|
|
# deliberate keystroke would train people to hold the y key down.
|
|
#
|
|
# ASSUME_YES=1 answers all of them, for an unattended run.
|
|
confirm() {
|
|
local message="${1:-Proceed?}" answer
|
|
[[ "${ASSUME_YES:-}" == "1" ]] && return 0
|
|
|
|
while true; do
|
|
# EOF is not a yes. Without this an unattended run without ASSUME_YES would
|
|
# spin here forever.
|
|
if ! read -rp " ${message} [Y/n]: " answer; then
|
|
echo ""
|
|
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
|
fi
|
|
case "$answer" in
|
|
"" | y | Y | yes | Yes) return 0 ;;
|
|
n | N | no | No) return 1 ;;
|
|
*) warn "Answer y or n." ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Run a block as the created user (login shell, inherits HOME)
|
|
as_user() {
|
|
sudo -u "$USERNAME" -i bash -c "$1"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Operating system detection
|
|
# -----------------------------------------------------------------------------
|
|
#
|
|
# Read one key out of /etc/os-release without leaking the rest of it into this
|
|
# script. That file defines NAME, VERSION and ID — all generic enough to collide
|
|
# with something here — so it is sourced in a subshell and only the one value
|
|
# asked for comes back.
|
|
os_release() {
|
|
[[ -r /etc/os-release ]] || return 1
|
|
# shellcheck disable=SC1091
|
|
(
|
|
. /etc/os-release 2>/dev/null
|
|
printf '%s' "${!1:-}"
|
|
)
|
|
}
|
|
|
|
# Identify the machine, or refuse to guess.
|
|
#
|
|
# /etc/os-release rather than probing for a binary: a box can have more than one
|
|
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
|
|
# converted box), and only os-release can say which distribution the machine
|
|
# actually IS, or give a version worth reporting.
|
|
#
|
|
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
|
|
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
|
|
detect_os() {
|
|
local kernel like
|
|
kernel="$(uname -s)"
|
|
|
|
case "$kernel" in
|
|
Darwin)
|
|
OS="macos"
|
|
OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
|
|
OS_NAME="macOS ${OS_VERSION}"
|
|
PM="brew"
|
|
;;
|
|
Linux)
|
|
OS="$(os_release ID || true)"
|
|
OS_NAME="$(os_release PRETTY_NAME || true)"
|
|
OS_VERSION="$(os_release VERSION_ID || true)"
|
|
like="$(os_release ID_LIKE || true)"
|
|
|
|
case "$OS" in
|
|
ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
|
|
arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
|
|
fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
|
|
*)
|
|
case " $like " in
|
|
*" debian "* | *" ubuntu "*) PM="apt" ;;
|
|
*" arch "*) PM="pacman" ;;
|
|
*" fedora "* | *" rhel "*) PM="dnf" ;;
|
|
esac
|
|
;;
|
|
esac
|
|
|
|
# WSL reports itself as Linux, but has no real systemd session: masking
|
|
# sleep targets, restarting logind and anything touching the boot path
|
|
# either fail or silently do nothing. Worth knowing before those steps run.
|
|
if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
|
|
;;
|
|
MINGW* | MSYS* | CYGWIN*)
|
|
fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
|
|
;;
|
|
*)
|
|
fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
|
|
;;
|
|
esac
|
|
|
|
# Normalised once here because upstream projects spell it differently:
|
|
# Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
|
|
case "$(uname -m)" in
|
|
x86_64 | amd64) ARCH="amd64" ;;
|
|
aarch64 | arm64) ARCH="arm64" ;;
|
|
*) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
|
|
esac
|
|
|
|
[[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
|
|
[[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Machine role
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# The interface packets actually leave by, which is not always the first one up.
|
|
default_iface() {
|
|
ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
|
|
}
|
|
|
|
# Ask what this machine is, unless the environment already said.
|
|
#
|
|
# Asked in pre-flight rather than at the point of use so that the run knows its
|
|
# own shape before it starts: the steps that care are spread from swap through to
|
|
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
|
|
# a provisioning run is how people start answering without reading.
|
|
#
|
|
# NO DEFAULT, deliberately, and it is the only question in the script like that.
|
|
# A guessed default is right often enough to be trusted and wrong in exactly the
|
|
# case that costs the most: pinning a static IP on a rented box, or leaving the
|
|
# firewall open on one. Every branch downstream is about what this machine is
|
|
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
|
|
ask_machine_role() {
|
|
if [[ -n "$MACHINE_ROLE" ]]; then
|
|
case "$MACHINE_ROLE" in
|
|
homelab | vps | dev) return ;;
|
|
*) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
|
|
esac
|
|
fi
|
|
|
|
echo ""
|
|
info "What is this machine? Several later steps depend on the answer."
|
|
echo " [1] homelab — yours, on a network you control"
|
|
echo " [2] vps — rented, public IP, provider's DHCP and console"
|
|
echo " [3] dev — a laptop or desktop you sit at"
|
|
echo ""
|
|
|
|
local choice
|
|
while [[ -z "$MACHINE_ROLE" ]]; do
|
|
# A failed read means EOF, not a wrong answer — without this the loop would
|
|
# spin forever when stdin is closed, which is how an unattended run hangs.
|
|
if ! read -rp " Which one? (1/2/3): " choice; then
|
|
fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
|
|
fi
|
|
case "$choice" in
|
|
1 | homelab) MACHINE_ROLE=homelab ;;
|
|
2 | vps) MACHINE_ROLE=vps ;;
|
|
3 | dev) MACHINE_ROLE=dev ;;
|
|
"") warn "There is no default here — pick 1, 2 or 3." ;;
|
|
*) warn "Not one of the options: '$choice'" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Convenience for the steps that branch on it.
|
|
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
|
|
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
|