Files
h2mm-cli/h2mm
T
2025-03-06 01:29:14 +02:00

1216 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
VERSION="0.3.10"
# --- Globals ---
RED='\033[0;31m'
GREEN='\033[0;32m'
ORANGE='\033[0;33m'
NC='\033[0m'
MODS_DIR=""
DB_FILE=""
MODPACKS_FOLDER=""
MODPACKS_DB_FILE=""
H2PATH="${HOME}/.config/h2mm/h2path"
LAST_CHECKED_UPDATE_FILE="${HOME}/.config/h2mm/last_update"
VERSION_URL="https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/version"
# --- Utility Functions ---
function get_version_major() {
echo "$1" | awk -F. '{print $2}'
}
function get_filename_without_path() {
echo "$1" | awk -F/ '{print $NF}'
}
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_files_by_entry_from_db() {
echo "$1" | cut -d',' -f4- | tr ',' ' ' | head -1
}
function disable_all_modpacks() {
sed -i 's/ENABLED/DISABLED/' "$MODPACKS_DB_FILE"
}
function log() {
local type="$1"
shift
case "$type" in
INFO)
echo -e "$*" >&2
;;
ERROR)
echo -e "${RED}[ERROR]${NC} $*" >&2
;;
PROMPT)
echo -ne "$*" >&2
;;
*)
echo -e "$*" >&2
;;
esac
}
# --- Functions ---
function remove_disabled_prefix() {
local disabledFile="$1"
while [[ "$disabledFile" == disabled_* ]]; do
normalFile=$(echo "$disabledFile" | sed 's/^disabled_//')
done
echo "$normalFile"
}
function get_mod_name_and_index() {
if [[ -n "$mod_index" ]]; then # if mod index exists
entry=$(grep "^${mod_index}," "$DB_FILE")
mod_name=$(echo "$entry" | awk -F, '{print $3}')
elif [[ -n "$mod_name" ]]; then # if mod name exists
entry=$(grep -F ",$mod_name," "$DB_FILE")
mod_index=$(echo "$entry" | awk -F, '{print $1}' | head -1)
fi
if [[ -z "$entry" || -z "$mod_index" || -z "$mod_name" ]]; then
if [[ "$1" == "--do-not-exit" ]]; then
mod_index=-1
else
log ERROR "Mod not found."
exit 1
fi
fi
status=$(echo "$entry" | awk -F, '{print $2}')
}
function get_modpack_name_and_index() {
if [[ -n "$modpack_index" ]]; then # if modpack index exists
entry=$(grep "^${modpack_index}," "$MODPACKS_DB_FILE")
modpack_name=$(echo "$entry" | awk -F, '{print $3}')
elif [[ -n "$modpack_name" ]]; then # if modpack name exists
entry=$(grep ",$modpack_name$" "$MODPACKS_DB_FILE")
modpack_index=$(echo "$entry" | awk -F, '{print $1}' | head -1)
fi
if [[ -z "$entry" || -z "$modpack_index" || -z "$modpack_name" ]]; then
log ERROR "Modpack not found."
exit 1
fi
}
function find_game_directory() {
local search_dir="${HOME}"
local target_dir="Steam/steamapps/common/Helldivers\ 2/data"
# check if path is saved
if [[ -f "$H2PATH" ]]; then
saved_dir=$(cat "$H2PATH")
if [[ -d "$saved_dir" ]]; then
echo "$saved_dir"
return
else
log ERROR "Saved game directory is invalid. Proceeding to get a new directory."
fi
fi
# first time setup, or directory is not valid anymore
log INFO "Searching for the Helldivers 2 data directory... (20 seconds timeout)"
game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1)
if [[ -z "$game_dir" ]]; then
log INFO "Could not find the Helldivers 2 data directory automatically."
log PROMPT "Please enter the path to the Helldivers 2 data directory: "
IFS= read -e game_dir
game_dir="$(realpath "${game_dir/#\~/$HOME}")"
[[ ! -d "$game_dir" ]] && { log ERROR "Provided path is not a valid directory."; exit 1; }
fi
# save path
mkdir -p "$(dirname "$H2PATH")"
echo "$game_dir" > "$H2PATH"
[[ $? -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_directories() {
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 "$VERSION" | awk -F. '{print $2}' > "$DB_FILE"
log INFO "Database file ${GREEN}created${NC}: $DB_FILE"
fi
}
function initialize_modpack_directories() {
MODPACKS_FOLDER="$MODS_DIR/modpacks"
MODPACKS_DB_FILE="$MODPACKS_FOLDER/modpacks.csv"
if [[ ! -d "$MODPACKS_FOLDER" || ! -f "$MODPACKS_DB_FILE" ]]; then
mkdir -p "$MODPACKS_FOLDER" && touch "$MODPACKS_DB_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not create modpacks folder/file."; exit 1; }
echo "$VERSION" | awk -F. '{print $2}' > "$MODPACKS_DB_FILE"
log INFO "Modpacks folder and file ${GREEN}created${NC}: $MODPACKS_FOLDER"
fi
}
# --- Help Functions ---
function display_help() {
cat << EOF
Helldivers 2 Mod Manager v${VERSION}
Usage: h2mm [OPTION] <COMMAND>
Commands:
i, install Install a mod by the file provided (directory, zip, patch).
u, uninstall Uninstall a mod by name (or index).
l, list List all installed mods.
e, enable Enable a mod by name (or index).
d, disable Disable a mod by name (or index).
ex, export Export installed mods to a zip file.
im, import Import mods from a zip file.
mc, modpack-create Create a modpack from the currently installed mods.
ms, modpack-switch Switch to a modpack by name (or index).
ml, modpack-list List all installed modpacks.
mc, modpack-delete Delete a modpack by name (or index).
mo, modpack-overwrite Overwrite a modpack by name (or index).
mr, modpack-reset Reset all installed modpacks.
up, update Update h2mm to the latest version.
r, reset Reset all installed mods.
help Display this help message.
For more information on usage, use h2mm [COMMAND] --help.
Usage:
h2mm install /path/to/mod.zip
h2mm install /path/to/mod/files
h2mm uninstall \"Example mod\"
EOF
}
function display_install_help() {
cat << EOF
Usage: h2mm install [OPTIONS] <MOD_FILES|MOD_DIRECTORIES|MOD_ZIPS>
Install a mod with any combination of mod files, directories, and zip files.
Options:
-n \"<MOD_NAME>\" Name the mod yourself, inside double quotes.
<MOD_FILES> Multiple mod files, accepts wildcards.
<MOD_DIRECTORIES> Directory/directories containing mod files.
<MOD_ZIPS> Zip file(s) containing mod files.
Example:
h2mm install /path/to/mod.zip
h2mm install /path/to/mod/files
h2mm install /path/to/mod.zip /path/to/mod2.zip /path/to/mod/files
h2mm install -n \"Example mod\" mod.patch_0 mod.patch_0.stream
EOF
}
function display_uninstall_help() {
cat << EOF
Usage: h2mm uninstall [OPTIONS] \"<MOD_NAME>\"
Uninstall a mod by name or index.
Options:
-i <index> Index of the mod to uninstall.
Usage:
h2mm uninstall \"Example mod\"
h2mm uninstall -i 1 # uninstall mod with index 1
EOF
}
function display_enable_help() {
cat << EOF
Usage: h2mm enable [OPTIONS] \"<MOD_NAME>\"
Enable a mod by name or index.
Options:
-i <index> Index of the mod to enable.
Usage:
h2mm enable \"Example mod\"
h2mm enable -i 1 # enable mod with index 1
EOF
}
function display_disable_help() {
cat << EOF
Usage: h2mm disable [OPTIONS] \"<MOD_NAME>\"
Disable a mod by name or index.
Options:
-i <index> Index of the mod to disable.
Usage:
h2mm disable \"Example mod\"
h2mm disable -i 1 # disable mod with index 1
EOF
}
function display_list_help() {
cat << EOF
Usage: h2mm list
Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv
You can rename, delete, or edit this file to manage mods manually.
Options:
-v Verbose mode.
EOF
}
function display_reset_help() {
cat << EOF
Usage: h2mm reset
Reset all installed mods.
Deletes all installed mods/modpacks and the database file.
Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv, along with the mods.
EOF
}
function display_export_help() {
cat << EOF
Usage: h2mm export
Export installed mods and database to a zip file (in h2mm format, archive with csv).
EOF
}
function display_import_help() {
cat << EOF
Usage: h2mm import
Import mods and database from an archive file (coming from h2mm).
EOF
}
function display_modpack_list_help() {
cat << EOF
Usage: h2mm modpack-list
List all installed modpacks.
Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv
You can rename, delete, or edit this file to manage modpacks manually.
EOF
}
function display_modpack_create_help() {
cat << EOF
Usage: h2mm modpack-create \"<MODPACK_NAME>\"
Create a modpack from the currently installed mods.
EOF
}
function display_modpack_switch_help() {
cat << EOF
Usage: h2mm modpack-switch [OPTIONS] \"<MODPACK_NAME>\"
Switch to a modpack by name or index.
Options:
-i <index> Index of the modpack to switch to.
Switch to a modpack by name or index.
EOF
}
function display_modpack_reset_help() {
cat << EOF
Usage: h2mm modpack-reset
Reset all installed modpacks.
Deletes all installed modpacks and the database file.
Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv, along with the modpacks.
EOF
}
function display_modpack_delete_help() {
cat << EOF
Usage: h2mm modpack-delete [OPTIONS] \"<MODPACK_NAME>\"
Options:
-i <index> Index of the modpack to delete.
Delete a modpack by name or index.
EOF
}
function display_modpack_overwrite_help() {
cat << EOF
Usage: h2mm modpack-overwrite [OPTIONS] \"<MODPACK_NAME>\"
Options:
-i <index> Index of the modpack to overwrite.
Overwrite a modpack (the mods that it uses) by name or index.
EOF
}
# --- Main Functions ---
# Check for updates
function check_for_updates() {
if [[ -f "$LAST_CHECKED_UPDATE_FILE" ]]; then
last_update=$(cat "$LAST_CHECKED_UPDATE_FILE")
if [[ "$(date +%s)" -lt "$(date +%s -d "$last_update + 1 hour")" ]]; then
return
fi
else
echo "$(date +%Y-%m-%dT%H:%M:%S)" > "$LAST_CHECKED_UPDATE_FILE"
exit 0
fi
latest_version=$(curl -sS "$VERSION_URL")
if [[ $? -ne 0 ]]; then
log ERROR "Could not check for updates."
return
fi
if [[ "$latest_version" != "$VERSION" ]]; then
log INFO "A new version of h2mm is available: $VERSION -> $latest_version"
log INFO "Run \"h2mm update\" to update."
fi
echo "$(date +%Y-%m-%dT%H:%M:%S)" > "$LAST_CHECKED_UPDATE_FILE"
}
# Upgrade/downgrade logic
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=$(echo "$file" | grep -oP '(?<=patch_)\d+')
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"* 2>/dev/null | sort -V)); unset IFS
for mod in "${mods_to_downgrade[@]}"; do
mod=$(get_filename_without_path "$mod")
patch_version=$(echo $mod | grep -oP '(?<=patch_)\d+')
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 upgrade_mods() {
local files="$1"
declare -A upgrade_versions
declare -A upgrades_to_apply
# opposite of downgrade_mods
for file in $files; do
# remove disabled_ prefix if it exists
file=$(remove_disabled_prefix "$file")
current_version=$(echo "$file" | grep -oP '(?<=patch_)\d+')
base_name=$(get_basename "$file")
# basically save the lowest number, by limiting the setting of the key to the first time we see it
[[ -z "${upgrade_versions["$base_name"]+unset}" ]] && upgrade_versions["$base_name"]=$current_version
[[ -z "${upgrades_to_apply["$base_name"]+unset}" ]] && upgrades_to_apply["$base_name"]=$(echo $files | tr ' ' '\n' | grep "$base_name" | sed -E "s/(.*_[0-9]+).*/\1/" | sort -u | wc -l)
done
for base_name in "${!upgrades_to_apply[@]}"; do
IFS=$'\n' mods_to_upgrade=($(ls "$MODS_DIR/$base_name"* 2>/dev/null | sort -V)); unset IFS
for mod in "${mods_to_upgrade[@]}"; do
mod=$(get_filename_without_path "$mod")
patch_version=$(echo $mod | grep -oP '(?<=patch_)\d+')
if [[ $patch_version -ge ${upgrade_versions[$base_name]} ]]; then
new_version=$((patch_version + upgrades_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 upgrade mod file $mod."; exit 1; }
log INFO "Upgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}."
sed -i "s/\(\b$mod\b\)/$new_patch/" "$DB_FILE"
fi
done
done
}
# Mod management
function mod_disable() {
local mod_name=""
local mod_index=""
[[ $# -eq 0 ]] && { display_disable_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
mod_index="$2"; shift 2
;;
--help|-h)
display_disable_help; exit 0
;;
*)
mod_name="$1"; shift 1
;;
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
# disable each mod file by adding disabled_ to the start of the filename
files=$(get_files_by_entry_from_db "$entry")
for file in $files; do
[[ ! -f "$MODS_DIR/$file" ]] && { log ERROR "Mod file $file does not exist."; exit 1; }
disabled_file="disabled_$file"
while [[ -f "$MODS_DIR/$disabled_file" ]]; do
disabled_file="disabled_$disabled_file"
done
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 "$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 $mod_name ${ORANGE}disabled${NC} successfully."
}
function mod_enable() {
local mod_name=""
local mod_index=""
[[ $# -eq 0 ]] && { display_enable_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
mod_index="$2"; shift 2
;;
--help|-h)
display_enable_help; exit 0
;;
*)
mod_name="$1"; shift 1
;;
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; }
files=$(get_files_by_entry_from_db "$entry")
# upgrade mods with lower version number
upgrade_mods "$files"
# enable each mod file by removing disabled_ from the start of the filename
for file in $files; do
enabled_file=$(remove_disabled_prefix "$file")
# check if the files exists
[[ -f "$MODS_DIR/$file" ]] || { log ERROR "Mod file $file does not exist."; exit 1; }
mv "$MODS_DIR/$file" "$MODS_DIR/$enabled_file"
# check if the file was moved successfully
[[ $? -ne 0 ]] && { log ERROR "Could not enable mod file $file."; exit 1; }
log INFO "Enabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$enabled_file${NC})."
# save change to db
sed -i "/^$mod_index,/ s/\(\b$file\b\)/$enabled_file/" "$DB_FILE"
done
# update the database
sed -i "/^$mod_index,/s/DISABLED/ENABLED/" "$DB_FILE"
[[ $? -ne 0 ]] && { log ERROR "Could not enable mod."; exit 1; }
log INFO "Mod $mod_name ${GREEN}enabled${NC} successfully."
}
function mod_reset() {
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
display_reset_help
exit 0
fi
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"
rm -f "$H2PATH"
log INFO "Mods and related database file deleted."
else
log INFO "Reset cancelled."
exit 1
fi
}
function mod_install() {
local mod_name=""
local mod_dir=()
local mod_files=()
local mod_zip=()
[[ $# -eq 0 ]] && { display_install_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-n)
mod_name="$2"; shift 2
;;
--help|-h)
display_install_help; exit 0
;;
*)
if [[ -f "$1" && "$1" == *.zip ]]; then
mod_zip+=("$1")
elif [[ -d "$1" ]]; then
mod_dir+=("$1")
else
mod_files+=("$1")
fi
shift
;;
esac
done
# 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
# extract the zip file and pass it to mod dirs
if [[ -n "$mod_zip" ]]; then
command -v unzip &> /dev/null || { log ERROR "unzip package is not installed."; exit 1; }
[[ ! -f "$mod_zip" ]] && { log ERROR "Zip file $mod_zip does not exist."; exit 1; }
# 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=$(basename "$mod_zip" | sed -E 's/\.zip//' | awk -F/ '{print $NF}' | sed -E 's/-[0-9]+-.*//')
fi
# mod_dir as a temporary directory
mod_dir+=$(mktemp -d)
unzip -qq "$mod_zip" -d "$mod_dir"
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
# 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 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 1 -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}."
for i in "${!filtered_dirs[@]}"; do
log INFO "$((i + 1)). $(basename "${filtered_dirs[$i]}")"
done
# prompt user to choose
log PROMPT "Enter the number of the variant(s) to install (separated by space) or press Enter to install all: "
read -a variant_indices
if [[ -n "${variant_indices[0]}" ]]; then
# clear mod_files
mod_files=()
# get the files from the chosen variant
for index in "${variant_indices[@]}"; do
[[ ! "$index" =~ ^[0-9]+$ ]] && { log ERROR "Invalid variant index."; exit 1; }
[[ $index -lt 1 || $index -gt ${#filtered_dirs[@]} ]] && { log ERROR "Variant index out of range."; exit 1; }
readarray -d '' variant_files < <(find "${filtered_dirs[$((index - 1))]}" -type f -name "*.patch_*" -print0)
# update mod_name to contain the variant name
mod_name="${mod_name} [$(basename "${filtered_dirs[$((index - 1))]}")]"
# add the files to the mod_files array
mod_files+=("${variant_files[@]}")
done
fi
fi
fi
# verify minimum information required
[[ -z "$mod_name" || ${#mod_files[@]} -eq 0 ]] && { log ERROR "Mod name and files are required."; exit 1; }
# verify duplicate mod names
get_mod_name_and_index --do-not-exit
[[ $mod_index -ne -1 ]] && { log ERROR "The mod '$mod_name' is already installed."; 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
# hash table - in case multiple named files are needed for 1 mod install, store the patch count
declare -A patch_count
# 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")
patch_prefix="$MODS_DIR/${base_name}.patch_"
# count already installed patches
count=$(ls "${patch_prefix}"* 2>/dev/null | grep -E '([0-9]+$)' 2>/dev/null | wc -l)
# set patch count for file name if it doesn't exist yet
if [[ -z "${patch_count[$file]+unset}" ]]; then
patch_count["$file"]=$count
fi
# if the file has an extension (e.g. .stream, .gpu_resources), set the last patch number for the next step
patch_count["$base_name"]=$count
# if the file has an extension, look for the last patch number and use that, otherwise, the count will be wrong
extension=$(get_extension "$file")
if [[ -n "$extension" ]]; then
target_file="${base_name}.patch_$((patch_count[$base_name] - 1))${extension}"
else
target_file="${base_name}.patch_${patch_count[$file]}"
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}$file${NC} installed at ${GREEN}\$MODS_DIR/$target_file${NC}."
done
# add entry to database
next_id=$(awk -F, 'NR > 1 {last_id = $1} END {print last_id + 1}' "$DB_FILE")
echo "$next_id,ENABLED,$mod_name,${target_files[*]}" >> "$DB_FILE"
log INFO "Mod $mod_name ($base_name) ${GREEN}installed${NC} successfully."
# disable any modpack
disable_all_modpacks
}
function mod_uninstall() {
local mod_name=""
local mod_index=""
[[ $# -eq 0 ]] && { display_uninstall_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
mod_index="$2"; shift 2
;;
--help|-h)
display_uninstall_help; exit 0
;;
*)
mod_name="$1"; shift 1
;;
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
files=$(get_files_by_entry_from_db "$entry")
for file in $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 "$files"
# remove entry from database
sed -i "/^$mod_index,/d" "$DB_FILE"
log INFO "Mod $mod_name ${ORANGE}uninstalled${NC} successfully."
# disable any modpack
disable_all_modpacks
}
function mod_list() {
local verbose=false
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
display_list_help
exit 0
;;
--verbose|-v)
verbose=true
shift
;;
*)
shift
;;
esac
done
[[ $(wc -l < "$DB_FILE") -le 1 ]] && { log INFO "No mods installed."; return; }
log INFO "Installed mods:"
awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -v verbose="$verbose" -F, 'NR > 1 {
color = ($2 == "DISABLED") ? RED : GREEN;
if (verbose == "false") {
$4 = "";
} else {
gsub(/ /,"\n -> ", $4);
$4 = "\n -> " $4;
}
printf "%2s. [%s%s%s] %s %s\n", $1, color, $2, NC, $3, $4
}' "$DB_FILE"
}
function mod_export() {
[[ "$1" == "--help" || "$1" == "-h" ]] && { display_export_help; exit 0; }
local save_dir=$(pwd)
local archive_name="Helldivers_2_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 INFO "No modpacks saved."; exit 1; }
if [[ $modpack_export == false ]]; then
log PROMPT "Archive file will be saved to ${save_dir}/${archive_name}. Make? (Y/n): "
read -r confirm
fi
if [[ silent == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then
# create a temporary directory to store the mods
OUT_DIR=$(mktemp -d)
MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods"
mkdir -p "$MODS_EXPORT_DIR"
cp "$DB_FILE" "$MODS_EXPORT_DIR"
[[ $? -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}exported${NC} to $save_dir/${archive_name}.tar.gz."
fi
}
function mod_import() {
[[ "$1" == "--help" || "$1" == "-h" ]] && { display_import_help; exit 0; }
local modpack_export=false
[[ "$1" == "--modpack" ]] && { modpack_export=true; shift 1; }
[[ ! -f "$1" ]] && { log ERROR "File $1 does not exist."; exit 1; }
# reset mods before importing
[[ modpack_export == false ]] && log INFO "Importing mods will ${RED}reset${NC} your mods."
mod_reset
# extract in temp directory
OUT_DIR=$(mktemp -d)
tar -xzf "$1" -C "$OUT_DIR"
[[ $? -ne 0 ]] && { log ERROR "Could not import mods. Possibly because the archive is invalid."; exit 1; }
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; }
# copy mods
cp "$MODS_EXPORT_DIR"/* "$MODS_DIR"
[[ $? -ne 0 ]] && { log ERROR "Failed to import mods."; exit 1; }
log INFO "Mods imported ${GREEN}successfully${NC}."
}
# --- Modpacks management ---
function modpack_list() {
[[ "$1" == "--help" || "$1" == "-h" ]] && { display_modpack_list_help; exit 0; }
[[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && { log INFO "No modpacks saved."; return; }
log INFO "Saved modpacks:"
awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -F, 'NR > 1{
color = ($2 == "DISABLED") ? RED : GREEN;
printf "%2s. [%s%s%s] %s\n", $1, color, $2, NC, $3}' "$MODPACKS_DB_FILE"
}
function modpack_create() {
local modpack_name=""
[[ $# -eq 0 ]] && { display_modpack_create_help; exit 0; }
# if no mods are installed, exit
[[ $(wc -l < "$DB_FILE") -le 1 ]] && { log ERROR "No mods installed."; exit 1; }
# use built-in export function
modpack_name="$1"
mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name"
log INFO "Modpack ${GREEN}created${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz"
# add entry to database
next_id=$(awk -F, 'NR > 1 {last_id = $1} END {print last_id + 1}' "$MODPACKS_DB_FILE")
sed -i "s/ENABLED/DISABLED/" "$MODPACKS_DB_FILE"
echo "$next_id,ENABLED,$modpack_name" >> "$MODPACKS_DB_FILE"
}
function modpack_switch() {
local modpack_name=""
local modpack_index=""
[[ $# -eq 0 ]] && { display_modpack_switch_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
modpack_index="$2"; shift 2
;;
--help|-h)
display_modpack_switch_help; exit 0
;;
*)
modpack_name="$1"; shift 1
;;
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_FOLDER/$modpack_name.tar.gz"
log INFO "Modpack ${GREEN}switched${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz"
# 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() {
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
display_modpack_reset_help
exit 0
fi
local force=false
[[ "$1" == "--force" ]] && force=true
if [[ force == false ]]; then
log PROMPT "Are you sure you want to ${RED}reset${NC} all installed modpacks? (Y/n): "
read confirm
fi
if [[ force == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then
rm -f "$MODPACKS_FOLDER"/*.tar.gz
rm -f "$MODPACKS_DB_FILE"
rmdir "$MODPACKS_FOLDER"
log INFO "Modpacks and related database file deleted."
else
log INFO "Reset cancelled."
exit 1
fi
}
function modpack_delete() {
local modpack_name=""
local modpack_index=""
[[ $# -eq 0 ]] && { display_modpack_delete_help; exit 0; }
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
modpack_index="$2"; shift 2
;;
--help|-h)
display_modpack_delete_help; exit 0
;;
*)
modpack_name="$1"; shift 1
;;
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_FOLDER/$modpack_name.tar.gz"
[[ $? -ne 0 ]] && { log ERROR "Could not delete modpack."; exit 1; }
log INFO "Modpack ${GREEN}deleted${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz"
# remove entry from database
sed -i "/^$modpack_index,/d" "$MODPACKS_DB_FILE"
}
function modpack_overwrite() {
[[ $# -eq 0 ]] && { display_modpack_overwrite_help; exit 0; }
local modpack_name=""
local modpack_index=""
# parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-i)
modpack_index="$2"; shift 2
;;
--help|-h)
display_modpack_save_help; exit 0
;;
*)
modpack_name="$1"; shift 1
;;
esac
done
[[ -z "$modpack_name" && -z "$modpack_index" ]] && { log ERROR "Modpack name or index is required to save."; exit 1; }
get_modpack_name_and_index "$modpack_name" "$modpack_index"
# if the modpack doesn't exist, exit
[[ ! -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" ]] && { log ERROR "Modpack $modpack_name does not exist."; exit 1; }
rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz"
[[ $? -ne 0 ]] && { log ERROR "Could not delete modpack."; exit 1; }
# use built-in export function
mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name"
log INFO "Modpack ${GREEN}saved${NC}: \$MODPACKS_FOLDER/$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
}
# --- Main ---
function main() {
[[ $# -lt 1 ]] && { display_help; exit 1; }
command="$1"
shift
initialize_directories
check_for_updates
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 "$@"
;;
"modpack-list"|"ml")
initialize_modpack_directories
modpack_list "$@"
;;
"modpack-create"|"mc")
initialize_modpack_directories
modpack_create "$@"
;;
"modpack-delete"|"md")
initialize_modpack_directories
modpack_delete "$@"
;;
"modpack-overwrite"|"mo")
initialize_modpack_directories
modpack_overwrite "$@"
;;
"modpack-switch"|"ms")
initialize_modpack_directories
modpack_switch "$@"
;;
"modpack-reset"|"mr")
initialize_modpack_directories
modpack_reset "$@"
;;
"reset"|"r")
mod_reset "$@"
;;
"version"|"v"|"-v"|"--version")
log INFO "${VERSION}"
;;
"update"|"up")
self_update
;;
"help"|"--help"|"-h"|"h")
display_help
;;
*)
display_help
;;
esac
}
main "$@"