diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh new file mode 100644 index 00000000..17b9d6fc --- /dev/null +++ b/scripts/setup/machine-setup/lib/base.sh @@ -0,0 +1,209 @@ +#!/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 + +# ----------------------------------------------------------------------------- +# 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 +} + +# 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}" +} diff --git a/scripts/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh similarity index 88% rename from scripts/machine-setup.sh rename to scripts/setup/machine-setup/machine-setup.sh index b05f5078..bd2fa1cc 100755 --- a/scripts/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -2,178 +2,27 @@ set -e # ============================================================================= -# Ubuntu Server Setup Script -# Single-pass, non-interactive (install-everything) provisioning for a fresh -# Ubuntu server. Run as root: sudo ./setup-ubuntu.sh +# machine-setup — provisioning for a fresh machine +# +# Brings a blank box up to a usable state: users, SSH, networking, firewall, +# Docker, shell and editor tooling, language runtimes. +# +# Run as root: sudo scripts/setup/machine-setup/machine-setup.sh # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROGRESS_FILE="$SCRIPT_DIR/.setup-progress" -SUMMARY=() -ERRORS=() -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 +# Shared state, output helpers, the step/resume machine and OS detection. Kept in +# lib/ so a step can eventually be read — or run — on its own without dragging the +# whole script in. Definitions only; nothing in there acts. +# shellcheck source=lib/base.sh +source "$SCRIPT_DIR/lib/base.sh" -# ----------------------------------------------------------------------------- -# Helpers -# ----------------------------------------------------------------------------- - -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; } - -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" -} - -# Trap errors with context +# 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. trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR -# 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 -} - -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 -} - -# 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}" -} - # ============================================================================= # 1. Pre-flight # =============================================================================