#!/usr/bin/env bash set -euo pipefail # --- Helper functions --- log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } exists() { command -v "$1" >/dev/null 2>&1; } # The package names in a list, without the comments that explain them. # # The lists are annotated -- which package exists for which settings page, why # an exception was made -- and those annotations are for whoever reads the file # next. dnf is not so forgiving: it does not ignore an argument it cannot # match, it reports "No match for argument: #" and exits 1, and with `set -e` # above that ends this stage on the first annotated list it reaches. # # It could not be seen from here. On a machine that already has everything, a # re-run matches every real name and fails only on the comments; and every # contract that reads these lists strips comments before comparing, so the # tests were reading a file this script was not. packages_in() { sed 's/#.*//' "$1" | tr "\n" " " } # Names a list asked for that still are not installed, so --skip-unavailable # above can never silently shrink a list: a skipped font is a warning somebody # reads, not an absence somebody debugs a month later. report_missing() { local file="$1" name missing=() for name in $(packages_in "$file"); do # Three ways a list entry can be satisfied: it is a package name # (rpm -q), a capability another package provides (--whatprovides, # e.g. wget -> wget2-wget), or a bare command name provided as a file # path (command -v, e.g. awk -> /usr/bin/awk from gawk, which # --whatprovides misses because the provide is the path, not the word). rpm -q --whatprovides "$name" >/dev/null 2>&1 && continue command -v "$name" >/dev/null 2>&1 && continue missing+=("$name") done (( ${#missing[@]} > 0 )) && log "WARNING: not available on this machine: ${missing[*]}" return 0 } # Runs something whose failure must not cost you the desktop. # # `set -e` above is right for the packages Panama cannot work without and wrong # for everything else. A codec swap that finds nothing to swap, a group update # renamed upstream, a third-party host that is down -- each of those used to end # this stage wherever it happened to sit, and the desktop was installed near the # bottom, so any one of them meant a machine with no Hyprland on it and a single # line of dnf output to explain why. # # So the ordering rule for this file: anything that can fail for a reason # outside this repository goes below the desktop, and goes through here. # stdout only. Swallowing stderr here would hide the one line that says WHY a # step was stepped over -- and worse, every one of these runs under sudo, whose # password prompt is the thing you would be hiding on a machine that asks for # one. soft() { local what="$1"; shift "$@" >/dev/null || { log "$what did not complete; continuing"; softly_failed+=("$what"); } } softly_failed=() # --- Defined Paths --- # The default, not an assignment: ./install and link-dotfiles honor an exported # PANAMA_PATH, and clobbering it here made a clone anywhere else source the # extras catalog from a path that does not exist. PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" # Kept as a named path so the hermetic contract can redirect reads after # sourcing this file. Normal installer execution always resets it to /etc. PANAMA_SYSTEM_ETC=/etc PANAMA_SYSTEM_FLATPAK_REPO=/var/lib/flatpak/repo # Reviewed installer data and verification primitives. The config parser treats # every value as inert data and rejects unknown, duplicate, or missing fields. # shellcheck source=../lib/artifact-provenance source "$PANAMA_PATH/setup/lib/artifact-provenance" load_installer_provenance "$PANAMA_PATH/setup/provenance/installers.conf" # Reading the extras catalog, shared with `panama apps` so the two front doors # cannot disagree about what a category contains. # shellcheck source=../lib/extras-catalog source "$PANAMA_PATH/setup/lib/extras-catalog" # Which machine this is. A server takes the short path below: core tools, # node, the agents -- no third-party repos, no desktop, no flatpaks. # shellcheck source=../lib/machine-role source "$PANAMA_PATH/setup/lib/machine-role" ROLE="$(panama_role)" # One list, installed the way every list is installed: --skip-unavailable so a # single rotted name cannot cost the transaction, then report_missing so a # skipped name is a warning somebody reads. install_list() { local file="$PANAMA_PATH/setup/packages/$1" label="$2" packages if [[ -f "$file" ]]; then packages=$(packages_in "$file") log "Installing $label Packages" echo -e "Includes the following packages:" echo -e "$(<"$file")" sudo dnf install -y --skip-unavailable $packages > /dev/null report_missing "$file" log "$label packages installed!" else log "Package list was not in specified path: $file" fi } # --- Node and pnpm, through nvm ---------------------------------------------- # # nvm is a shell function rather than a binary, so it has to be sourced before # it can be used at all -- and its script reads variables that `set -u` above # treats as fatal, so the strictness is lifted for exactly that source and put # straight back. # # Deliberately not dnf's nodejs: config/bash/shell switches Node per project # from .nvmrc, and a system Node earlier on PATH would win every switch, leaving # `nvm use` looking like it did nothing. # # pnpm goes inside the nvm-managed Node rather than beside it as its own dnf # package, so it travels with the version it belongs to instead of outliving it. setup_node() { if [[ -s /etc/profile.d/nvm.sh ]]; then log "Installing the latest Node LTS through nvm" set +u # shellcheck source=/dev/null source /etc/profile.d/nvm.sh if nvm install --lts >/dev/null 2>&1; then nvm alias default 'lts/*' >/dev/null 2>&1 || true npm install -g pnpm >/dev/null 2>&1 || { log "pnpm did not install"; softly_failed+=("pnpm"); } log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" else log "nvm could not install Node; skipping"; softly_failed+=("Node (nvm)") fi set -u else log "nvm is not installed, so Node was not set up" fi } # --- Applications no repository packages ------------------------------------- # # Everything else Panama installs comes from dnf or Flathub. These do not # exist in either, so each is an explicit exception with a reason, and each is # skipped when already present so a re-run costs nothing. # # None of them pins a version. sunhat pinned URLs -- upscayl 2.11.5, LACT 0.5.4, # a fedora-40 RPM -- and every one of them was a 404 within a release cycle. An # installer that resolves "latest" keeps working; one that names a version rots. # # A failure here is logged and stepped over rather than aborting: an # unreachable third-party host should not cost the rest of the run. # Bun: the JavaScript runtime and package manager. No RPM, no flatpak. install_bun() { if [[ -x "$HOME/.bun/bin/bun" ]]; then log "Bun already installed at \"$HOME/.bun/bin/bun\"" else log "Installing Bun via curl..." curl -fsSL https://bun.sh/install | bash > /dev/null 2>&1 || { log "Bun install failed; skipping"; softly_failed+=("Bun"); } fi } # Codex: OpenAI's CLI. Distributed through npm, which is why this runs after # setup_node -- the nvm-managed Node is the one it should land in. install_codex() { if command -v codex >/dev/null 2>&1; then log "Codex already installed at \"$(command -v codex)\"" elif command -v npm >/dev/null 2>&1; then log "Installing Codex via npm..." npm install -g @openai/codex >/dev/null 2>&1 || { log "Codex install failed; skipping"; softly_failed+=("Codex"); } else log "npm is not available, so Codex was not installed"; softly_failed+=("Codex") fi } # --- What was stepped over --------------------------------------------------- # # Tolerating a failure is only better than aborting on it if somebody is told. # The whole point of surviving a soft failure is that the rest gets installed # anyway -- but a machine missing something should say so once, here, rather # than be discovered a week later. report_soft_failures() { if (( ${#softly_failed[@]} > 0 )); then log "Installed, but these were stepped over:" printf ' - %s\n' "${softly_failed[@]}" log "None of them stops the machine, but this run is not recorded as" log "complete, so the next 'panama update' tries them again." # A step that did not complete has not happened. Exiting non-zero is what # keeps ./install from stamping the packages hash over the gaps -- stamped, # they would never be retried (the hash-skip would say nothing changed). exit 1 fi } # --- Reviewed third-party repositories ------------------------------------- _require_policy_value() { local name="$1" expected="$2" [[ "${INSTALLER_PROVENANCE[$name]:-}" == "$expected" ]] || { log "Installer provenance for $name does not match Panama's reviewed policy" return 1 } } require_reviewed_fedora_release() { local current _require_policy_value FEDORA_RELEASE 44 || return 1 current="$(rpm -E %fedora)" || return 1 [[ "$current" == "${INSTALLER_PROVENANCE[FEDORA_RELEASE]}" ]] || { log "Fedora $current is not reviewed for third-party repositories; expected ${INSTALLER_PROVENANCE[FEDORA_RELEASE]}" return 1 } } # RPM repository bootstrap packages and Flatpak descriptors are authenticated # after download rather than by a SHA-256 pin. Keep their untrusted bytes in a # private file, enforce the reviewed size limit, and publish the file only after # curl has completed successfully. _download_bounded() { local url="$1" max_bytes="$2" destination="$3" directory filename directory="$(dirname -- "$destination")" filename="$(basename -- "$destination")" ( local part="" trap '[[ -z "$part" ]] || rm -f -- "$part"' EXIT trap 'exit 130' INT trap 'exit 143' TERM [[ "$max_bytes" =~ ^[1-9][0-9]*$ && -d "$directory" ]] || exit 1 umask 077 part="$(mktemp "$directory/.${filename}.part.XXXXXX")" || exit 1 curl --fail --location --connect-timeout 10 --max-time 600 \ --max-filesize "$max_bytes" --output "$part" "$url" || exit 1 [[ -f "$part" && "$(stat -c %s "$part")" -le "$max_bytes" ]] || exit 1 mv -f -- "$part" "$destination" ) } _stage_reviewed_key() { local source_key="$1" destination="$2" fingerprint_name="$3" expected="$4" _require_policy_value "$fingerprint_name" "$expected" || return 1 cp -- "$source_key" "$destination" || return 1 chmod 0600 "$destination" key_fingerprint_matches "$destination" "${INSTALLER_PROVENANCE[$fingerprint_name]}" } _ini_value() { local file="$1" wanted_section="$2" wanted_key="$3" local -a values=() mapfile -t values < <(awk -v wanted_section="$wanted_section" -v wanted_key="$wanted_key" ' function trim(value) { sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } { sub(/\r$/, "") line = trim($0) if (line == "" || line ~ /^[#;]/) next if (line ~ /^\[[^]]+\]$/) { section = substr(line, 2, length(line) - 2) next } equals = index(line, "=") if (tolower(section) == tolower(wanted_section) && equals > 1) { key = trim(substr(line, 1, equals - 1)) if (tolower(key) == tolower(wanted_key)) print trim(substr(line, equals + 1)) } } ' "$file") (( ${#values[@]} > 0 )) || return 1 [[ ${#values[@]} -eq 1 && -n "${values[0]}" ]] || return 2 printf '%s\n' "${values[0]}" } _ini_section_count() { local file="$1" wanted_section="$2" awk -v wanted_section="$wanted_section" ' function trim(value) { sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } { sub(/\r$/, "") line = trim($0) if (line ~ /^\[[^]]+\]$/) { section = substr(line, 2, length(line) - 2) if (tolower(section) == tolower(wanted_section)) count++ } } END { print count + 0 } ' "$file" } _restore_repository_file() { local existed="$1" backup="$2" mode="$3" destination="$4" if (( existed )); then sudo install -m "$mode" "$backup" "$destination" else sudo rm -f -- "$destination" fi } # A key and its repository file form one trust root. If either activation # write fails after touching its target, restore both prior files or return both # targets to absence before reporting failure. _publish_repository_pair() { local staged_key="$1" key_destination="$2" staged_repo="$3" repo_destination="$4" local backup_dir key_backup repo_backup key_mode=0644 repo_mode=0644 local key_current repo_current local key_existed=0 repo_existed=0 status=0 rollback_status=0 [[ "$key_destination" == /etc/* && "$repo_destination" == /etc/* ]] || return 1 key_current="$PANAMA_SYSTEM_ETC${key_destination#/etc}" repo_current="$PANAMA_SYSTEM_ETC${repo_destination#/etc}" [[ ! -L "$key_current" && ! -L "$repo_current" ]] || return 1 backup_dir="$(dirname -- "$staged_key")" key_backup="$backup_dir/prior-key" repo_backup="$backup_dir/prior-repo" if [[ -e "$key_current" ]]; then [[ -f "$key_current" ]] || return 1 cp -- "$key_current" "$key_backup" || return 1 key_mode="$(stat -c %a "$key_current")" || return 1 key_existed=1 fi if [[ -e "$repo_current" ]]; then [[ -f "$repo_current" ]] || return 1 cp -- "$repo_current" "$repo_backup" || return 1 repo_mode="$(stat -c %a "$repo_current")" || return 1 repo_existed=1 fi sudo install -m 0644 "$staged_key" "$key_destination" || status=$? if (( status == 0 )); then sudo install -m 0644 "$staged_repo" "$repo_destination" || status=$? fi (( status == 0 )) && return 0 _restore_repository_file "$repo_existed" "$repo_backup" "$repo_mode" "$repo_destination" \ || rollback_status=$? _restore_repository_file "$key_existed" "$key_backup" "$key_mode" "$key_destination" \ || rollback_status=$? (( rollback_status == 0 )) || log "Repository activation rollback did not complete" return "$status" } _terra_repository_is_trusted() { local repo_file baseurl enabled gpgcheck repo_gpgcheck gpgkey local_key repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/terra.repo" [[ -f "$repo_file" ]] || return 1 baseurl="$(_ini_value "$repo_file" terra baseurl)" || return 1 enabled="$(_ini_value "$repo_file" terra enabled)" || return 1 gpgcheck="$(_ini_value "$repo_file" terra gpgcheck)" || return 1 repo_gpgcheck="$(_ini_value "$repo_file" terra repo_gpgcheck)" || return 1 gpgkey="$(_ini_value "$repo_file" terra gpgkey)" || return 1 [[ "$baseurl" == "${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ && "$enabled" == 1 && "$gpgcheck" == 1 && "$repo_gpgcheck" == 1 \ && "$gpgkey" == 'file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' ]] || return 1 local_key="$PANAMA_SYSTEM_ETC/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama" [[ -f "$local_key" ]] || return 1 key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/terra44.asc" \ "${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" \ && key_fingerprint_matches "$local_key" \ "${INSTALLER_PROVENANCE[TERRA_FINGERPRINT]}" } # Status 0 is trusted, 1 is absent, and 2 is present but untrusted or malformed. _flathub_remote_status() { local config section_count url gpg_verify summary_verify disabled disabled_status config="$PANAMA_SYSTEM_FLATPAK_REPO/config" [[ -f "$config" ]] || return 1 section_count="$(_ini_section_count "$config" 'remote "flathub"')" || return 2 (( section_count > 0 )) || return 1 (( section_count == 1 )) || return 2 url="$(_ini_value "$config" 'remote "flathub"' url)" || return 2 gpg_verify="$(_ini_value "$config" 'remote "flathub"' gpg-verify)" || return 2 summary_verify="$(_ini_value "$config" 'remote "flathub"' gpg-verify-summary)" || return 2 [[ "$url" == 'https://dl.flathub.org/repo/' ]] || return 2 case "${gpg_verify,,}" in true|yes|1) ;; *) return 2 ;; esac case "${summary_verify,,}" in true|yes|1) ;; *) return 2 ;; esac disabled_status=0 disabled="$(_ini_value "$config" 'remote "flathub"' xa.disable)" || disabled_status=$? if (( disabled_status == 0 )); then case "${disabled,,}" in true|yes|1) return 2 ;; esac elif (( disabled_status != 1 )); then return 2 fi [[ -f "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" ]] || return 2 key_fingerprint_matches "$PANAMA_SYSTEM_FLATPAK_REPO/flathub.trustedkeys.gpg" \ "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}" || return 2 } install_rpmfusion_repositories() { local work free_rpm nonfree_rpm require_reviewed_fedora_release || return 1 _require_policy_value RPMFUSION_FREE_RELEASE_URL \ 'https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-44.noarch.rpm' || return 1 _require_policy_value RPMFUSION_FREE_RELEASE_MAX_BYTES 4194304 || return 1 _require_policy_value RPMFUSION_NONFREE_RELEASE_URL \ 'https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-44.noarch.rpm' || return 1 _require_policy_value RPMFUSION_NONFREE_RELEASE_MAX_BYTES 4194304 || return 1 _require_policy_value RPMFUSION_FREE_FINGERPRINT E9A491A3DE247814E7E067EAE06F8ECDD651FF2E || return 1 _require_policy_value RPMFUSION_NONFREE_FINGERPRINT 79BDB88F9BBF73910FD4095B6A2AF96194843C65 || return 1 work="$(mktemp -d)" || return 1 chmod 0700 "$work" free_rpm="$work/rpmfusion-free-release.rpm" nonfree_rpm="$work/rpmfusion-nonfree-release.rpm" if ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_URL]}" \ "${INSTALLER_PROVENANCE[RPMFUSION_FREE_RELEASE_MAX_BYTES]}" "$free_rpm" \ || ! rpm_signature_matches "$free_rpm" \ "$PANAMA_PATH/setup/provenance/keys/rpmfusion-free.asc" \ "${INSTALLER_PROVENANCE[RPMFUSION_FREE_FINGERPRINT]}" \ || ! _download_bounded "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_URL]}" \ "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_RELEASE_MAX_BYTES]}" "$nonfree_rpm" \ || ! rpm_signature_matches "$nonfree_rpm" \ "$PANAMA_PATH/setup/provenance/keys/rpmfusion-nonfree.asc" \ "${INSTALLER_PROVENANCE[RPMFUSION_NONFREE_FINGERPRINT]}"; then rm -rf -- "$work" return 1 fi local status=0 sudo dnf install -y --setopt=localpkg_gpgcheck=1 "$free_rpm" "$nonfree_rpm" || status=$? rm -rf -- "$work" return "$status" } install_terra_repository() { local work staged_key staged_repo status require_reviewed_fedora_release || return 1 _require_policy_value TERRA_BASEURL 'https://repos.fyralabs.com/terra44' || return 1 _require_policy_value TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F || return 1 if rpm -q terra-release >/dev/null 2>&1; then if _terra_repository_is_trusted; then log "Terra repository already installed and verified" return 0 fi log "Installed Terra repository does not match Panama's reviewed trust policy" return 1 fi work="$(mktemp -d)" || return 1 chmod 0700 "$work" staged_key="$work/terra44.asc" staged_repo="$work/terra.repo" if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/terra44.asc" "$staged_key" \ TERRA_FINGERPRINT AE09157A4DE88B497EA1D5D300CDAB43DE226D6F; then rm -rf -- "$work" return 1 fi sudo install -m 0644 "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama || { rm -rf -- "$work" return 1 } printf '%s\n' \ '[terra]' \ 'name=Panama reviewed Terra 44' \ "baseurl=${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ 'enabled=1' \ 'gpgcheck=1' \ 'repo_gpgcheck=1' \ 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama' > "$staged_repo" chmod 0600 "$staged_repo" status=0 sudo dnf install -y \ --repofrompath "terra,${INSTALLER_PROVENANCE[TERRA_BASEURL]}" \ --setopt=terra.pkg_gpgcheck=1 \ --setopt=terra.repo_gpgcheck=1 \ --setopt=terra.gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-terra44-panama \ terra-release || status=$? if (( status == 0 )); then sudo install -m 0644 "$staged_repo" /etc/yum.repos.d/terra.repo || status=$? fi if (( status == 0 )) && ! _terra_repository_is_trusted; then status=1 fi rm -rf -- "$work" return "$status" } configure_hyprland_repository() { local work staged_key staged_repo status require_reviewed_fedora_release || return 1 _require_policy_value HYPRLAND_COPR_BASEURL \ 'https://download.copr.fedorainfracloud.org/results/lionheartp/Hyprland/fedora-$releasever-$basearch/' \ || return 1 work="$(mktemp -d)" || return 1 chmod 0700 "$work" staged_key="$work/hyprland-copr.asc" staged_repo="$work/panama-hyprland.repo" if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/hyprland-copr.asc" "$staged_key" \ HYPRLAND_COPR_FINGERPRINT 97E23476C89635135407C7D5E9BA41342C4B2995; then rm -rf -- "$work" return 1 fi printf '%s\n' \ '[panama-hyprland]' \ 'name=Panama reviewed Hyprland COPR' \ "baseurl=${INSTALLER_PROVENANCE[HYPRLAND_COPR_BASEURL]}" \ 'enabled=1' \ 'gpgcheck=1' \ 'repo_gpgcheck=0' \ 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland' > "$staged_repo" chmod 0600 "$staged_repo" status=0 _publish_repository_pair \ "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-panama-hyprland \ "$staged_repo" /etc/yum.repos.d/panama-hyprland.repo || status=$? rm -rf -- "$work" return "$status" } ensure_flathub_remote() { local work descriptor encoded key_file url no_gpg_verify gpg_verify local status remote_status no_gpg_status gpg_status require_reviewed_fedora_release || return 1 _require_policy_value FLATHUB_DESCRIPTOR_URL 'https://flathub.org/repo/flathub.flatpakrepo' || return 1 _require_policy_value FLATHUB_DESCRIPTOR_MAX_BYTES 1048576 || return 1 _require_policy_value FLATHUB_FINGERPRINT 6E5C05D979C76DAF93C081354184DD4D907A7CAE || return 1 remote_status=0 _flathub_remote_status || remote_status=$? if (( remote_status == 0 )); then return 0 elif (( remote_status != 1 )); then log "Existing Flathub remote does not match Panama's reviewed trust policy" return 1 fi work="$(mktemp -d)" || return 1 chmod 0700 "$work" descriptor="$work/flathub.flatpakrepo" key_file="$work/flathub-key.asc" if ! _download_bounded "${INSTALLER_PROVENANCE[FLATHUB_DESCRIPTOR_URL]}" \ "${INSTALLER_PROVENANCE[FLATHUB_DESCRIPTOR_MAX_BYTES]}" "$descriptor" \ || ! url="$(_ini_value "$descriptor" 'Flatpak Repo' Url)" \ || [[ "$url" != 'https://dl.flathub.org/repo/' ]] \ || ! encoded="$(_ini_value "$descriptor" 'Flatpak Repo' GPGKey)" \ || ! printf '%s' "$encoded" | base64 --decode > "$key_file"; then rm -rf -- "$work" return 1 fi no_gpg_status=0 no_gpg_verify="$(_ini_value "$descriptor" 'Flatpak Repo' NoGPGVerify)" \ || no_gpg_status=$? if (( no_gpg_status == 0 )); then case "${no_gpg_verify,,}" in true|yes|1) rm -rf -- "$work"; return 1 ;; esac elif (( no_gpg_status != 1 )); then rm -rf -- "$work" return 1 fi gpg_status=0 gpg_verify="$(_ini_value "$descriptor" 'Flatpak Repo' GPGVerify)" || gpg_status=$? if (( gpg_status == 0 )); then case "${gpg_verify,,}" in false|no|0) rm -rf -- "$work"; return 1 ;; esac elif (( gpg_status != 1 )); then rm -rf -- "$work" return 1 fi if ! key_fingerprint_matches "$key_file" "${INSTALLER_PROVENANCE[FLATHUB_FINGERPRINT]}"; then rm -rf -- "$work" return 1 fi status=0 sudo flatpak remote-add --if-not-exists --gpg-import="$key_file" flathub "$url" \ || status=$? if (( status == 0 )); then _flathub_remote_status || status=$? fi rm -rf -- "$work" return "$status" } install_claude_code() { local work staged_key staged_repo status if command -v claude >/dev/null 2>&1; then log "Claude Code already installed at \"$(command -v claude)\"" return 0 fi require_reviewed_fedora_release || return 1 _require_policy_value CLAUDE_CODE_BASEURL 'https://downloads.claude.ai/claude-code/rpm/stable' || return 1 work="$(mktemp -d)" || return 1 chmod 0700 "$work" staged_key="$work/claude-code.asc" staged_repo="$work/claude-code.repo" if ! _stage_reviewed_key "$PANAMA_PATH/setup/provenance/keys/claude-code.asc" "$staged_key" \ CLAUDE_CODE_FINGERPRINT 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE; then rm -rf -- "$work" return 1 fi printf '%s\n' \ '[claude-code]' \ 'name=Claude Code' \ "baseurl=${INSTALLER_PROVENANCE[CLAUDE_CODE_BASEURL]}" \ 'enabled=1' \ 'gpgcheck=1' \ 'repo_gpgcheck=1' \ 'gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama' > "$staged_repo" chmod 0600 "$staged_repo" status=0 _publish_repository_pair \ "$staged_key" /etc/pki/rpm-gpg/RPM-GPG-KEY-claude-code-panama \ "$staged_repo" /etc/yum.repos.d/claude-code.repo || status=$? if (( status == 0 )); then sudo dnf install -y claude-code || status=$? fi rm -rf -- "$work" return "$status" } _claude_desktop_manual() { log "Claude Desktop is optional; configure its reviewed local-key repository manually to install it" } install_claude_desktop_if_trusted() { local repo_file baseurl gpgcheck repo_gpgcheck gpgkey local_key require_reviewed_fedora_release || return 1 _require_policy_value CLAUDE_DESKTOP_BASEURL \ 'https://patrickjaja.github.io/claude-desktop-extra/rpm/' || return 1 _require_policy_value CLAUDE_DESKTOP_FINGERPRINT 825A7D15D78BABE45646D5DF382409F597908867 || return 1 repo_file="$PANAMA_SYSTEM_ETC/yum.repos.d/claude-desktop.repo" if [[ ! -f "$repo_file" ]] \ || ! baseurl="$(_ini_value "$repo_file" claude-desktop baseurl)" \ || [[ "$baseurl" != "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_BASEURL]}" ]] \ || ! gpgcheck="$(_ini_value "$repo_file" claude-desktop gpgcheck)" \ || [[ "$gpgcheck" != 1 ]] \ || ! repo_gpgcheck="$(_ini_value "$repo_file" claude-desktop repo_gpgcheck)" \ || [[ "$repo_gpgcheck" != 1 ]] \ || ! gpgkey="$(_ini_value "$repo_file" claude-desktop gpgkey)" \ || [[ "$gpgkey" != file:///* ]]; then _claude_desktop_manual return 0 fi local_key="${gpgkey#file://}" if [[ ! -f "$local_key" ]] \ || ! key_fingerprint_matches "$PANAMA_PATH/setup/provenance/keys/claude-desktop.asc" \ "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}" \ || ! key_fingerprint_matches "$local_key" \ "${INSTALLER_PROVENANCE[CLAUDE_DESKTOP_FINGERPRINT]}"; then _claude_desktop_manual return 0 fi sudo dnf install -y claude-desktop-extra } # --- The server path --------------------------------------------------------- # # Everything a server runs is above this line plus the lists it installs. No # RPM Fusion, no Terra, no COPR, no multimedia, no flatpaks: those exist for a # desktop, and every one of them is a network dependency and a failure mode a # headless machine has no reason to carry. if [[ "$ROLE" == server ]]; then echo -e "\n--- Installing packages (server) ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null install_list core-packages "Core" install_list server-packages "Server" setup_node install_bun install_claude_code install_codex report_soft_failures exit 0 fi echo -e "\n--- Installing Repositories ---" log "Installing RPM Fusion Free and Nonfree Repositories" install_rpmfusion_repositories > /dev/null log "Enabling Fedora Cisco OpenH264 Repository" # soft: this repo does not exist on every spin, and its absence must not cost # the desktop -- the ordering rule at soft()'s definition applies to the # repository extras just as much as to the codec swaps below. soft "enabling the openh264 repository" sudo dnf config-manager setopt fedora-cisco-openh264.enabled=1 log "Installing RPM Fusion AppStream Metadata" soft "the core group update" sudo dnf update @core -y soft "the RPM Fusion appstream metadata" sudo dnf install -y rpmfusion-\*-appstream-data # Terra bootstraps itself: --repofrompath defines a throwaway repo just long # enough to install terra-release, which then writes the real /etc/yum.repos.d # entry. Doing that a second time is not harmless -- dnf5 refuses the whole # transaction with 'Id is present more than once in the configuration', because # the throwaway id collides with the one terra-release already installed. # # That is what killed a re-run on a machine Terra had already reached: this sits # in the repository section, above everything, so `set -e` ended the stage # before a single package was considered. An installer whose second run does # less than its first is worse than one that never ran. log "Installing Terra Repository" install_terra_repository > /dev/null echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null # --- Install the shared core, then the desktop-only lists --- # --skip-unavailable throughout (inside install_list): dnf5 refuses a whole # transaction over one missing name, so a single rotted entry used to cost # every package in a list -- and the desktop below never installed. The # skipped names are reported afterwards rather than silently dropped. install_list core-packages "Core" install_list initial-packages "Initial" install_list desktop-packages "Desktop" # --- Install the Hyprland desktop --- # # Directly after desktop-packages and deliberately before anything optional. # The reviewed local repository below supplies these packages. This is the one # thing on the list that Panama is; a machine that gets only this far is a # machine you can log into, and every step below it is a convenience. # # Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" if [[ -f "$HYPR_FILE" ]]; then log "Configuring the reviewed Hyprland repository" configure_hyprland_repository > /dev/null HYPR_PACKAGES=$(packages_in "$HYPR_FILE") log "Installing Hyprland desktop packages" echo -e "Includes the following packages:" echo -e "$(<"$HYPR_FILE")" sudo dnf install -y --setopt=install_weak_deps=False $HYPR_PACKAGES > /dev/null log "Hyprland packages installed!" else log "Package list was not in specified path: $HYPR_FILE" fi # Said out loud, because the failure this guards against was silent. The stage # used to die somewhere above this point and report one red line among twenty # minutes of scrollback, and the machine looked installed until you tried to log # into it. if rpm -q hyprland >/dev/null 2>&1; then log "Hyprland $(rpm -q --queryformat '%{VERSION}' hyprland) is installed." else log "Hyprland is NOT installed. Nothing below this point will give you a desktop." exit 1 fi # --- Codecs and multimedia --------------------------------------------------- # # Below the desktop and every one of them non-fatal, because none is a # dependency of it and each can fail for reasons that have nothing to do with # this repository -- a swap whose source package this spin never shipped, a # group renamed upstream between Fedora releases. # # A trailing `&& sync` on the group update previously meant a failure was exempt # from set -e as well (bash does not apply -e to the left of a && list), so it # went unreported rather than being deliberately tolerated. It is deliberate now. log "Updating core, multimedia, and sound-and-video groups" soft "the multimedia group update" \ sudo dnf4 groupupdate -y 'core' 'multimedia' 'sound-and-video' \ --setop='install_weak_deps=False' \ --exclude='PackageKit-gstreamer-plugin' \ --allowerasing sync log "Swapping ffmpeg-free for ffmpeg" soft "the ffmpeg swap" sudo dnf swap -y 'ffmpeg-free' 'ffmpeg' --allowerasing log "Swapping mesa-va-drivers for mesa-va-drivers-freeworld" soft "the mesa driver swap" sudo dnf swap -y mesa-va-drivers mesa-va-drivers-freeworld log "Upgrading Multimedia group with optional packages" soft "the optional Multimedia upgrade" sudo dnf4 group upgrade -y --with-optional Multimedia log "Installing GStreamer plugins (bad, good, base)" soft "the GStreamer plugins" \ sudo dnf install -y gstreamer1-plugins-{bad-\*,good-\*,base} \ --exclude=gstreamer1-plugins-bad-free-devel # --- Install Development Packages needed for Neovim --- DEV_FILE="$PANAMA_PATH/setup/packages/development-packages" if [[ -f "$DEV_FILE" ]]; then DEV_PACKAGES=$(packages_in "$DEV_FILE") log "Installing Development Packages. Mostly for Neovim." echo -e "Includes the following packages:" echo -e "$(<"$DEV_FILE")" soft "the development packages" sudo dnf install -y $DEV_PACKAGES log "Development packages installed!" else log "Package list was not in specified path: $DEV_FILE" fi setup_node install_bun install_claude_code install_codex # Claude Desktop remains optional. Panama never downloads its community setup # script; only a repository an operator has already configured with the exact # reviewed local key is eligible for installation. if ! install_claude_desktop_if_trusted; then log "Claude Desktop install failed; skipping" softly_failed+=("Claude Desktop") fi # RustDesk: remote desktop. The flatpak cannot register the root-owned system # service that unattended access needs -- see panama-doctor's rustdesk check -- # so this takes the RPM. The download URL is resolved from the latest release # rather than written down, so it does not go stale. if rpm -q rustdesk >/dev/null 2>&1; then log "RustDesk already installed" else log "Resolving the latest RustDesk release..." # `|| true` because a failed curl -- unauthenticated GitHub API calls get # rate-limited -- would otherwise trip set -e and kill the stage before the # empty-result fallback below could do its job. rustdesk_url="$(curl -fsSL https://api.github.com/repos/rustdesk/rustdesk/releases/latest 2>/dev/null \ | jq -r --arg arch "$(uname -m)" '.assets[].browser_download_url | select(test($arch + "\\.rpm$")) | select(test("suse") | not)' \ | head -1 || true)" if [[ -n "$rustdesk_url" ]]; then log "Installing RustDesk from $rustdesk_url" # The RPM ships rustdesk.service already enabled, which is what provides # unattended access; Panama deliberately does not start it a second time. sudo dnf install -y "$rustdesk_url" > /dev/null || { log "RustDesk install failed; skipping"; softly_failed+=("RustDesk"); } else log "Could not resolve a RustDesk release; skipping"; softly_failed+=("RustDesk") fi fi # --- Install Flatpak Packages --- FLATPAK_FILE="$PANAMA_PATH/setup/packages/flatpak-packages" if [[ -f "$FLATPAK_FILE" ]]; then FLATPAK_PACKAGES=$(packages_in "$FLATPAK_FILE") log "Adding Flathub remote" if ensure_flathub_remote; then log "Installing Flatpak Packages" echo -e "Includes the following packages:" echo -e "$(<"$FLATPAK_FILE")" # One ID renamed on Flathub must not cost the rest of the run; the desktop # is already installed by this point and none of these is part of it. soft "some Flatpak packages" sudo flatpak install -y flathub $FLATPAK_PACKAGES log "Flatpak packages installed!" else log "Flathub trust verification failed; Flatpak packages were not installed" softly_failed+=("Flathub") fi else log "Package list was not in specified path: $FLATPAK_FILE" fi # --- Install the extras that were chosen ------------------------------------ # # Everything above is what every Panama machine gets. This is what one machine # asked for: the interview offers the categories in setup/packages/extras/ as a # checklist and records the chosen names, so a work laptop does not acquire # emulators and a desktop does not skip Steam. # # Absent means none. That is what makes this stage safe to re-run by hand while # repairing one piece of a machine -- and it means a category is installed only # by an explicit answer, never by a default that drifted. # # A category mixes both package managers, because the applications do: some are # in Fedora or RPM Fusion and some publish only a flatpak. A bare line is a dnf # package and a `flatpak:` line is a Flathub ID, so one file per category holds # the whole answer rather than splitting each category across two. # # Reading the file is setup/lib/extras-catalog's job, not this function's, because # `panama apps` offers the same catalog from the other side. Two parsers would # eventually disagree about what a category contains, and the one that disagreed # quietly would be this one -- it runs unattended. # # Neither install is fatal. A category is a set of applications somebody wanted, # not a dependency of the desktop, and losing the rest of the run because one of # them was renamed upstream would be the wrong trade. install_extra_category() { local file="$1" name name="$(basename "$file")" local dnf_packages flatpak_ids # sed rather than grep -v: most categories are flatpak-only, and grep exits 1 # when it selects nothing, which set -e above turns into a dead stage. dnf_packages=$(catalog_all_targets "$file" | sed '/^flatpak:/d' | tr "\n" " ") flatpak_ids=$(catalog_all_targets "$file" | sed -n 's/^flatpak://p' | tr "\n" " ") if [[ -n "${dnf_packages// /}" ]]; then log "Installing $name: $dnf_packages" sudo dnf install -y $dnf_packages > /dev/null || { log "Some $name packages did not install"; softly_failed+=("$name packages"); } fi if [[ -n "${flatpak_ids// /}" ]]; then log "Installing $name flatpaks: $flatpak_ids" if ensure_flathub_remote; then sudo flatpak install -y flathub $flatpak_ids > /dev/null \ || { log "Some $name flatpaks did not install"; softly_failed+=("$name flatpaks"); } else log "Flathub trust verification failed; $name flatpaks were not installed" softly_failed+=("$name flatpaks") fi fi } EXTRAS_DIR="$PANAMA_PATH/setup/packages/extras" for extra in ${PANAMA_EXTRAS:-}; do if [[ -f "$EXTRAS_DIR/$extra" ]]; then install_extra_category "$EXTRAS_DIR/$extra" else log "No such extras category: $extra" fi done report_soft_failures