#!/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}" # 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" echo -e "\n--- Installing Repositories ---" log "Installing RPM Fusion Free and Nonfree Repositories" sudo dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm > /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. if rpm -q terra-release >/dev/null 2>&1; then log "Terra repository already installed" else log "Installing Terra Repository" sudo dnf install -y --nogpgcheck --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' terra-release > /dev/null fi echo -e "\n--- Installing relevant packages ---" log "Updating all packages. This may take a while" sudo dnf update -y --refresh > /dev/null # --- Install all initial packages --- PACKAGES_FILE="$PANAMA_PATH/setup/packages/initial-packages" if [[ -f "$PACKAGES_FILE" ]]; then INITIAL_PACKAGES=$(packages_in "$PACKAGES_FILE") log "Installing Initial Packages" echo -e "Includes the following packages:" echo -e "$(<"$PACKAGES_FILE")" # --skip-unavailable: dnf5 refuses a whole transaction over one missing # name, so a single rotted entry in this list used to cost every package # in it -- and the desktop below never installed. The skipped names are # reported afterwards rather than silently dropped. sudo dnf install -y --skip-unavailable $INITIAL_PACKAGES > /dev/null report_missing "$PACKAGES_FILE" log "Initial packages installed!" else log "Package list was not in specified path: $PACKAGES_FILE" fi # --- Install Desktop Packages --- DESKTOP_FILE="$PANAMA_PATH/setup/packages/desktop-packages" if [[ -f "$DESKTOP_FILE" ]]; then DESKTOP_PACKAGES=$(packages_in "$DESKTOP_FILE") log "Installing Desktop Packages" echo -e "Includes the following packages:" echo -e "$(<"$DESKTOP_FILE")" sudo dnf install -y --skip-unavailable $DESKTOP_PACKAGES > /dev/null report_missing "$DESKTOP_FILE" log "Desktop packages installed!" else log "Package list was not in specified path: $DESKTOP_FILE" fi # --- Install the Hyprland desktop --- # # Directly after desktop-packages, which is what supplies the dnf plugin that # `dnf copr` needs, and deliberately before anything optional. 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 "Enabling Hyprland COPR" sudo dnf copr enable -y lionheartp/Hyprland > /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 # --- 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. 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" log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" else log "nvm could not install Node; skipping" 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 four 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. # # Claude Desktop is the half-exception: a repository does carry it, just not one # Fedora or Flathub knows about, so what is exceptional there is adding the # repository rather than installing around one. # # 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: this stage has # already installed the desktop by this point, and an unreachable third-party # host should not cost you that. # Bun: the JavaScript runtime and package manager. No RPM, no flatpak. 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" fi # Claude Code: Anthropic's CLI. The official installer keeps itself updated # afterwards, so this runs once and then never needs to again. if command -v claude >/dev/null 2>&1; then log "Claude Code already installed at \"$(command -v claude)\"" else log "Installing Claude Code via the official installer..." curl -fsSL https://claude.ai/install.sh | bash > /dev/null 2>&1 || log "Claude Code install failed; skipping" fi # Claude Desktop: Anthropic ships macOS and Windows only, so this is a community # RPM built from the official release. Panama used to build it from source -- it # was `panama app claude-desktop` -- because no repository carried it. Upstream # publishes one now, which is strictly better: the result upgrades with every # other package instead of needing a slow rebuild each time a version ships. # # The repository is added by upstream's own setup script rather than by writing # the .repo file out here. A baseurl copied into this repository is a pin by # another name, and that script is the part upstream keeps correct. if rpm -q claude-desktop-extra >/dev/null 2>&1; then log "Claude Desktop already installed" else if [[ ! -f /etc/yum.repos.d/claude-desktop.repo ]]; then log "Adding the Claude Desktop repository..." # Fetched to a file and then run, never piped into root: a pipe executes # whatever the network answered with no chance to look, and this one is an # unpinned script from a personal GitHub Pages site -- the least trusted # thing this installer touches. The file is kept next to the run so what # executed is still on disk to read afterwards. claude_repo_script="$(mktemp -t claude-desktop-repo.XXXXXX.sh)" if curl -fsSL https://patrickjaja.github.io/claude-desktop-extra/install-rpm.sh \ -o "$claude_repo_script"; then sudo bash "$claude_repo_script" > /dev/null 2>&1 \ || log "Could not add the Claude Desktop repository (script kept at $claude_repo_script)" else log "Could not download the Claude Desktop repository script" fi fi log "Installing Claude Desktop..." sudo dnf install -y claude-desktop-extra > /dev/null \ || log "Claude Desktop install failed; skipping" 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" else log "Could not resolve a RustDesk release; skipping" 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" soft "adding the Flathub remote" \ sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 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 "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" fi if [[ -n "${flatpak_ids// /}" ]]; then log "Installing $name flatpaks: $flatpak_ids" sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo > /dev/null sudo flatpak install -y flathub $flatpak_ids > /dev/null || log "Some $name flatpaks did not install" 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 # --- What was stepped over --------------------------------------------------- # # Tolerating a failure is only better than aborting on it if somebody is told. # This stage now survives a codec swap that finds nothing to swap, and the whole # point of surviving it is that the desktop gets installed anyway -- but a # machine missing its video codecs should say so once, here, rather than be # discovered a week later by a video that will not play. if (( ${#softly_failed[@]} > 0 )); then log "Installed, but these were stepped over:" printf ' - %s\n' "${softly_failed[@]}" log "None of them stops the desktop. Re-run this stage to try them again." fi