diff --git a/config/bash/shell b/config/bash/shell index f0f4485..9002cf5 100644 --- a/config/bash/shell +++ b/config/bash/shell @@ -22,8 +22,10 @@ export DOTNETPATH="$HOME/.dotnet/tools" # Set complete path export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH" -# Nvm -source /etc/profile.d/nvm.sh +# Nvm. Guarded because the file belongs to the nvm package: before that is +# installed it does not exist, and an unconditional source means every shell on +# a fresh machine opens with an error. +[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh # Auto-switch Node version when entering a directory with .nvmrc _nvm_auto_use() { if [[ -f .nvmrc ]]; then diff --git a/setup/packages/development-packages b/setup/packages/development-packages index 1729972..a9e03fd 100644 --- a/setup/packages/development-packages +++ b/setup/packages/development-packages @@ -13,12 +13,13 @@ ImageMagick java-latest-openjdk-devel luarocks maven -nodejs -nodejs-npm +# Node is installed through nvm rather than dnf, because config/bash/shell +# switches version per project from .nvmrc and a system Node earlier on PATH +# would win every switch. install-packages does the rest. +nvm pipx php php-fpm -pnpm # Rootless containers, and the backend for the Containers settings page. podman python3-devel diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 5eae70b..5ec6445 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -103,6 +103,36 @@ 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" + log "Node $(node --version 2>/dev/null) with pnpm $(pnpm --version 2>/dev/null)" + else + log "nvm could not install Node; skipping" + fi + set -u +else + log "nvm is not installed, so Node was not set up" +fi + # --- Install the Hyprland desktop --- # Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index 1e98411..e04897e 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -32,16 +32,20 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio # 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)$' +# +# 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, because no repository carries them. -# They are deliberately absent from the package lists, and install-packages -# probes for them with `command -v` precisely because they arrive out of band -- -# so that probe must not be read as an undeclared dependency. Anything added -# here needs a matching install block and a stated reason for the exception. -SELF_INSTALLED='^(bun|claude)$' +# 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. @@ -77,6 +81,7 @@ package_for() { } missing=() +unguarded=() checked=0 while read -r script; do @@ -128,8 +133,48 @@ while read -r script; do 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