From 725e274ef49c7613f484a75bd9d7abba95de062f Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 00:30:48 -0400 Subject: [PATCH] Install the Node this shell config has always assumed config/bash/shell sources /etc/profile.d/nvm.sh, switches Node per project from .nvmrc, and puts PNPM_HOME on PATH. None of it worked on a fresh machine. nvm was never installed -- it is a Terra package, present here since before Panama -- and the source was unconditional, so every shell on a new box opened with an error before it got as far as failing to find nvm. That is the second instance of the same bug. $HOME/.cargo/env was the first, and fixing it one file at a time is why this one survived: the dependency contract scanned setup/scripts, bin and the quickshell helpers, but never config/bash -- the one place in this repository whose entire job is to name tools and source the files that provide them. So it scans it now, and checks the shape rather than the instance: a literal path sourced without testing it exists is a finding, wherever it appears. It found the nvm line, and authselect behind the fingerprint aliases. Node and pnpm move to nvm with it. They were declared as dnf packages while the machine ran them from ~/.nvm, which is not a preference so much as a contradiction -- a system Node earlier on PATH wins every `nvm use`, so the per-project switching this shell config sets up could never have worked. nvm install --lts, then pnpm inside it, so pnpm travels with the Node version it belongs to instead of outliving it. Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1 --- config/bash/shell | 6 +- setup/packages/development-packages | 7 ++- setup/scripts/install-packages | 30 ++++++++++ .../quickshell/declared-dependencies-contract | 59 ++++++++++++++++--- 4 files changed, 90 insertions(+), 12 deletions(-) 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