mirror of
https://github.com/ivuorinen/dotfiles.git
synced 2026-02-08 05:50:38 +00:00
1297 lines
33 KiB
Bash
Executable File
1297 lines
33 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# This script checks if all versions of tools installed via asdf are in use
|
|
# in any .tool-versions file in the home directory or its subdirectories,
|
|
# excluding some directories that shouldn't be scanned.
|
|
#
|
|
# It will print out the tools and versions that are not in use and remove them.
|
|
#
|
|
# This script is useful for cleaning up old versions of tools that are no longer
|
|
# in use and are taking up extra space on your system.
|
|
#
|
|
# Usage: x-asdf-cleanup
|
|
# Author: Ismo Vuorinen <https://github.com/ivuorinen>
|
|
# License: MIT
|
|
#
|
|
# vim: set ft=sh ts=2 sw=2 et: ft=sh
|
|
set -u
|
|
|
|
VERSION="1.0.0"
|
|
BASENAME=$(basename "$0")
|
|
|
|
# Additional configuration
|
|
USE_CACHE=true
|
|
CACHE_MAX_AGE=3600 # 1 hour in seconds
|
|
|
|
# Logging and backup configuration
|
|
readonly LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/asdf-cleanup"
|
|
readonly LOG_FILE="$LOG_DIR/cleanup.log"
|
|
readonly BACKUP_DIR="$LOG_DIR/backups"
|
|
readonly LAST_OP_FILE="$LOG_DIR/last_operation"
|
|
|
|
# Define colors for output formatting
|
|
readonly RED='\033[0;31m'
|
|
readonly GREEN='\033[0;32m'
|
|
readonly YELLOW='\033[1;33m'
|
|
readonly NC='\033[0m' # No Color
|
|
readonly BOLD='\033[1m'
|
|
|
|
# Create temporary file and ensure it's removed on exit
|
|
readonly TMPFILE=$(mktemp)
|
|
|
|
# Signal handling and cleanup
|
|
trap 'cleanup' INT TERM
|
|
trap 'error_handler $?' ERR
|
|
trap 'error_occurred $? $LINENO $BASH_LINENO "$BASH_COMMAND" $(printf "::%s" ${FUNCNAME[@]:-})' ERR
|
|
trap 'rm -f "$TMPFILE"' EXIT
|
|
|
|
# Performance monitoring variables
|
|
readonly START_TIME=$SECONDS
|
|
readonly START_MEM=$(ps -o rss= -p $$)
|
|
|
|
# Default configuration
|
|
BASE_DIR="$HOME"
|
|
DRYRUN=false
|
|
VERBOSE=false
|
|
DEBUG=false
|
|
MAX_PARALLEL_JOBS=4
|
|
|
|
# Global arrays for version management
|
|
declare -A defined_versions=()
|
|
declare -A installed_versions=()
|
|
declare -A keep_version=()
|
|
declare -A all_versions=()
|
|
declare -a uninstall_list=()
|
|
|
|
# Supported tools and their version files
|
|
declare -A VERSION_FILES=(
|
|
["nodejs"]=".nvmrc"
|
|
["python"]=".python-version"
|
|
["asdf"]=".tool-versions"
|
|
)
|
|
|
|
# Define the exclude patterns
|
|
EXCLUDE_PATTERNS=(
|
|
".bundle"
|
|
".cache"
|
|
".git"
|
|
".local/state"
|
|
".npm"
|
|
".Trash"
|
|
".vscode"
|
|
"bower_components"
|
|
"gems"
|
|
"Library"
|
|
"node_modules"
|
|
"Photos"
|
|
"vendor"
|
|
)
|
|
|
|
# Output formatting functions
|
|
print_success()
|
|
{
|
|
echo -e "${GREEN}$1${NC}"
|
|
}
|
|
print_error()
|
|
{
|
|
echo -e "${RED}$1${NC}"
|
|
}
|
|
print_warning()
|
|
{
|
|
echo -e "${YELLOW}$1${NC}"
|
|
}
|
|
|
|
# Cleanup and error handling
|
|
cleanup()
|
|
{
|
|
print_warning "Received termination signal"
|
|
rm -f "$TMPFILE"
|
|
exit 1
|
|
}
|
|
|
|
# Sort array keys by tool name
|
|
sort_by_tool()
|
|
{
|
|
local -n array=$1
|
|
local -a sorted_keys
|
|
readarray -t sorted_keys < <(printf '%s\n' "${!array[@]}" | sort)
|
|
echo "${sorted_keys[@]}"
|
|
}
|
|
|
|
error_handler()
|
|
{
|
|
local exit_code=$1
|
|
print_error "An error occurred (exit code: $exit_code)"
|
|
cleanup
|
|
}
|
|
|
|
error_occurred()
|
|
{
|
|
local exit_code=$1
|
|
local line_no=$2
|
|
local bash_lineno=$3
|
|
local last_command=$4
|
|
local func_trace=$5
|
|
|
|
echo "Error occurred in:"
|
|
echo " Exit code: $exit_code"
|
|
echo " Line number: $line_no ($bash_lineno)"
|
|
echo " Command: $last_command"
|
|
echo " Function trace: $func_trace"
|
|
}
|
|
|
|
# Logging function with different levels
|
|
log()
|
|
{
|
|
local level=$1
|
|
shift
|
|
local message=$*
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
|
|
if [[ $VERBOSE == true ]] || [[ $level == "ERROR" ]]; then
|
|
echo "[$timestamp][$level] $message"
|
|
fi
|
|
}
|
|
|
|
# File logging function
|
|
log_to_file()
|
|
{
|
|
local level=$1
|
|
shift
|
|
local message=$*
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
echo "[$timestamp][$level] $message" >> "$LOG_FILE"
|
|
}
|
|
|
|
# Error handling function
|
|
handle_error()
|
|
{
|
|
local exit_code=$1
|
|
local error_message=$2
|
|
print_error "Error ($exit_code): $error_message" >&2
|
|
log_to_file "ERROR" "$error_message"
|
|
exit "$exit_code"
|
|
}
|
|
|
|
# Debug function
|
|
debug()
|
|
{
|
|
if [[ $DEBUG == true ]]; then
|
|
local timestamp
|
|
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
echo "[$timestamp][DEBUG] $*" >&2
|
|
fi
|
|
}
|
|
|
|
debug_array()
|
|
{
|
|
local -n array="$1"
|
|
local name="$2"
|
|
|
|
if [[ $DEBUG == true ]]; then
|
|
echo "Debug: Contents of $name:"
|
|
for key in "${!array[@]}"; do
|
|
echo " $key => ${array[$key]}"
|
|
done
|
|
fi
|
|
}
|
|
|
|
# Debug information function
|
|
debug_info()
|
|
{
|
|
echo "----------------------------------------"
|
|
echo "Script variables:"
|
|
echo "----------------------------------------"
|
|
echo " BASE_DIR = $BASE_DIR"
|
|
echo " BASE_DIR IS DIR = $(is_dir "$BASE_DIR")"
|
|
echo " DEBUG = $DEBUG"
|
|
echo " DRY_RUN = $DRYRUN"
|
|
echo " EXCLUDE_PATTERNS = ${EXCLUDE_PATTERNS[*]}"
|
|
echo " VERBOSE = $VERBOSE"
|
|
echo " VERSION = $VERSION"
|
|
echo " Remaining arguments:"
|
|
for var in "$@"; do
|
|
echo " $var"
|
|
done
|
|
echo "----------------------------------------"
|
|
echo "Tool versions:"
|
|
echo "----------------------------------------"
|
|
echo "asdf version: $(asdf --version 2> /dev/null || echo "not found")"
|
|
echo "fd version: $(fd --version 2> /dev/null || echo "not found")"
|
|
echo "jq version: $(jq --version 2> /dev/null || echo "not found")"
|
|
echo "----------------------------------------"
|
|
|
|
# Enable debug mode for the rest of the script
|
|
DEBUG=true
|
|
return 0
|
|
}
|
|
|
|
# Display usage information
|
|
usage()
|
|
{
|
|
cat << EOF
|
|
Usage: $BASENAME [OPTIONS]
|
|
|
|
A tool to clean up unused asdf tool versions from your system.
|
|
|
|
Options:
|
|
--base-dir=DIR Specify the base directory to search for version files (default: \$HOME)
|
|
--dry-run Perform a dry run without uninstalling any versions
|
|
--exclude=DIR Exclude a directory from the search path (can be used multiple times)
|
|
--debug Show debug information and enable debug mode
|
|
--verbose Enable verbose output
|
|
--no-cache Disable version cache
|
|
--restore Restore the last uninstalled version from backup
|
|
--max-parallel=N Set maximum number of parallel processes (default: 4)
|
|
-h, --help Show this help message and exit
|
|
-v, --version Show version information and exit
|
|
|
|
Environment variables:
|
|
DEBUG Enable debug mode
|
|
VERBOSE Enable verbose output
|
|
XDG_CONFIG_HOME Configuration directory (default: ~/.config)
|
|
XDG_CACHE_HOME Cache directory (default: ~/.cache)
|
|
|
|
Files:
|
|
~/.config/asdf-cleanup/config Configuration file
|
|
~/.cache/asdf-cleanup/* Cache files
|
|
|
|
Examples:
|
|
$BASENAME --dry-run
|
|
$BASENAME --exclude=node_modules --exclude=vendor
|
|
$BASENAME --verbose --debug
|
|
$BASENAME --base-dir=~/projects --max-parallel=8
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# Function to check dependencies
|
|
check_dependencies()
|
|
{
|
|
local -a required_tools=(asdf fd jq)
|
|
local missing_tools=()
|
|
|
|
for tool in "${required_tools[@]}"; do
|
|
if ! command -v "$tool" > /dev/null 2>&1; then
|
|
missing_tools+=("$tool")
|
|
fi
|
|
done
|
|
|
|
if ((${#missing_tools[@]} > 0)); then
|
|
print_error "Missing required tools: ${missing_tools[*]}"
|
|
if [[ " ${missing_tools[*]} " =~ " fd " ]]; then
|
|
echo "Install fd with asdf:"
|
|
echo "asdf plugin add fd && asdf install fd latest"
|
|
fi
|
|
if [[ " ${missing_tools[*]} " =~ " jq " ]]; then
|
|
echo "Install jq with asdf:"
|
|
echo "asdf plugin add jq && asdf install jq latest"
|
|
fi
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Initialize required directories
|
|
initialize_directories()
|
|
{
|
|
local -a dirs=("$LOG_DIR" "$BACKUP_DIR" "$cache_dir")
|
|
for dir in "${dirs[@]}"; do
|
|
if [[ ! -d $dir ]]; then
|
|
mkdir -p "$dir" || handle_error 1 "Failed to create directory: $dir"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Load configuration file if it exists
|
|
load_config()
|
|
{
|
|
local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/asdf-cleanup/config"
|
|
if [[ -f $config_file ]]; then
|
|
debug "Loading configuration from $config_file"
|
|
# shellcheck source=/dev/null
|
|
source "$config_file"
|
|
fi
|
|
}
|
|
|
|
# Show performance metrics
|
|
show_performance_metrics()
|
|
{
|
|
local end_time=$SECONDS
|
|
local end_mem
|
|
end_mem=$(ps -o rss= -p $$)
|
|
local duration=$((end_time - START_TIME))
|
|
local mem_usage=$((end_mem - START_MEM))
|
|
|
|
log "INFO" "Execution time: ${duration}s"
|
|
log "INFO" "Memory usage: ${mem_usage}KB"
|
|
log_to_file "INFO" "Performance: Time=${duration}s, Memory=${mem_usage}KB"
|
|
}
|
|
|
|
# Progress indicator
|
|
show_progress()
|
|
{
|
|
local -r pid="$1"
|
|
local -r delay='0.75'
|
|
local spinstr='|/-\'
|
|
local temp
|
|
|
|
while ps a | awk '{print $1}' | grep -q "$pid"; do
|
|
temp="${spinstr#?}"
|
|
printf " [%c] " "$spinstr"
|
|
spinstr=$temp${spinstr%"$temp"}
|
|
sleep $delay
|
|
printf "\b\b\b\b\b\b"
|
|
done
|
|
printf " \b\b\b\b"
|
|
}
|
|
|
|
# Semantic version comparison
|
|
version_compare()
|
|
{
|
|
local IFS=.
|
|
local i ver1=($1) ver2=($2)
|
|
|
|
debug "Comparing versions: $1 vs $2"
|
|
|
|
for ((i = 0; i < ${#ver1[@]} || i < ${#ver2[@]}; i++)); do
|
|
local v1=${ver1[i]:-0} v2=${ver2[i]:-0}
|
|
debug "Comparing components: $v1 vs $v2"
|
|
|
|
((v1 > v2)) && {
|
|
debug "First version is greater"
|
|
return 1
|
|
}
|
|
((v1 < v2)) && {
|
|
debug "First version is less"
|
|
return 2
|
|
}
|
|
done
|
|
|
|
debug "Versions are equal"
|
|
return 0
|
|
}
|
|
|
|
# Cache handling functions
|
|
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/asdf-cleanup"
|
|
cache_file="$cache_dir/version-cache"
|
|
|
|
get_file_mtime()
|
|
{
|
|
local file="$1"
|
|
local mtime
|
|
|
|
# Check which platform we're on and use appropriate stat command
|
|
case "$(uname)" in
|
|
"Darwin") # macOS
|
|
mtime=$(stat -f "%m" "$file")
|
|
;;
|
|
"Linux") # Linux
|
|
mtime=$(stat -c "%Y" "$file")
|
|
;;
|
|
*)
|
|
# Default fallback, might not work on all systems
|
|
mtime=$(date +%s -r "$file")
|
|
;;
|
|
esac
|
|
|
|
echo "$mtime"
|
|
}
|
|
|
|
read_cache_versions()
|
|
{
|
|
local -n output_array="$1"
|
|
local content
|
|
|
|
debug "Reading cache from: $cache_file"
|
|
debug "Cache file contents:"
|
|
debug "$(cat "$cache_file")"
|
|
|
|
# Check that the file exists and is not empty
|
|
if [[ ! -f $cache_file || ! -s $cache_file ]]; then
|
|
debug "Cache file doesn't exist or is empty"
|
|
return 1
|
|
fi
|
|
|
|
# Check that the JSON is valid and contains the versions object
|
|
if ! jq -e '.versions' "$cache_file" > /dev/null 2>&1; then
|
|
debug "Cache file doesn't contain valid versions data"
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r line; do
|
|
[[ -n $line ]] || continue
|
|
local key value
|
|
key=$(echo "$line" | jq -r '.key')
|
|
value=$(echo "$line" | jq -r '.value')
|
|
output_array["$key"]="$value"
|
|
done < <(jq -c '.versions | to_entries[]' "$cache_file")
|
|
}
|
|
|
|
write_cache_versions()
|
|
{
|
|
local -n input_array="$1"
|
|
local tmp_file
|
|
tmp_file=$(mktemp)
|
|
|
|
# Create a JSON object with the versions
|
|
echo "{" > "$tmp_file"
|
|
echo ' "versions": {' >> "$tmp_file"
|
|
|
|
local first=true
|
|
for key in "${!input_array[@]}"; do
|
|
if [[ $first == true ]]; then
|
|
first=false
|
|
else
|
|
echo "," >> "$tmp_file"
|
|
fi
|
|
printf ' "%s": "%s"' \
|
|
"$(echo "$key" | sed 's/"/\\"/g')" \
|
|
"${input_array[$key]}" >> "$tmp_file"
|
|
done
|
|
|
|
echo >> "$tmp_file"
|
|
echo " }" >> "$tmp_file"
|
|
echo "}" >> "$tmp_file"
|
|
|
|
# Validate JSON before moving it
|
|
if jq '.' "$tmp_file" > /dev/null 2>&1; then
|
|
mv "$tmp_file" "$cache_file"
|
|
else
|
|
print_error "Failed to create valid JSON cache"
|
|
rm -f "$tmp_file"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
initialize_cache()
|
|
{
|
|
mkdir -p "$cache_dir"
|
|
if [[ ! -f $cache_file ]]; then
|
|
echo '{"versions":{}}' > "$cache_file"
|
|
fi
|
|
}
|
|
|
|
cache_is_valid()
|
|
{
|
|
if [[ ! -f $cache_file ]] || [[ ! -s $cache_file ]]; then
|
|
debug "Cache file doesn't exist or is empty"
|
|
return 1
|
|
fi
|
|
|
|
local mtime
|
|
mtime=$(get_file_mtime "$cache_file")
|
|
local current_time
|
|
current_time=$(date +%s)
|
|
|
|
debug "Cache mtime: $mtime"
|
|
debug "Current time: $current_time"
|
|
debug "Cache age: $((current_time - mtime)) seconds"
|
|
|
|
# Check that the cache contains valid data
|
|
if ! jq -e '.versions' "$cache_file" > /dev/null 2>&1; then
|
|
debug "Cache file doesn't contain valid versions data"
|
|
return 1
|
|
fi
|
|
|
|
if ((current_time - mtime < CACHE_MAX_AGE)); then
|
|
debug "Cache is valid (age < $CACHE_MAX_AGE seconds)"
|
|
return 0
|
|
else
|
|
debug "Cache is too old"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
update_cache()
|
|
{
|
|
local type=$1
|
|
local data=$2
|
|
local tmp_file
|
|
tmp_file=$(mktemp)
|
|
|
|
if ! jq --arg type "$type" --arg data "$data" \
|
|
'.[$type] = $data' "$cache_file" > "$tmp_file"; then
|
|
print_warning "Failed to update cache"
|
|
log_to_file "WARNING" "Failed to update cache for type: $type"
|
|
rm -f "$tmp_file"
|
|
return 1
|
|
fi
|
|
|
|
mv "$tmp_file" "$cache_file"
|
|
}
|
|
|
|
read_cache()
|
|
{
|
|
local type=$1
|
|
jq -r --arg type "$type" '.[$type] // empty' "$cache_file"
|
|
}
|
|
|
|
read_cache_versions()
|
|
{
|
|
# Check that the output array is properly initialized
|
|
if [[ -z ${!1+x} ]]; then
|
|
debug "Output array not properly initialized"
|
|
return 1
|
|
fi
|
|
|
|
local -n output_array="$1"
|
|
local content
|
|
|
|
debug "Reading cache file: $cache_file"
|
|
|
|
# Check that the file exists and is not empty
|
|
if [[ ! -f $cache_file ]] || [[ ! -s $cache_file ]]; then
|
|
debug "Cache file doesn't exist or is empty"
|
|
return 1
|
|
fi
|
|
|
|
# Check the JSON structure
|
|
if ! content=$(jq -e '.versions' "$cache_file" 2> /dev/null); then
|
|
debug "Invalid JSON structure in cache file"
|
|
return 1
|
|
fi
|
|
|
|
# Empty the target array for safety
|
|
output_array=()
|
|
|
|
# Create an associative array from the JSON data
|
|
while IFS= read -r line; do
|
|
[[ -n $line ]] || continue
|
|
local key value
|
|
key=$(echo "$line" | jq -r '.key')
|
|
value=$(echo "$line" | jq -r '.value')
|
|
if [[ -n $key && $key != "null" ]]; then
|
|
debug "Adding cached version: $key = $value"
|
|
output_array["$key"]="$value"
|
|
fi
|
|
done < <(jq -c '.versions | to_entries[]' "$cache_file")
|
|
|
|
debug "Read ${#output_array[@]} versions from cache"
|
|
return 0
|
|
}
|
|
|
|
write_cache_versions()
|
|
{
|
|
local -n input_array="$1"
|
|
local tmp_file
|
|
tmp_file=$(mktemp)
|
|
|
|
# Convert the associative array to JSON format
|
|
printf '{"versions":{\n' > "$tmp_file"
|
|
local first=true
|
|
for key in "${!input_array[@]}"; do
|
|
if [[ $first == true ]]; then
|
|
first=false
|
|
else
|
|
printf ',\n' >> "$tmp_file"
|
|
fi
|
|
printf ' "%s": %s' "$key" "${input_array[$key]}" >> "$tmp_file"
|
|
done
|
|
printf '\n}}\n' >> "$tmp_file"
|
|
|
|
mv "$tmp_file" "$cache_file"
|
|
}
|
|
|
|
copy_versions()
|
|
{
|
|
local -n dest="$1"
|
|
local -n source="$2"
|
|
|
|
for key in "${!source[@]}"; do
|
|
dest["$key"]="${source[$key]}"
|
|
done
|
|
}
|
|
|
|
# Function to check if directory exists
|
|
is_dir()
|
|
{
|
|
local dir="$1"
|
|
if [[ -d $dir ]]; then
|
|
echo "yes"
|
|
else
|
|
echo "no"
|
|
fi
|
|
}
|
|
|
|
# Trim whitespace from string
|
|
trim_whitespace()
|
|
{
|
|
local var="$1"
|
|
# Remove leading whitespace
|
|
var="${var#"${var%%[![:space:]]*}"}"
|
|
# Remove trailing whitespace
|
|
var="${var%"${var##*[![:space:]]}"}"
|
|
echo "$var"
|
|
}
|
|
|
|
# Process .tool-versions file
|
|
process_tool_versions_file()
|
|
{
|
|
local file="$1"
|
|
if [[ ! -f $file ]]; then
|
|
debug "File not found: $file"
|
|
return 1
|
|
fi
|
|
# Process file content and output "tool version" pairs
|
|
# Ignore comments and empty lines, require at least tool and version
|
|
awk '!/#/ && NF >= 2 {print $1, $2}' "$file"
|
|
}
|
|
|
|
# Version file processing functions
|
|
process_version_files()
|
|
{
|
|
local file_type="$1"
|
|
local array_name="$2"
|
|
local -A local_versions=()
|
|
|
|
debug "Processing version files for $file_type"
|
|
|
|
while IFS= read -r relative_path; do
|
|
[[ -z $relative_path ]] && continue
|
|
|
|
# Create an absolute path
|
|
local file="${BASE_DIR}/${relative_path}"
|
|
debug "Processing file: $file (relative: $relative_path)"
|
|
|
|
if ! validate_version_file "$file" "$file_type"; then
|
|
debug "Invalid version file format: $file"
|
|
continue
|
|
fi
|
|
|
|
case "$file_type" in
|
|
"nodejs")
|
|
local version
|
|
version=$(< "$file")
|
|
debug "Raw nodejs version content: '$version'"
|
|
version=$(trim_whitespace "$version")
|
|
# Handle lts/* separately
|
|
if [[ $version == "lts/*" ]] || [[ $version =~ ^lts/[*]$ ]]; then
|
|
version="lts/*"
|
|
else
|
|
version=${version#v} # Remove leading 'v' if present
|
|
fi
|
|
debug "Processed nodejs version: '$version'"
|
|
local_versions["nodejs ${version}"]=1
|
|
;;
|
|
"python")
|
|
local content
|
|
content=$(< "$file")
|
|
debug "Raw python version content: '$content'"
|
|
content=$(trim_whitespace "$content")
|
|
for version in $content; do
|
|
version=$(trim_whitespace "$version")
|
|
[[ -z $version || $version == \#* ]] && continue
|
|
debug "Found python version: '$version'"
|
|
local_versions["python ${version}"]=1
|
|
done
|
|
;;
|
|
"asdf")
|
|
while IFS= read -r line; do
|
|
line=$(trim_whitespace "$line")
|
|
[[ -z $line || $line == \#* ]] && continue
|
|
local tool version
|
|
read -r tool version <<< "$line"
|
|
tool=$(trim_whitespace "$tool")
|
|
version=$(trim_whitespace "$version")
|
|
debug "Found tool and version: '$tool' '$version'"
|
|
local_versions["$tool ${version}"]=1
|
|
done < "$file"
|
|
;;
|
|
esac
|
|
done < <(find_version_files "$file_type")
|
|
|
|
debug "Finished processing $file_type, found ${#local_versions[@]} versions"
|
|
|
|
# Return the local versions as an associative array
|
|
declare -p local_versions
|
|
}
|
|
|
|
# Validate version file format
|
|
validate_version_file()
|
|
{
|
|
local file="$1"
|
|
local file_type="$2"
|
|
|
|
debug "Validating file: $file"
|
|
[[ -f $file ]] || {
|
|
debug "File does not exist: $file"
|
|
return 1
|
|
}
|
|
[[ -r $file ]] || {
|
|
debug "File is not readable: $file"
|
|
return 2
|
|
}
|
|
|
|
local content
|
|
content=$(< "$file")
|
|
content=$(trim_whitespace "$content")
|
|
[[ -z $content ]] && {
|
|
debug "File is empty: $file"
|
|
return 1
|
|
}
|
|
|
|
debug "Validating $file_type file: '$file' with content: '$content'"
|
|
|
|
case "$file_type" in
|
|
"nodejs")
|
|
# Support both direct versions and lts/* notation
|
|
if [[ $content =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
|
|| [[ $content =~ ^lts/[*]$ ]] \
|
|
|| [[ $content == "lts/*" ]]; then
|
|
debug "Valid nodejs version: $content"
|
|
return 0
|
|
else
|
|
debug "Invalid nodejs version format: $content"
|
|
fi
|
|
;;
|
|
"python")
|
|
# Support both single and multiple versions
|
|
local valid=true
|
|
for ver in $content; do
|
|
debug "Checking Python version: $ver"
|
|
if ! [[ $ver =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
debug "Invalid Python version format: $ver"
|
|
valid=false
|
|
break
|
|
fi
|
|
done
|
|
if $valid; then
|
|
debug "Valid Python version(s): $content"
|
|
return 0
|
|
fi
|
|
;;
|
|
"asdf")
|
|
# Support .tool-versions format
|
|
local valid=true
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
line=$(trim_whitespace "$line")
|
|
debug "Checking .tool-versions line: '$line'"
|
|
[[ -z $line || $line == \#* ]] && continue
|
|
|
|
if ! [[ $line =~ ^[a-zA-Z0-9_-]+[[:space:]]+[a-zA-Z0-9._*/-]+$ ]]; then
|
|
debug "Invalid .tool-versions format: '$line'"
|
|
valid=false
|
|
break
|
|
else
|
|
debug "Valid .tool-versions line: '$line'"
|
|
fi
|
|
done <<< "$content"
|
|
if $valid; then
|
|
debug "Valid .tool-versions file"
|
|
return 0
|
|
fi
|
|
;;
|
|
esac
|
|
return 1
|
|
}
|
|
|
|
# Find version files using fd
|
|
find_version_files()
|
|
{
|
|
local file_type="$1"
|
|
local pattern="${VERSION_FILES[$file_type]}"
|
|
|
|
debug "Searching for $file_type version files with pattern: $pattern"
|
|
debug "Base directory: $BASE_DIR"
|
|
debug "Exclude patterns: ${EXCLUDE_PATTERNS[*]}"
|
|
|
|
if [[ -z $pattern ]]; then
|
|
handle_error 1 "Unknown version file type: $file_type"
|
|
fi
|
|
|
|
local fd_args=(
|
|
--base-directory "$BASE_DIR"
|
|
--type f
|
|
--hidden
|
|
--glob "$pattern"
|
|
)
|
|
|
|
# Add all exclude patterns
|
|
for exclude in "${EXCLUDE_PATTERNS[@]}"; do
|
|
fd_args+=(--exclude "$exclude")
|
|
done
|
|
|
|
debug "fd command arguments: ${fd_args[*]}"
|
|
local files
|
|
files=$(fd "${fd_args[@]}" 2> /dev/null)
|
|
debug "Found files for $file_type:"
|
|
debug "$files"
|
|
|
|
echo "$files"
|
|
}
|
|
|
|
# Process all version files in parallel
|
|
process_all_version_files()
|
|
{
|
|
local -n output_array="$1"
|
|
|
|
debug "Starting to process version files..."
|
|
output_array=()
|
|
|
|
for type in "${!VERSION_FILES[@]}"; do
|
|
debug "Processing type: $type"
|
|
local versions_data
|
|
versions_data=$(process_version_files "$type" "temp_versions")
|
|
|
|
if [[ $versions_data == "declare -A"* ]]; then
|
|
eval "local -A type_versions=${versions_data#*=}"
|
|
for key in "${!type_versions[@]}"; do
|
|
output_array["$key"]=1
|
|
debug "Added version: $key"
|
|
done
|
|
fi
|
|
done
|
|
|
|
debug "Total versions found: ${#output_array[@]}"
|
|
}
|
|
|
|
# Backup functions
|
|
create_backup()
|
|
{
|
|
local tool=$1
|
|
local version=$2
|
|
local backup_path="$BACKUP_DIR/${tool}_${version}_$(date +%Y%m%d_%H%M%S)"
|
|
local tool_path
|
|
tool_path=$(asdf where "$tool" "$version")
|
|
|
|
# Check backup directory permissions
|
|
if [[ ! -w $BACKUP_DIR ]]; then
|
|
debug "Backup directory not writable: $BACKUP_DIR"
|
|
return 1
|
|
fi
|
|
|
|
if [[ -d $tool_path ]]; then
|
|
debug "Creating backup of $tool $version"
|
|
log_to_file "INFO" "Creating backup of $tool $version"
|
|
if tar czf "$backup_path.tar.gz" -C "$(dirname "$tool_path")" "$(basename "$tool_path")"; then
|
|
# Verify backup integrity
|
|
if ! tar tzf "$backup_path.tar.gz" > /dev/null 2>&1; then
|
|
debug "Backup verification failed"
|
|
rm -f "$backup_path.tar.gz"
|
|
return 1
|
|
fi
|
|
|
|
echo "$tool $version" > "$LAST_OP_FILE"
|
|
return 0
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
restore_last_backup()
|
|
{
|
|
if [[ ! -f $LAST_OP_FILE ]]; then
|
|
print_error "No backup information found"
|
|
return 1
|
|
fi
|
|
|
|
read -r tool version < "$LAST_OP_FILE"
|
|
local backup_file
|
|
backup_file=$(find "$BACKUP_DIR" -name "${tool}_${version}_*.tar.gz" | sort -r | head -n1)
|
|
|
|
if [[ -f $backup_file ]]; then
|
|
local install_path
|
|
install_path=$(asdf where "$tool" "$version" 2> /dev/null || echo "$HOME/.asdf/installs/$tool/$version")
|
|
|
|
print_warning "Restoring $tool $version from backup"
|
|
log_to_file "INFO" "Restoring $tool $version from backup"
|
|
mkdir -p "$(dirname "$install_path")"
|
|
if tar xzf "$backup_file" -C "$(dirname "$install_path")"; then
|
|
print_success "Restored $tool $version successfully"
|
|
log_to_file "INFO" "Successfully restored $tool $version"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
print_error "Backup not found or restore failed"
|
|
log_to_file "ERROR" "Failed to restore $tool $version"
|
|
return 1
|
|
}
|
|
|
|
# Read installed versions from asdf
|
|
read_installed_versions()
|
|
{
|
|
local current_tool=""
|
|
local line
|
|
debug "Reading installed versions from asdf"
|
|
|
|
# Capture asdf output and check for errors
|
|
local asdf_output
|
|
if ! asdf_output=$(asdf list 2>&1); then
|
|
print_error "Failed to read installed versions: $asdf_output"
|
|
log_to_file "ERROR" "Failed to read installed versions: $asdf_output"
|
|
return 1
|
|
fi
|
|
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
if [[ $line =~ ^[^[:space:]] ]]; then
|
|
current_tool=$(trim_whitespace "$line")
|
|
continue
|
|
fi
|
|
|
|
line=$(trim_whitespace "$line")
|
|
if [[ $line == "No versions installed" ]]; then
|
|
print_warning "No versions installed for $current_tool"
|
|
log_to_file "WARNING" "No versions installed for $current_tool"
|
|
continue
|
|
fi
|
|
|
|
# Extract version, removing the asterisk if present
|
|
local version
|
|
if [[ $line =~ ^\* ]]; then
|
|
version=$(trim_whitespace "${line#\*}")
|
|
keep_version["$current_tool $version"]=1
|
|
debug "Keeping version: $current_tool $version"
|
|
else
|
|
version=$(trim_whitespace "$line")
|
|
fi
|
|
|
|
installed_versions["$current_tool $version"]=1
|
|
debug "Added installed version: $current_tool $version"
|
|
|
|
done <<< "$asdf_output"
|
|
}
|
|
|
|
# Group and sort versions by tool
|
|
group_versions()
|
|
{
|
|
local array_name="$1"
|
|
local -A tools=()
|
|
local tool version
|
|
|
|
# Safely create a temporary array for the keys
|
|
declare -a keys
|
|
while IFS= read -r key; do
|
|
[[ -n $key ]] && keys+=("$key")
|
|
done < <(eval "printf '%s\n' \"\${!$array_name[@]}\"")
|
|
|
|
# Group versions by tool
|
|
for key in "${keys[@]}"; do
|
|
tool="${key%% *}"
|
|
version="${key#* }"
|
|
if [[ -n ${tools["$tool"]-} ]]; then
|
|
tools["$tool"]="${tools["$tool"]}, $version"
|
|
else
|
|
tools["$tool"]="$version"
|
|
fi
|
|
done
|
|
|
|
# Sort and output with bold tool names
|
|
while IFS= read -r tool; do
|
|
[[ -n $tool ]] && echo -e " ${BOLD}${tool}${NC} ${tools["$tool"]}"
|
|
done < <(printf '%s\n' "${!tools[@]}" | sort)
|
|
}
|
|
|
|
# Update display functions to use the new grouping
|
|
display_defined_versions()
|
|
{
|
|
echo ""
|
|
print_success "All Defined Versions in .nvmrc, .python-version and .tool-versions files:"
|
|
group_versions defined_versions
|
|
echo
|
|
log_to_file "INFO" "Found ${#defined_versions[@]} defined version(s)"
|
|
}
|
|
|
|
display_installed_versions()
|
|
{
|
|
print_success "All Installed Versions:"
|
|
group_versions installed_versions
|
|
echo
|
|
log_to_file "INFO" "Found ${#installed_versions[@]} installed version(s)"
|
|
}
|
|
|
|
display_versions_to_keep()
|
|
{
|
|
print_success "Versions to Keep (tools set as global):"
|
|
group_versions keep_version
|
|
echo
|
|
log_to_file "INFO" "Found ${#keep_version[@]} version(s) to keep"
|
|
}
|
|
|
|
# Parse arguments function
|
|
parse_arguments()
|
|
{
|
|
local arg
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
-h | --help)
|
|
usage
|
|
;;
|
|
-v | --version)
|
|
version
|
|
;;
|
|
--base-dir=*)
|
|
BASE_DIR="${arg#*=}"
|
|
;;
|
|
--dry-run)
|
|
DRYRUN=true
|
|
;;
|
|
--exclude=*)
|
|
EXCLUDE_PATTERNS+=("${arg#*=}")
|
|
;;
|
|
--verbose)
|
|
VERBOSE=true
|
|
;;
|
|
--debug)
|
|
DEBUG=true
|
|
debug_info "$@"
|
|
;;
|
|
--no-cache)
|
|
USE_CACHE=false
|
|
;;
|
|
--restore)
|
|
restore_last_backup
|
|
exit
|
|
;;
|
|
--max-parallel=*)
|
|
MAX_PARALLEL_JOBS="${arg#*=}"
|
|
if ! [[ $MAX_PARALLEL_JOBS =~ ^[0-9]+$ ]]; then
|
|
handle_error 1 "Invalid value for max-parallel: $MAX_PARALLEL_JOBS"
|
|
fi
|
|
;;
|
|
*)
|
|
print_error "Unknown option: $arg"
|
|
usage
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# Version information with added details
|
|
version()
|
|
{
|
|
cat << EOF
|
|
$BASENAME version $VERSION
|
|
Author: Ismo Vuorinen <https://github.com/ivuorinen>
|
|
License: MIT
|
|
|
|
System information:
|
|
OS: $(uname -s)
|
|
Shell: $SHELL
|
|
BASH Version: $BASH_VERSION
|
|
|
|
Dependencies:
|
|
asdf: $(asdf --version 2> /dev/null || echo "not found")
|
|
fd: $(fd --version 2> /dev/null || echo "not found")
|
|
jq: $(jq --version 2> /dev/null || echo "not found")
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# Update determine_versions_to_uninstall to use the same formatting
|
|
determine_versions_to_uninstall()
|
|
{
|
|
print_warning "Versions to Uninstall:"
|
|
local count=0
|
|
declare -A to_uninstall=() # Declare associative array
|
|
uninstall_list=() # Empty the uninstall list
|
|
|
|
debug "Determining versions to uninstall..."
|
|
debug "Installed versions: ${!installed_versions[*]}"
|
|
debug "Defined versions: ${!defined_versions[*]}"
|
|
debug "Keep versions: ${!keep_version[*]}"
|
|
|
|
for key in "${!installed_versions[@]}"; do
|
|
local name="${key% *}"
|
|
local version="${key#* }"
|
|
local keep=false
|
|
|
|
debug "Checking $name version $version"
|
|
|
|
# Check if the version is in the keep_version list
|
|
if [[ -v keep_version[$key] ]]; then
|
|
debug "$name $version is in keep_version list"
|
|
continue
|
|
fi
|
|
|
|
debug "Version not in keep_version list, checking defined versions"
|
|
# Check if the version is defined in the defined_versions list
|
|
for defined_key in "${!defined_versions[@]}"; do
|
|
local defined_name="${defined_key% *}"
|
|
local defined_version="${defined_key#* }"
|
|
|
|
if [[ $name == "$defined_name" ]]; then
|
|
debug "Found matching defined version for $name: $defined_version (installed: $version)"
|
|
version_compare "$version" "$defined_version"
|
|
local compare_result=$?
|
|
debug "Version comparison result: $compare_result"
|
|
|
|
if [[ $compare_result -eq 0 ]] || [[ $compare_result -eq 1 ]]; then
|
|
debug "Keeping $name $version (equal or newer than defined version)"
|
|
keep=true
|
|
break
|
|
fi
|
|
fi
|
|
done
|
|
|
|
if [[ $keep == false ]]; then
|
|
debug "Will uninstall $name $version"
|
|
((count++))
|
|
# Check if the tool already has versions and initialize if necessary
|
|
if [[ -v to_uninstall[$name] ]]; then
|
|
to_uninstall[$name]="${to_uninstall[$name]}, $version"
|
|
debug "Added version to existing tool: $name ${to_uninstall[$name]}"
|
|
else
|
|
to_uninstall[$name]="$version"
|
|
debug "Added new tool and version: $name $version"
|
|
fi
|
|
uninstall_list+=("$key")
|
|
debug "Added to uninstall list: $key (total: ${#uninstall_list[@]})"
|
|
fi
|
|
done
|
|
|
|
debug "Processing complete. Found $count versions to uninstall"
|
|
debug "Uninstall list contains: ${uninstall_list[*]}"
|
|
debug "To uninstall map contains: ${!to_uninstall[*]}"
|
|
|
|
# Display grouped uninstall versions
|
|
if ((count > 0)); then
|
|
local -a sorted_tools
|
|
readarray -t sorted_tools < <(printf '%s\n' "${!to_uninstall[@]}" | sort)
|
|
for tool in "${sorted_tools[@]}"; do
|
|
echo " $tool ${to_uninstall[$tool]}"
|
|
debug "Displaying: $tool ${to_uninstall[$tool]}"
|
|
done
|
|
else
|
|
debug "No versions to uninstall"
|
|
fi
|
|
|
|
echo "Found $count version(s) to uninstall"
|
|
log_to_file "INFO" "Found $count version(s) to uninstall"
|
|
echo
|
|
|
|
debug "Exiting determine_versions_to_uninstall"
|
|
return 0
|
|
}
|
|
|
|
# Uninstall versions with progress indication
|
|
uninstall_versions()
|
|
{
|
|
if ((${#uninstall_list[@]} == 0)); then
|
|
print_success "No versions to uninstall."
|
|
return
|
|
fi
|
|
|
|
if [[ $DRYRUN == true ]]; then
|
|
print_warning "Dry run mode - would uninstall these versions:"
|
|
for key in "${uninstall_list[@]}"; do
|
|
echo " Would uninstall: $key"
|
|
done
|
|
log_to_file "INFO" "Dry run completed, would uninstall ${#uninstall_list[@]} version(s)"
|
|
return
|
|
fi
|
|
|
|
local confirm
|
|
read -rp "Do you want to proceed with uninstallation? (y/N): " confirm
|
|
if [[ $confirm =~ ^[Yy]$ ]]; then
|
|
local total=${#uninstall_list[@]}
|
|
local current=0
|
|
|
|
for key in "${uninstall_list[@]}"; do
|
|
((current++))
|
|
local name="${key% *}"
|
|
local version="${key#* }"
|
|
print_success "($current/$total) Uninstalling $name: $version"
|
|
|
|
# Create backup before uninstalling
|
|
if ! create_backup "$name" "$version"; then
|
|
print_warning "Failed to create backup for $name $version"
|
|
continue
|
|
fi
|
|
|
|
if ! asdf uninstall "$name" "$version"; then
|
|
print_error "Failed to uninstall $name $version"
|
|
log_to_file "ERROR" "Failed to uninstall $name $version"
|
|
else
|
|
log_to_file "INFO" "Successfully uninstalled $name $version"
|
|
fi
|
|
done
|
|
print_success "Uninstallation completed"
|
|
log_to_file "INFO" "Uninstallation process completed"
|
|
else
|
|
print_warning "Uninstallation aborted by user."
|
|
log_to_file "INFO" "Uninstallation aborted by user"
|
|
fi
|
|
}
|
|
|
|
# Main execution
|
|
main()
|
|
{
|
|
check_dependencies
|
|
initialize_directories
|
|
load_config
|
|
parse_arguments "$@"
|
|
initialize_cache
|
|
|
|
if [[ $(is_dir "$BASE_DIR") == "no" ]]; then
|
|
handle_error 1 "Base directory $BASE_DIR does not exist or is not a directory"
|
|
fi
|
|
|
|
print_success "Starting asdf cleanup process..."
|
|
log_to_file "INFO" "Starting cleanup process"
|
|
|
|
# Make sure the array is initialized
|
|
declare -A all_versions=()
|
|
|
|
debug "Checking cache status..."
|
|
|
|
if [[ $USE_CACHE == true ]] && cache_is_valid; then
|
|
debug "Cache is valid, reading from cache..."
|
|
if read_cache_versions all_versions && ((${#all_versions[@]} > 0)); then
|
|
debug "Successfully read ${#all_versions[@]} versions from cache"
|
|
debug_array all_versions "Cached versions"
|
|
else
|
|
debug "Cache read failed or was empty, processing version files..."
|
|
all_versions=() # Empty the array for safety
|
|
process_all_version_files all_versions
|
|
debug_array all_versions "Processed versions"
|
|
if [[ $USE_CACHE == true ]] && ((${#all_versions[@]} > 0)); then
|
|
debug "Writing ${#all_versions[@]} versions to cache..."
|
|
write_cache_versions all_versions
|
|
fi
|
|
fi
|
|
else
|
|
debug "Cache is not valid or disabled, processing version files..."
|
|
all_versions=() # Empty the array for safety
|
|
process_all_version_files all_versions
|
|
debug_array all_versions "Processed versions"
|
|
if [[ $USE_CACHE == true ]] && ((${#all_versions[@]} > 0)); then
|
|
debug "Writing ${#all_versions[@]} versions to cache..."
|
|
write_cache_versions all_versions
|
|
fi
|
|
fi
|
|
|
|
# If no versions were found, try again
|
|
if ((${#all_versions[@]} == 0)); then
|
|
debug "No versions found, forcing version file processing..."
|
|
all_versions=() # Empty the array for safety
|
|
process_all_version_files all_versions
|
|
debug_array all_versions "Processed versions (forced)"
|
|
fi
|
|
|
|
# Declare an associative array for defined versions
|
|
declare -A defined_versions=()
|
|
|
|
debug "Copying ${#all_versions[@]} versions to defined_versions..."
|
|
copy_versions defined_versions all_versions
|
|
debug_array defined_versions "Defined versions"
|
|
|
|
# Continue normally...
|
|
display_defined_versions
|
|
read_installed_versions
|
|
display_installed_versions
|
|
display_versions_to_keep
|
|
determine_versions_to_uninstall
|
|
uninstall_versions
|
|
|
|
show_performance_metrics
|
|
print_success "Cleanup process completed"
|
|
log_to_file "INFO" "Cleanup process completed successfully"
|
|
}
|
|
|
|
# Execute main function only if script is run directly
|
|
if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then
|
|
main "$@"
|
|
fi
|