aboutsummaryrefslogtreecommitdiffstats
path: root/nvtable
blob: 1eb008ad3a11bf5039526bcfbcc1e4b76cbba153 (plain)
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