Let applications be chosen a few at a time

The catalog held fourteen applications. This machine runs thirty-four flatpaks,
so most of what is actually used had no way to be installed from here at all --
Zoom, Slack, Obsidian, Spotify, LibreOffice, OBS and its sixteen plugins.

So the catalog is seeded from the machine, and `panama apps` opens it: pick a
category, tick what you want, install just those. ./install still offers the
same catalog as whole categories, because during a first install you want coarse
and fast. Both read setup/lib/extras-catalog. Two parsers would eventually
disagree about what a category contains, and the one that disagreed quietly
would be the one that runs unattended.

Two pieces of syntax earn their keep. A `| Name` suffix gives the menu something
readable, since com.obsproject.Studio is not a name anybody wants to pick from a
list. An indented line belongs to the entry above it, which is how OBS carries
its plugins as one thing to tick rather than seventeen -- they are extensions of
the flatpak, useless alone.

That is also why creative moved from dnf to Flathub: the plugins attach only to
the flatpak, so the dnf build cannot have them. The rest of the category
followed rather than leave one machine with GIMP from dnf and its neighbour from
Flathub.

The contract now reads the catalog through the same parser instead of keeping a
third idea of the format, and checks the two things this syntax can break
silently: a label leaking into an install command, and a bundle that installs
the application without its plugins. It caught a typo in the Pixelorama id on
the first run.

It also got slow enough to be worth fixing -- fifty-one names, each its own
network call. One bulk query per manager took it from minutes to four seconds.
That query needs `flatpak remote-ls --all`: without it, end-of-life applications
are hidden and read as missing, which reported yuzu as gone from Flathub when it
installs perfectly well.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
This commit is contained in:
Gabriel Brown
2026-08-20 23:58:36 -04:00
parent 48f7c1e962
commit 215da285f3
10 changed files with 422 additions and 35 deletions
+109 -20
View File
@@ -24,6 +24,14 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
installer="$repo_dir/setup/scripts/install-packages"
interview="$repo_dir/setup/scripts/interview"
extras_dir="$repo_dir/setup/packages/extras"
catalog="$repo_dir/setup/lib/extras-catalog"
# The same parser both front doors use. A contract that re-implemented the
# format would eventually be testing its own idea of it rather than the one that
# runs -- which is exactly how the package lists came to be annotated with
# comments that every contract stripped and dnf did not.
# shellcheck source=../../setup/lib/extras-catalog
source "$catalog"
findings=()
note() { findings+=("$1"); }
@@ -39,7 +47,7 @@ categories=("$extras_dir"/*)
for category in "${categories[@]}"; do
name="$(basename "$category")"
[[ -f "$category" ]] || { note "$name is not a file"; continue; }
entries="$(sed 's/#.*//' "$category" | tr -d ' \t' | grep -cv '^$')"
entries="$(catalog_entries "$category" | grep -c . || true)"
(( entries > 0 )) || note "the $name category installs nothing, so choosing it does nothing"
done
@@ -88,6 +96,7 @@ LIST
(
PATH="$stub_dir:$PATH"
log() { :; }
source "$catalog"
eval "$filter"
eval "$loop"
install_extra_category "$fixture"
@@ -112,6 +121,7 @@ fi
PATH="$stub_dir:$PATH"
PANAMA_PATH="$repo_dir"
log() { :; }
source "$catalog"
eval "$filter"
eval "$loop"
EXTRAS_DIR="$extras_dir"
@@ -121,34 +131,113 @@ fi
)
[[ -s "$calls" ]] && note 'with no categories chosen the installer still installed something'
# ── 3b. Labels and bundles ───────────────────────────────────────────────────
#
# Two pieces of syntax carry real weight, and both fail quietly when wrong: a
# label that leaked into an install command would be handed to dnf as a package
# name, and a bundle that did not resolve would install OBS without the plugins
# that are the reason to pick it.
bundle_fixture="$work/bundle"
cat >"$bundle_fixture" <<'LIST'
# A named entry, a bundle, and a plain one.
flatpak:com.example.Named | A Friendly Name
flatpak:com.example.Host | Host
flatpak:com.example.Host.Plugin.One
flatpak:com.example.Host.Plugin.Two
plain-package
LIST
# An indented line belongs to the entry above it and must never be offered on
# its own, or the menu lists plugins as though they were applications.
selectable="$(catalog_entries "$bundle_fixture" | wc -l)"
(( selectable == 3 )) \
|| note "a category with 3 entries and 2 attached lines offers $selectable choices, not 3"
catalog_entries "$bundle_fixture" | grep -q 'Plugin.One' \
&& note 'an indented line is offered as a selectable application'
# The label is for the menu and must not survive into an install target.
catalog_all_targets "$bundle_fixture" | grep -q '|' \
&& note 'a label reaches the install targets, where it would be treated as a package name'
catalog_entries "$bundle_fixture" | grep -q "$(printf 'flatpak:com.example.Named\tA Friendly Name')" \
|| note 'an explicit label is not carried through to the menu'
# Ticking a bundle installs the entry and everything attached to it.
host_targets="$(catalog_targets "$bundle_fixture" "flatpak:com.example.Host" | wc -l)"
(( host_targets == 3 )) \
|| note "selecting a bundle resolves to $host_targets targets, not the entry plus its 2 attached lines"
catalog_targets "$bundle_fixture" "flatpak:com.example.Host" | grep -q '^flatpak:com.example.Host$' \
|| note 'selecting a bundle does not install the entry itself'
# A plain entry stays plain: it must not absorb whatever follows it.
plain_targets="$(catalog_targets "$bundle_fixture" "plain-package" | wc -l)"
(( plain_targets == 1 )) \
|| note "a plain entry resolves to $plain_targets targets rather than just itself"
# `panama apps` maps a selection back to an entry by its menu label, because
# that is all gum returns. Two entries sharing a label would therefore install
# whichever came first, silently and with no way to pick the other.
for category in "${categories[@]}"; do
[[ -f "$category" ]] || continue
duplicate="$(catalog_entries "$category" | cut -f2 | sort | uniq -d)"
[[ -z "$duplicate" ]] \
|| note "$(basename "$category") has more than one entry labelled '$duplicate', which makes the menu ambiguous"
done
# ── 3c. Both front doors, one catalog ────────────────────────────────────────
#
# `panama apps` and the interview offer the same applications. They diverge the
# moment either grows its own parser, and the divergence would be invisible.
panama="$repo_dir/bin/panama"
grep -q 'source "$PANAMA_DIR/setup/lib/extras-catalog"' "$panama" \
|| note 'panama apps does not read the shared catalog'
grep -q 'source "$PANAMA_PATH/setup/lib/extras-catalog"' "$installer" \
|| note 'install-packages does not read the shared catalog'
grep -qE '^\s*apps\)' "$panama" \
|| note 'panama does not dispatch an apps subcommand'
# ── 4. Every name resolves ───────────────────────────────────────────────────
#
# Skipped rather than failed when the repositories cannot be reached, so this
# contract stays runnable on a train.
#
# One bulk query per manager, not one per name. Asking Flathub about forty-five
# ids individually took minutes and got slower every time an application was
# added -- a check nobody will wait for is a check that gets commented out.
if timeout 60 dnf list --available --quiet bash >/dev/null 2>&1; then
for category in "${categories[@]}"; do
[[ -f "$category" ]] || continue
while read -r package; do
[[ -n "$package" ]] || continue
[[ "$package" == flatpak:* ]] && continue
timeout 90 dnf list --quiet "$package" >/dev/null 2>&1 \
|| note "$(basename "$category") names $package, which dnf cannot resolve"
done < <(sed 's/#.*//' "$category" | tr -d ' \t' | grep -v '^$')
done
dnf_wanted="$(for category in "${categories[@]}"; do
[[ -f "$category" ]] && catalog_all_targets "$category" | grep -v '^flatpak:'
done | sort -u)"
flatpak_wanted="$(for category in "${categories[@]}"; do
[[ -f "$category" ]] && catalog_all_targets "$category" | sed -n 's/^flatpak://p'
done | sort -u)"
if [[ -n "$dnf_wanted" ]] && timeout 60 dnf list --available --quiet bash >/dev/null 2>&1; then
# repoquery answers for every name at once and simply omits the ones it
# cannot resolve, so the difference is the finding.
resolved="$(timeout 180 dnf repoquery --qf '%{name}\n' $dnf_wanted 2>/dev/null | sort -u)"
while read -r package; do
[[ -n "$package" ]] || continue
grep -qx "$package" <<<"$resolved" \
|| note "$package is named by a category but dnf cannot resolve it"
done <<<"$dnf_wanted"
else
printf 'extras contract: dnf is unreachable, so package names were not resolved\n' >&2
fi
if timeout 60 flatpak remote-info flathub org.mozilla.firefox >/dev/null 2>&1; then
for category in "${categories[@]}"; do
[[ -f "$category" ]] || continue
while read -r id; do
[[ -n "$id" ]] || continue
timeout 90 flatpak remote-info flathub "$id" >/dev/null 2>&1 \
|| note "$(basename "$category") names $id, which is not on Flathub"
done < <(sed 's/#.*//' "$category" | tr -d ' \t' | sed -n 's/^flatpak://p')
done
# --all matters: without it, remote-ls hides end-of-life applications, which
# still resolve and still install. Leaving it off reported yuzu as missing from
# Flathub when it is merely unmaintained -- a false negative that would have
# quietly deleted a working entry.
if [[ -n "$flatpak_wanted" ]] && timeout 120 flatpak remote-ls flathub --columns=application --all >"$work/flathub" 2>/dev/null \
&& [[ -s "$work/flathub" ]]; then
while read -r id; do
[[ -n "$id" ]] || continue
grep -qx "$id" "$work/flathub" \
|| note "$id is named by a category but is not on Flathub"
done <<<"$flatpak_wanted"
else
printf 'extras contract: Flathub is unreachable, so flatpak IDs were not resolved\n' >&2
fi