diff --git a/h2mm b/h2mm index 4aaea44..2aa96af 100755 --- a/h2mm +++ b/h2mm @@ -1,1107 +1,1107 @@ -#!/usr/bin/env bash - -VERSION="0.3.5" - -# --- Globals --- - -RED='\033[0;31m' -GREEN='\033[0;32m' -ORANGE='\033[0;33m' -NC='\033[0m' - -H2PATH="${HOME}/.config/h2mm/h2path" -MODS_DIR="" -DB_FILE="" -MODPACKS_FOLDER="" -MODPACKS_DB_FILE="" - -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 remove_disabled_prefix() { - local _file="$1" - while [[ "$_file" == disabled_* ]]; do - _file=$(echo "$_file" | sed 's/^disabled_//') - done - echo "$_file" -} - -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 ",$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 - echo -e "${RED}Error${NC}: Mod not found." >&2 - 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 - echo -e "${RED}Error${NC}: Modpack not found." >&2 - 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 - echo -e "${RED}Error${NC}: Saved game directory is invalid. Proceeding to get a new directory." >&2 - fi - fi - - # first time setup, or directory is not valid anymore - echo "Searching for the Helldivers 2 data directory... (20 seconds timeout)" >&2 - game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1) - - if [[ -z "$game_dir" ]]; then - echo "Could not find the Helldivers 2 data directory automatically." >&2 - IFS= read -ep "Please enter the path to the Helldivers 2 data directory: " game_dir - game_dir="$(realpath "${game_dir/#\~/$HOME}")" - - [[ ! -d "$game_dir" ]] && { echo -e "${RED}Error${NC}: Provided path is not a valid directory." >&2; exit 1; } - fi - - # save path - mkdir -p "$(dirname "$H2PATH")" - echo "$game_dir" > "$H2PATH" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not save game directory." >&2; exit 1; } - echo -e "Game directory ${GREEN}saved${NC}: $game_dir" >&2 - - # 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 ]] && { echo -e "${RED}Error${NC}: Could not create database file." >&2; exit 1; } - - echo "$VERSION" | awk -F. '{print $2}' > "$DB_FILE" - echo -e "Database file ${GREEN}created${NC}: $DB_FILE" &>2 - 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 ]] && { echo -e "${RED}Error${NC}: Could not create modpacks folder/file." >&2; exit 1; } - echo "$VERSION" | awk -F. '{print $2}' > "$MODPACKS_DB_FILE" - echo -e "Modpacks folder and file ${GREEN}created${NC}: $MODPACKS_FOLDER" &>2 - fi -} - -# --- Help Functions --- - -function display_help() { - echo "Helldivers 2 Mod Manager v${VERSION}" - echo "Usage: h2mm [command] [options]" - echo "Commands:" - echo " install Install a mod by the file provided (directory, zip, patch)." - echo " uninstall Uninstall a mod by name (or index)." - echo " list List all installed mods." - echo " enable Enable a mod by name (or index)." - echo " disable Disable a mod by name (or index)." - echo " export Export installed mods to a zip file." - echo " import Import mods from a zip file." - echo " modpack-create Create a modpack from the currently installed mods." - echo " modpack-switch Switch to a modpack by name (or index)." - echo " modpack-list List all installed modpacks." - echo " modpack-delete Delete a modpack by name (or index)." - echo " modpack-overwrite Overwrite a modpack by name (or index)." - echo " modpack-reset Reset all installed modpacks." - echo " reset Reset all installed mods." - echo " update Update h2mm to the latest version." - echo " help Display this help message." - echo "For more information on usage, use h2mm [command] --help." - echo "Basic Usage:" - echo " h2mm install /path/to/mod.zip" - echo " h2mm install /path/to/mod/files" - echo " h2mm uninstall \"Example mod\"" -} - -function display_install_help() { - echo "Usage: h2mm install [options] " - echo "Short form: h2mm i" - echo "Options:" - echo " -n \"\" Name the mod yourself, inside double quotes." - echo " Multiple mod files, accepts wildcards." - echo " Directory/directories containing mod files." - echo " Zip file(s) containing mod files." - echo "Usage:" - echo " h2mm install /path/to/mod.zip" - echo " h2mm install /path/to/mod/files" - echo " h2mm install /path/to/mod.zip /path/to/mod2.zip /path/to/mod/files" - echo " h2mm install -n \"Example mod\" mod.patch_0 mod.patch_0.stream # -n is mandatory when using files" - echo " h2mm install -n \"Example mod\" mod* # using a wildcard to include all files" - echo "If the mod has more than 1 variant, you need to install the one you want by unarchiving it separately." -} - -function display_uninstall_help() { - echo "Usage: h2mm uninstall [options] \"\"" - echo "Short form: h2mm u" - echo "Options:" - echo " -i Index of the mod to uninstall." - echo "Usage:" - echo " h2mm uninstall \"Example mod\"" - echo " h2mm uninstall -i 1 # uninstall mod with index 1" -} - -function display_enable_help() { - echo "Usage: h2mm enable [options] \"\"" - echo "Short form: h2mm e" - echo "Options:" - echo " -i Index of the mod to enable." - echo "Usage:" - echo " h2mm enable \"Example mod\"" - echo " h2mm enable -i 1 # enable mod with index 1" -} - -function display_disable_help() { - echo "Usage: h2mm disable [options] \"\"" - echo "Short form: h2mm d" - echo "Options:" - echo " -i Index of the mod to disable." - echo "Usage:" - echo " h2mm disable \"Example mod\"" - echo " h2mm disable -i 1 # disable mod with index 1" -} - -function display_list_help() { - echo "Usage: h2mm list" - echo "Short form: h2mm l" - echo "List all installed mods." - echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv" - echo "You can rename, delete, or edit this file to manage mods manually." -} - -function display_reset_help() { - echo "Usage: h2mm reset" - echo "Short form: h2mm r" - echo "Reset all installed mods." - echo "Deletes all installed mods and the database file." - echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv, along with the mods." -} - -function display_export_help() { - echo "Usage: h2mm export" - echo "Short form: h2mm ex" - echo "Export installed mods and database to a zip file (in h2mm format, archive with csv)." -} - -function display_import_help() { - echo "Usage: h2mm import" - echo "Short form: h2mm im" - echo "Import mods and database from an archive file (coming from h2mm)." -} - -function display_modpack_list_help() { - echo "Usage: h2mm modpack-list" - echo "Short form: h2mm ml" - echo "List all installed modpacks." - echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv" - echo "You can rename, delete, or edit this file to manage modpacks manually." -} - -function display_modpack_create_help() { - echo "Usage: h2mm modpack-create \"\"" - echo "Short form: h2mm mc" - echo "Create a modpack from the currently installed mods." -} - -function display_modpack_switch_help() { - echo "Usage: h2mm modpack-switch [options] \"\"" - echo "Short form: h2mm ms" - echo "Options:" - echo " -i Index of the modpack to switch to." - echo "Switch to a modpack by name or index." -} - -function display_modpack_reset_help() { - echo "Usage: h2mm modpack-reset" - echo "Short form: h2mm mr" - echo "Reset all installed modpacks." - echo "Deletes all installed modpacks and the database file." - echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv, along with the modpacks." -} - -function display_modpack_delete_help() { - echo "Usage: h2mm modpack-delete [options] \"\"" - echo "Short form: h2mm md" - echo "Options:" - echo " -i Index of the modpack to delete." - echo "Delete a modpack by name or index." -} - -function display_modpack_overwrite_help() { - echo "Usage: h2mm modpack-overwrite [options] \"\"" - echo "Short form: h2mm mo" - echo "Options:" - echo " -i Index of the modpack to overwrite." - echo "Overwrite a modpack (the mods that it uses) by name or index." -} - -# --- 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 - echo "${RED}Error:${NC} Could not check for updates." >&2 - return - fi - - if [[ "$latest_version" != "$VERSION" ]]; then - echo -e "${ORANGE}Info:${NC} A new version of h2mm is available: ${ORANGE}$VERSION${NC} -> ${GREEN}$latest_version${NC}" >&2 - echo -e "${ORANGE}Info:${NC} Run \"h2mm update\" to update." >&2 - 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 ]] && { echo -e "${RED}Error${NC}: Could not downgrade mod file $mod." >&2; exit 1; } - echo -e "Downgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 - - # 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 ]] && { echo -e "${RED}Error${NC}: Could not upgrade mod file $mod." >&2; exit 1; } - echo -e "Upgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 - - 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 - echo -e "${RED}Error${NC}: Mod name or index is required to disable." >&2 - exit 1 - fi - - # find mod files - get_mod_name_and_index - - if [[ "$status" == "DISABLED" ]]; then - echo -e "${RED}Error${NC}: Mod $mod_name is already disabled." >&2 - 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" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not disable mod file $file." >&2; exit 1; } - echo -e "Disabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$disabled_file${NC})." >&2 - - # 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 ]] && { echo -e "${RED}Error${NC}: Could not disable mod." >&2; exit 1; } - echo -e "Mod $mod_name ${ORANGE}disabled${NC} successfully." >&2 -} - -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" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to enable." >&2; exit 1; } - - # find mod files - get_mod_name_and_index - - [[ "$status" == "ENABLED" ]] && { echo -e "${RED}Error${NC}: Mod $mod_name is already enabled." >&2; 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" ]] || { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } - - mv "$MODS_DIR/$file" "$MODS_DIR/$enabled_file" - - # check if the file was moved successfully - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not enable mod file $file." >&2; exit 1; } - echo -e "Enabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$enabled_file${NC})." >&2 - - # 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 ]] && { echo -e "${RED}Error${NC}: Could not enable mod." >&2; exit 1; } - echo -e "Mod $mod_name ${GREEN}enabled${NC} successfully." >&2 -} - -function modpack_reset() { - : -} - -function mod_reset() { - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - display_reset_help - exit 0 - fi - - local without_modpacks=false - [[ "$1" == "--without-modpacks" ]] && without_modpacks=true - - echo -ne "Are you sure you want to ${RED}reset${NC} all installed mods? (Y/n): " - read -r confirm - if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then - rm -f "$MODS_DIR"/*.patch_* - rm -f "$DB_FILE" - rm -f "$H2PATH" - echo "Mods and related database file deleted." - - [[ $without_modpacks == false ]] && modpack_reset --force - else - echo "Reset cancelled." >&2 - 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 || { echo -e "${RED}Error${NC}: unzip package is not installed." >&2; exit 1; } - - [[ ! -f "$mod_zip" ]] && { echo -e "${RED}Error${NC}: Zip file $mod_zip does not exist." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Directory $mod_dir does not exist." >&2; 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 - fi - - # verify minimum information required - [[ -z "$mod_name" || ${#mod_files[@]} -eq 0 ]] && { echo -e "${RED}Error${NC}: Mod name and files are required." >&2; exit 1; } - - # verify duplicate mod names - get_mod_name_and_index --do-not-exit - [[ $mod_index -ne -1 ]] && { echo -e "${RED}Error${NC}: The mod '$mod_name' is already installed."; exit 1; } - - # verify mod files exist - for file in "${mod_files[@]}"; do - [[ ! -f "$file" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not install mod file $file." >&2; exit 1; } - echo -e "Mod file ${ORANGE}$file${NC} installed at ${GREEN}\$MODS_DIR/$target_file${NC}." >&2 - 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" - echo -e "Mod $mod_name ($base_name) ${GREEN}installed${NC} successfully." >&2 - - # 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" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to uninstall." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } - - echo -e "Removing ${ORANGE}\$MODS_DIR/$file${NC}." >&2 - rm "$MODS_DIR/$file" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not remove mod file $file." >&2; exit 1; } - done - - # downgrade mods with greater version number, only if the mod is enabled - [[ $disabled == false ]] && downgrade_mods "$files" - - # remove entry from database - sed -i "/^$mod_index,/d" "$DB_FILE" - echo -e "Mod $mod_name ${ORANGE}uninstalled${NC} successfully." >&2 - - # disable any modpack - disable_all_modpacks -} - -function mod_list() { - [[ "$1" == "--help" || "$1" == "-h" ]] && { display_list_help; exit 0; } - - [[ $(wc -l < "$DB_FILE") -le 1 ]] && { echo "No mods installed."; return; } - - echo "Installed mods:" >&2 - - awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -F, 'NR > 1 { - color = ($2 == "DISABLED") ? RED : GREEN; - if (length($4) > 150) $4 = substr($4, 1, 147) "..."; - 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 ]] && { echo "No modpacks saved."; exit 1; } - - if [[ $modpack_export == false ]]; then - echo -ne "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 ]] && { echo -e "${RED}Error${NC}: Could not copy mods to target directory." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not export mods. Possibly because no mods are present." >&2; exit 1; } - - # zip up the mods with the current date and time in the name - [[ -f "$save_dir/${archive_name}.tar.gz" ]] && { echo -e "${RED}Error${NC}: File $save_dir/${archive_name}.tar.gz already exists." >&2; exit 1; } - tar -czf "$save_dir/${archive_name}.tar.gz" -C "$OUT_DIR" "Helldivers 2 Mods" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to export mods." >&2; exit 1; } - [[ "$modpack_export" == false ]] && echo -e "Mods ${GREEN}exported${NC} to $save_dir/${archive_name}.tar.gz." >&2 - 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" ]] && { echo -e "${RED}Error${NC}: File $1 does not exist." >&2; exit 1; } - - # reset mods before importing - [[ modpack_export == false ]] && echo -e "Importing mods will ${RED}reset${NC} your mods." >&2 - mod_reset --without-modpacks - - # extract in temp directory - OUT_DIR=$(mktemp -d) - tar -xzf "$1" -C "$OUT_DIR" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } - - MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods" - [[ ! -d "$MODS_EXPORT_DIR" ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } - - # copy mods - cp "$MODS_EXPORT_DIR"/* "$MODS_DIR" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to import mods." >&2; exit 1; } - - echo -e "Mods imported ${GREEN}successfully${NC}." >&2 -} - -# --- Modpacks management --- - -function modpack_list() { - [[ "$1" == "--help" || "$1" == "-h" ]] && { display_modpack_list_help; exit 0; } - - [[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && { echo "No modpacks saved."; return; } - - echo "Saved modpacks:" >&2 - - 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 ]] && { echo -e "${RED}Error${NC}: No mods installed." >&2; exit 1; } - - # use built-in export function - modpack_name="$1" - mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" - - echo -e "Modpack ${GREEN}created${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 - - # 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to switch." >&2; exit 1; } - - # find modpack files - get_modpack_name_and_index "$modpack_name" "$modpack_index" - - echo -e "Switching modpacks mods will ${RED}reset${NC} your mods." >&2 - mod_import --modpack "$MODPACKS_FOLDER/$modpack_name.tar.gz" - - echo -e "Modpack ${GREEN}switched${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 - - # 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 - - [[ force == false ]] && read -p "Are you sure you want to reset all installed modpacks? (Y/n): " confirm - if [[ force == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then - rm -f "$MODPACKS_FOLDER"/*.tar.gz - rm -f "$MODPACKS_DB_FILE" - rmdir "$MODPACKS_FOLDER" - echo "Modpacks and related database file deleted." - else - echo "Reset cancelled." >&2 - 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to delete." >&2; exit 1; } - - get_modpack_name_and_index "$modpack_name" "$modpack_index" - - rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } - echo -e "Modpack ${GREEN}deleted${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 - - # 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to save." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Modpack $modpack_name does not exist." >&2; exit 1; } - - - rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" - - [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } - - # use built-in export function - mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" - - echo -e "Modpack ${GREEN}saved${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 - sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE" -} - -function self_update() { - latest_version=$(curl -sS "$VERSION_URL") - - if [[ "$latest_version" == "$VERSION" ]]; then - echo -e "h2mm is already up-to-date." >&2 - exit 0 - fi - - echo -e "Starting update script..." >&2 - - # Run the installer for the latest version - bash -c "$(curl -fsSL https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/install.sh)" -} - -# --- Main --- - -function main() { - [[ $# -lt 1 ]] && { display_help; exit 1; } - - command="$1" - shift - initialize_directories - initialize_modpack_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) - modpack_list "$@" - ;; - modpack-create|mc) - modpack_create "$@" - ;; - modpack-delete|md) - modpack_delete "$@" - ;; - modpack-overwrite|mo) - modpack_overwrite "$@" - ;; - modpack-switch|ms) - modpack_switch "$@" - ;; - modpack-reset|mr) - modpack_reset "$@" - ;; - reset|r) - mod_reset "$@" - ;; - version|v|-v|--version) - echo "${VERSION}" - ;; - update|up) - self_update - ;; - help|--help|-h|h) - display_help - ;; - *) - display_help - ;; - esac -} - -main "$@" +#!/usr/bin/env bash + +VERSION="0.3.5" + +# --- Globals --- + +RED='\033[0;31m' +GREEN='\033[0;32m' +ORANGE='\033[0;33m' +NC='\033[0m' + +H2PATH="${HOME}/.config/h2mm/h2path" +MODS_DIR="" +DB_FILE="" +MODPACKS_FOLDER="" +MODPACKS_DB_FILE="" + +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 remove_disabled_prefix() { + local _file="$1" + while [[ "$_file" == disabled_* ]]; do + _file=$(echo "$_file" | sed 's/^disabled_//') + done + echo "$_file" +} + +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 ",$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 + echo -e "${RED}Error${NC}: Mod not found." >&2 + 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 + echo -e "${RED}Error${NC}: Modpack not found." >&2 + 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 + echo -e "${RED}Error${NC}: Saved game directory is invalid. Proceeding to get a new directory." >&2 + fi + fi + + # first time setup, or directory is not valid anymore + echo "Searching for the Helldivers 2 data directory... (20 seconds timeout)" >&2 + game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1) + + if [[ -z "$game_dir" ]]; then + echo "Could not find the Helldivers 2 data directory automatically." >&2 + IFS= read -ep "Please enter the path to the Helldivers 2 data directory: " game_dir + game_dir="$(realpath "${game_dir/#\~/$HOME}")" + + [[ ! -d "$game_dir" ]] && { echo -e "${RED}Error${NC}: Provided path is not a valid directory." >&2; exit 1; } + fi + + # save path + mkdir -p "$(dirname "$H2PATH")" + echo "$game_dir" > "$H2PATH" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not save game directory." >&2; exit 1; } + echo -e "Game directory ${GREEN}saved${NC}: $game_dir" >&2 + + # 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 ]] && { echo -e "${RED}Error${NC}: Could not create database file." >&2; exit 1; } + + echo "$VERSION" | awk -F. '{print $2}' > "$DB_FILE" + echo -e "Database file ${GREEN}created${NC}: $DB_FILE" &>2 + 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 ]] && { echo -e "${RED}Error${NC}: Could not create modpacks folder/file." >&2; exit 1; } + echo "$VERSION" | awk -F. '{print $2}' > "$MODPACKS_DB_FILE" + echo -e "Modpacks folder and file ${GREEN}created${NC}: $MODPACKS_FOLDER" &>2 + fi +} + +# --- Help Functions --- + +function display_help() { + echo "Helldivers 2 Mod Manager v${VERSION}" + echo "Usage: h2mm [command] [options]" + echo "Commands:" + echo " install Install a mod by the file provided (directory, zip, patch)." + echo " uninstall Uninstall a mod by name (or index)." + echo " list List all installed mods." + echo " enable Enable a mod by name (or index)." + echo " disable Disable a mod by name (or index)." + echo " export Export installed mods to a zip file." + echo " import Import mods from a zip file." + echo " modpack-create Create a modpack from the currently installed mods." + echo " modpack-switch Switch to a modpack by name (or index)." + echo " modpack-list List all installed modpacks." + echo " modpack-delete Delete a modpack by name (or index)." + echo " modpack-overwrite Overwrite a modpack by name (or index)." + echo " modpack-reset Reset all installed modpacks." + echo " reset Reset all installed mods." + echo " update Update h2mm to the latest version." + echo " help Display this help message." + echo "For more information on usage, use h2mm [command] --help." + echo "Basic Usage:" + echo " h2mm install /path/to/mod.zip" + echo " h2mm install /path/to/mod/files" + echo " h2mm uninstall \"Example mod\"" +} + +function display_install_help() { + echo "Usage: h2mm install [options] " + echo "Short form: h2mm i" + echo "Options:" + echo " -n \"\" Name the mod yourself, inside double quotes." + echo " Multiple mod files, accepts wildcards." + echo " Directory/directories containing mod files." + echo " Zip file(s) containing mod files." + echo "Usage:" + echo " h2mm install /path/to/mod.zip" + echo " h2mm install /path/to/mod/files" + echo " h2mm install /path/to/mod.zip /path/to/mod2.zip /path/to/mod/files" + echo " h2mm install -n \"Example mod\" mod.patch_0 mod.patch_0.stream # -n is mandatory when using files" + echo " h2mm install -n \"Example mod\" mod* # using a wildcard to include all files" + echo "If the mod has more than 1 variant, you need to install the one you want by unarchiving it separately." +} + +function display_uninstall_help() { + echo "Usage: h2mm uninstall [options] \"\"" + echo "Short form: h2mm u" + echo "Options:" + echo " -i Index of the mod to uninstall." + echo "Usage:" + echo " h2mm uninstall \"Example mod\"" + echo " h2mm uninstall -i 1 # uninstall mod with index 1" +} + +function display_enable_help() { + echo "Usage: h2mm enable [options] \"\"" + echo "Short form: h2mm e" + echo "Options:" + echo " -i Index of the mod to enable." + echo "Usage:" + echo " h2mm enable \"Example mod\"" + echo " h2mm enable -i 1 # enable mod with index 1" +} + +function display_disable_help() { + echo "Usage: h2mm disable [options] \"\"" + echo "Short form: h2mm d" + echo "Options:" + echo " -i Index of the mod to disable." + echo "Usage:" + echo " h2mm disable \"Example mod\"" + echo " h2mm disable -i 1 # disable mod with index 1" +} + +function display_list_help() { + echo "Usage: h2mm list" + echo "Short form: h2mm l" + echo "List all installed mods." + echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv" + echo "You can rename, delete, or edit this file to manage mods manually." +} + +function display_reset_help() { + echo "Usage: h2mm reset" + echo "Short form: h2mm r" + echo "Reset all installed mods." + echo "Deletes all installed mods and the database file." + echo "Database of mods is stored in Steam/steamapps/common/Helldivers\ 2/data/mods.csv, along with the mods." +} + +function display_export_help() { + echo "Usage: h2mm export" + echo "Short form: h2mm ex" + echo "Export installed mods and database to a zip file (in h2mm format, archive with csv)." +} + +function display_import_help() { + echo "Usage: h2mm import" + echo "Short form: h2mm im" + echo "Import mods and database from an archive file (coming from h2mm)." +} + +function display_modpack_list_help() { + echo "Usage: h2mm modpack-list" + echo "Short form: h2mm ml" + echo "List all installed modpacks." + echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv" + echo "You can rename, delete, or edit this file to manage modpacks manually." +} + +function display_modpack_create_help() { + echo "Usage: h2mm modpack-create \"\"" + echo "Short form: h2mm mc" + echo "Create a modpack from the currently installed mods." +} + +function display_modpack_switch_help() { + echo "Usage: h2mm modpack-switch [options] \"\"" + echo "Short form: h2mm ms" + echo "Options:" + echo " -i Index of the modpack to switch to." + echo "Switch to a modpack by name or index." +} + +function display_modpack_reset_help() { + echo "Usage: h2mm modpack-reset" + echo "Short form: h2mm mr" + echo "Reset all installed modpacks." + echo "Deletes all installed modpacks and the database file." + echo "Database of modpacks is stored in Steam/steamapps/common/Helldivers\ 2/data/modpacks/modpacks.csv, along with the modpacks." +} + +function display_modpack_delete_help() { + echo "Usage: h2mm modpack-delete [options] \"\"" + echo "Short form: h2mm md" + echo "Options:" + echo " -i Index of the modpack to delete." + echo "Delete a modpack by name or index." +} + +function display_modpack_overwrite_help() { + echo "Usage: h2mm modpack-overwrite [options] \"\"" + echo "Short form: h2mm mo" + echo "Options:" + echo " -i Index of the modpack to overwrite." + echo "Overwrite a modpack (the mods that it uses) by name or index." +} + +# --- 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 + echo "${RED}Error:${NC} Could not check for updates." >&2 + return + fi + + if [[ "$latest_version" != "$VERSION" ]]; then + echo -e "${ORANGE}Info:${NC} A new version of h2mm is available: ${ORANGE}$VERSION${NC} -> ${GREEN}$latest_version${NC}" >&2 + echo -e "${ORANGE}Info:${NC} Run \"h2mm update\" to update." >&2 + 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 ]] && { echo -e "${RED}Error${NC}: Could not downgrade mod file $mod." >&2; exit 1; } + echo -e "Downgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 + + # 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 ]] && { echo -e "${RED}Error${NC}: Could not upgrade mod file $mod." >&2; exit 1; } + echo -e "Upgraded ${ORANGE}$mod${NC} to ${GREEN}\$MODS_DIR/$new_patch${NC}." >&2 + + 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 + echo -e "${RED}Error${NC}: Mod name or index is required to disable." >&2 + exit 1 + fi + + # find mod files + get_mod_name_and_index + + if [[ "$status" == "DISABLED" ]]; then + echo -e "${RED}Error${NC}: Mod $mod_name is already disabled." >&2 + 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" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not disable mod file $file." >&2; exit 1; } + echo -e "Disabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$disabled_file${NC})." >&2 + + # 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 ]] && { echo -e "${RED}Error${NC}: Could not disable mod." >&2; exit 1; } + echo -e "Mod $mod_name ${ORANGE}disabled${NC} successfully." >&2 +} + +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" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to enable." >&2; exit 1; } + + # find mod files + get_mod_name_and_index + + [[ "$status" == "ENABLED" ]] && { echo -e "${RED}Error${NC}: Mod $mod_name is already enabled." >&2; 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" ]] || { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } + + mv "$MODS_DIR/$file" "$MODS_DIR/$enabled_file" + + # check if the file was moved successfully + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not enable mod file $file." >&2; exit 1; } + echo -e "Enabled ${ORANGE}$file${NC} (changed to ${GREEN}\$MODS_DIR/$enabled_file${NC})." >&2 + + # 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 ]] && { echo -e "${RED}Error${NC}: Could not enable mod." >&2; exit 1; } + echo -e "Mod $mod_name ${GREEN}enabled${NC} successfully." >&2 +} + +function modpack_reset() { + : +} + +function mod_reset() { + if [[ "$1" == "--help" || "$1" == "-h" ]]; then + display_reset_help + exit 0 + fi + + local without_modpacks=false + [[ "$1" == "--without-modpacks" ]] && without_modpacks=true + + echo -ne "Are you sure you want to ${RED}reset${NC} all installed mods? (Y/n): " + read -r confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then + rm -f "$MODS_DIR"/*.patch_* + rm -f "$DB_FILE" + rm -f "$H2PATH" + echo "Mods and related database file deleted." + + [[ $without_modpacks == false ]] && modpack_reset --force + else + echo "Reset cancelled." >&2 + 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 || { echo -e "${RED}Error${NC}: unzip package is not installed." >&2; exit 1; } + + [[ ! -f "$mod_zip" ]] && { echo -e "${RED}Error${NC}: Zip file $mod_zip does not exist." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Directory $mod_dir does not exist." >&2; 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 + fi + + # verify minimum information required + [[ -z "$mod_name" || ${#mod_files[@]} -eq 0 ]] && { echo -e "${RED}Error${NC}: Mod name and files are required." >&2; exit 1; } + + # verify duplicate mod names + get_mod_name_and_index --do-not-exit + [[ $mod_index -ne -1 ]] && { echo -e "${RED}Error${NC}: The mod '$mod_name' is already installed."; exit 1; } + + # verify mod files exist + for file in "${mod_files[@]}"; do + [[ ! -f "$file" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not install mod file $file." >&2; exit 1; } + echo -e "Mod file ${ORANGE}$file${NC} installed at ${GREEN}\$MODS_DIR/$target_file${NC}." >&2 + 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" + echo -e "Mod $mod_name ($base_name) ${GREEN}installed${NC} successfully." >&2 + + # 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" ]] && { echo -e "${RED}Error${NC}: Mod name or index is required to uninstall." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Mod file $file does not exist." >&2; exit 1; } + + echo -e "Removing ${ORANGE}\$MODS_DIR/$file${NC}." >&2 + rm "$MODS_DIR/$file" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not remove mod file $file." >&2; exit 1; } + done + + # downgrade mods with greater version number, only if the mod is enabled + [[ $disabled == false ]] && downgrade_mods "$files" + + # remove entry from database + sed -i "/^$mod_index,/d" "$DB_FILE" + echo -e "Mod $mod_name ${ORANGE}uninstalled${NC} successfully." >&2 + + # disable any modpack + disable_all_modpacks +} + +function mod_list() { + [[ "$1" == "--help" || "$1" == "-h" ]] && { display_list_help; exit 0; } + + [[ $(wc -l < "$DB_FILE") -le 1 ]] && { echo "No mods installed."; return; } + + echo "Installed mods:" >&2 + + awk -v GREEN="$GREEN" -v RED="$RED" -v NC="$NC" -F, 'NR > 1 { + color = ($2 == "DISABLED") ? RED : GREEN; + if (length($4) > 150) $4 = substr($4, 1, 147) "..."; + 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 ]] && { echo "No modpacks saved."; exit 1; } + + if [[ $modpack_export == false ]]; then + echo -ne "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 ]] && { echo -e "${RED}Error${NC}: Could not copy mods to target directory." >&2; 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 ]] && { echo -e "${RED}Error${NC}: Could not export mods. Possibly because no mods are present." >&2; exit 1; } + + # zip up the mods with the current date and time in the name + [[ -f "$save_dir/${archive_name}.tar.gz" ]] && { echo -e "${RED}Error${NC}: File $save_dir/${archive_name}.tar.gz already exists." >&2; exit 1; } + tar -czf "$save_dir/${archive_name}.tar.gz" -C "$OUT_DIR" "Helldivers 2 Mods" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to export mods." >&2; exit 1; } + [[ "$modpack_export" == false ]] && echo -e "Mods ${GREEN}exported${NC} to $save_dir/${archive_name}.tar.gz." >&2 + 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" ]] && { echo -e "${RED}Error${NC}: File $1 does not exist." >&2; exit 1; } + + # reset mods before importing + [[ modpack_export == false ]] && echo -e "Importing mods will ${RED}reset${NC} your mods." >&2 + mod_reset --without-modpacks + + # extract in temp directory + OUT_DIR=$(mktemp -d) + tar -xzf "$1" -C "$OUT_DIR" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } + + MODS_EXPORT_DIR="$OUT_DIR/Helldivers 2 Mods" + [[ ! -d "$MODS_EXPORT_DIR" ]] && { echo -e "${RED}Error${NC}: Could not import mods. Possibly because the archive is invalid." >&2; exit 1; } + + # copy mods + cp "$MODS_EXPORT_DIR"/* "$MODS_DIR" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Failed to import mods." >&2; exit 1; } + + echo -e "Mods imported ${GREEN}successfully${NC}." >&2 +} + +# --- Modpacks management --- + +function modpack_list() { + [[ "$1" == "--help" || "$1" == "-h" ]] && { display_modpack_list_help; exit 0; } + + [[ $(wc -l < "$MODPACKS_DB_FILE") -le 1 ]] && { echo "No modpacks saved."; return; } + + echo "Saved modpacks:" >&2 + + 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 ]] && { echo -e "${RED}Error${NC}: No mods installed." >&2; exit 1; } + + # use built-in export function + modpack_name="$1" + mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" + + echo -e "Modpack ${GREEN}created${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 + + # 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to switch." >&2; exit 1; } + + # find modpack files + get_modpack_name_and_index "$modpack_name" "$modpack_index" + + echo -e "Switching modpacks mods will ${RED}reset${NC} your mods." >&2 + mod_import --modpack "$MODPACKS_FOLDER/$modpack_name.tar.gz" + + echo -e "Modpack ${GREEN}switched${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 + + # 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 + + [[ force == false ]] && read -p "Are you sure you want to reset all installed modpacks? (Y/n): " confirm + if [[ force == true || "$confirm" == "y" || "$confirm" == "Y" || "$confirm" = "" ]]; then + rm -f "$MODPACKS_FOLDER"/*.tar.gz + rm -f "$MODPACKS_DB_FILE" + rmdir "$MODPACKS_FOLDER" + echo "Modpacks and related database file deleted." + else + echo "Reset cancelled." >&2 + 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to delete." >&2; exit 1; } + + get_modpack_name_and_index "$modpack_name" "$modpack_index" + + rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } + echo -e "Modpack ${GREEN}deleted${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 + + # 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" ]] && { echo -e "${RED}Error${NC}: Modpack name or index is required to save." >&2; 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" ]] && { echo -e "${RED}Error${NC}: Modpack $modpack_name does not exist." >&2; exit 1; } + + + rm -f "$MODPACKS_FOLDER/$modpack_name.tar.gz" + + [[ $? -ne 0 ]] && { echo -e "${RED}Error${NC}: Could not delete modpack." >&2; exit 1; } + + # use built-in export function + mod_export --modpack "$MODPACKS_FOLDER" "$modpack_name" + + echo -e "Modpack ${GREEN}saved${NC}: \$MODPACKS_FOLDER/$modpack_name.tar.gz" >&2 + sed -i "/^$modpack_index,/s/DISABLED/ENABLED/" "$MODPACKS_DB_FILE" +} + +function self_update() { + latest_version=$(curl -sS "$VERSION_URL") + + if [[ "$latest_version" == "$VERSION" ]]; then + echo -e "h2mm is already up-to-date." >&2 + exit 0 + fi + + echo -e "Starting update script..." >&2 + + # Run the installer for the latest version + bash -c "$(curl -fsSL https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master/install.sh)" +} + +# --- Main --- + +function main() { + [[ $# -lt 1 ]] && { display_help; exit 1; } + + command="$1" + shift + initialize_directories + initialize_modpack_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) + modpack_list "$@" + ;; + modpack-create|mc) + modpack_create "$@" + ;; + modpack-delete|md) + modpack_delete "$@" + ;; + modpack-overwrite|mo) + modpack_overwrite "$@" + ;; + modpack-switch|ms) + modpack_switch "$@" + ;; + modpack-reset|mr) + modpack_reset "$@" + ;; + reset|r) + mod_reset "$@" + ;; + version|v|-v|--version) + echo "${VERSION}" + ;; + update|up) + self_update + ;; + help|--help|-h|h) + display_help + ;; + *) + display_help + ;; + esac +} + +main "$@" diff --git a/install.sh b/install.sh index afd56d9..4405abb 100755 --- a/install.sh +++ b/install.sh @@ -1,146 +1,146 @@ -#!/usr/bin/env bash -set -e - -RED='\033[0;31m' -GREEN='\033[0;32m' -ORANGE='\033[0;33m' -NC='\033[0m' - -DESTINATION_PATH="/usr/local/bin" -SCRIPT_NAME="h2mm" -REPO_URL="https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master" - -if [ "$(id -u)" -eq 0 ]; then - echo "Run me as normal user, not as root." - exit 1 -fi - -# --- Main --- - -# Warning - -echo -e "!!! ${RED}WARNING${NC} !!!" -echo -e "This script will install Helldivers 2 Mod Manager CLI for Linux to $DESTINATION_PATH/$SCRIPT_NAME." -echo -e "Running this script will require sudo permissions. ${RED}DO NOT TRUST${NC} random scripts from the internet." -echo -e "If you want to review the script before running it, check out the mod repository for yourself:" -echo -e "https://github.com/v4n00/h2mm-cli" -echo -e "!!! ${RED}WARNING${NC} !!!" -echo - -# Check if update - -# Breaking changes hash table - -breaking_changes_patches=( - ["2"]='sed -i "s/^\([0-9]\+\),/\1,ENABLED,/" "$1/mods.csv"' - ["3"]='sed -i "1 i\\3" "$1/mods.csv"' -) - -# Handle breaking changes - -if [[ -x "$(command -v $SCRIPT_NAME)" ]]; then - installed_version=$($SCRIPT_NAME --version) - # version 1 show the help message, if the first character is not a 0, store installed version as 0.1.6 - [[ ${installed_version:0:1} != "0" ]] && { installed_version="0.1.6"; } - - latest_version=$(curl -sS "$REPO_URL"/version) - if [[ "$latest_version" == "$installed_version" ]]; then - echo -e "You are reinstalling version $installed_version." - else - echo -e "You are upgrading from ${ORANGE}$installed_version${NC} -> ${GREEN}$latest_version${NC}." - fi - - # split version numbers - installed_major="" - latest_major="" - IFS='.' read -r _1 installed_major _2 <<< "$installed_version" - IFS='.' read -r _1 latest_major _2 <<< "$latest_version" - - if [[ $latest_major -gt $installed_major ]]; then - echo -e "${ORANGE}Warning:${NC} Major version upgrade detected." - echo "${ORANGE}Info${NC}: Check out the changelogs here -> https://github.com/v4n00/h2mm-cli/releases" - echo "The script will proceed to upgrade ${SCRIPT_NAME} to avoid breaking changes." - - # find hd2 path - search_dir="${HOME}" - target_dir="Steam/steamapps/common/Helldivers\ 2/data" - echo "Searching for the Helldivers 2 data directory... (20 seconds timeout)" >&2 - - game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1) - if [[ -z "$game_dir" ]]; then - echo "Could not find the Helldivers 2 data directory automatically." >&2 - IFS= read -ep "Please enter the path to the Helldivers 2 data directory: " game_dir - if [[ ! -d "$game_dir" ]]; then - echo -e "${RED}Error${NC}: Provided path is not a valid directory." >&2 - exit 1 - fi - fi - - [[ ! -f "$game_dir/mods.csv" ]] && { echo -e "${RED}Error:${NC} mods.csv not found in $game_dir."; exit 1; } - - # make backup of mods in case something goes wrong - echo "${ORANGE}V${NC} It is advised to make a backup before proceeding." - h2mm export - - # iterate from installed major number to latest major number - for ((i = installed_major + 1; i <= latest_major; i++)); do - if [[ -n "${breaking_changes_patches[$i]}" ]]; then - eval $(echo "${breaking_changes_patches[$i]}" | sed "s:\$1:$game_dir:") - else - echo "No breaking changes for version $i." - fi - if [[ $? -ne 0 ]]; then - echo -ne "${RED}Error:${NC} Failed to apply breaking changes patch for version $i. Do you want to continue? (Y/n): " - read -er response - - [[ "$response" != "y" && "$response" != "Y" && -n "$response" ]] && { echo "Exiting. Uninstall the script first the retry the install script."; exit 1; } - else - echo -e "Breaking changes patch for version ${ORANGE}$i${NC} applied ${GREEN}successfully${NC}." - fi - done - fi - echo -fi - -# Install - -# if steam deck, set destination path to ~/.local/bin -IFS= read -ep "Are you installing on a Steam Deck? (y/N): " response_sd -if [[ "$response_sd" == "y" || "$response_sd" == "Y" ]]; then - # steam deck - DESTINATION_PATH="$HOME/.local/bin" - mkdir -p "$DESTINATION_PATH" - - # check if ~/.local/bin is in PATH - if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - # add ~/.local/bin to PATH - echo -e "${ORANGE}Warning:${NC} Installing the script on a Steam Deck means adding $DESTINATION_PATH to your \$PATH." - echo -e "${ORANGE}Warning:${NC} If you're using a different shell, you may need to add it manually." - - IFS= read -ep "Do you want to add $DESTINATION_PATH to your \$PATH in ~/.bashrc? (Y/n): " response - if [[ "$response" == "y" || "$response" = "Y" || -z "$response" ]]; then - echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc" - echo -e "${GREEN}Success:${NC} Added $DESTINATION_PATH to your \$PATH in ~/.bashrc." - fi - fi -else - # not steam deck - # set another path if needed - IFS= read -ep "Install the script to $DESTINATION_PATH or specify another path (must be included in \$PATH)? (Y/path): " response - - if [[ "$response" != "y" && "$response" != "Y" && -n "$response" ]]; then - DESTINATION_PATH="$response" - [[ ! -d "$DESTINATION_PATH" ]] && { echo -e "${RED}Error:${NC} Path $DESTINATION_PATH does not exist."; exit 1; } - fi -fi - -echo "Installing $SCRIPT_NAME to $DESTINATION_PATH." -sudo curl "$REPO_URL"/h2mm --output "$DESTINATION_PATH/$SCRIPT_NAME" -sudo chmod +x "$DESTINATION_PATH/$SCRIPT_NAME" - -if [[ ! -x "$(command -v $SCRIPT_NAME)" ]]; then - echo -e "${RED}Error:${NC} Installation failed." - exit 1 -fi - -echo "Helldivers 2 Mod Manager CLI installed successfully to $DESTINATION_PATH/$SCRIPT_NAME. Use it by running '$SCRIPT_NAME'." +#!/usr/bin/env bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +ORANGE='\033[0;33m' +NC='\033[0m' + +DESTINATION_PATH="/usr/local/bin" +SCRIPT_NAME="h2mm" +REPO_URL="https://raw.githubusercontent.com/v4n00/h2mm-cli/refs/heads/master" + +if [ "$(id -u)" -eq 0 ]; then + echo "Run me as normal user, not as root." + exit 1 +fi + +# --- Main --- + +# Warning + +echo -e "!!! ${RED}WARNING${NC} !!!" +echo -e "This script will install Helldivers 2 Mod Manager CLI for Linux to $DESTINATION_PATH/$SCRIPT_NAME." +echo -e "Running this script will require sudo permissions. ${RED}DO NOT TRUST${NC} random scripts from the internet." +echo -e "If you want to review the script before running it, check out the mod repository for yourself:" +echo -e "https://github.com/v4n00/h2mm-cli" +echo -e "!!! ${RED}WARNING${NC} !!!" +echo + +# Check if update + +# Breaking changes hash table + +breaking_changes_patches=( + ["2"]='sed -i "s/^\([0-9]\+\),/\1,ENABLED,/" "$1/mods.csv"' + ["3"]='sed -i "1 i\\3" "$1/mods.csv"' +) + +# Handle breaking changes + +if [[ -x "$(command -v $SCRIPT_NAME)" ]]; then + installed_version=$($SCRIPT_NAME --version) + # version 1 show the help message, if the first character is not a 0, store installed version as 0.1.6 + [[ ${installed_version:0:1} != "0" ]] && { installed_version="0.1.6"; } + + latest_version=$(curl -sS "$REPO_URL"/version) + if [[ "$latest_version" == "$installed_version" ]]; then + echo -e "You are reinstalling version $installed_version." + else + echo -e "You are upgrading from ${ORANGE}$installed_version${NC} -> ${GREEN}$latest_version${NC}." + fi + + # split version numbers + installed_major="" + latest_major="" + IFS='.' read -r _1 installed_major _2 <<< "$installed_version" + IFS='.' read -r _1 latest_major _2 <<< "$latest_version" + + if [[ $latest_major -gt $installed_major ]]; then + echo -e "${ORANGE}Warning:${NC} Major version upgrade detected." + echo "${ORANGE}Info${NC}: Check out the changelogs here -> https://github.com/v4n00/h2mm-cli/releases" + echo "The script will proceed to upgrade ${SCRIPT_NAME} to avoid breaking changes." + + # find hd2 path + search_dir="${HOME}" + target_dir="Steam/steamapps/common/Helldivers\ 2/data" + echo "Searching for the Helldivers 2 data directory... (20 seconds timeout)" >&2 + + game_dir=$(timeout 20 find "$search_dir" -type d -path "*/$target_dir" 2>/dev/null | head -n 1) + if [[ -z "$game_dir" ]]; then + echo "Could not find the Helldivers 2 data directory automatically." >&2 + IFS= read -ep "Please enter the path to the Helldivers 2 data directory: " game_dir + if [[ ! -d "$game_dir" ]]; then + echo -e "${RED}Error${NC}: Provided path is not a valid directory." >&2 + exit 1 + fi + fi + + [[ ! -f "$game_dir/mods.csv" ]] && { echo -e "${RED}Error:${NC} mods.csv not found in $game_dir."; exit 1; } + + # make backup of mods in case something goes wrong + echo "${ORANGE}V${NC} It is advised to make a backup before proceeding." + h2mm export + + # iterate from installed major number to latest major number + for ((i = installed_major + 1; i <= latest_major; i++)); do + if [[ -n "${breaking_changes_patches[$i]}" ]]; then + eval $(echo "${breaking_changes_patches[$i]}" | sed "s:\$1:$game_dir:") + else + echo "No breaking changes for version $i." + fi + if [[ $? -ne 0 ]]; then + echo -ne "${RED}Error:${NC} Failed to apply breaking changes patch for version $i. Do you want to continue? (Y/n): " + read -er response + + [[ "$response" != "y" && "$response" != "Y" && -n "$response" ]] && { echo "Exiting. Uninstall the script first the retry the install script."; exit 1; } + else + echo -e "Breaking changes patch for version ${ORANGE}$i${NC} applied ${GREEN}successfully${NC}." + fi + done + fi + echo +fi + +# Install + +# if steam deck, set destination path to ~/.local/bin +IFS= read -ep "Are you installing on a Steam Deck? (y/N): " response_sd +if [[ "$response_sd" == "y" || "$response_sd" == "Y" ]]; then + # steam deck + DESTINATION_PATH="$HOME/.local/bin" + mkdir -p "$DESTINATION_PATH" + + # check if ~/.local/bin is in PATH + if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + # add ~/.local/bin to PATH + echo -e "${ORANGE}Warning:${NC} Installing the script on a Steam Deck means adding $DESTINATION_PATH to your \$PATH." + echo -e "${ORANGE}Warning:${NC} If you're using a different shell, you may need to add it manually." + + IFS= read -ep "Do you want to add $DESTINATION_PATH to your \$PATH in ~/.bashrc? (Y/n): " response + if [[ "$response" == "y" || "$response" = "Y" || -z "$response" ]]; then + echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> "$HOME/.bashrc" + echo -e "${GREEN}Success:${NC} Added $DESTINATION_PATH to your \$PATH in ~/.bashrc." + fi + fi +else + # not steam deck + # set another path if needed + IFS= read -ep "Install the script to $DESTINATION_PATH or specify another path (must be included in \$PATH)? (Y/path): " response + + if [[ "$response" != "y" && "$response" != "Y" && -n "$response" ]]; then + DESTINATION_PATH="$response" + [[ ! -d "$DESTINATION_PATH" ]] && { echo -e "${RED}Error:${NC} Path $DESTINATION_PATH does not exist."; exit 1; } + fi +fi + +echo "Installing $SCRIPT_NAME to $DESTINATION_PATH." +sudo curl "$REPO_URL"/h2mm --output "$DESTINATION_PATH/$SCRIPT_NAME" +sudo chmod +x "$DESTINATION_PATH/$SCRIPT_NAME" + +if [[ ! -x "$(command -v $SCRIPT_NAME)" ]]; then + echo -e "${RED}Error:${NC} Installation failed." + exit 1 +fi + +echo "Helldivers 2 Mod Manager CLI installed successfully to $DESTINATION_PATH/$SCRIPT_NAME. Use it by running '$SCRIPT_NAME'."