#!/usr/bin/env bash # Panama's installer. Safe to re-run: every stage is idempotent, and this is # also the upgrade path. # # ./install A machine being built. Asks the interview, runs # every stage, enrolls hardware. # ./install --upgrade A machine that already exists. Asks nothing. # # Two entry points, one stage list, deliberately in one file. `panama update` # passes --upgrade; if the upgrade path owned a second copy of STAGES the two # would drift the first time somebody added a stage to one of them, and the # symptom would be a stage that silently never runs. Keeping the lists together # means the decision about which path owns a new stage is made in view of the # other one. # # What --upgrade changes, and nothing else: # # * The interview is skipped, so every PANAMA_* answer is unset and each # stage takes its documented empty-answer path. Six of the eight need no # answer at all; link-user falls back to the decision it recorded. # * setup-identity and install-hardware are dropped. They exist only to # consume interview answers -- git identity, NVIDIA, Secure Boot, firmware # -- and every one of those is a first-run decision. # * install-packages runs only when its tracked installation inputs changed. # * Migrations always run rather than baseline. See the migrations block. # # Everything else is shared on purpose: the sudo keepalive, the per-stage # failure collection, migrations, the health summary and the post-upgrade hook. set -uo pipefail PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" UPGRADE=0 FORCE_PACKAGES=0 ROLE_PRESET="" for arg in "$@"; do case "$arg" in --upgrade) UPGRADE=1 ;; --packages) FORCE_PACKAGES=1 ;; --server) ROLE_PRESET=server ;; -h|--help) cat <<'USAGE' usage: install [--upgrade] [--packages] [--server] (no arguments) Build this machine. Asks the interview, runs every stage. --upgrade Update a machine that already exists. Asks nothing, and skips setup-identity and install-hardware. --packages Run install-packages even when the lists are unchanged. Only meaningful with --upgrade; a full install always runs it. --server Answer the interview's role question with 'server' without being asked -- the curl-onto-a-fresh-VPS path. The rest of the interview still runs. Meaningless with --upgrade, which reads the role this machine already recorded. USAGE exit 0 ;; *) printf 'install: unknown argument: %s\n' "$arg" >&2 printf "Run './install --help' to see what it takes.\n" >&2 exit 2 ;; esac done source "$PANAMA_PATH/bin/ascii" # ── Have the installation inputs changed? ─────────────────────────────────── # # install-packages is the slow stage -- a dnf metadata refresh, a Flathub # round-trip, and a transaction that resolves to "nothing to do" almost every # time. On an upgrade it is worth running only when its package lists or # reviewed installer trust inputs changed, so this hashes them and remembers # the result. The framed, sorted stream includes top-level package files, the # package-stage adapter, the provenance helper, and regular provenance files; # both relative paths and bytes are part of the state. # # A content hash rather than a git range, because Panama is developed in place: # a package added to a list and not yet committed must still install. A range # check would see nothing, and the package would arrive whenever the commit # happened to be pulled somewhere else. # # -maxdepth 1 excludes setup/packages/extras/. An answer-free run has an empty # PANAMA_EXTRAS and installs no optional category, so hashing those files would # flip the hash, run the stage, install nothing, and record the new hash as # though it had. Optional categories cannot be re-applied by an upgrade at all # -- which ones this machine chose is nowhere on disk, because the interview's # answers are deliberately transient -- and `panama apps` is the tool for that. STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama" PACKAGES_HASH="$STATE_DIR/packages-hash" hash_packages() { local file relative size fixed_input digest for fixed_input in \ "$PANAMA_PATH/setup/scripts/install-packages" \ "$PANAMA_PATH/setup/lib/artifact-provenance"; do [[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1 done digest="$( { find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1 printf '%s\0' \ "$PANAMA_PATH/setup/scripts/install-packages" \ "$PANAMA_PATH/setup/lib/artifact-provenance" || exit 1 find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1 } | LC_ALL=C sort -z | while IFS= read -r -d '' file; do relative="${file#"$PANAMA_PATH"/}" size="$(wc -c <"$file")" || exit 1 printf '%s\0%s\0' "$relative" "$size" || exit 1 cat -- "$file" || exit 1 printf '\0' || exit 1 done | sha256sum | cut -d' ' -f1 )" || return 1 printf '%s\n' "$digest" } packages_needed() { local current_hash recorded_hash (( FORCE_PACKAGES )) && return 0 (( UPGRADE )) || return 0 [[ -r "$PACKAGES_HASH" ]] || return 0 current_hash="$(hash_packages)" || return 2 recorded_hash="$(cat "$PACKAGES_HASH")" || return 2 [[ "$current_hash" != "$recorded_hash" ]] } # Written only after the stage succeeds, mirroring the rule panama-migrate # documents for its markers: a step that did not complete has not happened, and # recording it as done hides it forever. record_packages_hash() { local temporary_hash mkdir -p "$STATE_DIR" temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1 if hash_packages >"$temporary_hash"; then mv -f -- "$temporary_hash" "$PACKAGES_HASH" else rm -f -- "$temporary_hash" return 1 fi } # Repository trust is checked before the installer can reach its bootstrap DNF. # Status 78 is reserved for a trust-root failure and is propagated unchanged so # no later stage, especially install-hardware, can invoke DNF with that repo. TERRA_TRUST_FAILURE_STATUS=78 trust_preflight="$PANAMA_PATH/setup/scripts/install-packages" if [[ ! -x "$trust_preflight" ]]; then printf 'install: package repository trust preflight is unavailable\n' >&2 exit "$TERRA_TRUST_FAILURE_STATUS" fi if "$trust_preflight" --trust-preflight; then : else trust_status=$? printf 'install: package repository trust preflight failed\n' >&2 exit "$trust_status" fi # ── The interview ──────────────────────────────────────────────────────────── # # 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. # # The interview's tools are bootstrapped first because it cannot install them # itself -- they are declared in the package lists, which install-packages # installs, which runs after this. gum is the interface; pciutils, mokutil and # fwupd are the interview's eyes. # It probes for an NVIDIA card, Secure Boot state and updatable firmware # BEFORE install-packages runs, and a missing probe tool degrades the answer # silently to "no" -- which for Secure Boot once meant installing a driver # that could never load. Workstation ships all four; a minimal base does not. # Gated exactly like the interview itself: under --upgrade no questions are # asked, so nothing here is used, and a machine that cannot install gum must # not have that stop an upgrade that never needed it. if (( ! UPGRADE )); then bootstrap=() command -v gum >/dev/null 2>&1 || bootstrap+=(gum) # The probe tools serve only the hardware questions, which a server is never # asked -- installing lspci on a VPS to not use it would be the interview # costing packages the machine has no reason to carry. Only gated when the # role is already known; a plain ./install on a server still bootstraps them, # harmlessly, because the role is not known until the interview answers. if [[ "$ROLE_PRESET" != server ]]; then command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils) command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil) command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd) fi if (( ${#bootstrap[@]} > 0 )); then echo "Installing what the setup questions are built on: ${bootstrap[*]}" sudo dnf install -y "${bootstrap[@]}" >/dev/null || { echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2 exit 1 } fi fi # ── Keep the machine awake for the duration ────────────────────────────────── # Package installation takes long enough to hit an idle lock, and being locked # 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. 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" [[ -n "${SUDO_KEEPALIVE:-}" ]] && kill "$SUDO_KEEPALIVE" 2>/dev/null } trap cleanup EXIT # A bare `trap cleanup INT` is not an abort: bash runs the handler and then # carries on with the script, so Ctrl-C would kill only the current stage and # the remaining ones -- MOK enrollment, firmware -- would still run. Exit # explicitly instead; the EXIT trap above does the actual cleanup. trap 'exit 130' INT trap 'exit 143' 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 # ── Stages ─────────────────────────────────────────────────────────────────── # Each runs in its own process so strict-shell options and helper variables stay # local to the script that owns them. A failing stage is reported and the rest # still run: a missing optional package should not stop the dotfiles being # linked. The summary at the end is what decides whether the install worked, # because a failure scrolled past twenty minutes ago is a failure nobody saw. # # 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); # link-user runs before setup-identity so tracked personal content wins over # what the interview would otherwise seed, and after link-skills so a personal # skill wins a name collision with a shipped one; setup-identity needs the gh and # git-all that install-packages provides; and # install-hardware is last because MOK enrollment arms a prompt consumed at the # next boot and a firmware update may ask for a reboot -- a machine that reboots # out of the final stage has already been completely configured. New scripts # must be added here explicitly, or they will not run at all. # 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. # # Skipped entirely under --upgrade. Nothing is exported, so every answer below # is unset and each stage takes the empty-answer path it already documents -- # which is why this is a flag rather than a rewrite of seven stage scripts. if (( ! UPGRADE )); then PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)" export PANAMA_ANSWERS if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then exit 1 fi # shellcheck source=/dev/null source "$PANAMA_ANSWERS" export PANAMA_ROLE PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL \ PANAMA_GIT_EDITOR PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA \ PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS \ PANAMA_USER_CONTENT fi # ── The role ───────────────────────────────────────────────────────────────── # # The one interview answer that outlives the run, because every later # `panama update` runs with no interview and still has to know which machine # this is. A fresh install records what was just answered; an upgrade reads # what an earlier install recorded, defaulting to desktop -- which is what # every machine that predates roles is. Exported so each stage sees the same # answer through setup/lib/machine-role without re-deriving it. # shellcheck source=setup/lib/machine-role source "$PANAMA_PATH/setup/lib/machine-role" if (( ! UPGRADE )); then PANAMA_ROLE="${PANAMA_ROLE:-desktop}" panama_role_record "$PANAMA_ROLE" else PANAMA_ROLE="$(panama_role)" fi export PANAMA_ROLE # One password, before anything long runs, and then never again. The stages # call sudo dozens of times across twenty-plus minutes, and the timestamp # expires five minutes after whichever call came last -- so a single dnf step # that outlasts it turned the next stage into a password prompt nobody was # there to answer. The refresher holds the timestamp open for exactly as long # as this script lives; cleanup() kills it on every exit path, so nothing # outlives the install with ambient credentials. if (( UPGRADE )); then echo "Panama needs administrator rights to apply system settings and packages." else echo "Panama needs administrator rights for the rest of the run." fi sudo -v || exit 1 ( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) & SUDO_KEEPALIVE=$! # Applied here rather than in a stage, and applied early: it needs sudo, and # sudo is warm right now. if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then sudo hostnamectl set-hostname "$PANAMA_HOSTNAME" echo "Hostname set to: $(hostname)" fi # One list per role, chosen whole rather than filtered from a superset, so # what a server runs is readable here rather than derived. A server gets the # shared stages plus its own two; it never links skills (all three shipped # skills operate the desktop), never touches gsettings or Vicinae, and has no # hardware stage -- NVIDIA, Secure Boot and firmware are first-boot desktop # concerns. setup-server runs after packages (it needs podman and firewalld # installed) and link-server after that, so the units it links land on a # machine already able to run them. if [[ "$PANAMA_ROLE" == server ]]; then STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity) else STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware) fi # The two an upgrade drops. Both exist only to act on interview answers, and # both are first-run decisions: who you are and what hardware this is. Filtered # by name rather than by position so reordering STAGES cannot silently change # which stages an upgrade runs. if (( UPGRADE )); then upgrade_stages=() for stage in "${STAGES[@]}"; do case "$stage" in setup-identity|install-hardware) continue ;; esac upgrade_stages+=("$stage") done STAGES=("${upgrade_stages[@]}") fi failed=() for stage in "${STAGES[@]}"; do script="$PANAMA_PATH/setup/scripts/$stage" [[ -x "$script" ]] || continue printf '\n=== %s ===\n' "$stage" if [[ "$stage" == install-packages ]]; then package_state_status=0 packages_needed || package_state_status=$? if (( package_state_status == 1 )); then echo "The package lists have not changed since the last run; skipping." echo "Run with --packages to install them anyway." continue elif (( package_state_status != 0 )); then failed+=("$stage") printf '!!! %s could not read its tracked installation inputs\n' "$stage" >&2 continue fi fi if "$script"; then if [[ "$stage" == install-packages ]]; then if ! record_packages_hash; then failed+=("$stage") printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2 fi fi else stage_status=$? if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2 exit "$stage_status" fi failed+=("$stage") printf '!!! %s failed\n' "$stage" >&2 fi done # ── Migrations ─────────────────────────────────────────────────────────────── # # Repairs for machines that installed an older Panama: removing a file this # repository stopped shipping, disabling a unit it stopped wanting. The # installer itself cannot do any of that, because it only ever adds. # # A machine that has never seen migrations before is one of two things, and # the difference matters. If it has no marker directory at all it was just # built from THIS checkout, so every repair those migrations describe is # already true of it -- they are marked applied without running, exactly as # Migrations.qml stamps a pre-versioning settings file at its baseline rather # than replaying upgrades it never needed. Otherwise the pending ones run. # # That inference is only sound during a real install. Under --upgrade the # machine demonstrably existed before this run, so an absent marker directory # means it predates migrations entirely -- exactly the machine the repairs were # written for -- and baselining would skip every one of them forever. Every # migration is self-guarding and a no-op where it does not apply, so running # them is the safe direction. migrate="$PANAMA_PATH/bin/panama-migrate" if [[ -x "$migrate" ]]; then printf '\n=== migrations ===\n' if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then "$migrate" run || failed+=(migrations) else "$migrate" --baseline || true fi fi # ── 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. # On a server the quickshell doctor would report a desktop that was never # installed; what is actually running there is the container services, and # panama-server status is the check that answers for them. if [[ "$PANAMA_ROLE" == server ]]; then server_status="$PANAMA_PATH/bin/panama-server" if [[ -x "$server_status" ]]; then printf '\n=== health ===\n' "$server_status" status || true fi else doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor" if [[ -x "$doctor" ]]; then printf '\n=== health ===\n' "$doctor" --summary || true fi fi # Whatever this particular machine wants doing that Panama should not carry for # everyone. Runs last, after every stage, migrations and the health summary. hook="$PANAMA_PATH/bin/panama-hook" [[ -x "$hook" ]] && "$hook" post-upgrade || true printf '\n' if (( UPGRADE )); then retry='panama update' else retry='./install' fi if (( ${#failed[@]} == 0 )); then if (( UPGRADE )); then echo "Panama is up to date." elif [[ "$PANAMA_ROLE" == server ]]; then echo "Panama installed. Enable a service with: panama server enable " else echo "Panama installed. Log out and choose the Hyprland session to start it." fi else if (( UPGRADE )); then printf 'Panama updated with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 else printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 fi printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2 printf 'If it fails again, hand it to an agent: panama diagnose\n' >&2 exit 1 fi