Skip to content

Instantly share code, notes, and snippets.

@yayanet
Last active July 11, 2025 11:03
Show Gist options
  • Select an option

  • Save yayanet/b6ee9c22ca6a2eedfd95436d1f6ef20b to your computer and use it in GitHub Desktop.

Select an option

Save yayanet/b6ee9c22ca6a2eedfd95436d1f6ef20b to your computer and use it in GitHub Desktop.
list and clean expired provisioning profiles on macOS
#!/bin/bash
# -----------------------------------------------------------------------------
# Function: Scan and manage provisioning profiles from system and Xcode paths.
# Version: 4.1 (Fixes bug in file list processing from v4.0)
#
# Usage:
# ./cleanup_profiles.sh -l - List the status (Valid/Expired) of all profiles.
# ./cleanup_profiles.sh -d - Interactively move all expired profiles to Trash.
# -----------------------------------------------------------------------------
# --- Configuration ---
COLOR_RED='\033[0;31m'
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[1;33m'
COLOR_CYAN='\033[0;36m'
COLOR_NC='\033[0m'
# Global arrays to store information about expired files for the delete operation.
declare -a expired_files
declare -a expired_file_displays
# --- Function Definitions ---
# Function: Display usage instructions.
function show_usage() {
echo "Usage: $0 [OPTION]"
echo
echo "Options:"
echo " -l, --list Scan and list the status of all provisioning profiles."
echo " -d, --delete Scan all profiles and start an interactive flow to delete expired ones."
echo
}
# Function: Core logic to scan a given path and display results.
# Arguments:
# $1: The file path to scan for profiles.
# $2: A descriptive name for the path being scanned (for display).
function scan_and_display_profiles() {
local target_path="$1"
local path_description="$2"
local currentTimestamp=$(date "+%s")
# Check if the directory exists before scanning
if [ ! -d "$target_path" ]; then
echo -e "${COLOR_YELLOW}Warning: Directory not found, skipping scan:${COLOR_NC} $target_path"
echo
return
fi
echo -e "${COLOR_CYAN}--- Scanning: $path_description ---${COLOR_NC}"
echo
# Temporary arrays to store file info for sorting.
declare -a all_profiles
declare -a all_statuses
declare -a all_dates
declare -a all_files
declare -a all_displays
local profile_found=0
# **BUG FIX**: Reverted to direct process substitution for find.
# This correctly handles NULL-delimited filenames with special characters.
while IFS= read -r -d $'\0' file; do
profile_found=1
local filename=$(basename "$file")
local plist_content=$(security cms -D -i "$file" 2>/dev/null)
if [ -z "$plist_content" ]; then
printf "${COLOR_RED}[Parse Error]${COLOR_NC} - Could not decode file: %s\n" "$filename"
continue
fi
local profileName=$(echo "$plist_content" | grep -A1 '<key>Name</key>' | tail -n1 | sed -E 's/.*<string>(.*)<\/string>.*/\1/')
local expirationDateString=$(echo "$plist_content" | grep -A1 ExpirationDate | tail -n1 | sed -E 's/.*<date>(.*)<\/date>.*/\1/')
if [ -n "$expirationDateString" ]; then
local expirationTimestamp=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$expirationDateString" "+%s" 2>/dev/null)
if [ $? -eq 0 ]; then
local formattedExpirationDate=$(date -j -f "%s" "$expirationTimestamp" "+%Y-%m-%d")
local display_info
if [ -n "$profileName" ]; then
display_info="$profileName (${filename})"
else
display_info="$filename"
profileName="$filename" # Use filename for sorting if name is missing
fi
# Store in temporary arrays for sorting
all_profiles+=("$profileName")
all_files+=("$file")
all_displays+=("$display_info")
all_dates+=("$formattedExpirationDate")
if [ "$expirationTimestamp" -lt "$currentTimestamp" ]; then
all_statuses+=("expired")
else
all_statuses+=("valid")
fi
else
printf "${COLOR_RED}[Date Error]${COLOR_NC} - %-80s\n" "$profileName ($filename)"
fi
else
local uuid=$(echo "$plist_content" | grep -A1 UUID | tail -n1 | sed -E 's/.*<string>(.*)<\/string>.*/\1/')
printf "${COLOR_RED}[File Error]${COLOR_NC} - UUID: %s (%s)\n" "$uuid" "$filename"
fi
done < <(find "$target_path" \( -name "*.mobileprovision" -o -name "*.provisionprofile" \) -print0 2>/dev/null)
if [ "$profile_found" -eq 0 ]; then
echo "No profiles found in this directory."
echo
return
fi
# Create an index array for sorting
declare -a indices
for i in "${!all_profiles[@]}"; do
indices+=("$i")
done
# Sort indices based on profile name
IFS=$'\n' sorted_indices=($(
for i in "${indices[@]}"; do
# Use a tab character to safely separate name from index
printf '%s\t%s\n' "${all_profiles[$i]}" "$i"
done | sort -f -k1,1 | cut -f2
))
unset IFS
# Print results in sorted order
for i in "${sorted_indices[@]}"; do
local status="${all_statuses[$i]}"
local display_info="${all_displays[$i]}"
local date="${all_dates[$i]}"
local file_path="${all_files[$i]}"
if [ "$status" = "expired" ]; then
printf "${COLOR_RED}[Expired]${COLOR_NC} - %-80s ${COLOR_YELLOW}Expiration Date: %s${COLOR_NC}\n" "$display_info" "$date"
# Add to global array for deletion
expired_files+=("$file_path")
expired_file_displays+=("$display_info")
else
printf "${COLOR_GREEN}[ Valid ]${COLOR_NC} - %-80s ${COLOR_YELLOW}Expiration Date: %s${COLOR_NC}\n" "$display_info" "$date"
fi
done
echo
}
# Function: High-level wrapper to scan all configured locations.
function main_scan_logic() {
local currentTimestamp=$(date "+%s")
echo -e "${COLOR_CYAN}==========================================================================================${COLOR_NC}"
echo -e "${COLOR_CYAN}Scanning Provisioning Profiles...${COLOR_NC}"
echo -e "${COLOR_CYAN}Current Date: $(date -j -f "%s" "$currentTimestamp" "+%Y-%m-%d")${COLOR_NC}"
echo -e "${COLOR_CYAN}==========================================================================================${COLOR_NC}"
echo
# Scan the primary system path
scan_and_display_profiles "$HOME/Library/MobileDevice/Provisioning Profiles" "System Profiles (~/Library/MobileDevice/Provisioning Profiles)"
# Scan the Xcode user data path
scan_and_display_profiles "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "Xcode User-Data Profiles (~/Library/Developer/Xcode/UserData/Provisioning Profiles)"
echo -e "${COLOR_CYAN}==========================================================================================${COLOR_NC}"
echo -e "${COLOR_CYAN}Scan Complete.${COLOR_NC}"
}
# Function: Execute the deletion flow.
function perform_delete_flow() {
echo
if [ ${#expired_files[@]} -eq 0 ]; then
echo -e "${COLOR_GREEN}🎉 Congratulations! No expired provisioning profiles were found.${COLOR_NC}"
return
fi
echo -e "${COLOR_YELLOW}⚠️ Starting deletion process...${COLOR_NC}"
echo "The following ${#expired_files[@]} expired profile(s) will be moved to the Trash:"
echo "------------------------------------------------------------------"
for i in "${!expired_files[@]}"; do
echo -e " - ${COLOR_RED}${expired_file_displays[$i]}${COLOR_NC}"
done
echo "------------------------------------------------------------------"
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Moving files to Trash..."
for i in "${!expired_files[@]}"; do
local file_to_move="${expired_files[$i]}"
mv "$file_to_move" ~/.Trash/
echo " -> Moved: ${expired_file_displays[$i]}"
done
echo -e "${COLOR_GREEN}✅ Operation complete! All expired profiles have been moved to the Trash.${COLOR_NC}"
else
echo -e "${COLOR_YELLOW}Operation canceled. No files were changed.${COLOR_NC}"
fi
}
# --- Main Logic ---
# If no arguments are provided, show usage and exit.
if [ $# -eq 0 ]; then
show_usage
exit 0
fi
# Decide what to do based on the first argument.
case "$1" in
-l|--list)
main_scan_logic
;;
-d|--delete)
main_scan_logic
perform_delete_flow
;;
*)
echo -e "${COLOR_RED}Error: Unknown option '$1'${COLOR_NC}"
echo
show_usage
exit 1
;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment