#!/bin/bash
set -e

# =============================================================================
# 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"
ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"

# --only <step> runs one section and nothing else, for working on it. Pre-flight
# still runs, because every section needs what it establishes — the system, the
# role, the account and its home.
ONLY_STEP=""

# Kept before the loop consumes them: this script re-executes itself through sudo
# below and was passing `"$@"`, which `shift` had already emptied — so `--only` and
# `--reask` silently stopped existing the moment it escalated.
ORIGINAL_ARGS=(${@+"$@"})

while [[ $# -gt 0 ]]; do
  case "$1" in
    --only)
      ONLY_STEP="${2:-}"
      shift 2
      ;;
    --only=*)
      ONLY_STEP="${1#*=}"
      shift
      ;;
    --reask)
      RE_ASK=1
      shift
      ;;
    # Every question that HAS a default answers itself; the ones with none still ask.
    # ASSUME_YES drives confirm(), UNATTENDED drives the numbered menus and the
    # free-text prompts that carry a default.
    --unattended | -y)
      export UNATTENDED=1 ASSUME_YES=1
      shift
      ;;
    -l | --list)
      grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
      exit 0
      ;;
    -h | --help)
      echo "usage: machine-setup.sh [--only <step>] [--reask] [--list] [--unattended]"
      echo ""
      echo "  --unattended   take the default for every question that has one (-y)."
      echo "                 Still asks the ones with no possible default: the"
      echo "                 username, the Tailscale control plane / login server /"
      echo "                 auth key, an SSH public key when the account has none,"
      echo "                 and the git identity."
      exit 0
      ;;
    *) echo "unknown option: $1" >&2 && exit 2 ;;
  esac
done

# 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/../report.sh"
source "$SCRIPT_DIR/lib/base.sh"
# shellcheck source=lib/packages.sh
source "$SCRIPT_DIR/lib/packages.sh"
# shellcheck source=lib/tools.sh
source "$SCRIPT_DIR/lib/tools.sh"
# shellcheck source=lib/system.sh
source "$SCRIPT_DIR/lib/system.sh"
# shellcheck source=lib/disk.sh
source "$SCRIPT_DIR/lib/disk.sh"
# shellcheck source=lib/files.sh
source "$SCRIPT_DIR/lib/files.sh"
# shellcheck source=lib/ssh.sh
source "$SCRIPT_DIR/lib/ssh.sh"
# shellcheck source=lib/network.sh
source "$SCRIPT_DIR/lib/network.sh"
# shellcheck source=lib/dev.sh
source "$SCRIPT_DIR/lib/dev.sh"
# shellcheck source=lib/docker.sh
source "$SCRIPT_DIR/lib/docker.sh"
# shellcheck source=lib/tailscale.sh
source "$SCRIPT_DIR/lib/tailscale.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.
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

# =============================================================================
# 1. Pre-flight
# =============================================================================

echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║          Machine Setup                           ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"

# Anything answered on a previous run, unless --reask says to ask again.
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
load_answers

trap report_flush EXIT
detect_os
echo ""
info "Machine:  ${OS_NAME} (${ARCH})"
info "Packages: ${PM:-none detected}"
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"

# Everything below this line is written against apt and systemd. Detection above
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
# nothing implements them yet — and running the apt path on Arch would half-build
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
# list one entry at a time as each package manager grows a real path.
case "$PM" in
  apt) ;;
  "") fail "Could not find a package manager for '${OS_NAME}'." ;;
  *) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
esac

ask_machine_role
info "Role:     ${MACHINE_ROLE}"

if [[ -f "$PROGRESS_FILE" ]]; then
  DONE_COUNT=$(wc -l <"$PROGRESS_FILE")
  echo ""
  info "Resuming — ${DONE_COUNT} step(s) already done, and they will be skipped"
  echo "     To start over instead:  sudo rm ${PROGRESS_FILE}"
else
  echo ""
  echo "     This can be stopped at any point and run again later. Completed"
  echo "     steps are remembered and skipped, and the answers below are kept,"
  echo "     so coming back costs nothing."
fi

# ── root on Linux, NOT root on macOS ──
#
# The two are opposites and it is not a preference. On Linux nearly every section
# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew
# REFUSES to run as root and says so; running the whole script under sudo there
# would fail at the first `brew install` having already asked for a password.
#
# It works out because the macOS path skips everything that needed root in the
# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode
# command line tools, the agent CLIs, bun — is all per-user by design.
# ── Privileges: asked for, not demanded ──
#
# Run this as YOURSELF. Linux needs root for apt, systemd units, useradd, netplan
# and ufw, so it asks through sudo and re-executes itself rather than making you
# type it. Variables go to sudo by name rather than with -E: `env_reset` is the
# sudoers default and strips the environment, which is how DATA_PATH was lost
# once already.
#
# macOS never escalates — Homebrew refuses to run as root, and the sections that
# needed root are the ones the macOS path skips.
if [[ "$OS" == "macos" ]]; then
  if [[ "$EUID" -eq 0 ]]; then
    fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
  fi
elif [[ "$EUID" -ne 0 ]]; then
  command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
  echo ""
  echo "  This needs administrator rights. You will be asked for your password."
  echo ""
  exec sudo \
    OFFICER_ROOT="${OFFICER_ROOT:-}" \
    SETUP_USERNAME="${SETUP_USERNAME:-}" \
    MACHINE_ROLE="${MACHINE_ROLE:-}" \
    REPORT_FILE="${REPORT_FILE:-}" \
    UNATTENDED="${UNATTENDED:-}" \
    ASSUME_YES="${ASSUME_YES:-}" \
    bash "$SCRIPT_DIR/machine-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi

# On macOS the account running the script IS the account, and there is nothing to
# create — the User account step is skipped entirely.
if [[ "$OS" == "macos" ]]; then
  USERNAME="$(id -un)"
  USER_HOME="$HOME"
  info "Account:  ${USERNAME} (you — macOS creates no accounts here)"
else
  ask_username
  if id "$USERNAME" &>/dev/null; then
    info "Account:  ${USERNAME} (exists, home ${USER_HOME})"
  else
    info "Account:  ${USERNAME} (will be created, home ${USER_HOME})"
  fi
fi

ask_officer_root
if [[ -d "$OFFICER_ROOT" ]]; then
  info "Officer:  ${OFFICER_ROOT} (exists already)"
else
  info "Officer:  ${OFFICER_ROOT}"
fi

save_answers

# Always, and outside any step: everything below reads this index — core utils,
# the Docker repo — and `step` skips a step whose name is already in the progress
# file. With the refresh inside one of those, a resumed run installed against
# whatever the index happened to say hours or days ago.
echo ""
info "Refreshing the package index..."
pkg_refresh >/dev/null

# =============================================================================
# 2. User account
# =============================================================================

# First of the acting sections, and before anything writes into a home.
#
# If the account does not exist yet, USER_HOME is a path that is not there —
# and the ballast's mkdir -p, running as root, would create it root-owned. adduser
# then finds the directory already present and does not populate or chown it. So
# the account is made before any step can put a file in its home.
step "User account"
if ! skip; then
  echo ""
  info "User account — the account you will actually log in and work as"

  if id "$USERNAME" &>/dev/null; then
    echo "     ${USERNAME} already exists"
    USER_EXISTED=true
  else
    echo "     ${USERNAME} does not exist yet and will be created"
    echo "     adduser will ask for a password and a few details"
    USER_EXISTED=false
  fi

  IN_SUDO=false
  id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo && IN_SUDO=true
  echo "     sudo group:   $($IN_SUDO && echo 'already a member' || echo 'will be added')"
  echo "     passwordless: $(has_passwordless_sudo "$USERNAME" && echo 'already granted' || echo 'not granted')"

  if $USER_EXISTED && $IN_SUDO; then
    SUMMARY+=("User: ${USERNAME} (already set up)")
  elif confirm "Proceed?"; then
    if ! $USER_EXISTED; then
      adduser --gecos "" "$USERNAME"
      SUMMARY+=("User: ${USERNAME} created")

      # Re-read it now the account is real. Until this point USER_HOME was the
      # /home/<name> guess, since there was nothing to look up; adduser is free
      # to have used something else, and every step after this writes there.
      USER_HOME="$(getent passwd "$USERNAME" | cut -d: -f6)"
      ok "home is ${USER_HOME}"
    fi
    $IN_SUDO || usermod -aG sudo "$USERNAME"
    ok "${USERNAME} is in the sudo group"
    $USER_EXISTED && SUMMARY+=("User: ${USERNAME} added to sudo")
  else
    warn "skipped by request"
    SUMMARY+=("User: SKIPPED by request")
  fi

  # ── passwordless sudo ──
  #
  # Asked separately because it is a security posture rather than part of
  # creating an account, and because Officer has an actual requirement here:
  # os-user-shell.ts runs `sudo -n` to provision a member's home, and a prompt it
  # cannot answer is a failure it reports as a permissions error.
  if has_passwordless_sudo "$USERNAME"; then
    echo ""
    echo "     passwordless sudo is already granted to ${USERNAME}"
    SUMMARY+=("Sudo: passwordless (already)")
  else
    echo ""
    info "Passwordless sudo for ${USERNAME}?"
    echo "     Means sudo never asks for a password again. Convenient, and the"
    echo "     cost is real: anything that gets hold of this account, or of a key"
    echo "     that can log into it, is root without another step."
    if is_role vps; then
      echo "     Worth weighing on a ${MACHINE_ROLE}, which faces the open internet."
    fi
    echo ""
    echo "     Officer needs it: it runs 'sudo -n' to provision a member's Linux"
    echo "     account, and a password prompt it cannot answer surfaces as a"
    echo "     permissions error rather than a question."
    if confirm "Grant it?"; then
      grant_passwordless_sudo "$USERNAME"
      ok "passwordless sudo granted — remove /etc/sudoers.d/99-${USERNAME}-nopasswd to undo"
      SUMMARY+=("Sudo: passwordless")
    else
      warn "skipped by request"
      SUMMARY+=("Sudo: password required (Officer's account provisioning will not work)")
    fi
  fi
  step_ok
fi

# =============================================================================
# 3. Disk space
# =============================================================================
#
# First of the sections that change anything, because everything after it sizes
# itself from free disk — the swapfile and the ballast both.

step "Disk space"
if ! skip; then
  ROOT_DEV="$(root_device)"
  ROOT_DISK="$(parent_disk "$ROOT_DEV")"
  ROOT_PART="$(backing_partition "$ROOT_DEV" || true)"

  DISK_B="$(dev_bytes "$ROOT_DISK")"
  CONT_B="$(dev_bytes "$ROOT_DEV")"
  FS_B="$(fs_bytes "$ROOT_DEV")"
  VG_FREE_B="$(vg_free_bytes)"

  # A filesystem is always a little smaller than the thing holding it —
  # metadata, journal, reserved blocks. Only a real gap is worth reporting.
  SLACK=$((1024 * 1024 * 1024))

  echo ""
  info "Disk space — whether the root filesystem is actually using the whole drive"
  echo "     Ubuntu's installer, left on its defaults, gives the root volume a"
  echo "     fixed size and leaves the rest of the drive unallocated. On a 2TB"
  echo "     disk that is a 100G root and no sign anything is wrong: lsblk shows"
  echo "     the whole drive, df shows 100G, and the two are never seen together"
  echo "     until the day it fills up. Growing a virtual disk at the provider"
  echo "     leaves the same shape."
  echo ""
  echo "     drive:        $(human_bytes "$DISK_B")   ${ROOT_DISK}"
  echo "     volume:       $(human_bytes "$CONT_B")   ${ROOT_DEV}"
  echo "     filesystem:   $(human_bytes "$FS_B")   $(root_fstype), mounted at /"
  ((VG_FREE_B > SLACK)) && echo "     unused in LVM: $(human_bytes "$VG_FREE_B")"

  # growpart is the authority on whether a partition can move, but installing a
  # package just to ask is too eager — the arithmetic decides whether it is even
  # worth looking.
  MIGHT_GROW=false
  ((DISK_B - CONT_B > SLACK)) && MIGHT_GROW=true
  [[ -n "$ROOT_PART" ]] && $MIGHT_GROW && ensure_growpart

  if ((VG_FREE_B > SLACK)); then
    echo ""
    echo "     $(human_bytes "$VG_FREE_B") is sitting unallocated in the volume group"
    echo "     the fix is lvextend, then growing the filesystem into it — both online"
    if confirm "Use it?"; then
      grow_lv && grow_fs
      ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
      SUMMARY+=("Disk: reclaimed $(human_bytes "$VG_FREE_B") from the volume group")
    else
      warn "skipped by request"
      SUMMARY+=("Disk: SKIPPED — $(human_bytes "$VG_FREE_B") left unallocated")
    fi

  elif [[ -n "$ROOT_PART" ]] && partition_can_grow "$ROOT_PART"; then
    echo ""
    echo "     the partition stops short of the end of the drive"
    echo "     this is the one step that edits the partition table — it only ever"
    echo "     moves the end of ${ROOT_PART} outwards, and never touches another one"
    if confirm "Extend it?"; then
      grow_partition "$ROOT_PART"
      if root_is_lvm; then
        grow_pv "$ROOT_PART"
        grow_lv
      fi
      grow_fs
      ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
      SUMMARY+=("Disk: partition extended, filesystem now $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
    else
      warn "skipped by request"
      SUMMARY+=("Disk: SKIPPED — partition left short of the drive")
    fi

  elif ((FS_B > 0 && CONT_B - FS_B > SLACK)); then
    echo ""
    echo "     the filesystem is smaller than the volume holding it"
    if confirm "Grow it?"; then
      grow_fs
      ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
      SUMMARY+=("Disk: filesystem grown to $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
    else
      warn "skipped by request"
      SUMMARY+=("Disk: SKIPPED — filesystem left short of its volume")
    fi

  else
    echo ""
    echo "     the whole drive is in use, nothing to reclaim"
    SUMMARY+=("Disk: already using the whole drive")
  fi
  step_ok
fi

# =============================================================================
# 4. System update
# =============================================================================
#
# Its own section because it is the only thing in the script that moves versions
# of software already on the machine. Everything else only ever adds what is
# absent, so this is the one that deserves to be refused on its own.
#
# The index refresh is NOT here — it runs in pre-flight, unconditionally, because
# every later step reads it and this one can be skipped.

step "System update"
if ! skip; then
  mapfile -t UPGRADABLE < <(pkg_upgradable)

  echo ""
  info "System update — upgrades installed packages to their latest versions"

  if ((${#UPGRADABLE[@]} == 0)); then
    echo "     to upgrade:   nothing, everything is current"
    SUMMARY+=("System update: already up to date")
  else
    echo "     to upgrade:   ${#UPGRADABLE[@]} package(s)"
    # Capped, because a box that has not been touched in months lists hundreds
    # and a wall of names is no more informative than a count.
    printf '       %s\n' "${UPGRADABLE[@]:0:25}"
    ((${#UPGRADABLE[@]} > 25)) && echo "       … and $((${#UPGRADABLE[@]} - 25)) more"

    if confirm "Proceed?"; then
      pkg_upgrade_all
      ok "System upgraded"
      SUMMARY+=("System upgraded: ${#UPGRADABLE[@]} package(s)")
    else
      warn "skipped by request"
      SUMMARY+=("System update: SKIPPED by request — ${#UPGRADABLE[@]} package(s) left as they are")
    fi
  fi
  step_ok
fi

# =============================================================================
# 5. Core utils
# =============================================================================
#
# What the distribution provides: the six this script would break without, and
# the command-line tools that make a machine worth sitting at.

# macOS's compilers, before anything that might need to build a native module.
if [[ "$OS" == "macos" ]]; then
  step "Xcode command line tools"
  if ! skip; then
    echo ""
    if xcode_clt_installed; then
      ok "already installed ($(xcode-select -p))"
      SUMMARY+=("Xcode CLT: already installed")
    else
      info "Xcode command line tools — macOS's compilers"
      echo "     Needed because node-pty ships no prebuilt binary and compiles"
      echo "     from source on every machine, so 'bun install' cannot finish"
      echo "     without a compiler."
      echo ""
      if confirm "Start the install?"; then
        xcode_clt_install
        warn "a macOS dialogue has opened — finish it there, then re-run this step"
        echo "       ./machine-setup.sh --only 'Xcode command line tools'"
        SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run")
      else
        warn "skipped — 'bun install' will fail on node-pty without it"
        SUMMARY+=("Xcode CLT: SKIPPED by request")
      fi
    fi
    step_ok
  fi
fi

step "Core utils"
if ! skip; then
  # shellcheck disable=SC2046  # word splitting is how the list is passed
  pkg_install "Core utils" $(pkgs_core)
  summarise_last "Core utils"
  step_ok
fi

# =============================================================================
# 6. Tailscale
# =============================================================================
#
# Placed here, before everything that can go wrong, because it is a second way
# into the machine. It needs curl, so it cannot come before core utils; it wants
# to come before SSH hardening, which is the step that can lock you out.

step "Tailscale"
if ! skip; then
  echo ""
  info "Tailscale — a private network between your machines"

  if ! tailscale_is_installed; then
    echo "     not installed on this machine"
    echo ""
    if confirm "Install Tailscale?" y tailscale_help; then
      tailscale_install
      tailscale_is_installed && ok "$(tailscale version 2>/dev/null | head -1) installed"
    else
      warn "skipped by request"
      SUMMARY+=("Tailscale: SKIPPED by request")
    fi
  fi

  if tailscale_is_installed; then
    TS_STATE="$(tailscale_state)"
    TS_URL_NOW="$(tailscale_control_url)"

    echo ""
    echo "     state:         ${TS_STATE:-unknown}"
    echo "     control plane: ${TS_URL_NOW:-tailscale.com (the default service)}"
    [[ "$TS_STATE" == "Running" ]] && echo "     this machine:  $(tailscale_ip) ($(hostname))"

    TS_CONNECT=true
    if [[ "$TS_STATE" == "Running" ]]; then
      echo ""
      echo "     Already connected. Reconnecting is only needed to change the"
      echo "     control plane or what this node advertises."
      confirm "Reconfigure it?" n tailscale_help || TS_CONNECT=false
    fi

    if [[ "$TS_CONNECT" == false ]]; then
      SUMMARY+=("Tailscale: connected, unchanged ($(tailscale_ip))")
    else
      echo ""
      tailscale_network_menu

      TS_LOGIN_SERVER=""
      TS_PLANE=""
      while [[ -z "$TS_PLANE" ]]; do
        if ! read -rp "  Which one? (1/2/3/4/?): " TS_PLANE_CHOICE; then
          echo ""
          fail "No answer."
        fi
        case "$TS_PLANE_CHOICE" in
          1 | 2)
            # Both end in the same place: a coordination server somebody runs.
            # The difference is only whether they already have one, which changes
            # what to say, not what to do.
            if [[ "$TS_PLANE_CHOICE" == "1" ]]; then
              echo ""
              echo "     offscale — just like headscale, and just like Tailscale's own"
              echo "     service — needs to run on a publicly reachable server of its"
              echo "     own. Not this machine, and not behind a home router. Work"
              echo "     through the whole setup there first:"
              echo ""
              echo "       https://officer.dev/infrastructure/offscale.html#install"
              echo ""
              echo "     Then come back here with its address and a key. Nothing below"
              echo "     will work until that server is up and answering."
              echo ""
              echo "     Take as long as you need. Stop this script whenever you like"
              echo "     — Ctrl-C is fine — and run it again when you are ready. Every"
              echo "     step already done is remembered and skipped, and the answers"
              echo "     you have given are kept, so you will come back to this same"
              echo "     question and nothing before it."
            fi
            # No default offered. A coordination server URL is somebody's private
            # infrastructure, and a machine that joins the wrong one has joined a
            # stranger's network.
            read -rp "  Address of your server (e.g. https://headscale.example.com): " TS_LOGIN_SERVER || fail "No answer."
            if [[ "$TS_LOGIN_SERVER" =~ ^https?:// ]]; then
              TS_PLANE="self-hosted"
            else
              warn "That needs to be a full URL, starting with https://"
            fi
            ;;
          3) TS_PLANE="tailscale" ;;
          4)
            echo ""
            warn "No private network — do this at your own risk."
            echo ""
            tailscale_none_warning | page
            echo ""
            if confirm "Continue with no private network?" n; then
              TS_PLANE="none"
            else
              echo ""
              tailscale_network_menu
            fi
            ;;
          *) warn "Pick 1, 2, 3, 4 or ?." ;;
        esac
      done

      if [[ "$TS_PLANE" == "none" ]]; then
        warn "no private network — Tailscale is installed but not connected"
        echo "     Connect it later with:  sudo tailscale up"
        echo "     Remember an HTTPS proxy in front of Officer, and restrict who can reach it."
        SUMMARY+=("Tailscale: NOT connected by choice — no private network, so the token is the only lock")
        TS_CONNECT=false
      fi

      if [[ "$TS_CONNECT" != false ]]; then
      echo ""
      info "How should this machine authenticate?"
      if [[ "$TS_PLANE" == "tailscale" ]]; then
        echo "     Leave this blank and Tailscale prints a link to open in a"
        echo "     browser. Signing in there creates the account if you do not"
        echo "     have one, or adds this machine to it if you do."
        echo "     An auth key, if you have minted one, skips the browser."
      else
        echo "     An auth key enrols this machine without a browser. Leaving it"
        echo "     blank prints a link to open against your own server instead."
      fi
      echo ""
      read -rp "  Auth key (blank for the browser flow): " TS_AUTHKEY || fail "No answer."

      echo ""
      info "What should this machine offer the tailnet?"
      echo ""
      echo "     Tailscale SSH — ssh to this machine over the tailnet with no keys"
      echo "     at all; who may connect is decided by your tailnet's ACLs rather"
      echo "     than by authorized_keys. Independent of the sshd hardening later"
      echo "     in this run, and a useful way back in if that goes wrong."
      TS_SSH=""
      confirm "Enable Tailscale SSH?" n && TS_SSH="--ssh"

      TS_ROUTES=""
      if is_role homelab; then
        echo ""
        echo "     Subnet router — makes this machine a door onto its LAN, so"
        echo "     every tailnet device can reach the printers, NAS and switches"
        echo "     here without each of them running Tailscale."
        LAN="$(lan_cidr)"
        if [[ -n "$LAN" ]] && confirm "Advertise ${LAN} to the tailnet?" n; then
          TS_ROUTES="--advertise-routes=${LAN}"
        fi
      fi

      echo ""
      echo "     Exit node — lets other tailnet devices send ALL their internet"
      echo "     traffic out through this machine, as a VPN would. Useful from a"
      echo "     phone on hostile wifi; it means this machine's connection carries"
      echo "     their traffic, and their browsing exits from this IP."
      TS_EXIT=""
      confirm "Advertise as an exit node?" n && TS_EXIT="--advertise-exit-node"

      if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then
        echo ""
        info "     forwarding other machines' packets needs routing enabled — writing ${TS_EXIT_SYSCTL}"
        enable_ip_forwarding
        info "     applying Tailscale's recommended NIC offload settings (roughly doubles forwarding throughput)"
        install_exit_node_tuning
      fi

      # Where this node is being pointed, named explicitly in both cases — see
      # TS_DEFAULT_CONTROL_URL for why the default is not left implicit.
      if [[ "$TS_PLANE" == "self-hosted" ]]; then
        TS_TARGET_URL="$TS_LOGIN_SERVER"
      else
        TS_TARGET_URL="$TS_DEFAULT_CONTROL_URL"
      fi

      # A node logged in to one coordination server cannot simply be pointed at
      # another; it has to be logged out first. Said out loud rather than done
      # quietly, because it drops the tailnet for a moment.
      TS_URL_NOW="$(tailscale_control_url)"
      if [[ "$TS_STATE" == "Running" ]] && tailscale_needs_logout "$TS_URL_NOW" "$TS_TARGET_URL"; then
        echo ""
        warn "this node is on ${TS_URL_NOW}, and moving it to ${TS_TARGET_URL} means logging out first"
        echo "     The tailnet drops while that happens. If you are connected over"
        echo "     it right now, this session goes with it."
        if confirm "Log out and move it?" n; then
          tailscale logout || true
        else
          warn "left where it is"
          SUMMARY+=("Tailscale: left on ${TS_URL_NOW}")
          TS_CONNECT=false
        fi
      fi

      echo ""
      TS_ARGS=(up --timeout=60s --login-server "$TS_TARGET_URL")
      # Never passed empty. `--authkey ""` silently falls back to the interactive
      # flow and blocks forever, which is exactly how the original hung.
      [[ -n "$TS_AUTHKEY" ]] && TS_ARGS+=(--authkey "$TS_AUTHKEY")
      [[ -n "$TS_SSH" ]] && TS_ARGS+=("$TS_SSH")
      [[ -n "$TS_ROUTES" ]] && TS_ARGS+=("$TS_ROUTES")
      [[ -n "$TS_EXIT" ]] && TS_ARGS+=("$TS_EXIT")

      if [[ -z "$TS_AUTHKEY" ]]; then
        warn "no auth key given — a URL will be printed below, and this waits for you to open it"
      fi
      echo ""

      if [[ "$TS_CONNECT" == false ]]; then
        :
      elif tailscale "${TS_ARGS[@]}"; then
        TS_IP="$(tailscale_ip)"
        ok "connected as ${TS_IP} on $(hostname)"
        SUMMARY+=("Tailscale: ${TS_IP}${TS_SSH:+, Tailscale SSH}${TS_ROUTES:+, subnet router}${TS_EXIT:+, exit node}")
        if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then
          warn "an exit node or advertised route must be approved in the admin console before it carries traffic"
        fi
      else
        # Loud rather than silent. The original had no timeout at all, so a
        # failure to authenticate looked like the script having frozen.
        warn "tailscale up did not complete within 60s"
        echo "     Run it by hand to see what it is waiting for:  tailscale up"
        ERRORS+=("Tailscale: up did not complete")
        SUMMARY+=("Tailscale: NOT connected")
      fi
      fi
    fi
  fi
  step_ok
fi

# =============================================================================
# 7. Command-line tools
# =============================================================================
#
# A different thing from core utils, and kept apart from them: upstream binaries
# fetched from upstream, on their own release cadence, none of which the
# distribution ships. Lumping them in made a run look like it was installing
# system packages and then start pulling tarballs unannounced.

step "Command-line tools"
if ! skip; then
  # shellcheck disable=SC2046  # word splitting is how the list is passed
  tools_install "Command-line tools" $(tools_default)
  summarise_last "Command-line tools"
  step_ok
fi


# =============================================================================
# 8. Locale
# =============================================================================
#
# LOCALE in the environment overrides the default.

step "Locale"
if ! skip; then
  LOCALE="${LOCALE:-en_US.UTF-8}"
  CURRENT_LOCALE="$(locale_current)"

  echo ""
  info "Locale — the system language and character encoding"
  echo "     current:      ${CURRENT_LOCALE:-none configured}"
  echo "     to set:       ${LOCALE}"

  if [[ "$CURRENT_LOCALE" == "$LOCALE" ]] && locale_is_generated "$LOCALE"; then
    echo "     already set and generated, nothing to do"
    SUMMARY+=("Locale: already ${LOCALE}")
  else
    # Say which of the two is actually wrong, since they fail differently: a
    # missing LANG means the C locale, a missing generation means every login
    # prints a setlocale warning.
    [[ "$CURRENT_LOCALE" != "$LOCALE" ]] && echo "     LANG is not set to it"
    locale_is_generated "$LOCALE" || echo "     the locale has not been generated on this machine"

    if confirm "Proceed?"; then
      locale_set "$LOCALE"
      ok "Locale set to ${LOCALE}"
      SUMMARY+=("Locale: ${LOCALE}")
    else
      warn "skipped by request"
      SUMMARY+=("Locale: SKIPPED by request — left at ${CURRENT_LOCALE:-unset}")
    fi
  fi
  step_ok
fi

# =============================================================================
# 9. Timezone
# =============================================================================
#
# TIMEZONE in the environment answers the prompt ahead of time.

step "Timezone"
if ! skip; then
  CURRENT_TZ="$(timezone_current)"

  echo ""
  info "Timezone — what logs, timers and every printed date are relative to"
  echo "     current:      ${CURRENT_TZ:-unknown}"

  if [[ -z "${TIMEZONE:-}" ]]; then
    echo ""
    for i in "${!TZ_OPTIONS[@]}"; do
      printf '     [%d] %s\n' "$((i + 1))" "${TZ_OPTIONS[$i]}"
    done
    echo ""

    while [[ -z "${TIMEZONE:-}" ]]; do
      # Unattended keeps the current zone, which is what Enter does here. TIMEZONE=<zone>
      # in the environment answers it ahead of time and skips this block entirely.
      menu_answer TZ_CHOICE "  Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: "

      if [[ -z "$TZ_CHOICE" ]]; then
        TIMEZONE="$CURRENT_TZ"
      elif [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]]; then
        if ((TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]})); then
          TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE - 1))]}"
        else
          warn "There is no option ${TZ_CHOICE}."
        fi
      else
        # Validated here rather than left to timedatectl, which fails on an
        # unknown name and would take the whole run down over a typo.
        if timezone_is_valid "$TZ_CHOICE"; then
          TIMEZONE="$TZ_CHOICE"
        else
          warn "Not a zone this machine knows: '${TZ_CHOICE}' — try e.g. Europe/Berlin"
        fi
      fi
    done
  elif ! timezone_is_valid "$TIMEZONE"; then
    fail "TIMEZONE='${TIMEZONE}' is not a zone this machine knows."
  fi

  if [[ "$TIMEZONE" == "$CURRENT_TZ" ]]; then
    echo "     keeping ${CURRENT_TZ}, nothing to do"
    SUMMARY+=("Timezone: already ${CURRENT_TZ}")
  else
    echo "     to set:       ${TIMEZONE}"
    if confirm "Proceed?"; then
      timezone_set "$TIMEZONE"
      ok "Timezone set to ${TIMEZONE}"
      SUMMARY+=("Timezone: ${TIMEZONE}")
    else
      warn "skipped by request"
      SUMMARY+=("Timezone: SKIPPED by request — left at ${CURRENT_TZ:-unknown}")
    fi
  fi
  step_ok
fi

# =============================================================================
# 10. Swap
# =============================================================================
#
# Disk the kernel can park cold pages on when RAM fills, so a spike costs
# latency instead of a process. A `bun install` or a Docker build on a small
# machine is exactly the spike this is for.

step "Swap"
if ! skip; then
  ACTIVE_SWAP_GB="$(swap_active_gb)"
  WANT_SWAP_GB="$(swap_recommended_gb)"
  SWAPPINESS="$(swappiness_for_role)"
  CURRENT_SWAPPINESS="$(sysctl -n vm.swappiness 2>/dev/null || echo unknown)"

  echo ""
  info "Swap — overflow space so a memory spike costs speed rather than a process"
  echo "     RAM:          $(ram_gb)G"
  echo "     active swap:  ${ACTIVE_SWAP_GB}G"
  echo "     swappiness:   ${CURRENT_SWAPPINESS} -> ${SWAPPINESS} (${MACHINE_ROLE})"

  if [[ "$IS_WSL" == true ]]; then
    # WSL2 runs its own managed swap inside the VM; a swapfile here is wasted
    # disk and is not what the kernel would use anyway.
    echo "     WSL manages its own swap — leaving it alone"
    SUMMARY+=("Swap: left to WSL")
  elif ((ACTIVE_SWAP_GB > 0)); then
    echo "     already has ${ACTIVE_SWAP_GB}G of swap, leaving it alone"
    if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]] && confirm "Set swappiness to ${SWAPPINESS}?"; then
      swappiness_set "$SWAPPINESS"
      ok "swappiness is now $(sysctl -n vm.swappiness)"
      SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}")
    else
      SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G")
    fi
  elif ((WANT_SWAP_GB == 0)); then
    # Capped to nothing by the disk check rather than by choice.
    warn "not enough free disk to add swap safely — $(disk_free_gb)G free"
    SUMMARY+=("Swap: none added, disk too full")
  else
    echo "     to create:    ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)"
    if confirm "Proceed?"; then
      swap_create "$WANT_SWAP_GB"
      swappiness_set "$SWAPPINESS"
      ok "$(swap_active_gb)G swap active, swappiness $(sysctl -n vm.swappiness)"
      SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}")
    else
      warn "skipped by request"
      SUMMARY+=("Swap: SKIPPED by request")
    fi
  fi
  step_ok
fi

# =============================================================================
# 11. Emergency disk ballast
# =============================================================================
#
# Always offered, whatever the role — the role only decides which way the
# recommendation points.

step "Emergency disk ballast"
# Servers only. On a machine you sit at, the disk filling up announces itself —
# the editor refuses to save, the browser complains — and you are there to deal
# with it. The reserve is for the box nobody is watching, where the first sign is
# a service that stopped working hours ago. Skipped rather than offered, but said
# out loud, so a silent gap in the run is never left unexplained.
if ! skip && ! is_server; then
  echo ""
  info "Emergency disk ballast — not offered on a ${MACHINE_ROLE} machine"
  echo "     The reserve is for a box nobody is watching. You are sitting at"
  echo "     this one, so a filling disk tells you itself."
  SUMMARY+=("Disk ballast: not applicable on ${MACHINE_ROLE}")
  step_ok
elif ! skip; then
  echo ""
  info "Emergency disk ballast — a reserve you can burn when the disk fills up"
  echo "     A file holding nothing, whose only job is to be deleted. A root cron"
  echo "     checks every 10 minutes and removes it if free space drops below"
  echo "     ${BALLAST_THRESHOLD}%, which buys you room to log in and clean up rather than"
  echo "     meeting a wedged machine — Docker, journald and postgres all"
  echo "     misbehave badly at 100% full, and not all of them recover on their"
  echo "     own."
  echo ""
  echo "     It is a one-shot valve: once spent, run this again to recreate it."

  if [[ -f "${USER_HOME}/${BALLAST_NAME}" ]]; then BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}"; fi
  [[ -f "${OFFICER_ROOT}/${BALLAST_NAME}" ]] && BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}"

  if ballast_exists; then
    echo ""
    echo "     already present: $(ballast_size_human) at ${BALLAST_FILE}"
    SUMMARY+=("Disk ballast: already present ($(ballast_size_human))")
  elif ! confirm "Create one?"; then
    warn "skipped by request"
    SUMMARY+=("Disk ballast: SKIPPED by request")
  else
    # ── where ──
    #
    # A ballast only protects the filesystem it sits on, because the checker
    # measures its own directory. So this is also a choice of which mount is
    # being protected, not just where the file is tidiest.
    echo ""
    info "Where should it go?"
    echo "     [1] ${USER_HOME}/${BALLAST_NAME}"
    echo "         your home — easiest to find again months from now"
    echo "     [2] ${OFFICER_ROOT}/${BALLAST_NAME}"
    echo "         beside Officer — same place as everything else it owns"
    echo "     [3] somewhere else, typed in"
    echo ""

    BALLAST_FILE=""
    while [[ -z "$BALLAST_FILE" ]]; do
      menu_answer BALLAST_WHERE "  Which one? (1/2/3) [1]: "
      case "${BALLAST_WHERE:-1}" in
        1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
        2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
        3)
          read -rp "  Full path to the file: " BALLAST_TYPED || fail "No answer."
          BALLAST_TYPED="${BALLAST_TYPED/#\~/$USER_HOME}"
          if [[ "$BALLAST_TYPED" == /* ]]; then
            BALLAST_FILE="$BALLAST_TYPED"
          else
            warn "That needs to be an absolute path, starting with /"
          fi
          ;;
        *) warn "Pick 1, 2 or 3." ;;
      esac
    done

    # ── how much ──
    #
    # Percentages mean nothing without the numbers behind them, and the number
    # that matters is what is LEFT — the point of the reserve is to be big enough
    # to matter and small enough not to be the thing that filled the disk.
    BALLAST_FREE_KB="$(ballast_free_kb "$(dirname "$BALLAST_FILE")")"

    echo ""
    info "How much? ${BALLAST_FILE} sits on a filesystem with $(human_bytes $((BALLAST_FREE_KB * 1024))) free."
    for i in 1 2 3; do
      case $i in
        1) BP=5 ;;
        2) BP=10 ;;
        3) BP=20 ;;
      esac
      BSZ=$((BALLAST_FREE_KB * BP / 100))
      printf '     [%d] %3d%%  — reserves %-8s leaving %s free\n' \
        "$i" "$BP" "$(human_bytes $((BSZ * 1024)))" "$(human_bytes $(((BALLAST_FREE_KB - BSZ) * 1024)))"
    done
    echo ""

    BALLAST_PCT=""
    while [[ -z "$BALLAST_PCT" ]]; do
      menu_answer BALLAST_SIZE_CHOICE "  Which one? (1/2/3) [2]: "
      case "${BALLAST_SIZE_CHOICE:-2}" in
        1) BALLAST_PCT=5 ;;
        2) BALLAST_PCT=10 ;;
        3) BALLAST_PCT=20 ;;
        *) warn "Pick 1, 2 or 3." ;;
      esac
    done

    BALLAST_MB=$((BALLAST_FREE_KB * BALLAST_PCT / 100 / 1024))
    ballast_create "$BALLAST_MB"
    ballast_install_checker
    ok "ballast $(ballast_size_human) at ${BALLAST_FILE}, checked every 10 minutes"
    ok "status any time: ${BALLAST_CHECKER} --status"
    SUMMARY+=("Disk ballast: $(ballast_size_human) at ${BALLAST_FILE} (${BALLAST_PCT}%)")
  fi
  step_ok
fi

# =============================================================================
# 12. earlyoom
# =============================================================================

step "earlyoom"
if ! skip; then
  echo ""
  info "earlyoom — keeps the machine reachable when it runs out of memory"
  echo "     The kernel's own OOM killer waits until an allocation actually"
  echo "     fails, and by then the machine has usually spent minutes thrashing:"
  echo "     unresponsive, ssh refusing to connect, nothing to do but reset it."
  echo "     earlyoom watches free memory and kills the biggest consumer while"
  echo "     there is still enough left to stay logged in."
  echo ""
  echo "     This is what happens after swap runs out, so the two go together."

  if earlyoom_is_active; then
    echo "     already installed and running"
    SUMMARY+=("earlyoom: already running")
  elif confirm "Install it?"; then
    earlyoom_install
    if earlyoom_is_active; then
      ok "earlyoom running"
      SUMMARY+=("earlyoom: installed and running")
    else
      warn "earlyoom installed but not running — check: systemctl status earlyoom"
      SUMMARY+=("earlyoom: installed, not running")
    fi
  else
    warn "skipped by request"
    SUMMARY+=("earlyoom: SKIPPED by request")
  fi
  step_ok
fi

# =============================================================================
# 13. inotify watch limit
# =============================================================================

step "inotify watch limit"
if ! skip; then
  CURRENT_WATCHES="$(inotify_current_watches)"

  echo ""
  info "inotify watch limit — how many files can be watched for changes at once"
  echo "     A single file watcher walking a project with node_modules in it can"
  echo "     exhaust the stock limit on its own, and every watcher on the machine"
  echo "     draws from the same pool. The failure is silent: nothing errors, the"
  echo "     watcher just stops noticing changes. Hot reload goes quiet, a build"
  echo "     stops rebuilding, and the reason is never on screen."
  echo ""
  echo "     current:      ${CURRENT_WATCHES}"
  echo "     to set:       ${INOTIFY_WATCHES}"
  if is_role dev; then
    echo "     recommended on dev — editors, bun --watch and vite are all watchers"
  else
    echo "     less pressing on ${MACHINE_ROLE}, but anything running bun --watch or"
    echo "     serving a file browser is a watcher too"
  fi

  if ((CURRENT_WATCHES >= INOTIFY_WATCHES)); then
    echo "     already at or above that, nothing to do"
    SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}")
  elif confirm "Raise it?"; then
    inotify_raise
    ok "inotify watches raised to $(inotify_current_watches)"
    SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}")
  else
    warn "skipped by request"
    SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}")
  fi
  step_ok
fi

# =============================================================================
# 14. Sleep and suspend
# =============================================================================

step "Sleep and suspend"
# Homelab only, and the two exclusions are for different reasons.
#
# dev: a laptop should sleep. Disabling it on the machine somebody carries is how
# you get a hot bag and a flat battery.
#
# vps: not merely unnecessary but harmful. A virtual machine has no lid and no
# power button, but the provider's "Shut down" control works by sending an ACPI
# power button event — HandlePowerKey=ignore makes the VM ignore it, so a
# graceful shutdown request does nothing and the provider hard-kills the instance
# instead. systemd defaults that key to poweroff for exactly this reason.
if ! skip && is_role dev; then
  echo ""
  info "Sleep and suspend — left alone on a ${MACHINE_ROLE} machine"
  echo "     Suspending is what a machine you carry should do. Only a server has"
  echo "     to be stopped from it."
  SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE}")
  step_ok
elif ! skip && is_role vps; then
  echo ""
  info "Sleep and suspend — left alone on a ${MACHINE_ROLE}"
  echo "     A virtual machine has no lid and no power button to disable. What it"
  echo "     does have is the provider's Shut down control, which works by sending"
  echo "     an ACPI power button event — telling this machine to ignore that would"
  echo "     mean graceful shutdowns silently do nothing and the instance gets"
  echo "     hard-killed instead."
  SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE} — would break provider shutdown")
  step_ok
elif ! skip; then
  echo ""
  info "Sleep and suspend — stop this machine putting itself to sleep"
  echo "     A server that suspends is a server that is off: it stops answering,"
  echo "     and with no keyboard attached there is nothing to wake it. It looks"
  echo "     like a crash and the fix is a physical visit."
  echo ""
  echo "     sleep targets:  $(sleep_targets_masked && echo 'masked' || echo 'available')"
  echo "     lid closed:     $(logind_effective HandleLidSwitch || echo 'default (suspend)')"
  echo "     idle:           $(logind_effective IdleAction || echo 'default (ignore)')"
  echo "     power button:   $(logind_effective HandlePowerKey || echo 'default (power off)')"

  if [[ "$IS_WSL" == true ]]; then
    echo "     WSL has no logind and cannot suspend — nothing to do"
    SUMMARY+=("Sleep: not applicable under WSL")
  elif sleep_targets_masked && logind_is_configured; then
    echo "     already configured, nothing to do"
    SUMMARY+=("Sleep: already disabled")
  else
    echo ""
    echo "     This masks the four sleep targets and tells logind to ignore a"
    echo "     closed lid, an idle session and the power button. Note the last"
    echo "     one: after this, physically pressing power does nothing, so a"
    echo "     clean shutdown is 'sudo poweroff' rather than the button."
    if confirm "Proceed?"; then
      disable_sleep
      # Verified rather than asserted: disable_sleep returns 0 whatever happens,
      # so that a failed logind restart cannot abort the remaining sections. The
      # check is what turns that into an honest report.
      if sleep_targets_masked && logind_is_configured; then
        ok "sleep disabled, logind reloaded"
        SUMMARY+=("Sleep: disabled (targets masked, logind handlers ignored)")
      else
        warn "sleep settings were written but are not all in force — check: systemctl status systemd-logind"
        ERRORS+=("Sleep: settings written but not in force")
        SUMMARY+=("Sleep: written, NOT fully in force")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("Sleep: SKIPPED by request")
    fi
  fi
  step_ok
fi

# =============================================================================
# 15. Boot hang
# =============================================================================

step "Boot hang"
# Not on a VPS. There the network IS systemd-networkd — cloud-init and netplan
# put it in charge — so the unit below is load-bearing and completes in
# milliseconds. Masking it on that stack is how a box comes up with no network
# and no ssh.
if ! skip && is_role vps; then
  echo ""
  info "Boot hang — not applicable on a ${MACHINE_ROLE}"
  echo "     systemd-networkd is what configures the network here, so the unit"
  echo "     this would mask is doing real work."
  SUMMARY+=("Boot hang: not applicable on ${MACHINE_ROLE}")
  step_ok
elif ! skip; then
  WAIT_ONLINE_TIME="$(wait_online_boot_time)"

  echo ""
  info "Boot hang — a unit that can add two minutes to every boot"
  echo "     On a machine where NetworkManager owns the network, systemd-networkd"
  echo "     runs nothing — but systemd-networkd-wait-online is still enabled, and"
  echo "     waits for a link that will never come up. It gives up after its full"
  echo "     timeout, every single boot."
  echo ""
  echo "     network managed by:  $(network_manager_name)"
  echo "     that unit took:      ${WAIT_ONLINE_TIME:-it did not run}  on this boot"

  if ! wait_online_is_spurious; then
    echo ""
    echo "     systemd-networkd is live here, so that unit is doing real work."
    echo "     Masking it would be how this machine comes up with no network."
    SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
  else
    echo ""
    if is_role dev; then
      echo "     Only worth doing if you actually feel this — if the machine sits"
      echo "     there for a couple of minutes before the login screen. The number"
      echo "     above is the honest answer for this boot; if it is small, there is"
      echo "     nothing here for you."
    fi
    echo "     This masks systemd-networkd-wait-online.service only. NetworkManager"
    echo "     keeps configuring the network exactly as it does now."
    if confirm "Mask it?"; then
      mask_wait_online
      ok "systemd-networkd-wait-online masked"
      SUMMARY+=("Boot hang: wait-online masked (NetworkManager stack)")
    else
      warn "skipped by request"
      SUMMARY+=("Boot hang: SKIPPED by request")
    fi
  fi
  step_ok
fi

# =============================================================================
# 16. SSH access
# =============================================================================
#
# Keys and hardening in one section, deliberately. They were two in the original,
# and being two is what let the second one lock you out of a machine the first
# one had failed to put a key on.

step "SSH access"
if ! skip; then
  KEY_COUNT="$(authorized_key_count)"

  echo ""
  info "SSH access — how you get into this machine"
  echo "     authorised keys for ${USERNAME}:  ${KEY_COUNT}"
  echo "     password login:                $(sshd_effective passwordauthentication)"
  echo "     root login:                    $(sshd_effective permitrootlogin)"

  # ── a key first ──
  #
  # The same menu whether or not there is already one, because "add another" is a
  # real need — a second laptop, a rebuilt machine — and the original had no way
  # to do it at all. What changes is whether the question can be declined: with no
  # key, declining means the hardening below will refuse too, and the run says so.
  ADD_KEY=false
  if ((KEY_COUNT == 0)); then
    echo ""
    warn "${USERNAME} has no authorised key. Password login cannot be turned off until it has one."
    ADD_KEY=true
  else
    fix_ssh_permissions
    echo ""
    ADD_KEY=false
    confirm "Add another authorised key for ${USERNAME}?" n && ADD_KEY=true
  fi

  if $ADD_KEY; then
    echo ""
    echo "     [1] paste a public key   (one line, from your own ~/.ssh/id_ed25519.pub)"
    echo "     [2] generate a new keypair on this machine"
    echo "     [3] leave it for now"
    echo ""

    SSH_KEY_DONE=false
    while [[ "$SSH_KEY_DONE" == false ]]; do
      if ! read -rp "  Which one? (1/2/3) [1]: " SSH_KEY_CHOICE; then
        echo ""
        fail "No answer."
      fi
      case "${SSH_KEY_CHOICE:-1}" in
        1)
          read -rp "  Paste the public key: " SSH_PASTED || fail "No answer."
          # Trimmed: pasting from a terminal or a password manager routinely
          # brings leading or trailing whitespace, and ssh-keygen will not parse
          # a key with it attached.
          SSH_PASTED="${SSH_PASTED#"${SSH_PASTED%%[![:space:]]*}"}"
          SSH_PASTED="${SSH_PASTED%"${SSH_PASTED##*[![:space:]]}"}"
          add_authorized_key "$SSH_PASTED" && SSH_KEY_DONE=true
          ;;
        2)
          generate_user_key "${USERNAME}@$(hostname)"
          ok "keypair generated and authorised"
          echo ""
          echo "     The PRIVATE key is on this machine at ${USER_HOME}/.ssh/id_ed25519."
          echo "     Copy it to the machine you connect FROM, then delete it here —"
          echo "     a private key that lives on the server it opens is not a"
          echo "     second factor, it is a spare copy of the lock."
          SSH_KEY_DONE=true
          ;;
        3) SSH_KEY_DONE=true ;;
        *) warn "Pick 1, 2 or 3." ;;
      esac
    done
    KEY_COUNT="$(authorized_key_count)"
  fi

  # ── then hardening, and only then ──
  if [[ "$(sshd_effective passwordauthentication)" == "no" && "$(sshd_effective permitrootlogin)" == "no" ]]; then
    echo ""
    echo "     already hardened, nothing to do"
    SUMMARY+=("SSH: already hardened, ${KEY_COUNT} key(s) for ${USERNAME}")
  elif ((KEY_COUNT == 0)); then
    echo ""
    warn "not hardening: ${USERNAME} still has no authorised key"
    echo "     Turning off password login now would leave no way in at all. Add a"
    echo "     key and run this again."
    SUMMARY+=("SSH: NOT hardened — no key for ${USERNAME}, password login left on")
  else
    echo ""
    info "Harden sshd?"
    echo "     Turns off password login, keyboard-interactive login and root"
    echo "     login. ${USERNAME} has ${KEY_COUNT} authorised key(s), so you keep a way in."
    echo ""
    echo "     Written to ${SSHD_DROPIN} rather than sshd_config, and named 01- so"
    echo "     it is read before the cloud-init drop-in that would otherwise win."
    echo "     Checked with 'sshd -t' before anything is reloaded, and reloaded"
    echo "     rather than restarted so this session is not the experiment."
    echo ""
    warn "Test a new ssh session before closing this one."
    if confirm "Proceed?"; then
      if harden_sshd; then
        ok "password login: $(sshd_effective passwordauthentication), root login: $(sshd_effective permitrootlogin)"
        SUMMARY+=("SSH: hardened (password and root login off, ${KEY_COUNT} key(s))")
      else
        SUMMARY+=("SSH: hardening FAILED — sshd rejected the config, nothing changed")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("SSH: SKIPPED by request — password login left on")
    fi
  fi
  step_ok
fi

# =============================================================================
# 17. DNS
# =============================================================================

step "DNS"
if ! skip; then
  CURRENT_DNS="$(dns_current_global)"

  echo ""
  info "DNS — which resolver answers public lookups"
  echo "     global resolver:  ${CURRENT_DNS:-none set, using whatever DHCP gave}"

  # Shown so it is clear what this step is NOT doing. Overriding these is how a
  # provider's internal names, or the tailnet, quietly stop resolving.
  if [[ -n "$(dns_per_link)" ]]; then
    echo ""
    echo "     per-interface, left untouched:"
    dns_per_link | while IFS=: read -r link servers; do
      printf '       %-14s %s\n' "$link" "$(echo "$servers" | xargs)"
    done
  fi

  echo ""
  echo "     Whoever runs this resolver sees every name this machine looks up."
  echo "     [1] keep what is there"
  echo "     [2] Cloudflare   1.1.1.1        fast, logs for 24h"
  echo "     [3] Quad9        9.9.9.9        blocks known-malicious domains"
  echo "     [4] Google       8.8.8.8        fast, ubiquitous"
  echo "     [5] type your own"
  echo ""

  DNS_PRIMARY=""
  DNS_FALLBACK=""
  DNS_CHOSEN=""
  while [[ -z "$DNS_CHOSEN" ]]; do
    menu_answer DNS_CHOICE "  Which one? (1-5) [1]: "
    case "${DNS_CHOICE:-1}" in
      1) DNS_CHOSEN="keep" ;;
      2)
        DNS_CHOSEN="Cloudflare"
        DNS_PRIMARY="1.1.1.1 2606:4700:4700::1111"
        DNS_FALLBACK="1.0.0.1 2606:4700:4700::1001"
        ;;
      3)
        DNS_CHOSEN="Quad9"
        DNS_PRIMARY="9.9.9.9 2620:fe::fe"
        DNS_FALLBACK="149.112.112.112 2620:fe::9"
        ;;
      4)
        DNS_CHOSEN="Google"
        DNS_PRIMARY="8.8.8.8 2001:4860:4860::8888"
        DNS_FALLBACK="8.8.4.4 2001:4860:4860::8844"
        ;;
      5)
        read -rp "  Primary resolver(s), space separated: " DNS_PRIMARY || fail "No answer."
        read -rp "  Fallback resolver(s), or blank: " DNS_FALLBACK || fail "No answer."
        [[ -n "$DNS_PRIMARY" ]] && DNS_CHOSEN="custom" || warn "A primary resolver is needed."
        ;;
      *) warn "Pick 1 to 5." ;;
    esac
  done

  if [[ "$DNS_CHOSEN" == "keep" ]]; then
    echo "     keeping ${CURRENT_DNS:-the current configuration}"
    SUMMARY+=("DNS: unchanged")
  else
    echo ""
    echo "     to set:  ${DNS_PRIMARY}"
    [[ -n "$DNS_FALLBACK" ]] && echo "     fallback: ${DNS_FALLBACK}"
    if confirm "Proceed?"; then
      dns_set_global "$DNS_PRIMARY" "$DNS_FALLBACK"
      if dns_works; then
        ok "${DNS_CHOSEN} in use, resolution verified"
        SUMMARY+=("DNS: ${DNS_CHOSEN} (${DNS_PRIMARY%% *})")
      else
        # Reported rather than swallowed. Every step after this fetches
        # something, and they would all fail for a reason that has nothing to do
        # with them.
        warn "DNS is set but a test lookup failed — check 'resolvectl status'"
        ERRORS+=("DNS: set to ${DNS_CHOSEN} but a test lookup failed")
        SUMMARY+=("DNS: ${DNS_CHOSEN}, but resolution did not verify")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("DNS: SKIPPED by request")
    fi
  fi
  step_ok
fi

# =============================================================================
# 18. Network address
# =============================================================================
#
# Homelab only. On a vps the provider's DHCP is authoritative and already stable,
# and pinning an address there is how an instance is stranded. On dev the machine
# moves between networks and a fixed address is the opposite of what is wanted.

step "Network address"
if ! skip && ! is_role homelab; then
  echo ""
  info "Network address — left alone on a ${MACHINE_ROLE}"
  if is_role vps; then
    echo "     The provider's DHCP already hands this machine the same address,"
    echo "     and pinning one here is how an instance ends up unreachable."
  else
    echo "     A machine that moves between networks wants whatever each one"
    echo "     gives it."
  fi
  SUMMARY+=("Network address: left alone on ${MACHINE_ROLE}")
  step_ok
elif ! skip; then
  NET_IFACE="$(default_iface)"
  NET_CIDR="$(iface_ipv4 "$NET_IFACE")"
  NET_GW="$(iface_gateway)"
  NET_ID="$(dhcp_client_identifier "$NET_IFACE")"

  echo ""
  info "Network address — keeping the same IP across reboots"
  echo "     interface:    ${NET_IFACE}"
  echo "     address:      ${NET_CIDR:-unknown}   $(iface_is_dhcp "$NET_IFACE" && echo 'from DHCP' || echo 'static')"
  echo "     gateway:      ${NET_GW:-unknown}"
  echo "     identifies as: ${NET_ID:-not on DHCP}"

  if [[ "$NET_ID" == "duid" ]]; then
    echo ""
    echo "     That is why the address changes. Ubuntu identifies to DHCP by a"
    echo "     DUID, while routers key their leases and reservations on the MAC."
    echo "     The router does not recognise this machine as one it has seen, so"
    echo "     it hands out the next free address — and a reservation pinned to"
    echo "     the MAC is never matched."
  fi

  echo ""
  echo "     [1] identify by MAC instead   — DHCP keeps working, reservations start"
  echo "                                     being honoured, nothing is pinned here"
  echo "     [2] pin this address as static"
  echo "     [3] leave it alone"
  echo ""

  NET_CHOICE=""
  while [[ -z "$NET_CHOICE" ]]; do
    menu_answer NET_ANSWER "  Which one? (1/2/3) [1]: "
    case "${NET_ANSWER:-1}" in
      1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
      *) warn "Pick 1, 2 or 3." ;;
    esac
  done

  case "$NET_CHOICE" in
    1)
      if dhcp_identifier_is_mac; then
        echo "     already asked for in /etc/netplan, nothing to do"
        SUMMARY+=("Network address: already identifying by MAC")
      else
        echo ""
        echo "     Writes ${NETPLAN_DHCP_ID}, merged with what is already there."
        echo "     Takes effect at the next reboot — which is the moment the"
        echo "     problem shows up anyway, so there is nothing to apply now and"
        echo "     no risk to this session."
        echo ""
        echo "     Then reserve ${NET_CIDR%%/*} against ${NET_IFACE}'s MAC on the router,"
        echo "     and it will keep being handed back."
        if confirm "Proceed?"; then
          set_dhcp_identifier_mac "$NET_IFACE"
          if netplan_check >/dev/null 2>&1; then
            ok "written — reboot, then set the reservation on the router"
            SUMMARY+=("Network address: identifying by MAC from next boot")
          else
            warn "netplan rejected the file — removing it, nothing changed"
            rm -f "$NETPLAN_DHCP_ID"
            ERRORS+=("Network address: netplan rejected the dhcp-identifier file")
            SUMMARY+=("Network address: FAILED, netplan rejected it")
          fi
        else
          warn "skipped by request"
          SUMMARY+=("Network address: SKIPPED by request")
        fi
      fi
      ;;
    2)
      echo ""
      warn "This freezes the address this machine happens to hold right now."
      echo "     ${NET_CIDR} via ${NET_GW} on ${NET_IFACE}"
      echo ""
      echo "     If the router hands that address to something else later, both"
      echo "     end up fighting for it. A reservation on the router does the same"
      echo "     job with the router still in charge, which is option 1."
      echo "     'netplan apply' runs immediately and drops the network for a"
      echo "     moment — over ssh, a wrong value here ends the session."
      if [[ -z "$NET_CIDR" || -z "$NET_GW" ]]; then
        warn "could not read the address or gateway — not writing anything"
        SUMMARY+=("Network address: could not detect, left alone")
      elif confirm "Pin it?" n; then
        write_static_netplan "$NET_IFACE" "$NET_CIDR" "$NET_GW"
        if netplan_check >/dev/null 2>&1; then
          netplan apply
          ok "static ${NET_CIDR} on ${NET_IFACE}"
          SUMMARY+=("Network address: static ${NET_CIDR}")
        else
          warn "netplan rejected the file — removing it, nothing changed"
          rm -f "$NETPLAN_STATIC"
          ERRORS+=("Network address: netplan rejected the static config")
          SUMMARY+=("Network address: FAILED, netplan rejected it")
        fi
      else
        warn "skipped by request"
        SUMMARY+=("Network address: SKIPPED by request")
      fi
      ;;
    3)
      echo "     left alone"
      SUMMARY+=("Network address: unchanged")
      ;;
  esac
  step_ok
fi

# =============================================================================
# 19. fail2ban
# =============================================================================
#
# Installed as part of core utils rather than here — it is a distro package and
# nothing about it needs configuring. This step only reports what it is doing,
# because a daemon that silently blocks addresses is worth knowing is running.

step "fail2ban"
if ! skip; then
  echo ""
  info "fail2ban — blocks addresses that keep failing to log in"

  if ! pkg_is_installed fail2ban; then
    echo "     not installed — it is part of core utils, which was declined or skipped"
    SUMMARY+=("fail2ban: not installed")
  elif systemctl is-active --quiet fail2ban 2>/dev/null; then
    echo "     running, and watching:"
    fail2ban-client status 2>/dev/null | awk -F: '/Jail list/ { print "       " $2 }' | xargs -r echo "      "
    echo ""
    echo "     Ubuntu enables the sshd jail by default: five failed logins from"
    echo "     one address within ten minutes blocks it for ten. That includes"
    echo "     you, from wherever you are connecting."
    echo "     Unban with:  fail2ban-client set sshd unbanip <address>"
    SUMMARY+=("fail2ban: running")
  else
    warn "installed but not running"
    echo "     start it with: systemctl enable --now fail2ban"
    SUMMARY+=("fail2ban: installed but not running")
  fi
  step_ok
fi

# =============================================================================
# 20. Unattended upgrades
# =============================================================================
#
# The package comes from core utils. This makes sure it is actually switched on,
# and reports the one thing about it nobody notices.

AUTO_UPGRADES=/etc/apt/apt.conf.d/20auto-upgrades

step "Unattended upgrades"
if ! skip; then
  echo ""
  info "Unattended upgrades — security updates installed on a timer, unattended"

  if ! pkg_is_installed unattended-upgrades; then
    echo "     not installed — it is part of core utils, which was declined or skipped"
    SUMMARY+=("Unattended upgrades: not installed")
  else
    UU_ENABLED=false
    grep -qs '^APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UPGRADES" && UU_ENABLED=true

    echo "     package:      installed"
    echo "     enabled:      $($UU_ENABLED && echo 'yes' || echo 'no')"
    echo "     timers:       $(systemctl is-enabled apt-daily.timer 2>/dev/null), $(systemctl is-enabled apt-daily-upgrade.timer 2>/dev/null)"

    if $UU_ENABLED; then
      SUMMARY+=("Unattended upgrades: enabled")
    else
      echo ""
      echo "     The package is there but ${AUTO_UPGRADES} does not switch it on,"
      echo "     so the timers run and do nothing. Writing that file is the whole"
      echo "     of enabling it."
      if confirm "Enable it?"; then
        cat >"$AUTO_UPGRADES" <<'EOF'
// Written by machine-setup.
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
        ok "enabled — updates apply daily, package cache cleaned weekly"
        SUMMARY+=("Unattended upgrades: enabled")
      else
        warn "skipped by request"
        SUMMARY+=("Unattended upgrades: installed but NOT enabled")
      fi
    fi

    # The failure mode people do not notice for months: a kernel update is
    # installed but the machine keeps running the old one until it reboots, and
    # nothing says so except this file.
    echo ""
    if [[ -f /var/run/reboot-required ]]; then
      warn "a reboot is pending — updates are installed but not all of them are in use"
      [[ -r /var/run/reboot-required.pkgs ]] && sed 's/^/       /' /var/run/reboot-required.pkgs
      echo "     Unattended upgrades never reboots on its own, deliberately. Until"
      echo "     this machine restarts it keeps running the old kernel or library."
      SUMMARY+=("Reboot: PENDING — updates installed but not active")
    else
      echo "     no reboot pending"
    fi
  fi
  step_ok
fi

# =============================================================================
# 21. Git
# =============================================================================

step "Git"
if ! skip; then
  echo ""
  info "Git — the identity commits from this machine are made under"

  GIT_NAME_NOW="$(git_get user.name)"
  GIT_EMAIL_NOW="$(git_get user.email)"
  GIT_BRANCH_NOW="$(git_get init.defaultBranch)"
  GIT_REBASE_NOW="$(git_get pull.rebase)"

  GIT_DO=true
  if git_has_identity; then
    echo "     name:           ${GIT_NAME_NOW:-not set}"
    echo "     email:          ${GIT_EMAIL_NOW:-not set}"
    echo "     default branch: ${GIT_BRANCH_NOW:-not set (git uses master)}"
    echo "     git pull:       ${GIT_REBASE_NOW:+rebase}${GIT_REBASE_NOW:-not set — git asks every time branches diverge}"
    echo ""
    # Default no. There is already a working identity, and the common case for
    # re-running this script is everything except this.
    confirm "Change it?" n || GIT_DO=false
  else
    echo "     nothing configured yet"
    echo ""
    echo "     Worth knowing: agents Officer runs commit as this account, so this"
    echo "     is what git log will attribute their commits to as well."
  fi

  if [[ "$GIT_DO" == false ]]; then
    echo "     left as it is"
    SUMMARY+=("Git: unchanged (${GIT_NAME_NOW})")
  else
    echo ""
    ask_required GIT_NAME "Name" "$GIT_NAME_NOW"
    ask_required GIT_EMAIL "Email" "$GIT_EMAIL_NOW"
    ask_required GIT_BRANCH "Default branch for new repositories" "${GIT_BRANCH_NOW:-master}"

    echo ""
    echo "     name:           ${GIT_NAME}"
    echo "     email:          ${GIT_EMAIL}"
    echo "     default branch: ${GIT_BRANCH}"
    echo "     git pull:       rebase"
    echo ""
    echo "     pull.rebase replays your local commits on top of what was fetched"
    echo "     instead of adding a merge commit that records nothing. Without it,"
    echo "     git stops on the first divergence and refuses to pull until told"
    echo "     which to do."
    echo ""
    echo "     core.editor is deliberately not set here. Git falls back to \$EDITOR,"
    echo "     which the shell section sets — so crontab, visudo and git all follow"
    echo "     one preference instead of two that can drift apart."
    if confirm "Proceed?"; then
      # Checked, not assumed. These run as another account and can fail for
      # reasons that have nothing to do with the values — which is exactly what
      # happened when they were run from a directory that account could not stat.
      GIT_OK=true
      git_set user.name "$GIT_NAME" || GIT_OK=false
      git_set user.email "$GIT_EMAIL" || GIT_OK=false
      git_set init.defaultBranch "$GIT_BRANCH" || GIT_OK=false
      git_set pull.rebase true || GIT_OK=false

      if $GIT_OK && [[ "$(git_get user.name)" == "$GIT_NAME" ]]; then
        ok "written to ${USER_HOME}/.gitconfig"
        SUMMARY+=("Git: ${GIT_NAME} <${GIT_EMAIL}>, default branch ${GIT_BRANCH}, pull rebases")
      else
        warn "could not write the git config for ${USERNAME}"
        ERRORS+=("Git: writing ${USER_HOME}/.gitconfig failed")
        SUMMARY+=("Git: FAILED to write")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("Git: SKIPPED by request")
    fi
  fi
  step_ok
fi

# =============================================================================
# 22. Docker
# =============================================================================

step "Docker"
if ! skip; then
  echo ""
  info "Docker — containers, and how ${USERNAME} is allowed to talk to them"
  echo "     engine:       $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')"
  echo "     daemon:       $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')"
  if [[ "$OS" != "macos" ]]; then
    echo "     ${USERNAME}:  $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
  fi

  # ── macOS: we do not install Docker, we check for it ──
  #
  # Docker Desktop is the only thing that works here without a fight. Lima and
  # colima both technically run containers on a Mac and both cost an evening the
  # first time something does not resolve, so this asks for Desktop by name
  # rather than installing an alternative that will disappoint later.
  #
  # Not installed by the script either: it is a GUI app that wants to be opened,
  # granted permissions and left running, none of which a shell script should be
  # doing on somebody's laptop.
  if [[ "$OS" == "macos" ]]; then
    if docker_daemon_ok; then
      ok "Docker Desktop is running"
      SUMMARY+=("Docker: Docker Desktop running")
    elif docker_is_installed; then
      warn "the docker CLI is here but the daemon is not answering"
      echo "     Open Docker Desktop from Applications and let it finish starting."
      SUMMARY+=("Docker: installed but not running — open Docker Desktop")
    else
      warn "Docker is not installed"
      echo ""
      echo "     Officer needs it for Postgres and for anything the app store"
      echo "     installs. Get Docker Desktop:"
      echo ""
      echo "       https://www.docker.com/products/docker-desktop/"
      echo ""
      echo "     Open it once after installing, then run this step again:"
      echo "       ./machine-setup.sh --only Docker"
      SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step")
    fi
    step_ok
  elif ! docker_is_installed; then
    echo ""
    echo "     to install:   docker-ce, the CLI, containerd, buildx and compose,"
    echo "                   from Docker's own repository — plus uidmap,"
    echo "                   dbus-user-session and docker-ce-rootless-extras,"
    echo "                   which every member's own rootless daemon needs"
    if confirm "Install it?"; then
      if install_docker_engine; then
        ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
        SUMMARY+=("Docker: engine installed")
      else
        warn "the Docker install did not complete"
        ERRORS+=("Docker: engine install failed")
        SUMMARY+=("Docker: install FAILED")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("Docker: SKIPPED by request")
    fi
  fi

  # The group-vs-rootless choice below is Linux only: Docker Desktop runs
  # containers in a VM owned by whoever is logged in, so there is no group to
  # join and no rootless variant to pick.
  if [[ "$OS" != "macos" ]] && docker_is_installed; then
    # ── how this account reaches the daemon ──
    if user_in_docker_group || docker_rootless_installed; then
      echo ""
      echo "     ${USERNAME} can already reach a daemon — leaving that as it is"
      SUMMARY+=("Docker: access unchanged")
    else
      echo ""
      info "How should ${USERNAME} use Docker?"
      echo ""
      echo "     [1] add ${USERNAME} to the docker group    (recommended)"
      echo "         Plain 'docker' commands, and the daemon runs as root."
      echo "         Be clear about what the group is: anyone in it can run"
      echo "           docker run -v /:/host -it alpine chroot /host"
      echo "         which is a root shell. The group is not 'access to Docker',"
      echo "         it is root by a longer route."
      # Only true if the account really does have sudo. Saying it of one that
      # does not would be reassuring about the exact case where the group is a
      # genuine escalation.
      if id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo; then
        echo "         On this machine that grants nothing new — ${USERNAME} already"
        echo "         has sudo, so it is a shorter route to something they can"
        echo "         already reach."
      else
        warn "         ${USERNAME} does NOT have sudo, so this genuinely escalates them."
        echo "         That is the case rootless exists for, and why members get it."
      fi
      echo ""
      echo "     [2] rootless Docker for ${USERNAME}"
      echo "         Their own daemon, containers in their own user namespace,"
      echo "         root inside a container is nobody outside it. The same thing"
      echo "         Officer gives members."
      warn "         Officer's app store cannot provision containers with this."
      echo "         It runs 'docker' with no environment of its own, so it talks"
      echo "         to /var/run/docker.sock — not this account's socket. Making"
      echo "         it work means putting DOCKER_HOST in the officer process's"
      echo "         environment, which this script does not do."
      echo ""
      echo "     [3] neither — use 'sudo docker'"
      echo "         Nothing is granted. Every command needs sudo, including"
      echo "         anything Officer would run as ${USERNAME}."
      echo ""

      DOCKER_ACCESS=""
      while [[ -z "$DOCKER_ACCESS" ]]; do
        menu_answer DOCKER_CHOICE "  Which one? (1/2/3) [1]: "
        case "${DOCKER_CHOICE:-1}" in
          1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
          *) warn "Pick 1, 2 or 3." ;;
        esac
      done

      case "$DOCKER_ACCESS" in
        1)
          usermod -aG docker "$USERNAME"
          ok "${USERNAME} added to the docker group"
          echo "     Takes effect at their next login — group membership is read"
          echo "     when the session starts, so the shell you are in now still"
          echo "     does not have it."
          SUMMARY+=("Docker: ${USERNAME} in the docker group")
          ;;
        2)
          if ! pkg_is_installed uidmap || ! pkg_is_installed dbus-user-session; then
            info "     installing uidmap and dbus-user-session, which rootless needs"
            pkg_install_now uidmap dbus-user-session
          fi
          if install_docker_rootless; then
            ok "rootless Docker running for ${USERNAME}"
            echo "     DOCKER_HOST=unix:///run/user/$(id -u "$USERNAME")/docker.sock"
            SUMMARY+=("Docker: rootless for ${USERNAME} — app store provisioning will NOT work until DOCKER_HOST is in officer's environment")
          else
            warn "the rootless setup did not complete"
            ERRORS+=("Docker: rootless setup failed")
            SUMMARY+=("Docker: rootless setup FAILED")
          fi
          ;;
        3)
          echo "     nothing granted — docker needs sudo"
          SUMMARY+=("Docker: no access granted, sudo required")
          ;;
      esac
    fi

    # ── the shared network ──
    if docker_daemon_ok; then
      if ensure_docker_network; then
        echo "     network '${DOCKER_NETWORK}' present, so containers from separate"
        echo "     compose files can reach each other by name"
      fi
    fi
  fi
  step_ok
fi

# =============================================================================
# 23. Neovim
# =============================================================================

step "Neovim"
if ! skip; then
  NVIM_NOW="$(nvim_installed_version)"
  NVIM_LATEST="$(nvim_latest_version)"

  echo ""
  info "Neovim — from upstream, not from the distribution"
  echo "     installed:    ${NVIM_NOW:-not installed}"
  echo "     latest:       ${NVIM_LATEST:-could not reach github}"
  echo "     Ubuntu ships a Neovim years behind upstream, and LazyVim wants a"
  echo "     recent one, so this takes the release tarball."

  if [[ -z "$NVIM_LATEST" ]]; then
    warn "cannot reach the release API — leaving Neovim alone"
    SUMMARY+=("Neovim: skipped, could not reach github")
  elif [[ "$NVIM_NOW" == "$NVIM_LATEST" ]]; then
    echo "     already on the latest release, nothing to do"
    SUMMARY+=("Neovim: already ${NVIM_NOW}")
  else
    echo ""
    if confirm "${NVIM_NOW:+Upgrade}${NVIM_NOW:-Install} Neovim ${NVIM_LATEST}?"; then
      if nvim_install; then
        ok "neovim $(nvim_installed_version) at /usr/local/bin/nvim"
        SUMMARY+=("Neovim: $(nvim_installed_version)")
      else
        warn "the Neovim install did not complete"
        ERRORS+=("Neovim: install failed")
        SUMMARY+=("Neovim: install FAILED")
      fi
    else
      warn "skipped by request"
      SUMMARY+=("Neovim: SKIPPED by request")
    fi
  fi

  # ── configuration ──
  if command -v nvim &>/dev/null && id "$USERNAME" &>/dev/null; then
    NVIM_CONFIG="${USER_HOME}/.config/nvim"

    if [[ -d "$NVIM_CONFIG" ]]; then
      echo ""
      echo "     ${USERNAME} already has a Neovim config at ${NVIM_CONFIG}"
      SUMMARY+=("Neovim: existing config left alone")
    else
      echo ""
      info "Neovim configuration for ${USERNAME}"
      echo "     [1] LazyVim starter — a maintained set of defaults, sensible to"
      echo "         build on and easy to remove"
      echo "     [2] a git repository of your own"
      echo "     [3] nothing — plain Neovim"
      echo ""

      NVIM_REPO=""
      NVIM_PICK=""
      while [[ -z "$NVIM_PICK" ]]; do
        menu_answer NVIM_CHOICE "  Which one? (1/2/3) [1]: "
        case "${NVIM_CHOICE:-1}" in
          1)
            NVIM_REPO="https://github.com/LazyVim/starter"
            NVIM_PICK=config
            ;;
          2)
            # No default. The original suggested its author's own private repo,
            # which nobody else can clone.
            read -rp "  Repository URL: " NVIM_REPO || fail "No answer."
            [[ -n "$NVIM_REPO" ]] && NVIM_PICK=config || warn "A repository URL is needed."
            ;;
          3) NVIM_PICK=none ;;
          *) warn "Pick 1, 2 or 3." ;;
        esac
      done

      if [[ "$NVIM_PICK" == config ]]; then
        if nvim_clone_config "$NVIM_REPO"; then
          ok "config cloned to ${NVIM_CONFIG}"
          SUMMARY+=("Neovim: config from ${NVIM_REPO}")
        else
          warn "could not clone ${NVIM_REPO}"
          ERRORS+=("Neovim: cloning ${NVIM_REPO} failed")
          SUMMARY+=("Neovim: config clone FAILED")
        fi
      else
        SUMMARY+=("Neovim: no config, plain Neovim")
      fi
    fi
  fi
  step_ok
fi

# =============================================================================
# 24. JavaScript runtimes
# =============================================================================
#
# Not offered as a choice. Officer does not run without these, so asking would be
# asking whether to install Officer — which was settled by running this script.
# Deno is the exception and is asked, because nothing uses it.

step "JavaScript runtimes"
if ! skip; then
  NODE_LTS="$(node_lts_major)"
  NODE_NOW="$(node_installed_major)"

  echo ""
  info "JavaScript runtimes — required, so these are installed rather than offered"
  echo "     node   ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
  echo "            pm2 is a Node application, and officer-pty compiles node-pty"
  echo "            against whatever Node is installed — there is no Linux prebuild."
  echo "     bun    $(bun_installed && bun_version || echo 'not installed')"
  echo "            the platform itself, and nineteen of the twenty pm2 apps."
  echo "     pm2    $(pm2_installed && echo installed || echo 'not installed')"
  echo "            supervises all of them, and the ecosystem files are written for it."
  echo ""

  # ── node ──
  if [[ -z "$NODE_LTS" ]]; then
    warn "could not reach nodejs.org to find the current LTS — leaving Node alone"
    ERRORS+=("Node: could not determine the current LTS")
  elif [[ "$NODE_NOW" == "$NODE_LTS" ]]; then
    ok "node v${NODE_NOW} is the current LTS"
  else
    info "     installing Node ${NODE_LTS}${NODE_NOW:+, replacing v$NODE_NOW}..."
    # Checked afterwards rather than trusting the installer's exit status: a
    # NodeSource run can succeed while apt keeps an older nodejs held back, and
    # reporting the version we asked for instead of the one that is there is how
    # a machine ends up disagreeing with its own setup log.
    if install_node "$NODE_LTS" && [[ "$(node_installed_major)" == "$NODE_LTS" ]]; then
      ok "node $(node -v)"
      SUMMARY+=("Node: $(node -v) (current LTS)")
    else
      warn "the Node install did not complete"
      ERRORS+=("Node: install failed")
    fi
  fi

  # ── bun ──
  if bun_installed; then
    ok "bun $(bun_version) already installed"
  else
    info "     installing bun..."
    if install_bun && bun_installed; then
      ok "bun $(bun_version)"
      SUMMARY+=("Bun: $(bun_version)")
    else
      warn "the bun install did not complete"
      ERRORS+=("Bun: install failed")
    fi
  fi

  # Ensured every run, not only after an install. A machine that already had bun
  # would otherwise never get the link, and the failure only shows up at the next
  # reboot.
  if ensure_bun_symlink; then
    ok "bun symlinked to /usr/local/bin/bun — pm2 at boot has no ~/.bun on PATH"
    SUMMARY+=("Bun: symlinked system-wide")
  elif [[ -x /usr/local/bin/bun ]]; then
    echo "     /usr/local/bin/bun already points at it"
  else
    warn "bun is not at ${USER_HOME}/.bun/bin/bun — cannot link it system-wide"
    ERRORS+=("Bun: no system-wide symlink; pm2 apps will fail at boot")
  fi

  # ── pm2 ──
  if pm2_installed; then
    ok "pm2 $(pm2 -v 2>/dev/null | tail -1) already installed"
  elif command -v npm &>/dev/null; then
    info "     installing pm2..."
    if install_pm2 && pm2_installed; then
      ok "pm2 $(pm2 -v | tail -1)"
      SUMMARY+=("pm2: $(pm2 -v | tail -1)")
    else
      warn "the pm2 install did not complete"
      ERRORS+=("pm2: install failed")
    fi
  else
    warn "no npm, so pm2 cannot be installed — Node did not install correctly"
    ERRORS+=("pm2: not installed, npm missing")
  fi

  # ── deno, which is the one genuine choice here ──
  echo ""
  if deno_installed; then
    echo "     deno is already installed — left alone"
  else
    info "Deno?"
    echo "     Nothing in Officer uses Deno. It was in the original setup script"
    echo "     for one thing that no longer exists, and there is no reference to"
    echo "     it anywhere in the platform today."
    echo "     Offered because you may want it for your own work."
    if confirm "Install Deno?" n; then
      if install_deno && deno_installed; then
        ok "deno installed to ${USER_HOME}/.deno"
        SUMMARY+=("Deno: installed (not used by Officer)")
      else
        warn "the Deno install did not complete"
      fi
    else
      echo "     skipped"
    fi
  fi
  step_ok
fi

# =============================================================================
# 25. Agent CLIs
# =============================================================================
#
# claude and opencode are what the chat sidecars spawn, so they are installed
# rather than offered — for the same reason node and bun are. PI is nobody's
# dependency and is asked for.

step "Agent CLIs"
if ! skip; then
  echo ""
  info "Agent CLIs — the programs Officer's chat actually runs"
  echo "     claude    $(agent_version claude || echo 'not installed')"
  echo "               spawned by officer-claude-code; chat does not work without it."
  echo "     opencode  $(agent_version opencode || echo 'not installed')"
  echo "               the alternative agent, run by officer-opencode."
  echo ""
  echo "     Installed as ${USERNAME} rather than as root: Anthropic's installer"
  echo "     refuses to run under sudo, because everything it writes goes under"
  echo "     \$HOME and under sudo that is root's. They land in different places —"
  echo "     claude in ~/.local/bin, opencode in ~/.opencode/bin — and the"
  echo "     sidecars look in exactly those two."

  if agent_installed claude && agent_is_npm_install claude; then
    # The npm package does not auto-update; Anthropic's installer does, which is
    # why the platform uses it for members. Worth offering to switch rather than
    # leaving a copy that quietly goes stale.
    echo ""
    warn "claude here came from npm ($(agent_path claude)) and does not auto-update"
    echo "     Anthropic's installer puts it in ${USER_HOME}/.local/bin and keeps it"
    echo "     current. That is what the platform installs for members."
    if confirm "Reinstall it with the official installer?"; then
      if install_claude_code; then
        ok "claude $(agent_version claude) from $(agent_path claude)"
        echo "     the npm copy is still at /usr/local/bin/claude — remove it with:"
        echo "       sudo npm uninstall -g @anthropic-ai/claude-code"
        SUMMARY+=("Claude Code: reinstalled via the official installer")
      else
        warn "the Claude Code install did not complete"
        ERRORS+=("Claude Code: install failed")
      fi
    else
      echo "     left as the npm install"
      SUMMARY+=("Claude Code: npm install kept, does not auto-update")
    fi
  elif agent_installed claude; then
    ok "claude $(agent_version claude) already installed"
  else
    info "     installing Claude Code..."
    if install_claude_code; then
      ok "claude $(agent_version claude)"
      SUMMARY+=("Claude Code: $(agent_version claude)")
    else
      warn "the Claude Code install did not complete"
      ERRORS+=("Claude Code: install failed")
    fi
  fi

  if agent_installed opencode; then
    ok "opencode $(agent_version opencode) already installed"
  else
    info "     installing opencode..."
    if install_opencode; then
      ok "opencode $(agent_version opencode)"
      SUMMARY+=("opencode: $(agent_version opencode)")
    else
      warn "the opencode install did not complete"
      ERRORS+=("opencode: install failed")
    fi
  fi

  # ── PI, which nothing here uses ──
  echo ""
  if command -v pi &>/dev/null; then
    echo "     pi is already installed — left alone"
  else
    info "PI?"
    echo "     Another coding agent. Officer does not use it — nothing in the"
    echo "     platform spawns it — and it is offered because it was in the"
    echo "     original script and you may want it for your own work."
    if confirm "Install PI?" n; then
      if install_pi && command -v pi &>/dev/null; then
        ok "pi installed"
        SUMMARY+=("PI: installed (not used by Officer)")
      else
        warn "the PI install did not complete"
      fi
    else
      echo "     skipped"
    fi
  fi
  step_ok
fi

# =============================================================================
# 26. Shell
# =============================================================================
#
# zsh, oh-my-zsh, the starship prompt, and the dotfiles that go with them. The
# .tmux.conf lives here rather than in user creation, where the original put it
# only because that is where $USER_HOME first exists.

step "Shell"
if ! skip; then
  SHELL_NOW="$(user_login_shell)"

  echo ""
  info "Shell — what ${USERNAME} gets at every login"
  echo "     login shell:  ${SHELL_NOW}"
  echo "     zsh:          $(command -v zsh &>/dev/null && echo 'installed' || echo 'not installed')"
  echo "     oh-my-zsh:    $(oh_my_zsh_installed && echo 'installed' || echo 'not installed')"
  echo "     starship:     $(command -v starship &>/dev/null && echo 'installed' || echo 'not installed')"

  # ── zsh and oh-my-zsh ──
  if ! command -v zsh &>/dev/null || ! oh_my_zsh_installed; then
    echo ""
    echo "     oh-my-zsh is a configuration framework for zsh: completions, a"
    echo "     plugin system, and sensible history behaviour out of the box."
    if confirm "Install zsh and oh-my-zsh?"; then
      command -v zsh &>/dev/null || pkg_install_now zsh
      if ! oh_my_zsh_installed; then
        install_oh_my_zsh
        oh_my_zsh_installed && ok "oh-my-zsh installed" || warn "oh-my-zsh did not install"
      fi
      SUMMARY+=("Shell: zsh and oh-my-zsh installed")
    else
      warn "skipped by request"
      SUMMARY+=("Shell: SKIPPED by request")
    fi
  fi

  # ── the login shell, asked separately ──
  #
  # Having zsh on the machine and being handed it at every login are different
  # decisions, and the original made the second one silently.
  if command -v zsh &>/dev/null && [[ "$SHELL_NOW" != *zsh ]]; then
    echo ""
    echo "     ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
    echo "     effect at the next login, and does not affect this session."
    # Guarded, not bare: `chsh` can refuse — a PAM policy, a shell missing from
    # /etc/shells, an account whose password field blocks it — and a bare call would
    # end the run there under `set -e`. Reported instead, because a machine with the
    # right shell installed and the wrong one at login still works.
    if confirm "Make zsh the login shell?"; then
      if set_login_shell "$(command -v zsh)"; then
        ok "login shell is now $(user_login_shell)"
        SUMMARY+=("Shell: login shell set to zsh")
      else
        warn "chsh refused — login shell is still $(user_login_shell)"
        echo "     Change it later with:  chsh -s $(command -v zsh) ${USERNAME}"
        ERRORS+=("Shell: chsh refused, login shell left as $(user_login_shell)")
        SUMMARY+=("Shell: login shell NOT changed")
      fi
    else
      warn "left as ${SHELL_NOW}"
      SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
    fi
  fi

  # ── dotfiles, none of which overwrite ──
  if id "$USERNAME" &>/dev/null; then
    echo ""
    info "     shell configuration"

    # The prompt config the platform also deploys to every member. install_config
    # keeps whatever is already there if it differs.
    if [[ -r "$STARSHIP_SRC" ]]; then
      # && / || rather than a bare call: 2 means "kept yours", which is an
      # outcome and not a failure, but is still non-zero and would end the run.
      install_config "$STARSHIP_SRC" "${USER_HOME}/.config/starship.toml" "$USERNAME" && RC=0 || RC=$?
      case $RC in
        0) ok "starship config installed — the same one members get" ;;
        1) echo "     starship config already matches" ;;
      esac
    fi

    # scripts/setup/tmux.conf, one level up — the shell templates live together
    # beside starship.toml, which has to be there because the PLATFORM reads it
    # too (os-user-shell.ts, for every member's Linux account). Keeping them in
    # one directory means "where do the dotfile templates live" has one answer.
    #
    # No leading dot on any of them: they are templates in a repository, not
    # dotfiles in a home directory, and tmux's destination is increasingly
    # ~/.config/tmux/tmux.conf, which has no dot either.
    if [[ -r "$SCRIPT_DIR/../tmux.conf" ]]; then
      # Not always ~/.tmux.conf — see tmux_config_target. tmux 3.1+ prefers
      # ~/.config/tmux/tmux.conf, so writing the old path on a machine that has
      # the new one produces a file tmux never reads and a success message that
      # means nothing.
      TMUX_TARGET="$(tmux_config_target "$USER_HOME")"
      if tmux_dot_conf_is_shadowed "$USER_HOME"; then
        warn "you have BOTH ~/.tmux.conf and ~/.config/tmux/tmux.conf — tmux reads the second"
        echo "     Targeting the one it actually reads: ${TMUX_TARGET}"
      fi
      install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$(dirname "$TMUX_TARGET")"
      install_config "$SCRIPT_DIR/../tmux.conf" "$TMUX_TARGET" "$USERNAME" && RC=0 || RC=$?
      case $RC in
        0) ok "tmux config installed — ${TMUX_TARGET}" ;;
        1) echo "     tmux config already matches (${TMUX_TARGET})" ;;
      esac
    fi

    # Everything below writes .zshrc, and every block is marker-wrapped so a
    # second run recognises its own work. The original appended all of it
    # unguarded, so a re-run duplicated the lot.
    ZSHRC="${USER_HOME}/.zshrc"
    touch "$ZSHRC"
    chown "${USERNAME}:$(user_group)" "$ZSHRC"

    if command -v starship &>/dev/null; then
      if append_once "$ZSHRC" starship <<'EOF'
eval "$(starship init zsh)"
EOF
      then
        ok "starship added to .zshrc"
      fi
    fi

    # The agent CLIs install to two different directories, neither of which is on
    # PATH by default. The sidecars find them regardless — they check the exact
    # paths — but a user who cannot run `claude` in their own terminal reasonably
    # concludes it was never installed.
    if append_once "$ZSHRC" agent-clis <<'EOF'
export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"
EOF
    then
      ok "~/.local/bin and ~/.opencode/bin added to PATH"
    fi

    # ── Keys ──
    #
    # This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
    # PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
    # the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
    # history-prefix search, no Home/End. Confirmed on this machine before writing it, with
    # `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
    #
    # The two files are still separate — one is a template the platform copies, the other is an
    # idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
    # web terminal and neither should have to learn which account they are on.
    if append_once "$ZSHRC" keybindings <<'EOF'
bindkey -e
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search
bindkey '^[[B' down-line-or-beginning-search
bindkey '^[[1;5C' forward-word
bindkey '^[[1;5D' backward-word
bindkey '^[[1;3C' forward-word
bindkey '^[[1;3D' backward-word
bindkey '^[[3~' delete-char
bindkey '^[[H' beginning-of-line
bindkey '^[[F' end-of-line
bindkey '^[[1~' beginning-of-line
bindkey '^[[4~' end-of-line
bindkey '^H' backward-kill-word
bindkey '^[^?' backward-kill-word
bindkey '^[[3;5~' kill-word
EOF
    then
      ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
    fi

    # The eza aliases are GUARDED and the rest are not, for one reason: these
    # replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
    # failed to install leaves the owner with no working `ls` at all, in every new
    # shell, which reads as a broken machine rather than a missing package. The
    # others degrade honestly — `alias ld=lazydocker` without lazydocker is one
    # command-not-found when you type it, not a core utility gone.
    #
    # Same principle shell-skel/zshrc already holds to: every optional tool is used
    # only if present, so one file works on a minimal VPS and a full workstation.
    if append_once "$ZSHRC" aliases <<'EOF'
if command -v eza >/dev/null 2>&1; then
  alias ls='eza --icons'
  alias la='eza --icons -la'
  alias ll='eza --icons -l'
  alias lll='eza --icons -lA'
  alias lh='eza --icons -lhA'
  alias ltr='eza --icons -ltr'
  alias l='eza --icons -la'
fi

alias grep='grep --color=auto'
alias less='less -R'
alias diff='diff --color=auto'
alias cp='cp -iv'
alias mv='mv -iv'
alias rm='rm -i'
alias mkdir='mkdir -p'
alias which='which -a'
alias history='fc -l 1'

alias n="nvim"
alias vim="n"
alias sz="source ~/.zshrc"
alias ld="lazydocker"
alias httpserver="python3 -m http.server 8888"
EOF
    then
      ok "shell aliases added"
    fi

    SUMMARY+=("Shell: prompt and dotfiles in place")
  fi

  # ── the default editor ──
  echo ""
  info "Default editor — what opens when anything needs you to type something"
  echo "     git commit, crontab -e, systemctl edit, sudoedit. One preference,"
  echo "     which is why git's own core.editor is deliberately not set: git"
  echo "     falls back to \$EDITOR, so setting it here covers everything."
  echo ""
  mapfile -t EDITORS < <(editor_candidates)
  if ((${#EDITORS[@]} == 0)); then
    warn "no editor found to offer — skipping"
  else
    EDITOR_NOW="$(current_editor)"
    echo "     currently:    ${EDITOR_NOW:-not set}"
    for i in "${!EDITORS[@]}"; do
      printf '     [%d] %s\n' "$((i + 1))" "${EDITORS[$i]}"
    done
    echo ""

    EDITOR_PICK=""
    while [[ -z "$EDITOR_PICK" ]]; do
      menu_answer EDITOR_CHOICE "  Which one? (1-${#EDITORS[@]}) [1]: "
      EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
      if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
        EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
      else
        warn "Pick a number from the list."
      fi
    done

    # Written to the account's shell, and set as the system `editor` alternative
    # so root and sudoedit agree with it.
    if append_once "$ZSHRC" editor <<EOF
export EDITOR="${EDITOR_PICK}"
export VISUAL="${EDITOR_PICK}"
export SUDO_EDITOR="${EDITOR_PICK}"
EOF
    then
      ok "EDITOR, VISUAL and SUDO_EDITOR set to ${EDITOR_PICK}"
    else
      echo "     .zshrc already has an editor block — edit it by hand to change it"
    fi
    set_system_editor "$EDITOR_PICK" && ok "system 'editor' alternative set to ${EDITOR_PICK}"
    SUMMARY+=("Editor: ${EDITOR_PICK}")
  fi
  step_ok
fi

# =============================================================================
# 27. Firewall
# =============================================================================
#
# Last, deliberately. Enabling a firewall is the one step in this script that can
# cut the connection it is running over, so everything else is done and working
# before it happens.

step "Firewall"
if ! skip; then
  echo ""
  info "Firewall — what this machine will accept connections on"
  echo "     ufw:          $(ufw_is_active && echo active || echo inactive)"
  echo "     ssh allowed:  $(ufw_allows_ssh && echo yes || echo 'NO')"
  echo "     tailscale0:   $(ufw_has_rule tailscale0 && echo allowed || echo 'not allowed')"
  echo "     docker rules: $(ufw_docker_rules_applied && echo applied || echo 'not applied')"

  # ── ssh first, always, before anything is enabled ──
  #
  # The order matters more than anything else here. A firewall enabled without an
  # ssh rule on a machine reached over ssh is unrecoverable without a console.
  if ! ufw_allows_ssh; then
    info "     allowing OpenSSH before anything else"
    ufw allow OpenSSH >/dev/null 2>&1
  fi

  # ── the tailnet ──
  #
  # Checked rather than assumed. Without this rule Officer is unreachable over
  # the tailnet even though Tailscale is connected — the default is deny
  # inbound, and the tailnet is an inbound interface like any other.
  if ip link show tailscale0 &>/dev/null; then
    if ufw_has_rule tailscale0; then
      echo "     tailnet traffic already allowed"
    else
      echo ""
      echo "     tailscale0 exists but is not allowed through. Everything Officer"
      echo "     serves is reached over the tailnet, so without this rule the"
      echo "     platform is unreachable even though Tailscale is connected."
      if confirm "Allow all traffic on tailscale0?"; then
        ufw allow in on tailscale0 >/dev/null 2>&1
        ok "tailnet traffic allowed"
        SUMMARY+=("Firewall: tailscale0 allowed")
      fi
    fi
  fi

  # ── docker ──
  #
  # Docker publishes ports by writing its own iptables rules, which bypass ufw
  # entirely. DOCKER-USER is the hook that lets ufw have a say at all.
  if command -v docker &>/dev/null && [[ -r "$SCRIPT_DIR/ufw-docker-rules.conf" ]]; then
    if ufw_docker_rules_applied; then
      echo "     docker rules already in ${UFW_AFTER_RULES}"
    else
      echo ""
      echo "     Docker publishes container ports by writing iptables rules of its"
      echo "     own, underneath ufw — a published port is reachable from the"
      echo "     internet whatever ufw says. These rules close that, allowing only"
      echo "     80 and 443 in from outside."
      if confirm "Apply the Docker firewall rules?"; then
        if apply_ufw_docker_rules "$SCRIPT_DIR/ufw-docker-rules.conf"; then
          ok "docker rules appended, bound to $(default_iface)"
          SUMMARY+=("Firewall: Docker rules applied on $(default_iface)")
        else
          warn "could not apply the Docker rules"
        fi
      fi
    fi
  fi

  # ── enable ──
  if ufw_is_active; then
    ufw reload >/dev/null 2>&1
    ok "firewall active and reloaded"
    SUMMARY+=("Firewall: active")
  else
    echo ""
    info "Enable the firewall?"
    echo "     Default: deny everything inbound, allow everything outbound."
    echo "     Allowed in: $(ufw_allows_ssh && echo 'OpenSSH') $(ufw_has_rule tailscale0 && echo '· all traffic on tailscale0')"
    echo ""
    warn "If you are connected over ssh, this is the moment it could go wrong."
    echo "     OpenSSH is allowed above before anything is enabled, which is what"
    echo "     makes this safe — but check you can open a second session before"
    echo "     closing this one."
    if confirm "Enable it?"; then
      ufw --force enable >/dev/null 2>&1
      if ufw_is_active; then
        ok "firewall enabled"
        SUMMARY+=("Firewall: enabled")
      else
        warn "ufw did not come up — check: ufw status verbose"
        ERRORS+=("Firewall: enable failed")
      fi
    else
      warn "skipped by request — this machine has no firewall"
      SUMMARY+=("Firewall: SKIPPED by request, not enabled")
    fi
  fi

  echo ""
  ufw status 2>/dev/null | sed 's/^/     /'
  step_ok
fi

# =============================================================================
# NOT PORTED YET
# =============================================================================
#
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
#
#   ufw · zshrc
#
# And one that is new rather than ported, to come last of all:
#
#   default editor — ask for nano, vim or nvim and set EDITOR/VISUAL in the shell
#   config, plus the Debian `editor` alternative so root and anything reading the
#   system default agree with it. Has to come after Neovim is installed, or nvim
#   cannot honestly be offered as a choice. This is the setting core.editor was
#   deliberately left out in favour of.
#
# Each arrives as its own commit. Delete this block when the list is empty.


# =============================================================================
# 28. Summary
# =============================================================================

echo ""
echo ""
if [[ ${#ERRORS[@]} -gt 0 ]]; then
  echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
  echo -e "${YELLOW}║  Setup Complete (with warnings)                  ║${NC}"
  echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
else
  echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
  echo -e "${GREEN}║  Setup Complete                                  ║${NC}"
  echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
fi

echo ""
echo -e "${BOLD}  What was done:${NC}"
for item in "${SUMMARY[@]}"; do
  echo -e "    ${GREEN}+${NC} $item"
done

if [[ ${#ERRORS[@]} -gt 0 ]]; then
  echo ""
  echo -e "${BOLD}  Non-critical issues:${NC}"
  for err in "${ERRORS[@]}"; do
    echo -e "    ${YELLOW}!${NC} $err"
  done
fi

echo ""
echo -e "${BOLD}  Machine:${NC}"
echo "    System:     $OS_NAME ($ARCH)"
echo "    Role:       $MACHINE_ROLE"
echo "    User:       $USERNAME"
echo "    Home:       $USER_HOME"
echo "    Officer:    $OFFICER_ROOT"
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo "    Tailscale:  $TS_IP"
echo ""

# ── Who you are when this exits ──
#
# Root. This script never becomes ${USERNAME} — it cannot, since a process cannot
# change its own uid — so it stays root and drops privileges per command instead.
# Everything written into their home was written that way.
#
# Worth saying out loud because the two things a fresh session fixes are both
# invisible until they bite: group membership is fixed at LOGIN, so the `docker`
# group just granted does not exist in this session, and their shell configuration
# lives in their home and is not loaded in root's.
#
# Suppressed when officer-setup is about to run — install.sh sets the variable. It
# would be wrong advice in the middle of an install, because the half that follows
# still needs the root session this would tell you to leave.
if [[ "$EUID" -eq 0 && -z "${OFFICER_SETUP_FOLLOWS:-}" ]]; then
  echo -e "${BOLD}  You are still root.${NC}"
  echo ""
  echo "    This machine is set up for ${USERNAME}. To carry on as them:"
  echo ""
  echo -e "      ${BOLD}su - ${USERNAME}${NC}                     from this session"
  echo -e "      ${BOLD}ssh ${USERNAME}@<this machine>${NC}       or log in fresh"
  echo ""
  echo "    A new session is what makes their docker group membership and their"
  echo "    shell configuration take effect — neither applies to the session you"
  echo "    are in now."
  echo ""
fi

# Clean up progress file on success
rm -f "$PROGRESS_FILE"

report_mark_complete
