Files
Panama/setup/scripts/install-packages
T
Gabriel Brown cb305f6662 Install ChatGPT Desktop from a repository this checkout can verify
OpenAI ships an official Linux RPM now, so the community wrapper goes away:
`panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk
image and ran a local rebuild daemon to keep it current, and the official
package comes from a repository that upgrades with everything else. The app
file, the help example and the dock's pinned id all move over, and a migration
replaces the build on machines that already have it -- official package on
before the community one comes off, so a failure part-way still leaves an app.

The install itself does not follow upstream's instructions. Those are "download
this RPM and install it", and the RPM's own root scriptlet is what writes the
repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs
an unverified download and then learns from it what to trust. That is the shape
the repository audit forbids: no network response is executed as root without a
verified digest or signature first.

OpenAI publishes no key and no fingerprint anywhere an install could fetch and
check them, so the key is pinned here instead. setup/keys/ carries it and says
where it came from, including the honest part -- this is trust established on
first use and then held, not trust verified against the publisher. setup/lib/
chatgpt-package verifies that copy's fingerprint, installs it, and writes the
repository with gpgcheck and repo_gpgcheck on before anything is installed, so
dnf checks the metadata signature and the package signature itself. It is byte
for byte the repository the scriptlet would have written, so nothing churns
afterwards, and every later upgrade goes through the same key. Both callers use
it; a verification failure skips ChatGPT rather than installing it anyway.

The contract proves the pinned key is the key the library names, that a
missing, unreadable or mismatched key writes nothing at all, that what is
written actually turns the checks on, and that neither caller hands root a
downloaded RPM.

Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
2026-08-27 14:48:20 -04:00

496 lines
23 KiB
Bash
Executable File

#!/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"
# 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)"
# Establishing the verified ChatGPT repository, shared with the migration that
# replaces the community build, so neither can install it a less careful way.
# shellcheck source=../lib/chatgpt-package
source "$PANAMA_PATH/setup/lib/chatgpt-package"
# 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
}
# Claude Code: Anthropic's CLI. The official installer keeps itself updated
# afterwards, so this runs once and then never needs to again.
install_claude_code() {
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"; softly_failed+=("Claude Code"); }
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
}
# --- 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"
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 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, 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
setup_node
install_bun
install_claude_code
install_codex
# 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"; softly_failed+=("Claude Desktop"); }
fi
# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build
# a community wrapper from the macOS disk image -- it was `panama app
# chatgpt-desktop` -- because no packaged form existed; that build froze often
# and carried its own local rebuild daemon. The official package is strictly
# better: it comes from a repository, so it upgrades with every other package
# from then on.
#
# The repository and its signing key are established first, from the copy
# pinned in setup/keys/, so dnf verifies the metadata and the package before
# either reaches root. Upstream's own instructions do not allow that -- see
# setup/lib/chatgpt-package for why they are not followed here.
if rpm -q chatgpt >/dev/null 2>&1; then
log "ChatGPT Desktop already installed"
elif ! chatgpt_install_repository sudo; then
log "Could not establish the verified ChatGPT repository; skipping"
softly_failed+=("ChatGPT Desktop")
else
log "Installing ChatGPT Desktop..."
sudo dnf install -y chatgpt > /dev/null \
|| { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT 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"
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"; softly_failed+=("$name packages"); }
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"; softly_failed+=("$name flatpaks"); }
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