Files
linux-scripts/changelog-diff.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

456 lines
17 KiB
Bash

#!/usr/bin/env bash
#########################################################################################
#### changelog-diff.sh — Compare installed package versions between snapshots ####
#### Supports before/after upgrade diffs, remote host comparison via SSH ####
#### Detects additions, removals, upgrades, and downgrades across dpkg and rpm ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./changelog-diff.sh --snapshot ####
#### ./changelog-diff.sh --diff snap1.txt snap2.txt ####
#### ./changelog-diff.sh --remote user@host ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
SNAPSHOT_DIR="${SNAPSHOT_DIR:-/var/backups/pkg-snapshots}"
MODE=""
DIFF_FILE_A=""
DIFF_FILE_B=""
REMOTE_HOST=""
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
# ── Colors ────────────────────────────────────────────────────────────
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET=""
return
fi
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET=""
fi
}
# ── Logging ───────────────────────────────────────────────────────────
log() { echo -e "${CYAN}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; }
# ── Helpers ───────────────────────────────────────────────────────────
section_header() {
echo ""
echo -e " ${BOLD}${CYAN}── $1 ──${RESET}"
echo ""
}
field() {
printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2"
}
detect_package_manager() {
if command -v dpkg-query &>/dev/null; then
echo "dpkg"
elif command -v rpm &>/dev/null; then
echo "rpm"
else
echo "unknown"
fi
}
get_package_list() {
local pm
pm=$(detect_package_manager)
case "$pm" in
dpkg)
dpkg-query -W -f='${Package} ${Version}\n' 2>/dev/null | sort
;;
rpm)
rpm -qa --queryformat '%{NAME} %{VERSION}-%{RELEASE}\n' 2>/dev/null | sort
;;
*)
err "No supported package manager found (need dpkg or rpm)"
exit 1
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# SNAPSHOT
# ══════════════════════════════════════════════════════════════════════
do_snapshot() {
if [[ ! -d "$SNAPSHOT_DIR" ]]; then
mkdir -p "$SNAPSHOT_DIR" 2>/dev/null || {
err "Cannot create snapshot directory: ${SNAPSHOT_DIR}"
exit 1
}
fi
local timestamp
timestamp=$(date +%Y%m%d-%H%M%S)
local host
host=$(hostname -s 2>/dev/null || hostname)
local pm
pm=$(detect_package_manager)
local snapshot_file="${SNAPSHOT_DIR}/${host}-${pm}-${timestamp}.txt"
verbose "Package manager: ${pm}"
verbose "Snapshot file: ${snapshot_file}"
get_package_list > "$snapshot_file"
local pkg_count
pkg_count=$(wc -l < "$snapshot_file")
log "Snapshot saved: ${snapshot_file}"
field "Packages:" "$pkg_count"
field "Package manager:" "$pm"
field "File:" "$snapshot_file"
}
# ══════════════════════════════════════════════════════════════════════
# DIFF
# ══════════════════════════════════════════════════════════════════════
do_diff() {
local file_a="$1"
local file_b="$2"
if [[ ! -f "$file_a" ]]; then
err "File not found: ${file_a}"
exit 1
fi
if [[ ! -f "$file_b" ]]; then
err "File not found: ${file_b}"
exit 1
fi
echo ""
echo -e "${BOLD}Package Diff${RESET}"
field "Before:" "$file_a"
field "After:" "$file_b"
local added=0 removed=0 upgraded=0 downgraded=0
# Build associative arrays
local tmp_added tmp_removed tmp_changed
tmp_added=$(mktemp)
tmp_removed=$(mktemp)
tmp_changed=$(mktemp)
trap 'rm -f "$tmp_added" "$tmp_removed" "$tmp_changed"' EXIT
# Find added packages (in B but not A)
while IFS=' ' read -r pkg ver; do
if ! grep -q "^${pkg} " "$file_a"; then
echo "${pkg} ${ver}" >> "$tmp_added"
fi
done < "$file_b"
# Find removed packages (in A but not B)
while IFS=' ' read -r pkg ver; do
if ! grep -q "^${pkg} " "$file_b"; then
echo "${pkg} ${ver}" >> "$tmp_removed"
fi
done < "$file_a"
# Find changed packages
while IFS=' ' read -r pkg ver_b; do
local ver_a
ver_a=$(grep "^${pkg} " "$file_a" 2>/dev/null | head -1 | cut -d' ' -f2-)
if [[ -n "$ver_a" && "$ver_a" != "$ver_b" ]]; then
echo "${pkg} ${ver_a} ${ver_b}" >> "$tmp_changed"
fi
done < "$file_b"
# Display additions
if [[ -s "$tmp_added" ]]; then
section_header "Added Packages"
while IFS=' ' read -r pkg ver; do
printf " ${CYAN}+${RESET} %-40s %s\n" "$pkg" "$ver"
added=$((added + 1))
done < "$tmp_added"
fi
# Display removals
if [[ -s "$tmp_removed" ]]; then
section_header "Removed Packages"
while IFS=' ' read -r pkg ver; do
printf " ${RED}-${RESET} %-40s %s\n" "$pkg" "$ver"
removed=$((removed + 1))
done < "$tmp_removed"
fi
# Display upgrades and downgrades
if [[ -s "$tmp_changed" ]]; then
local has_upgrades=false
local has_downgrades=false
# First pass: categorize
while IFS=' ' read -r pkg ver_a ver_b; do
if dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null; then
has_upgrades=true
elif dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null; then
has_downgrades=true
else
# Fallback: string comparison
if [[ "$ver_b" > "$ver_a" ]]; then
has_upgrades=true
else
has_downgrades=true
fi
fi
done < "$tmp_changed"
if [[ "$has_upgrades" == "true" ]]; then
section_header "Upgraded Packages"
while IFS=' ' read -r pkg ver_a ver_b; do
local is_upgrade=false
if dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null; then
is_upgrade=true
elif ! dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null && [[ "$ver_b" > "$ver_a" ]]; then
is_upgrade=true
fi
if [[ "$is_upgrade" == "true" ]]; then
printf " ${GREEN}${RESET} %-35s %s → %s\n" "$pkg" "$ver_a" "$ver_b"
upgraded=$((upgraded + 1))
fi
done < "$tmp_changed"
fi
if [[ "$has_downgrades" == "true" ]]; then
section_header "Downgraded Packages"
while IFS=' ' read -r pkg ver_a ver_b; do
local is_downgrade=false
if dpkg --compare-versions "$ver_b" lt "$ver_a" 2>/dev/null; then
is_downgrade=true
elif ! dpkg --compare-versions "$ver_b" gt "$ver_a" 2>/dev/null && [[ "$ver_b" < "$ver_a" ]]; then
is_downgrade=true
fi
if [[ "$is_downgrade" == "true" ]]; then
printf " ${YELLOW}${RESET} %-35s %s → %s\n" "$pkg" "$ver_a" "$ver_b"
downgraded=$((downgraded + 1))
fi
done < "$tmp_changed"
fi
fi
# Summary
local total=$((added + removed + upgraded + downgraded))
echo ""
echo -e " ${BOLD}══════════════════════════════════════════${RESET}"
echo -e " ${BOLD}Change Summary${RESET}"
echo -e " ${BOLD}══════════════════════════════════════════${RESET}"
field "Total changes:" "$total"
if [[ "$added" -gt 0 ]]; then
printf " ${BOLD}%-22s${RESET} ${CYAN}%s${RESET}\n" "Additions:" "$added"
else
field "Additions:" "0"
fi
if [[ "$removed" -gt 0 ]]; then
printf " ${BOLD}%-22s${RESET} ${RED}%s${RESET}\n" "Removals:" "$removed"
else
field "Removals:" "0"
fi
if [[ "$upgraded" -gt 0 ]]; then
printf " ${BOLD}%-22s${RESET} ${GREEN}%s${RESET}\n" "Upgrades:" "$upgraded"
else
field "Upgrades:" "0"
fi
if [[ "$downgraded" -gt 0 ]]; then
printf " ${BOLD}%-22s${RESET} ${YELLOW}%s${RESET}\n" "Downgrades:" "$downgraded"
else
field "Downgrades:" "0"
fi
echo ""
}
# ══════════════════════════════════════════════════════════════════════
# REMOTE COMPARE
# ══════════════════════════════════════════════════════════════════════
do_remote() {
local remote="$1"
if ! command -v ssh &>/dev/null; then
err "ssh is required for remote comparison"
exit 1
fi
log "Fetching local package list..."
local local_file
local_file=$(mktemp)
log "Fetching remote package list from ${remote}..."
local remote_file
remote_file=$(mktemp)
trap 'rm -f "$local_file" "$remote_file"' EXIT
get_package_list > "$local_file"
local pm
pm=$(detect_package_manager)
case "$pm" in
dpkg)
ssh "$remote" "dpkg-query -W -f='\${Package} \${Version}\n' 2>/dev/null | sort" > "$remote_file" || {
err "Failed to fetch package list from ${remote}"
exit 1
}
;;
rpm)
ssh "$remote" "rpm -qa --queryformat '%{NAME} %{VERSION}-%{RELEASE}\n' 2>/dev/null | sort" > "$remote_file" || {
err "Failed to fetch package list from ${remote}"
exit 1
}
;;
esac
log "Comparing local vs ${remote}..."
do_diff "$local_file" "$remote_file"
}
# ══════════════════════════════════════════════════════════════════════
# USAGE
# ══════════════════════════════════════════════════════════════════════
usage() {
cat <<EOF
${SCRIPT_NAME} — Compare installed package versions between snapshots
USAGE:
${SCRIPT_NAME} --snapshot
${SCRIPT_NAME} --diff <before.txt> <after.txt>
${SCRIPT_NAME} --remote <user@host>
MODES:
--snapshot Save current package list to a timestamped file
--diff FILE1 FILE2 Compare two snapshot files
--remote HOST Compare local packages with a remote host via SSH
OPTIONS:
--snapshot-dir DIR Directory for snapshots (default: ${SNAPSHOT_DIR})
--verbose Enable debug output
--no-color Disable colored output
--help Show this help
ENVIRONMENT VARIABLES:
SNAPSHOT_DIR Snapshot directory (default: /var/backups/pkg-snapshots)
COLOR Color mode: auto, always, never (default: auto)
EXAMPLES:
# Take a snapshot before upgrade
./changelog-diff.sh --snapshot
# Upgrade packages, take another snapshot, then diff
./changelog-diff.sh --snapshot
sudo apt upgrade -y
./changelog-diff.sh --snapshot
./changelog-diff.sh --diff /var/backups/pkg-snapshots/host-*.txt
# Compare with a remote server
./changelog-diff.sh --remote admin@prod-server
# Custom snapshot directory
./changelog-diff.sh --snapshot --snapshot-dir /tmp/snapshots
EOF
}
# ══════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING
# ══════════════════════════════════════════════════════════════════════
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--snapshot)
MODE="snapshot"; shift ;;
--diff)
MODE="diff"
if [[ $# -lt 3 ]]; then
err "--diff requires two file arguments"
exit 1
fi
DIFF_FILE_A="$2"
DIFF_FILE_B="$3"
shift 3 ;;
--remote)
MODE="remote"
if [[ $# -lt 2 ]]; then
err "--remote requires a host argument"
exit 1
fi
REMOTE_HOST="$2"
shift 2 ;;
--snapshot-dir)
SNAPSHOT_DIR="$2"; shift 2 ;;
--verbose)
VERBOSE="true"; shift ;;
--no-color)
COLOR="never"; shift ;;
--help|-h)
setup_colors
usage
exit 0 ;;
*)
err "Unknown option: $1"
echo "Run ${SCRIPT_NAME} --help for usage" >&2
exit 1 ;;
esac
done
if [[ -z "$MODE" ]]; then
err "No mode specified. Use --snapshot, --diff, or --remote"
echo "Run ${SCRIPT_NAME} --help for usage" >&2
exit 1
fi
}
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
main() {
parse_args "$@"
setup_colors
echo ""
echo -e "${BOLD}Package Changelog Diff — $(hostname -f 2>/dev/null || hostname)${RESET}"
echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}"
case "$MODE" in
snapshot)
do_snapshot
;;
diff)
do_diff "$DIFF_FILE_A" "$DIFF_FILE_B"
;;
remote)
do_remote "$REMOTE_HOST"
;;
esac
}
main "$@"