File created successfully at: /boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh (file state is current in your context — no need to Read it back)

#!/bin/bash
# ==============================================================================================
# ====================== Partnership — Unraid Container Adapter ================================
# ==============================================================================================
#
# Sourced by partnership_onboard.sh and partnership_offboard.sh via:
#   source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
#
# Provides container deploy/cleanup functions specific to the Unraid platform:
#   - Docker container deployment from Unraid CA XML templates
#   - Community Applications plugin install via `plugin install`
#   - FolderView3 docker.json manipulation
#
# Functions use variables from the calling script's scope (sourced, not exec'd):
#   MIRROR, MIRROR_IP, MIRROR_SSH_KEY, SSH_TIMEOUT, DRY_RUN, SCRIPTS_ROOT
#
# ==============================================================================================

TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"

_STACK_DEPLOYED=0
_STACK_FAILED=0

# ==============================================================================================
# ── Install Unraid plugins on the mirror ──────────────────────────────────────────────────────
#
# Installs FolderView3 via `plugin install` — Unraid Community Applications command.
# Checks if already installed before attempting. Returns 1 if install fails.
# ==============================================================================================
platform_install_plugins() {
    local remote_ip="$1" ssh_key="$2"

    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] || [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
        log "FolderView3 not configured — skipping"
        return 0
    fi

    local fv3_present
    fv3_present=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
        -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)

    if [[ "$fv3_present" == "yes" ]]; then
        log "FolderView3 already installed on $MIRROR ✅"
        return 0
    fi

    if [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would install FolderView3 on $MIRROR"
        return 0
    fi

    log "Installing FolderView3 on $MIRROR..."
    if timeout 60 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
        2>/dev/null | grep -q installed; then
        log "FolderView3 installed ✅"
        return 0
    else
        warn "FolderView3 install failed — install manually from Community Applications"
        return 1
    fi
}

# ==============================================================================================
# ── Wait for a container on the remote to be healthy/running ─────────────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state. Non-fatal after timeout — some containers take time to
# fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
    local name="$1" remote_ip="$2" ssh_key="$3"
    local max_wait=60 interval=5 elapsed=0

    [[ "$DRY_RUN" == true ]] && return 0

    log "  Waiting for $name to be ready..."
    while (( elapsed < max_wait )); do
        local status
        status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
             r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
             echo \${h:-\$r}" 2>/dev/null)

        case "$status" in
            healthy|true)
                log "  $name ready ✅"
                return 0
                ;;
            *)
                sleep "$interval"
                (( elapsed += interval ))
                ;;
        esac
    done

    warn "  $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
    return 0
}

# ==============================================================================================
# ── Deploy a container from a local Unraid CA XML template to a remote host ──────────────────
#
# Parses Port / Path / Variable Config entries from the Unraid XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
    local xml_file="$1" remote_ip="$2" ssh_key="$3"
    local xml_name
    xml_name=$(basename "$xml_file")

    local name repo network extra privileged
    name=$(       awk 'match($0,/<Name>([^<]+)<\/Name>/,        a){print a[1];exit}' "$xml_file")
    repo=$(       awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
    network=$(    awk 'match($0,/<Network>([^<]+)<\/Network>/,   a){print a[1];exit}' "$xml_file")
    extra=$(      awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
    privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")

    if [[ -z "$name" || -z "$repo" ]]; then
        warn "  Cannot parse Name/Repository from $xml_name — skipping"
        return 1
    fi

    log "Deploying $name..."

    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
            "$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
            warn "  SCP failed for $xml_name — skipping $name"
            return 1
        }
    else
        warn "  DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
    fi

    local tmp_script
    tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
    chmod 600 "$tmp_script"

    {
        echo "#!/bin/bash"
        echo "set -e"
        echo ""
        printf "docker pull %q 2>/dev/null || true\n" "$repo"
        printf "docker stop %q 2>/dev/null || true\n" "$name"
        printf "docker rm   %q 2>/dev/null || true\n" "$name"
        echo ""
        printf "docker create --name %q --restart=unless-stopped" "$name"
        [[ -n "$network"           ]] && printf " --network=%q"  "$network"
        [[ "$privileged" == "true" ]] && printf " --privileged"
        [[ -n "$extra"             ]] && printf " %s" "$extra"

        # Port mappings → -p host:container/proto
        awk '/Type="Port"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                proto = (m[1] == "udp") ? "udp" : "tcp"
                printf " -p %s:%s/%s", v[1], t[1], proto
            }
        }' "$xml_file"

        # Volume mappings → -v 'host:container:mode'
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                mode = (m[1] == "ro") ? "ro" : "rw"
                printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
            }
        }' "$xml_file"

        # Environment variables → -e 'KEY=VALUE'  (single-quoted to protect $ and special chars)
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                printf " -e %s%s=%s%s", q, t[1], v[1], q
            }
        }' "$xml_file"

        printf " %q\n" "$repo"
        echo ""
        printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
    } > "$tmp_script"

    if [[ "$DRY_RUN" == true ]]; then
        warn "  DRY RUN — would deploy $name on $MIRROR"
        rm -f "$tmp_script"
        return 0
    fi

    local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"

    if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
        "$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
       timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
        grep -q "deployed:${name}"; then
        log "  $name deployed ✅"
        rm -f "$tmp_script"
        return 0
    else
        warn "  $name deployment failed — check $MIRROR manually"
        rm -f "$tmp_script"
        return 1
    fi
}

# ==============================================================================================
# ── Deploy a stack of Unraid CA XMLs to the mirror ───────────────────────────────────────────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# Health-checks database deps (Mariadb/Redis/Postgres) between batches so dependents
# (e.g. Authelia) start cleanly.
# ==============================================================================================
deploy_xml_stack() {
    local -n xml_array_ref="$1"
    _STACK_DEPLOYED=0
    _STACK_FAILED=0

    for xml_name in "${xml_array_ref[@]}"; do
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "$xml_name not found in $TEMPLATES_DIR — skipping"
            (( _STACK_FAILED++ ))
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

        if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
            (( _STACK_DEPLOYED++ ))
            if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
                wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
            fi
        else
            (( _STACK_FAILED++ ))
        fi
    done
}

# ==============================================================================================
# ── Remove owner-deployed containers from a remote host ──────────────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK (owner's conf) to derive container
# names from local XML templates. SSHes to remote to stop, remove, and delete appdata.
# Appdata paths collected via docker inspect before removal. Safety gate: only
# /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
    local remote_ip="$1" ssh_key="$2"
    local -a xml_names=()
    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
            continue
        fi

        local appdata_paths
        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "docker stop '$cname' >/dev/null 2>&1
             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "  $cname removed from $MIRROR ✅" || \
            log "  $cname not found on $MIRROR — skipping"

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
                log "  Appdata removed on $MIRROR: $path ✅" || \
                warn "  Failed to remove appdata on $MIRROR: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── Remove owner-deployed containers locally (mirror-initiated offboard) ─────────────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
    local owner_ip="$1" ssh_key="$2"
    local -a xml_names=()

    if [[ -n "$owner_ip" ]]; then
        local -a auth_arr arr_arr
        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
    fi

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) locally..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found locally — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname"
            warn "  DRY RUN — would delete appdata for $cname"
            continue
        fi

        local appdata_paths=""
        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
            appdata_paths=$(docker inspect \
                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
            _PM_TRAP_STOPPED+=("$cname")
            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
                log "  $cname removed ✅" || warn "  $cname rm failed"
        else
            log "  $cname not found locally — skipping"
        fi

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── Remove a FolderView3 folder from a remote host ───────────────────────────────────────────
#
# Uses jq on the remote to delete the named folder from docker.json.
# No-op if PARTNERSHIP_FOLDERVIEW3 is not enabled or jq is unavailable on remote.
# ==============================================================================================
platform_remove_folderview3_on_remote() {
    local remote_ip="$1" ssh_key="$2" folder_name="$3" server_label="${4:-remote}"

    [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] && return 0

    log "Removing FolderView3 folder '$folder_name' from $server_label..."
    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "fv3='/boot/config/plugins/folder.view3/docker.json'
             [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
             jq --arg n '$folder_name' \
                'with_entries(select(.value.name != \$n))' \
                \"\$fv3\" > \"\${fv3}.tmp\" && \
             mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "FolderView3 '$folder_name' removed from $server_label ✅" || \
            warn "FolderView3 folder not found on $server_label or jq unavailable — skipping"
    else
        warn "DRY RUN — would remove FolderView3 folder '$folder_name' from $server_label"
    fi
}


Now update `onboard.sh` — remove the big helper blocks and replace with the source + call pattern:


Edit

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"


The file /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh has been updated successfully. (file state is current in your context — no need to Read it back)

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────


Now remove the three helper function blocks from onboard.sh:


# ==============================================================================================
# ── HELPER: deploy a container from a local Unraid template XML to a remote host ─────────────
#
# Parses Port / Path / Variable Config entries from the XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
    local xml_file="$1" remote_ip="$2" ssh_key="$3"
    local xml_name
    xml_name=$(basename "$xml_file")

    # Extract top-level fields
    local name repo network extra privileged
    name=$(       awk 'match($0,/<Name>([^<]+)<\/Name>/,        a){print a[1];exit}' "$xml_file")
    repo=$(       awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
    network=$(    awk 'match($0,/<Network>([^<]+)<\/Network>/,   a){print a[1];exit}' "$xml_file")
    extra=$(      awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
    privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")

    if [[ -z "$name" || -z "$repo" ]]; then
        warn "  Cannot parse Name/Repository from $xml_name — skipping"
        return 1
    fi

    log "Deploying $name..."

    # SCP the XML so Unraid Docker Manager recognises and can manage the container
    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
            "$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
            warn "  SCP failed for $xml_name — skipping $name"
            return 1
        }
    else
        warn "  DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
    fi

    # Build a self-contained deploy script locally.
    # Writing to a temp file keeps credentials out of SSH command strings.
    local tmp_script
    tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
    chmod 600 "$tmp_script"

    {
        echo "#!/bin/bash"
        echo "set -e"
        echo ""
        printf "docker pull %q 2>/dev/null || true\n" "$repo"
        printf "docker stop %q 2>/dev/null || true\n" "$name"
        printf "docker rm   %q 2>/dev/null || true\n" "$name"
        echo ""
        printf "docker create --name %q --restart=unless-stopped" "$name"
        [[ -n "$network"         ]] && printf " --network=%q"  "$network"
        [[ "$privileged" == "true" ]] && printf " --privileged"
        [[ -n "$extra"           ]] && printf " %s" "$extra"

        # Port mappings → -p host:container/proto
        awk '/Type="Port"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                proto = (m[1] == "udp") ? "udp" : "tcp"
                printf " -p %s:%s/%s", v[1], t[1], proto
            }
        }' "$xml_file"

        # Volume mappings → -v 'host:container:mode'
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                mode = (m[1] == "ro") ? "ro" : "rw"
                printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
            }
        }' "$xml_file"

        # Environment variables → -e 'KEY=VALUE'  (single-quoted to protect $ and special chars)
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                printf " -e %s%s=%s%s", q, t[1], v[1], q
            }
        }' "$xml_file"

        printf " %q\n" "$repo"
        echo ""
        printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
    } > "$tmp_script"

    if [[ "$DRY_RUN" == true ]]; then
        warn "  DRY RUN — would deploy $name on $MIRROR"
        rm -f "$tmp_script"
        return 0
    fi

    # SCP deploy script → remote, execute, clean up both sides
    local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"

    if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
        "$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
       timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
        grep -q "deployed:${name}"; then
        log "  $name deployed ✅"
        rm -f "$tmp_script"
        return 0
    else
        warn "  $name deployment failed — check $MIRROR manually"
        rm -f "$tmp_script"
        return 1
    fi
}

# ==============================================================================================
# ── HELPER: wait for a container on the remote to be healthy/running ─────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state for containers with no healthcheck. Non-fatal after timeout
# — Authelia may take time to fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
    local name="$1" remote_ip="$2" ssh_key="$3"
    local max_wait=60 interval=5 elapsed=0

    [[ "$DRY_RUN" == true ]] && return 0

    log "  Waiting for $name to be ready..."
    while (( elapsed < max_wait )); do
        local status
        status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
             r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
             echo \${h:-\$r}" 2>/dev/null)

        case "$status" in
            healthy|true)
                log "  $name ready ✅"
                return 0
                ;;
            starting|unhealthy|false|"")
                sleep "$interval"
                (( elapsed += interval ))
                ;;
            *)
                sleep "$interval"
                (( elapsed += interval ))
                ;;
        esac
    done

    warn "  $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
    return 0
}

# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#


# ==============================================================================================
# ── HELPER: deploy a container from a local Unraid template XML to a remote host ─────────────
#
# Parses Port / Path / Variable Config entries from the XML, SCPs the template and a
# self-contained deploy script to the remote, executes it, then cleans up both sides.
# Credentials are never passed as SSH command-line args — they stay in the SCPed script.
# ==============================================================================================
deploy_container_from_xml() {
    local xml_file="$1" remote_ip="$2" ssh_key="$3"
    local xml_name
    xml_name=$(basename "$xml_file")

    # Extract top-level fields
    local name repo network extra privileged
    name=$(       awk 'match($0,/<Name>([^<]+)<\/Name>/,        a){print a[1];exit}' "$xml_file")
    repo=$(       awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")
    network=$(    awk 'match($0,/<Network>([^<]+)<\/Network>/,   a){print a[1];exit}' "$xml_file")
    extra=$(      awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")
    privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")

    if [[ -z "$name" || -z "$repo" ]]; then
        warn "  Cannot parse Name/Repository from $xml_name — skipping"
        return 1
    fi

    log "Deploying $name..."

    # SCP the XML so Unraid Docker Manager recognises and can manage the container
    if [[ "$DRY_RUN" == false ]]; then
        timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
            "$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {
            warn "  SCP failed for $xml_name — skipping $name"
            return 1
        }
    else
        warn "  DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"
    fi

    # Build a self-contained deploy script locally.
    # Writing to a temp file keeps credentials out of SSH command strings.
    local tmp_script
    tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)
    chmod 600 "$tmp_script"

    {
        echo "#!/bin/bash"
        echo "set -e"
        echo ""
        printf "docker pull %q 2>/dev/null || true\n" "$repo"
        printf "docker stop %q 2>/dev/null || true\n" "$name"
        printf "docker rm   %q 2>/dev/null || true\n" "$name"
        echo ""
        printf "docker create --name %q --restart=unless-stopped" "$name"
        [[ -n "$network"         ]] && printf " --network=%q"  "$network"
        [[ "$privileged" == "true" ]] && printf " --privileged"
        [[ -n "$extra"           ]] && printf " %s" "$extra"

        # Port mappings → -p host:container/proto
        awk '/Type="Port"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                proto = (m[1] == "udp") ? "udp" : "tcp"
                printf " -p %s:%s/%s", v[1], t[1], proto
            }
        }' "$xml_file"

        # Volume mappings → -v 'host:container:mode'
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, /Mode="([^"]+)"/,   m)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                mode = (m[1] == "ro") ? "ro" : "rw"
                printf " -v %s%s:%s:%s%s", q, v[1], t[1], mode, q
            }
        }' "$xml_file"

        # Environment variables → -e 'KEY=VALUE'  (single-quoted to protect $ and special chars)
        awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {
            match($0, /Target="([^"]+)"/, t)
            match($0, />([^<]+)<\/Config>/, v)
            if (t[1] != "" && v[1] != "") {
                printf " -e %s%s=%s%s", q, t[1], v[1], q
            }
        }' "$xml_file"

        printf " %q\n" "$repo"
        echo ""
        printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"
    } > "$tmp_script"

    if [[ "$DRY_RUN" == true ]]; then
        warn "  DRY RUN — would deploy $name on $MIRROR"
        rm -f "$tmp_script"
        return 0
    fi

    # SCP deploy script → remote, execute, clean up both sides
    local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"

    if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \
        "$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \
       timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
        "bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \
        grep -q "deployed:${name}"; then
        log "  $name deployed ✅"
        rm -f "$tmp_script"
        return 0
    else
        warn "  $name deployment failed — check $MIRROR manually"
        rm -f "$tmp_script"
        return 1
    fi
}

# ==============================================================================================
# ── HELPER: wait for a container on the remote to be healthy/running ─────────────────────────
#
# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;
# falls back to the running state for containers with no healthcheck. Non-fatal after timeout
# — Authelia may take time to fully initialize but the deploy itself succeeded.
# ==============================================================================================
wait_for_container_healthy() {
    local name="$1" remote_ip="$2" ssh_key="$3"
    local max_wait=60 interval=5 elapsed=0

    [[ "$DRY_RUN" == true ]] && return 0

    log "  Waiting for $name to be ready..."
    while (( elapsed < max_wait )); do
        local status
        status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)
             r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)
             echo \${h:-\$r}" 2>/dev/null)

        case "$status" in
            healthy|true)
                log "  $name ready ✅"
                return 0
                ;;
            starting|unhealthy|false|"")
                sleep "$interval"
                (( elapsed += interval ))
                ;;
            *)
                sleep "$interval"
                (( elapsed += interval ))
                ;;
        esac
    done

    warn "  $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"
    return 0
}

# ==============================================================================================
# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────
#

[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"

-# ── HELPER: deploy a container from a local Unraid template XML to a remote host ─────────────

-# Parses Port / Path / Variable Config entries from the XML, SCPs the template and a

-# self-contained deploy script to the remote, executes it, then cleans up both sides.

-# Credentials are never passed as SSH command-line args — they stay in the SCPed script.

-    local xml_file="$1" remote_ip="$2" ssh_key="$3"

-    local name repo network extra privileged

-    name=$(       awk 'match($0,/<Name>([^<]+)<\/Name>/,        a){print a[1];exit}' "$xml_file")

-    repo=$(       awk 'match($0,/<Repository>([^<]+)<\/Repository>/,a){print a[1];exit}' "$xml_file")

-    network=$(    awk 'match($0,/<Network>([^<]+)<\/Network>/,   a){print a[1];exit}' "$xml_file")

-    extra=$(      awk 'match($0,/<ExtraParams>([^<]*)<\/ExtraParams>/,a){print a[1];exit}' "$xml_file")

-    privileged=$( awk 'match($0,/<Privileged>([^<]+)<\/Privileged>/,a){print a[1];exit}' "$xml_file")

-    if [[ -z "$name" || -z "$repo" ]]; then

-        warn "  Cannot parse Name/Repository from $xml_name — skipping"

-    # SCP the XML so Unraid Docker Manager recognises and can manage the container

-        timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \

-            "$xml_file" "root@${remote_ip}:${TEMPLATES_DIR}/${xml_name}" 2>/dev/null || {

-            warn "  SCP failed for $xml_name — skipping $name"

-        warn "  DRY RUN — would SCP $xml_name → $MIRROR:${TEMPLATES_DIR}/"

-    # Build a self-contained deploy script locally.

-    # Writing to a temp file keeps credentials out of SSH command strings.

-    tmp_script=$(mktemp /tmp/deploy_XXXXXX.sh)

-        printf "docker pull %q 2>/dev/null || true\n" "$repo"

-        printf "docker stop %q 2>/dev/null || true\n" "$name"

-        printf "docker rm   %q 2>/dev/null || true\n" "$name"

-        printf "docker create --name %q --restart=unless-stopped" "$name"

-        [[ -n "$network"         ]] && printf " --network=%q"  "$network"

-        [[ "$privileged" == "true" ]] && printf " --privileged"

-        [[ -n "$extra"           ]] && printf " %s" "$extra"

-        # Port mappings → -p host:container/proto

-        # Volume mappings → -v 'host:container:mode'

-        awk 'BEGIN{q=sprintf("%c",39)} /Type="Path"/ {

-        # Environment variables → -e 'KEY=VALUE'  (single-quoted to protect $ and special chars)

-        awk 'BEGIN{q=sprintf("%c",39)} /Type="Variable"/ {

-        printf "docker start %q && echo 'deployed:%s'\n" "$name" "$name"

-        warn "  DRY RUN — would deploy $name on $MIRROR"

-    # SCP deploy script → remote, execute, clean up both sides

-    local remote_script="/tmp/deploy_${name//[^a-zA-Z0-9_]/_}.sh"

-    if timeout "$SSH_TIMEOUT" scp -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" \

-        "$tmp_script" "root@${remote_ip}:${remote_script}" 2>/dev/null && \

-       timeout 120 ssh -i "$ssh_key" -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \

-        "bash '$remote_script' 2>&1; rc=\$?; rm -f '$remote_script'; exit \$rc" 2>/dev/null | \

-        grep -q "deployed:${name}"; then

-        warn "  $name deployment failed — check $MIRROR manually"

-# ── HELPER: wait for a container on the remote to be healthy/running ─────────────────────────

-# Polls docker inspect on the remote. Prefers the health status if a healthcheck is defined;

-# falls back to the running state for containers with no healthcheck. Non-fatal after timeout

-# — Authelia may take time to fully initialize but the deploy itself succeeded.

-    local name="$1" remote_ip="$2" ssh_key="$3"

-    local max_wait=60 interval=5 elapsed=0

-    [[ "$DRY_RUN" == true ]] && return 0

-    log "  Waiting for $name to be ready..."

-        status=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \

-            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \

-            "h=\$(docker inspect --format '{{.State.Health.Status}}' '$name' 2>/dev/null)

-             r=\$(docker inspect --format '{{.State.Running}}' '$name' 2>/dev/null)

-            starting|unhealthy|false|"")

-                (( elapsed += interval ))

-    warn "  $name not confirmed healthy after ${max_wait}s — continuing (may affect dependents)"

# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers


Now remove the `deploy_xml_stack` block and its globals:


# ==============================================================================================
# ── HELPER: deploy a stack of XMLs to the mirror, health-checking db deps between batches ────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# This avoids the process-substitution capture problem: warn() writes to stdout, so any
# read -r X Y < <(func) would capture warn output as the count values.
# ==============================================================================================
_STACK_DEPLOYED=0
_STACK_FAILED=0

deploy_xml_stack() {
    local -n xml_array_ref="$1"
    _STACK_DEPLOYED=0
    _STACK_FAILED=0

    for xml_name in "${xml_array_ref[@]}"; do
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "$xml_name not found in $TEMPLATES_DIR — skipping"
            (( _STACK_FAILED++ ))
            continue
        fi

        # Extract container name to use for health-wait matching
        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

        if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
            (( _STACK_DEPLOYED++ ))
            # Health-check database deps before continuing — they must be ready before
            # Authelia/app containers that depend on them can start cleanly.
            if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
                wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
            fi
        else
            (( _STACK_FAILED++ ))
        fi
    done
}

# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────


# ==============================================================================================
# ── HELPER: deploy a stack of XMLs to the mirror, health-checking db deps between batches ────
#
# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.
# This avoids the process-substitution capture problem: warn() writes to stdout, so any
# read -r X Y < <(func) would capture warn output as the count values.
# ==============================================================================================
_STACK_DEPLOYED=0
_STACK_FAILED=0

deploy_xml_stack() {
    local -n xml_array_ref="$1"
    _STACK_DEPLOYED=0
    _STACK_FAILED=0

    for xml_name in "${xml_array_ref[@]}"; do
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "$xml_name not found in $TEMPLATES_DIR — skipping"
            (( _STACK_FAILED++ ))
            continue
        fi

        # Extract container name to use for health-wait matching
        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

        if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then
            (( _STACK_DEPLOYED++ ))
            # Health-check database deps before continuing — they must be ready before
            # Authelia/app containers that depend on them can start cleanly.
            if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then
                wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"
            fi
        else
            (( _STACK_FAILED++ ))
        fi
    done
}

# ==============================================================================================
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────

-# ── HELPER: deploy a stack of XMLs to the mirror, health-checking db deps between batches ────

-# Sets globals _STACK_DEPLOYED and _STACK_FAILED rather than printing to stdout.

-# This avoids the process-substitution capture problem: warn() writes to stdout, so any

-# read -r X Y < <(func) would capture warn output as the count values.

-    for xml_name in "${xml_array_ref[@]}"; do

-        local xml_file="${TEMPLATES_DIR}/${xml_name}"

-        if [[ ! -f "$xml_file" ]]; then

-            warn "$xml_name not found in $TEMPLATES_DIR — skipping"

-        # Extract container name to use for health-wait matching

-        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

-        if deploy_container_from_xml "$xml_file" "$MIRROR_IP" "$MIRROR_SSH_KEY"; then

-            # Health-check database deps before continuing — they must be ready before

-            # Authelia/app containers that depend on them can start cleanly.

-            if [[ -n "$cname" ]] && echo "$cname" | grep -qiE 'mariadb|redis|postgres|mysql'; then

-                wait_for_container_healthy "$cname" "$MIRROR_IP" "$MIRROR_SSH_KEY"


Now replace Step 2 with the `platform_install_plugins` call:


# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"

if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && [[ -n "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
    FV3_PRESENT=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
        -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)

    if [[ "$FV3_PRESENT" == "yes" ]]; then
        log "FolderView3 already installed on $MIRROR ✅"
    elif [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would install FolderView3 on $MIRROR"
    else
        log "Installing FolderView3 on $MIRROR..."
        timeout 60 ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
            "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
            2>/dev/null | grep -q installed && \
            log "FolderView3 installed ✅" || {
            warn "FolderView3 install failed — install manually from Community Applications"
            STEP_PLUGINS_OK=false
        }
    fi
else
    log "FolderView3 not configured — skipping"
fi

# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"

platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false


# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"

if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && [[ -n "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
    FV3_PRESENT=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
        -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \
        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)

    if [[ "$FV3_PRESENT" == "yes" ]]; then
        log "FolderView3 already installed on $MIRROR ✅"
    elif [[ "$DRY_RUN" == true ]]; then
        warn "DRY RUN — would install FolderView3 on $MIRROR"
    else
        log "Installing FolderView3 on $MIRROR..."
        timeout 60 ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
            "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
            2>/dev/null | grep -q installed && \
            log "FolderView3 installed ✅" || {
            warn "FolderView3 install failed — install manually from Community Applications"
            STEP_PLUGINS_OK=false
        }
    fi
else
    log "FolderView3 not configured — skipping"
fi

# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
echo ""
echo "━━━ Step 2 — Plugin Install on Mirror ━━━"

platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false

-if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && [[ -n "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then

-    FV3_PRESENT=$(timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \

-        -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$MIRROR_IP" \

-        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)

+platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false

-    if [[ "$FV3_PRESENT" == "yes" ]]; then

-        log "FolderView3 already installed on $MIRROR ✅"

-    elif [[ "$DRY_RUN" == true ]]; then

-        warn "DRY RUN — would install FolderView3 on $MIRROR"

-        log "Installing FolderView3 on $MIRROR..."

-        timeout 60 ssh -i "$MIRROR_SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \

-            "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \

-            2>/dev/null | grep -q installed && \

-            log "FolderView3 installed ✅" || {

-            warn "FolderView3 install failed — install manually from Community Applications"

-    log "FolderView3 not configured — skipping"

# ── Step 3: Stop mirror's existing auth stack ─────────────────────────────────────────────────

echo "━━━ Step 3 — Stop Mirror Auth Stack ━━━"


Now update `offboard.sh` — same pattern:


SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --reason=*) REASON="${arg#--reason=}" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

parse_args "${FILTERED_ARGS[@]}"

# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --reason=*) REASON="${arg#--reason=}" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

parse_args "${FILTERED_ARGS[@]}"

# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"


The file /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh has been updated successfully. (file state is current in your context — no need to Read it back)

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --reason=*) REASON="${arg#--reason=}" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

parse_args "${FILTERED_ARGS[@]}"

# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPTS_ROOT="$SCRIPT_DIR/.."
SSH_TIMEOUT=15

source "$SCRIPTS_ROOT/load_config.sh"
source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"

# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
REASON="manual"
FILTERED_ARGS=()

for arg in "$@"; do
    case "$arg" in
        --reason=*) REASON="${arg#--reason=}" ;;
        *)          FILTERED_ARGS+=("$arg") ;;
    esac
done

parse_args "${FILTERED_ARGS[@]}"

# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"


Now remove the two cleanup function blocks from offboard.sh:


# ==============================================================================================
# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
# container names from local XML templates. SSHes to remote to stop, remove, and delete
# appdata. Appdata paths are collected via docker inspect before removal so they aren't
# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
    local remote_ip="$1" ssh_key="$2"
    local -a xml_names=()
    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
            continue
        fi

        # Collect appdata paths via docker inspect before removal
        local appdata_paths
        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "docker stop '$cname' >/dev/null 2>&1
             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "  $cname removed from $MIRROR ✅" || \
            log "  $cname not found on $MIRROR — skipping"

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
                log "  Appdata removed on $MIRROR: $path ✅" || \
                warn "  Failed to remove appdata on $MIRROR: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
    local owner_ip="$1" ssh_key="$2"
    local -a xml_names=()

    if [[ -n "$owner_ip" ]]; then
        local -a auth_arr arr_arr
        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
    fi

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) locally..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found locally — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname"
            warn "  DRY RUN — would delete appdata for $cname"
            continue
        fi

        local appdata_paths=""
        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
            appdata_paths=$(docker inspect \
                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
            _PM_TRAP_STOPPED+=("$cname")
            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
                log "  $cname removed ✅" || warn "  $cname rm failed"
        else
            log "  $cname not found locally — skipping"
        fi

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────


# ==============================================================================================
# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
#
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
# container names from local XML templates. SSHes to remote to stop, remove, and delete
# appdata. Appdata paths are collected via docker inspect before removal so they aren't
# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.
# ==============================================================================================
cleanup_deployed_stack_on_remote() {
    local remote_ip="$1" ssh_key="$2"
    local -a xml_names=()
    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname on $MIRROR"
            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"
            continue
        fi

        # Collect appdata paths via docker inspect before removal
        local appdata_paths
        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
            "docker stop '$cname' >/dev/null 2>&1
             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
            grep -q removed && \
            log "  $cname removed from $MIRROR ✅" || \
            log "  $cname not found on $MIRROR — skipping"

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
                log "  Appdata removed on $MIRROR: $path ✅" || \
                warn "  Failed to remove appdata on $MIRROR: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────
#
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
# local templates-user/ copies (SCPed there during onboard) to get container names and
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
# ==============================================================================================
cleanup_deployed_stack_locally() {
    local owner_ip="$1" ssh_key="$2"
    local -a xml_names=()

    if [[ -n "$owner_ip" ]]; then
        local -a auth_arr arr_arr
        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
             detect_hosts 2>/dev/null
             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
        xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
    fi

    if [[ ${#xml_names[@]} -eq 0 ]]; then
        log "Could not read deployed stack from owner — skipping auth/arr cleanup"
        return 0
    fi

    log "Removing owner-deployed containers (auth/arr stacks) locally..."
    for xml_name in "${xml_names[@]}"; do
        [[ -z "$xml_name" ]] && continue
        local xml_file="${TEMPLATES_DIR}/${xml_name}"
        if [[ ! -f "$xml_file" ]]; then
            warn "  $xml_name not found locally — skipping"
            continue
        fi

        local cname
        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
        [[ -z "$cname" ]] && continue

        if [[ "$DRY_RUN" == true ]]; then
            warn "  DRY RUN — would stop + rm $cname"
            warn "  DRY RUN — would delete appdata for $cname"
            continue
        fi

        local appdata_paths=""
        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
            appdata_paths=$(docker inspect \
                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
            _PM_TRAP_STOPPED+=("$cname")
            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \
                log "  $cname removed ✅" || warn "  $cname rm failed"
        else
            log "  $cname not found locally — skipping"
        fi

        while IFS= read -r path; do
            [[ -z "$path" ]] && continue
            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"
        done <<< "$appdata_paths"
    done
}

# ==============================================================================================
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────

[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"

-# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────

-# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive

-# container names from local XML templates. SSHes to remote to stop, remove, and delete

-# appdata. Appdata paths are collected via docker inspect before removal so they aren't

-# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.

-    [[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")

-    [[ ${#PARTNERSHIP_ARR_STACK[@]}  -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")

-    if [[ ${#xml_names[@]} -eq 0 ]]; then

-        log "No auth/arr stack arrays configured — skipping deployed stack cleanup"

-    log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."

-    for xml_name in "${xml_names[@]}"; do

-        [[ -z "$xml_name" ]] && continue

-        local xml_file="${TEMPLATES_DIR}/${xml_name}"

-        if [[ ! -f "$xml_file" ]]; then

-            warn "  $xml_name not found in local $TEMPLATES_DIR — skipping"

-        cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")

-        if [[ "$DRY_RUN" == true ]]; then

-            warn "  DRY RUN — would stop + rm $cname on $MIRROR"

-            warn "  DRY RUN — would delete appdata for $cname on $MIRROR"

-        # Collect appdata paths via docker inspect before removal

-        appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \

-            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \

-            "docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \

-             | awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)

-        timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \

-            -o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \

-            "docker stop '$cname' >/dev/null 2>&1

-             docker rm   '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \

-            log "  $cname removed from $MIRROR ✅" || \

-            log "  $cname not found on $MIRROR — skipping"

-            [[ -z "$path" ]] && continue

-                "rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \

-                log "  Appdata removed on $MIRROR: $path ✅" || \

-                warn "  Failed to remove appdata on $MIRROR: $path"

-# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────

-# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the

-# local templates-user/ copies (SCPed there during onboard) to get container names and

-# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.

-        mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \

-            -o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \

-            "source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null

-             printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')

-        mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \

-             printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')

-    if [[ ${#xml_names[@]} -eq 0 ]]; then

-        log "Could not read deployed stack from owner — skipping auth/arr cleanup"

-    log "Removing owner-deployed containers (auth/arr stacks) locally..."

-    for xml_name in "${xml_names[@]}"; do

-        [[ -z "$xml_name" ]] && continue

-        if [[ ! -f "$xml_file" ]]; then

-            warn "  $xml_name not found locally — skipping"

-        if [[ "$DRY_RUN" == true ]]; then

-            warn "  DRY RUN — would stop + rm $cname"

-            warn "  DRY RUN — would delete appdata for $cname"

-        if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then

-            appdata_paths=$(docker inspect \

-                --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \

-                "$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')

-            timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true

-            _PM_TRAP_STOPPED+=("$cname")

-            timeout "${DOCKER_TIMEOUT:-30}" docker rm   "$cname" >/dev/null 2>&1 && \

-                log "  $cname removed ✅" || warn "  $cname rm failed"

-            log "  $cname not found locally — skipping"

-            [[ -z "$path" ]] && continue

-            rm -rf "$path" && log "  Appdata removed: $path ✅" || warn "  Failed to remove: $path"

# Mirror-initiated path only. Called before start_own_stack so Emby is still running.


Now replace the FolderView3 removal block in offboard Step 7 with the adapter call:


    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
        OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
        log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."
        if [[ "$DRY_RUN" == false ]]; then
            timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
                "fv3='/boot/config/plugins/folder.view3/docker.json'
                 [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
                 jq --arg n '$OWNER_FOLDER_ON_MIRROR' \
                    'with_entries(select(.value.name != \$n))' \
                    \"\$fv3\" > \"\${fv3}.tmp\" && \
                 mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
                grep -q removed && \
                log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR ✅" || \
                warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"
        else
            warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"
        fi
    fi

    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
    OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
    platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"


    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
        OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
        log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."
        if [[ "$DRY_RUN" == false ]]; then
            timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
                -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \
                "fv3='/boot/config/plugins/folder.view3/docker.json'
                 [[ -f \"\$fv3\" ]] && command -v jq >/dev/null 2>&1 && \
                 jq --arg n '$OWNER_FOLDER_ON_MIRROR' \
                    'with_entries(select(.value.name != \$n))' \
                    \"\$fv3\" > \"\${fv3}.tmp\" && \
                 mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \
                grep -q removed && \
                log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR ✅" || \
                warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"
        else
            warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"
        fi
    fi

    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
    OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")
    platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"

# Remove fallback coverage containers (by *-owner_short naming pattern)

-    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then

-        OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")

-        log "Removing FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR..."

-        if [[ "$DRY_RUN" == false ]]; then

-            timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \

-                -o ConnectTimeout="$SSH_TIMEOUT" root@"$MIRROR_IP" \

-                "fv3='/boot/config/plugins/folder.view3/docker.json'

-                 jq --arg n '$OWNER_FOLDER_ON_MIRROR' \

-                    'with_entries(select(.value.name != \$n))' \

-                 mv \"\${fv3}.tmp\" \"\$fv3\" && echo removed" 2>/dev/null | \

-                log "FolderView3 '$OWNER_FOLDER_ON_MIRROR' removed from $MIRROR ✅" || \

-                warn "FolderView3 folder not found on $MIRROR or jq unavailable — skipping"

-            warn "DRY RUN — would remove FolderView3 folder '$OWNER_FOLDER_ON_MIRROR' from $MIRROR"

+    OWNER_FOLDER_ON_MIRROR=$(derive_partner_folder_name "$OWNER")

+    platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"

warn "$MIRROR unreachable — remote container cleanup skipped"

warn "Run 'partnership_offboard.sh' on $MIRROR to clean up manually"


Now make the new file executable and do a quick sanity check on the line counts:


Bash

chmod +x /boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh
wc -l /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
       /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh \
       /boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh

Check line counts after refactor


  659 /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh
  561 /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh
  409 /boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh
 1629 total


Good. Let me verify the source lines are in the right places and the call sites look clean:


grep -n "source.*containers\|platform_install_plugins\|platform_remove_folderview3\|TEMPLATES_DIR" \
    /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh

Verify source lines and call sites in both scripts


/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:77:source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:445:    platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:42:# XML templates are the single source of truth for deployed containers
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:143:source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh"
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:501:platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false


# Verify no TEMPLATES_DIR remains in either main script
grep -n "TEMPLATES_DIR" \
    /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh

Confirm TEMPLATES_DIR removed from main scripts


(Bash completed with no output)


# Confirm deploy_container_from_xml and the cleanup functions are gone from main scripts
grep -n "deploy_container_from_xml\|wait_for_container_healthy\|deploy_xml_stack\|cleanup_deployed_stack" \
    /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh

Confirm moved functions are gone from main scripts


/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:259:        cleanup_deployed_stack_locally "$OWNER_IP" "$OWNER_SSH_KEY" || STEP_STACK_CLEANUP_OK=false
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:440:    cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:235:# deploy_container_from_xml() already stops/removes containers with the same name as what's
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:524:    deploy_xml_stack PARTNERSHIP_AUTH_STACK
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:551:    deploy_xml_stack PARTNERSHIP_ARR_STACK


The calls remain (correct — they use the functions now sourced from containers.sh). Let me confirm no function *definitions* linger in the main scripts:


grep -n "^deploy_container_from_xml\|^wait_for_container_healthy\|^deploy_xml_stack\|^cleanup_deployed_stack\|^platform_install_plugins" \
    /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh

Confirm no function definitions remain in main scripts


/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:501:platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false


That's a call site, not a definition. All function definitions have moved cleanly to containers.sh.

Done. Here's the shape of what happened:

- **`Plugin/unraid/Partnership/containers.sh`** — new adapter file (409 lines) containing everything Unraid-specific: `TEMPLATES_DIR`, XML template parsing, `deploy_container_from_xml`, `deploy_xml_stack`, `wait_for_container_healthy`, `cleanup_deployed_stack_on_remote`, `cleanup_deployed_stack_locally`, `platform_install_plugins` (plugin install via CA), and `platform_remove_folderview3_on_remote`.

- **`partnership_onboard.sh`** — shrank by ~220 lines. Sources `containers.sh` right after `load_config.sh`, Step 2 is now a single call: `platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false`.

- **`partnership_offboard.sh`** — shrank by ~160 lines. Sources `containers.sh`, FolderView3 removal in Step 7 is now `platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"`.

Adding TrueNAS support later is just `Plugin/truenas/Partnership/containers.sh` with equivalent functions that use TrueNAS jails/iocage instead of Unraid CA XMLs — the main orchestration scripts are untouched.


ok do we havepartner installing anu plugins?


grep -n "plugin\|Plugin\|install" /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh | grep -iv "FolderView\|fv3\|FOLDERVIEW\|containers.sh\|plugin install\|plugin.*URL\|Plugin/\|emhttp/plugins"

Check for any non-FolderView3 plugin installs in onboard


22:#   Step 1: SSH key setup        — generate keypair, install on mirror, update conf
150:PHASE1_ONLY=false   # OWNER: SSH + conf push only (HOST2 not yet installed)
187:# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.
300:            'grep SCRIPTS_DIR /boot/config/plugins/varaverk/varaverk.cfg 2>/dev/null | cut -d= -f2 | tr -d "\"'"'"'" 2>/dev/null' 2>/dev/null | tr -d '[:space:]')
301:        OWNER_SCRIPTS_DIR="${OWNER_SCRIPTS_DIR:-/boot/config/plugins/varaverk}"
370:        log "SSH to $MIRROR already works ✅ — skipping key install"
378:            # Soft-fail: generate key locally if not present, then tell user to install manually
379:            warn "Could not install key on $MIRROR automatically (no terminal for password prompt)"
393:                # Write key-ready flag so UI can show the manual-install state
415:# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.
419:        # SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.
420:        # UI will show "key ready, install manually" state via HOST2_KEY_READY flag.
429:        echo "  SSH keys:    key generated ✅ — NOT yet installed on $MIRROR ⚠"
434:        echo "  ACTION NEEDED: install the key on $MIRROR:"
490:    echo "  HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin."
497:# ── Step 2: Plugins ───────────────────────────────────────────────────────────────────────────
501:platform_install_plugins "$MIRROR_IP" "$MIRROR_SSH_KEY" || STEP_PLUGINS_OK=false
640:echo "  Step 2 — Plugins:       $(_ok "$STEP_PLUGINS_OK")"


grep -n "plugin install\|FOLDERVIEW3\|PARTNERSHIP_FOLDERVIEW3" /boot/config/plugins/varaverk/Configurations/master.conf 2>/dev/null | head -20

Check master.conf for FolderView3 and plugin config


230:    PARTNERSHIP_FOLDERVIEW3=true            # create/remove FolderView3 folder on onboard/offboard
231:    PARTNERSHIP_FOLDERVIEW3_URL=""          # CA plugin URL — leave empty to skip auto-install


Just FolderView3, and only if `PARTNERSHIP_FOLDERVIEW3_URL` is set in master.conf (it's empty by default, so auto-install is skipped unless you fill it in). The Step 2 header says "Plugin Install" but it's only ever done that one thing.

No other plugins are installed anywhere in the partnership flow.


lets remove it. our own page has support for peoplw who already have it, but is a standalone feature also


Let me check what other FolderView3 references exist in the partnership scripts before cutting:


grep -n "FOLDERVIEW\|folderview\|FolderView\|folder.view" \
    /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh \
    /boot/config/plugins/varaverk/Partnership/partnership_manager.sh \
    /boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh \
    /boot/config/plugins/varaverk/Configurations/master.conf

Find all FolderView3 references across partnership files


/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:12:#   - FolderView3 docker.json manipulation
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:27:# Installs FolderView3 via `plugin install` — Unraid Community Applications command.
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:33:    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] || [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:34:        log "FolderView3 not configured — skipping"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:41:        "test -d /usr/local/emhttp/plugins/folder.view3 && echo yes" 2>/dev/null)
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:44:        log "FolderView3 already installed on $MIRROR ✅"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:49:        warn "DRY RUN — would install FolderView3 on $MIRROR"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:53:    log "Installing FolderView3 on $MIRROR..."
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:55:        "plugin install '$PARTNERSHIP_FOLDERVIEW3_URL' 2>/dev/null && echo installed" \
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:57:        log "FolderView3 installed ✅"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:60:        warn "FolderView3 install failed — install manually from Community Applications"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:383:# ── Remove a FolderView3 folder from a remote host ───────────────────────────────────────────
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:386:# No-op if PARTNERSHIP_FOLDERVIEW3 is not enabled or jq is unavailable on remote.
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:388:platform_remove_folderview3_on_remote() {
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:391:    [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" != true ]] && return 0
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:393:    log "Removing FolderView3 folder '$folder_name' from $server_label..."
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:397:            "fv3='/boot/config/plugins/folder.view3/docker.json'
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:404:            log "FolderView3 '$folder_name' removed from $server_label ✅" || \
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:405:            warn "FolderView3 folder not found on $server_label or jq unavailable — skipping"
/boot/config/plugins/varaverk/Plugin/unraid/Partnership/containers.sh:407:        warn "DRY RUN — would remove FolderView3 folder '$folder_name' from $server_label"
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:23:#   Step 2: Plugin install       — FolderView3 and required plugins on mirror
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:29:#   Step 7: Partnership onboard  — configure WebUIs → owner IP, write state, FolderView3, Emby
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:424:            warn "Local setup had issues — FolderView3 may need manual setup"
/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh:478:        warn "Local setup had issues — FolderView3 may need manual setup"
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:443:    # Remove mirror's FolderView3 fallback folder (owner's containers were hosted there)
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:445:    platform_remove_folderview3_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY" "$OWNER_FOLDER_ON_MIRROR" "$MIRROR"
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:548:[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh:549:    echo "  FolderView3: ${PARTNER_FOLDER_NAME:-} (local) + mirror remote cleaned ✅"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:277:# Used by folderview3_remove_partner_folder() and cleanup_partner_containers() — also shared
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:576:# ── FolderView3 integration ───────────────────────────────────────────────────────────────────
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:577:FOLDERVIEW3_DIR="/usr/local/emhttp/plugins/folder.view3"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:578:FOLDERVIEW3_JSON="/boot/config/plugins/folder.view3/docker.json"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:589:folderview3_ensure_plugin() {
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:590:    if [[ -d "$FOLDERVIEW3_DIR" ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:591:        log "FolderView3 plugin present ✅"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:594:    if [[ -z "${PARTNERSHIP_FOLDERVIEW3_URL:-}" ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:595:        warn "FolderView3 plugin not installed and PARTNERSHIP_FOLDERVIEW3_URL is empty"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:596:        warn "Install manually from Community Applications or set PARTNERSHIP_FOLDERVIEW3_URL in master.conf"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:599:    warn "FolderView3 not found — installing from CA..."
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:601:        warn "DRY RUN — would run: plugin install $PARTNERSHIP_FOLDERVIEW3_URL"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:604:    plugin install "$PARTNERSHIP_FOLDERVIEW3_URL" 2>/dev/null && \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:605:        log "FolderView3 installed ✅" || {
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:606:        warn "FolderView3 install failed — folder will not be created"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:611:folderview3_create_partner_folder() {
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:616:    log "FolderView3: creating folder '$partner_name' with ${#containers[@]} container(s)..."
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:618:    folderview3_ensure_plugin || return 1
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:621:        warn "DRY RUN — would create FolderView3 folder: $partner_name"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:629:    if [[ ! -f "$FOLDERVIEW3_JSON" ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:630:        mkdir -p "$(dirname "$FOLDERVIEW3_JSON")"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:631:        echo '{}' > "$FOLDERVIEW3_JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:638:        "$FOLDERVIEW3_JSON" 2>/dev/null)
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:640:        log "FolderView3: folder '$partner_name' already exists (id: $existing) — skipping"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:656:       "$FOLDERVIEW3_JSON" > "${FOLDERVIEW3_JSON}.tmp" && \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:657:       mv "${FOLDERVIEW3_JSON}.tmp" "$FOLDERVIEW3_JSON" && \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:658:       log "FolderView3: folder '$partner_name' created with ${#containers[@]} container(s) ✅" || {
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:659:       warn "FolderView3: failed to write JSON — check $FOLDERVIEW3_JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:664:folderview3_remove_partner_folder() {
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:667:    log "FolderView3: removing folder '$partner_name' and stopping its containers..."
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:669:    if [[ ! -f "$FOLDERVIEW3_JSON" ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:670:        log "FolderView3: JSON not found — nothing to remove"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:675:        warn "jq not found — cannot manage FolderView3 JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:683:        "$FOLDERVIEW3_JSON" 2>/dev/null)
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:686:        log "FolderView3: folder '$partner_name' not found — nothing to remove"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:723:            "$FOLDERVIEW3_JSON" > "${FOLDERVIEW3_JSON}.tmp" && \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:724:            mv "${FOLDERVIEW3_JSON}.tmp" "$FOLDERVIEW3_JSON" && \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:725:            log "FolderView3: folder '$partner_name' removed ✅" || \
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:726:            warn "FolderView3: failed to remove folder from JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:728:        warn "DRY RUN — would remove folder '$partner_name' from $FOLDERVIEW3_JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:732:        log "FolderView3 cleanup: $stopped stopped, $removed removed, $failed failed"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:806:# Uses FolderView3 folder if enabled (precise list), else falls back to FALLBACK_*_COVERS_* config.
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:816:        if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:817:            folderview3_remove_partner_folder "$folder_name"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:835:    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:836:        folderview3_remove_partner_folder "$folder_name"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1274:    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1277:        if [[ -f "$FOLDERVIEW3_JSON" ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1280:                "$FOLDERVIEW3_JSON" 2>/dev/null)
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1284:                    "$FOLDERVIEW3_JSON" 2>/dev/null)
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1285:                echo "  FolderView3:  $FOLDER_STATUS_NAME ✅"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1288:                echo "  FolderView3:  $FOLDER_STATUS_NAME — not found in JSON"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1291:            echo "  FolderView3:  config not found ($FOLDERVIEW3_JSON)"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1417:    # (FolderView3, setup.db flag) while waiting for HOST2 to install and onboard.
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1423:        if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1424:            echo "FolderView3 Integration"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1429:                folderview3_create_partner_folder "$PARTNER_FOLDER_NAME" "${PARTNER_CONTAINERS[@]}"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1434:            log "FolderView3 not configured — skipping"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1573:    # FolderView3 — create partner folder with this server's fallback containers for remote
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1574:    if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1576:        echo "━━━ $ICON_CONTAINERS FolderView3 Integration ━━━"
/boot/config/plugins/varaverk/Partnership/partnership_manager.sh:1581:            folderview3_create_partner_folder "$PARTNER_FOLDER_NAME" "${PARTNER_CONTAINERS[@]}"
/boot/config/plugins/varaverk/Configurations/master.conf:226:# FolderView3 plugin integration — chodeus/VladoPortos/scolcipitato.
/boot/config/plugins/varaverk/Configurations/master.conf:229:# Plugin config: /boot/config/plugins/folder.view3/docker.json
/boot/config/plugins/varaverk/Configurations/master.conf:230:    PARTNERSHIP_FOLDERVIEW3=true            # create/remove FolderView3 folder on onboard/offboard
/boot/config/plugins/varaverk/Configurations/master.conf:231:    PARTNERSHIP_FOLDERVIEW3_URL=""          # CA plugin URL — leave empty to skip auto-install


This is a significant removal — manager.sh has the deepest FolderView3 integration. Let me read the relevant sections before cutting: