#!/usr/bin/env bash
# ubuntu-to-mint-convert-v3.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 .
#
# ----------------------------------------------------------------------
# Purpose:
# Keep Ubuntu as the base OS and add Linux Mint repositories + install a
# Mint desktop environment, while preserving corporate tooling as much
# as possible.
#
# Supported:
# Ubuntu 24.04 (noble) -> Linux Mint 22.x (default target: zena)
# Ubuntu 22.04 (jammy) -> Linux Mint 21.x (default target: virginia)
#
# Modes:
# doctor
# plan
# convert --i-accept-the-risk
# rollback
#
# X11/Wayland note:
# This script defaults to an X11 session (safer for conversions/corp tooling).
# You may pass --prefer-wayland, but since this script standardizes on LightDM
# (Mint default), it will only select a Wayland session if it is actually
# available as a LightDM-compatible Xsession. Otherwise it will warn and use X11.
# ----------------------------------------------------------------------
set -Eeuo pipefail
IFS=$'\n\t'
umask 022
SCRIPT_VERSION="4.3"
LOG_DIR="/var/log/ubuntu-to-mint"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/ubuntu-to-mint-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
export LC_ALL=C
RED=$'\033[31m'; YEL=$'\033[33m'; GRN=$'\033[32m'; BLU=$'\033[34m'; NC=$'\033[0m'
BOLD=$'\033[1m'
die() { echo "${RED}ERROR:${NC} $*" >&2; exit 1; }
warn(){ echo "${YEL}WARN:${NC} $*" >&2; }
info(){ echo "${BLU}INFO:${NC} $*"; }
ok() { echo "${GRN}OK:${NC} $*"; }
# Optional: set to full fingerprint to hard-fail if it doesn't match.
# Example format: "0123456789ABCDEF0123456789ABCDEF01234567"
MINT_KEY_FPR_EXPECT="${MINT_KEY_FPR_EXPECT:-}"
ON_ERROR_BACKUP_HINT=""
on_err() {
local line="${1:-?}" code="${2:-?}"
echo "${RED}FAILED${NC} at line ${line} (exit ${code})." >&2
echo "Command: ${BASH_COMMAND}" >&2
[[ -n "${ON_ERROR_BACKUP_HINT}" ]] && echo "Rollback hint: ${ON_ERROR_BACKUP_HINT}" >&2
echo "Log: ${LOG_FILE}" >&2
exit "$code"
}
trap 'on_err "$LINENO" "$?"' ERR
need_root() { [[ ${EUID:-$(id -u)} -eq 0 ]] || die "Run as root (use sudo)."; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
# -------------------------
# UNSUPPORTED DISCLAIMER GATE (CONVERT ONLY)
# -------------------------
require_unsupported_disclaimer() {
local phrase="I UNDERSTAND THIS IS UNSUPPORTED"
echo
echo -e "${RED}${BOLD}======================================================================${NC}"
echo -e "${RED}${BOLD} UNSUPPORTED MIGRATION METHOD — PROBABLY A REALLY DUMB IDEA${NC}"
echo -e "${RED}${BOLD}======================================================================${NC}"
echo -e "${RED}${BOLD}This script attempts to graft Linux Mint repositories/packages onto Ubuntu.${NC}"
echo -e "${RED}${BOLD}It is NOT supported by Linux Mint, Canonical, your IT department, or your employer.${NC}"
echo -e "${RED}${BOLD}It can break APT, boot/login, device management, VPN/EDR, and leave the system unrecoverable.${NC}"
echo -e "${RED}${BOLD}${NC}"
echo -e "${RED}${BOLD}Recommended approach: do a CLEAN Linux Mint install and restore your data/apps from backup.${NC}"
echo -e "${RED}${BOLD}If this is a corporate-managed device: STOP and get approval first.${NC}"
echo -e "${RED}${BOLD}======================================================================${NC}"
echo
echo "To continue anyway, type exactly:"
echo " ${phrase}"
echo "Anything else will abort."
echo
[[ -r /dev/tty ]] || die "No interactive TTY available (/dev/tty not readable). Refusing to proceed."
local ans=""
read -r -p "> " ans < /dev/tty || die "Unable to read confirmation from TTY."
[[ "$ans" == "$phrase" ]] || die "Aborted."
ok "Disclaimer acknowledged."
}
usage() {
cat <<'EOF'
Usage:
sudo bash ubuntu-to-mint-convert-v3.sh doctor [--auto-fix]
sudo bash ubuntu-to-mint-convert-v3.sh plan [--auto-fix] [--edition cinnamon|mate|xfce] [--target ] [--mint-mirror ] [--with-recommends]
sudo bash ubuntu-to-mint-convert-v3.sh convert --i-accept-the-risk [options...]
sudo bash ubuntu-to-mint-convert-v3.sh rollback /root/ubuntu-to-mint-backup-YYYYMMDD-HHMMSS
Options:
--i-accept-the-risk (required for convert)
--edition cinnamon|mate|xfce (default: cinnamon)
--target zena|zara|xia|wilma|virginia|victoria|vera|vanessa
--mint-mirror URL (default: http://packages.linuxmint.com)
--keep-ppas (do not disable 3rd-party repos; higher conflict risk)
--allow-unhold (temporarily unhold packages during convert; risky)
--preserve-snap (default: yes)
--no-preserve-snap (disable snap-preservation behavior)
--with-recommends (default: no; safer for corp systems)
--overwrite-keyring (always overwrite Mint repo keyring file in-place)
--recreate-keyring (move existing keyring aside and recreate from scratch)
--auto-fix (doctor/plan only: allow dpkg/apt repairs and tool installs)
--prefer-wayland (attempt to prefer a Wayland session if LightDM-compatible; otherwise warn and use X11)
--yes (skip interactive confirmation inside convert; does NOT bypass disclaimer)
EOF
}
# -------------------------
# Defaults / arg parsing
# -------------------------
MODE="${1:-}"
[[ -n "$MODE" ]] || { usage; exit 1; }
shift || true
ROLLBACK_DIR=""
if [[ "$MODE" == "rollback" ]]; then
ROLLBACK_DIR="${1:-}"
[[ -n "$ROLLBACK_DIR" ]] || die "rollback requires a backup dir argument."
shift || true
fi
EDITION="cinnamon"
TARGET_MINT=""
MINT_MIRROR="http://packages.linuxmint.com"
KEEP_PPAS="no"
ALLOW_UNHOLD="no"
PRESERVE_SNAP="yes"
WITH_RECOMMENDS="no"
ASSUME_YES="no"
ACCEPT_RISK="no"
AUTO_FIX="no"
KEYRING_MODE="auto" # auto|overwrite|recreate
PREFER_WAYLAND="no"
while [[ $# -gt 0 ]]; do
case "$1" in
--i-accept-the-risk) ACCEPT_RISK="yes"; shift;;
--edition) EDITION="${2:-}"; shift 2;;
--target) TARGET_MINT="${2:-}"; shift 2;;
--mint-mirror) MINT_MIRROR="${2:-}"; shift 2;;
--keep-ppas) KEEP_PPAS="yes"; shift;;
--allow-unhold) ALLOW_UNHOLD="yes"; shift;;
--preserve-snap) PRESERVE_SNAP="yes"; shift;;
--no-preserve-snap) PRESERVE_SNAP="no"; shift;;
--with-recommends) WITH_RECOMMENDS="yes"; shift;;
--overwrite-keyring) KEYRING_MODE="overwrite"; shift;;
--recreate-keyring) KEYRING_MODE="recreate"; shift;;
--auto-fix) AUTO_FIX="yes"; shift;;
--prefer-wayland) PREFER_WAYLAND="yes"; shift;;
--yes) ASSUME_YES="yes"; shift;;
-h|--help) usage; exit 0;;
*) die "Unknown arg: $1 (use --help)";;
esac
done
validate_choice() {
case "$1" in
cinnamon|mate|xfce) ;;
*) die "Invalid --edition '$1' (use cinnamon|mate|xfce)";;
esac
}
validate_mirror() {
[[ "$MINT_MIRROR" =~ ^https?:// ]] || die "--mint-mirror must start with http:// or https://"
}
read_os_release() {
[[ -r /etc/os-release ]] || die "/etc/os-release missing"
# shellcheck disable=SC1091
. /etc/os-release
OS_ID="${ID:-}"
OS_NAME="${NAME:-}"
OS_VERSION_CODENAME="${VERSION_CODENAME:-}"
OS_VERSION_ID="${VERSION_ID:-}"
}
have_regular_file_or_die() {
local p="$1"
if [[ -e "$p" && ! -f "$p" ]]; then
die "Expected regular file at '$p' but found non-regular (dir/symlink/device). Fix it before proceeding."
fi
}
detect_ubuntu_codename() {
local c=""
if have_cmd lsb_release; then c="$(lsb_release -cs 2>/dev/null || true)"; fi
[[ -n "$c" ]] || c="${OS_VERSION_CODENAME:-}"
[[ -n "$c" ]] || die "Could not determine Ubuntu codename."
echo "$c"
}
set_targets_from_ubuntu() {
UBUNTU_CODENAME="$(detect_ubuntu_codename)"
local -a ALLOWED_TARGETS=()
case "$UBUNTU_CODENAME" in
noble)
UBUNTU_BASE="noble"
DEFAULT_MINT="zena"
ALLOWED_TARGETS=(zena zara xia wilma)
;;
jammy)
UBUNTU_BASE="jammy"
DEFAULT_MINT="virginia"
ALLOWED_TARGETS=(virginia victoria vera vanessa)
;;
*)
die "Unsupported Ubuntu codename '$UBUNTU_CODENAME'. Supports Ubuntu noble (24.04) or jammy (22.04) only."
;;
esac
[[ -n "$TARGET_MINT" ]] || TARGET_MINT="$DEFAULT_MINT"
local ok_target="no"
for t in "${ALLOWED_TARGETS[@]}"; do
[[ "$TARGET_MINT" == "$t" ]] && ok_target="yes" && break
done
if [[ "$ok_target" != "yes" ]]; then
local allowed_str
printf -v allowed_str '%s ' "${ALLOWED_TARGETS[@]}"
allowed_str="${allowed_str% }"
die "--target '$TARGET_MINT' not allowed for Ubuntu '$UBUNTU_CODENAME'. Allowed: $allowed_str"
fi
}
detect_ubuntu_mirrors() {
local sources_txt=""
if [[ -r /etc/apt/sources.list ]]; then
sources_txt+="$(grep -E '^[[:space:]]*deb ' /etc/apt/sources.list || true)"$'\n'
fi
if compgen -G "/etc/apt/sources.list.d/*.list" >/dev/null; then
sources_txt+="$(grep -RhsE '^[[:space:]]*deb ' /etc/apt/sources.list.d/*.list || true)"$'\n'
fi
if compgen -G "/etc/apt/sources.list.d/*.sources" >/dev/null; then
sources_txt+="$(grep -RhsE '^[[:space:]]*URIs:[[:space:]]*' /etc/apt/sources.list.d/*.sources | awk '{print $2}' || true)"$'\n'
fi
UBUNTU_ARCHIVE_MIRROR="http://archive.ubuntu.com/ubuntu"
UBUNTU_SECURITY_MIRROR="http://security.ubuntu.com/ubuntu"
local m
m="$(echo "$sources_txt" | grep -Eo 'https?://[^ ]+/ubuntu' | head -n1 || true)"
[[ -n "$m" ]] && UBUNTU_ARCHIVE_MIRROR="$m"
info "Ubuntu archive mirror: ${UBUNTU_ARCHIVE_MIRROR}"
info "Ubuntu security mirror: ${UBUNTU_SECURITY_MIRROR}"
}
apt_fix_broken_overwrite() {
DEBIAN_FRONTEND=noninteractive apt-get -y -f install \
-o Dpkg::Options::=--force-overwrite \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold || true
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
DEBIAN_FRONTEND=noninteractive apt-get -y -f install \
-o Dpkg::Options::=--force-overwrite \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold || true
}
ensure_tools() {
# ensure_tools ...
local allow="$1"; shift
local missing=()
local t
for t in "$@"; do
have_cmd "$t" || missing+=("$t")
done
if [[ ${#missing[@]} -eq 0 ]]; then
return 0
fi
if [[ "$allow" == "yes" ]]; then
info "Installing required tools: ${missing[*]}"
DEBIAN_FRONTEND=noninteractive apt-get update -o Acquire::Retries=3
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl gnupg dirmngr
for t in "$@"; do
have_cmd "$t" || die "Tool install failed; still missing: $t"
done
return 0
fi
die "Missing required tools (${missing[*]}). Install them first or re-run with --auto-fix."
}
preflight_common() {
# preflight_common
local allow_changes="$1"
need_root
validate_choice "$EDITION"
validate_mirror
read_os_release
info "Script v${SCRIPT_VERSION}"
info "Detected OS: ${OS_NAME} (ID=${OS_ID}, VERSION_ID=${OS_VERSION_ID}, CODENAME=${OS_VERSION_CODENAME})"
[[ "$OS_ID" == "ubuntu" ]] || die "This script is intended for Ubuntu (ID=ubuntu). Detected ID=${OS_ID}."
set_targets_from_ubuntu
ok "Ubuntu base: ${UBUNTU_BASE} | Target Mint codename: ${TARGET_MINT} | Edition: ${EDITION} | Prefer Wayland: ${PREFER_WAYLAND}"
for l in /var/lib/dpkg/lock /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock; do
if [[ -e "$l" ]] && fuser "$l" >/dev/null 2>&1; then
die "APT/DPKG lock is held ($l). Close updaters and try again."
fi
done
if dpkg --audit | grep -q .; then
if [[ "$allow_changes" == "yes" ]]; then
warn "dpkg reports issues. Attempting to fix..."
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
apt_fix_broken_overwrite
else
warn "dpkg reports issues (doctor/plan won't modify). Run: sudo dpkg --configure -a && sudo apt-get -f install"
fi
fi
if ! apt-get check >/dev/null 2>&1; then
if [[ "$allow_changes" == "yes" ]]; then
warn "apt-get check failed. Attempting best-effort repair..."
apt_fix_broken_overwrite
apt-get check >/dev/null 2>&1 || warn "apt-get check still failing; conversion may fail."
else
warn "apt-get check failed (doctor/plan won't modify). Fix before converting."
fi
fi
local root_free
root_free="$(df -Pm / | awk 'NR==2{print $4}')"
[[ "${root_free:-0}" -ge 6144 ]] || warn "Low free space on / (${root_free} MB). Recommend >= 6GB free."
if [[ "$MODE" == "plan" || "$MODE" == "convert" ]]; then
ensure_tools "$allow_changes" curl gpg
else
have_cmd curl || warn "curl not found (recommended)."
have_cmd gpg || warn "gpg not found (recommended)."
fi
if have_cmd curl; then
curl -fsS --connect-timeout 10 --max-time 30 "${MINT_MIRROR%/}/" >/dev/null || warn "Cannot reach Mint mirror ${MINT_MIRROR} (may be blocked/proxy)."
fi
ok "Preflight completed."
}
# -------------------------
# Keyring helpers
# -------------------------
keyring_contains_keyid() {
local keyring="$1"
local keyid="${2^^}"
[[ -s "$keyring" ]] || return 1
have_regular_file_or_die "$keyring"
have_cmd gpg || return 1
local gh
gh="$(mktemp -d)"
chmod 700 "$gh"
local found="no"
if gpg --homedir "$gh" --batch --no-default-keyring --keyring "$keyring" --with-colons --list-keys 2>/dev/null \
| awk -F: '$1=="pub"||$1=="sub"{print toupper($5)}' \
| grep -q "${keyid}"; then
found="yes"
fi
rm -rf "$gh"
[[ "$found" == "yes" ]]
}
get_key_fingerprint_from_keyring() {
local keyring="$1"
have_cmd gpg || return 1
[[ -s "$keyring" ]] || return 1
local gh
gh="$(mktemp -d)"
chmod 700 "$gh"
local fpr
fpr="$(gpg --homedir "$gh" --batch --no-default-keyring --keyring "$keyring" --with-colons --list-keys 2>/dev/null \
| awk -F: '$1=="fpr"{print toupper($10); exit}' || true)"
rm -rf "$gh"
[[ -n "$fpr" ]] || return 1
echo "$fpr"
}
backup_existing_keyring() {
local keyring="$1"
local bdir="${LOG_DIR}/keyring-backups"
mkdir -p "$bdir"
local ts
ts="$(date +%Y%m%d-%H%M%S)"
local dest="${bdir}/$(basename "$keyring").${ts}.bak"
cp -a "$keyring" "$dest"
ok "Backed up existing keyring to: $dest"
}
# -------------------------
# Key handling (HKPS -> HKP:80 -> HTTPS fallback) + atomic write (same dir)
# FIXED: do not dearmor to an already-existing file; validate OpenPGP data
# -------------------------
mint_repo_key_write_to() {
# mint_repo_key_write_to
local out_keyring="$1"
local allow_changes="${2:-no}"
local keyid="A6616109451BBBF2"
[[ -n "$out_keyring" ]] || die "mint_repo_key_write_to requires an output path"
ensure_tools "$allow_changes" curl gpg
local out_dir
out_dir="$(dirname "$out_keyring")"
mkdir -p "$out_dir"
# Temp dir on same filesystem for atomic mv; output file does NOT exist yet.
local tmp_dir tmp_out
tmp_dir="$(mktemp -d -p "$out_dir" ".linuxmint-repo.gpg.tmp.XXXXXX")"
tmp_out="${tmp_dir}/linuxmint-repo.gpg"
local gnupghome
gnupghome="$(mktemp -d)"
chmod 700 "$gnupghome"
local -a ks_opts=()
if [[ -n "${http_proxy:-}" ]]; then
ks_opts+=(--keyserver-options "http-proxy=${http_proxy}")
elif [[ -n "${https_proxy:-}" ]]; then
ks_opts+=(--keyserver-options "http-proxy=${https_proxy}")
fi
local got="no"
if gpg --homedir "$gnupghome" --batch "${ks_opts[@]}" --keyserver hkps://keyserver.ubuntu.com --recv-keys "$keyid" >/dev/null 2>&1; then
got="yes"
elif gpg --homedir "$gnupghome" --batch "${ks_opts[@]}" --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys "$keyid" >/dev/null 2>&1; then
got="yes"
fi
if [[ "$got" == "yes" ]]; then
gpg --homedir "$gnupghome" --batch --export "$keyid" \
| gpg --batch --yes --dearmor -o "$tmp_out" \
|| { rm -rf "$gnupghome" "$tmp_dir"; die "Failed to export+dearmor Mint repo key from keyserver."; }
else
info "Keyserver blocked; fetching key over HTTPS from Ubuntu keyserver (exact match)..."
local armored="$gnupghome/linuxmint-repo.asc"
if ! curl -fsSL --connect-timeout 10 --max-time 30 \
-H "Accept: application/pgp-keys" \
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${keyid}&exact=on" -o "$armored"; then
curl -fsSL --connect-timeout 10 --max-time 30 \
-H "Accept: application/pgp-keys" \
"http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${keyid}&exact=on" -o "$armored" \
|| { rm -rf "$gnupghome" "$tmp_dir"; die "Unable to fetch Mint repo key via keyserver or HTTPS fallback."; }
fi
grep -q "BEGIN PGP PUBLIC KEY BLOCK" "$armored" \
|| { rm -rf "$gnupghome" "$tmp_dir"; die "Downloaded content is not a PGP public key block (proxy portal/HTML?)."; }
local keyinfo
if ! keyinfo="$(gpg --batch --with-colons --show-keys "$armored" 2>/dev/null)"; then
rm -rf "$gnupghome" "$tmp_dir"
die "Downloaded key block is not valid OpenPGP data (gpg cannot parse it)."
fi
if ! awk -F: '$1=="pub"||$1=="sub"{print toupper($5)}' <<<"$keyinfo" | grep -q "${keyid^^}"; then
rm -rf "$gnupghome" "$tmp_dir"
die "Fetched key does not contain expected keyid ${keyid^^}"
fi
gpg --batch --yes --dearmor -o "$tmp_out" "$armored" \
|| { rm -rf "$gnupghome" "$tmp_dir"; die "Failed to dearmor downloaded key block."; }
fi
chmod 644 "$tmp_out"
mv -f "$tmp_out" "$out_keyring"
chmod 644 "$out_keyring"
rm -rf "$gnupghome" "$tmp_dir"
local fpr
fpr="$(get_key_fingerprint_from_keyring "$out_keyring" || true)"
[[ -n "$fpr" ]] || die "Unable to read fingerprint from written keyring: $out_keyring"
info "Mint repo key fingerprint installed: $fpr"
if [[ -n "$MINT_KEY_FPR_EXPECT" ]]; then
[[ "${fpr^^}" == "${MINT_KEY_FPR_EXPECT^^}" ]] || die "Mint key fingerprint mismatch. Expected ${MINT_KEY_FPR_EXPECT^^}, got ${fpr^^}"
fi
}
mint_repo_key_install() {
local keyring="/usr/share/keyrings/linuxmint-repo.gpg"
local keyid="A6616109451BBBF2"
have_regular_file_or_die "$keyring"
if [[ -e "$keyring" ]]; then
case "$KEYRING_MODE" in
recreate)
warn "Mint keyring already exists at ${keyring}; recreating as requested."
[[ -s "$keyring" ]] && backup_existing_keyring "$keyring" || true
rm -f "$keyring"
;;
overwrite)
warn "Mint keyring already exists at ${keyring}; overwriting in-place as requested."
;;
auto)
if [[ -s "$keyring" ]] && keyring_contains_keyid "$keyring" "$keyid"; then
ok "Mint keyring already present and contains expected key (${keyid})."
return 0
fi
warn "Mint keyring exists but is missing/invalid or does not contain expected key (${keyid}); recreating safely."
[[ -s "$keyring" ]] && backup_existing_keyring "$keyring" || true
rm -f "$keyring"
;;
*)
die "Internal error: unknown KEYRING_MODE=${KEYRING_MODE}"
;;
esac
fi
info "Installing Linux Mint repo signing key into ${keyring}"
mint_repo_key_write_to "$keyring" "yes"
[[ -s "$keyring" ]] || die "Mint keyring did not get created at ${keyring}"
if ! keyring_contains_keyid "$keyring" "$keyid"; then
backup_existing_keyring "$keyring" || true
die "Mint keyring created but does not contain expected key (${keyid})."
fi
ok "Key installed."
}
# -------------------------
# APT sources/pinning
# -------------------------
disable_ubuntu_sources_if_present() {
if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]]; then
local new="/etc/apt/sources.list.d/ubuntu.sources.disabled.ubuntu-to-mint"
info "Disabling existing ubuntu.sources to avoid duplicate entries: ${new}"
mv -f /etc/apt/sources.list.d/ubuntu.sources "$new" || true
fi
}
write_mint_sources_system() {
local list="/etc/apt/sources.list.d/official-package-repositories.list"
local keyring="/usr/share/keyrings/linuxmint-repo.gpg"
[[ -s "$keyring" ]] || die "Missing keyring ${keyring}. Run key install first."
detect_ubuntu_mirrors
disable_ubuntu_sources_if_present
info "Writing Mint+Ubuntu sources to ${list}"
cat > "$list" < "$pref" <<'EOF'
Package: *
Pin: release o=LinuxMint
Pin-Priority: 100
Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome*
Pin: release o=LinuxMint
Pin-Priority: 700
Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 700
Package: cinnamon* nemo* muffin* cjs* slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons*
Pin: release o=LinuxMint
Pin-Priority: 900
Package: cinnamon* nemo* muffin* cjs* slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 900
Package: python3-xapp* python-xapp* libxapp* gir1.2-xapp* xapps* xapps-common xapp-symbolic-icons xapp-status-icon
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: python3-xapp* python-xapp* libxapp* gir1.2-xapp* xapps* xapps-common xapp-symbolic-icons xapp-status-icon
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
Package: libnemo-extension1* nemo-data*
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: libnemo-extension1* nemo-data*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
Package: libcinnamon-control-center1* libcinnamon-menu-3-0* libcinnamon-desktop4* cinnamon-desktop-data* cinnamon-control-center-data* cinnamon-l10n*
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: libcinnamon-control-center1* libcinnamon-menu-3-0* libcinnamon-desktop4* cinnamon-desktop-data* cinnamon-control-center-data* cinnamon-l10n*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
EOF
ok "Pinning written."
}
# -------------------------
# 3rd-party repo handling (preserve Falcon/GlobalProtect/etc)
# -------------------------
disable_thirdparty_sources_system() {
local backup_dir="$1"
local disabled_dir="${backup_dir}/disabled-sources"
mkdir -p "$disabled_dir"
info "Disabling 3rd-party sources into: ${disabled_dir}"
shopt -s nullglob
local allow_re='(crowdstrike|falcon|globalprotect|paloalto|pan(gp)?|cortex|prisma)'
for f in /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[[ "$(basename "$f")" == "official-package-repositories.list" ]] && continue
if echo "$(basename "$f")" | grep -Eiq "$allow_re" || grep -Eiq "$allow_re" "$f"; then
info "Preserving vendor repo: $f"
continue
fi
mv -v "$f" "${disabled_dir}/" || true
done
shopt -u nullglob
ls -1 "${disabled_dir}" 2>/dev/null | sed 's/^/ disabled: /' | tee -a "${backup_dir}/disabled-sources.txt" >/dev/null || true
ok "Third-party sources disabled (restorable via rollback)."
}
# -------------------------
# dpkg-divert for known conflicts
# -------------------------
apply_known_diversions() {
local f="/usr/share/icons/hicolor/16x16/apps/software-properties.png"
if dpkg-query -W -f='${Status}' software-properties-gtk 2>/dev/null | grep -q "installed"; then
if dpkg -S "$f" 2>/dev/null | grep -q "^software-properties-gtk:"; then
info "Applying dpkg-divert for known conflict: $f"
dpkg-divert --package ubuntu-to-mint-convert --rename --add "$f" || true
fi
fi
}
# -------------------------
# Backup / snapshot
# -------------------------
backup_system_state() {
local backup_dir="/root/ubuntu-to-mint-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
ON_ERROR_BACKUP_HINT="sudo bash $0 rollback ${backup_dir}"
info "Creating backup at: ${backup_dir}"
mkdir -p "${backup_dir}/etc"
cp -a /etc/apt "${backup_dir}/etc/" || true
cp -a /etc/os-release /etc/lsb-release 2>/dev/null "${backup_dir}/etc/" || true
mkdir -p "${backup_dir}/etc/X11" "${backup_dir}/etc/lightdm"
cp -a /etc/X11/default-display-manager 2>/dev/null "${backup_dir}/etc/X11/" || true
cp -a /etc/lightdm 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" || true
ok "Backup complete."
echo "$backup_dir"
}
timeshift_snapshot_best_effort() {
if have_cmd timeshift; then
info "Timeshift detected. Attempting pre-change snapshot (best-effort)..."
timeshift --create --comments "pre ubuntu->mint $(date -Is)" --tags D || warn "Timeshift snapshot failed (may not be configured)."
else
warn "Timeshift not installed. Strongly recommended to snapshot/backup before converting."
fi
}
# -------------------------
# apt-get with tee but correct exit status
# -------------------------
run_apt_tee() {
local outfile="$1"; shift
local old_trap
old_trap="$(trap -p ERR || true)"
trap - ERR
set +e
DEBIAN_FRONTEND=noninteractive apt-get "$@" 2>&1 | tee "$outfile"
local rc="${PIPESTATUS[0]}"
set -e
if [[ -n "$old_trap" ]]; then eval "$old_trap"; else trap 'on_err "$LINENO" "$?"' ERR; fi
return "$rc"
}
parse_and_guard_apt_actions() {
local sim_output_file="$1"
[[ -r "$sim_output_file" ]] || die "Missing simulation output: $sim_output_file"
local removed removed_count
removed="$(grep -E '^Remv ' "$sim_output_file" | awk '{print $2}' || true)"
removed_count="$(echo "$removed" | grep -c . || true)"
info "APT simulation: packages marked for removal: ${removed_count}"
local critical_re='^(sudo|openssh-server|ssh|network-manager|systemd|systemd-sysv|dbus|polkit|linux-image|linux-generic|linux-modules|grub|grub2|initramfs-tools|libc6|libstdc\+\+6|snapd|netplan\.io|systemd-resolved)$'
local bad=""
while IFS= read -r p; do
[[ -z "$p" ]] && continue
[[ "$p" =~ $critical_re ]] && bad+="$p"$'\n'
done <<< "$removed"
if [[ -n "$bad" ]]; then
echo "$bad" | sed 's/^/ - /'
die "Refusing to proceed: simulation removes critical packages above."
fi
if [[ "$removed_count" -gt 25 ]]; then
die "Refusing to proceed: too many removals (${removed_count}). Inspect conflicts."
fi
ok "Guard rails passed."
}
# -------------------------
# Desktop / Display Manager defaults (prefer X11 by default)
# -------------------------
find_session_name_for_edition() {
local desired="$1"
local prefer_wayland="$2" # yes|no
local xs="/usr/share/xsessions"
local ws="/usr/share/wayland-sessions"
[[ -d "$xs" ]] || die "Missing $xs (no X sessions installed?)."
local sess=""
if [[ "$prefer_wayland" == "yes" ]]; then
local -a way_candidates=()
case "$desired" in
cinnamon) way_candidates=(cinnamon-wayland) ;;
mate) way_candidates=() ;;
xfce) way_candidates=() ;;
esac
local c
for c in "${way_candidates[@]}"; do
if [[ -f "$xs/${c}.desktop" ]]; then
sess="$c"
echo "$sess"
return 0
fi
if [[ -d "$ws" && -f "$ws/${c}.desktop" ]]; then
warn "Wayland session '${c}' exists in ${ws}, but LightDM may not support it. Falling back to X11."
fi
done
fi
local -a candidates=()
case "$desired" in
cinnamon) candidates=(cinnamon cinnamon2d) ;;
mate) candidates=(mate) ;;
xfce) candidates=(xfce xfce4) ;;
*) candidates=("$desired") ;;
esac
local c
for c in "${candidates[@]}"; do
[[ -f "$xs/${c}.desktop" ]] && sess="$c" && break
done
[[ -n "$sess" ]] || sess="$desired"
echo "$sess"
}
set_mint_defaults_display_manager_and_session() {
local dm_pkg="lightdm"
local greeter="slick-greeter"
local session
session="$(find_session_name_for_edition "$EDITION" "$PREFER_WAYLAND")"
if [[ "$PREFER_WAYLAND" == "yes" && "$session" != *wayland* ]]; then
warn "--prefer-wayland was set, but no LightDM-compatible Wayland session was found; using X11 session '${session}'."
fi
info "Staging defaults (apply fully after reboot): DM=${dm_pkg}, greeter=${greeter}, session=${session}"
DEBIAN_FRONTEND=noninteractive apt-get -y install "${dm_pkg}" "${greeter}"
local dm_path
dm_path="$(command -v lightdm 2>/dev/null || true)"
[[ -n "$dm_path" ]] || dm_path="/usr/sbin/lightdm"
[[ -x "$dm_path" ]] || die "LightDM binary not found/executable at: $dm_path"
mkdir -p /etc/X11
echo "${dm_path}" > /etc/X11/default-display-manager
if have_cmd debconf-set-selections; then
for owner in gdm3 lightdm sddm; do
dpkg-query -W -f='${Status}' "$owner" 2>/dev/null | grep -q "installed" || continue
echo "${owner} shared/default-x-display-manager select lightdm" | debconf-set-selections || true
done
echo "lightdm shared/default-x-display-manager select lightdm" | debconf-set-selections || true
fi
mkdir -p /etc/lightdm/lightdm.conf.d
cat > /etc/lightdm/lightdm.conf.d/60-mint-defaults.conf </dev/null || true
systemctl disable sddm 2>/dev/null || true
systemctl unmask lightdm.service 2>/dev/null || true
systemctl enable lightdm.service 2>/dev/null || systemctl enable lightdm 2>/dev/null || true
ok "Defaults staged: lightdm + ${session}"
}
# -------------------------
# Post-conversion validation
# -------------------------
is_enabled_any() { systemctl is-enabled "$1" >/dev/null 2>&1; }
is_active_any() { systemctl is-active "$1" >/dev/null 2>&1; }
detect_first_matching_unit() {
local re="$1"
systemctl list-unit-files --no-legend 2>/dev/null | awk '{print $1}' | grep -Ei "$re" | head -n1 || true
}
post_convert_validate_or_die() {
local backup_dir="$1"
local report="${backup_dir}/post-convert-validation.txt"
: > "$report"
v() { echo "$*" | tee -a "$report" >/dev/null; }
local failed=0
local session
session="$(find_session_name_for_edition "$EDITION" "$PREFER_WAYLAND")"
info "Running post-conversion validation (report: ${report})"
v "=== Post-Conversion Validation ==="
v "Timestamp: $(date -Is)"
v "Edition: ${EDITION}"
v "Session: ${session}"
v "PreferWL: ${PREFER_WAYLAND}"
v "---------------------------------"
if [[ -f "/usr/share/xsessions/${session}.desktop" ]]; then
v "OK: session desktop file exists: /usr/share/xsessions/${session}.desktop"
else
v "FAIL: missing session desktop file: /usr/share/xsessions/${session}.desktop"
failed=1
fi
if [[ "$PREFER_WAYLAND" != "yes" && "$session" == *wayland* ]]; then
v "WARN: selected session appears to be Wayland despite X11 preference: ${session}"
fi
if [[ -r /etc/X11/default-display-manager ]]; then
local dm_path
dm_path="$(cat /etc/X11/default-display-manager 2>/dev/null || true)"
if [[ "$dm_path" =~ lightdm ]] && [[ -x "$dm_path" ]]; then
v "OK: default display manager set to: ${dm_path}"
else
v "FAIL: /etc/X11/default-display-manager is '${dm_path}' (expected executable lightdm path)"
failed=1
fi
else
v "FAIL: missing /etc/X11/default-display-manager"
failed=1
fi
if is_enabled_any lightdm.service || is_enabled_any lightdm; then
v "OK: lightdm is enabled for next boot"
else
v "FAIL: lightdm is NOT enabled"
failed=1
fi
if is_active_any NetworkManager.service || is_active_any network-manager.service; then
v "OK: NetworkManager is active"
else
v "FAIL: NetworkManager is NOT active"
failed=1
fi
if apt-get check >/dev/null 2>&1; then
v "OK: apt-get check passed"
else
v "FAIL: apt-get check failed"
failed=1
fi
local snapd_installed="no"
if dpkg-query -W -f='${Status}' snapd 2>/dev/null | grep -q "installed"; then
snapd_installed="yes"
fi
if [[ "$PRESERVE_SNAP" == "yes" && "$snapd_installed" == "yes" ]]; then
if is_enabled_any snapd.service || is_enabled_any snapd.socket; then
v "OK: snapd is enabled (service or socket)"
else
v "FAIL: snapd is installed but not enabled (service/socket)"
failed=1
fi
else
v "INFO: snapd check skipped (preserve-snap=${PRESERVE_SNAP}, snapd_installed=${snapd_installed})"
fi
local falcon_detect="no"
[[ -d /opt/CrowdStrike ]] && falcon_detect="yes"
[[ -x /opt/CrowdStrike/falconctl ]] && falcon_detect="yes"
local falcon_unit
falcon_unit="$(detect_first_matching_unit 'falcon(-sensor)?\.service|crowdstrike|falcon')"
if [[ "$falcon_detect" == "yes" || -n "$falcon_unit" ]]; then
v "INFO: CrowdStrike detected (unit=${falcon_unit:-none})"
if [[ -n "$falcon_unit" ]]; then
if is_enabled_any "$falcon_unit"; then v "OK: ${falcon_unit} is enabled"; else v "FAIL: ${falcon_unit} is NOT enabled"; failed=1; fi
if is_active_any "$falcon_unit"; then v "OK: ${falcon_unit} is active"; else v "FAIL: ${falcon_unit} is NOT active"; failed=1; fi
else
if pgrep -fa 'falcon' >/dev/null 2>&1; then
v "OK: CrowdStrike process detected (no unit found, but processes are running)"
else
v "FAIL: CrowdStrike files detected but no unit found and no falcon process running"
failed=1
fi
fi
else
v "INFO: CrowdStrike not detected (skipping)"
fi
local gp_detect="no"
[[ -d /opt/paloaltonetworks/globalprotect ]] && gp_detect="yes"
[[ -x /opt/paloaltonetworks/globalprotect/PanGPS ]] && gp_detect="yes"
local gp_unit
gp_unit="$(detect_first_matching_unit 'gpd\.service|pangps\.service|globalprotect|pan(gp|gps)')"
if [[ "$gp_detect" == "yes" || -n "$gp_unit" ]]; then
v "INFO: GlobalProtect detected (unit=${gp_unit:-none})"
if [[ -x /opt/paloaltonetworks/globalprotect/PanGPS ]]; then
v "OK: PanGPS binary present"
else
v "FAIL: PanGPS binary missing at /opt/paloaltonetworks/globalprotect/PanGPS"
failed=1
fi
if [[ -n "$gp_unit" ]]; then
if is_enabled_any "$gp_unit"; then
v "OK: ${gp_unit} is enabled"
else
v "FAIL: ${gp_unit} is NOT enabled"
failed=1
fi
if is_active_any "$gp_unit"; then
v "OK: ${gp_unit} is active"
else
v "FAIL: ${gp_unit} is NOT active (VPN may fail to connect after reboot)"
failed=1
fi
else
if pgrep -x PanGPS >/dev/null 2>&1 || pgrep -x PanGPA >/dev/null 2>&1; then
v "OK: GlobalProtect processes detected (no unit found)"
else
v "WARN: No GlobalProtect unit found and no PanGPS/PanGPA process running (may be normal if not connected)"
fi
fi
else
v "INFO: GlobalProtect not detected (skipping)"
fi
if [[ -r /etc/lightdm/lightdm.conf.d/60-mint-defaults.conf ]]; then
if grep -q "user-session=${session}" /etc/lightdm/lightdm.conf.d/60-mint-defaults.conf; then
v "OK: LightDM default session configured to ${session}"
else
v "FAIL: LightDM defaults file does not set user-session=${session}"
failed=1
fi
if grep -q "greeter-session=slick-greeter" /etc/lightdm/lightdm.conf.d/60-mint-defaults.conf; then
v "OK: LightDM greeter configured to slick-greeter"
else
v "FAIL: LightDM defaults file does not set greeter-session=slick-greeter"
failed=1
fi
else
v "FAIL: missing /etc/lightdm/lightdm.conf.d/60-mint-defaults.conf"
failed=1
fi
if [[ "$failed" -ne 0 ]]; then
echo
echo "${RED}${BOLD}DO NOT REBOOT YET.${NC}"
echo "${RED}${BOLD}Post-conversion validation FAILED.${NC}"
echo "Report: ${report}"
echo "Log: ${LOG_FILE}"
echo
echo "Suggested next steps:"
echo " 1) Open the report and fix the failing items."
echo " 2) If you need to revert APT sources immediately:"
echo " ${ON_ERROR_BACKUP_HINT}"
echo
exit 12
fi
ok "Post-conversion validation passed."
}
# -------------------------
# Plan mode (temp APT env)
# -------------------------
apt_simulate_with_temp_sources() {
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
mkdir -p "$tmp/etc"
cp -a /etc/apt "$tmp/etc/"
mkdir -p "$tmp/usr/share/keyrings"
local temp_keyring="$tmp/usr/share/keyrings/linuxmint-repo.gpg"
info "Plan mode: creating temporary Mint keyring at $temp_keyring"
mint_repo_key_write_to "$temp_keyring" "no"
detect_ubuntu_mirrors
cat > "$tmp/etc/apt/sources.list" < "$tmp/etc/apt/preferences.d/50-linuxmint-conversion.pref" <<'EOF'
Package: *
Pin: release o=LinuxMint
Pin-Priority: 100
Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome*
Pin: release o=LinuxMint
Pin-Priority: 700
Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 700
Package: cinnamon* nemo* muffin* cjs* slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons*
Pin: release o=LinuxMint
Pin-Priority: 900
Package: cinnamon* nemo* muffin* cjs* slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 900
Package: python3-xapp* python-xapp* libxapp* gir1.2-xapp* xapps* xapps-common xapp-symbolic-icons xapp-status-icon
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: python3-xapp* python-xapp* libxapp* gir1.2-xapp* xapps* xapps-common xapp-symbolic-icons xapp-status-icon
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
Package: libnemo-extension1* nemo-data*
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: libnemo-extension1* nemo-data*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
Package: libcinnamon-control-center1* libcinnamon-menu-3-0* libcinnamon-desktop4* cinnamon-desktop-data* cinnamon-control-center-data* cinnamon-l10n*
Pin: release o=LinuxMint
Pin-Priority: 1001
Package: libcinnamon-control-center1* libcinnamon-menu-3-0* libcinnamon-desktop4* cinnamon-desktop-data* cinnamon-control-center-data* cinnamon-l10n*
Pin: origin "packages.linuxmint.com"
Pin-Priority: 1001
EOF
mkdir -p "$tmp/var/lib/apt/lists/partial" "$tmp/var/cache/apt/archives/partial"
local recommends="--no-install-recommends"
[[ "$WITH_RECOMMENDS" == "yes" ]] && recommends=""
local -a pkgs=()
case "$EDITION" in
cinnamon) pkgs+=(mint-meta-cinnamon) ;;
mate) pkgs+=(mint-meta-mate) ;;
xfce) pkgs+=(mint-meta-xfce) ;;
esac
pkgs+=(mint-meta-core mint-meta-codecs mintsystem mintupdate mintsources)
info "Plan mode: apt update (temporary dirs)..."
DEBIAN_FRONTEND=noninteractive apt-get \
-o Dir::Etc="$tmp/etc/apt" \
-o Dir::Etc::sourceparts="-" \
-o Dir::State="$tmp/var/lib/apt" \
-o Dir::Cache="$tmp/var/cache/apt" \
-o Dir::State::status="/var/lib/dpkg/status" \
-o Acquire::Retries=3 \
update
local plan_log="$LOG_DIR/plan-$(date +%Y%m%d-%H%M%S).txt"
info "Plan mode: simulated install: ${pkgs[*]}"
if ! run_apt_tee "$plan_log" \
-o Dir::Etc="$tmp/etc/apt" \
-o Dir::Etc::sourceparts="-" \
-o Dir::State="$tmp/var/lib/apt" \
-o Dir::Cache="$tmp/var/cache/apt" \
-o Dir::State::status="/var/lib/dpkg/status" \
-o Acquire::Retries=3 \
-s install $recommends "${pkgs[@]}"; then
tail -n 160 "$plan_log" >&2 || true
die "Plan simulation failed. See: $plan_log"
fi
ok "Plan completed. Review: $plan_log"
}
# -------------------------
# Convert mode
# -------------------------
convert_apply() {
[[ "$ACCEPT_RISK" == "yes" ]] || die "You must pass --i-accept-the-risk to run convert."
require_unsupported_disclaimer
preflight_common "yes"
if [[ "$ASSUME_YES" != "yes" ]]; then
echo
warn "This can break a corporate-managed machine. Ensure you have approval + a rollback plan."
echo "Target: Ubuntu ${UBUNTU_BASE} -> Mint ${TARGET_MINT} (${EDITION})"
read -r -p "Type 'I UNDERSTAND' to continue: " ans
[[ "$ans" == "I UNDERSTAND" ]] || die "Aborted by user."
fi
local backup_dir
backup_dir="$(backup_system_state)"
timeshift_snapshot_best_effort
local held
held="$(apt-mark showhold || true)"
if [[ -n "$held" ]]; then
warn "Held packages detected:"
echo "$held" | sed 's/^/ HOLD: /'
if [[ "$ALLOW_UNHOLD" == "yes" ]]; then
info "Saving holds list to ${backup_dir}/held-packages.txt and temporarily unholding..."
echo "$held" > "${backup_dir}/held-packages.txt"
# shellcheck disable=SC2086
apt-mark unhold $held
else
die "Held packages will block dependency resolution. Resolve holds or re-run with --allow-unhold."
fi
fi
DEBIAN_FRONTEND=noninteractive apt-get -y install debconf-utils || true
if have_cmd debconf-set-selections; then
echo "lightdm shared/default-x-display-manager select lightdm" | debconf-set-selections || true
fi
systemctl stop apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
systemctl kill --kill-who=all apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
if [[ "$KEEP_PPAS" != "yes" ]]; then
disable_thirdparty_sources_system "$backup_dir"
else
warn "--keep-ppas set. Proceeding with third-party sources enabled may increase conflict risk."
fi
mint_repo_key_install
write_mint_sources_system
write_mint_pinning_system
info "APT update..."
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 update
if ! apt-cache show mint-meta-core >/dev/null 2>&1; then
die "Mint repo not usable: apt cannot see 'mint-meta-core'. Check mirror/key/network."
fi
local recommends="--no-install-recommends"
[[ "$WITH_RECOMMENDS" == "yes" ]] && recommends=""
local -a pkgs=()
case "$EDITION" in
cinnamon) pkgs+=(mint-meta-cinnamon) ;;
mate) pkgs+=(mint-meta-mate) ;;
xfce) pkgs+=(mint-meta-xfce) ;;
esac
pkgs+=(mint-meta-core mintsystem mintupdate mintsources mint-meta-codecs)
info "Simulation (safety check) of install: ${pkgs[*]}"
local sim_out="${backup_dir}/apt-sim-install.txt"
if ! run_apt_tee "$sim_out" -s install $recommends "${pkgs[@]}"; then
tail -n 200 "$sim_out" >&2 || true
die "APT simulation failed. See: $sim_out"
fi
parse_and_guard_apt_actions "$sim_out"
apply_known_diversions
info "Installing Mint packages..."
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
if ! apt-get -y install \
-o Dpkg::Options::=--force-overwrite \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
$recommends \
"${pkgs[@]}"; then
warn "Initial install hit errors; attempting fix-broken with overwrite smoothing..."
apt_fix_broken_overwrite
apt-get -y install \
-o Dpkg::Options::=--force-overwrite \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
$recommends \
"${pkgs[@]}"
fi
if [[ "$PRESERVE_SNAP" == "yes" ]]; then
local nosnap="/etc/apt/preferences.d/nosnap.pref"
if [[ -f "$nosnap" ]]; then
warn "Mint 'nosnap' preference detected at ${nosnap}. Removing to preserve snap functionality."
rm -f "$nosnap"
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 update
fi
fi
set_mint_defaults_display_manager_and_session
if [[ -f "${backup_dir}/held-packages.txt" ]]; then
info "Re-applying package holds from ${backup_dir}/held-packages.txt"
# shellcheck disable=SC2046
apt-mark hold $(cat "${backup_dir}/held-packages.txt") || true
fi
post_convert_validate_or_die "$backup_dir"
ok "Conversion completed successfully."
info "Backup dir: ${backup_dir}"
info "Log file: ${LOG_FILE}"
echo
echo "Next steps:"
echo " 1) Reboot (required for display manager/session defaults to fully apply)."
echo " 2) Validate corp software end-to-end: VPN, EDR, SSO, printers, etc."
}
# -------------------------
# Rollback mode: restore /etc/apt from backup (does NOT remove installed packages)
# -------------------------
rollback_apply() {
need_root
local backup_dir="$1"
[[ -n "$backup_dir" ]] || die "rollback requires a backup dir argument."
[[ -d "$backup_dir" ]] || die "No such backup dir: $backup_dir"
[[ -d "${backup_dir}/etc/apt" ]] || die "Backup dir missing etc/apt: $backup_dir"
if [[ "$backup_dir" != /root/ubuntu-to-mint-backup-* ]]; then
warn "Backup dir does not match expected pattern /root/ubuntu-to-mint-backup-*. Proceeding anyway."
fi
info "Restoring /etc/apt from backup: ${backup_dir}/etc/apt"
if [[ -d /etc/apt ]]; then
mv /etc/apt "/etc/apt.pre-rollback.$(date +%Y%m%d-%H%M%S)" || true
fi
cp -a "${backup_dir}/etc/apt" /etc/apt
if [[ -d "${backup_dir}/disabled-sources" ]]; then
info "Restoring disabled sources from ${backup_dir}/disabled-sources"
mkdir -p /etc/apt/sources.list.d
cp -a "${backup_dir}/disabled-sources/." /etc/apt/sources.list.d/ || true
fi
info "APT update after rollback..."
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=3 update || true
ok "Rollback of APT configuration completed."
info "Note: This does NOT automatically remove Mint-installed packages. Use Timeshift/snapshot to fully revert system state."
}
main() {
case "$MODE" in
doctor)
preflight_common "$AUTO_FIX"
ok "Doctor completed."
;;
plan)
preflight_common "$AUTO_FIX"
apt_simulate_with_temp_sources
;;
convert)
convert_apply
;;
rollback)
rollback_apply "$ROLLBACK_DIR"
;;
*)
usage
exit 1
;;
esac
}
main "$@"