mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-08-28 17:11:40 +00:00
993 lines
32 KiB
Bash
993 lines
32 KiB
Bash
#!/bin/bash
|
|
# assemblrr core library — sourced by all other lib modules and entry points
|
|
# Provides: color codes, logging, safe_source, find_install_directory,
|
|
# path utilities, directory helpers, wait_inline
|
|
|
|
# Guard against double-sourcing (readonly arrays would fail on re-source)
|
|
if [ -n "${_ASSEMBLRR_CORE_SOURCED:-}" ]; then
|
|
return 0
|
|
fi
|
|
readonly _ASSEMBLRR_CORE_SOURCED=1
|
|
|
|
# --- Color codes ---
|
|
readonly RED='\033[0;31m'
|
|
readonly GREEN='\033[0;32m'
|
|
readonly YELLOW='\033[1;33m'
|
|
readonly NC='\033[0m'
|
|
|
|
# --- Logging ---
|
|
# Optional: set LOG_FILE before sourcing lib/core.sh to enable file logging
|
|
_log_to_file() {
|
|
if [ -n "${LOG_FILE:-}" ]; then
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
|
fi
|
|
return 0
|
|
}
|
|
log_success() { echo -e "${GREEN}$1${NC}"; _log_to_file "INFO: $1"; return 0; }
|
|
log_error() { echo -e "${RED}$1${NC}" >&2; _log_to_file "ERROR: $1"; exit 1; }
|
|
log_warning() { echo -e "${YELLOW}$1${NC}"; _log_to_file "WARN: $1"; return 0; }
|
|
log_info() { echo "$1"; _log_to_file "INFO: $1"; return 0; }
|
|
log_debug() { _log_to_file "DEBUG: $1"; return 0; }
|
|
|
|
# --- Progress indicators ---
|
|
# Rewrite the current line as "Waiting for Radarr API (12s)".
|
|
wait_inline() {
|
|
local label="$1"
|
|
local secs="${2:-0}"
|
|
printf '\r\033[K%s (%ss)' "$label" "$secs" >&2
|
|
}
|
|
|
|
# Run a command while updating wait_inline every second.
|
|
# Captures stdout+stderr in WAIT_WHILE_OUTPUT. Returns the command's exit status.
|
|
wait_while() {
|
|
local label="$1"
|
|
shift
|
|
local out ticker rc=0 had_errexit=0
|
|
[ $# -gt 0 ] || return 1
|
|
out=$(mktemp)
|
|
[[ $- == *e* ]] && had_errexit=1
|
|
(
|
|
secs=0
|
|
trap 'exit 0' TERM INT
|
|
while true; do
|
|
wait_inline "$label" "$secs"
|
|
sleep 1
|
|
secs=$((secs + 1))
|
|
done
|
|
) &
|
|
ticker=$!
|
|
set +e
|
|
"$@" >"$out" 2>&1
|
|
rc=$?
|
|
[ "$had_errexit" = 1 ] && set -e
|
|
kill "$ticker" 2>/dev/null || true
|
|
wait "$ticker" 2>/dev/null || true
|
|
printf '\r\033[K' >&2
|
|
WAIT_WHILE_OUTPUT=$(cat "$out" 2>/dev/null || true)
|
|
rm -f "$out"
|
|
return "$rc"
|
|
}
|
|
|
|
# --- Safe source ---
|
|
# Safely source a config file — validates that it contains only KEY=VALUE
|
|
# assignments (no command substitution, pipes, etc.) before sourcing
|
|
safe_source() {
|
|
local file="$1"
|
|
if [ ! -f "$file" ]; then
|
|
return 1
|
|
fi
|
|
|
|
local line
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
line="${line%$'\r'}"
|
|
# Skip blank lines and comments
|
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
|
|
# Must match KEY=VALUE pattern (KEY: alphanumeric + underscore)
|
|
if ! [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
|
|
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Reject dangerous shell constructs in the value
|
|
case "$line" in
|
|
*'$('* | *'`'* | *';'* | *'||'* | *'&&'* | *'>'* | *'<'*)
|
|
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
done < "$file"
|
|
|
|
# Strip CR so a .env written from a Windows clone / drvfs mount can be sourced
|
|
# shellcheck disable=SC1090
|
|
source <(tr -d '\r' < "$file")
|
|
}
|
|
|
|
# --- Install directory discovery ---
|
|
# Discover installation directory from runtime config
|
|
find_install_directory() {
|
|
# Prefer the home pointer over $HOME/assemblrr so a leftover default tree
|
|
# from the VPN bootstrap cannot shadow a later non-default install path.
|
|
local search_order=(
|
|
"/opt/assemblrr/.assemblrr-config"
|
|
"$HOME/.assemblrr-config"
|
|
"$HOME/assemblrr/.assemblrr-config"
|
|
)
|
|
|
|
# Check ASSEMBLRR_DIR env var first
|
|
if [ -n "${ASSEMBLRR_DIR:-}" ]; then
|
|
echo "$ASSEMBLRR_DIR"
|
|
return 0
|
|
fi
|
|
|
|
for config_file in "${search_order[@]}"; do
|
|
if [ -f "$config_file" ]; then
|
|
safe_source "$config_file"
|
|
if [ -n "${INSTALL_DIRECTORY:-}" ]; then
|
|
echo "$INSTALL_DIRECTORY"
|
|
return 0
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
return 1
|
|
}
|
|
|
|
# Read KEY="value" from a dotenv file without sourcing (does not clobber the shell).
|
|
_pointer_get() {
|
|
local file="$1"
|
|
local key="$2"
|
|
local line
|
|
[ -f "$file" ] || return 0
|
|
line=$(grep -E "^${key}=" "$file" 2>/dev/null | tail -1) || true
|
|
line="${line#${key}=}"
|
|
line="${line%$'\r'}"
|
|
line="${line#\"}"
|
|
line="${line%\"}"
|
|
line="${line#\'}"
|
|
line="${line%\'}"
|
|
printf '%s\n' "$line"
|
|
}
|
|
|
|
# $1 = pipe-separated list, $2 = path to add. Prints the new list (no dups).
|
|
_known_path_list_add() {
|
|
local list="${1:-}"
|
|
local add="${2:-}"
|
|
local p
|
|
add="${add%/}"
|
|
[ -n "$add" ] || { printf '%s\n' "$list"; return 0; }
|
|
if [ -n "$list" ]; then
|
|
while IFS= read -r -d '|' p || [ -n "$p" ]; do
|
|
[ "$p" = "$add" ] && { printf '%s\n' "$list"; return 0; }
|
|
done <<< "${list}|"
|
|
printf '%s|%s\n' "$list" "$add"
|
|
else
|
|
printf '%s\n' "$add"
|
|
fi
|
|
}
|
|
|
|
# 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.
|
|
# KNOWN_INSTALLS / KNOWN_MEDIA remember every path we have written so a later
|
|
# install cannot hide an earlier tree on another drive.
|
|
write_install_pointer() {
|
|
local install_dir="${1%/}"
|
|
local media_dir="${2:-}"
|
|
local pointer="$HOME/.assemblrr-config"
|
|
local known_i known_m prev_i prev_m
|
|
media_dir="${media_dir%/}"
|
|
|
|
known_i=$(_pointer_get "$pointer" "KNOWN_INSTALLS")
|
|
known_m=$(_pointer_get "$pointer" "KNOWN_MEDIA")
|
|
prev_i=$(_pointer_get "$pointer" "INSTALL_DIRECTORY")
|
|
prev_m=$(_pointer_get "$pointer" "MEDIA_DIRECTORY")
|
|
known_i=$(_known_path_list_add "$known_i" "$prev_i")
|
|
known_i=$(_known_path_list_add "$known_i" "$install_dir")
|
|
known_m=$(_known_path_list_add "$known_m" "$prev_m")
|
|
known_m=$(_known_path_list_add "$known_m" "$media_dir")
|
|
|
|
{
|
|
printf 'INSTALL_DIRECTORY="%s"\n' "$install_dir"
|
|
if [ -n "$media_dir" ]; then
|
|
printf 'MEDIA_DIRECTORY="%s"\n' "$media_dir"
|
|
fi
|
|
[ -n "$known_i" ] && printf 'KNOWN_INSTALLS="%s"\n' "$known_i"
|
|
[ -n "$known_m" ] && printf 'KNOWN_MEDIA="%s"\n' "$known_m"
|
|
} > "$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" ]
|
|
}
|
|
|
|
# A finished (or mid-setup after the tree was copied) install — not an empty folder.
|
|
is_complete_assemblrr_install() {
|
|
local d="$1"
|
|
[ -n "$d" ] && [ -d "$d" ] || return 1
|
|
{ [ -f "$d/.assemblrr-config" ] || [ -f "$d/.${APP_NAME:-assemblrr}-config" ]; } || return 1
|
|
[ -f "$d/cli.sh" ] || [ -f "$d/compose/base.yaml" ]
|
|
}
|
|
|
|
# Shared location block for `status` and `uninstall`.
|
|
# Uses INSTALL_DIR, MEDIA_DIRECTORY, APP_CLI_NAME (optional heading as $1).
|
|
print_detected_locations() {
|
|
local heading="${1:-}"
|
|
local cli_path="$HOME/.local/bin/${APP_CLI_NAME:-assemblrr}"
|
|
local kind path
|
|
[ -n "$heading" ] && echo "$heading"
|
|
printf ' Config: %s\n' "${INSTALL_DIR:-unknown}"
|
|
printf ' Media: %s\n' "${MEDIA_DIRECTORY:-unknown}"
|
|
if [ -e "$cli_path" ]; then
|
|
printf ' CLI: %s\n' "$cli_path"
|
|
fi
|
|
while IFS=$'\t' read -r kind path; do
|
|
[ -n "$path" ] || continue
|
|
case "$kind" in
|
|
install) printf ' Leftover install: %s\n' "$path" ;;
|
|
media) printf ' Leftover media: %s\n' "$path" ;;
|
|
esac
|
|
done < <(list_assemblrr_leftovers "${INSTALL_DIR:-}" "${MEDIA_DIRECTORY:-}")
|
|
}
|
|
|
|
# MEDIA_DIRECTORY recorded inside an install tree (empty if none).
|
|
_assemblrr_tree_media_dir() {
|
|
local d="$1"
|
|
local cfg media
|
|
cfg=""
|
|
[ -f "$d/.assemblrr-config" ] && cfg="$d/.assemblrr-config"
|
|
[ -z "$cfg" ] && [ -f "$d/.${APP_NAME:-assemblrr}-config" ] && cfg="$d/.${APP_NAME:-assemblrr}-config"
|
|
[ -n "$cfg" ] || return 0
|
|
media=$(_pointer_get "$cfg" "MEDIA_DIRECTORY")
|
|
printf '%s\n' "${media%/}"
|
|
}
|
|
|
|
# TSV: kind<TAB>path kind is install|media
|
|
# Optional $1/$2 are already-known paths to skip.
|
|
# Finds: remembered pointer paths (any name), $root/assemblrr on home /opt /
|
|
# storage roots, and one directory down (browse-to-parent).
|
|
list_assemblrr_leftovers() {
|
|
local skip_install="${1:-}"
|
|
local skip_media="${2:-}"
|
|
local name="${APP_NAME:-assemblrr}"
|
|
local pointer=""
|
|
local root child d media
|
|
local -A seen_i=()
|
|
local -A seen_m=()
|
|
|
|
skip_install="${skip_install%/}"
|
|
skip_media="${skip_media%/}"
|
|
|
|
_leftover_emit_install() {
|
|
local p="${1%/}"
|
|
local trust="${2:-0}"
|
|
[ -n "$p" ] && [ -d "$p" ] || return 0
|
|
[ "$p" != "$skip_install" ] || return 0
|
|
[ -z "${seen_i[$p]:-}" ] || return 0
|
|
if [ "$trust" != "1" ] && ! is_assemblrr_install_tree "$p"; then
|
|
return 0
|
|
fi
|
|
seen_i[$p]=1
|
|
printf 'install\t%s\n' "$p"
|
|
media=$(_assemblrr_tree_media_dir "$p")
|
|
if [ -n "$media" ] && [ -d "$media" ] && [ "$media" != "$skip_media" ] && \
|
|
[ -z "${seen_m[$media]:-}" ]; then
|
|
seen_m[$media]=1
|
|
printf 'media\t%s\n' "$media"
|
|
fi
|
|
}
|
|
|
|
_leftover_emit_media() {
|
|
local p="${1%/}"
|
|
local trust="${2:-0}"
|
|
[ -n "$p" ] && [ -d "$p" ] || return 0
|
|
[ "$p" != "$skip_media" ] || return 0
|
|
[ -z "${seen_m[$p]:-}" ] || return 0
|
|
if [ "$trust" != "1" ] && ! is_assemblrr_media_tree "$p"; then
|
|
return 0
|
|
fi
|
|
seen_m[$p]=1
|
|
printf 'media\t%s\n' "$p"
|
|
}
|
|
|
|
if [ -f "$HOME/.assemblrr-config" ]; then
|
|
pointer="$HOME/.assemblrr-config"
|
|
elif [ -f "$HOME/.${name}-config" ]; then
|
|
pointer="$HOME/.${name}-config"
|
|
fi
|
|
if [ -n "$pointer" ]; then
|
|
local part
|
|
while IFS= read -r -d '|' part || [ -n "$part" ]; do
|
|
[ -n "$part" ] && _leftover_emit_install "$part" 1
|
|
done <<< "$(_pointer_get "$pointer" "KNOWN_INSTALLS")|"
|
|
while IFS= read -r -d '|' part || [ -n "$part" ]; do
|
|
[ -n "$part" ] && _leftover_emit_media "$part" 1
|
|
done <<< "$(_pointer_get "$pointer" "KNOWN_MEDIA")|"
|
|
fi
|
|
|
|
_leftover_scan_root() {
|
|
local r="${1%/}"
|
|
[ -n "$r" ] && [ -d "$r" ] || return 0
|
|
_leftover_emit_install "${r}/${name}" 0
|
|
_leftover_emit_media "${r}/${name}-media" 0
|
|
local c
|
|
for c in "$r"/*; do
|
|
[ -d "$c" ] || continue
|
|
_leftover_emit_install "${c}/${name}" 0
|
|
_leftover_emit_media "${c}/${name}-media" 0
|
|
done
|
|
}
|
|
|
|
_leftover_scan_root "${HOME:-}"
|
|
_leftover_scan_root /opt
|
|
while IFS= read -r root; do
|
|
[ -n "$root" ] || continue
|
|
_leftover_scan_root "$root"
|
|
done < <(list_storage_roots)
|
|
}
|
|
|
|
# --- Path utilities ---
|
|
|
|
# Expand tilde in user input paths (no eval — safe from code injection)
|
|
expand_path() {
|
|
local path="$1"
|
|
# Only expand leading ~ to $HOME, nothing else
|
|
echo "${path/#\~/$HOME}"
|
|
}
|
|
|
|
# Human label for a detected storage root (WSL drive letter or mount name).
|
|
storage_root_label() {
|
|
local root="${1%/}"
|
|
local letter
|
|
case "$root" in
|
|
/mnt/[a-z])
|
|
letter="${root##*/}"
|
|
echo "Windows ${letter^^}:"
|
|
;;
|
|
/media/*|/run/media/*)
|
|
echo "${root##*/}"
|
|
;;
|
|
*)
|
|
echo "$root"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# " (123G free)" or empty if df cannot report.
|
|
storage_free_label() {
|
|
local root="$1"
|
|
local avail
|
|
avail=$(df -hP "$root" 2>/dev/null | awk 'NR==2 {print $4}')
|
|
if [ -n "$avail" ]; then
|
|
echo " (${avail} free)"
|
|
fi
|
|
}
|
|
|
|
# Mounted volumes that are useful as install/media parents.
|
|
# WSL: /mnt/<letter>. Linux: /media and /run/media children.
|
|
list_storage_roots() {
|
|
local d
|
|
local -A seen=()
|
|
|
|
_list_storage_emit() {
|
|
local p="${1%/}"
|
|
[ -n "$p" ] && [ -d "$p" ] || return 0
|
|
[ "$p" = "${HOME:-}" ] && return 0
|
|
[ -n "${seen[$p]:-}" ] && return 0
|
|
seen[$p]=1
|
|
printf '%s\n' "$p"
|
|
}
|
|
|
|
for d in /mnt/[a-z]; do
|
|
[ -d "$d" ] || continue
|
|
if grep -Eq "[[:space:]]${d}[[:space:]]" /proc/mounts 2>/dev/null; then
|
|
_list_storage_emit "$d"
|
|
fi
|
|
done
|
|
|
|
for d in /media/"${USER:-}"/* /media/* /run/media/"${USER:-}"/*; do
|
|
[ -d "$d" ] || continue
|
|
case "$d" in
|
|
/media|"/media/${USER:-}"|/run/media|"/run/media/${USER:-}") continue ;;
|
|
esac
|
|
[ -r "$d" ] || continue
|
|
_list_storage_emit "$d"
|
|
done
|
|
}
|
|
|
|
# WSL often does not attach a USB letter until something touches /mnt/<letter>.
|
|
# 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
|
|
}
|
|
|
|
# PATH entry (~/.local/bin/<cli>) is a wrapper that execs $INSTALL_DIR/cli.sh.
|
|
# Older installs copied cli.sh plus a lib/ subset there; those leftovers are removed.
|
|
|
|
remove_stale_user_cli_libs() {
|
|
local lib="$HOME/.local/bin/lib"
|
|
local m
|
|
[ -d "$lib" ] || return 0
|
|
for m in core branding compose vpn managed_files upgrade services ui config_edit; do
|
|
rm -f "$lib/${m}.sh"
|
|
done
|
|
rmdir "$lib" 2>/dev/null || true
|
|
}
|
|
|
|
write_user_cli_wrapper() {
|
|
local dest="$1"
|
|
mkdir -p "$(dirname "$dest")"
|
|
cat >"$dest" <<'EOF'
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
# PATH wrapper — runs the install-tree CLI.
|
|
|
|
_read_install_dir() {
|
|
local f="$1" line
|
|
[ -f "$f" ] || return 1
|
|
line=$(grep -E '^INSTALL_DIRECTORY=' "$f" | head -1) || return 1
|
|
line="${line#INSTALL_DIRECTORY=}"
|
|
line="${line%$'\r'}"
|
|
line="${line#\"}"
|
|
line="${line%\"}"
|
|
line="${line#\'}"
|
|
line="${line%\'}"
|
|
[ -n "$line" ] || return 1
|
|
printf '%s\n' "$line"
|
|
}
|
|
|
|
dir="${ASSEMBLRR_DIR:-}"
|
|
if [ -z "$dir" ]; then
|
|
for f in \
|
|
/opt/assemblrr/.assemblrr-config \
|
|
"${HOME}/.assemblrr-config" \
|
|
"${HOME}/assemblrr/.assemblrr-config"
|
|
do
|
|
if dir=$(_read_install_dir "$f"); then
|
|
break
|
|
fi
|
|
dir=""
|
|
done
|
|
fi
|
|
|
|
if [ -z "$dir" ] || [ ! -f "$dir/cli.sh" ]; then
|
|
echo "assemblrr: could not find the install. Run setup, or set ASSEMBLRR_DIR." >&2
|
|
exit 1
|
|
fi
|
|
|
|
exec bash "$dir/cli.sh" "$@"
|
|
EOF
|
|
chmod +x "$dest"
|
|
}
|
|
|
|
install_user_cli_wrapper() {
|
|
local dest="$HOME/.local/bin/${APP_CLI_NAME:-assemblrr}"
|
|
write_user_cli_wrapper "$dest"
|
|
remove_stale_user_cli_libs
|
|
ensure_local_bin_on_path
|
|
}
|
|
|
|
# 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() {
|
|
local file="$1"
|
|
local line="$2"
|
|
local marker="$3"
|
|
if [ -f "$file" ] && grep -qF "$marker" "$file" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
mkdir -p "$(dirname "$file")"
|
|
printf '%s\n' "$line" >> "$file"
|
|
}
|
|
|
|
ensure_local_bin_on_path() {
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
local line='export PATH="$HOME/.local/bin:$PATH"'
|
|
local marker='.local/bin'
|
|
_append_path_line "$HOME/.profile" "$line" "$marker"
|
|
_append_path_line "$HOME/.bashrc" "$line" "$marker"
|
|
if [ -f "$HOME/.bash_profile" ]; then
|
|
_append_path_line "$HOME/.bash_profile" "$line" "$marker"
|
|
fi
|
|
if [ -f "$HOME/.zshrc" ] || command -v zsh >/dev/null 2>&1; then
|
|
_append_path_line "$HOME/.zshrc" "$line" "$marker"
|
|
fi
|
|
if command -v fish >/dev/null 2>&1 || [ -d "$HOME/.config/fish" ]; then
|
|
_append_path_line "$HOME/.config/fish/config.fish" 'fish_add_path $HOME/.local/bin' "$marker"
|
|
fi
|
|
}
|
|
|
|
# Validate that a path is safe to remove (used by safe_rm_rf)
|
|
_is_safe_rm_path() {
|
|
local dir="$1"
|
|
# Refuse empty paths
|
|
if [ -z "$dir" ]; then
|
|
log_error "Refusing to remove empty path"
|
|
return 1
|
|
fi
|
|
# Safety guard: never remove root, system paths, or shallow paths
|
|
case "$dir" in
|
|
""|"/"|"/home"|"/usr"|"/etc"|"/var"|"/opt"|"/root"|"/tmp"|"${HOME:-}")
|
|
log_error "Refusing to remove system path: $dir"
|
|
return 1
|
|
;;
|
|
esac
|
|
# Refuse to remove a user's home directory directly
|
|
if [[ "$dir" =~ ^/home/[^/]+$ ]]; then
|
|
log_error "Refusing to remove home directory: $dir"
|
|
return 1
|
|
fi
|
|
# Refuse to remove paths shallower than /home/<user>/<app> (3+ slashes required)
|
|
# Examples: /home/soulis (2 levels) = blocked, /home/soulis/assemblrr (3 levels) = allowed
|
|
local depth
|
|
depth=$(echo "$dir" | tr -cd '/' | wc -c)
|
|
if [ "$depth" -lt 2 ]; then
|
|
log_error "Refusing to remove shallow path (depth < 2): $dir"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Remove a directory with sudo fallback (Docker containers create root-owned files)
|
|
safe_rm_rf() {
|
|
local dir="$1"
|
|
# Normalize trailing slashes
|
|
while [ "${dir%/}" != "$dir" ]; do
|
|
dir="${dir%/}"
|
|
done
|
|
|
|
if ! _is_safe_rm_path "$dir"; then
|
|
return 1
|
|
fi
|
|
|
|
if ! rm -rf "$dir" 2>/dev/null; then
|
|
local parent_dir; parent_dir="$(dirname "$dir")"
|
|
local base_name; base_name="$(basename "$dir")"
|
|
# Run rm inside docker by mounting the parent directory
|
|
if ! docker run --rm -v "$parent_dir:/target" alpine rm -rf "/target/$base_name" 2>/dev/null; then
|
|
log_warning "Failed to remove $dir. You may need to remove it manually."
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# --- Directory helpers ---
|
|
|
|
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..."
|
|
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
|
|
|
|
if [ ! -w "$dir" ] || [ ! -r "$dir" ]; then
|
|
log_error "Directory \"$dir\" is not writable or readable. Check permissions"
|
|
fi
|
|
}
|
|
|
|
# Set ownership on a path (recursive). Tries without sudo first.
|
|
# Usage: ensure_owned "$path" "$uid" "$gid"
|
|
ensure_owned() {
|
|
local path="$1"
|
|
local uid="$2"
|
|
local gid="$3"
|
|
|
|
[ -e "$path" ] || return 0
|
|
|
|
local actual_uid actual_gid
|
|
actual_uid=$(stat -c '%u' "$path" 2>/dev/null || echo "")
|
|
actual_gid=$(stat -c '%g' "$path" 2>/dev/null || echo "")
|
|
|
|
if [ "$actual_uid" = "$uid" ] && [ "$actual_gid" = "$gid" ]; then
|
|
# Directory may still be mode-inaccessible to us (e.g. root sticky leftovers)
|
|
if [ -d "$path" ] && [ "$(id -u)" = "$uid" ] && [ ! -w "$path" ]; then
|
|
:
|
|
else
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if chown -R "$uid:$gid" "$path" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
if command -v sudo >/dev/null 2>&1 && sudo chown -R "$uid:$gid" "$path" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
log_error "Cannot set ownership of $path to ${uid}:${gid} (Docker may have created it as root). Fix with: sudo chown -R ${uid}:${gid} \"$path\""
|
|
}
|
|
|
|
# Create every host bind-mount target under the install tree *before* any
|
|
# container starts. Docker creates missing mount parents as root; that makes
|
|
# later setup mkdir fail. Call this immediately before compose up / VPN test.
|
|
#
|
|
# Usage: prepare_install_dirs "$install_dir" "$puid" "$pgid"
|
|
prepare_install_dirs() {
|
|
local install_dir="$1"
|
|
local uid="${2:-$(id -u)}"
|
|
local gid="${3:-$(id -g)}"
|
|
|
|
if [ -z "$install_dir" ]; then
|
|
log_error "prepare_install_dirs: install_dir is required"
|
|
fi
|
|
|
|
# Paths mounted from compose/base.yaml, vpn.yaml, and optional custom services
|
|
local -a rel_paths=(
|
|
"config"
|
|
"config/gluetun"
|
|
"config/jellyfin"
|
|
"config/emby"
|
|
"config/plex"
|
|
"config/qbittorrent"
|
|
"config/sonarr"
|
|
"config/radarr"
|
|
"config/prowlarr"
|
|
"config/seerr"
|
|
"config/recyclarr"
|
|
"config/lidarr"
|
|
"config/sabnzbd"
|
|
"config/bazarr"
|
|
"secrets"
|
|
"scripts"
|
|
)
|
|
|
|
# Ensure install root is owned by the target user
|
|
if [ -d "$install_dir" ] && [ ! -w "$install_dir" ]; then
|
|
ensure_owned "$install_dir" "$(id -u)" "$(id -g)"
|
|
fi
|
|
|
|
if [ ! -d "$install_dir" ]; then
|
|
mkdir -p "$install_dir" || log_error "Failed to create install directory: $install_dir"
|
|
fi
|
|
|
|
# If config/ already exists as root (failed prior VPN test), reclaim before mkdir
|
|
if [ -d "$install_dir/config" ] && [ ! -w "$install_dir/config" ]; then
|
|
ensure_owned "$install_dir/config" "$(id -u)" "$(id -g)"
|
|
fi
|
|
|
|
local rel
|
|
for rel in "${rel_paths[@]}"; do
|
|
local path="$install_dir/$rel"
|
|
if [ ! -d "$path" ]; then
|
|
if ! mkdir -p "$path" 2>/dev/null; then
|
|
ensure_owned "$(dirname "$path")" "$(id -u)" "$(id -g)"
|
|
mkdir -p "$path" || log_error "Failed to create $path"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
# Align tree to the service host user (PUID/PGID)
|
|
ensure_owned "$install_dir/config" "$uid" "$gid"
|
|
ensure_owned "$install_dir/secrets" "$uid" "$gid"
|
|
ensure_owned "$install_dir/scripts" "$uid" "$gid"
|
|
|
|
# Seerr runs as node (UID 1000), not necessarily PUID
|
|
if [ "$uid" != "1000" ] || [ "$gid" != "1000" ]; then
|
|
ensure_owned "$install_dir/config/seerr" "1000" "1000"
|
|
fi
|
|
|
|
# Setup process must keep writing under config/ (qBittorrent conf, etc.)
|
|
if [ "$(id -u)" = "$uid" ] && [ ! -w "$install_dir/config" ]; then
|
|
log_error "Install config directory is not writable: $install_dir/config"
|
|
fi
|
|
}
|
|
|
|
setup_directory_structure() {
|
|
local media_dir="$1"
|
|
create_and_verify_directory "$media_dir" "media"
|
|
for subdir in "${MEDIA_SUBDIRS[@]}"; do
|
|
create_and_verify_directory "$media_dir/$subdir" "media subdirectory"
|
|
done
|
|
}
|
|
|
|
verify_user_permissions() {
|
|
local username="$1"
|
|
local directory="$2"
|
|
|
|
if ! id -u "$username" &>/dev/null; then
|
|
log_error "User \"$username\" doesn't exist!"
|
|
fi
|
|
|
|
# Check write access directly for the current user (no sudo needed)
|
|
if [ "$username" = "$(id -un)" ]; then
|
|
if [ ! -w "$directory" ]; then
|
|
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
|
|
fi
|
|
elif ! sudo -u "$username" test -w "$directory"; then
|
|
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
|
|
fi
|
|
}
|
|
|
|
verify_docker() {
|
|
local docker_exe="/mnt/c/Program Files/Docker/Docker/Docker Desktop.exe"
|
|
local is_wsl=false
|
|
if grep -qi microsoft /proc/version 2>/dev/null; then
|
|
is_wsl=true
|
|
fi
|
|
|
|
# Check if docker command is missing
|
|
if ! command -v docker &>/dev/null; then
|
|
# If in WSL2 and Docker Desktop is installed on host, we can start it to mount it
|
|
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
|
|
log_info "Docker Desktop integration is offline. Attempting to start Docker Desktop on Windows..."
|
|
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
|
|
for i in {1..45}; do
|
|
wait_inline "Waiting for Docker Desktop integration" "$i"
|
|
sleep 1
|
|
if command -v docker &>/dev/null && docker info &>/dev/null; then
|
|
echo ""
|
|
log_success "Docker Desktop started successfully and integration is ready."
|
|
return 0
|
|
fi
|
|
done
|
|
echo ""
|
|
fi
|
|
log_warning "Docker is not installed or not in PATH."
|
|
return 1
|
|
fi
|
|
|
|
# If docker command exists, check if daemon is running
|
|
if ! docker info &>/dev/null; then
|
|
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
|
|
log_info "Docker daemon is not running. Attempting to start Docker Desktop on Windows..."
|
|
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
|
|
for i in {1..45}; do
|
|
wait_inline "Waiting for Docker daemon" "$i"
|
|
sleep 1
|
|
if docker info &>/dev/null; then
|
|
echo ""
|
|
log_success "Docker Desktop started successfully and is ready."
|
|
return 0
|
|
fi
|
|
done
|
|
echo ""
|
|
fi
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Helper to generate PBKDF2 password hash for qBittorrent WebUI
|
|
# Format: @ByteArray(salt:hash) where both salt and hash are Base64 encoded
|
|
qbit_generate_pbkdf2() {
|
|
local password="$1"
|
|
if [ -z "$password" ]; then
|
|
echo ""
|
|
return
|
|
fi
|
|
if command -v python3 &>/dev/null; then
|
|
QBIT_PWD="$password" python3 -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
|
|
elif command -v python &>/dev/null; then
|
|
QBIT_PWD="$password" python -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
|
|
else
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
run_docker() {
|
|
# Run the docker command directly
|
|
# Disable exit on error temporarily so we can catch the status
|
|
set +e
|
|
docker "$@"
|
|
local exit_code=$?
|
|
set -e
|
|
|
|
if [ $exit_code -ne 0 ]; then
|
|
# If it failed, check if the Docker daemon is actually running or missing
|
|
if ! command -v docker &>/dev/null || ! docker info &>/dev/null; then
|
|
log_warning "Docker daemon is not running or integration is offline. Attempting auto-start..."
|
|
if verify_docker; then
|
|
# Retry the command once
|
|
docker "$@"
|
|
return $?
|
|
else
|
|
log_error "Docker daemon is not running. Please start Docker and try again."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
return $exit_code
|
|
}
|
|
|
|
# --- Masked input ---
|
|
# Read masked input (shows asterisks for each character, works in WSL2)
|
|
read_masked() {
|
|
local prompt="$1"
|
|
local var_name="$2"
|
|
local value=""
|
|
local charcount=0
|
|
local old_settings
|
|
|
|
clear_masked_input() {
|
|
while [ "$charcount" -gt 0 ]; do
|
|
printf '\b \b' >&2
|
|
charcount=$((charcount - 1))
|
|
done
|
|
value=""
|
|
}
|
|
|
|
printf "%s" "$prompt"
|
|
|
|
# Save and disable terminal echo
|
|
old_settings=$(stty -g)
|
|
stty -echo
|
|
|
|
while IFS= read -r -n 1 char; do
|
|
# Enter key
|
|
if [[ $char == $'\n' ]] || [[ -z "$char" ]]; then
|
|
break
|
|
fi
|
|
# Backspace / Delete
|
|
if [[ $char == $'\177' ]] || [[ $char == $'\b' ]]; then
|
|
if [ $charcount -gt 0 ]; then
|
|
charcount=$((charcount - 1))
|
|
printf '\b \b' >&2
|
|
value="${value%?}"
|
|
fi
|
|
# Ctrl+U and Ctrl+W clear current masked input
|
|
elif [[ $char == $'\025' ]] || [[ $char == $'\027' ]]; then
|
|
clear_masked_input
|
|
# Escape sequences (Ctrl+Backspace, Ctrl+Delete, etc.)
|
|
elif [[ $char == $'\e' ]]; then
|
|
local seq=""
|
|
local next=""
|
|
while IFS= read -r -s -n 1 -t 0.01 next; do
|
|
seq+="$next"
|
|
[[ $next == "~" ]] && break
|
|
[ ${#seq} -ge 8 ] && break
|
|
done
|
|
|
|
case "$seq" in
|
|
"[3;5~"|"[127;5u"|"[8;5~")
|
|
clear_masked_input
|
|
;;
|
|
esac
|
|
else
|
|
charcount=$((charcount + 1))
|
|
printf '*' >&2
|
|
value+="$char"
|
|
fi
|
|
done
|
|
|
|
# Restore terminal settings
|
|
stty "$old_settings"
|
|
echo
|
|
printf -v "$var_name" '%s' "$value"
|
|
}
|