Panama learns what a server is: from a root login to running containers

A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
Gabriel Brown
2026-08-25 23:11:49 -04:00
parent 9b338608ef
commit f33da41cc6
93 changed files with 4735 additions and 247 deletions
+35
View File
@@ -0,0 +1,35 @@
# Which kind of machine this is: 'desktop' or 'server'. Sourced, not run.
#
# The role is the one interview answer Panama keeps. Everything else is
# transient by design because it is personal -- a name, an email -- and
# re-asking costs less than a state file that drifts. The role is neither
# personal nor re-askable: `panama update` runs with no interview at all, and
# a server updated as a desktop would acquire Hyprland, so the answer has to
# outlive the run that gave it.
#
# Precedence: an exported PANAMA_ROLE (a fresh install, where the interview
# just asked) beats the recorded file (every later run), which beats the
# default. The default is desktop because every machine that predates roles
# is one.
PANAMA_ROLE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/role"
panama_role() {
local role="${PANAMA_ROLE:-}"
if [[ -z "$role" && -r "$PANAMA_ROLE_FILE" ]]; then
role="$(<"$PANAMA_ROLE_FILE")"
fi
case "$role" in
server) printf 'server' ;;
*) printf 'desktop' ;;
esac
}
# Anything but 'server' records 'desktop' rather than erroring: the recorded
# file must never hold a value panama_role would refuse to read back.
panama_role_record() {
local role="$1"
[[ "$role" == server ]] || role=desktop
mkdir -p "$(dirname "$PANAMA_ROLE_FILE")"
printf '%s\n' "$role" >"$PANAMA_ROLE_FILE"
}
+37
View File
@@ -0,0 +1,37 @@
# Every Panama machine, desktop or server. This is the shell environment
# itself -- the tools config/bash/shell and the aliases assume -- plus the
# few things both roles genuinely share. Desktop-only tools live in
# initial-packages; server-only ones in server-packages.
awk
bat
btop
curl
# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A
# machine that has used copr before has it; a fresh Workstation install does
# not, and the failure would cost the entire desktop.
dnf5-plugins
eza
fontconfig
fzf
gh
git-all
gum
jq
neovim
# config/bash/shell initialises the prompt with this.
oh-my-posh
# ssh-keygen, which setup-identity uses to create a key on request.
openssh
openssl
# Rootless containers: the compose services on a server, the speech-to-text
# quadlet and the Containers settings page on a desktop.
podman
# What provides `podman compose` on Fedora; without it every unit's
# ExecStart resolves to an error about a missing external provider.
podman-compose
python3-dnf
ripgrep
tmux
unzip
wget
zoxide
-3
View File
@@ -24,14 +24,11 @@ python3-questionary
python3-pyperclip
php
php-fpm
# Rootless containers, and the backend for the Containers settings page.
podman
python3-devel
python3-gobject
python3-tkinter
python3-torch
python3-virtualenv
ripgrep
ruby
sqlite3
# vimx: clipboard-capable vim, which the shipped `vim` alias and the vimrc
+3 -26
View File
@@ -1,38 +1,15 @@
awk
bat
btop
# Desktop machines only. What every machine gets -- the shell environment
# both roles share -- moved to core-packages; this is the remainder that only
# makes sense with a screen, a session, or a person developing at it.
cargo
curl
# `dnf copr`, which install-packages needs to enable the Hyprland COPR. A
# machine that has used copr before has it; a fresh Workstation install does
# not, and the failure would cost the entire desktop.
dnf5-plugins
eza
# install-packages installs flatpaks with it; Fedora Workstation ships it,
# but a minimal install does not.
flatpak
fontconfig
fwupd
fzf
gh
git-all
gum
jq
kitty
ksshaskpass
libselinux-utils
neovim
# config/bash/shell initialises the prompt with this.
oh-my-posh
# ssh-keygen, which setup-identity uses to create a key on request.
openssh
openssl
pciutils
python3-dnf
python3-neovim
rustup
tmux
unzip
wget
wireguard-tools
zoxide
+13
View File
@@ -0,0 +1,13 @@
# Only on role=server, on top of core-packages. Deliberately short: a server
# is the shell environment plus containers, and almost everything it needs is
# already core.
#
# setup-server opens 80/443/81 through it. Fedora Server ships it; a minimal
# cloud image may not, and a server whose firewall step silently no-ops is a
# server somebody believes is firewalled.
firewalld
# Node comes through nvm here for the same reason as on the desktop --
# config/bash/shell switches versions per project from .nvmrc -- and because
# install-packages puts Codex on with npm, agents' MCP servers want a node,
# and a system nodejs earlier on PATH would win every switch.
nvm
+155 -112
View File
@@ -73,6 +73,148 @@ PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# 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
}
# 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
@@ -105,37 +247,14 @@ 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 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 ---
#
@@ -212,70 +331,10 @@ 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"; 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 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"; 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.
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
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
@@ -406,20 +465,4 @@ for extra in ${PANAMA_EXTRAS:-}; do
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, 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
report_soft_failures
+53 -14
View File
@@ -42,6 +42,24 @@ yes_no() { gum confirm --default=false "$1"; }
# ── Machine ──────────────────────────────────────────────────────────────────
heading "This machine"
# The role decides most of what follows: a server is never asked about NVIDIA
# drivers or Steam, and a desktop is never asked about compose services. It is
# also the one answer that outlives the run -- install records it durably,
# because `panama update` asks nothing and still has to know which machine it
# is updating. See setup/lib/machine-role.
#
# PANAMA_ROLE_PRESET is how `./install --server` answers this without a prompt,
# for the curl-onto-a-fresh-VPS path where the caller already said what the
# machine is. An empty or escaped choice falls back to desktop, which is what
# every Panama machine was before roles existed.
role="${PANAMA_ROLE_PRESET:-}"
if [[ -z "$role" ]]; then
role="$(gum choose --header "What is this machine?" "desktop" "server")" || role=""
fi
[[ "$role" == server ]] || role=desktop
record PANAMA_ROLE "$role"
current_hostname="$(hostname)"
printf 'Current hostname: %s\n' "$current_hostname"
new_hostname=""
@@ -94,11 +112,23 @@ record PANAMA_SSH_KEY "$ssh_key"
# machine rather than an answer to a hypothetical. A machine with no NVIDIA card
# is never asked about drivers, and one with nothing to remove is never asked
# about removing it.
heading "Hardware"
#
# A server is asked none of it. The stages these answers feed --
# install-hardware, the debloat removal -- do not run on the server path at
# all, and a question whose answer nothing consumes is a control that lies.
# The defaults are still recorded so the answers file has the same shape
# either way.
nvidia=no
mok_hash=""
installed=()
firmware=no
debloat=no
if [[ "$role" != server ]]; then
heading "Hardware"
nvidia_card="$(lspci 2>/dev/null | grep -iE 'vga compatible|3d controller' | grep -i nvidia | sed 's/.*: //' | head -1)"
if [[ -n "$nvidia_card" ]]; then
@@ -139,13 +169,9 @@ if [[ -n "$nvidia_card" ]]; then
else
printf 'No NVIDIA card found.\n'
fi
record PANAMA_NVIDIA "$nvidia"
record PANAMA_MOK_HASH "$mok_hash"
# The stage that removes them owns the list, so there is one copy of it.
debloat=no
mapfile -t removable < <("$(dirname "${BASH_SOURCE[0]}")/install-hardware" --debloat-list)
installed=()
for package in "${removable[@]}"; do
rpm -q "$package" >/dev/null 2>&1 && installed+=("$package")
done
@@ -154,14 +180,18 @@ if (( ${#installed[@]} > 0 )); then
debloat=yes
fi
fi
record PANAMA_DEBLOAT "$debloat"
firmware=no
if command -v fwupdmgr >/dev/null 2>&1; then
if yes_no "Update firmware with fwupdmgr?"; then
firmware=yes
fi
fi
fi # role != server
record PANAMA_NVIDIA "$nvidia"
record PANAMA_MOK_HASH "$mok_hash"
record PANAMA_DEBLOAT "$debloat"
record PANAMA_FIRMWARE "$firmware"
# ── Applications ─────────────────────────────────────────────────────────────
@@ -175,10 +205,12 @@ record PANAMA_FIRMWARE "$firmware"
# applications nobody chose, on a machine whose owner answered a question they
# thought was about something else.
extras=""
if [[ "$role" != server ]]; then
heading "Applications"
extras_dir="$(dirname "${BASH_SOURCE[0]}")/../packages/extras"
extras=""
if [[ -d "$extras_dir" ]]; then
mapfile -t categories < <(for file in "$extras_dir"/*; do
[[ -f "$file" ]] && basename "$file"
@@ -189,6 +221,8 @@ if [[ -d "$extras_dir" ]]; then
extras="${extras% }"
fi
fi
fi # role != server
record PANAMA_EXTRAS "$extras"
# ── Personal content ─────────────────────────────────────────────────────────
@@ -227,17 +261,22 @@ shown() { [[ -n "$1" ]] && printf '%s' "$1" || printf 'unchanged'; }
heading "Ready"
gum style --border rounded --padding "0 1" "$(
printf 'Role %s\n' "$role"
printf 'Hostname %s\n' "${new_hostname:-"$current_hostname (unchanged)"}"
printf 'Git name %s\n' "$(shown "$git_name")"
printf 'Git email %s\n' "$(shown "$git_email")"
printf 'Git editor %s\n' "$(shown "$git_editor")"
printf 'GitHub %s\n' "$([[ "$gh_login" == yes ]] && echo "sign in" || echo "no change")"
printf 'SSH key %s\n' "$([[ "$ssh_key" == yes ]] && echo "generate" || echo "no change")"
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
printf 'Extras %s\n' "${extras:-none}"
# Hardware and extras were never asked on a server, and a summary line for
# a question that was not asked reads as a decision that was not made.
if [[ "$role" != server ]]; then
printf 'NVIDIA %s\n' "$([[ "$nvidia" == yes ]] && echo "install driver" || echo "no")"
printf 'Secure Boot %s\n' "$([[ -n "$mok_hash" ]] && echo "enroll a key" || echo "no change")"
printf 'Fedora apps %s\n' "$([[ "$debloat" == yes ]] && echo "remove ${installed[*]}" || echo "keep")"
printf 'Firmware %s\n' "$([[ "$firmware" == yes ]] && echo "update" || echo "no")"
printf 'Extras %s\n' "${extras:-none}"
fi
printf 'Personal %s' "$([[ "$user_content" == yes ]] && echo "link user/ into home" || echo "not linked")"
)"
+70 -42
View File
@@ -20,6 +20,13 @@ CONFIG="$HOME/.config"
# every symlink below fail silently while the stage still reported success.
mkdir -p "$PANAMA_OLD" "$CONFIG"
# Which machine this is. A server links the shell environment -- bash, vim,
# the universal dirs below, the tmux and btop theming, the hook samples --
# and then stops: everything after the early exit assumes a screen.
# shellcheck source=../lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
ROLE="$(panama_role)"
# --- Bashrc ---
echo -e "\n--- Replacing .bashrc ---"
# Backup existing .bashrc if it's a regular file
@@ -45,8 +52,21 @@ ln -s "$PANAMA_BASH/.bashrc" "$HOME/.bashrc"
#
# gnome-control-center is a separate matter and stays declared: Panama's own
# Settings hands off to it for the panels it deliberately does not own.
dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" "nvim" \
"quickshell" "tmux" "uwsm" "vicinae" "wofi" "xdg-desktop-portal")
#
# Split by role, and every directory under config/dot must be claimed by
# exactly one of these three lists -- universal, desktop, or handled (linked
# or consumed some other way below: btop exposes only themes, ohmyposh is
# read in place by config/bash/shell, panama's hook samples are copied, vim
# links a single file). tests/setup/dotfile-classification-contract fails
# when a new directory appears in none of them, because unclassified means
# silently absent from every server.
universal_dirs=("nvim" "tmux")
desktop_dirs=("espanso" "ghostty" "gtk-3.0" "gtk-4.0" "hypr" "kitty" \
"quickshell" "uwsm" "vicinae" "wofi" "xdg-desktop-portal")
handled_dirs=("btop" "ohmyposh" "panama" "vim")
dirs=("${universal_dirs[@]}")
[[ "$ROLE" == server ]] || dirs+=("${desktop_dirs[@]}")
# --- Vim vimrc ---
echo -e "\n--- Setting up vim ---"
@@ -107,6 +127,54 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
# lives there. A symlinked directory would put their scripts in the repository
# working tree, which is the mistake the gtk bookmarks made.
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
mkdir -p "$USER_HOOK_DIR"
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
[ -e "$sample" ] || continue
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
if [ -e "$sample_dst" ]; then
log "Keeping existing hook sample at $sample_dst"
else
cp "$sample" "$sample_dst"
log "Copied hook sample → $sample_dst"
fi
done
fi
# A server's dotfiles end here. Everything below assumes a session: lock
# screens, GTK, launcher themes, icons, wallpapers, Firefox chrome, desktop
# entries, quadlets for the desktop's own containers, file associations.
if [ "$ROLE" = server ]; then
log "Server role: desktop dotfiles skipped"
exit 0
fi
# hyprlock.conf is generated from a template on every color scheme change and
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
# without it hyprlock falls back to its own defaults, which is a bare gray
@@ -139,26 +207,6 @@ elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
fi
fi
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
# color_theme line in place.
BTOP_THEME_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/btop/themes"
mkdir -p "$BTOP_THEME_DIR"
for btop_theme_src in "$PANAMA_DOT"/btop/themes/*.theme; do
[ -e "$btop_theme_src" ] || continue
btop_theme_dst="$BTOP_THEME_DIR/$(basename "$btop_theme_src")"
if [ -L "$btop_theme_dst" ]; then
rm "$btop_theme_dst"
fi
if [ -e "$btop_theme_dst" ]; then
log "Keeping existing btop theme at $btop_theme_dst"
else
ln -s "$btop_theme_src" "$btop_theme_dst"
log "Linked btop theme → $btop_theme_dst"
fi
done
# GTK3 has no include mechanism, so its settings.ini is generated whole from a
# template rather than layered. Without this, a fresh checkout has a template
# and no settings.ini, and GTK3 applications fall back to their built-in theme.
@@ -454,26 +502,6 @@ fi
# than by symlinking the directory itself, the same way the quadlets and
# desktop entries are: Nautilus writes nothing here today, but a directory
# symlink into the repository is how machine state ends up in a tracked path.
# Hook samples. Copied rather than symlinked, and only when absent: hooks are
# the user's own scripts, and ~/.config/panama is theirs too -- settings.json
# lives there. A symlinked directory would put their scripts in the repository
# working tree, which is the mistake the gtk bookmarks made.
PANAMA_HOOK_SAMPLES="$PANAMA_DOT/panama/hooks"
USER_HOOK_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks"
if [ -d "$PANAMA_HOOK_SAMPLES" ]; then
mkdir -p "$USER_HOOK_DIR"
for sample in "$PANAMA_HOOK_SAMPLES"/*.sample; do
[ -e "$sample" ] || continue
sample_dst="$USER_HOOK_DIR/$(basename "$sample")"
if [ -e "$sample_dst" ]; then
log "Keeping existing hook sample at $sample_dst"
else
cp "$sample" "$sample_dst"
log "Copied hook sample → $sample_dst"
fi
done
fi
PANAMA_NAUTILUS_DIR="$PANAMA_PATH/config/local/share/nautilus-python/extensions"
USER_NAUTILUS_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/nautilus-python/extensions"
if [ -d "$PANAMA_NAUTILUS_DIR" ]; then
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# The server counterpart of link-dotfiles, and deliberately thin: ~/Server is
# created here, and everything about which services are linked into it is
# panama-server's job -- relink refreshes the symlinks of whatever this
# machine has enabled, and enabling something new is a decision a person makes
# with `panama server enable`, not something an installer infers.
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
mkdir -p "$HOME/Server"
exec "$PANAMA_PATH/bin/panama-server" relink
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# What makes a Fedora machine able to run rootless compose services. Server
# role only -- ./install never runs this on a desktop. Idempotent throughout:
# every step checks the machine before touching it, so a re-run on a machine
# that already has all of this changes nothing and says so.
set -euo pipefail
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
# ── Linger ───────────────────────────────────────────────────────────────────
# Without it every user unit -- which is every service -- stops at logout and
# starts only at login, which on a server means "runs while somebody is SSHed
# in". Linger is what makes the user session a real init.
if loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
log "Linger already enabled for $USER"
else
log "Enabling linger for $USER"
sudo loginctl enable-linger "$USER"
fi
# ── Unprivileged ports from 80 ───────────────────────────────────────────────
# Rootless containers cannot bind 80/443 while the kernel reserves everything
# below 1024 for root. Lowering the floor to 80 is what lets the reverse proxy
# be a rootless container like everything else. A file in /etc/sysctl.d so it
# survives reboots; sysctl --system so it applies now.
SYSCTL_FILE=/etc/sysctl.d/99-rootless-ports.conf
SYSCTL_WANT='net.ipv4.ip_unprivileged_port_start=80'
if [[ -r "$SYSCTL_FILE" ]] && grep -qx "$SYSCTL_WANT" "$SYSCTL_FILE"; then
log "Unprivileged ports already start at 80 ($SYSCTL_FILE)"
else
log "Allowing unprivileged binds from port 80"
printf '%s\n' "$SYSCTL_WANT" | sudo tee "$SYSCTL_FILE" >/dev/null
sudo sysctl --system >/dev/null
fi
# ── Firewall ─────────────────────────────────────────────────────────────────
# 80 and 443 because everything is reverse-proxied; 81 for the proxy's own
# admin portal. Deliberately nothing else: a service needing another port open
# documents that in its own folder and it is opened by hand, because a list of
# per-service firewall holes maintained by an installer is a list nobody
# audits.
if systemctl is-active firewalld >/dev/null 2>&1; then
reload_needed=0
for port in 80 443 81; do
if sudo firewall-cmd --permanent --query-port="${port}/tcp" >/dev/null 2>&1; then
log "Port ${port}/tcp already open"
else
log "Opening port ${port}/tcp"
sudo firewall-cmd --permanent --add-port="${port}/tcp" >/dev/null
reload_needed=1
fi
done
(( reload_needed )) && sudo firewall-cmd --reload >/dev/null
else
log "firewalld is not active; no ports to open"
fi
# ── The shared container network ─────────────────────────────────────────────
# Every compose file expects nginx-bridge as an external network: the reverse
# proxy reaches each service by container name across it, and no service needs
# a published port of its own. External means compose will not create it, so
# somebody has to -- this is that somebody.
if podman network exists nginx-bridge 2>/dev/null; then
log "podman network nginx-bridge already exists"
else
log "Creating podman network nginx-bridge"
podman network create nginx-bridge >/dev/null
fi
# ── Nightly image updates ────────────────────────────────────────────────────
# server/scripts/update-containers, on a midnight timer. Linked rather than
# copied so a pull updates the machinery with everything else; see the script
# header for why this replaced watchtower.
UNIT_SRC="$PANAMA_PATH/server/systemd"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$UNIT_DIR" "$HOME/Server/logs"
for unit in podman-update.service podman-update.timer; do
src="$UNIT_SRC/$unit"
dst="$UNIT_DIR/$unit"
[[ -e "$src" ]] || { log "Missing $src; skipping the update timer"; continue; }
if [[ -L "$dst" ]]; then
rm "$dst"
elif [[ -e "$dst" ]]; then
mv "$dst" "$dst.pre-panama"
log "Kept the existing $unit as $unit.pre-panama"
fi
ln -s "$src" "$dst"
log "Linked $unit"
done
systemctl --user daemon-reload
if systemctl --user is-enabled podman-update.timer >/dev/null 2>&1; then
log "podman-update.timer already enabled"
else
log "Enabling podman-update.timer (nightly image updates)"
systemctl --user enable --now podman-update.timer
fi