1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
|
#!/bin/bash
# nvtable - compare upstream (nvchecker) vs installed vs shipped-in-repo versions.
#
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# 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 <repo>/<pkg>/<pkg>.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"
)
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 <<EOF
usage: nvtable [-a] [-n] [-C] [-E]
-a, --all show every tracked package, not just those needing action
-n, --no-take do not run nvtake on caught-up packages
-C, --no-color plain output
-E, --no-emoji ASCII status markers instead of emoji
EOF
exit 0 ;;
*) echo "nvtable: unknown option $a" >&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 "<want>-" 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
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
|