From 5f75b305a7904e8e12f6dcc4230524a5c6a8a37e Mon Sep 17 00:00:00 2001 From: "LINUXexpert.org" <51408202+LINUXexpert-org@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:01:05 -0700 Subject: [PATCH] Add ubuntu-desktop-prune.sh script for cleanup --- ubuntu-desktop-prune.sh | 510 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 ubuntu-desktop-prune.sh diff --git a/ubuntu-desktop-prune.sh b/ubuntu-desktop-prune.sh new file mode 100644 index 0000000..9ace7db --- /dev/null +++ b/ubuntu-desktop-prune.sh @@ -0,0 +1,510 @@ +#!/usr/bin/env bash +# ubuntu-desktop-prune.sh +# +# Copyright (C) 2026 LINUXexpert.org +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, version 3 of the License. +# +# 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. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see . + +set -euo pipefail + +# ========================= +# Version +# ========================= +SCRIPT_VERSION="1.1" + +# ========================= +# Globals / Defaults +# ========================= +LOG_DIR="/var/log/ubuntu-to-mint" +mkdir -p "$LOG_DIR" +LOG_FILE="${LOG_DIR}/ubuntu-desktop-prune-$(date +%Y%m%d-%H%M%S).log" +exec > >(tee -a "$LOG_FILE") 2>&1 + +CMD="" +ASSUME_YES="no" +WITH_RECOMMENDS="no" +SKIP_DM_FIX="no" +ROLLBACK_DIR="" + +# For safety gates +MAX_REMOVALS_DEFAULT=75 + +# ========================= +# Pretty output +# ========================= +is_tty() { [[ -t 1 ]]; } + +if is_tty && command -v tput >/dev/null 2>&1; then + RED="$(tput setaf 1)" + GREEN="$(tput setaf 2)" + YELLOW="$(tput setaf 3)" + BLUE="$(tput setaf 4)" + BOLD="$(tput bold)" + RESET="$(tput sgr0)" +else + RED=""; GREEN=""; YELLOW=""; BLUE=""; BOLD=""; RESET="" +fi + +info() { echo "${BLUE}INFO:${RESET} $*"; } +ok() { echo "${GREEN}OK:${RESET} $*"; } +warn() { echo "${YELLOW}WARN:${RESET} $*"; } +die() { echo "${RED}ERROR:${RESET} $*"; exit 1; } + +have_cmd() { command -v "$1" >/dev/null 2>&1; } + +need_root() { + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + die "This must be run as root. Use: sudo bash $0 ..." + fi +} + +on_err() { + local rc=$? + local line="${BASH_LINENO[0]:-unknown}" + echo "ERROR: FAILED at line ${line} (exit ${rc})." + echo "ERROR: Command: ${BASH_COMMAND}" + echo "ERROR: Log: ${LOG_FILE}" + exit "$rc" +} +trap on_err ERR + +usage() { + cat </dev/null 2>&1; then + die "APT/dpkg lock is held (lock file: $l). Close package managers and try again." + fi + done +} + +apt_fix_broken() { + info "Attempting basic dpkg/apt remediation (best-effort)..." + DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true + DEBIAN_FRONTEND=noninteractive apt-get -y -f install || true +} + +apt_opts_common() { + local -a opts + opts=(-y -o Dpkg::Use-Pty=0 -o Acquire::Retries=3) + if [[ "$WITH_RECOMMENDS" != "yes" ]]; then + opts+=(--no-install-recommends) + fi + echo "${opts[@]}" +} + +# ========================= +# OS / environment checks +# ========================= +detect_os_minimal() { + [[ -r /etc/os-release ]] || die "Cannot read /etc/os-release" + # shellcheck disable=SC1091 + . /etc/os-release + + info "Script v${SCRIPT_VERSION}" + info "Log: ${LOG_FILE}" + info "Detected OS: ${NAME:-unknown} (ID=${ID:-?}, VERSION_ID=${VERSION_ID:-?}, CODENAME=${VERSION_CODENAME:-?})" + + # We allow Ubuntu base systems (including “Ubuntu-with-Mint-repos” setups). + if [[ "${ID:-}" != "ubuntu" && "${ID:-}" != "linuxmint" ]]; then + warn "OS ID is not ubuntu/linuxmint. Proceeding anyway, but this script is designed for Ubuntu base systems." + fi + + if ! have_cmd apt-get || ! have_cmd dpkg; then + die "apt-get/dpkg not found. This script requires Debian/Ubuntu-style package management." + fi +} + +# ========================= +# Backup / rollback (limited) +# ========================= +backup_state() { + local backup_dir="/root/ubuntu-desktop-prune-backup-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$backup_dir" + + info "Creating backup at: ${backup_dir}" + mkdir -p "${backup_dir}/etc" + cp -a /etc/apt "${backup_dir}/etc/" || true + cp -a /etc/lightdm 2>/dev/null "${backup_dir}/etc/" || true + cp -a /etc/X11/default-display-manager 2>/dev/null "${backup_dir}/etc/" || true + cp -a /etc/systemd/system/display-manager.service 2>/dev/null "${backup_dir}/etc/" || true + + dpkg-query -W -f='${Package}\t${Version}\n' > "${backup_dir}/dpkg-packages.tsv" || true + apt-mark showmanual > "${backup_dir}/apt-manual.txt" || true + apt-mark showhold > "${backup_dir}/apt-holds.txt" || true + systemctl list-unit-files --state=enabled > "${backup_dir}/enabled-services.txt" 2>/dev/null || true + + ok "Backup complete." + echo "$backup_dir" +} + +rollback() { + need_root + local dir="${1:-}" + [[ -n "$dir" ]] || die "rollback requires a backup directory argument" + [[ -d "$dir" ]] || die "backup directory not found: $dir" + [[ -d "$dir/etc/apt" ]] || die "backup does not contain etc/apt: $dir/etc/apt" + + info "Restoring /etc/apt from: $dir/etc/apt" + rm -rf /etc/apt + cp -a "$dir/etc/apt" /etc/apt + + if [[ -d "$dir/etc/lightdm" ]]; then + info "Restoring /etc/lightdm from backup..." + rm -rf /etc/lightdm + cp -a "$dir/etc/lightdm" /etc/lightdm + fi + + if [[ -f "$dir/etc/default-display-manager" ]]; then + info "Restoring /etc/X11/default-display-manager from backup..." + mkdir -p /etc/X11 + cp -a "$dir/etc/default-display-manager" /etc/X11/default-display-manager + fi + + if [[ -f "$dir/etc/display-manager.service" ]]; then + info "Restoring /etc/systemd/system/display-manager.service from backup..." + mkdir -p /etc/systemd/system + cp -a "$dir/etc/display-manager.service" /etc/systemd/system/display-manager.service + systemctl daemon-reload >/dev/null 2>&1 || true + fi + + ok "Rollback completed (APT + DM configs)." + warn "Rollback does NOT automatically reinstall removed packages." + info "Now run:" + echo " sudo apt-get update" + echo " sudo apt-get -f install" +} + +# ========================= +# Display manager hardening (noninteractive-safe) +# ========================= +ensure_lightdm_default_noninteractive() { + if [[ "$SKIP_DM_FIX" == "yes" ]]; then + warn "--skip-dm-fix set; skipping LightDM default selection." + return 0 + fi + + if [[ ! -d /run/systemd/system ]]; then + warn "systemd not detected; skipping LightDM enablement." + return 0 + fi + + info "Ensuring LightDM is default display manager (debconf preseed + dpkg-reconfigure)..." + + DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) install lightdm slick-greeter debconf-utils >/dev/null 2>&1 || true + + # Preseed the DM selection so noninteractive installs switch away from gdm3. + # Key: shared/default-x-display-manager + echo "lightdm shared/default-x-display-manager select lightdm" | debconf-set-selections || true + + # Apply selection + DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive lightdm >/dev/null 2>&1 || true + + # Debian/Ubuntu mechanism + mkdir -p /etc/X11 + echo "/usr/sbin/lightdm" > /etc/X11/default-display-manager || true + + if have_cmd update-alternatives; then + update-alternatives --set x-display-manager /usr/sbin/lightdm >/dev/null 2>&1 || true + fi + + # If gdm3 exists, disable it so it can't take control + if systemctl list-unit-files | awk '{print $1}' | grep -qx 'gdm3.service'; then + systemctl disable --now gdm3 >/dev/null 2>&1 || true + systemctl mask gdm3 >/dev/null 2>&1 || true + fi + + # Ensure LightDM enabled + systemctl unmask lightdm >/dev/null 2>&1 || true + systemctl set-default graphical.target >/dev/null 2>&1 || true + systemctl enable lightdm >/dev/null 2>&1 || true + systemctl daemon-reload >/dev/null 2>&1 || true + + # Best-effort start + systemctl restart lightdm >/dev/null 2>&1 || true + + ok "LightDM default selection/enablement applied." +} + +# ========================= +# Target selection +# ========================= +pkg_installed() { + dpkg-query -W -f='${Status}\n' "$1" 2>/dev/null | grep -q "install ok installed" +} + +build_prune_list() { + # Conservative list: remove Ubuntu GNOME session + Ubuntu desktop meta packages + GDM. + # We intentionally avoid removing libs/toolkit packages that Cinnamon/Xfce/MATE may still rely on. + local -a candidates=( + ubuntu-desktop + ubuntu-desktop-minimal + ubuntu-session + ubuntu-session-minimal + gdm3 + gnome-shell + gnome-shell-common + gnome-shell-extension-ubuntu-dock + yaru-theme-gnome + yaru-theme-gtk + yaru-theme-icon + yaru-theme-sound + ubuntu-wallpapers + ubuntu-wallpapers-noble + ubuntu-wallpapers-jammy + gnome-software + snap-store + ) + + local -a selected=() + local p + for p in "${candidates[@]}"; do + if pkg_installed "$p"; then + selected+=("$p") + fi + done + + printf '%s\n' "${selected[@]}" +} + +show_target_summary() { + local -a pkgs=("$@") + if [[ ${#pkgs[@]} -eq 0 ]]; then + warn "No target packages from the conservative prune list are currently installed." + warn "Nothing to do." + return 1 + fi + + info "Prune targets (installed):" + printf ' - %s\n' "${pkgs[@]}" + return 0 +} + +# ========================= +# Simulation + safety gates +# (FIXED: exact-match critical package detection) +# ========================= +apt_simulate_purge() { + local -a pkgs=("$@") + local sim="/tmp/ubuntu-desktop-prune-sim.$$.txt" + + info "Simulating purge (no changes)..." + set +e + DEBIAN_FRONTEND=noninteractive apt-get -s purge --autoremove "${pkgs[@]}" 2>&1 | tee "$sim" + local rc=${PIPESTATUS[0]} + set -e + + [[ $rc -eq 0 ]] || die "APT simulation failed (exit $rc). See: $sim" + + # Build an exact package list from the simulation. + # APT prints: "Remv :amd64 ..." or "Purg ..." + mapfile -t sim_removed < <( + awk '/^(Remv|Purg)[[:space:]]+/{print $2}' "$sim" \ + | sed -E 's/:[a-z0-9]+$//' \ + | sort -u + ) + + # Hard stops: ONLY if these exact packages are removed. + # NOTE: do NOT treat "systemd-*" as a hard stop. + local -a critical_exact=( + sudo + systemd + systemd-sysv + network-manager + openssh-server + linux-image-generic + linux-image-amd64 + ) + + local c r + for c in "${critical_exact[@]}"; do + for r in "${sim_removed[@]}"; do + if [[ "$r" == "$c" ]]; then + die "Simulation shows removal of critical package '${c}'. Aborting. See: $sim" + fi + done + done + + local remv_count + remv_count="$(grep -cE '^(Remv|Purg)[[:space:]]' "$sim" || true)" + info "Simulation removal count: ${remv_count}" + + if [[ "${ASSUME_YES}" != "yes" && "${remv_count}" -gt "${MAX_REMOVALS_DEFAULT}" ]]; then + die "Simulation wants to remove ${remv_count} packages (too many for 'gentle'). Re-run with --yes only if you reviewed $sim." + fi + + ok "Simulation looks acceptable. Review: $sim" +} + +# ========================= +# Commands +# ========================= +doctor() { + need_root + ensure_no_apt_locks + detect_os_minimal + + info "Doctor checks..." + apt_fix_broken + + local holds + holds="$(apt-mark showhold || true)" + if [[ -n "$holds" ]]; then + warn "Held packages detected (may affect pruning):" + echo "$holds" + else + ok "No held packages detected." + fi + + local -a pkgs=() + mapfile -t pkgs < <(build_prune_list || true) + + if show_target_summary "${pkgs[@]}"; then + ok "Doctor complete." + else + ok "Doctor complete (nothing obvious to prune)." + fi +} + +plan() { + need_root + ensure_no_apt_locks + detect_os_minimal + apt_fix_broken + + local -a pkgs=() + mapfile -t pkgs < <(build_prune_list || true) + show_target_summary "${pkgs[@]}" || return 0 + + # LightDM check (plan-time warning) + if [[ "$SKIP_DM_FIX" != "yes" ]]; then + if ! pkg_installed lightdm; then + warn "lightdm is not installed. prune will attempt to install/activate it first." + fi + if [[ -f /etc/X11/default-display-manager ]]; then + info "Current default-display-manager: $(cat /etc/X11/default-display-manager || true)" + fi + fi + + apt_simulate_purge "${pkgs[@]}" +} + +prune() { + need_root + ensure_no_apt_locks + detect_os_minimal + + if [[ "$ASSUME_YES" != "yes" ]]; then + die "prune is destructive and requires --yes" + fi + + apt_fix_broken + + local -a pkgs=() + mapfile -t pkgs < <(build_prune_list || true) + show_target_summary "${pkgs[@]}" || return 0 + + # Ensure DM is safe before removing gdm3/gnome session packages + ensure_lightdm_default_noninteractive + + # Re-run simulation immediately before changes + apt_simulate_purge "${pkgs[@]}" + + local backup_dir + backup_dir="$(backup_state)" + info "Backup dir: ${backup_dir}" + + info "Proceeding with purge..." + DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) purge --autoremove "${pkgs[@]}" + + info "Post-purge remediation..." + apt_fix_broken + DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) autoremove --purge || true + DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) clean || true + + ok "Prune completed." + info "Log: ${LOG_FILE}" + info "Backup (limited): ${backup_dir}" + info "Next:" + echo " 1) Reboot" + echo " 2) Confirm you can login to your desired session" + echo " 3) If you hit issues, restore via Timeshift/snapshot (preferred) or rollback APT/DM configs:" + echo " sudo bash $0 rollback ${backup_dir}" +} + +# ========================= +# Arg parsing (command-first OR options-first) +# ========================= +parse_args_any_order() { + while [[ $# -gt 0 ]]; do + case "$1" in + doctor|plan|prune|rollback) + [[ -z "$CMD" ]] || die "Multiple commands specified: '${CMD}' and '$1'" + CMD="$1" + shift 1 + if [[ "$CMD" == "rollback" ]]; then + if [[ $# -gt 0 && "${1:-}" != --* ]]; then + ROLLBACK_DIR="$1" + shift 1 + fi + fi + ;; + --yes) ASSUME_YES="yes"; shift 1;; + --with-recommends) WITH_RECOMMENDS="yes"; shift 1;; + --skip-dm-fix) SKIP_DM_FIX="yes"; shift 1;; + -h|--help|help) usage; exit 0;; + *) die "Unknown argument: $1" ;; + esac + done +} + +main() { + parse_args_any_order "$@" + [[ -n "$CMD" ]] || { usage; exit 1; } + + case "$CMD" in + doctor) doctor ;; + plan) plan ;; + prune) prune ;; + rollback) + [[ -n "$ROLLBACK_DIR" ]] || die "rollback requires a directory argument" + rollback "$ROLLBACK_DIR" + ;; + *) die "Unknown command: $CMD" ;; + esac +} + +main "$@"