Three surfaces the first laptop install showed were missing. The bar's battery icon gets an optional exact number beside it -- GNOME's "Show Battery Percentage", off by default for GNOME's reason, one color with the icon so it reads as one indicator. The Power page says what closing the lid does. The policy already existed (LidPolicy holds a suspend inhibitor while an external display is connected) but was surfaced nowhere, so the machine's most physical behavior was undiscoverable -- and the deliberate absence of an override deserves stating rather than leaving someone to hunt for a switch that does not exist. And the Users page grows a Fingerprint card, because fingerprint login is two systems that fail silently when they disagree: fprintd holds the enrolled prints, authselect decides whether PAM ever asks the reader. This machine arrived with a finger enrolled from its GNOME days and with-fingerprint off, which reads as "the reader is broken". The card shows both facts, flips the authselect feature through polkit with a stated reason, and hands enrollment to GNOME's Users panel, which owns the only good capture dialog -- a named exception in the handoff contract. Everything through scripts/panama-fingerprint, pinned by a stub-driven contract. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
236 lines
12 KiB
Bash
Executable File
236 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Every external command Panama's own scripts invoke must be installed by
|
|
# Panama's own package lists.
|
|
#
|
|
# This exists because the lists had drifted badly. jq is used by thirty-one call
|
|
# sites across the helpers and the contracts; kitty has a full shipped config
|
|
# and a dock pin; tmux and btop have shipped themes that the color scheme
|
|
# switches. None of the four were declared. So a fresh machine that followed
|
|
# this repository's own install instructions would not have them.
|
|
#
|
|
# The failure is quiet by design, which is what makes it worth a test: the
|
|
# helpers are written to report "not installed" rather than crash, so a missing
|
|
# dependency presents as a feature that silently is not there.
|
|
#
|
|
# Commands from coreutils and the shell itself are not checked -- nothing
|
|
# installs those separately, and listing them would be noise.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
fail() {
|
|
printf 'declared dependencies contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Shell syntax and builtins. These are not commands anyone installs, and the
|
|
# first version of this contract reported `then`, `esac` and `done` as missing
|
|
# packages, which buried the four real findings in a hundred lines of noise.
|
|
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
|
|
|
|
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
|
|
# systemd/session tooling. Nothing here is a choice Panama makes.
|
|
#
|
|
# authselect is on the list for the same reason: it manages Fedora's PAM and
|
|
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
|
|
# fingerprint aliases in config/bash can rely on it without declaring it.
|
|
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect|setsid|nohup)$'
|
|
|
|
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
|
|
|
# Installed by install-packages itself rather than by a package list. Two
|
|
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
|
# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
|
|
# would win every per-project `nvm use`, which is the whole point of having nvm.
|
|
# Anything added here needs a matching install block and a stated reason.
|
|
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
|
|
|
|
# Tools an alias may lean on without Panama installing them anywhere. The
|
|
# docker aliases serve the machines that run Docker by deliberate choice;
|
|
# Panama's container runtime is rootless podman (development-packages), and
|
|
# declaring docker in a list would put a second container daemon on every
|
|
# fresh machine to keep five aliases company. Where docker is absent the
|
|
# aliases fail by naming the missing command, which is the honest outcome.
|
|
# Anything added here needs that same property: absence must be loud.
|
|
OPTIONAL='^(docker)$'
|
|
|
|
# jq programs are quoted arguments, but the scanner is line-based and cannot
|
|
# tell a filter from a command. `not` is a jq builtin appearing inside one.
|
|
JQ_BUILTINS='^(not|empty|error|env|input|inputs)$'
|
|
|
|
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
|
|
[[ -n "$declared" ]] || fail 'no package lists found'
|
|
|
|
# A package is not always named after its command. Only the genuine mismatches
|
|
# are mapped, so an unmapped command is a real omission rather than a lookup
|
|
# failure.
|
|
package_for() {
|
|
case "$1" in
|
|
zbarimg) printf 'zbar' ;;
|
|
fc-list|fc-match) printf 'fontconfig' ;;
|
|
fprintd-list) printf 'fprintd' ;;
|
|
lspci) printf 'pciutils' ;;
|
|
getenforce) printf 'libselinux-utils' ;;
|
|
nmcli) printf 'NetworkManager' ;;
|
|
wpctl) printf 'wireplumber' ;;
|
|
nvim) printf 'neovim' ;;
|
|
fwupdmgr) printf 'fwupd' ;;
|
|
dnf4) printf 'python3-dnf' ;;
|
|
notify-send) printf 'libnotify' ;;
|
|
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
|
ssh-keygen) printf 'openssh' ;;
|
|
ssh|ssh-add) printf 'openssh-clients' ;;
|
|
# The Wayland build is the one that can inject into this session; the
|
|
# x11 one cannot. desktop-packages declares it under that name.
|
|
espanso) printf 'espanso-wayland' ;;
|
|
rg) printf 'ripgrep' ;;
|
|
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
|
|
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
|
|
python3) printf 'python3' ;;
|
|
*) printf '%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
missing=()
|
|
unguarded=()
|
|
checked=0
|
|
|
|
while read -r script; do
|
|
[[ -n "$script" ]] || continue
|
|
head -1 "$script" | grep -qE 'bash|/sh' || continue
|
|
|
|
# Commands appearing at the start of a statement or after a pipe. Crude, but
|
|
# it is looking for undeclared dependencies, not building a call graph.
|
|
#
|
|
# No minimum length. An earlier version required three characters, which
|
|
# quietly excluded the most-used dependency in the repository -- jq, at
|
|
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
|
|
# with a blind spot for short names is worse than none, because it reports
|
|
# PASS.
|
|
while read -r cmd; do
|
|
[[ -n "$cmd" ]] || continue
|
|
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
|
|
[[ "$cmd" =~ $BASELINE ]] && continue
|
|
[[ "$cmd" =~ $SESSION ]] && continue
|
|
[[ "$cmd" =~ $SELF_INSTALLED ]] && continue
|
|
[[ "$cmd" =~ $OPTIONAL ]] && continue
|
|
[[ "$cmd" =~ $JQ_BUILTINS ]] && continue
|
|
|
|
pkg="$(package_for "$cmd")"
|
|
grep -qx "$pkg" <<<"$declared" && continue
|
|
|
|
# Only report a command that actually exists on this machine. An
|
|
# invented name in a comment or a heredoc is a false positive; a real
|
|
# binary that nothing declares is the thing being looked for.
|
|
command -v "$cmd" >/dev/null 2>&1 || continue
|
|
|
|
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
|
|
done < <({
|
|
# Heredoc bodies are not shell. A python or sql block embedded in a
|
|
# script is scanned as though every line began a command, and its
|
|
# keywords collide with real binaries often enough to matter: `import`
|
|
# is ImageMagick, `time` is a package, `select` is shell syntax. The
|
|
# scanner cannot parse those languages and should not try, so the
|
|
# bodies are dropped before anything else looks at them.
|
|
scanned="$(awk '
|
|
# Whole-line comments. These files explain themselves at length,
|
|
# and prose containing "; cancel it" or "| list" reads as a
|
|
# statement to a line-based scanner. Dropped first so a comment
|
|
# mentioning <<EOF cannot open a heredoc either.
|
|
!inbody && /^[[:space:]]*#/ { next }
|
|
|
|
# <<MARKER, <<-MARKER, <<"MARKER", <<'"'"'MARKER'"'"' -- with or
|
|
# without a command in front of it.
|
|
!inbody && match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/) {
|
|
marker = substr($0, RSTART, RLENGTH)
|
|
gsub(/^<<-?[[:space:]]*["'"'"']?|["'"'"']?$/, "", marker)
|
|
inbody = 1
|
|
print
|
|
next
|
|
}
|
|
inbody {
|
|
line = $0
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
|
|
if (line == marker) inbody = 0
|
|
next
|
|
}
|
|
{ print }
|
|
' "$script")"
|
|
|
|
# Statement-initial or after a pipe.
|
|
#
|
|
# A word followed by `=` is a variable assignment and a word followed
|
|
# by `)` is a case label; neither is a command, and both collide with
|
|
# real binaries -- `count`, `host` and `cancel` are all installed on a
|
|
# normal Fedora machine, so `command -v` below cannot filter them out.
|
|
# Requiring whitespace or end-of-line after the word excludes both.
|
|
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+([[:space:]]|$)' <<<"$scanned" \
|
|
| grep -oE '[a-z][a-z0-9_-]+'
|
|
|
|
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
|
|
# so it never appears statement-initial and was missed entirely.
|
|
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' <<<"$scanned" \
|
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
|
|
|
# `command -v X` is how these helpers probe for a tool before using it,
|
|
# which makes it the clearest possible statement of a dependency.
|
|
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
|
} | sort -u)
|
|
|
|
checked=$((checked + 1))
|
|
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
|
|
"$repo_dir/config/local/share/vicinae/scripts" \
|
|
"$repo_dir/setup/scripts" "$repo_dir/bin" \
|
|
"$repo_dir/config/bash" \
|
|
-type f 2>/dev/null)
|
|
|
|
# ── Sourced paths ────────────────────────────────────────────────────────────
|
|
#
|
|
# config/bash is scanned above because it is where dependencies hide: the shell
|
|
# configuration names nvm, oh-my-posh, zoxide, eza and fzf, and it was excluded
|
|
# for long enough that `nvm` reached this repository's own shell config without
|
|
# ever being installed by it.
|
|
#
|
|
# Sourcing is the other half. A shell configuration that sources a path nothing
|
|
# guarantees exists produces an error on every single shell start, on exactly
|
|
# the machines least able to explain it -- new ones. That happened twice here:
|
|
# `$HOME/.cargo/env`, which rustup writes only after rustup-init has run, and
|
|
# `/etc/profile.d/nvm.sh`, which belongs to a package nothing installed.
|
|
#
|
|
# So a source of anything outside this repository has to be guarded. Whether the
|
|
# owning package is declared is not enough: the file still does not exist until
|
|
# that package is installed, and a shell can be opened before then.
|
|
while read -r file; do
|
|
[[ -f "$file" ]] || continue
|
|
while IFS= read -r line; do
|
|
# Only unconditional ones. A guard anywhere on the line is the fix.
|
|
[[ "$line" =~ \[\[?[[:space:]]*-[fesr] ]] && continue
|
|
path="$(sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]].*//' <<<"$line")"
|
|
[[ -n "$path" ]] || continue
|
|
# Only literal paths. `. "$rc"` inside a loop that already tested the
|
|
# variable is a different shape, guarded structurally rather than on the
|
|
# same line, and reporting it would bury the real findings.
|
|
[[ "$path" =~ ^[\"\']?(/|~|\$HOME|\$\{HOME) ]] || continue
|
|
# Paths inside the repository ship with it and are always present.
|
|
[[ "$path" == *PANAMA* ]] && continue
|
|
unguarded+=("$(basename "$file"): $path")
|
|
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^[:space:]]' "$file" | sed 's/^[0-9]*://')
|
|
done < <(find "$repo_dir/config/bash" -type f 2>/dev/null)
|
|
|
|
if (( ${#unguarded[@]} > 0 )); then
|
|
printf 'declared dependencies contract: sourced without checking it exists:\n' >&2
|
|
printf ' %s\n' "${unguarded[@]}" | sort -u >&2
|
|
fail 'guard each with a -f test, or every shell on a fresh machine starts with an error'
|
|
fi
|
|
|
|
if (( ${#missing[@]} > 0 )); then
|
|
printf 'declared dependencies contract: commands used but never installed:\n' >&2
|
|
printf ' %s\n' "${missing[@]}" | sort -u >&2
|
|
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
|
|
fi
|
|
|
|
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"
|