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
This commit is contained in:
Gabriel Brown
2026-08-21 00:30:48 -04:00
parent f09763ef5d
commit 725e274ef4
4 changed files with 90 additions and 12 deletions
+4 -2
View File
@@ -22,8 +22,10 @@ export DOTNETPATH="$HOME/.dotnet/tools"
# Set complete path # 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" 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 # Nvm. Guarded because the file belongs to the nvm package: before that is
source /etc/profile.d/nvm.sh # 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 # Auto-switch Node version when entering a directory with .nvmrc
_nvm_auto_use() { _nvm_auto_use() {
if [[ -f .nvmrc ]]; then if [[ -f .nvmrc ]]; then
+4 -3
View File
@@ -13,12 +13,13 @@ ImageMagick
java-latest-openjdk-devel java-latest-openjdk-devel
luarocks luarocks
maven maven
nodejs # Node is installed through nvm rather than dnf, because config/bash/shell
nodejs-npm # switches version per project from .nvmrc and a system Node earlier on PATH
# would win every switch. install-packages does the rest.
nvm
pipx pipx
php php
php-fpm php-fpm
pnpm
# Rootless containers, and the backend for the Containers settings page. # Rootless containers, and the backend for the Containers settings page.
podman podman
python3-devel python3-devel
+30
View File
@@ -103,6 +103,36 @@ else
log "Package list was not in specified path: $DEV_FILE" log "Package list was not in specified path: $DEV_FILE"
fi 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 --- # --- Install the Hyprland desktop ---
# Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper. # Most of these live in the lionheartp/Hyprland COPR rather than Fedora proper.
HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages" HYPR_FILE="$PANAMA_PATH/setup/packages/hyprland-packages"
@@ -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 # Provided by any Fedora install: coreutils, util-linux, the shell, and the
# systemd/session tooling. Nothing here is a choice Panama makes. # 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)$' 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. # Installed by install-packages itself rather than by a package list. Two
# They are deliberately absent from the package lists, and install-packages # reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
# probes for them with `command -v` precisely because they arrive out of band -- # node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
# so that probe must not be read as an undeclared dependency. Anything added # would win every per-project `nvm use`, which is the whole point of having nvm.
# here needs a matching install block and a stated reason for the exception. # Anything added here needs a matching install block and a stated reason.
SELF_INSTALLED='^(bun|claude)$' SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
# jq programs are quoted arguments, but the scanner is line-based and cannot # 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. # tell a filter from a command. `not` is a jq builtin appearing inside one.
@@ -77,6 +81,7 @@ package_for() {
} }
missing=() missing=()
unguarded=()
checked=0 checked=0
while read -r script; do while read -r script; do
@@ -128,8 +133,48 @@ while read -r script; do
done < <(find "$repo_dir/config/dot/quickshell/scripts" \ done < <(find "$repo_dir/config/dot/quickshell/scripts" \
"$repo_dir/config/local/share/vicinae/scripts" \ "$repo_dir/config/local/share/vicinae/scripts" \
"$repo_dir/setup/scripts" "$repo_dir/bin" \ "$repo_dir/setup/scripts" "$repo_dir/bin" \
"$repo_dir/config/bash" \
-type f 2>/dev/null) -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 if (( ${#missing[@]} > 0 )); then
printf 'declared dependencies contract: commands used but never installed:\n' >&2 printf 'declared dependencies contract: commands used but never installed:\n' >&2
printf ' %s\n' "${missing[@]}" | sort -u >&2 printf ' %s\n' "${missing[@]}" | sort -u >&2