#!/bin/bash # nvtable - compare upstream (nvchecker) vs installed vs shipped-in-repo versions. # # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # Three version sources per tracked name: # upstream - nvchecker new_ver.json # installed - /var/log/packages, then appimage-updater state, then flatpak # repo - VERSION= in //.info # # When installed == upstream the row is caught up, and nvtake records it so # old_ver tracks the system. See take_caught_up() for the locking. set -uo pipefail CONF="${NVTABLE_CONF:-$HOME/.config/nvchecker/nvchecker.toml}" NEWVER="${NVTABLE_NEWVER:-$HOME/.config/nvchecker/new_ver.json}" AI_STATE="${XDG_STATE_HOME:-$HOME/.local/state}/appimage-updater/installed.json" LOCK="${TMPDIR:-/tmp}/nvtable-$UID.lock" REPOS=( "sbo:$HOME/Programming/GIT/sbo-slackbuilds" "my:$HOME/Programming/GIT/GITHUB/my-slackbuilds" "sps:$HOME/Programming/GIT/GITHUB/Slackware-Pentesting-Suite" ) # Stanza name -> installed package name(s), space separated for one-to-many. # Only for names that differ; everything else joins directly. declare -A ALIAS=( [kvantum]="kvantum-qt5 kvantum-qt6" [metasploit-framework]="metasploit-framework-bin" ) # Stanza name -> SlackBuild directory name, where the repo dir differs from the # stanza (a -bin build tracked under its plain upstream name). declare -A REPO_ALIAS=( [kitty]="kitty-bin" [claude-code]="claude-code-bin" [kvantum]="kvantum-qt6" [metasploit-framework]="metasploit-framework-bin" ) # Stanza name -> JSON file carrying the installed version, for apps unpacked # under /opt that are neither a package, an AppImage, nor a flatpak. Value is # ":". declare -A OPT_JSON=( [Typora]="/opt/Typora-linux-x64/resources/package.json:.version" ) SHOW_ALL=0; NO_TAKE=0; NO_COLOR=0; NO_EMOJI=0 for a in "$@"; do case "$a" in -a|--all) SHOW_ALL=1 ;; -n|--no-take) NO_TAKE=1 ;; -C|--no-color) NO_COLOR=1 ;; -E|--no-emoji) NO_EMOJI=1 ;; -h|--help) cat <&2; exit 2 ;; esac done [[ -t 1 ]] || NO_COLOR=1 if (( NO_COLOR )); then R=""; B=""; DIM=""; RED=""; GRN=""; YEL=""; CYA=""; WHT="" else R=$'\e[0m'; B=$'\e[1m'; DIM=$'\e[2m'; RED=$'\e[1;31m' GRN=$'\e[1;32m'; YEL=$'\e[1;33m'; CYA=$'\e[1;36m'; WHT=$'\e[1;37m' fi # Status markers. Emoji are double-width, so each ASCII fallback is padded to # two columns to keep the name column aligned either way. if (( NO_EMOJI )); then E_UP="!!" E_REPO="~ " E_OK="ok" E_NA="- " else E_UP="🔴" E_REPO="🟡" E_OK="🟢" E_NA="⚪" fi command -v jq >/dev/null || { echo "nvtable: jq required" >&2; exit 1; } [[ -r $NEWVER ]] || { echo "nvtable: no $NEWVER (run nvchecker first)" >&2; exit 1; } # --- installed versions ----------------------------------------------------- # Package files are PRGNAM-VERSION-ARCH-BUILD, but VERSION may itself contain a # dash (solvespace-3.1-659215d), so the PRGNAM/VERSION boundary cannot be found # by counting dashes. Drop ARCH-BUILD from the right, then keep the whole # remainder; lookup_installed() anchors on the name it is searching for. declare -A PKGREST while IFS= read -r f; do rest=${f%-*} # drop BUILD rest=${rest%-*} # drop ARCH -> "PRGNAM-VERSION" [[ -n $rest ]] && PKGREST[$rest]=1 done < <(/bin/ls -1 /var/log/packages 2>/dev/null) declare -A INST for rest in "${!PKGREST[@]}"; do # Default split: last dash. Correct for the common case, fixed up below for # any name that a tracked stanza claims explicitly. INST[${rest%-*}]=${rest##*-} done # Given an exact package name, take everything after it as the version. The # match is anchored on "-" and strips exactly that many characters, so a # VERSION containing dashes survives and a longer sibling package (kvantum-qt5 # when asked for kvantum) is never mistaken for a match. pkg_version() { local want=$1 rest for rest in "${!PKGREST[@]}"; do if [[ ${rest,,} == "${want,,}-"* ]]; then rest=${rest:${#want}+1} # Reject when what follows is not a version (another name component). [[ $rest == [0-9]* ]] || continue echo "$rest"; return 0 fi done return 1 } # AppImages tracked by appimage-updater (its state is authoritative for those). if [[ -r $AI_STATE ]]; then while IFS=$'\t' read -r k v; do [[ -n $k ]] && INST[$k]=$v done < <(jq -r 'to_entries[]|"\(.key)\t\(.value.version)"' "$AI_STATE" 2>/dev/null) fi # Flatpak apps, keyed on the last dot-component of the app id. if command -v flatpak >/dev/null 2>&1; then while IFS=$'\t' read -r app v; do [[ -n $app ]] || continue INST[${app##*.}]=$v done < <(flatpak list --app --columns=application,version 2>/dev/null) fi installed_of() { # echoes "version" or "" ; handles alias + case-insensitive local n=$1 cand v spec f if [[ -n ${OPT_JSON[$n]:-} ]]; then spec=${OPT_JSON[$n]}; f=${spec%%:*} [[ -r $f ]] && v=$(jq -r "${spec#*:} // empty" "$f" 2>/dev/null) \ && [[ -n $v ]] && { echo "$v"; return; } fi for cand in ${ALIAS[$n]:-$n}; do # Anchored lookup first: correct even when VERSION contains a dash. v=$(pkg_version "$cand") && { echo "$v"; return; } [[ -n ${INST[$cand]:-} ]] && { echo "${INST[$cand]}"; return; } for k in "${!INST[@]}"; do [[ ${k,,} == "${cand,,}" ]] && { echo "${INST[$k]}"; return; } done done } # --- repo .info versions ---------------------------------------------------- declare -A RVER RWHICH for entry in "${REPOS[@]}"; do tag=${entry%%:*}; dir=${entry#*:} [[ -d $dir ]] || continue while IFS= read -r inf; do pkg=$(basename "$inf" .info) v=$(sed -n 's/^VERSION="\(.*\)"$/\1/p' "$inf" | head -1) [[ -n $v ]] || continue if [[ -n ${RVER[$pkg]:-} ]]; then RWHICH[$pkg]="${RWHICH[$pkg]},$tag" else RVER[$pkg]=$v; RWHICH[$pkg]=$tag fi done < <(find "$dir" -mindepth 2 -maxdepth 3 -name '*.info' 2>/dev/null) done # --- compare ---------------------------------------------------------------- # Versions come from unrelated sources, so normalise before comparing: strip a # leading v, fold - and _ to dots, lowercase. norm() { local s=${1,,}; s=${s#v}; s=${s//[-_]/.}; echo "$s"; } # Some packages append build metadata the upstream version never carries: # a kernel version (r8125 9.018.00_6.18.41) or a git hash (solvespace # 3.1-659215d). Treat installed as equal when it merely extends upstream at a # component boundary. vers_equal() { local a b; a=$(norm "$1"); b=$(norm "$2") [[ $a == "$b" ]] && return 0 [[ -n $a && -n $b && $b == "$a".* ]] && return 0 return 1 } rows=(); take=(); n_up=0; n_repo=0; n_ok=0; n_na=0 while IFS=$'\t' read -r name uver; do [[ -n $name ]] || continue iver=$(installed_of "$name") rkey=$name if [[ -z ${RVER[$rkey]:-} ]]; then for cand in ${REPO_ALIAS[$name]:-} ${ALIAS[$name]:-}; do [[ -n ${RVER[$cand]:-} ]] && { rkey=$cand; break; } done fi rver=${RVER[$rkey]:-}; rwhich=${RWHICH[$rkey]:-} same_inst=0; [[ -n $iver ]] && vers_equal "$uver" "$iver" && same_inst=1 same_repo=1; [[ -n $rver ]] && { vers_equal "$uver" "$rver" || same_repo=0; } if (( same_inst )) && (( same_repo )); then (( n_ok++ )); take+=("$name") (( SHOW_ALL )) || continue else (( same_inst )) || { [[ -n $iver ]] && (( n_up++ )); } (( same_repo )) || (( n_repo++ )) # Nothing installed and the .info already matches upstream: there is no # action to take on this host, so keep it out of the default view. if [[ -z $iver ]] && (( same_repo )); then (( n_na++ )) (( SHOW_ALL )) || continue fi fi # Packed with | not tab: read collapses runs of whitespace IFS chars, which # would shift every field left whenever installed or repo is empty. rows+=("$name|$uver|$iver|$rver|$rwhich|$same_inst|$same_repo") done < <(jq -r '.data|to_entries[]|"\(.key)\t\(.value.version // .value)"' "$NEWVER") # --- output ----------------------------------------------------------------- if ((${#rows[@]})); then # Widen the name column to the longest actual name, so a long stanza does not # shunt every later column out of alignment. w=22 for r in "${rows[@]}"; do n=${r%%|*}; (( ${#n} > w )) && w=${#n}; done printf '%s %-*s %-14s %-14s %s%s\n' "$DIM" "$w" "package" "upstream" "installed" "repo" "$R" for r in "${rows[@]}"; do IFS='|' read -r name uver iver rver rwhich si sr <<<"$r" if [[ -z $iver ]]; then ic="$DIM"; ishow="--" elif (( si )); then ic="$GRN"; ishow=$iver else ic="$RED"; ishow=$iver fi # Installed-behind and repo-behind are independent, and a row can be both # (upstream moved, and neither the system nor the .info has caught up), so # the marker shows each dimension rather than collapsing to the worst one. if [[ -z $iver ]]; then mark="$E_NA" elif (( si )); then mark="$E_OK" else mark="$E_UP" fi (( sr )) || mark+="$E_REPO" if [[ -z $rver ]]; then rc="$DIM"; rtxt="--" elif (( sr )); then rc="$GRN"; rtxt="$rwhich $rver" else rc="$YEL"; rtxt="$rwhich $rver" fi # Pad the marker cell to a fixed width: rows carrying both markers must not # push the name column right relative to rows carrying one. if (( NO_EMOJI )); then mcell=$(printf '%-5s' "$mark") else mcell=$mark; (( ${#mark} > 1 )) || mcell="$mark " fi printf ' %s %s%-*s%s %-14s %s%-14s%s %s%s%s\n' \ "$mcell" "$WHT" "$w" "$name" "$R" "$uver" "$ic" "$ishow" "$R" "$rc" "$rtxt" "$R" done echo fi printf ' %s%d%s to upgrade · %s%d%s repo%s to bump · %s%d%s current' \ "$RED" "$n_up" "$R" "$YEL" "$n_repo" "$R" "$( ((n_repo==1)) || echo s )" \ "$GRN" "$n_ok" "$R" (( n_na )) && printf ' · %s%d%s not installed' "$DIM" "$n_na" "$R" echo # --- nvtake ----------------------------------------------------------------- # Record caught-up packages so old_ver reflects the system. flock serialises # concurrent logins: nvtake rewrites old_ver.json without locking of its own. take_caught_up() { ((${#take[@]})) || return 0 command -v nvtake >/dev/null || return 0 exec 9>"$LOCK" || return 0 flock -n 9 || return 0 # another shell is taking; skip, next run catches it nvtake -c "$CONF" --ignore-nonexistent "${take[@]}" >/dev/null 2>&1 exec 9>&- } (( NO_TAKE )) || take_caught_up