diff --git a/install b/install index c0a09d5..2438c27 100755 --- a/install +++ b/install @@ -1,11 +1,24 @@ #!/usr/bin/env bash -source ~/.local/share/Panama/bin/ascii -# Set host name + +# Panama's installer. Safe to re-run: every stage is idempotent, and this is +# also the upgrade path. + +set -uo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" +source "$PANAMA_PATH/bin/ascii" + +# ── Hostname, which is optional ────────────────────────────────────────────── +# +# Declining this used to `exit`, which aborted the ENTIRE installation. The +# prompt defaults to N, so simply pressing Enter -- the obvious thing to do when +# you do not want to rename your machine -- installed nothing at all and said +# nothing about it. echo -e "Current hostname is: $(hostname)" -read -p "Do you want to change the hostname? [y/N]: " confirm_change +read -r -p "Do you want to change the hostname? [y/N]: " confirm_change if [[ "$confirm_change" =~ ^[Yy]$ ]]; then - read -p "Hostname: " HOST_NAME - read -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname + read -r -p "Hostname: " HOST_NAME + read -r -p "Set hostname to '$HOST_NAME'? [y/N]: " confirm_hostname if [[ "$confirm_hostname" =~ ^[Yy]$ ]]; then sudo hostnamectl set-hostname "$HOST_NAME" echo "Hostname set to: $(hostname)" @@ -13,18 +26,45 @@ if [[ "$confirm_change" =~ ^[Yy]$ ]]; then echo "Hostname not changed." fi else - echo "Not changing hostname." - exit + echo "Keeping the current hostname." fi -# Ensure computer doesn't go to sleep. -gsettings set org.gnome.desktop.screensaver lock-enabled false -gsettings set org.gnome.desktop.session idle-delay 0 +# ── Keep the machine awake for the duration ────────────────────────────────── +# Package installation takes long enough to hit an idle lock, and being locked +# out mid-transaction is unpleasant. Restored on every exit path, including +# failure and Ctrl-C, so an interrupted install does not leave the screen +# permanently awake. +restore_idle() { + gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true + gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true +} +trap restore_idle EXIT INT TERM -# Run each setup stage in its own process. This keeps strict-shell options and -# helper variables local to the script that owns them. -for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done +gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true +gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true -# Revert to normal idle settings -gsettings set org.gnome.desktop.screensaver lock-enabled true -gsettings set org.gnome.desktop.session idle-delay 300 +# ── Stages ─────────────────────────────────────────────────────────────────── +# Each runs in its own process so strict-shell options and helper variables stay +# local to the script that owns them. A failing stage is reported and the rest +# still run: a missing optional package should not stop the dotfiles being +# linked. The summary at the end is what decides whether the install worked, +# because a failure scrolled past twenty minutes ago is a failure nobody saw. +failed=() +for script in "$PANAMA_PATH"/setup/scripts/*; do + [[ -x "$script" ]] || continue + stage="$(basename "$script")" + printf '\n=== %s ===\n' "$stage" + if ! "$script"; then + failed+=("$stage") + printf '!!! %s failed\n' "$stage" >&2 + fi +done + +printf '\n' +if (( ${#failed[@]} == 0 )); then + echo "Panama installed. Log out and choose the Hyprland session to start it." +else + printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2 + printf 'Re-running ./install is safe and will retry them.\n' >&2 + exit 1 +fi diff --git a/setup/packages/hyprland-packages b/setup/packages/hyprland-packages index 550aa17..bd9f678 100644 --- a/setup/packages/hyprland-packages +++ b/setup/packages/hyprland-packages @@ -1,38 +1,45 @@ -hyprland -hyprland-uwsm -uwsm -quickshell -vicinae -hyprlock -hypridle -hyprpaper -hyprpicker -hyprsunset -hyprpolkitagent -hyprshutdown -hyprpwcenter -hyprsysteminfo -hyprland-guiutils -xdg-desktop-portal-hyprland -grim -slurp -grimblast -satty -wl-clipboard -wf-recorder -gpu-screen-recorder -brightnessctl -playerctl -pamixer -udiskie -wofi +NetworkManager adw-gtk3-theme adwaita-icon-theme adwaita-sans-fonts -qt6-qtwayland -nm-connection-editor +brightnessctl +ddcutil +gpu-screen-recorder +grim +grimblast +gtk-update-icon-cache +hypridle +hyprland +hyprland-guiutils +hyprland-uwsm +hyprlock +hyprpaper +hyprpicker +hyprpolkitagent +hyprpwcenter +hyprshutdown +hyprsunset +hyprsysteminfo kde-connect +libnotify +nm-connection-editor +orca +pamixer +playerctl +qrencode +qt6-qtwayland +quickshell +satty +slurp +system-config-printer tesseract tesseract-langpack-eng +udiskie +uwsm +vicinae +wf-recorder +wireplumber +wl-clipboard +wofi +xdg-desktop-portal-hyprland zbar -system-config-printer diff --git a/setup/packages/initial-packages b/setup/packages/initial-packages index 12f0d89..6a556e8 100644 --- a/setup/packages/initial-packages +++ b/setup/packages/initial-packages @@ -1,18 +1,27 @@ awk bat +btop cargo curl eza +fontconfig +fwupd fzf -git-all gh +git-all gum +jq +kitty ksshaskpass +libselinux-utils neovim openssl +pciutils +python3-dnf python3-neovim rustup +tmux unzip -wireguard-tools wget +wireguard-tools zoxide diff --git a/tests/quickshell/declared-dependencies-contract.sh b/tests/quickshell/declared-dependencies-contract.sh new file mode 100755 index 0000000..f73b746 --- /dev/null +++ b/tests/quickshell/declared-dependencies-contract.sh @@ -0,0 +1,122 @@ +#!/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 colour 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. +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)$' + +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)$' + +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' ;; + rg) printf 'ripgrep' ;; + python3) printf 'python3' ;; + *) printf '%s' "$1" ;; + esac +} + +missing=() +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 + + 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 < <({ + # Statement-initial or after a pipe. + grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+' "$script" \ + | 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_-]+' "$script" \ + | 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" \ + -type f 2>/dev/null) + +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" diff --git a/tests/quickshell/settings-hyprland-write-contract.sh b/tests/quickshell/settings-hyprland-write-contract.sh index f56e002..78c3d2f 100755 --- a/tests/quickshell/settings-hyprland-write-contract.sh +++ b/tests/quickshell/settings-hyprland-write-contract.sh @@ -137,10 +137,20 @@ last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastE # ── A rejected value must be refused, not silently accepted ────────────────── qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null -sleep 0.3 + +# The refusal is reported asynchronously, so wait for it rather than sleeping a +# fixed 0.3s and hoping. That sleep made this fail roughly one run in three, +# reporting "a rejected value did not surface an error" when the error simply +# had not arrived yet -- which reads as a missing guard rather than a slow one. +rejected="" +for _ in $(seq 1 60); do + rejected="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" + [[ -n "$rejected" ]] && break + sleep 0.1 +done + [[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor' -[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \ - || fail 'a rejected VRR value did not surface an error' +[[ -n "$rejected" ]] || fail 'a rejected VRR value did not surface an error' # ── Every getoption answer shape must be handled, not just integers ────────── # The compositor reports each option in a different JSON field depending on its