Files
profile/profile.d/updates.sh
T

493 lines
20 KiB
Bash

#!/usr/bin/env bash
# ------------------------------------------------------------------------------
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
# Protected by the BSD3 license. Please read bellow for details.
#
# * Redistribution and use in source and binary forms,
# * with or without modification, are permitted provided
# * that the following conditions are met:
# *
# * Redistributions of source code must retain the above
# * copyright notice, this list of conditions and the
# * following disclaimer.
# *
# * Redistributions in binary form must reproduce the above
# * copyright notice, this list of conditions and the following
# * disclaimer in the documentation and/or other materials
# * provided with the distribution.
# *
# * Neither the name of the copyright holder nor the names
# * of any other contributors may be used to endorse or
# * promote products derived from this software without
# * specific prior written permission.
# *
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
# * OF SUCH DAMAGE.
# ------------------------------------------------------------------------------
export BASE_URL="https://git.geoffray-levasseur.org/fatalerrors/profile"
# Load [updates] configuration before building the URLs so that
# UPDT_DEFAULT_BRANCH (if set in profile.conf) is already available.
load_conf "updates"
# Common helpers, then the ones private to this module (helpers/updates.sh)
# shellcheck source=../helpers/common.sh
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
_profile_load_helpers "${BASH_SOURCE[0]}"
# ------------------------------------------------------------------------------
if ! _updt_valid_branch "${UPDT_DEFAULT_BRANCH:-master}"; then
disp W "Invalid UPDT_DEFAULT_BRANCH '${UPDT_DEFAULT_BRANCH}', using master."
UPDT_DEFAULT_BRANCH=master
fi
export UPDT_DEFAULT_BRANCH="${UPDT_DEFAULT_BRANCH:-master}"
export UPDT_URL="$BASE_URL/raw/branch/$UPDT_DEFAULT_BRANCH"
export ARCH_URL="$BASE_URL/archive/$UPDT_DEFAULT_BRANCH.tar.gz"
# ------------------------------------------------------------------------------
# Check whether a newer profile version is available
# Usage: check_updates [-q]
# If -q is specified, the function will operate in quiet mode (internal use
# only): only the "update available" notice is displayed, errors are silent.
# Return status: 0 up to date, 1 update available, 2 invalid option,
# 4 temporary file error, 5 version unavailable or invalid.
check_updates()
{
local quiet=0 result=5 PARSED
local vfile="" lastver=""
PARSED=$(getopt -o hq --long help,quiet -n 'check_updates' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"check_updates --help\" to display usage."
return 2
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "check_updates: Check whether a newer profile version is available.\n\n"
printf "Usage: check_updates [-q|--quiet]\n"
printf "This command only checks availability; it does not modify the installation.\n"
return 0
;;
-q|--quiet)
quiet=1
shift
;;
--)
shift
break
;;
*)
break
;;
esac
done
(( quiet != 1 )) && disp I "Checking for updates..."
vfile=$(mktemp "${TMPDIR:-/tmp}/profile_version.XXXXXX") || {
(( quiet != 1 )) && disp E "Failed to create a temporary file."
return 4
}
# In quiet mode (startup), use a short timeout so a missing or slow network
# never blocks the interactive prompt.
local dwl_opts=()
(( quiet == 1 )) && dwl_opts+=(-t 3)
dwl "${dwl_opts[@]}" "$UPDT_URL/version" "$vfile" >/dev/null 2>&1 || {
rm -f "$vfile"
(( quiet != 1 )) && disp E "Cannot download version file; unable to continue."
return 5
}
# First line only, and only a plausible version: the text comes from the
# network and is displayed by disp, which interprets backslash escapes (a
# crafted file, or a captive portal page, could otherwise inject terminal
# control sequences at every shell startup).
IFS= read -r lastver < "$vfile" || true
rm -f "$vfile"
lastver="${lastver%$'\r'}"
lastver="${lastver#"${lastver%%[![:space:]]*}"}"
lastver="${lastver%"${lastver##*[![:space:]]}"}"
if [[ ! "$lastver" =~ ^[0-9]+(\.[0-9]+)*([-_.+][0-9A-Za-z._+-]+)?$ ]]; then
(( quiet != 1 )) && disp E "The downloaded version file is empty or invalid; unable to continue."
return 5
fi
if [[ "$lastver" == "${PROFVERSION:-}" ]]; then
(( quiet != 1 )) && disp I "Your version is up-to-date."
result=0
elif _updt_is_newer "${PROFVERSION:-}" "$lastver"; then
disp I "Installed: ${PROFVERSION:-unknown}. Available: $lastver."
(( quiet != 1 )) && disp I "You should upgrade when possible."
result=1
else
(( quiet != 1 )) && disp I "Installed version (${PROFVERSION:-}) is more recent than the published one ($lastver)."
result=0
fi
return $result
}
export -f check_updates
# ------------------------------------------------------------------------------
# Apply the available profile upgrade
# Usage: profile_upgrade [options]
# Return status: 0 done (or nothing to do), 1 refused/bad context, 2 git or
# option error, 3 git/install error, 4 local archive or force error, 5
# temporary directory, 6 download, 7 extraction, 8 archive content, 9 copy.
profile_upgrade()
{
local PARSED
local check_rc=0 dry_run=0 force_git=0 switch_to_git=0
local archive_file="" tmpbase="" use_archive=0 branch=""
local tmpdir="" archive="" extracted_root=""
PARSED=$(getopt -o hf:t:nFb:g --long help,file:,tmpdir:,dry-run,force,branch:,switch-to-git -n 'profile_upgrade' -- "$@")
# shellcheck disable=SC2181 # getopt return code is checked immediately after
if [[ $? -ne 0 ]]; then
disp E "Invalid options, use \"profile_upgrade --help\" to display usage."
return 2
fi
# Restore the getopt-normalised (shell-quoted) argument list
local -a _pargs=()
_getopt_split _pargs "$PARSED"
set -- "${_pargs[@]}"
while true; do
case "$1" in
-h|--help)
printf "profile_upgrade: Apply the available profile upgrade.\n\n"
printf "Usage: profile_upgrade [options]\n\n"
printf "Options:\n"
printf "\t-h, --help\t\tDisplay this help screen\n"
printf "\t-f, --file ARCHIVE\tUse a local archive file for the upgrade\n"
printf "\t-t, --tmpdir DIR\tCreate the temporary working directory under DIR\n"
printf "\t-b, --branch NAME\tUse NAME as the target Git branch\n"
printf "\t-g, --switch-to-git\tReplace current install with a fresh Git clone\n"
printf "\t-n, --dry-run\t\tDisplay what would be done without changing anything\n"
printf "\t-F, --force\t\tDiscard local changes before upgrading\n\n"
printf "If the profile is installed from Git, the upgrade uses 'git pull --ff-only'.\n"
printf "Otherwise, it downloads or applies an archive and refreshes the files.\n"
return 0
;;
-f|--file)
archive_file="$2"
use_archive=1
shift 2
;;
-t|--tmpdir)
tmpbase="$2"
shift 2
;;
-b|--branch)
branch="$2"
shift 2
;;
-g|--switch-to-git)
switch_to_git=1
shift
;;
-n|--dry-run)
dry_run=1
shift
;;
-F|--force)
force_git=1
shift
;;
--)
shift
break
;;
*)
disp E "Invalid options, use \"profile_upgrade --help\" to display usage."
return 1
;;
esac
done
# Target branch: explicit -b flag > config default > master
local target_branch="${branch:-${UPDT_DEFAULT_BRANCH:-master}}"
if ! _updt_valid_branch "$target_branch"; then
disp E "Invalid branch name '$target_branch'."
return 1
fi
if (( ! use_archive && ! switch_to_git )); then
check_updates -q
check_rc=$?
if (( check_rc == 0 )); then
disp I "No update available."
return 0
elif (( check_rc > 1 )); then
disp E "Unable to check whether an update is available."
return "$check_rc"
fi
fi
if [[ ! -s $MYPATH/profile.sh ]]; then
disp E "Install path detection failed; cannot upgrade automatically."
return 1
fi
if [[ -d $MYPATH/.git ]] && (( use_archive )) && (( ! force_git )); then
disp E "Refusing archive upgrade on a Git install without --force."
return 1
fi
if (( switch_to_git )); then
command -v git >/dev/null 2>&1 || {
disp E "Git is required to switch this install to a Git clone."
return 3
}
local backup_dir="${MYPATH}.pre-git.$$.bak"
if (( dry_run )); then
disp I "[dry-run] mv \"$MYPATH\" \"$backup_dir\""
disp I "[dry-run] git clone $BASE_URL \"$MYPATH\""
[[ -n "$branch" ]] && disp I "[dry-run] git -C \"$MYPATH\" checkout $branch"
disp I "[dry-run] copy profile.conf and custom themes from \"$backup_dir\""
return 0
fi
if [[ -d $MYPATH/.git ]]; then
disp W "Git repository already present; no switch is needed."
else
mv "$MYPATH" "$backup_dir" || {
disp E "Failed to move current install out of the way."
return 3
}
git clone "$BASE_URL" "$MYPATH" || {
disp E "Git clone failed; previous install kept in $backup_dir."
mv "$backup_dir" "$MYPATH" 2>/dev/null || true
return 3
}
if [[ -n "$branch" ]] && ! ( cd "$MYPATH" && git checkout "$branch" ); then
disp W "Could not switch to branch '$branch'; staying on the default branch."
fi
# The personal files are not in the repository (profile.conf is
# git-ignored): without this the new install would start without
# the user's configuration, which would stay in the backup.
# Existing files are never overwritten.
[[ -f "$backup_dir/profile.conf" ]] && \
cp -an "$backup_dir/profile.conf" "$MYPATH/profile.conf"
if [[ -d "$backup_dir/profile.d/themes" && -d "$MYPATH/profile.d/themes" ]]; then
cp -an "$backup_dir/profile.d/themes/." "$MYPATH/profile.d/themes/"
fi
disp I "Switched installation to Git source."
disp I "Previous install kept in $backup_dir (profile.conf and custom themes were copied)."
return 0
fi
fi
if [[ -d $MYPATH/.git ]] && (( ! use_archive )); then
disp I "Git installation detected, applying git pull."
command -v git >/dev/null 2>&1 || {
disp E "Git is required for this upgrade but is not available."
return 3
}
# git -C instead of cd: no directory change to undo on each exit path
local -a G=(git -C "$MYPATH")
"${G[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
disp E "Install directory is not a valid Git working tree."
return 3
}
branch="$target_branch"
# Detect current local branch and warn+switch when it differs
local current_branch
current_branch=$("${G[@]}" rev-parse --abbrev-ref HEAD 2>/dev/null)
if [[ -n "$current_branch" && "$current_branch" != "$branch" ]]; then
disp W "Branch mismatch: currently on '$current_branch', configured branch is '$branch'."
if (( dry_run )); then
disp I "[dry-run] git fetch origin $branch"
disp I "[dry-run] git checkout $branch"
disp W "[dry-run] Branch would be changed from '$current_branch' to '$branch'."
else
"${G[@]}" fetch origin "$branch" || {
disp E "Git fetch failed for branch $branch."
return 2
}
"${G[@]}" checkout "$branch" || {
disp E "Git checkout failed for branch $branch."
return 2
}
disp W "Branch has been changed from '$current_branch' to '$branch'."
fi
fi
# status --porcelain: staged, unstaged and untracked (not ignored) files
if [[ -n $("${G[@]}" status --porcelain 2>/dev/null) ]]; then
if (( force_git )); then
disp W "Force mode: local Git changes and untracked files will be lost."
if (( dry_run )); then
disp I "[dry-run] git fetch --all --prune"
disp I "[dry-run] git reset --hard HEAD"
disp I "[dry-run] git clean -fd"
else
"${G[@]}" fetch --all --prune || {
disp E "Git fetch failed, upgrade not applied."
return 4
}
"${G[@]}" reset --hard HEAD || {
disp E "Git reset failed, upgrade not applied."
return 4
}
"${G[@]}" clean -fd || {
disp E "Git clean failed, upgrade not applied."
return 4
}
fi
else
disp W "The Git working tree contains local changes."
disp W "Consider committing or stashing them before upgrading, or use --force."
disp W "Upgrade may fail if the changes conflict with the upgrade."
fi
fi
if (( dry_run )); then
disp I "[dry-run] git pull --ff-only origin $branch"
else
# --ff-only: never create a merge commit nor leave conflict markers
# in the installed scripts; local commits that diverge are reported
"${G[@]}" pull --ff-only origin "$branch" || {
disp E "Git pull failed, upgrade not applied (see the git message above: local changes in the way, or local commits that diverge from origin/$branch and must be rebased or dropped by hand)."
return 2
}
disp I "Successfully upgraded using git. Please log out and log in again."
fi
else
if (( use_archive )); then
[[ -r "$archive_file" ]] || {
disp E "Local archive '$archive_file' is missing or unreadable."
return 4
}
disp I "Using local archive $archive_file."
else
disp W "No Git repo found. Git is the recommended source."
disp I "Applying upgrade from archive..."
fi
if [[ -n "$tmpbase" ]]; then
if (( dry_run )); then
disp I "[dry-run] mkdir -p \"$tmpbase\""
disp I "[dry-run] mktemp -d \"$tmpbase/profile_upg.XXXXXX\""
tmpdir="$tmpbase/profile_upg.DRYRUN"
else
mkdir -p "$tmpbase" || {
disp E "Failed to create temporary directory base $tmpbase."
return 5
}
tmpdir=$(mktemp -d "$tmpbase/profile_upg.XXXXXX") || {
disp E "Failed to create temp working directory under $tmpbase."
return 5
}
fi
else
if (( dry_run )); then
disp I "[dry-run] mktemp -d /tmp/profile_upg.XXXXXX"
tmpdir="/tmp/profile_upg.DRYRUN"
else
tmpdir=$(mktemp -d /tmp/profile_upg.XXXXXX) || {
disp E "Failed to create temporary directory."
return 5
}
fi
fi
if (( use_archive )); then
archive="$archive_file"
else
archive="$tmpdir/profile.tar.gz"
if (( dry_run )); then
disp I "[dry-run] dwl \"$ARCH_URL\" \"$archive\""
else
dwl -t 300 "$ARCH_URL" "$archive" || {
disp E "Failed to download archive."
rm -rf "$tmpdir"
return 6
}
fi
fi
if (( dry_run )); then
disp I "[dry-run] tar -xf \"$archive\" -C \"$tmpdir\""
disp I "[dry-run] cp -a <extracted_profile>/. \"$MYPATH\"/"
else
# An archive is refused if one of its members is an absolute path or
# climbs out of the extraction directory with "..".
if ! tar -tf "$archive" >"$tmpdir/list" 2>/dev/null; then
disp E "Archive extraction failed (unreadable archive)."
rm -rf "$tmpdir"
return 7
fi
if grep -qE '(^/|(^|/)\.\.(/|$))' "$tmpdir/list"; then
disp E "Unsafe archive (absolute or '..' path); upgrade refused."
rm -rf "$tmpdir"
return 7
fi
rm -f "$tmpdir/list"
# --no-same-owner: the files belong to the user running the upgrade
tar --no-same-owner -xf "$archive" -C "$tmpdir" || {
disp E "Archive extraction failed."
rm -rf "$tmpdir"
return 7
}
extracted_root=$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d ! -name '.*' | head -n 1)
if [[ -z "$extracted_root" ]]; then
disp E "Could not find extracted profile files."
rm -rf "$tmpdir"
return 8
fi
# cp only adds and replaces: a module removed from the new version
# stays in profile.d and keeps being loaded. They are listed after
# the copy (never deleted: they may be the user's own modules).
local f
local -a stale=()
for f in "$MYPATH"/profile.d/*.sh "$MYPATH"/profile.d/bash-completion/*.sh; do
[[ -e "$f" ]] || continue
[[ -e "$extracted_root/${f#"$MYPATH"/}" ]] || stale+=("${f#"$MYPATH"/}")
done
disp I "Installing new version..."
cp -a "$extracted_root"/. "$MYPATH"/ || {
disp E "Failed to copy new files into $MYPATH."
rm -rf "$tmpdir"
return 9
}
if (( ${#stale[@]} )); then
disp W "These files are not part of the new version and are still loaded; remove them if they are not yours: ${stale[*]}"
fi
disp I "Upgrade complete. Please log out and log in again."
rm -rf "$tmpdir"
fi
fi
return 0
}
export -f profile_upgrade
# ------------------------------------------------------------------------------
# EOF