From 843d8dfc619e28f9346cf09dc7889f84973b1dc0 Mon Sep 17 00:00:00 2001 From: soulis-1256 Date: Sun, 16 Aug 2026 14:15:46 +0300 Subject: [PATCH] Record external-drive paths early and make uninstall find leftovers A hung copy onto /mnt/e left the home pointer pointing at ~/assemblrr, so uninstall --force --media never touched the E: trees. Write INSTALL_DIRECTORY and MEDIA_DIRECTORY as soon as paths are chosen, scan mounted drives for leftover assemblrr dirs, time out stuck USB/WSL I/O, and let r refresh the drive picker (WSL automount). --- bin/cli.sh | 28 +++++- bin/setup.sh | 34 ++++++-- lib/core.sh | 183 +++++++++++++++++++++++++++++++++++++++- lib/fzf-tui.sh | 26 ++++-- lib/prompts.sh | 62 ++++++++++---- tests/unit/test_core.sh | 41 +++++++++ 6 files changed, 340 insertions(+), 34 deletions(-) diff --git a/bin/cli.sh b/bin/cli.sh index 877ddda..2921dae 100644 --- a/bin/cli.sh +++ b/bin/cli.sh @@ -44,7 +44,9 @@ if [ -z "$INSTALL_DIR" ]; then log_error "Could not find ${APP_NAME:-assemblrr} installation. Run setup first." fi -# Source runtime config (may be partial during mid-setup abort) +# Pointer first — has MEDIA_DIRECTORY even when the install tree never finished +# (path picker chose /mnt/e, then copy hung). Install-dir config overrides. +load_install_pointer || true if [ -f "$INSTALL_DIR/.assemblrr-config" ]; then safe_source "$INSTALL_DIR/.assemblrr-config" elif [ -f "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config" ]; then @@ -429,6 +431,30 @@ uninstall_app() { log_info "Media directory preserved at $MEDIA_DIRECTORY" fi + # Path picker / hung setup can leave assemblrr trees on other mounts + # (e.g. /mnt/e) that the pointer never recorded. + poke_wsl_automounts 2>/dev/null || true + local leftover_kind leftover_path + while IFS=$'\t' read -r leftover_kind leftover_path; do + [ -n "$leftover_path" ] || continue + case "$leftover_kind" in + install) + log_warning "Found leftover install at $leftover_path" + safe_rm_rf "$leftover_path" + log_warning "Leftover install deleted" + ;; + media) + if [ "$delete_media" = true ]; then + log_warning "Found leftover media at $leftover_path" + safe_rm_rf "$leftover_path" + log_warning "Leftover media deleted" + else + log_info "Left leftover media at $leftover_path (pass --media to delete)" + fi + ;; + esac + done < <(list_assemblrr_leftovers "$INSTALL_DIR" "$MEDIA_DIRECTORY") + echo "Removing CLI..." if [ -n "${APP_CLI_NAME:-}" ]; then rm -f "$HOME/.local/bin/$APP_CLI_NAME" "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null || true diff --git a/bin/setup.sh b/bin/setup.sh index 0bc4ea8..1ca7314 100644 --- a/bin/setup.sh +++ b/bin/setup.sh @@ -249,16 +249,32 @@ source "$APP_ROOT/lib/fzf-tui.sh" source "$APP_ROOT/lib/services.sh" source "$APP_ROOT/lib/prompts.sh" +_copy_stall_error() { + log_error "Timed out writing to ${install_directory} — the drive looks stuck (flaky USB / WSL). From PowerShell: wsl --shutdown — then check the drive in Explorer, or re-run setup and pick Home." +} + copy_configuration_files() { - local copied=0 src dest src_path dest_path dest_dir - log_debug "Copying install tree into $install_directory" + local copied=0 src dest src_path dest_path dest_dir rc + if storage_needs_probe "$install_directory"; then + log_info "Copying install files to ${install_directory}..." + if ! probe_writable_path "$(storage_mount_root "$install_directory")"; then + log_error "Cannot copy install files to ${install_directory}. Pick Home, or fix the drive and re-run setup." + fi + else + log_debug "Copying install tree into $install_directory" + fi UPGRADE_SOURCE_ROOT="${UPGRADE_SOURCE_ROOT:-$APP_ROOT}" while IFS='|' read -r src dest; do [ -z "$src" ] && continue [[ "$src" =~ ^# ]] && continue dest_path="$install_directory/$dest" dest_dir=$(dirname "$dest_path") - [ -d "$dest_dir" ] || mkdir -p "$dest_dir" + if [ ! -d "$dest_dir" ]; then + rc=0 + run_with_timeout 15 mkdir -p "$dest_dir" || rc=$? + [ "$rc" -eq 124 ] && _copy_stall_error + [ "$rc" -eq 0 ] || log_error "Failed to create $dest_dir" + fi if ! src_path=$(resolve_project_file "$src"); then if [[ "$src" == compose/examples/* ]]; then @@ -269,7 +285,11 @@ copy_configuration_files() { fi log_debug " $src -> $dest_path" - if cp "$src_path" "$dest_path"; then + rc=0 + run_with_timeout 20 cp "$src_path" "$dest_path" || rc=$? + if [ "$rc" -eq 124 ]; then + _copy_stall_error + elif [ "$rc" -eq 0 ]; then case "$src" in scripts/*|bin/cli.sh|bin/config.sh|bin/setup.sh) chmod +x "$dest_path" ;; esac @@ -541,11 +561,7 @@ SUBTITLE_LANGUAGE="${subtitle_language:-}" SETUP_MODE="${SETUP_MODE:-manual}" EOF chmod 600 "$config_file" - # Pointer so CLI discovery works even for non-default install paths - if [ "$config_file" != "$HOME/.${APP_NAME}-config" ]; then - printf 'INSTALL_DIRECTORY="%s"\n' "$install_directory" > "$HOME/.${APP_NAME}-config" - chmod 600 "$HOME/.${APP_NAME}-config" - fi + write_install_pointer "$install_directory" "$media_directory" log_debug "Runtime config written to $config_file" } diff --git a/lib/core.sh b/lib/core.sh index b5bbbfb..a1502cb 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -98,6 +98,78 @@ find_install_directory() { return 1 } +# Home pointer so the CLI can find a non-default (or mid-setup) install. +# Includes MEDIA_DIRECTORY so uninstall --media can see an external drive +# even if the install tree was never finished. +write_install_pointer() { + local install_dir="$1" + local media_dir="${2:-}" + local pointer="$HOME/.assemblrr-config" + { + printf 'INSTALL_DIRECTORY="%s"\n' "$install_dir" + if [ -n "$media_dir" ]; then + printf 'MEDIA_DIRECTORY="%s"\n' "$media_dir" + fi + } > "$pointer" + chmod 600 "$pointer" +} + +load_install_pointer() { + local pointer="" + if [ -f "$HOME/.assemblrr-config" ]; then + pointer="$HOME/.assemblrr-config" + elif [ -f "$HOME/.${APP_NAME:-assemblrr}-config" ]; then + pointer="$HOME/.${APP_NAME:-assemblrr}-config" + else + return 1 + fi + safe_source "$pointer" +} + +is_assemblrr_install_tree() { + local d="$1" + local name="${APP_NAME:-assemblrr}" + [ -d "$d" ] || return 1 + [ "$(basename "$d")" = "$name" ] || return 1 + if [ -f "$d/.assemblrr-config" ] || [ -f "$d/.${name}-config" ] || \ + [ -f "$d/cli.sh" ] || [ -f "$d/compose/base.yaml" ] || [ -d "$d/lib" ]; then + return 0 + fi + # mkdir-only leftover from a setup that died before copying files + [ -z "$(find "$d" -mindepth 1 -maxdepth 1 2>/dev/null | head -1)" ] +} + +is_assemblrr_media_tree() { + local d="$1" + local name="${APP_NAME:-assemblrr}" + [ -d "$d" ] || return 1 + [ "$(basename "$d")" = "${name}-media" ] || return 1 + [ -d "$d/torrents/movies" ] || [ -d "$d/media/movies" ] || [ -d "$d/blackhole" ] +} + +# TSV: kindpath kind is install|media +# Optional $1/$2 are already-known paths to skip. +list_assemblrr_leftovers() { + local skip_install="${1:-}" + local skip_media="${2:-}" + local root d + local -A seen=() + + while IFS= read -r root; do + [ -n "$root" ] || continue + [ -n "${seen[$root]:-}" ] && continue + seen[$root]=1 + d="${root}/${APP_NAME:-assemblrr}" + if [ -d "$d" ] && [ "$d" != "$skip_install" ] && is_assemblrr_install_tree "$d"; then + printf 'install\t%s\n' "$d" + fi + d="${root}/${APP_NAME:-assemblrr}-media" + if [ -d "$d" ] && [ "$d" != "$skip_media" ] && is_assemblrr_media_tree "$d"; then + printf 'media\t%s\n' "$d" + fi + done < <({ printf '%s\n' "${HOME:-}"; list_storage_roots; }) +} + # --- Path utilities --- # Expand tilde in user input paths (no eval — safe from code injection) @@ -167,6 +239,107 @@ list_storage_roots() { done } +# WSL often does not attach a USB letter until something touches /mnt/. +# Ask Windows which letters exist, then ls each so automount can catch up. +poke_wsl_automounts() { + grep -qi microsoft /proc/version 2>/dev/null || return 0 + local letters letter mountpoint + letters=$(timeout 5 cmd.exe /c "wmic logicaldisk get name" 2>/dev/null \ + | tr -d '\r' | grep -Eo '[A-Za-z]:' | tr -d ':' | tr '[:upper:]' '[:lower:]' || true) + for letter in $letters; do + mountpoint="/mnt/$letter" + mkdir -p "$mountpoint" 2>/dev/null || true + timeout 2 ls "$mountpoint" >/dev/null 2>&1 || true + if ! grep -Eq "[[:space:]]${mountpoint}[[:space:]]" /proc/mounts 2>/dev/null; then + rmdir "$mountpoint" 2>/dev/null || true + fi + done + for mountpoint in /mnt/[a-z]; do + [ -d "$mountpoint" ] || continue + timeout 2 ls "$mountpoint" >/dev/null 2>&1 || true + done +} + +# Mount root for a path we might install onto (/mnt/e/foo → /mnt/e). +storage_mount_root() { + local p="${1%/}" + local rest + case "$p" in + /mnt/[a-z]) + echo "$p" + ;; + /mnt/[a-z]/*) + rest="${p#/mnt/}" + echo "/mnt/${rest%%/*}" + ;; + /run/media/*) + echo "$p" | awk -F/ '{ if (NF >= 5) print "/"$2"/"$3"/"$4"/"$5; else print $0 }' + ;; + /media/*) + echo "$p" | awk -F/ '{ if (NF >= 4) print "/"$2"/"$3"/"$4; else print $0 }' + ;; + *) + echo "$p" + ;; + esac +} + +# True for WSL drive letters and typical removable-media mounts. +storage_needs_probe() { + case "${1%/}" in + /mnt/[a-z]|/mnt/[a-z]/*|/media/*|/run/media/*) return 0 ;; + *) return 1 ;; + esac +} + +run_with_timeout() { + local secs="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout --foreground "$secs" "$@" + else + "$@" + fi +} + +# Timed write/read/unlink on the mount. 0 = ok, 1 = fail/timeout (already logged). +probe_writable_path() { + local path="$1" + local secs="${2:-8}" + local root probe start now elapsed rc=0 + + root=$(storage_mount_root "$path") + [ -n "$root" ] || return 1 + probe="$root/.assemblrr-write-test.$$" + start=$(date +%s) + + export ASSEMBLRR_PROBE_FILE="$probe" + run_with_timeout "$secs" bash -c \ + 'printf ok > "$ASSEMBLRR_PROBE_FILE" && grep -qx ok "$ASSEMBLRR_PROBE_FILE" && rm -f "$ASSEMBLRR_PROBE_FILE"' \ + || rc=$? + unset ASSEMBLRR_PROBE_FILE + rm -f "$probe" 2>/dev/null || true + + now=$(date +%s) + elapsed=$((now - start)) + + if [ "$rc" -eq 124 ]; then + log_warning "No response from ${root} after ${secs}s — the drive looks stuck." + log_info "Common on flaky USB / WSL drive letters (Explorer may freeze on that disk too)." + log_info "From PowerShell: wsl --shutdown" + log_info "Then check the drive in Explorer, or pick Home in the list." + return 1 + fi + if [ "$rc" -ne 0 ]; then + log_warning "Cannot write to ${root} (disconnected, or no permission)." + return 1 + fi + if [ "$elapsed" -ge 3 ]; then + log_warning "${root} is slow (${elapsed}s). USB/WSL mounts can stall setup and Docker later." + fi + return 0 +} + # Persist ~/.local/bin on PATH for bash, zsh, and fish. # Does not create ~/.bash_profile (that would hide ~/.profile on login bash). _append_path_line() { @@ -255,13 +428,17 @@ safe_rm_rf() { create_and_verify_directory() { local dir="$1" local dir_type="$2" + local rc=0 if [ ! -d "$dir" ]; then echo "The directory \"$dir\" does not exist. Attempting to create..." - if mkdir -p "$dir"; then - log_success "Directory $dir created" - else + run_with_timeout 15 mkdir -p "$dir" || rc=$? + if [ "$rc" -eq 124 ]; then + log_error "Timed out creating \"$dir\" (15s). The drive looks stuck (flaky USB / WSL). From PowerShell: wsl --shutdown — then check the drive in Explorer, or re-run setup and pick Home." + elif [ "$rc" -ne 0 ]; then log_error "Failed to create $dir_type directory at \"$dir\". Check permissions" + else + log_success "Directory $dir created" fi fi diff --git a/lib/fzf-tui.sh b/lib/fzf-tui.sh index 5e57a00..f32b97c 100644 --- a/lib/fzf-tui.sh +++ b/lib/fzf-tui.sh @@ -67,19 +67,36 @@ format_path_menu() { } _fzf_select_path_row() { - local data="$1" - local selected="" + local def_install="${1:-}" + local def_media="${2:-}" + local data selected="" gen="" + poke_wsl_automounts 2>/dev/null || true + data=$(format_path_menu "$def_install" "$def_media") if _fzf_can_render && _fzf_has_tty; then + gen=$(mktemp) + cat > "$gen" </dev/null || true +format_path_menu $(printf '%q' "$def_install") $(printf '%q' "$def_media") +EOF + chmod +x "$gen" selected=$(echo "$data" | fzf \ --delimiter=$'\t' \ --with-nth=2,3 \ --prompt="Install location> " \ - --header="↑↓=navigate Enter=confirm Esc=home default" \ + --header="↑↓=navigate Enter=confirm r=refresh drives Esc=home default" \ + --bind="r:reload($gen)+clear-query" \ --height=60% \ --reverse \ --border \ --scrollbar='│' \ 2>/dev/null) || true + rm -f "$gen" echo "$selected" return 0 fi @@ -178,8 +195,7 @@ pick_storage_paths() { fi while true; do - data=$(format_path_menu "$def_install" "$def_media") - row=$(_fzf_select_path_row "$data") + row=$(_fzf_select_path_row "$def_install" "$def_media") if [ -z "$row" ]; then install_directory="$def_install" media_directory="$def_media" diff --git a/lib/prompts.sh b/lib/prompts.sh index 0409a3d..6134f2c 100644 --- a/lib/prompts.sh +++ b/lib/prompts.sh @@ -467,30 +467,60 @@ get_user_info() { export username puid pgid } +_probe_selected_storage() { + local p root + local -A seen=() + for p in "$install_directory" "$media_directory"; do + storage_needs_probe "$p" || continue + root=$(storage_mount_root "$p") + [ -n "$root" ] || continue + [ -n "${seen[$root]:-}" ] && continue + seen[$root]=1 + log_info "Checking ${root} is responding..." + if ! probe_writable_path "$root"; then + echo + log_info "Pick another location (Home is safest), or fix the drive and choose it again." + return 1 + fi + done + return 0 +} + get_installation_paths() { ui_intro \ "Where should assemblrr store config and media? External drives (WSL E:, USB, …) show up in the list." \ "Change install and media directories. This does not move existing files." - pick_storage_paths - install_directory=$(expand_path "$install_directory") - media_directory=$(expand_path "$media_directory") + while true; do + pick_storage_paths + install_directory=$(expand_path "$install_directory") + media_directory=$(expand_path "$media_directory") - # VPN gate bootstraps ~/assemblrr first. If the user then picked another - # install path, drop that leftover tree so CLI discovery cannot shadow it. - if [ -n "${_ASSEMBLRR_PROVISIONAL_INSTALL:-}" ] && \ - [ "$install_directory" != "$_ASSEMBLRR_PROVISIONAL_INSTALL" ] && \ - [ -d "$_ASSEMBLRR_PROVISIONAL_INSTALL" ]; then - log_info "Install path changed — removing temporary files at $_ASSEMBLRR_PROVISIONAL_INSTALL" - safe_rm_rf "$_ASSEMBLRR_PROVISIONAL_INSTALL" - fi + if ! _probe_selected_storage; then + continue + fi - create_and_verify_directory "$install_directory" "installation" - setup_directory_structure "$media_directory" - verify_user_permissions "$username" "$media_directory" + # Record both paths before any copy so uninstall can find an external + # drive even if setup dies mid-copy. + write_install_pointer "$install_directory" "$media_directory" - log_success "Install directory: $install_directory" - log_success "Media directory: $media_directory" + # VPN gate bootstraps ~/assemblrr first. If the user then picked another + # install path, drop that leftover tree so CLI discovery cannot shadow it. + if [ -n "${_ASSEMBLRR_PROVISIONAL_INSTALL:-}" ] && \ + [ "$install_directory" != "$_ASSEMBLRR_PROVISIONAL_INSTALL" ] && \ + [ -d "$_ASSEMBLRR_PROVISIONAL_INSTALL" ]; then + log_info "Install path changed — removing temporary files at $_ASSEMBLRR_PROVISIONAL_INSTALL" + safe_rm_rf "$_ASSEMBLRR_PROVISIONAL_INSTALL" + fi + + create_and_verify_directory "$install_directory" "installation" + setup_directory_structure "$media_directory" + verify_user_permissions "$username" "$media_directory" + + log_success "Install directory: $install_directory" + log_success "Media directory: $media_directory" + break + done export install_directory media_directory } diff --git a/tests/unit/test_core.sh b/tests/unit/test_core.sh index 30dde0e..b6e6526 100755 --- a/tests/unit/test_core.sh +++ b/tests/unit/test_core.sh @@ -83,6 +83,29 @@ printf 'INSTALL_DIRECTORY="%s"\n' "$fid_home/on-disk" > "$fid_home/.assemblrr-co assert_eq "$fid_home/on-disk" "$(HOME="$fid_home" find_install_directory)" \ "pointer wins over leftover ~/assemblrr" +test_suite "write_install_pointer + leftovers" +ptr_home="$tmp/ptr-home" +mkdir -p "$ptr_home" +HOME="$ptr_home" write_install_pointer /mnt/e/assemblrr /mnt/e/assemblrr-media +assert_contains "$(cat "$ptr_home/.assemblrr-config")" 'INSTALL_DIRECTORY="/mnt/e/assemblrr"' "pointer has install" +assert_contains "$(cat "$ptr_home/.assemblrr-config")" 'MEDIA_DIRECTORY="/mnt/e/assemblrr-media"' "pointer has media" +HOME="$ptr_home" load_install_pointer +assert_eq "/mnt/e/assemblrr" "${INSTALL_DIRECTORY:-}" "load install" +assert_eq "/mnt/e/assemblrr-media" "${MEDIA_DIRECTORY:-}" "load media" + +empty_inst="$ptr_home/assemblrr" +mkdir -p "$empty_inst" +mkdir -p "$ptr_home/assemblrr-media/torrents/movies" +mkdir -p "$ptr_home/not-ours/assemblrr-media" +assert_success "empty named tree is leftover install" is_assemblrr_install_tree "$empty_inst" +assert_success "torrents/movies is leftover media" is_assemblrr_media_tree "$ptr_home/assemblrr-media" +assert_failure "unrelated folder is not leftover media" is_assemblrr_media_tree "$ptr_home/not-ours/assemblrr-media" +left=$(HOME="$ptr_home" list_assemblrr_leftovers) +assert_contains "$left" $'install\t'"$empty_inst" "lists home leftover install" +assert_contains "$left" $'media\t'"$ptr_home/assemblrr-media" "lists home leftover media" +skipped=$(HOME="$ptr_home" list_assemblrr_leftovers "$empty_inst" "$ptr_home/assemblrr-media") +assert_eq "" "$skipped" "skips known paths" + test_suite "storage_root_label" assert_eq "Windows E:" "$(storage_root_label /mnt/e)" "WSL E:" assert_eq "Windows C:" "$(storage_root_label /mnt/c/)" "strips trailing slash" @@ -90,6 +113,24 @@ assert_eq "Seagate" "$(storage_root_label /media/user/Seagate)" "linux /media" assert_eq "USB" "$(storage_root_label /run/media/user/USB)" "linux /run/media" assert_eq "/data" "$(storage_root_label /data)" "other path unchanged" +test_suite "storage_mount_root / storage_needs_probe" +assert_eq "/mnt/e" "$(storage_mount_root /mnt/e)" "letter is the root" +assert_eq "/mnt/e" "$(storage_mount_root /mnt/e/assemblrr)" "strips under WSL letter" +assert_eq "/mnt/e" "$(storage_mount_root /mnt/e/assemblrr-media/)" "media path" +assert_eq "/media/u/disk" "$(storage_mount_root /media/u/disk/foo)" "linux /media label" +assert_eq "/run/media/u/USB" "$(storage_mount_root /run/media/u/USB/data)" "run/media label" +assert_eq "/home/u/assemblrr" "$(storage_mount_root /home/u/assemblrr)" "home unchanged" +assert_success "WSL letter needs probe" storage_needs_probe /mnt/e/assemblrr +assert_success "linux media needs probe" storage_needs_probe /media/u/disk +assert_failure "home does not need probe" storage_needs_probe /home/u/assemblrr + +test_suite "probe_writable_path" +probe_ok="$tmp/probe-ok" +mkdir -p "$probe_ok" +assert_success "writable tmp is ok" probe_writable_path "$probe_ok" +assert_true "probe file cleaned up" "[ -z \"\$(ls -A \"$probe_ok\"/.assemblrr-write-test.* 2>/dev/null)\" ]" +assert_failure "missing path fails" probe_writable_path "$tmp/no-such-dir/nested" + test_suite "ensure_local_bin_on_path" path_home="$tmp/path-home" mkdir -p "$path_home"