diff --git a/install b/install index 220322a..16f5ac3 100755 --- a/install +++ b/install @@ -8,25 +8,22 @@ set -uo pipefail PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" source "$PANAMA_PATH/bin/ascii" -# ── Hostname, which is optional ────────────────────────────────────────────── +# ── The interview ──────────────────────────────────────────────────────────── # -# Declining this used to `exit`, which aborted the ENTIRE installation. The -# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when -# you do not want to rename your machine -- installed nothing at all and said -# nothing about it. -echo -e "Current hostname is: $(hostname)" -read -r -p "Do you want to change the hostname? [y/N]: " confirm_change -if [[ "$confirm_change" =~ ^[Yy]$ ]]; then - read -r -p "Hostname: " HOST_NAME - read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname - if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then - sudo hostnamectl set-hostname "$HOST_NAME" - echo "Hostname set to: $(hostname)" - else - echo "Hostname not changed." - fi -else - echo "Keeping the current hostname." +# Everything Panama needs to be told is asked here, before a single package is +# installed, and nothing asks again afterwards. That is the whole bargain: the +# rest of the run takes twenty minutes and needs nobody watching it. +# +# gum is bootstrapped first because the interview is built on it and it cannot +# install itself -- it is declared in initial-packages, which install-packages +# installs, which runs after this. One small dnf call buys a real interface for +# the only part of the install a person actually interacts with. +if ! command -v gum >/dev/null 2>&1; then + echo "Installing gum, which the setup questions are built on" + sudo dnf install -y gum >/dev/null || { + echo "Could not install gum, so the setup questions cannot be asked." >&2 + exit 1 + } fi # ── Keep the machine awake for the duration ────────────────────────────────── @@ -34,11 +31,14 @@ fi # out mid-transaction is unpleasant. Restored on every exit path, including # failure and Ctrl-C, so an interrupted install does not leave the screen # permanently awake. -restore_idle() { +cleanup() { gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true + # Deleted on every exit path, including Ctrl-C. The answers are transient by + # design, and one of them is an email address. + [[ -n "${PANAMA_ANSWERS:-}" ]] && rm -f "$PANAMA_ANSWERS" } -trap restore_idle EXIT INT TERM +trap cleanup EXIT INT TERM gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true @@ -52,9 +52,39 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true # # Explicit order, not glob order: change-settings runs `vicinae theme set`, # which needs both vicinae itself (installed by install-packages) and the -# theme files it selects among (symlinked into place by link-dotfiles). New +# theme files it selects among (symlinked into place by link-dotfiles); +# setup-identity needs the gh and git-all that install-packages provides. New # scripts must be added here explicitly, or they will not run at all. -STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts) + +# The interview is not in that list, because it is the one stage whose output the +# installer reads back -- and because declining it must stop everything rather +# than be recorded as one failure among several. +# +# The answers live for exactly one run. There is no state file to go stale and +# nothing personal reaches a durable path, which is what keeps this repository +# something somebody else could clone. Created here rather than earlier so the +# trap that deletes it is already armed before the file exists. +PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" +export PANAMA_ANSWERS + +if ! "$PANAMA_PATH/setup/scripts/interview"; then + exit 1 +fi +# shellcheck source=/dev/null +source "$PANAMA_ANSWERS" +export PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL PANAMA_GIT_EDITOR \ + PANAMA_GH_LOGIN PANAMA_SSH_KEY + +# Applied here rather than in a stage, and applied early: it needs sudo, and +# sudo is warm right now. At the end of a long unattended run the timestamp has +# expired, and a password prompt then is exactly the interruption the interview +# exists to prevent. +if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then + sudo hostnamectl set-hostname "$PANAMA_HOSTNAME" + echo "Hostname set to: $(hostname)" +fi + +STAGES=(install-packages link-dotfiles change-settings link-vicinae-scripts setup-identity) failed=() for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" @@ -66,6 +96,22 @@ for stage in "${STAGES[@]}"; do fi done +# ── Did it actually work? ──────────────────────────────────────────────────── +# +# A failed-stage count only reports what exited non-zero. It says nothing about a +# service that did not start or a font that did not land, and those are the +# failures that survive an install unnoticed. Doctor answers the question the +# stage list cannot. +# +# It never changes the exit code. On a fresh machine it legitimately reports +# things as unconfigured -- no Home Assistant token yet, Nextcloud not signed in +# -- and failing an install over those would be crying wolf. +doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor" +if [[ -x "$doctor" ]]; then + printf '\n=== health ===\n' + "$doctor" --summary || true +fi + printf '\n' if (( ${#failed[@]} == 0 )); then echo "Panama installed. Log out and choose the Hyprland session to start it." diff --git a/setup/packages/desktop-packages b/setup/packages/desktop-packages index 53c9259..978748a 100644 --- a/setup/packages/desktop-packages +++ b/setup/packages/desktop-packages @@ -3,6 +3,9 @@ akmods alsa-plugins-pulseaudio cascadiamono-nerd-fonts decibels +# The Wayland build specifically; the x11 one cannot inject into this session. +# Its config is linked by link-dotfiles. +espanso-wayland desktop-file-utils dnf-plugins-core ffmpeg diff --git a/setup/packages/initial-packages b/setup/packages/initial-packages index e491767..bfb35a4 100644 --- a/setup/packages/initial-packages +++ b/setup/packages/initial-packages @@ -18,6 +18,10 @@ kitty ksshaskpass libselinux-utils neovim +# config/bash/shell initialises the prompt with this. +oh-my-posh +# ssh-keygen, which setup-identity uses to create a key on request. +openssh openssl pciutils python3-dnf diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 4ed8cd7..ea31760 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -8,7 +8,6 @@ exists() { command -v "$1" >/dev/null 2>&1; } # --- Defined Paths --- PANAMA_PATH="$HOME/.local/share/Panama" -LOCAL_BIN_PATH="$HOME/.local/bin" echo -e "\n--- Installing Repositories ---" log "Installing RPM Fusion Free and Nonfree Repositories" @@ -70,15 +69,6 @@ else log "Package list was not in specified path: $DESKTOP_FILE" fi -# --- Install oh-my-posh if not already installed. --- -mkdir -p "$LOCAL_BIN_PATH" -if [[ -x "$LOCAL_BIN_PATH/oh-my-posh" ]]; then - log "oh-my-posh already installed at \"$LOCAL_BIN_PATH/oh-my-posh\"" -else - log "Installing oh-my-posh via curl..." - curl -s https://ohmyposh.dev/install.sh | bash -s -- -d "$LOCAL_BIN_PATH" > /dev/null 2>&1 -fi - # --- Install Development Packages needed for Neovim --- DEV_FILE="$PANAMA_PATH/setup/packages/development-packages" if [[ -f "$DEV_FILE" ]]; then diff --git a/setup/scripts/interview b/setup/scripts/interview new file mode 100755 index 0000000..8ab950d --- /dev/null +++ b/setup/scripts/interview @@ -0,0 +1,107 @@ +#!/usr/bin/env bash + +# Everything Panama needs to be told, asked before anything is installed. +# +# sunhat's failure mode was a question -- or a failure -- twenty minutes into a +# run, with a person needed at the keyboard to get past it. Walking away from an +# install meant coming back to a prompt that had been waiting an hour. +# +# So Panama asks first and then runs untouched. Everything interactive lives +# here, at the front, where the answers are cheap to change and nothing has been +# installed yet. +# +# Answers are NOT remembered between runs. There is no state file to go stale, and +# nothing personal is committed, which is what keeps this repository something +# somebody else could clone. Re-answering a handful of questions costs less than +# maintaining an answers file that drifts out of date. +# +# This asks only what a stage in this repository actually consumes. Extras, +# hardware and debloat questions arrive with the stages that act on them; a prompt +# whose answer nothing reads is a control that lies. + +set -uo pipefail + +# install passes the path. Refusing to guess one keeps the answers where the +# caller can delete them, rather than somewhere this script invented. +answers="${PANAMA_ANSWERS:-}" +[[ -n "$answers" ]] || { printf 'interview: PANAMA_ANSWERS is not set; run this through ./install\n' >&2; exit 1; } +: >"$answers" + +# %q so a value containing a space, a quote or a dollar sign survives being +# sourced by install exactly as it was typed. +record() { printf '%s=%q\n' "$1" "$2" >>"$answers"; } + +heading() { gum style --bold --foreground 4 "$1"; } +ask() { gum input --header "$1" --placeholder "${2:-}"; } +yes_no() { gum confirm --default=false "$1"; } + +# ── Machine ────────────────────────────────────────────────────────────────── + +heading "This machine" +current_hostname="$(hostname)" +printf 'Current hostname: %s\n' "$current_hostname" +new_hostname="" +if yes_no "Change the hostname?"; then + new_hostname="$(ask "Hostname" "$current_hostname")" +fi +record PANAMA_HOSTNAME "$new_hostname" + +# ── Identity ───────────────────────────────────────────────────────────────── +# +# Left blank, each of these keeps whatever git already has. That matters on a +# re-run: the prompts start empty every time by design, and an empty answer must +# not wipe a name that was already correct. + +heading "Git identity" +printf 'Leave any of these blank to keep the current setting.\n' +git_name="$(ask "Git user.name")" +git_email="$(ask "Git user.email")" +git_editor="$(ask "Git editor" "nvim")" +record PANAMA_GIT_NAME "$git_name" +record PANAMA_GIT_EMAIL "$git_email" +record PANAMA_GIT_EDITOR "$git_editor" + +# ── Accounts and keys ──────────────────────────────────────────────────────── +# +# These two are asked only when they would do something. Checking whether a +# credential already exists is not the same as remembering a previous answer -- +# it is refusing to ask a question whose answer is already on the machine. + +heading "Accounts" +gh_login=no +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + printf 'GitHub CLI is already signed in.\n' +elif yes_no "Sign in to GitHub after packages are installed?"; then + gh_login=yes +fi +record PANAMA_GH_LOGIN "$gh_login" + +ssh_key=no +if compgen -G "$HOME/.ssh/id_*.pub" >/dev/null 2>&1; then + printf 'An SSH key already exists.\n' +elif yes_no "Generate an SSH key?"; then + ssh_key=yes +fi +record PANAMA_SSH_KEY "$ssh_key" + +# ── Confirm ────────────────────────────────────────────────────────────────── +# +# The last chance to catch a typo before twenty minutes of package work that +# nobody is watching. + +shown() { [[ -n "$1" ]] && printf '%s' "$1" || printf 'unchanged'; } + +heading "Ready" +gum style --border rounded --padding "0 1" "$( + printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}" + printf 'Git name %s\n' "$(shown "$git_name")" + printf 'Git email %s\n' "$(shown "$git_email")" + printf 'Git editor %s\n' "$(shown "$git_editor")" + printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")" + printf 'SSH key %s' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")" +)" + +if ! gum confirm --default=true "Install with these answers?"; then + printf 'interview: cancelled; nothing was installed.\n' >&2 + exit 1 +fi diff --git a/setup/scripts/setup-identity b/setup/scripts/setup-identity new file mode 100755 index 0000000..a54656c --- /dev/null +++ b/setup/scripts/setup-identity @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Who this machine belongs to: git identity, GitHub, and an SSH key. +# +# Runs last, because gh and git-all arrive with install-packages. Everything here +# is driven by answers the interview collected before the run started, so nothing +# in this stage blocks waiting for input -- except `gh auth login`, which is an +# interactive browser flow by nature and only runs when it was asked for. +# +# Every value is optional. A blank answer means "keep whatever is already set", +# which is what makes this safe to re-run: the interview's prompts start empty +# every time by design, and an empty answer must never erase a correct name. + +set -uo pipefail + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } + +git_name="${PANAMA_GIT_NAME:-}" +git_email="${PANAMA_GIT_EMAIL:-}" +git_editor="${PANAMA_GIT_EDITOR:-}" + +if [[ -n "$git_name" ]]; then + git config --global user.name "$git_name" + log "git user.name set to $git_name" +fi +if [[ -n "$git_email" ]]; then + git config --global user.email "$git_email" + log "git user.email set to $git_email" +fi +if [[ -n "$git_editor" ]]; then + git config --global core.editor "$git_editor" + log "git core.editor set to $git_editor" +fi + +# Aliases and pull behaviour, carried over from sunhat. Setting these is +# idempotent, so they are applied unconditionally rather than asked about. +git config --global alias.co checkout +git config --global alias.br branch +git config --global alias.ci commit +git config --global alias.st status +git config --global pull.rebase true +log "git aliases and pull.rebase applied" + +if [[ "${PANAMA_GH_LOGIN:-no}" == yes ]]; then + if command -v gh >/dev/null 2>&1; then + log "Signing in to GitHub" + gh auth login || log "GitHub sign-in did not complete; run 'gh auth login' later" + else + log "gh is not installed; skipping GitHub sign-in" + fi +fi + +if [[ "${PANAMA_SSH_KEY:-no}" == yes ]]; then + key="$HOME/.ssh/id_ed25519" + if [[ -e "$key" ]]; then + log "An SSH key already exists at $key; leaving it alone" + else + mkdir -p "$HOME/.ssh" + chmod 700 "$HOME/.ssh" + # No passphrase prompt: this stage runs inside an install that was + # promised to need no attention. A key can be given a passphrase later + # with ssh-keygen -p. + ssh-keygen -t ed25519 -N "" -C "${git_email:-$USER@$(hostname)}" -f "$key" >/dev/null + log "SSH key generated at $key" + log "Public key: $(cat "$key.pub")" + fi +fi diff --git a/tests/quickshell/declared-dependencies-contract.sh b/tests/quickshell/declared-dependencies-contract.sh index da35851..cd308d7 100755 --- a/tests/quickshell/declared-dependencies-contract.sh +++ b/tests/quickshell/declared-dependencies-contract.sh @@ -55,6 +55,8 @@ package_for() { dnf4) printf 'python3-dnf' ;; notify-send) printf 'libnotify' ;; wl-copy|wl-paste) printf 'wl-clipboard' ;; + ssh-keygen) printf 'openssh' ;; + ssh|ssh-add) printf 'openssh-clients' ;; rg) printf 'ripgrep' ;; xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;; update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;; diff --git a/tests/setup/interview-contract b/tests/setup/interview-contract new file mode 100755 index 0000000..9f828bd --- /dev/null +++ b/tests/setup/interview-contract @@ -0,0 +1,118 @@ +#!/usr/bin/env bash + +# The interview asks, the stages consume, and nothing survives the run. +# +# Three properties matter enough to pin: +# +# 1. Every question maps to a stage that reads its answer. A prompt whose +# answer nothing consumes is a control that lies -- the same defect this +# repository refused to ship on the SSH Keys page -- and it is an easy one +# to introduce, because asking is cheap and wiring up is not. +# 2. Every answer a stage reads is one the interview asks. The reverse gap is +# quieter and worse: the stage silently takes its fallback forever. +# 3. The answers file is deleted on every exit path. It carries an email +# address, and it is transient by design -- there is deliberately no +# remembered state between runs. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +interview="$repo_dir/setup/scripts/interview" +install_script="$repo_dir/install" + +findings=() +note() { findings+=("$1"); } + +# ── 1 & 2. Questions and consumers agree ───────────────────────────────────── + +asked="$(grep -oE '^record [A-Z_]+' "$interview" | awk '{print $2}' | sort -u)" + +# Stages read answers as ${PANAMA_FOO:-default}; install re-exports them. +consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$install_script" 2>/dev/null \ + | sed 's/^\${//' | sort -u)" + +# Not answers: paths the installer sets up for itself. +INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR)$' + +while read -r key; do + [[ -n "$key" ]] || continue + grep -qx "$key" <<<"$consumed" \ + || note "the interview asks for $key, but no stage ever reads it" +done <<<"$asked" + +while read -r key; do + [[ -n "$key" ]] || continue + [[ "$key" =~ $INFRASTRUCTURE ]] && continue + grep -qx "$key" <<<"$asked" \ + || note "a stage reads $key, but the interview never asks for it" +done <<<"$consumed" + +# ── 3. Nothing is left behind ──────────────────────────────────────────────── + +grep -q 'trap cleanup EXIT INT TERM' "$install_script" \ + || note 'install does not arm a cleanup trap on EXIT INT TERM' +grep -q 'rm -f "$PANAMA_ANSWERS"' "$install_script" \ + || note 'the cleanup trap does not delete the answers file' +grep -qE 'mktemp' "$install_script" \ + || note 'install does not create the answers file with mktemp' + +# Declining must stop the run rather than count as one failed stage among five. +grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \ + || note 'install does not treat a declined interview as fatal' + +# ── 4. A real run, with gum stubbed ────────────────────────────────────────── +# +# The interview is built on gum, which needs a terminal. Standing in a stub on +# PATH exercises the actual script -- its ordering, its quoting, and the file it +# writes -- rather than asserting things about its source text. + +stub_dir="$(mktemp -d)" +answers_file="$(mktemp)" +trap 'rm -rf "$stub_dir" "$answers_file"' EXIT + +cat >"$stub_dir/gum" <<'STUB' +#!/usr/bin/env bash +case "$1" in + input) printf '%s\n' "$GUM_STUB_INPUT" ;; + confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;; + style) shift; printf '%s\n' "${@: -1}" ;; + *) exit 0 ;; +esac +STUB +chmod +x "$stub_dir/gum" + +# A value containing a space and a quote, to prove %q survives being sourced. +GUM_STUB_INPUT="O'Brien Test" GUM_STUB_CONFIRM=yes \ + PANAMA_ANSWERS="$answers_file" PATH="$stub_dir:$PATH" \ + bash "$interview" >/dev/null 2>&1 +interview_status=$? + +(( interview_status == 0 )) || note "the interview exited $interview_status on a run that answered everything" + +# Sourcing it back must reproduce the value exactly, not a mangled fragment. +( + # shellcheck source=/dev/null + source "$answers_file" + [[ "${PANAMA_GIT_NAME:-}" == "O'Brien Test" ]] +) || note 'an answer containing a quote and a space does not survive being sourced' + +# Declining at the confirmation must fail, so install stops. +GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=no \ + PANAMA_ANSWERS="$answers_file" PATH="$stub_dir:$PATH" \ + bash "$interview" >/dev/null 2>&1 \ + && note 'declining the final confirmation still exits zero, so install would proceed' + +# Refusing to invent an answers path keeps the file where the caller can delete it. +PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1 \ + && note 'the interview runs without PANAMA_ANSWERS instead of refusing' + +# ── Report ─────────────────────────────────────────────────────────────────── + +if (( ${#findings[@]} > 0 )); then + mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u) + printf 'interview contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'interview contract: PASS\n'