#!/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 < ${SCRIPT_NAME} --remote 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 "$@"