Panama had no idea whether it was running on a laptop. No upower, no battery, no lid, no AC: hypridle.conf says "This is a desktop" in its own header, and that was true of the code as well as the machine. panama-hw answers hardware questions one at a time, exits 0 or 1, and prints nothing, so scripts, services and contracts all ask the same way. The definition the rest of the laptop work hangs on is one line: clamshell is lid-closed AND an external monitor. A machine with no mains supply at all reports as being on wall power, because a desktop cannot run out of it. The battery service follows Vitals: sysfs through FileView, an availability flag, and no subprocess on the timer. Globbing is the one thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC or ADP1 or ACAD -- so panama-battery resolves the names once and the shell reads the files directly after. Nothing falls back to a plausible zero: a desktop shows no indicator, no card, and no charge limit control where the firmware has no ceiling. Also repairs two contracts that were already failing and had not been noticed, because only the full suite runs them. The dependency scanner treated line-initial variable assignments, case labels, comments and heredoc bodies as commands, and `count`, `host`, `cancel` and `import` are all real binaries on Fedora, so `command -v` could not filter them out. It now drops comments and heredoc bodies and requires a command to be followed by whitespace. Verified it still catches a genuinely undeclared dependency rather than passing quietly. The launcher command contract had not been told about the fourteen commands added earlier today.
225 lines
12 KiB
Bash
Executable File
225 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)$'
|
|
|
|
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)$'
|
|
|
|
# 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' ;;
|
|
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" =~ $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"
|