Files
2025-09-29 21:24:20 +03:00

2255 lines
73 KiB
Bash
Executable File

#!/usr/bin/env bash
# Helldivers 2 Mod Manager CLI
VERSION="0.7.0"
# --- Globals ---
RED='\033[0;31m'
GREEN='\033[0;32m'
ORANGE='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
MODS_DIR=""
DB_FILE=""
MODPACKS_DIR=""
MODPACKS_DB_FILE=""
H2CONFIG_DIR="${HOME}/.config/h2mm"
H2PATH_FILE="${H2CONFIG_DIR}/h2path"
API_KEY_FILE="${H2CONFIG_DIR}/api_key"
LAST_CHECKED_UPDATE_FILE="${H2CONFIG_DIR}/last_update"
BACKUPS_DIR="${H2CONFIG_DIR}/backups"
DB_MOD_ID_POS=1
DB_MOD_STATUS_POS=2
DB_MOD_NAME_POS=3
DB_MOD_NEXUS_ID_POS=4
DB_MOD_NEXUS_VERSION_POS=5
DB_MOD_FILES_POS=6
DB_MODPACK_ID_POS=1
DB_MODPACK_STATUS_POS=2
DB_MODPACK_NAME_POS=3
VERSION_URL="https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/version"
breaking_changes_patches=(
["2"]='sed -i "s/^\([0-9]\+\),/\1,ENABLED,/" "$1/mods.csv"'
["3"]='sed -i "1 i\\3" "$1/mods.csv"'
["4"]='tmp_file=$(mktemp) && awk '\''BEGIN {FS=OFS=","} NR==1 {print 4; next} {print NR-1, $2, $3, $4, $5}'\'' "$1/mods.csv" > "$tmp_file" && tee "$1/mods.csv" < "$tmp_file" > /dev/null && rm "$tmp_file"'
["5"]='sed -i "s/^\([0-9]\+\),\(.*\),\(.*\),\(.*\)/\1,\2,\3,,,,\4/" "$1/mods.csv"; sed -i "1 s/4/5/" "$1/mods.csv"'
["6"]='sed -i "s/^\([0-9]\+\),\(.*\),\(.*\),\(.*\),\(.*\),\(.*\),\(.*\)/\1,\2,\3,\4,\6,\7/" "$1/mods.csv"; sed -i "1 s/5/6/" "$1/mods.csv"'
)
# --- Utility Functions ---
function substitute_home() {
echo "${1/#\~/$HOME}"
}
function get_version_major() {
echo "$VERSION" | awk -F. '{print $2}'
}
function get_filename_without_path() {
echo "$1" | awk -F/ '{print $NF}'
}
function get_patch_number() {
echo "$1" | grep -oP '(?<=patch_)\d+'
}
function get_basename() {
get_filename_without_path "$1" | sed -E 's/\.+.*//'
}
function get_extension() {
get_filename_without_path "$1" | sed -E 's/.*patch_[0-9]+//'
}
function get_index_by_entry_from_db() {
echo "$1" | awk -F, -v pos="$DB_MOD_ID_POS" '{print $pos}'
}
function get_name_by_entry_from_db() {
echo "$1" | awk -F, -v pos="$DB_MOD_NAME_POS" '{print $pos}'
}
function get_nexus_mod_id_by_entry_from_db() {
echo "$1" | awk -F, -v pos="$DB_MOD_NEXUS_ID_POS" '{print $pos}'
}
function get_nexus_mod_version_by_entry_from_db() {
echo "$1" | awk -F, -v pos="$DB_MOD_NEXUS_VERSION_POS" '{print $pos}'
}
function get_files_by_entry_from_db() {
echo "$1" | cut -d',' -f"$DB_MOD_FILES_POS"- | tr ',' ' ' | head -1
}
function disable_all_modpacks() {
sed -i 's/ENABLED/DISABLED/' "$MODPACKS_DB_FILE"
}
function get_entry_from_db_by_nexus_mod_id() {
echo "$(awk -F, -v pos="$DB_MOD_NEXUS_ID_POS" -v id="$1" 'NR > 1 && $pos == id {print $0}' "$DB_FILE")"
}
function parse_help() {
display_help="$1"
[[ "$2" == "ARGS" && $# -eq 2 ]] || [[ "$2" == "--help" || "$2" == "-h" ]] && { $display_help; exit 0; }
}
function h() {
# helper function to display help for any command
local type="$1"
shift
case "$type" in
"e") # normal echo
echo -e "$*" >&2
;;
"c") # command mode, printf with padding
printf " %-20s %s\n" "$1" "$2" >&2
;;
"n") # new line
echo >&2
;;
esac
}
function log() {
local type="$1"
shift
case "$type" in
"INFO")
[[ "$silent" == "true" ]] && return
echo -e "$*" >&2
;;
"WARNING")
[[ "$silent" == "true" ]] && return
echo -e "${ORANGE}[!]${NC} $*" >&2
;;
"ERROR")
echo -e "${RED}[ERROR]${NC} $*" >&2
;;
"PROMPT")
echo -ne "$*" >&2
;;
*)
echo -e "$*" >&2
;;
esac
}
# --- Functions ---
function get_nexus_api_key() {
[[ ! -f "$API_KEY_FILE" ]] && { log ERROR "Nexus API key file not found. Please run h2mm nexus-setup."; exit 1; }
api_key=$(cat "$API_KEY_FILE")
[[ -z "$api_key" ]] && { log ERROR "Nexus API key is empty. Please run h2mm nexus-setup."; exit 1; }
}
function remove_disabled_prefix() {
local disabled_file="$1"
if [[ "$disabled_file" =~ ^disabled_[0-9]+_[A-Za-z0-9]+ ]]; then
# new format: disabled_<timestamp>_<filename>
disabled_file=$(echo "$disabled_file" | sed -E 's/^disabled_[0-9]+_//')
else
# old format: disabled_<filename>
while [[ "$disabled_file" == disabled_* ]]; do
disabled_file=$(echo "$disabled_file" | sed 's/^disabled_//')
done
fi
echo "$disabled_file"
}
function parse_indexes() {
_returned_indexes=()
while [[ $# -gt 0 ]]; do
case "$1" in
"--range")
[[ -z "$2" ]] && { log ERROR "Function parse indexes: range is required"; exit 1; }
range="$2"; shift 2
;;
*)
input_indexes+=("$1")
shift
;;
esac
done
# parse input_indexes 1-by-1
for index in "${input_indexes[@]}"; do
# normal index 5
if [[ "$index" =~ ^[0-9]+$ ]]; then
[[ $index -lt 1 || $index -gt $range ]] && { log ERROR "Function parse indexes: index $index out of range (1 -> $range)"; exit 1; }
_returned_indexes+=("$index")
# range index 1..5
elif [[ "$index" =~ ^[0-9]+\.\.[0-9]+$ ]]; then
# cut doesn't work with a delimiter of two characters
start=$(echo "$index" | cut -d. -f1)
end=$(echo "$index" | cut -d. -f3)
[[ $start -lt 1 || $end -lt 1 || $start -gt $range || $end -gt $range ]] && { log ERROR "Function parse indexes: range $index out of range (1 -> $range)"; exit 1; }
if [[ $start -le $end ]]; then
for ((i=start; i<=end; i++)); do
_returned_indexes+=("$i")
done
else
for ((i=start; i>=end; i--)); do
_returned_indexes+=("$i")
done
fi
# exclude index -5
elif [[ "$index" =~ ^-[0-9]+$ ]]; then
index=${index#-} # remove the - sign
# if _returned_indexes is empty, populate it with every number from 1 to range
[[ ${#_returned_indexes[@]} -eq 0 ]] && for ((i=1; i<=range; i++)); do _returned_indexes+=("$i"); done
[[ $index -lt 1 || $index -gt $range ]] && { log ERROR "Function parse indexes: index $index out of range (1 -> $range)"; exit 1; }
_returned_indexes=($(printf "%s\n" "${_returned_indexes[@]}" | grep -vx "$index"))
# exclusion range -3..5
elif [[ "$index" =~ ^-[0-9]+\.\.[0-9]+$ ]]; then
index=${index#-} # remove the - sign
# if _returned_indexes is empty, populate it with every number from 1 to range
[[ ${#_returned_indexes[@]} -eq 0 ]] && for ((i=1; i<=range; i++)); do _returned_indexes+=("$i"); done
start=$(echo "$index" | cut -d. -f1)
end=$(echo "$index" | cut -d. -f3)
[[ $start -lt 1 || $end -lt 1 || $start -gt $range || $end -gt $range ]] && { log ERROR "Function parse indexes: exclusion range $index out of range (1 -> $range)"; exit 1; }
if [[ $start -le $end ]]; then
for ((i=start; i<=end; i++)); do
_returned_indexes=($(printf "%s\n" "${_returned_indexes[@]}" | grep -vx "$i"))
log INFO i
done
else
for ((i=start; i>=end; i--)); do
_returned_indexes=($(printf "%s\n" "${_returned_indexes[@]}" | grep -vx "$i"))
done
fi
else
log ERROR "Function parse indexes: invalid index $index"
exit 1
fi
done
log INFO "Parsed indexes: ${_returned_indexes[*]}"
}
function get_mod_name_and_index() {
# if calling install multiple times in the same call of h2mm, the mod_index needs to be reset if it was -1
[[ $mod_index -eq -1 ]] && unset mod_index
if [[ -n "$mod_index" ]]; then # if mod index exists
entry=$(grep "^${mod_index}," "$DB_FILE")
mod_name=$(get_name_by_entry_from_db "$entry")
elif [[ -n "$mod_name" ]]; then # if mod name exists
entry=$(grep -F ",$mod_name," "$DB_FILE")
mod_index=$(echo "$entry" | awk -F, -v pos="$DB_MOD_ID_POS" '{print $pos}' | head -1)
fi
if [[ -z "$entry" || -z "$mod_index" || -z "$mod_name" ]]; then
[[ "$1" != "--do-not-exit" ]] && { log ERROR "Mod not found."; exit 1; }
mod_index=-1
fi
status=$(echo "$entry" | awk -F, -v pos="$DB_MOD_STATUS_POS" '{print $pos}')
}
function get_modpack_name_and_index() {
if [[ -n "$modpack_index" ]]; then # if modpack index exists
entry=$(grep "^${modpack_index}," "$MODPACKS_DB_FILE")
modpack_name=$(get_name_by_entry_from_db "$entry")
elif [[ -n "$modpack_name" ]]; then # if modpack name exists
entry=$(grep ",$modpack_name$" "$MODPACKS_DB_FILE")
modpack_index=$(echo "$entry" | awk -F, -v pos="$DB_MOD_ID_POS" '{print $pos}' | head -1)
fi
[[ -z "$entry" || -z "$modpack_index" || -z "$modpack_name" ]] && { log ERROR "Modpack not found."; exit 1; }
}
function find_game_directory() {
local search_dir="${HOME}"
local target_dir="steamapps/common/Helldivers\ 2/data"
# check if path is saved
saved_dir=$(cat "$H2PATH_FILE")
[[ -d "$saved_dir" ]] && { echo "$saved_dir"; return; }
# first time setup, or directory is not valid anymore
log INFO "Searching for the Helldivers 2 data directory... (10 seconds timeout)"
game_dir=$(timeout 10 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1)
if [[ -z "$game_dir" ]]; then
# if not found, ask user for the directory
log INFO "Could not find the Helldivers 2 data directory automatically."
log PROMPT "Please enter the ABSOLUTE path to the Helldivers 2 data directory: "
IFS= read -e game_dir; unset IFS
game_dir="$(substitute_home "$game_dir")" # replace ~ with $HOME
game_dir="${game_dir%/}" # remove last / if it exists
[[ ! -d "$game_dir" ]] && { log ERROR "Provided path is not a valid directory."; exit 1; }
else
# confirm with the user that the directory is ok
log INFO "Found Helldivers 2 data directory: $game_dir"
log PROMPT "Is this the correct directory? (Y/n): "
read confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" && "$confirm" != "" ]]; then
log PROMPT "Please enter the path to the Helldivers 2 data directory: "
IFS= read -e game_dir; unset IFS
game_dir="$(substitute_home "$game_dir")"
[[ ! -d "$game_dir" ]] && { log ERROR "Provided path is not a valid directory."; exit 1; }
fi
fi
# save path
echo "$game_dir" > "$H2PATH_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not save game directory."; exit 1; }
log INFO "Game directory ${GREEN}saved${NC}: $game_dir"
# return the directory
echo "$game_dir"
}
function initialize_config_directory() {
mkdir -p "$H2CONFIG_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not create config directory ($H2CONFIG_DIR)."; exit 1; }
mkdir -p "$BACKUPS_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not create backups directory ($BACKUPS_DIR)."; exit 1; }
if [[ ! -f "$H2PATH_FILE" ]]; then
touch "$H2PATH_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not create path file."; exit 1; }
fi
if [[ ! -f "$LAST_CHECKED_UPDATE_FILE" ]]; then
touch "$LAST_CHECKED_UPDATE_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not create last checked update file."; exit 1; }
fi
}
function initialize_directories() {
initialize_config_directory
MODS_DIR=$(find_game_directory)
DB_FILE="$MODS_DIR/mods.csv"
if [[ ! -f "$DB_FILE" ]]; then
touch "$DB_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not create database file."; exit 1; }
echo "$(get_version_major)" > "$DB_FILE"
log INFO "Database file ${GREEN}created${NC}: $DB_FILE"
fi
}
function initialize_modpack_directories() {
MODPACKS_DIR="$MODS_DIR/modpacks"
MODPACKS_DB_FILE="$MODPACKS_DIR/modpacks.csv"
if [[ ! -d "$MODPACKS_DIR" || ! -f "$MODPACKS_DB_FILE" ]]; then
mkdir -p "$MODPACKS_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not create modpacks directory."; exit 1; }
touch "$MODPACKS_DB_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not create modpacks database file."; exit 1; }
echo "$(get_version_major)" > "$MODPACKS_DB_FILE"
log INFO "Modpacks directory and file ${GREEN}created${NC}: $MODPACKS_DB_FILE"
fi
}
# --- Help Functions ---
function display_help_main() {
h e "Helldivers 2 Mod Manager CLI v$VERSION"
h n
h e "Usage:"
h e " h2mm [OPTIONS] COMMAND [ARGS]"
h n
h e "Options"
h c "-h, --help" "Show this help message for any command and exit."
h n
h e "Commands:"
h e " # Mod management"
h c "i, install" "Install a mod with any combination of mod files, directories, and zip files"
h c "u, uninstall" "Uninstall a mod"
h c "l, list" "List all installed mods and their status"
h c "e, enable" "Enable a mod"
h c "d, disable" "Disable a mod"
h c "r, rename" "Rename a mod"
h c "o, order" "Change load order of a mod"
h n
h e " # Bulk management"
h c "m, modpack" "Manage user-defined modpacks (collections of installed mods)"
h c "rs, reset" "Reset all installed mods"
h c "ex, export" "Export installed mods to an archive"
h c "im, import" "Import mods from an archive"
h n
h e " # Others"
h c "ns, nexus-setup" "Setup Nexus Mods integration"
h c "up, update" "Check for updates and update h2mm"
h c "h, help" "Show this help message and exit"
h n
h e "Examples:"
h e " h2mm install --help"
h e " h2mm install ~/Downloads/mod.zip"
h e " h2mm install ~/Downloads/mod\ files/"
h e " h2mm install a0b1c2d3.patch_0 a0b1c2d3.patch_0.stream -n \"Example mod\""
h e " h2mm list"
h e " h2mm uninstall -i 3"
h e " h2mm modpack create \"Example modpack\""
h e " h2mm modpack switch \"Example modpack\""
}
function display_help_install() {
h e "Usage:"
h e " h2mm install [OPTIONS] <FILES...>"
h n
h e "Description:"
h e " Install one or more mods from files, directories, or archives."
h e " Supports .zip, .rar, and .7z archives (requires 'unzip' or 'unar')."
h e " It is recommended to be in the directory where mod archives are, or to use absolute paths."
h e " Use 'cd ~/Downloads' to go to the Downloads folder, and run 'ls -la' to find the archives to install."
h e " Use the Tab key to auto-complete file and folder names, this helps escape spaces and special characters."
h n
h e "Options:"
h c "-n <NAME>" "Specify a custom mod name (defaults to archive/folder name)"
h n
h e "Examples:"
h e " h2mm install ~/Downloads/mod.zip"
h e " h2mm install ~/Downloads/mod\ files/"
h e " h2mm install a0b1c2d3.patch_0 a0b1c2d3.patch_0.stream -n \"Example mod\""
}
function display_help_uninstall() {
h e "Usage:"
h e " h2mm uninstall [OPTIONS]"
h n
h e "Description:"
h e " Uninstall a previously installed mod by name or index."
h e " Removes mod files, updates the database, and downgrades mods if necessary."
h n
h e "Options:"
h c "-i, --index <INDEX>" "Uninstall mod by index (see 'h2mm list')"
h c "-n, --name <NAME>" "Uninstall mod by name"
h n
h e "Examples:"
h e " h2mm uninstall -i 3"
h e " h2mm uninstall -n \"Example Mod\""
}
function display_help_enable() {
h e "Usage:"
h e " h2mm enable [OPTIONS]"
h n
h e "Description:"
h e " Enable a previously installed mod by name or index."
h e " This moves mod files from disabled state to active and preserves their order."
h n
h e "Options:"
h c "-i, --index <INDEX>" "Enable mod by index (see 'h2mm list')"
h c "-n, --name <NAME>" "Enable mod by name"
h n
h e "Examples:"
h e " h2mm enable -i 3"
h e " h2mm enable -n \"Example mod\""
}
function display_help_disable() {
h e "Usage:"
h e " h2mm disable [OPTIONS]"
h n
h e "Description:"
h e " Disable a previously enabled mod by name or index."
h e " This renames mod files to mark them as disabled and updates the database."
h n
h e "Options:"
h c "-i, --index <INDEX>" "Disable mod by index (see 'h2mm list')"
h c "-n, --name <NAME>" "Disable mod by name"
h n
h e "Examples:"
h e " h2mm disable -i 3"
h e " h2mm disable -n \"Example mod\""
}
function display_help_list() {
h e "Usage:"
h e " h2mm list [OPTIONS]"
h n
h e "Description:"
h e " List all installed mods, showing their status, type (LOCAL or NEXUS),"
h e " and optionally more details in verbose mode."
h e " The database of installed mods is stored in the game directory under data/mods.csv"
h n
h e "Options:"
h c "-v, --verbose" "Show detailed information including mod files and Nexus IDs"
}
function display_help_reset() {
h e "Usage:"
h e " h2mm reset [OPTIONS]"
h n
h e "Description:"
h e " Reset all installed mods by deleting their files and clearing the database."
h e " Does not reset modpacks, use 'h2mm modpack reset' for that."
h e " This operation is irreversible, use with caution."
h n
h e "Options:"
h c "--no-path-reset" "Do not reset the H2 path file when resetting mods"
}
function display_help_export() {
h e "Usage:"
h e " h2mm export [OPTIONS]"
h n
h e "Description:"
h e " Export all installed mods to a compressed archive."
h e " The archive is saved to the backups directory (\$HOME/.h2mm/backups)."
}
function display_help_import() {
h e "Usage:"
h e " h2mm import [OPTIONS] <ARCHIVE_FILE>"
h n
h e "Description:"
h e " Import mods from a compressed archive."
h e " Importing resets existing mods before applying the archive, modpacks are preserved."
h n
h e "Examples:"
h e " h2mm import ~/.h2mm/config/backups/HD2-Mods-2025-12-12_15-00-00.tar.gz"
}
function display_help_order() {
h e "Usage:"
h e " h2mm order [OPTIONS] <NEW_INDEX>"
h n
h e "Description:"
h e " Change the load order of an installed mod."
h e " Mods can be reindexed by name or index to ensure correct load priority."
h n
h e "Options:"
h c "-i, --index <INDEX>" "Specify the mod by its current index (see 'h2mm list')"
h c "-n, --name <NAME>" "Specify the mod by name"
h n
h e "Arguments:"
h c "<NEW_INDEX>" "The new load order index for the mod"
h n
h e "Examples:"
h e " h2mm order -i 3 1"
h e " h2mm order -n \"Example mod\" 2"
}
function display_help_rename() {
h e "Usage:"
h e " h2mm rename [OPTIONS] <NEW_NAME>"
h n
h e "Description:"
h e " Rename an installed mod."
h n
h e "Options:"
h c "-i, --index <INDEX>" "Specify the mod by its current index (see 'h2mm list')"
h c "-n, --name <NAME>" "Specify the mod by current name"
h n
h e "Arguments:"
h c "<NEW_NAME>" "The new name for the mod"
h n
h e "Examples:"
h e " h2mm rename -i 3 \"New mod name\""
h e " h2mm rename -n \"Old mod name\" \"New mod name\""
}
function display_help_modpack() {
h e "Usage:"
h e " h2mm modpack COMMAND [ARGS]"
h n
h e "Description:"
h e " Manage user-defined modpacks (collections of installed mods)."
h n
h e "Commands:"
h c "list, l" "List all installed modpacks"
h c "create, c" "Create a new modpack from currently installed mods"
h c "delete, d" "Delete a modpack"
h c "overwrite, o" "Overwrite an existing modpack"
h c "switch, s" "Switch to a different modpack"
h c "reset, rs" "Reset all installed modpacks"
h c "help, h" "Show this help message and exit"
h n
h e "Examples:"
h e " h2mm modpack list"
h e " h2mm modpack create \"Example modpack\""
h e " h2mm modpack switch -n \"Example modpack\""
}
function display_help_modpack_list() {
h e "Usage:"
h e " h2mm modpack list [OPTIONS]"
h n
h e "Description:"
h e " List all saved modpacks."
h e " Database of modpacks is stored in the game directory under data/modpacks/modpack.csv"
h n
h e "Options:"
h c "-v, --verbose" "Show detailed contents of each modpack"
}
function display_help_modpack_create() {
h e "Usage:"
h e " h2mm modpack create -n <MODPACK_NAME>"
h n
h e "Description:"
h e " Create a new modpack by selecting installed mods."
h n
h e "Options:"
h c "-n <MODPACK_NAME>" "Name of the modpack to create (required)"
h n
h e "Examples:"
h e " h2mm modpack create -n \"Example modpack\""
}
function display_help_modpack_switch() {
h e "Usage:"
h e " h2mm modpack switch [OPTIONS]"
h n
h e "Description:"
h e " Switch to a saved modpack. This will reset the current mods and apply the selected modpack."
h n
h e "Options:"
h c "-i, --index <MODPACK_INDEX>" "Index of the modpack to switch to"
h c "-n, --name <MODPACK_NAME>" "Name of the modpack to switch to"
h n
h e "Examples:"
h e " h2mm modpack switch -i 2"
h e " h2mm modpack switch -n \"Example modpack\""
}
function display_help_modpack_reset() {
h e "Usage:"
h e " h2mm modpack reset"
h n
h e "Description:"
h e " Reset all saved modpacks. This will delete all modpack archives and the modpack database."
}
function display_help_modpack_delete() {
h e "Usage:"
h e " h2mm modpack delete [OPTIONS]"
h n
h e "Description:"
h e " Delete a saved modpack by name or index. This will remove the modpack archive and its database entry."
h n
h e "Options:"
h e " -i, --index <modpack_index> Index of the modpack to delete."
h e " -n, --name <modpack_name> Name of the modpack to delete."
h n
h e "Examples:"
h e " h2mm modpack delete -i 2"
h e " h2mm modpack delete -n \"Example modpack\""
}
function display_help_modpack_overwrite() {
h e "Usage:"
h e " h2mm modpack overwrite [OPTIONS]"
h n
h e "Description:"
h e " Overwrite an existing modpack with the currently installed mods."
h n
h e "Options:"
h c "-n, --name <MODPACK_NAME>" "Name of the modpack to overwrite"
h c "-i, --index <MODPACK_INDEX>" "Index of the modpack to overwrite"
h n
h e "Examples:"
h e " h2mm modpack overwrite -n \"Example modpack\""
h e " h2mm modpack overwrite -i 2"
}
function display_help_nexus_setup() {
h e "Usage:"
h e " h2mm nexus-setup"
h n
h e "Description:"
h e " Run the setup wizard to configure Nexus Mods integration for h2mm."
h e " You will need to provide your Nexus Mods API key and configure a terminal for the desktop entry."
h e " This command creates a file containing your API key at ~/.config/h2mm/apikey."
h e " This command creates a desktop entry at ~/.local/share/applications/h2mm.desktop."
h e " If you change your API key or want to change the terminal, run this command again."
}
# --- Main Functions ---
function downgrade_mods() {
local files="$1"
declare -A downgrades_versions
declare -A downgrades_to_apply
for file in $files; do
# save the basename for the files that were deleted into a hash table, so we can downgrade mods with greater version number
# also depending on how many patches the mod has, we need to downgrade with more versions
current_version=$(get_patch_number "$file")
base_name=$(get_basename "$file")
downgrades_versions["$base_name"]=$current_version
# count how many unique patches the mod has
[[ -z "${downgrades_to_apply["$base_name"]+unset}" ]] && downgrades_to_apply["$base_name"]=$(echo $files | tr ' ' '\n' | grep "$base_name" | sed -E "s/(.*_[0-9]+).*/\1/" | sort -u | wc -l)
done
# downgrade any necessary mods - it takes ~20 minutes to re-understand this code so I'm writing a comment here
# for each base name, find all files that have the same base name, and are greater than the current version, and downgrade them
# the downgrades_versions hash table stores the current version, so we can compare it with the version of the files
# the downgrades_to_apply hash table stores the number of patches the mod has, so we can downgrade with more versions
# for example, if we have:
# mod 1: AAA.patch_0 AAA.patch_0.stream AAA.patch_1 AAA.patch_1.stream
# mod 2: AAA.patch_2 AAA.patch_2.stream AAA.patch_3 AAA.patch_3.stream
# mod 3: AAA.patch_4 AAA.patch_4.stream AAA.patch_5 AAA.patch_5.stream
# if we remove mod with index 2, we need to downgrade mod with index 3 (which has patch version 4 and 5 > 3 (biggest last patch removed)) by 2 versions,
# the number 2 we get by counting the number of unique base names (without extensions like .stream, but with the .patch_[0-9]) in the files
for base_name in "${!downgrades_to_apply[@]}"; do
# find all files that have the same base name, and are greater than the current version, and downgrade them
IFS=$'\n' mods_to_downgrade=($(ls "$MODS_DIR/$base_name"*.patch_* 2>/dev/null | sort -V)); unset IFS
for mod in "${mods_to_downgrade[@]}"; do
mod=$(get_filename_without_path "$mod")
patch_version=$(get_patch_number "$mod")
if [[ $patch_version -gt ${downgrades_versions[$base_name]} ]]; then
new_version=$((patch_version - downgrades_to_apply["$base_name"]))
extension=$(get_extension "$mod")
new_patch="${base_name}.patch_${new_version}${extension}"
mv "$MODS_DIR/$mod" "$MODS_DIR/$new_patch"
[[ $? -ne 0 ]] && { log ERROR "Could not downgrade mod file $mod."; exit 1; }
log INFO "Downgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}."
# save changes in database as well
sed -i "s/\b$mod\b/$new_patch/" "$DB_FILE"
fi
done
done
}
function mod_enable() {
parse_help display_help_enable ARGS "$@"
local mod_name=""
local mod_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
mod_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
[[ -z "$mod_name" && -z "$mod_index" ]] && { log ERROR "Mod name or index is required to enable."; exit 1; }
# find mod files
get_mod_name_and_index
[[ "$status" == "ENABLED" ]] && { log ERROR "Mod $mod_name is already enabled."; exit 1; }
# in case of nexus mod
nexus_mod_id=$(get_nexus_mod_id_by_entry_from_db "$entry")
nexus_mod_version=$(get_nexus_mod_version_by_entry_from_db "$entry")
current_mod_files=$(get_files_by_entry_from_db "$entry")
old_index="$mod_index"
mod_index=-1 # we will re-order the mod after installation, so we need to reset mod_index
# move files to a temp directory that has the same name as the mod
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT
[[ ! -d "$temp_dir" ]] && { log ERROR "Could not create temporary directory."; exit 1; }
for file in $current_mod_files; do
[[ ! -f "$MODS_DIR/$file" ]] && { log ERROR "Mod file $file does not exist."; exit 1; }
new_file=$(remove_disabled_prefix "$file")
cp "$MODS_DIR/$file" "$temp_dir/$new_file"
[[ $? -ne 0 ]] && { log ERROR "Could not move mod file $file to temporary directory."; exit 1; }
done
# run install function to install the mod files
silent=true # disable logging for this process
# uninstall mod
mod_uninstall -i "$old_index"
if [[ -n "$nexus_mod_id" && -n "$nexus_mod_version" ]]; then
mod_install "$temp_dir"/* -n "$mod_name" --mod-id "$nexus_mod_id" --version "$nexus_mod_version"
else
mod_install "$temp_dir"/* -n "$mod_name"
fi
# order mod back to original place
if [[ $next_id -ne $old_index ]]; then
mod_order -i "$next_id" "$old_index"
fi
silent=false # re-enable logging
log INFO "Mod ${GREEN}successfully${NC} enabled: $mod_name."
disable_all_modpacks
}
function mod_disable() {
parse_help display_help_disable ARGS "$@"
local mod_name=""
local mod_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
mod_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
if [[ -z "$mod_name" && -z "$mod_index" ]]; then
log ERROR "Mod name or index is required to disable."
exit 1
fi
# find mod files
get_mod_name_and_index
if [[ "$status" == "DISABLED" ]]; then
log ERROR "Mod $mod_name is already disabled."
exit 1
fi
hash=$(date +%s%3N) # generate a unique hash for files
# disable each mod file by adding disabled_ to the start of the filename
current_mod_files=$(get_files_by_entry_from_db "$entry")
for file in $current_mod_files; do
[[ ! -f "$MODS_DIR/$file" ]] && { log ERROR "Mod file $file does not exist."; exit 1; }
disabled_file="disabled_${hash}_${file}"
mv "$MODS_DIR/$file" "$MODS_DIR/$disabled_file"
[[ $? -ne 0 ]] && { log ERROR "Could not disable mod file $file."; exit 1; }
log INFO "Disabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$disabled_file${NC})."
# save change to db
sed -i "/^$mod_index,/ s/\b$file\b/$disabled_file/" "$DB_FILE"
done
# downgrade mods with greater version number
downgrade_mods "$current_mod_files"
# update the database
sed -i "/^$mod_index,/s/ENABLED/DISABLED/" "$DB_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not disable mod."; exit 1; }
log INFO "Mod ${GREEN}successfully${NC} disabled: $mod_name."
disable_all_modpacks
}
function mod_reset() {
parse_help display_help_reset "$@"
local no_path_reset=false; [[ "$1" == "--no-path-reset" ]] && no_path_reset=true
log PROMPT "Are you sure you want to ${RED}reset${NC} all installed mods? (Y/n): "
read confirm
if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then
rm -f "$MODS_DIR"/*.patch_*
rm -f "$DB_FILE"
[[ $no_path_reset == false ]] && rm -f "$H2PATH_FILE"
log INFO "Mods ${GREEN}successfully${NC} reset."
else
log INFO "Reset cancelled."
exit 0
fi
}
function mod_install() {
parse_help display_help_install ARGS "$@"
local mod_name=""
local mod_dir=()
local mod_files=()
local mod_zip=()
local nexus_mod_version=""
local nexus_mod_id=""
local is_not_zip=false
local has_nexus_mod_arguments=false
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
"--version")
[[ -z "$2" ]] && { log ERROR "Nexus mod version is required."; exit 1; }
nexus_mod_version="$2"; shift 2
;;
"--mod-id")
[[ -z "$2" ]] && { log ERROR "Nexus mod ID is required."; exit 1; }
nexus_mod_id="$2"; shift 2
;;
*)
if [[ "$1" == *.zip || "$1" == *.rar || "$1" == *.7z ]]; then
mod_zip+=("$1")
[[ "$1" != *.zip ]] && is_not_zip=true
elif [[ -d "$1" ]]; then
mod_dir+=("$1")
else
mod_files+=("$1")
fi
shift
;;
esac
done
# check if nexus mod arguments are provided
if [[ -n "$nexus_mod_id" || -n "$nexus_mod_version" ]]; then
if [[ -z "$nexus_mod_id" || -z "$nexus_mod_version" ]]; then
log ERROR "Nexus mod ID, file ID and version are required when specifing at least one of them."
exit 1
fi
has_nexus_mod_arguments=true
fi
# edge case when there is a combination of mod zips and directories, call function for all zips and dirs
if [[ ${mod_zip} && ${mod_dir} ]]; then
mod_install "${mod_zip[@]}"
mod_install "${mod_dir[@]}"
# reset arrays
mod_zip=()
mod_dir=()
# if there are no more arguments, exit
[[ ${#mod_files[@]} -eq 0 ]] && exit 0
fi
# if there's more than 1 zip, call recursively
while [[ ${#mod_zip[@]} -gt 1 ]]; do
mod_install "${mod_zip[0]}"
mod_zip=("${mod_zip[@]:1}")
done
# if zip, extract the zip file and pass it to mod dirs
if [[ -n "$mod_zip" ]]; then
if [[ $is_not_zip == true ]]; then
command -v unar &> /dev/null || { log ERROR "Archive in 7z/rar format could not be extracted because package \"unarchiver\" is not installed."; exit 1; }
else
command -v unzip &> /dev/null || { log ERROR "Archive in zip format could not be extracted because package \"unzip\" is not installed."; exit 1; }
fi
if [[ ! -f "$mod_zip" ]]; then
log ERROR "File $mod_zip does not exist."
log INFO "Are you sure the file exists? Check with 'ls -l'."
log INFO "If yes, check if it's written correctly, you must escape special characters like spaces and quotes."
log INFO "Simplest way to do this is to type a few letters and then press Tab to auto-complete the name."
exit 1
fi
# if the name is not specified, use the name of the directory, last sed for making nexusmods names not have numbers
if [[ -z "$mod_name" ]]; then
mod_name=$(get_filename_without_path "$mod_zip" | sed -E 's/\.zip//' | sed -E 's/\.rar//' | sed -E 's/-[0-9]+-[^a-zA-Z]*-[0-9]+$//')
fi
# mod_dir as a temporary directory
temp_folder=$(mktemp -d)
trap 'rm -rf "$temp_folder"' EXIT
mod_dir+=("$temp_folder")
if [[ $is_not_zip == true ]]; then
unar -q "$mod_zip" -o "$mod_dir"
else
unzip -qq "$mod_zip" -d "$mod_dir"
fi
fi
if [[ $has_nexus_mod_arguments == true ]]; then
# overwrite trap in case of nexus mod
trap 'read -p "Press Enter to continue..."; rm -rf "$output_folder"; rm -rf "$temp_folder"' EXIT
fi
# if there's more than 1 directory, call recursively
while [[ ${#mod_dir[@]} -gt 1 ]]; do
mod_install "${mod_dir[0]}"
mod_dir=("${mod_dir[@]:1}")
done
# directory containing mod files
if [[ -n "$mod_dir" ]]; then
# remove trailing slash if it exists
mod_dir="${mod_dir%/}"
# verify directory exists
[[ ! -d "$mod_dir" ]] && { log ERROR "Directory $mod_dir does not exist."; exit 1; }
# read every file from the directory
readarray -d '' mod_files < <(find "$mod_dir" -type f -name "*.patch_*" -print0)
# if the name is not specified, use the name of the directory, last sed for making nexusmods names not have numbers
if [[ -z "$mod_name" ]]; then
mod_name=$(echo "$mod_dir" | sed 's:/*$::' | awk -F/ '{print $NF}' | sed -E 's/-[0-9]+-.*//')
fi
# check for mod variants and handle
# if it's a tmp directory in the format /tmp/tmp.*/, we need to go mindepth 1 to avoid listing the tmp dir itself
[[ "$mod_dir" == /tmp/tmp.* ]] && mindepth=1 || mindepth=0
# if the mod directory contains more than 1 directory, it means there are multiple variants for the mod
# prompt the user to choose which variant to install, or install multiple
readarray -d '' all_dirs < <(find "$mod_dir" -mindepth $mindepth -type d -print0)
# filter so that we only have dirs that have *.patch_* files inside them
filtered_dirs=()
for dir in "${all_dirs[@]}"; do
if find "$dir" -maxdepth 1 -type f -name "*.patch_*" -print -quit | grep -q .; then
filtered_dirs+=("$dir")
fi
done
if [[ ${#filtered_dirs[@]} -gt 1 ]]; then
log INFO "Multiple mod variants found for mod ${mod_name}:"
# print the variant name by display all the directories (and how they're nested)
# first, take the basename of the directory/zip we're installing from
# then, make the variant name by removing the tmp dir name from the path (if it's a zip)
# finally, take out this name, if it exists
# test if directory has only 1 directory inside it, common for zips, so we want to remove that from each variant name
test_zip_dir=$(find "$mod_dir" -mindepth 1 -maxdepth 1 -type d)
contains_zip_dir=false
if [[ $(echo "$test_zip_dir" | wc -l) -eq 1 ]]; then
contains_zip_dir=true
test_zip_dir="${test_zip_dir#$mod_dir/}"
fi
for i in "${!filtered_dirs[@]}"; do
variant_name="${filtered_dirs[$i]}"
declare -A variants
# if mod_dir contains /tmp/tmp.*/* then remove the /tmp/tmp.*/ part and leave the rest, else just remove the mod_dir part
variant_name="${variant_name#$mod_dir/}"
[[ $contains_zip_dir == true ]] && variant_name="${variant_name#*/}"
if [[ "$variant_name" == "$test_zip_dir" ]]; then
variant_name="${variant_name} [base folder]"
variants[0]="base"
else
variants[$i]="$variant_name"
fi
log INFO "$((i + 1)). $variant_name"
done
# prompt user to choose
log INFO ""
log INFO "Use spaces to separate multiple variants (1 2 4) or use ranges (2..5) or use exclusions (-3) or any combination of them (1 3..5 7..12 -10)."
log INFO "When only exclusions are present (-3) it means all variants except the excluded ones. You can also use exclusion ranges (-3..5)."
log PROMPT "Enter the combination of variants to install (or press Enter to install all variants): "
read -a variant_indices
if [[ -n "${variant_indices[0]}" ]]; then
# clear mod_files
mod_files=()
# use this variable for formatting the mod name
has_atleast_one_variant=false
parse_indexes --range ${#filtered_dirs[@]} "${variant_indices[@]}"
# get the files from the chosen variant
for index in "${_returned_indexes[@]}"; do
[[ -z "$index" ]] && continue
# read the files from the directory
readarray -d '' variant_files < <(find "${filtered_dirs[$((index - 1))]}" -maxdepth 1 -type f -name "*.patch_*" -print0)
# update mod_name to contain the variant name
if [[ $has_atleast_one_variant == false ]]; then
mod_name="${mod_name} [${variants[$((index - 1))]}"
has_atleast_one_variant=true
else
mod_name="${mod_name} - ${variants[$((index - 1))]}"
fi
# add the files to the mod_files array
mod_files+=("${variant_files[@]}")
done
[[ $has_atleast_one_variant == true ]] && mod_name="${mod_name}]"
fi
fi
fi
# verify minimum information required
[[ ${#mod_files[@]} -eq 0 ]] && { log ERROR "No mod files found."; exit 1; }
[[ -z "$mod_name" ]] && { log ERROR "Mod name is required."; exit 1; }
# verify mod files exist
for file in "${mod_files[@]}"; do
[[ ! -f "$file" ]] && { log ERROR "Mod file $file does not exist."; exit 1; }
done
# sanitize mod name so it doesn't contain commas or underscores
mod_name=$(echo "$mod_name" | sed 's/,//g' | sed 's/_/ /g')
# verify duplicate mod names
if [[ $has_nexus_mod_arguments == false ]]; then
get_mod_name_and_index --do-not-exit
[[ $mod_index -ne -1 ]] && { log ERROR "The mod '$mod_name' is already installed."; exit 1; }
fi
# store the target files so we can put them in the database later
target_files=()
# sort the mod files because with the below logic, the .stream and .gpu_resources files need to come after their respective patch files
IFS=$'\n' mod_files=($(printf "%s\n" "${mod_files[@]}" | sort -t. -k1,1 -k2,2n)); unset IFS
for file in "${mod_files[@]}"; do
base_name=$(get_basename "$file")
extension=$(get_extension "$file")
# count already installed patches
count=$(ls "$MODS_DIR/${base_name}.patch_"* 2>/dev/null | grep -E '([0-9]+$)' 2>/dev/null | wc -l)
# if the file has an extension, look for the last patch number and subtract 1, otherwise, the count will be wrong
target_file="${base_name}.patch_"
if [[ -n "$extension" ]]; then
target_file="${target_file}$(($count - 1))${extension}"
else
target_file="${target_file}${count}"
fi
target_files+=($target_file)
cp "$file" "$MODS_DIR/$target_file"
# verify installation worked
[[ $? -ne 0 ]] && { log ERROR "Could not install mod file $file."; exit 1; }
log INFO "Mod file ${ORANGE}$(echo "$file" | sed -E 's/\/tmp\/tmp.[a-zA-Z0-9]+\///')${NC} installed at ${GREEN}\$MODS_DIR/$target_file${NC}."
done
# add entry to database
next_id=$(awk -F, -v pos="$DB_MOD_ID_POS" 'NR > 1 {last_id = $pos} END {print last_id + 1}' "$DB_FILE")
entry="$next_id,ENABLED,$mod_name,$nexus_mod_id,$nexus_mod_version,${target_files[*]}"
echo "$entry" >> "$DB_FILE"
log INFO "Mod ${GREEN}successfully${NC} installed: $mod_name."
# disable any modpack
disable_all_modpacks
}
function mod_uninstall() {
parse_help display_help_uninstall ARGS "$@"
local mod_name=""
local mod_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
mod_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
[[ -z "$mod_name" && -z "$mod_index" ]] && { log ERROR "Mod name or index is required to uninstall."; exit 1; }
# find mod files
get_mod_name_and_index
# delete mod files
current_mod_files=$(get_files_by_entry_from_db "$entry")
for file in $current_mod_files; do
[[ ! -f "$MODS_DIR/$file" ]] && { log ERROR "Mod file $file does not exist."; exit 1; }
log INFO "Removing ${ORANGE}\$MODS_DIR/$file${NC}."
rm "$MODS_DIR/$file"
[[ $? -ne 0 ]] && { log ERROR "Could not remove mod file $file."; exit 1; }
done
# downgrade mods with greater version number, only if the mod is enabled
[[ "$status" == "ENABLED" ]] && downgrade_mods "$current_mod_files"
# remove entry from database
sed -i "/^$mod_index,/d" "$DB_FILE"
# reindex the database
mods_to_reindex=($(awk -F, -v pos="$DB_MOD_ID_POS" -v idx=$mod_index 'NR > 1 && $pos > idx {print $pos}' "$DB_FILE"))
for idx in "${mods_to_reindex[@]}"; do
sed -i "s/^$idx,/$(($idx - 1)),/" "$DB_FILE"
done
log INFO "Mod ${GREEN}successfully${NC} uninstalled: $mod_name."
# disable any modpack
disable_all_modpacks
}
function mod_list() {
parse_help display_help_list "$@"
local verbose=false
# parse arguments
[[ "$1" == "--verbose" || "$1" == "-v" ]] && verbose=true
# quit if no mods are installed
[[ $(wc -l < "$DB_FILE") -le 1 ]] && { log INFO "No mods installed."; exit 0; }
# check if a modpack is enabled
modpack_entry=$(grep "ENABLED" "$MODPACKS_DB_FILE")
if [[ -n "$modpack_entry" ]]; then
modpack_name=$(echo "$modpack_entry" | awk -F, -v pos="$DB_MODPACK_NAME_POS" '{print $pos}')
log INFO "Modpack ${GREEN}enabled${NC}: $modpack_name."
fi
log INFO "Installed mods:"
awk -v GREEN="$GREEN" -v ORANGE="$ORANGE" -v BLUE="$BLUE" -v RED="$RED" -v NC="$NC" -v DB_MOD_ID_POS="$DB_MOD_ID_POS" -v DB_MOD_STATUS_POS="$DB_MOD_STATUS_POS" -v DB_MOD_NAME_POS="$DB_MOD_NAME_POS" -v DB_MOD_NEXUS_ID_POS="$DB_MOD_NEXUS_ID_POS" -v DB_MOD_NEXUS_VERSION_POS="$DB_MOD_NEXUS_VERSION_POS" -v DB_MOD_FILES_POS="$DB_MOD_FILES_POS" -v verbose="$verbose" -F, 'NR > 1 {
mod_index = $DB_MOD_ID_POS;
mod_status = $DB_MOD_STATUS_POS;
mod_name = $DB_MOD_NAME_POS;
mod_nexus_id = $DB_MOD_NEXUS_ID_POS;
mod_nexus_version = $DB_MOD_NEXUS_VERSION_POS;
mod_files = $DB_MOD_FILES_POS;
mod_details = "";
STATUS_COLOR = (mod_status == "DISABLED") ? RED : GREEN;
if (mod_nexus_id == "") {
mod_type = "LOCAL";
MOD_TYPE_COLOR = BLUE;
mod_nexus_version = "";
} else {
mod_type = "NEXUS";
MOD_TYPE_COLOR = ORANGE;
mod_nexus_version = "| ver: " mod_nexus_version;
}
printf "%2s. [%s%s%s/%s%s%s] %s %s\n", mod_index, STATUS_COLOR, mod_status, NC, MOD_TYPE_COLOR, mod_type, NC, mod_name, mod_nexus_version;
if (verbose == "true") {
gsub(/ /,"\n -> ", mod_files);
if (mod_nexus_id != "") printf " => Nexus mod ID: %s\n", mod_nexus_id;
printf " -> %s\n", mod_files;
}
}' "$DB_FILE"
}
function mod_export() {
parse_help display_help_export "$@"
local save_dir=${BACKUPS_DIR}
local archive_name="HD2-Mods-$(date +%Y-%m-%d_%H-%M-%S)"
local modpack_export=false
local force=false
if [[ "$1" == "--modpack" ]]; then
modpack_export=true
save_dir="$2"
archive_name="$3"
fi
[[ $(wc -l < "$DB_FILE") -le 1 ]] && { log ERROR "No mods installed."; exit 1; }
if [[ $modpack_export == false ]]; then
log PROMPT "Archive file will be saved to directory ${save_dir}. Make? (Y/n/path): "
read -e confirm
fi
[[ ! "$confirm" =~ ^[YyNn]$ && -n "$confirm" && $modpack_export == false ]] && { save_dir=$(substitute_home "$confirm"); confirm=""; }
[[ -d "$save_dir" ]] || { log ERROR "Directory $save_dir does not exist."; exit 1; }
if [[ $modpack_export == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then
# create a temporary directory to store the mods
OUT_DIR=$(mktemp -d)
trap 'rm -rf "$OUT_DIR"' EXIT
MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods"
mkdir -p "$MODS_EXPORT_DIR"
cp "$DB_FILE" "$MODS_EXPORT_DIR"
# copy modpacks if modpack_export is false
if [[ $modpack_export == false ]]; then
mkdir -p "$MODS_EXPORT_DIR/modpacks"
cp "$MODPACKS_DIR"/* "$MODS_EXPORT_DIR/modpacks"
fi
[[ $? -ne 0 ]] && { log ERROR "Could not copy mods to target directory."; exit 1; }
# copy all mod files to the export directory
for file in $(ls "$MODS_DIR/" 2>/dev/null | grep -E 'patch_.*'); do
cp "$MODS_DIR/$file" "$MODS_EXPORT_DIR"
done
[[ $? -ne 0 ]] && { log ERROR "Could not export mods. Possibly because no mods are present."; exit 1; }
# zip up the mods with the current date and time in the name
[[ -f "$save_dir/${archive_name}.tar.gz" ]] && { log ERROR "File $save_dir/${archive_name}.tar.gz already exists."; exit 1; }
tar -czf "$save_dir/${archive_name}.tar.gz" -C "$OUT_DIR" "Helldivers 2 Mods"
[[ $? -ne 0 ]] && { log ERROR "Failed to export mods."; exit 1; }
[[ "$modpack_export" == false ]] && log INFO "Mods ${GREEN}successfully${NC} exported."
fi
}
function mod_import() {
parse_help display_help_import ARGS "$@"
local modpack=false; [[ "$1" == "--modpack" ]] && { modpack=true; shift 1; }
# check if the file exists
[[ ! -f "$1" ]] && { log ERROR "File $1 does not exist."; exit 1; }
# reset mods before importing
[[ $modpack == false ]] && log INFO "Importing will ${RED}reset${NC} your mods."
mod_reset --no-path-reset
# extract in temp directory
OUT_DIR=$(mktemp -d)
trap 'rm -rf "$OUT_DIR"' EXIT
tar -xzf "$1" -C "$OUT_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not import mods. Possibly because the archive is invalid."; exit 1; }
# get the mods directory
MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods"
[[ ! -d "$MODS_EXPORT_DIR" ]] && { log ERROR "Could not import mods. Possibly because the archive is invalid."; exit 1; }
# fix breaking changes if the version number (from the first line) is different
db_version=$(head -n 1 "$MODS_EXPORT_DIR/mods.csv")
current_version=$(get_version_major)
[[ -z "$db_version" || ! "$db_version" =~ ^[0-9]+$ ]] && { log ERROR "Invalid version number inside mods.csv from imported archive."; exit 1; }
if [[ "$db_version" != "$current_version" ]]; then
log INFO "Import version mismatch detected: ${ORANGE}0.$db_version.x${NC} -> ${GREEN}0.$current_version.x${NC}."
# iterate from installed major number to latest major number
for ((i = db_version + 1; i <= current_version; i++)); do
if [[ -n "${breaking_changes_patches[$i]}" ]]; then
# apply breaking changes patch
eval $(echo "${breaking_changes_patches[$i]}" | sed "s:\$1:$MODS_EXPORT_DIR:g")
else
log INFO "No breaking changes for version 0.$i.x."
continue
fi
if [[ $? -ne 0 ]]; then
log ERROR "Failed to apply breaking changes patch for version $i. Do you want to continue? (Y/n): "
read response
[[ "$response" != "y" && "$response" != "Y" && -n "$response" ]] && { log INFO "Exiting." ; exit 1; }
else
log INFO "Upgrade ${GREEN}successfully${NC} applied for mismatch: ${GREEN}0.$i.x${NC}."
fi
done
fi
# copy everything in
cp -r "$MODS_EXPORT_DIR"/* "$MODS_DIR"
[[ $? -ne 0 ]] && { log ERROR "Failed to import mods."; exit 1; }
[[ $modpack == false ]] && log INFO "Mods ${GREEN}successfully${NC} imported."
}
function mod_order {
parse_help display_help_order ARGS "$@"
local mod_name=""
local mod_index=""
local new_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
mod_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
*)
new_index="$1"; shift 1
;;
esac
done
[[ -z "$mod_name" && -z "$mod_index" ]] && { log ERROR "Mod name or index is required to change order."; exit 1; }
# find mod files
get_mod_name_and_index
# get the number of mods
mod_count=$(($(cat "$DB_FILE" | wc -l) - 1))
# check if the mod is already at the desired index and if new index is valid
[[ "$mod_index" == "$new_index" ]] && { log ERROR "Mod $mod_name is already at index $new_index."; exit 1; }
[[ $new_index -lt 1 ]] && { log ERROR "Index can not be less than 1."; exit 1; }
[[ $new_index -gt $mod_count ]] && { log ERROR "Index can not be more than mod count (${mod_count})."; exit 1; }
# assert ascending or descending order
ascending_order=true
[[ $mod_index -gt $new_index ]] && ascending_order=false
# get entries between the indexes
if [[ $ascending_order == true ]]; then
entries=$(awk -v pos="$DB_MOD_ID_POS" -v mod_index="$mod_index" -v new_index="$new_index" -F, 'NR > 1 && $pos > mod_index && $pos <= new_index {print $0}' "$DB_FILE")
else
entries=$(awk -v pos="$DB_MOD_ID_POS" -v mod_index="$mod_index" -v new_index="$new_index" -F, 'NR > 1 && $pos < mod_index && $pos >= new_index {print $0}' "$DB_FILE")
fi
# get files for the current mod
IFS= current_mod_files=$(get_files_by_entry_from_db "$entry"); unset IFS
# step 1 - count how many replaces need to be done in hash table
declare -A replace_count
for file in $current_mod_files; do
extension=$(get_extension "$file")
base_name=$(get_basename "$file")
# if the file has no extension, add the basename to the hash table or increment the count
if [[ -z "$extension" ]]; then
replace_count["$base_name"]=$(( ${replace_count["$base_name"]:-0} + 1 ))
fi
done
# step 2.1 - iterate over the basenames, find and store the files with the same basenames as the ones we want to upgrade/downgrade
declare -A files_to_replace
for base_name in "${!replace_count[@]}"; do
IFS= files_to_replace["$base_name"]=$(echo "$entries" | awk -F, -v pos="$DB_MOD_FILES_POS" '{print $pos}' | grep -o "\b$base_name\.patch_[^ ]*"); unset IFS
done
# step 2.2 - reverse sort the files_to_replace if we are in descending order
[[ $ascending_order == false ]] && for base_name in "${!files_to_replace[@]}"; do
files_to_replace["$base_name"]=$(echo "${files_to_replace["$base_name"]}" | sort -rV)
done
# move current mod files to "t_$file" so we can move the new files to the correct index
for file in $current_mod_files; do
mv "$MODS_DIR/$file" "$MODS_DIR/t_$file"
log INFO "Moving ${ORANGE}$file${NC} to ${GREEN}t_$file${NC} for reindexing."
[[ $? -ne 0 ]] && { log ERROR "Could not move mod file $file."; exit 1; }
done
# step 3 - for every basename in files_to_replace, add replace_count to each file's patch number and move
for base_name in "${!files_to_replace[@]}"; do
files=$(echo "${files_to_replace["$base_name"]}" | tr ' ' '\n')
for file in $files; do
base_name=$(get_basename "$file")
patch_number=$(get_patch_number "$file")
extension=$(get_extension "$file")
# if ascending order, subtract replace_count to the patch number, otherwise add
operation="-"; [[ $ascending_order == false ]] && operation="+"
new_file="${base_name}.patch_$(($patch_number $operation ${replace_count["$base_name"]}))${extension}"
mv "$MODS_DIR/$file" "$MODS_DIR/$new_file"
[[ $? -ne 0 ]] && { log ERROR "Could not move mod file $file."; exit 1; }
log INFO "Reindexing ${ORANGE}$file${NC} to ${GREEN}$new_file${NC}."
# update db
sed -i "s/\b$file\b/$new_file/" "$DB_FILE"
done
done
# step 4 - for every file in current_mod_files, subtract the basename's array size of files_to_replace from the patch number
for file in $current_mod_files; do
base_name=$(get_basename "$file")
patch_number=$(get_patch_number "$file")
extension=$(get_extension "$file")
# get size of only files without an extension
size=$(echo "${files_to_replace["$base_name"]}" | grep -E "${base_name}.patch_[0-9]+$" | tr ' ' '\n' | wc -l)
# in case size is 0, move t_file back to file later
new_file=${file}
if [[ $size -gt 0 ]]; then
# if ascending order, add size from the patch number, otherwise subtract
operation="+"; [[ $ascending_order == false ]] && operation="-"
new_file="${base_name}.patch_$(($patch_number $operation $size))${extension}"
log INFO "Reindexing ${ORANGE}t_$file${NC} to ${GREEN}$new_file${NC}."
# dont forget current mod files are prefixed with t_
mv "$MODS_DIR/t_$file" "$MODS_DIR/t_$new_file"
[[ $? -ne 0 ]] && { log ERROR "Could not move mod file $file."; exit 1; }
# update file names
entry=$(echo "$entry" | sed "s/\b$file\b/$new_file/")
fi
# move back temp file
mv "$MODS_DIR/t_$new_file" "$MODS_DIR/$new_file"
# update index (will be replaced in db later)
entry=$(echo "$entry" | sed "s/^$mod_index,/$new_index,/")
done
# step 5.1 - change current mod to t_index so we can move the other indexes
sed -i "s/^$mod_index,/t_$mod_index,/" "$DB_FILE"
# step 5.2 - reindex the rest of the mods
idxs=$(echo "$entries" | awk -F, -v pos="$DB_MOD_ID_POS" '{print $pos}' | tr ' ' '\n')
# step 5.3 - reverse sort the idxs if we are in descending order
[[ $ascending_order == false ]] && idxs=$(echo "$idxs" | sort -rV)
for idx in $idxs; do
# if ascending order, subtract 1 from the index, otherwise add 1
operation="-"; [[ $ascending_order == false ]] && operation="+"
sed -i "s/^$idx,/$(($idx $operation 1)),/" "$DB_FILE"
done
# step 6 - move the entry to the new index
sed -i "/^t_$mod_index,/d" "$DB_FILE"
# weird edge case where the new index is the last index
if [[ $((new_index + 1)) -gt $(wc -l < "$DB_FILE") ]]; then
echo "$entry" >> "$DB_FILE"
else
sed -i "$((new_index + 1))i $entry" "$DB_FILE"
fi
log INFO "Mod ${GREEN}successfully${NC} reindexed: $mod_name: ${ORANGE}$mod_index${NC} -> ${GREEN}$new_index${NC}."
}
function mod_rename() {
parse_help display_help_rename ARGS "$@"
local mod_name=""
local new_mod_name=""
local mod_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
mod_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Mod name is required."; exit 1; }
mod_name="$2"; shift 2
;;
*)
new_mod_name="$1"; shift 1
;;
esac
done
[[ -z "$mod_name" && -z "$mod_index" ]] && { log ERROR "Mod name or index is required to rename."; exit 1; }
# find mod files
get_mod_name_and_index
# verify new mod name is not empty, does not contain commas and trim it
new_mod_name=$(echo "$new_mod_name" | sed 's/,//g' | sed 's/^[[:space:]]*//g' | sed 's/[[:space:]]*$//g')
[[ -z "$new_mod_name" ]] && { log ERROR "New mod name is required."; exit 1; }
# verify if new mod name already exists in the database
grep -q ",$new_mod_name," "$DB_FILE"
[[ $? -eq 0 ]] && { log ERROR "Mod with name \"$new_mod_name\" already exists."; exit 1; }
sed -i "s/^$mod_index,ENABLED,$mod_name,/$mod_index,ENABLED,$new_mod_name,/" "$DB_FILE"
sed -i "s/^$mod_index,DISABLED,$mod_name,/$mod_index,DISABLED,$new_mod_name,/" "$DB_FILE"
log INFO "Mod ${GREEN}successfully${NC} renamed: ${ORANGE}$mod_name${NC} -> ${GREEN}$new_mod_name${NC}."
}
# --- Modpack management ---
function modpack() {
parse_help display_help_modpack ARGS "$@"
local type="$1"
shift
# parse arguments
case "$type" in
"list"|"l")
modpack_list "$@"
;;
"create"|"c")
modpack_create "$@"
;;
"delete"|"d")
modpack_delete "$@"
;;
"overwrite"|"o")
modpack_overwrite "$@"
;;
"switch"|"s")
modpack_switch "$@"
;;
"reset"|"rs")
modpack_reset "$@"
;;
"help"|"h"|"-h"|"--help")
$display_help
;;
*)
log ERROR "Unknown command: $command"
$display_help
;;
esac
}
function modpack_list() {
parse_help display_help_modpack_list "$@"
local verbose=false
# parse arguments
[[ "$1" == "--verbose" || "$1" == "-v" ]] && verbose=true
# quit if no modpacks are saved
[[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && { log INFO "No modpacks saved."; exit 0; }
if [[ $verbose == true ]]; then
modpack_contents=""
# get modpack files
modpack_files=$(awk -F, -v pos="$DB_MODPACK_NAME_POS" 'NR > 1 {print $pos ".tar.gz"}' "$MODPACKS_DB_FILE")
for file in $modpack_files; do
mods=$(tar -xzvf "$MODPACKS_DIR/$file" --to-stdout "Helldivers 2 Mods/mods.csv" 2>/dev/null | awk -F, -v pos="$DB_MODPACK_NAME_POS" 'NR > 1 {print " -> " $pos}')
modpack_contents="${modpack_contents}${mods};"
done
fi
log INFO "Saved modpacks:"
awk -F, -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -v verbose="$verbose" -v modpack_contents="$modpack_contents" -v DB_MODPACK_ID_POS="$DB_MODPACK_ID_POS" -v DB_MODPACK_STATUS_POS="$DB_MODPACK_STATUS_POS" -v DB_MODPACK_NAME_POS="$DB_MODPACK_NAME_POS" 'BEGIN {
if(verbose == "true") {
split(modpack_contents, modpack_array, ";")
}
}
NR > 1 {
modpack_index = $DB_MODPACK_ID_POS;
modpack_status = $DB_MODPACK_STATUS_POS;
modpack_name = $DB_MODPACK_NAME_POS;
color = (modpack_status == "DISABLED") ? RED : GREEN;
if (verbose == "false") {
modpack_mods = "";
} else {
modpack_mods = "\n" modpack_array[NR - 1];
}
printf "%2s. [%s%s%s] %s%s\n", modpack_index, color, modpack_status, NC, modpack_name, modpack_mods;
}' "$MODPACKS_DB_FILE"
}
function modpack_create() {
parse_help display_help_modpack_create ARGS "$@"
local modpack_name=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-n")
[[ -z "$2" ]] && { log ERROR "Modpack name is required."; exit 1; }
modpack_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
# if no mods are installed, exit
[[ $(wc -l < "$DB_FILE") -le 1 ]] && { log ERROR "No mods installed."; exit 1; }
# if the same modpack name already exists, exit
[[ -f "$MODPACKS_DIR/${modpack_name}.tar.gz" ]] && { log ERROR "Modpack \"$modpack_name\" already exists."; exit 1; }
# let user select mods to save
mod_list
log PROMPT "Enter the mod indices to use for modpack (separated by space) or press Enter to select all: "
read -a mod_indices
if [[ -n "${mod_indices[0]}" ]]; then
# get the mod entries from the indices
mod_entries=()
# grab all the entries from the database from the indices
for index in "${mod_indices[@]}"; do
[[ ! "$index" =~ ^[0-9]+$ ]] && { log ERROR "Invalid mod index."; exit 1; }
[[ $index -lt 1 || $index -gt $(awk -F, -v pos="$DB_MODPACK_ID_POS" 'END {print $pos}' "$DB_FILE" ) ]] && { log ERROR "Mod index out of range."; exit 1; }
IFS= mod_entries+=($(awk -F, -v idx=$index -v pos="$DB_MOD_ID_POS" 'NR > 1 && $pos == idx {print $0}' "$DB_FILE")); unset IFS
done
# overwrite all paths to create a custom directory that will hold the mods specified, the mods will be "installed" there
OLD_MODS_DIR="$MODS_DIR"
MODS_DIR="$(mktemp -d)"
trap 'rm -rf "$MODS_DIR"' EXIT
DB_FILE="$MODS_DIR/mods.csv"
echo "$(get_version_major)" > "$DB_FILE"
# install selected mods to temp directory
for entry in "${mod_entries[@]}"; do
mod_name=$(get_name_by_entry_from_db "$entry")
IFS= _mod_files=$(get_files_by_entry_from_db "$entry"); unset IFS
mod_files=()
# add the OLD_MODS_DIR prefix to every entry from mod_files
for file in $_mod_files; do
mod_files+=("$OLD_MODS_DIR/$file")
done
silent=true
mod_install -n "$mod_name" "${mod_files[@]}"
silent=false
done
fi
# use built-in export function
mod_export --modpack "$MODPACKS_DIR" "$modpack_name"
log INFO "Modpack ${GREEN}successfully${NC} created: $modpack_name"
log INFO "Switch to the modpack with 'h2mm modpack-switch -n \"$modpack_name\"'."
# warn user to create a modpack with his main mods
[[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && log INFO "If this is your first modpack, it is ${ORANGE}recommended${NC} to create a separate modpack with your main mods."
# add entry to database
next_id=$(awk -F, -v pos="$DB_MODPACK_ID_POS" 'NR > 1 {last_id = $pos} END {print last_id + 1}' "$MODPACKS_DB_FILE")
echo "$next_id,DISABLED,$modpack_name" >> "$MODPACKS_DB_FILE"
}
function modpack_switch() {
parse_help display_help_modpack_switch ARGS "$@"
local modpack_name=""
local modpack_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid modpack index."; exit 1; }
modpack_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Modpack name is required."; exit 1; }
modpack_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
[[ -z "$modpack_name" && -z "$modpack_index" ]] && { log ERROR "Modpack name or index is required to switch."; exit 1; }
# find modpack files
get_modpack_name_and_index "$modpack_name" "$modpack_index"
log INFO "Switching modpacks mods will ${RED}reset${NC} your mods."
mod_import --modpack "$MODPACKS_DIR/$modpack_name.tar.gz"
log INFO "Modpack ${GREEN}successfully${NC} switched: $modpack_name."
# save status to db
sed -i "s/ENABLED/DISABLED/" "$MODPACKS_DB_FILE"
sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE"
}
function modpack_reset() {
parse_help display_help_modpack_reset "$@"
log PROMPT "Are you sure you want to ${RED}reset${NC} all installed modpacks? (Y/n): "
read confirm
if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then
rm -f "$MODPACKS_DIR"/*.tar.gz
rm -f "$MODPACKS_DB_FILE"
rmdir "$MODPACKS_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not reset modpacks."; exit 1; }
log INFO "Modpacks ${GREEN}successfully${NC} reset."
else
log INFO "Reset cancelled."
exit 1
fi
}
function modpack_delete() {
parse_help display_help_modpack_delete ARGS "$@"
local modpack_name=""
local modpack_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid modpack index."; exit 1; }
modpack_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Modpack name is required."; exit 1; }
modpack_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
[[ -z "$modpack_name" && -z "$modpack_index" ]] && { log ERROR "Modpack name or index is required to delete."; exit 1; }
get_modpack_name_and_index "$modpack_name" "$modpack_index"
rm -f "$MODPACKS_DIR/$modpack_name.tar.gz"
[[ $? -ne 0 ]] && { log ERROR "Could not delete modpack."; exit 1; }
log INFO "Modpack ${GREEN}successfully${NC} deleted: \$MODPACKS_DIRECTORY/$modpack_name.tar.gz"
# remove entry from database
sed -i "/^$modpack_index,/d" "$MODPACKS_DB_FILE"
}
function modpack_overwrite() {
parse_help display_help_modpack_overwrite ARGS "$@"
local modpack_name=""
local modpack_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
"-i")
[[ -z "$2" || ! "$2" =~ ^[0-9]+$ ]] && { log ERROR "Invalid modpack index."; exit 1; }
modpack_index="$2"; shift 2
;;
"-n")
[[ -z "$2" ]] && { log ERROR "Modpack name is required."; exit 1; }
modpack_name="$2"; shift 2
;;
*)
$display_help; exit 0
;;
esac
done
[[ -z "$modpack_name" && -z "$modpack_index" ]] && { log ERROR "Modpack name or index is required to overwrite."; exit 1; }
get_modpack_name_and_index "$modpack_name" "$modpack_index"
# if the modpack doesn't exist, exit
[[ ! -f "$MODPACKS_DIR/$modpack_name.tar.gz" ]] && { log ERROR "Modpack $modpack_name does not exist."; exit 1; }
rm -f "$MODPACKS_DIR/$modpack_name.tar.gz"
[[ $? -ne 0 ]] && { log ERROR "Could not delete modpack."; exit 1; }
# use built-in export function
mod_export --modpack "$MODPACKS_DIR" "$modpack_name"
log INFO "Modpack ${GREEN}successfully${NC} overwritten: \$MODPACKS_DIRECTORY/$modpack_name.tar.gz"
sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE"
}
function self_update() {
latest_version=$(curl -sS "$VERSION_URL")
if [[ "$latest_version" == "$VERSION" ]]; then
log INFO "Mod manager is already up-to-date."
exit 0
fi
log INFO "Starting update script..."
# run the installer for the latest version
bash -c "$(curl -fsSL https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/install.sh)"
exit 0
}
function modpack() {
parse_help_has_arguments display_help_modpack "$@"
local type="$1"
shift
# parse arguments
case "$type" in
"list"|"l")
modpack_list "$@"
;;
"create"|"c")
modpack_create "$@"
;;
"delete"|"d")
modpack_delete "$@"
;;
"overwrite"|"o")
modpack_overwrite "$@"
;;
"switch"|"s")
modpack_switch "$@"
;;
"reset"|"r")
modpack_reset "$@"
;;
"help"|"h"|"-h"|"--help")
$display_help
;;
*)
log ERROR "Unknown command: $command"
$display_help
;;
esac
}
# --- Nexus Mods Integration ---
function nexus_setup() {
parse_help display_help_nexus_setup "$@"
local nexus_api_key=""
log INFO "This is the setup wizard for the ${ORANGE}Nexus Mods${NC} integration."
log INFO "You need to provide your Nexus Mods API key to use this feature."
log INFO "You can find your API key in your Nexus Mods by:"
log INFO " -> Going to Site Preferences -> API Keys"
log INFO " -> Going to https://next.nexusmods.com/settings/api-keys"
log INFO "Scroll to the bottom of the page and request/copy your API key."
log INFO ""
log PROMPT "Enter your Nexus Mods API key: "
read -e nexus_api_key
[[ -z "$nexus_api_key" ]] && { log ERROR "Nexus Mods API key is required."; exit 1; }
# save api key to config dir, trim it
echo "$nexus_api_key" | tr -d '[:space:]' > "$API_KEY_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not save Nexus Mods API key."; exit 1; }
# test API key
response=$(curl -sSIH "apikey: $nexus_api_key" -X GET "https://api.nexusmods.com/v1/users/validate.json")
[[ $? -eq 8 ]] && log INFO "Above error is common for Steam Deck, everything is fine."
[[ ! "$response" =~ "HTTP/2 200" ]] && { log ERROR "Invalid Nexus Mods API key."; exit 1; }
log INFO "Nexus Mods API key ${GREEN}successfully${NC} tested and saved."
log INFO ""
# ask for default terminal
local terminal_command=""
log INFO "Now the manager needs to create a ${ORANGE}desktop entry${NC} for h2mm."
log INFO "In order to do that, it needs to know which terminal to use."
log INFO "Here's a list of pre-configured supported terminals:"
log INFO " -> gnome-terminal (On GNOME systems like Ubuntu)"
log INFO " -> konsole (On KDE systems like SteamOS, Steam Deck's default)"
log INFO " -> alacritty"
log INFO " -> kitty"
log INFO ""
log INFO "If you are not using any of these terminals, you need to provide"
log INFO "the command to spawn your terminal along with the flags to run a command"
log INFO "and the placeholder <COMMAND> where the h2mm command will be executed."
log INFO "To test if the command is valid, the manager spawn a terminal."
log INFO "For example, if you are using ghostty you need to provide this:"
log INFO "ghostty -e <COMMAND>"
log INFO ""
log PROMPT "Enter your terminal from the list (or custom terminal command): "
read -e terminal_command
[[ -z "$terminal_command" ]] && { log ERROR "Terminal command is required."; exit 1; }
command -v "$(echo "$terminal_command" | awk '{print $1}')" >/dev/null 2>&1 || { log ERROR "Terminal command does not work. Did you pick the correct terminal or do you have this terminal installed?"; exit 1; }
# check the 4 supported terminals
case "$terminal_command" in
"gnome-terminal")
terminal_command="$terminal_command -- <COMMAND>"
;;
"konsole"|"alacritty"|"kitty")
terminal_command="$terminal_command -e <COMMAND>"
;;
*)
# check if terminal even exists
command -v "$(echo "$terminal_command" | awk '{print $1}')" >/dev/null 2>&1 || { log ERROR "Terminal command does not work."; exit 1; }
# check if the format is valid
[[ "$terminal_command" != *"<COMMAND>"* ]] && { log ERROR "Terminal command is invalid. You did not include <COMMAND>"; exit 1; }
# test terminal -e command
wait_command="read -p 'Press Enter to validate terminal...'"
test_command=$(echo "$terminal_command" | sed "s/<COMMAND>/$wait_command/")
log INFO "Using test command: $test_command"
$test_command
[[ $? -ne 0 ]] && { log ERROR "Terminal command is invalid."; exit 1; }
;;
esac
# build terminal command
terminal_command=$(echo "$terminal_command" | sed "s|<COMMAND>|$(which h2mm) nexus \%u|")
# build the desktop entry
local desktop_entry="[Desktop Entry]
Name=h2mm
Comment=Link application to be used with Nexus Mods
Exec=$terminal_command
Terminal=false
Type=Application
Categories=Game;
MimeType=x-scheme-handler/nxm;"
local desktop_folder="$HOME/.local/share/applications"
[[ ! -d "$desktop_folder" ]] && mkdir -p "$desktop_folder"
local desktop_file="$desktop_folder/h2mm.desktop"
# create the desktop entry file
echo "$desktop_entry" > "$desktop_file"
[[ $? -ne 0 ]] && { log ERROR "Could not create desktop entry."; exit 1; }
log INFO ""
log INFO "Desktop entry ${GREEN}successfully${NC} created."
update-desktop-database "$desktop_folder"
# test xdg mime command exists
if command -v xdg-mime >/dev/null 2>&1; then
xdg-mime default "$desktop_file" x-scheme-handler/nxm
fi
log INFO "You can now use the ${ORANGE}Nexus Mods${NC} integration!"
log INFO "To use it, go to a mod page and click on the \"Vortex\" button"
log INFO "or \"Mod manager download\" button inside the mod files section."
log INFO "Additional information:"
log INFO "-> Your browser will ask you to open the link with h2mm, select it."
log INFO "-> Not all mods have this button, it all depends the mod author."
log INFO "-> A system restart might be needed if you can't open the link with h2mm."
}
function nexus() {
trap 'read -p "Press Enter to continue..."' EXIT
local mod_nxm_link="$1"
[[ -z "$mod_nxm_link" ]] && { log ERROR "Nexus Mods nxm link is required."; exit 1; }
[[ ! "$mod_nxm_link" =~ ^nxm://helldivers2/mods/[0-9]+/files/[0-9]+\?key=.*\&expires=[0-9]+\&user_id=[0-9]+$ ]] && { log ERROR "Invalid Nexus Mods nxm link."; exit 1; }
log INFO "Received NXM link: $mod_nxm_link"
# api_key
get_nexus_api_key
# extract info
nexus_mod_id=$(echo "$mod_nxm_link" | awk -F/ '{print $5}')
nexus_mod_file_id=$(echo "$mod_nxm_link" | awk -F/ '{print $7}' | cut -d\? -f1)
key=$(echo "$mod_nxm_link" | awk -F= '{print $2}' | cut -d\& -f1)
expires=$(echo "$mod_nxm_link" | awk -F= '{print $3}' | cut -d\& -f1)
[[ -z "$nexus_mod_id" || -z "$nexus_mod_file_id" || -z "$key" || -z "$expires" ]] && { log ERROR "Could not extract Nexus Mods mod ID, file ID, key or expiry."; exit 1; }
api_url="https://api.nexusmods.com/v1/games/helldivers2/mods/$nexus_mod_id/files/$nexus_mod_file_id/download_link.json?key=$key&expires=$expires"
log INFO "Using API URL: $api_url"
response=$(curl -sSw " http:%{http_code}" -H "apikey: $api_key" "$api_url")
[[ $? -ne 0 ]] && { log ERROR "curl failed."; exit 1; }
# checks
status="${response: -3}"
[[ "$status" != "200" ]] && { log ERROR "Invalid response from Nexus Mods API."; exit 1; }
log INFO "Received response from Nexus Mods API: $response"
# extract URI
download_url=$(printf "$response" | sed -E 's/.*"URI":"([^"]+)".*/\1/' | sed 's/\ /%20/g')
log INFO "Using download URL: $download_url"
log INFO ""
# extract file name
file_name=$(echo "$download_url" | awk -F/ '{print $6}' | cut -d\? -f1 | sed 's/%20/ /g')
output_folder="$(mktemp -d)"
trap 'read -p "Press Enter to continue..."; rm -rf "$output_folder"' EXIT
output_file="$output_folder/$file_name"
# download the file
log INFO "Download progress:"
curl -Lo "$output_file" "$download_url"
[[ $? -ne 0 ]] && { log ERROR "Download failed."; exit 1; }
log INFO "Download ${GREEN}successfully${NC} completed."
log INFO ""
# get mod info
api_url="https://api.nexusmods.com/v1/games/helldivers2/mods/$nexus_mod_id.json"
response=$(curl -sSw " http:%{http_code}" -H "apikey: $api_key" "$api_url")
[[ $? -ne 0 ]] && { log ERROR "curl failed."; exit 1; }
# checks
status="${response: -3}"
[[ "$status" != "200" ]] && { log ERROR "Invalid response from Nexus Mods API."; exit 1; }
# extract info
mod_name=$(echo "$response" | grep -oP '"name":"\K[^"]+' | head -n 1)
nexus_mod_version=$(echo "$response" | grep -oP '"version":"\K[^"]+')
[[ -z "$mod_name" || -z "$nexus_mod_version" ]] && { log ERROR "Could not extract mod name and version."; exit 1; }
# check if the mod is already installed, if yes, update it, otherwise install it
existing_mod_entry=$(get_entry_from_db_by_nexus_mod_id "$nexus_mod_id")
if [[ -z "$existing_mod_entry" ]]; then
# install the mod if it doesn't exist
mod_install "$output_file" -n "$mod_name" --mod-id "$nexus_mod_id" --version "$nexus_mod_version"
else
# replace the mod if it exists
existing_mod_index=$(get_index_by_entry_from_db "$existing_mod_entry")
mod_index=$(get_index_by_entry_from_db "$existing_mod_entry")
mod_uninstall -i "$mod_index"
mod_install "$output_file" -n "$mod_name" --mod-id "$nexus_mod_id" --version "$nexus_mod_version"
# get the current mod index from what we just installed, last line of the db
new_mod_index=$(get_index_by_entry_from_db "$(awk -F, -v pos="$DB_MOD_ID_POS" 'END {print $pos}' "$DB_FILE")")
[[ -z "$new_mod_index" ]] && { log ERROR "Could not get current mod index."; exit 1; }
[[ $mod_index -ne $new_mod_index ]] && mod_order -i "$new_mod_index" "$mod_index"
log INFO "Mod ${GREEN}successfully${NC} updated: $mod_name."
fi
disable_all_modpacks
}
# --- Update Check ---
function check_for_h2mm_update() {
last_update=$(cat "$LAST_CHECKED_UPDATE_FILE")
# if last_update is not in unix time format, reset it (for version <= 0.5.0)
if [[ ! "$last_update" =~ ^[0-9]+$ ]]; then
rm -f "$LAST_CHECKED_UPDATE_FILE"
echo "$(date +%s)" > "$LAST_CHECKED_UPDATE_FILE"
last_update=$(cat "$LAST_CHECKED_UPDATE_FILE")
fi
# check for updates by comparing the last update time + 3 hours with the current time
if [[ -n "$last_update" && "$(date +%s)" -lt $(("$last_update" + 7200)) ]]; then
return
fi
log INFO "Checking for updates..."
latest_version=$(curl -sS "$VERSION_URL")
if [[ $? -ne 0 ]]; then
log ERROR "Could not check for updates (curl failed)."
return
fi
if [[ "$latest_version" != "$VERSION" ]]; then
log WARNING "A new version of h2mm is available: ${ORANGE}$VERSION${NC} -> ${GREEN}$latest_version${NC} => run 'h2mm update'."
fi
echo "$(date +%s)" > "$LAST_CHECKED_UPDATE_FILE"
}
# --- Main ---
function main() {
parse_help display_help_main ARGS "$@"
command="$1"; shift
initialize_directories
initialize_modpack_directories
check_for_h2mm_update
case "$command" in
"install"|"i")
mod_install "$@"
;;
"list"|"l")
mod_list "$@"
;;
"uninstall"|"u")
mod_uninstall "$@"
;;
"enable"|"e")
mod_enable "$@"
;;
"disable"|"d")
mod_disable "$@"
;;
"export"|"ex")
mod_export "$@"
;;
"import"|"im")
mod_import "$@"
;;
"order"|"o")
mod_order "$@"
;;
"rename"|"r")
mod_rename "$@"
;;
"modpack"|"m")
modpack "$@"
;;
"nexus-setup"|"ns")
nexus_setup "$@"
;;
"nexus")
nexus "$@"
;;
"reset"|"rs")
mod_reset "$@"
;;
"version"|"v"|"-v"|"--version")
echo "$VERSION"
;;
"update"|"up")
self_update
;;
"help"|"h"|"-h"|"--help")
$display_help
;;
*)
log ERROR "Unknown command: $command"
$display_help
;;
esac
}
main "$@"