A machine's role is now the interview's first question and the one answer Panama records. Servers get the same shell minus the screen: core packages, nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless ports from 80, firewalld, the nginx-bridge network, and a nightly image updater that replaced watchtower for cause. server/containers/ carries junior's 23 compose services -- secrets moved to per-machine .env files that never enter this public repo, every transformed compose proven to render byte-identical to what is live. 'panama server' enables, disables and relinks them; nothing here restarts a running service. 'boot --server' walks a fresh VPS from its root login to a normal install. Five new contracts pin the secrets rule, the catalog's shape, panama-server's behavior, the role plumbing, and the dotfile classification. Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
287 lines
13 KiB
Bash
Executable File
287 lines
13 KiB
Bash
Executable File
#!/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. A prompt
|
|
# whose answer nothing reads is a control that lies.
|
|
#
|
|
# The hardware questions name what was found rather than asking a person to
|
|
# recite their own machine, and they are not asked at all on a machine they
|
|
# would do nothing to. Detection alone would be worse: it would remove the
|
|
# ability to decline a proprietary driver on a machine that has the card.
|
|
|
|
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"
|
|
|
|
# The role decides most of what follows: a server is never asked about NVIDIA
|
|
# drivers or Steam, and a desktop is never asked about compose services. It is
|
|
# also the one answer that outlives the run -- install records it durably,
|
|
# because `panama update` asks nothing and still has to know which machine it
|
|
# is updating. See setup/lib/machine-role.
|
|
#
|
|
# PANAMA_ROLE_PRESET is how `./install --server` answers this without a prompt,
|
|
# for the curl-onto-a-fresh-VPS path where the caller already said what the
|
|
# machine is. An empty or escaped choice falls back to desktop, which is what
|
|
# every Panama machine was before roles existed.
|
|
role="${PANAMA_ROLE_PRESET:-}"
|
|
if [[ -z "$role" ]]; then
|
|
role="$(gum choose --header "What is this machine?" "desktop" "server")" || role=""
|
|
fi
|
|
[[ "$role" == server ]] || role=desktop
|
|
record PANAMA_ROLE "$role"
|
|
|
|
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"
|
|
|
|
# ── Hardware ─────────────────────────────────────────────────────────────────
|
|
#
|
|
# Each question names what was detected, so declining is a decision about this
|
|
# machine rather than an answer to a hypothetical. A machine with no NVIDIA card
|
|
# is never asked about drivers, and one with nothing to remove is never asked
|
|
# about removing it.
|
|
#
|
|
# A server is asked none of it. The stages these answers feed --
|
|
# install-hardware, the debloat removal -- do not run on the server path at
|
|
# all, and a question whose answer nothing consumes is a control that lies.
|
|
# The defaults are still recorded so the answers file has the same shape
|
|
# either way.
|
|
|
|
nvidia=no
|
|
mok_hash=""
|
|
installed=()
|
|
firmware=no
|
|
debloat=no
|
|
|
|
if [[ "$role" != server ]]; then
|
|
|
|
heading "Hardware"
|
|
|
|
nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)"
|
|
|
|
if [[ -n "$nvidia_card" ]]; then
|
|
if yes_no "Found $nvidia_card — install the NVIDIA driver?"; then
|
|
nvidia=yes
|
|
|
|
# Only asked where it does something. On a machine with Secure Boot off,
|
|
# akmods' signature is never checked and enrolling a key is ceremony.
|
|
if mokutil --sb-state 2>/dev/null | grep -qi 'secureboot enabled'; then
|
|
printf 'Secure Boot is on, so the driver must be signed with a key you enroll.\n'
|
|
printf 'The next boot will ask for this password on a blue screen.\n'
|
|
if yes_no "Enroll a machine owner key?"; then
|
|
# Hashed here and only the hash recorded. The password never
|
|
# reaches the answers file, the environment, or a command line
|
|
# -- mokutil takes a hash file precisely so it does not have to.
|
|
while :; do
|
|
first="$(gum input --password --header "MOK password")"
|
|
if [[ -z "$first" ]]; then
|
|
printf 'No password given; skipping enrollment.\n'
|
|
break
|
|
fi
|
|
second="$(gum input --password --header "MOK password again")"
|
|
if [[ "$first" == "$second" ]]; then
|
|
# Fed on stdin, never as an argument: an argument sits
|
|
# in /proc/<pid>/cmdline for any local process to read
|
|
# while mokutil runs. mokutil prints its two prompts on
|
|
# stdout too, so the hash is the last line.
|
|
mok_hash="$(printf '%s\n%s\n' "$first" "$first" \
|
|
| mokutil --generate-hash | tail -n 1)"
|
|
break
|
|
fi
|
|
printf 'Those did not match.\n'
|
|
done
|
|
unset first second
|
|
fi
|
|
fi
|
|
fi
|
|
else
|
|
printf 'No NVIDIA card found.\n'
|
|
fi
|
|
|
|
# The stage that removes them owns the list, so there is one copy of it.
|
|
mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list)
|
|
for package in "${removable[@]}"; do
|
|
rpm -q "$package" >/dev/null 2>&1 && installed+=("$package")
|
|
done
|
|
if (( ${#installed[@]} > 0 )); then
|
|
if yes_no "Remove Fedora's preinstalled extras (${installed[*]})?"; then
|
|
debloat=yes
|
|
fi
|
|
fi
|
|
|
|
if command -v fwupdmgr >/dev/null 2>&1; then
|
|
if yes_no "Update firmware with fwupdmgr?"; then
|
|
firmware=yes
|
|
fi
|
|
fi
|
|
|
|
fi # role != server
|
|
|
|
record PANAMA_NVIDIA "$nvidia"
|
|
record PANAMA_MOK_HASH "$mok_hash"
|
|
record PANAMA_DEBLOAT "$debloat"
|
|
record PANAMA_FIRMWARE "$firmware"
|
|
|
|
# ── Applications ─────────────────────────────────────────────────────────────
|
|
#
|
|
# Everything else in this repository is what every Panama machine gets. This is
|
|
# the one question about what this machine is for: a work laptop should not
|
|
# acquire emulators and a desktop should not skip Steam.
|
|
#
|
|
# The categories are read from the directory rather than listed here, so adding
|
|
# one is adding a file. Nothing is preselected -- a default here would install
|
|
# applications nobody chose, on a machine whose owner answered a question they
|
|
# thought was about something else.
|
|
|
|
extras=""
|
|
if [[ "$role" != server ]]; then
|
|
|
|
heading "Applications"
|
|
|
|
extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras"
|
|
if [[ -d "$extras_dir" ]]; then
|
|
mapfile -t categories < <(for file in "$extras_dir"/*; do
|
|
[[ -f "$file" ]] && basename "$file"
|
|
done)
|
|
if (( ${#categories[@]} > 0 )); then
|
|
printf 'Optional application categories. Space to select, enter to accept.\n'
|
|
extras="$(gum choose --no-limit --header "Extras" "${categories[@]}" | tr '\n' ' ')"
|
|
extras="${extras% }"
|
|
fi
|
|
fi
|
|
|
|
fi # role != server
|
|
record PANAMA_EXTRAS "$extras"
|
|
|
|
# ── Personal content ─────────────────────────────────────────────────────────
|
|
#
|
|
# user/ holds whoever-owns-this-checkout's personal files: agent instructions,
|
|
# SSH host aliases, expansion triggers. Linking them is how one person keeps
|
|
# several machines identical, and it is exactly the wrong thing to do to
|
|
# somebody who just cloned this repository to try the desktop out.
|
|
#
|
|
# So it is asked rather than assumed, the question names what it would link,
|
|
# and no is the default. Someone who forks Panama replaces user/ with their own
|
|
# and starts answering yes.
|
|
|
|
user_content=no
|
|
if [[ -r "$(dirname "${BASH_SOURCE[0]}")/../../user/manifest" ]]; then
|
|
mapfile -t user_targets < <(
|
|
grep -vE '^\s*(#|$)' "$(dirname "${BASH_SOURCE[0]}")/../../user/manifest" \
|
|
| awk '{ print $3 }' | sort -u
|
|
)
|
|
if (( ${#user_targets[@]} > 0 )); then
|
|
printf 'This checkout carries personal content for: %s\n' "${user_targets[*]}"
|
|
printf 'Say no unless this checkout is yours.\n'
|
|
if yes_no "Link this checkout's personal content into your home?"; then
|
|
user_content=yes
|
|
fi
|
|
fi
|
|
fi
|
|
record PANAMA_USER_CONTENT "$user_content"
|
|
|
|
# ── 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 'Role %s\n' "$role"
|
|
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\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")"
|
|
# Hardware and extras were never asked on a server, and a summary line for
|
|
# a question that was not asked reads as a decision that was not made.
|
|
if [[ "$role" != server ]]; then
|
|
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
|
|
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
|
|
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
|
|
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
|
|
printf 'Extras %s\n' "${extras:-none}"
|
|
fi
|
|
printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")"
|
|
)"
|
|
|
|
if ! gum confirm --default=true "Install with these answers?"; then
|
|
printf 'interview: cancelled; nothing was installed.\n' >&2
|
|
exit 1
|
|
fi
|