#!/usr/bin/env bash # ------------------------------------------------------------------------------ # Copyright (c) 2013-2026 Geoffray Levasseur # 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) 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 } [[ -n "$branch" ]] && ( cd "$MYPATH" && git checkout "$branch" ) || true # 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 /. \"$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