Ctrl K
ComunidadGuías…

install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
set -euo pipefail

REPO_UPSTREAM="https://github.com/efficiencyx/JunOS.git"
REPO="${JUN_REPO:-$REPO_UPSTREAM}"
DIR="${JUN_DIR:-JunOS}"
# JUN_REF pins an exact branch or tag and skips the channel
# lookup. without it JUN_CHANNEL (or the .env of an existing
# install) decides, see choose_channel.
REF="${JUN_REF:-}"
HANDOFF="${JUN_HANDOFF:-0}"
CHANNEL=""
TARGET=""
DOCKER_SCRIPT_URL="https://get.docker.com"
DOCKER_SCRIPT=""
NVIDIA_REPO="https://nvidia.github.io/libnvidia-container"
NVIDIA_GUIDE_URL="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html"
NVIDIA_CONFIG=/etc/nvidia-container-runtime/config.toml
OS="$(uname -s)"
NEED_SG=0
NEED_SUDO=0
DOCKER_JUST_INSTALLED=0
ADDED_DOCKER_GROUP=""
# 1 only when a person picked Express, at the prompt or with
# JUN_EXPRESS=1. JUN_YES on its own can mean an unattended run,
# and the two want opposite things the moment something needs
# asking.
EXPRESS=0
# 1 for Cloud: the model runs on colab.ipynb (or Kaggle) in
# inference only mode, everything else here
CLOUD=0
REUSE=0
if [ "$HANDOFF" = 1 ]; then
    NEED_SG="${JUN_NEED_SG:-0}"
    NEED_SUDO="${JUN_NEED_SUDO:-0}"
    DOCKER_JUST_INSTALLED="${JUN_DOCKER_JUST_INSTALLED:-0}"
fi

if [ "$(id -u)" -eq 0 ]; then
    SUDO=""
elif command -v sudo >/dev/null 2>&1; then
    SUDO="sudo"
else
    SUDO=""
fi

if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then
    R=$'\033[0m'; B=$'\033[1m'; D=$'\033[2m'
    ACCENT=$'\033[38;2;124;158;255m'    #7c9eff
    PURPLE=$'\033[38;2;155;114;203m'    #9b72cb
    OK=$'\033[38;2;123;216;143m'        #7bd88f
    DANGER=$'\033[38;2;255;138;138m'    #ff8a8a
    WARN=$'\033[38;2;255;200;112m'      #ffc870
    DIM=$'\033[38;2;142;146;149m'       #8e9295
    MUTED=$'\033[38;2;196;199;197m'     #c4c7c5
else
    R=; B=; D=; ACCENT=; PURPLE=; OK=; DANGER=; WARN=; DIM=; MUTED=
fi

UI_W=52   # interior width of the window frame

_rule() { local n=$UI_W out=; while [ "$n" -gt 0 ]; do out="$out$1"; n=$((n-1)); done; printf '%s' "$out"; }

banner() {
    local os_name="Linux"; [ "$OS" = "Darwin" ] && os_name="macOS"
    printf '\n'
    printf '  %s┌%s┐%s\n' "$DIM" "$(_rule ─)" "$R"
    printf '  %s│%s %s●%s %s●%s %s●%s   %sjun@omega%s:%s ~/install%s%*s%s│%s\n' \
        "$DIM" "$R" "$DANGER" "$R" "$WARN" "$R" "$OK" "$R" \
        "$ACCENT" "$R" "$MUTED" "$R" $((UI_W-29)) "" "$DIM" "$R"
    printf '  %s└%s┘%s\n\n' "$DIM" "$(_rule ─)" "$R"
    printf '   %s%sΩ%s  %s%sJUN OS%s\n\n' "$B" "$PURPLE" "$R" "$B" "$ACCENT" "$R"
    printf '   %sWelcome to Jun OS%s %s·%s %somega build%s\n' "$MUTED" "$R" "$DIM" "$R" "$DIM" "$R"
    printf '   %sshell detected:%s %s%s%s %s-%s installer\n\n' "$DIM" "$R" "$B$ACCENT" "$os_name" "$R" "$DIM" "$R"
}

step()  { printf '   %s$%s %s%s%s\n' "$OK" "$R" "$B" "$1" "$R"; }
ok()    { printf '     %s✓%s %s%s%s\n' "$OK" "$R" "$MUTED" "$1" "$R"; }
note()  { printf '     %s%s%s\n' "$DIM" "$1" "$R"; }
warn_() { printf '   %s!%s %s%s%s\n' "$WARN" "$R" "$WARN" "$1" "$R" >&2; }
fail_()  { printf '   %s✗%s %s%s%s\n' "$DANGER" "$R" "$DANGER" "$1" "$R" >&2; }

_spin() {
    local pid="$1" msg="$2" frames='|/-\' i=0
    while kill -0 "$pid" 2>/dev/null; do
        printf '\r     %s%s%s %s%s%s' "$ACCENT" "${frames:i++%4:1}" "$R" "$DIM" "$msg" "$R"
        sleep 0.1
    done
    printf '\r\033[K'
}

# long installs need feedback on a TTY, so show a spinner there.
# piped runs stay quiet. keep output in a temp log and show it
# only on failure, then stop the installer.
run() {
    local msg="$1"; shift
    local log rc=0; log="$(mktemp)"
    if [ -t 1 ]; then
        "$@" >"$log" 2>&1 &
        local pid=$!
        _spin "$pid" "$msg"
        wait "$pid" || rc=$?
    else
        "$@" >"$log" 2>&1 || rc=$?
    fi
    if [ "$rc" -eq 0 ]; then
        ok "$msg"
        rm -f "$log"
    else
        fail_ "$msg"
        sed 's/^/       /' "$log" >&2
        rm -f "$log"
        exit "$rc"
    fi
}

# run() but the output streams live. for the long steps (image
# builds, model pulls), where a silent spinner looks like a hang.
run_live() {
    local msg="$1"; shift
    printf '     %s→%s %s%s%s\n' "$ACCENT" "$R" "$DIM" "$msg" "$R"
    local rc=0
    "$@" 2>&1 | sed -u 's/^/       /' || rc=$?
    if [ "$rc" -eq 0 ]; then
        ok "$msg"
    else
        fail_ "$msg"
        exit "$rc"
    fi
}

sha256_of() {
    if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
    elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
    elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{ print $NF }'
    fi
}

# --proto '=https' so a redirect can't walk us down to plain http
# on the way, --tlsv1.2 so we never negotiate something older
# with whoever answers.
fetch() {
    curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --retry-delay 2 -o "$2" "$1"
}

MODEL_REPO="hf.co/efficiencyx/Jun-LoRA"

# the drafter, the small model that guesses her next tokens,
# has to match the same Gemma 4 size and QAT branch (QAT is
# quantization-aware training). a mismatch still runs but
# accepted tokens dropped from 2.74 to 2.10 per pass. QAT and
# plain repos are not interchangeable.
MTP_DRAFTER_12B="hf.co/Janvitos/gemma-4-12B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0"
MTP_DRAFTER_E4B="hf.co/amaranus/Gemma-4-E4B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0"
MTP_DRAFTER_E2B="hf.co/amaranus/Gemma-4-E2B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0"

# Live2D is drawn by the browser on the same card Jun sits on,
# and it wants about this much while a chat is open. Left out of
# the budget the install looks fine and then she spills onto the
# CPU the moment somebody opens the tab.
LIVE2D_VRAM_MB=1500

mtp_drafter_for() {
    case "$1" in
        *Jun-LoRA-12B*)  echo "$MTP_DRAFTER_12B" ;;
        *E4B*)           echo "$MTP_DRAFTER_E4B" ;;
        *E2B*)           echo "$MTP_DRAFTER_E2B" ;;
    esac
}

# roughly what each model takes in VRAM once it's loaded, drafter
# included. close enough to tell "fits" from "does not", which is
# all it's Used for.
mtp_budget_mb() {
    case "$1" in
        *Jun-LoRA-12B*Q8_0) echo 13500 ;;
        *Jun-LoRA-12B*Q6_K) echo 10800 ;;
        *Jun-LoRA-12B*)     echo 8100 ;;
        *E4B*Q8_0)          echo 9000 ;;
        *E4B*Q6_K)          echo 6500 ;;
        *E4B*)              echo 5000 ;;
        *E2B*Q8_0)          echo 5600 ;;
        *E2B*Q6_K)          echo 4500 ;;
        *E2B*)              echo 3400 ;;
        *)                  echo 0 ;;
    esac
}

# menu picks MUST get the quant recommend_model named
quant_for() {  # size vram-MB -> quant
    local mb="${2:-0}"
    case "$1" in
        12B) if [ "$mb" -ge 23500 ]; then echo Q8_0; elif [ "$mb" -ge 15500 ]; then echo Q6_K; else echo Q4_K_M; fi ;;
        E4B) if [ "$mb" -ge 9500 ]; then echo Q8_0; else echo Q4_K_M; fi ;;
        E2B) if [ "$mb" -ge 7500 ]; then echo Q8_0; elif [ "$mb" -ge 5500 ]; then echo Q6_K; else echo Q4_K_M; fi ;;
    esac
}

gguf_gb() {
    case "$1:$2" in
        12B:Q4_K_M) echo 7.4 ;; 12B:Q6_K) echo 9.8 ;; 12B:Q8_0) echo 12.7 ;;
        E4B:Q4_K_M) echo 5.3 ;; E4B:Q6_K) echo 6.2 ;; E4B:Q8_0) echo 8.0 ;;
        E2B:Q4_K_M) echo 3.4 ;; E2B:Q6_K) echo 3.8 ;; E2B:Q8_0) echo 4.9 ;;
    esac
}

ask_quant() {  # full-ref -> full-ref, enter keeps its quant
    local base="${1%:*}" rec="${1##*:}" size q i=1 note ans
    size="${base##*Jun-LoRA-}"; size="${size%-GGUF}"
    {
        printf '\n     %sselect a quant for Jun %s%s\n' "$B" "$size" "$R"
        for q in Q4_K_M Q6_K Q8_0; do
            case "$q" in Q4_K_M) note=", lightest" ;; Q8_0) note=", closest to the full model" ;; *) note="" ;; esac
            [ "$q" = "$rec" ] && note="$note (recommended)"
            printf '       %s%s%s  %-7s %s- %s GB%s%s\n' "$ACCENT" "$i" "$R" "$q" "$DIM" "$(gguf_gb "$size" "$q")" "$note" "$R"
            i=$((i + 1))
        done
        printf '     %s$%s choice %s[enter = recommended %s]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$rec" "$R" "$ACCENT" "$R"
    } > /dev/tty
    read -r ans < /dev/tty || ans=""
    case "$(printf '%s' "$ans" | tr '[:lower:]' '[:upper:]')" in
        1|Q4_K_M) q=Q4_K_M ;; 2|Q6_K) q=Q6_K ;; 3|Q8_0) q=Q8_0 ;;
        "") q="$rec" ;;
        *) printf '     %s✗%s unrecognized choice, using %s\n' "$DANGER" "$R" "$rec" > /dev/tty; q="$rec" ;;
    esac
    echo "$base:$q"
}

resolve_model() {  # alias|full-ref [vram-MB] -> full-ref
    local size
    case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
        12b|jun|best|"") size=12B ;;
        e4b|balanced|fast) size=E4B ;;
        e2b|fastest|cpu) size=E2B ;;
        *) echo "$1"; return ;;
    esac
    echo "$MODEL_REPO-$size-GGUF:$(quant_for "$size" "${2:-}")"
}

# rocm-smi is ROCm userland, not the amdgpu kernel driver, and a
# plain mesa desktop does not have it. so "no rocm-smi" says
# NOTHING about whether there is a card in the box. this file
# ships with the driver itself, one per card, VRAM in bytes.
# start.sh detects AMD off /dev/kfd for the same reason, and the
# two scripts disagreeing is how a 7900 XTX ended up being handed
# E2B.
amd_sysfs_vram_mb() {
    local f name
    for f in /sys/class/drm/card*/device/mem_info_vram_total; do
        [ -r "$f" ] || continue
        # card0-DP-1 and friends are connectors, and their device symlink
        # lands back on the same card. count those and every GPU shows up
        # twice.
        name="${f#/sys/class/drm/}"; name="${name%%/*}"
        case "$name" in *-*) continue ;; esac
        # an APU's carveout is in here too (the slice of system RAM
        # it keeps as VRAM), usually 512MB. that is not a card you
        # plan a model around, and calling it one also flips karaoke
        # onto a GPU that cannot hold the stems.
        awk '{ mb = int($1 / 1048576); if (mb >= 1024) print mb }' "$f"
    done
}

# one line per AMD card, VRAM in MB. rocm-smi if it's installed,
# else the driver's own sysfs.
amd_vram_mb_list() {
    local out=
    if command -v rocm-smi >/dev/null 2>&1; then
        out="$(rocm-smi --showmeminfo vram --csv 2>/dev/null \
            | awk -F, 'NR>1 { gsub(/[^0-9]/,"",$2); if ($2 != "") print int($2/1048576) }')"
    fi
    [ -n "$out" ] || out="$(amd_sysfs_vram_mb)"
    [ -z "$out" ] || printf '%s\n' "$out"
}

detect_vram_mb() {
    if command -v nvidia-smi >/dev/null 2>&1; then
        nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
            | tr -dc '0-9\n' | sort -nr | head -n1
        return
    fi
    amd_vram_mb_list | sort -nr | head -n1
}

detect_vram_total_mb() {
    if command -v nvidia-smi >/dev/null 2>&1; then
        nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
            | tr -dc '0-9\n' | awk '{ t += $1 } END { if (t > 0) print t }'
        return
    fi
    amd_vram_mb_list | awk '{ t += $1 } END { if (t > 0) print t }'
}

detect_gpu_count() {
    if command -v nvidia-smi >/dev/null 2>&1; then
        nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | grep -c '[0-9]' || true
        return
    fi
    amd_vram_mb_list | grep -c '[0-9]' || true
}

recommend_model() {
    local mb="${1:-0}"
    if   [ "$mb" -ge 11500 ]; then resolve_model 12b "$mb"
    elif [ "$mb" -ge 7500 ]; then resolve_model e4b "$mb"
    else                          resolve_model e2b "$mb"
    fi
}

set_env() {
    local key="$1" val="$2" tmp
    if grep -qE "^${key}=" .env 2>/dev/null; then
        tmp="$(mktemp)"
        grep -vE "^${key}=" .env > "$tmp"
        printf '%s=%s\n' "$key" "$val" >> "$tmp"
        mv "$tmp" .env
    else
        printf '%s=%s\n' "$key" "$val" >> .env
    fi
}

# the || true is load bearing. pipefail plus set -e turns a grep
# that just found nothing into an aborted install.
env_value() {
    grep -E "^$1=" .env 2>/dev/null | tail -1 | cut -d= -f2- || true
}

# Cloud is Express apart from its own three questions (the
# notebook lines, voice, karaoke). JUN_CLOUD_ASK=1 is what still
# asks those under the JUN_YES=1 Express runs on.
can_ask() {
    [ -r /dev/tty ] || return 1
    [ "${JUN_YES:-}" != "1" ] || [ "${JUN_CLOUD_ASK:-}" = "1" ]
}

gen_key() {
    if command -v openssl >/dev/null 2>&1; then
        openssl rand -hex 16
    else
        head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n'
    fi
}

# Only when the line is missing altogether. an empty
# OMEGA_REGISTRATION_KEY= is the operator saying "off", and every
# upgrade run comes back through here, so filling that in would
# silently turn the gate back on behind their back.
ensure_key() {
    grep -qE "^$1=" .env 2>/dev/null && return 0
    set_env "$1" "$(gen_key)"
}

ask_model_ref() {
    local vram rec alias ans label gpus
    if [ "${TENSOR_PARALLEL:-off}" = on ]; then
        vram="$(detect_vram_total_mb)"; label=" combined"
    else
        vram="$(detect_vram_mb)"; label=" on the largest card"
    fi
    gpus="$(detect_gpu_count)"
    [ "${gpus:-0}" -gt 1 ] || label=""
    rec="$(recommend_model "$vram")"

    if [ -n "${JUN_MODEL:-}" ] || [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        alias="${JUN_MODEL:-$rec}"
    else
        {
            printf '\n     %sselect a model%s\n' "$B" "$R"
            printf '       %s1%s  Jun 12B  %s-%s highest quality\n' "$ACCENT" "$R" "$DIM" "$R"
            printf '       %s2%s  Jun E4B  %s-%s balanced\n' "$ACCENT" "$R" "$DIM" "$R"
            printf '       %s3%s  Jun E2B  %s-%s lightest / CPU-friendly\n' "$ACCENT" "$R" "$DIM" "$R"
            [ -n "$vram" ] && printf '       %sdetected %sMB VRAM%s%s\n' "$DIM" "$vram" "$label" "$R"
            printf '     %s$%s choice %s[enter = recommended %s]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$rec" "$R" "$ACCENT" "$R"
        } > /dev/tty
        read -r ans < /dev/tty || ans=""
        case "$ans" in
            1) alias=12b ;; 2) alias=e4b ;; 3) alias=e2b ;;
            "") alias="$rec" ;;
            *) printf '     %s✗%s unrecognized choice, using recommended model\n' "$DANGER" "$R" > /dev/tty; alias="$rec" ;;
        esac
        MODEL_REF="$(ask_quant "$(resolve_model "$alias" "$vram")")"
        return
    fi

    MODEL_REF="$(resolve_model "$alias" "$vram")"
}

# start.sh asks GitHub about releases on every boot when this is
# on, so somebody has to say yes. Express asks too. an unattended
# run (JUN_YES without Express, or no terminal) writes nothing and
# the check stays off until a run with a person in front of it.
# a value already in .env is the answer from last time.
ask_update_check() {
    local ans val
    case "$(printf '%s' "${JUN_UPDATE_CHECK:-}" | tr '[:upper:]' '[:lower:]')" in
        1|on|yes|true) val=on ;;
        0|off|no|false) val=off ;;
        *) val="$(env_value UPDATE_CHECK)" ;;
    esac
    if [ -z "$val" ]; then
        if [ ! -r /dev/tty ] || { [ "${JUN_YES:-}" = "1" ] && [ "$EXPRESS" != 1 ]; }; then
            note "update check off - JUN_UPDATE_CHECK=on turns it on"
            return 0
        fi
        printf '     %s$%s enable the update checker, for new releases and security fixes? %s(asks GitHub when she starts. it only warns you, it never updates anything for you)%s %s[Y/n]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r ans < /dev/tty || ans=""
        case "$ans" in n|N|no|NO) val=off ;; *) val=on ;; esac
    fi
    set_env UPDATE_CHECK "$val"
    ok "update check $val"
}

# off skips the seperate demucs/CUDA/ROCm image (demucs is the
# model that splits a song into stems). JUN_KARAOKE=on|off
# defaults on and wins over KARAOKE, same as install.ps1.
ask_karaoke() {
    local v preset
    preset="${JUN_KARAOKE:-${KARAOKE:-}}"
    if [ -n "$preset" ]; then
        case "$(printf '%s' "$preset" | tr '[:upper:]' '[:lower:]')" in
            off|0|false|no|n) KARAOKE=off ;; *) KARAOKE=on ;;
        esac
    elif ! can_ask; then
        KARAOKE=on
    else
        printf '     %s$%s enable karaoke %s(sing along - adds a few GB)%s %s[Y/n]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r v < /dev/tty || v=""
        case "$v" in n|N|no|NO) KARAOKE=off ;; *) KARAOKE=on ;; esac
    fi
}

# "run karaoke separation on the GPU? [Y/n]". we only ask when
# there is really a card. yes by default, splitting a song takes
# minutes on a CPU and seconds on a GPU, and it only holds the
# VRAM while a song is being prepared. sets $SEP_DEVICE, cuda or
# cpu. without a prompt: JUN_KARAOKE_GPU=on|off, default on.
ask_karaoke_gpu() {
    local v gpus
    gpus="$(detect_gpu_count)"
    if [ "${gpus:-0}" -lt 1 ]; then
        SEP_DEVICE=cpu
    elif [ -n "${JUN_KARAOKE_GPU:-}" ]; then
        case "$(printf '%s' "$JUN_KARAOKE_GPU" | tr '[:upper:]' '[:lower:]')" in
            off|0|false|no|n|cpu) SEP_DEVICE=cpu ;; *) SEP_DEVICE=cuda ;;
        esac
    elif [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        SEP_DEVICE=cuda
    else
        printf '     %s$%s use the GPU to split songs? %s(much faster; frees the VRAM again afterwards)%s %s[Y/n]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r v < /dev/tty || v=""
        case "$v" in n|N|no|NO) SEP_DEVICE=cpu ;; *) SEP_DEVICE=cuda ;; esac
    fi
}

# "split one model across every GPU? [y/N]". off by default even
# with several cards. when the cards don't match, spreading her
# out is usually SLOWER per word than just fitting her on the big
# one. sets $TENSOR_PARALLEL, on or off. without a prompt:
# JUN_TENSOR_PARALLEL=on|off, default off.
ask_tensor_parallel() {
    local v gpus
    gpus="$(detect_gpu_count)"
    if [ "${gpus:-0}" -lt 2 ]; then
        TENSOR_PARALLEL=off
    elif [ -n "${JUN_TENSOR_PARALLEL:-}" ]; then
        case "$(printf '%s' "$JUN_TENSOR_PARALLEL" | tr '[:upper:]' '[:lower:]')" in
            on|1|true|yes|y) TENSOR_PARALLEL=on ;; *) TENSOR_PARALLEL=off ;;
        esac
    elif [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        TENSOR_PARALLEL=off
    else
        printf '     %s$%s %s GPUs found - use all of them for one model? %s(slower per token, but fits bigger models)%s %s[y/N]%s %s→%s ' \
            "$OK" "$R" "$gpus" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r v < /dev/tty || v=""
        case "$v" in y|Y|yes|YES) TENSOR_PARALLEL=on ;; *) TENSOR_PARALLEL=off ;; esac
    fi
}

# MTP, multi-token prediction. Jun checks every token the drafter
# guesses, so it only changes speed, what she accepts stays the
# same. how much faster depends on the card. Express turns it on
# and measures the depth. JUN_MTP=on|off sets $MTP and
# $MTP_DRAFTER.
ask_mtp() {
    local v preset
    MTP=off
    MTP_DRAFTER="$(mtp_drafter_for "$MODEL_REF")"
    # Only Gemma 4 ships an MTP head, and only for the sizes we map
    # above. On anything else there is no drafter to pair, so there
    # is no question to ask.
    [ -n "$MTP_DRAFTER" ] || return 0

    preset="${JUN_MTP:-}"
    if [ -n "$preset" ]; then
        case "$(printf '%s' "$preset" | tr '[:upper:]' '[:lower:]')" in
            on|1|true|yes|y) MTP=on ;; *) MTP=off ;;
        esac
        return 0
    fi
    if [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        MTP=on
        return 0
    fi

    printf '     %s$%s enable experimental multi-token prediction? %s(speculative decoding - faster on some cards, slower on others)%s %s[y/N]%s %s→%s ' \
        "$OK" "$R" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
    read -r v < /dev/tty || v=""
    case "$v" in y|Y|yes|YES) MTP=on ;; *) MTP=off ;; esac
}

# "how many tokens ahead?" auto measures instead of guessing, and
# it's the right answer for almost everybody. the best depth
# swings with the card. on a 3060 the gain is gone by 3 and depth
# 4 is slower than not drafting at all, a bigger card can afford
# to guess deeper. sets $MTP_DEPTH to auto or 1-4. without a
# prompt: JUN_MTP_DEPTH=auto|1|2|3|4.
ask_mtp_depth() {
    local v
    MTP_DEPTH=auto
    if [ -n "${JUN_MTP_DEPTH:-}" ]; then
        case "$(printf '%s' "$JUN_MTP_DEPTH" | tr '[:upper:]' '[:lower:]')" in
            1|2|3|4) MTP_DEPTH="$JUN_MTP_DEPTH" ;; *) MTP_DEPTH=auto ;;
        esac
        return 0
    fi
    if [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        return 0
    fi

    {
        printf '\n     %show many tokens should it guess ahead?%s\n' "$B" "$R"
        printf '       %sauto%s  %s-%s try each one on your hardware and keep the fastest %s(recommended)%s\n' "$ACCENT" "$R" "$DIM" "$R" "$DIM" "$R"
        printf '       %s1-4%s   %s-%s fix it yourself; deeper guesses cost more to check\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '     %s$%s choice %s[enter = auto]%s %s→%s ' "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R"
    } > /dev/tty
    read -r v < /dev/tty || v=""
    case "$v" in
        1|2|3|4) MTP_DEPTH="$v" ;;
        ""|a|auto|AUTO) MTP_DEPTH=auto ;;
        *) printf '     %s✗%s unrecognized choice, measuring instead\n' "$DANGER" "$R" > /dev/tty; MTP_DEPTH=auto ;;
    esac
}

# ask, then write whichever pair of keys this provider reads.
# sets $MTP_AUTOTUNE=1 when the depth still has to be measured.
# only the boot step at the bottom of this script can do that,
# the stack has to be up first.
configure_mtp() {
    local provider="$1" vram budget headroom drafter_ref
    MTP_AUTOTUNE=0

    ask_mtp
    [ "$MTP" = on ] || { ok "multi-token prediction off"; return 0; }
    ask_mtp_depth

    if [ "$TENSOR_PARALLEL" = on ]; then
        vram="$(detect_vram_total_mb)"
    else
        vram="$(detect_vram_mb)"
    fi
    budget="$(mtp_budget_mb "$MODEL_REF")"
    if [ -n "$vram" ] && [ "$budget" -gt 0 ]; then
        headroom=$((vram - budget - LIVE2D_VRAM_MB))
        if [ "$headroom" -lt 0 ]; then
            warn_ "with Live2D drawing (~${LIVE2D_VRAM_MB}MB) she won't fit on the card - some layers"
            note "  run on the CPU. Drafting helps MORE there, +31% measured against +21%"
            note "  fully on the GPU, so this stays worth having. Everything is just slower."
        fi
    fi

    case "$provider" in
        ollama)
            set_env OLLAMA_MTP "$MTP_DRAFTER"
            # .env only ever holds a number. the entrypoint bakes this
            # straight into a Modelfile as draft_num_predict, and "auto"
            # there is a broken model, not a default. 1 is a temporary
            # pick, the autotune below overwrites it with whatever
            # actually won.
            if [ "$MTP_DEPTH" = auto ]; then
                set_env OLLAMA_MTP_N_MAX 1
                MTP_AUTOTUNE=1
            else
                set_env OLLAMA_MTP_N_MAX "$MTP_DEPTH"
            fi
            ;;
        llamacpp)
            # llama-server's -hfd takes a bare repo, no hf.co in front
            # and no quant tag. works because these drafter repos hold
            # a single gguf (the model file) each.
            drafter_ref="${MTP_DRAFTER#hf.co/}"
            set_env LLAMACPP_MTP "${drafter_ref%%:*}"
            if [ "$MTP_DEPTH" = auto ]; then
                set_env LLAMACPP_MTP_N_MAX 1
                MTP_AUTOTUNE=1
            else
                set_env LLAMACPP_MTP_N_MAX "$MTP_DEPTH"
            fi
            ;;
    esac

    if [ "$MTP_AUTOTUNE" = 1 ]; then
        ok "multi-token prediction on, depth measured after the models are pulled"
    else
        ok "multi-token prediction on, drafting $MTP_DEPTH token(s) ahead"
    fi
}

# what colab.ipynb prints in inference only mode. only these
# names get into .env, the rest of a paste is dropped.
CLOUD_KEYS="AI_PROVIDER OLLAMA_URL OLLAMA_API_KEY OLLAMA_MODELS_TO_PULL TITLE_MODEL OMEGA_NUM_CTX VLLM_URL VLLM_API_KEY VLLM_MODEL"

cloud_line() {
    local key="${1%%=*}" val="${1#*=}"
    [ "$key" != "$1" ] || return 1
    case " $CLOUD_KEYS " in
        *" $key "*) set_env "$key" "$val" ;;
        *) note "skipped $key, the notebook never prints that one"; return 1 ;;
    esac
}

# sets $CLOUD_PROVIDER. the lines come from a paste, or from the
# environment on an unattended run (same names). no paste keeps
# whatever a previous run left in .env.
configure_cloud() {
    local line key val url
    if can_ask; then
        {
            printf '\n     %spaste the lines from the notebook%s\n' "$B" "$R"
            printf '     %scolab.ipynb or Kaggle with Mode = inference only, Step 3 prints them.%s\n' "$DIM" "$R"
            printf '     %sthen enter on an empty line. no notebook yet? just enter, they go in .env later%s\n' "$DIM" "$R"
        } > /dev/tty
        while IFS= read -r line < /dev/tty; do
            line="$(printf '%s' "$line" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
            [ -n "$line" ] || break
            cloud_line "$line" || true
        done
    else
        for key in $CLOUD_KEYS; do
            val="$(printenv "$key" || true)"
            [ -z "$val" ] || set_env "$key" "$val"
        done
    fi

    CLOUD_PROVIDER="$(env_value AI_PROVIDER)"
    case "$CLOUD_PROVIDER" in
        vllm) url="$(env_value VLLM_URL)" ;;
        ollama)
            url="$(env_value OLLAMA_URL)"
            case "$url" in ''|*://ollama[:/]*|*://localhost[:/]*|*://127.0.0.1[:/]*) url="" ;; esac
            ;;
        *) url="" ;;
    esac
    if [ -n "$url" ]; then
        ok "her brain at $url"
    else
        # ollama with the default URL boots a local ollama and pulls
        # the 12B into it. vllm with no URL starts nothing. until the
        # lines arrive her replies just fail.
        CLOUD_PROVIDER=vllm
        warn_ "no notebook link yet - ./start.sh link takes Step 3's lines later"
    fi
    # a remote ollama has no jun-mtp, php would ask it for one
    # anyway when an old local install left this set
    set_env OLLAMA_MTP ""
}

# TELEMETRY and TELEMETRY_INSTALL_ID in an old .env do nothing,
# delete them. they were for a chat sharing feature that never
# shipped and isn't planned, nothing in the app ever read them.

configure() {
    if [ "$REUSE" = 1 ]; then
        configure_from_env
        return
    fi
    local provider voice ans profiles

    step "configure"

    if [ "$CLOUD" = 1 ]; then
        provider=cloud
    elif [ -n "${JUN_PROVIDER:-}" ]; then
        provider="$(printf '%s' "$JUN_PROVIDER" | tr '[:upper:]' '[:lower:]')"
    elif [ "${JUN_YES:-}" = "1" ] || [ ! -r /dev/tty ]; then
        provider=llamacpp
    else
        {
            printf '\n     %sselect an AI provider%s\n' "$B" "$R"
            printf '       %s1%s  llama.cpp   %s-%s local llama-server, fully managed %s(default)%s\n' "$ACCENT" "$R" "$DIM" "$R" "$DIM" "$R"
            printf '       %s2%s  OpenRouter  %s-%s cloud API - needs an API key; chats leave this machine\n' "$ACCENT" "$R" "$DIM" "$R"
            printf '       %s3%s  Ollama      %s-%s local, fully managed\n' "$ACCENT" "$R" "$DIM" "$R"
            printf '     %s$%s choice %s[enter = llama.cpp]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R"
        } > /dev/tty
        read -r ans < /dev/tty || ans=""
        case "$ans" in
            2) provider=openrouter ;; 3) provider=ollama ;;
            ""|1) provider=llamacpp ;;
            *) printf '     %s✗%s unrecognized choice, using llama.cpp\n' "$DANGER" "$R" > /dev/tty; provider=llamacpp ;;
        esac
    fi
    case "$provider" in
        ollama|openrouter|llamacpp|cloud) ;;
        *) printf '     %s✗%s unknown provider "%s", using llama.cpp\n' "$DANGER" "$R" "$provider" > /dev/tty 2>/dev/null || true
           provider=llamacpp ;;
    esac

    TENSOR_PARALLEL=off
    case "$provider" in
        openrouter|cloud) ;;
        *) ask_tensor_parallel ;;
    esac

    case "$provider" in
    ollama)
        ask_model_ref
        set_env OLLAMA_MODELS_TO_PULL "$MODEL_REF"
        profiles=ollama
        ok "model $MODEL_REF"
        configure_mtp ollama
        ;;

    openrouter)
        local key orm
        key="${OPENROUTER_API_KEY:-}"
        if [ -z "$key" ] && [ "${JUN_YES:-}" != "1" ] && [ -r /dev/tty ]; then
            # Silent read: the key must never be echoed (or land in
            # scrollback).
            printf '     %s$%s OpenRouter API key %s(hidden; from openrouter.ai/keys)%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
            read -rs key < /dev/tty || key=""
            printf '\n' > /dev/tty
        fi
        [ -z "$key" ] && warn_ "no API key set - add OPENROUTER_API_KEY to .env before chatting"

        orm="${OPENROUTER_MODEL:-}"
        if [ -z "$orm" ] && [ "${JUN_YES:-}" != "1" ] && [ -r /dev/tty ]; then
            printf '     %s$%s model id %s[enter = openrouter/auto]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
            read -r orm < /dev/tty || orm=""
        fi
        orm="${orm:-openrouter/auto}"

        set_env OPENROUTER_API_KEY "$key"
        set_env OPENROUTER_MODEL "$orm"
        profiles=""
        ok "model $orm"
        ;;

    llamacpp)
        local url
        url="${LLAMACPP_URL:-}"
        if [ -z "$url" ] && [ "${JUN_YES:-}" != "1" ] && [ -r /dev/tty ]; then
            printf '     %s$%s llama-server URL %s[enter = managed setup]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
            read -r url < /dev/tty || url=""
        fi
        if [ -n "$url" ]; then
            case "$url" in
                http://*|https://*) ;;
                *) printf '     %s✗%s not an http(s) URL, using managed setup\n' "$DANGER" "$R" > /dev/tty 2>/dev/null || true
                   url="" ;;
            esac
        fi
        if [ -n "$url" ]; then
            set_env LLAMACPP_URL "$url"
            profiles=""
            ok "llama-server $url"
        else
            ask_model_ref
            # llama-server -hf wants the name without the hf.co/ in front.
            set_env LLAMACPP_MODEL_HF "${MODEL_REF#hf.co/}"
            set_env LLAMACPP_URL "http://llamacpp:8080"
            profiles=llamacpp
            ok "model ${MODEL_REF#hf.co/}"
            configure_mtp llamacpp
        fi
        ;;

    cloud)
        configure_cloud
        provider="$CLOUD_PROVIDER"
        profiles=""
        ;;
    esac

    set_env AI_PROVIDER "$provider"
    set_env COMPOSE_PROFILES "$profiles"
    ok "provider $provider"

    if [ -n "${VOICE:-}" ]; then
        case "$(printf '%s' "$VOICE" | tr '[:upper:]' '[:lower:]')" in
            off|0|false|no) voice=off ;; *) voice=on ;;
        esac
    elif ! can_ask; then
        voice=on
    else
        printf '     %s$%s enable voice %s(TTS)%s %s[Y/n]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r v < /dev/tty || v=""
        case "$v" in n|N|no|NO) voice=off ;; *) voice=on ;; esac
    fi
    set_env VOICE "$voice"
    ok "voice $voice"

    # The voice sidecar stays on the CPU on purpose. both engines
    # keep up in real time there, and a GPU copy would sit on VRAM
    # she wants for her own layers.
    if [ "$voice" = on ]; then
        set_env TTS_DEVICE cpu
    fi

    ask_karaoke
    set_env KARAOKE "$KARAOKE"
    if [ "$KARAOKE" = on ]; then
        ask_karaoke_gpu
        set_env SEP_DEVICE "$SEP_DEVICE"
        if [ "$SEP_DEVICE" = cuda ]; then
            ok "karaoke on, splitting songs on the GPU"
        else
            ok "karaoke on, splitting songs on the CPU"
        fi
    else
        ok "karaoke off"
    fi

    set_env TENSOR_PARALLEL "$TENSOR_PARALLEL"
    if [ "$TENSOR_PARALLEL" = on ]; then
        ok "tensor parallelism on"
    fi

    ensure_key OMEGA_REGISTRATION_KEY
    ensure_key SIDECAR_SECRET
    ok "registration key ready"

    ask_update_check
}

configure_from_env() {
    local voice karaoke
    voice="$(env_value VOICE)"; karaoke="$(env_value KARAOKE)"
    ok "provider $(env_value AI_PROVIDER)"
    ok "voice ${voice:-on}, karaoke ${karaoke:-off}"
    # keys a newer release reads that an older configure() never wrote
    if [ "${voice:-on}" != off ] && [ -z "$(env_value TTS_DEVICE)" ]; then
        set_env TTS_DEVICE cpu
    fi
    ensure_key OMEGA_REGISTRATION_KEY
    ensure_key SIDECAR_SECRET
    ask_update_check
}

pkg_manager() {
    if [ "$OS" = "Darwin" ]; then
        command -v brew >/dev/null 2>&1 && echo brew || echo none
        return
    fi
    # rpm-ostree systems like Bazzite can't be changed in place, so
    # we add packages to the next boot instead of installing them
    # now.
    for pm in rpm-ostree apt-get dnf yum pacman zypper; do
        if command -v "$pm" >/dev/null 2>&1; then echo "$pm"; return; fi
    done
    echo none
}

manual_url() {
    case "$1" in
        git) echo "https://git-scm.com/downloads" ;;
        python) echo "https://www.python.org/downloads/" ;;
        compose) echo "https://docs.docker.com/compose/install/" ;;
        docker)
            if [ "$OS" = "Darwin" ]; then echo "https://www.docker.com/products/docker-desktop/"
            else echo "https://docs.docker.com/engine/install/"; fi ;;
    esac
}

install_git() {
    case "$PM" in
        apt-get) $SUDO apt-get update && $SUDO apt-get install -y git ;;
        dnf)     $SUDO dnf install -y git ;;
        yum)     $SUDO yum install -y git ;;
        pacman)  $SUDO pacman -Sy --noconfirm git ;;
        zypper)  $SUDO zypper install -y git ;;
        brew)    brew install git ;;
    esac
}

# Docker's own cross-distro install script, but it does not go
# straight into a root shell. we put it in a file first, check it
# really is a shell script, and print its SHA-256 so you can
# compare it against what anyone else got. set
# JUN_DOCKER_SCRIPT_SHA256=<digest> and a mismatch aborts instead
# of running. Homebrew doesn't need any of this, it has a real
# package.
stage_docker_script() {
    [ "$PM" = brew ] && return 0

    DOCKER_SCRIPT="$(mktemp)"
    if ! fetch "$DOCKER_SCRIPT_URL" "$DOCKER_SCRIPT"; then
        fail_ "couldn't download $DOCKER_SCRIPT_URL"
        exit 1
    fi
    if ! head -n1 "$DOCKER_SCRIPT" | grep -q '^#!'; then
        fail_ "$DOCKER_SCRIPT_URL didn't return a shell script - refusing to run it."
        exit 1
    fi

    local digest
    digest="$(sha256_of "$DOCKER_SCRIPT")"
    if [ -n "${JUN_DOCKER_SCRIPT_SHA256:-}" ]; then
        if [ "$digest" != "$JUN_DOCKER_SCRIPT_SHA256" ]; then
            fail_ "get-docker.sh doesn't match the digest you pinned:"
            note "expected $JUN_DOCKER_SCRIPT_SHA256"
            note "got      ${digest:-<no sha256 tool on this box>}"
            exit 1
        fi
        ok "get-docker.sh matches the pinned digest"
        return 0
    fi

    note "get-docker.sh sha256: ${digest:-<no sha256 tool on this box>}"
    note "it runs as root. the copy we will run is $DOCKER_SCRIPT"
    [ "${JUN_YES:-}" = "1" ] && return 0
    [ -r /dev/tty ] || return 0
    local a
    printf '     %s$%s run it? %s[Y/n]%s %s→%s ' \
        "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
    read -r a < /dev/tty || a=""
    case "$a" in
        n|N|no|NO) note "okay - install Docker yourself ($(manual_url docker)) and re-run."; exit 1 ;;
    esac
}

install_docker() {
    case "$PM" in
        brew)
            if [ "$OS" = "Darwin" ]; then
                brew install --cask docker
            else
                brew install docker docker-compose docker-engine rootlesskit slirp4netns
            fi
            ;;
        *)    $SUDO sh "$DOCKER_SCRIPT" ;;
    esac
}

install_compose() {
    case "$PM" in
        apt-get) $SUDO apt-get update && $SUDO apt-get install -y docker-compose-plugin ;;
        dnf|yum) $SUDO "$PM" install -y docker-compose-plugin ;;
        pacman)  $SUDO pacman -Sy --noconfirm docker-compose ;;
        zypper)  $SUDO zypper install -y docker-compose ;;
        brew)
            if [ "$OS" = "Darwin" ]; then
                brew install --cask docker
            else
                brew install docker-compose
            fi
            ;;
    esac
}

install_python() {
    case "$PM" in
        apt-get) $SUDO apt-get update && $SUDO apt-get install -y python3 python3-venv ;;
        dnf)     $SUDO dnf install -y python3 ;;
        yum)     $SUDO yum install -y python3 ;;
        pacman)  $SUDO pacman -Sy --noconfirm python ;;
        zypper)  $SUDO zypper install -y python3 ;;
        brew)    brew install python ;;
    esac
}

# rpm-ostree puts package changes in a new deployment (the OS
# image you Boot into next). so everything missing goes in one
# transaction, and the caller stops for a reboot.
install_ostree_deps() {
    local c packages=()
    for c in "$@"; do
        case "$c" in
            git)    packages+=(git) ;;
            docker) packages+=(moby-engine) ;;
            compose) packages+=(docker-compose) ;;
            python) packages+=(python3) ;;
        esac
    done
    $SUDO rpm-ostree install "${packages[@]}"
}

python_command() {
    local candidate
    for candidate in python3 python; do
        if command -v "$candidate" >/dev/null 2>&1 \
            && "$candidate" -c 'import ensurepip, sys, venv; assert sys.version_info >= (3, 10)' >/dev/null 2>&1; then
            command -v "$candidate"
            return 0
        fi
    done
    return 1
}

ensure_recovery_python() {
    python_command >/dev/null && return 0

    PM="$(pkg_manager)"
    if [ "$PM" = none ]; then
        warn_ "Python 3.10+ is needed for asset recovery - install it from $(manual_url python), then re-run."
        return 1
    fi
    if [ "$PM" != brew ] && [ "$SUDO" = "" ] && [ "$(id -u)" -ne 0 ]; then
        warn_ "installing Python needs root, and sudo isn't available."
        return 1
    fi

    local proceed=0 answer=""
    if [ "${JUN_YES:-}" = "1" ]; then
        proceed=1
    elif [ -r /dev/tty ]; then
        printf '     %s$%s install Python 3 for asset recovery with %s%s%s? %s[y/N]%s %s→%s ' \
            "$OK" "$R" "$B" "$PM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r answer < /dev/tty || answer=""
        case "$answer" in y|Y|yes|YES) proceed=1 ;; esac
    fi
    if [ "$proceed" != 1 ]; then
        warn_ "Python installation declined - asset recovery was skipped."
        return 1
    fi

    if [ "$PM" != brew ] && [ -n "$SUDO" ]; then
        note "sudo authentication"
        $SUDO -v
    fi
    if [ "$PM" = "rpm-ostree" ]; then
        note "immutable system detected - layering Python for the next boot"
        run "layer Python" install_ostree_deps python
        warn_ "Python was layered - reboot, then re-run this installer with JUN_EXTRACT=1."
        return 1
    fi

    run "install Python" install_python
    if ! python_command >/dev/null; then
        warn_ "Python 3.10+ is still unavailable - re-run after installation finishes."
        return 1
    fi
}

install_asset_recovery() {
    local python venv recovery_python args=(tools/recover_assets.py)
    ensure_recovery_python || return 1
    python="$(python_command)"
    venv="runtime/asset-recovery-venv"
    recovery_python="$venv/bin/python"

    # an existing interpreter doesn't prove pip bootstrapped.
    # check pip itself or the next install fails with
    # "No module named pip". rebuild the venv when it's missing.
    if [ ! -x "$recovery_python" ] || ! "$recovery_python" -m pip --version >/dev/null 2>&1; then
        note "setting up the local asset-recovery environment"
        "$python" -m venv --clear "$venv" || {
            warn_ "could not create the asset-recovery virtual environment."
            return 1
        }
        "$recovery_python" -m pip --version >/dev/null 2>&1 \
            || "$recovery_python" -m ensurepip --upgrade >/dev/null 2>&1 || true
        if ! "$recovery_python" -m pip --version >/dev/null 2>&1; then
            warn_ "the asset-recovery environment has no pip - install your distro's"
            warn_ "python venv/pip package (python3-venv on Debian) and re-run."
            return 1
        fi
    fi
    run "install UnityPy + Pillow" "$recovery_python" -m pip install \
        --disable-pip-version-check --quiet -r tools/requirements-recovery.txt
    [ -n "${JUN_GAME_DIR:-}" ] && args+=(--game "$JUN_GAME_DIR")
    if "$recovery_python" "${args[@]}"; then
        ok "assets extracted to webapp/assets (local use only)"
        return 0
    fi

    # honor supplied paths. interactive Express and Custom can ask
    # for a fallback. JUN_YES without $EXPRESS is headless and must
    # fail instead of waiting for input.
    if [ -n "${JUN_GAME_DIR:-}" ] || [ ! -r /dev/tty ] \
       || { [ "${JUN_YES:-}" = "1" ] && [ "$EXPRESS" != 1 ]; }; then
        warn_ "couldn't extract - she'll use placeholder art for now. set JUN_GAME_DIR"
        warn_ "to the game folder and re-run with JUN_EXTRACT=1 for her real model."
        return 1
    fi

    warn_ "couldn't find the game in its usual locations."
    local selection game_dir data_dir
    while :; do
        printf '     %s$%s paste the game folder or drag the game executable here %s[Enter = skip]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r selection < /dev/tty || selection=""
        selection="${selection%$'\r'}"
        # Drag-and-drop paths may be wrapped in shell quotes.
        selection="${selection#\"}"
        selection="${selection%\"}"
        selection="${selection#\'}"
        selection="${selection%\'}"
        if [ -z "$selection" ]; then
            note "asset extraction skipped."
            return 1
        fi

        if [ -d "$selection" ]; then
            game_dir="$selection"
        elif [ -f "$selection" ]; then
            game_dir="$(dirname "$selection")"
        else
            warn_ "path not found: $selection"
            continue
        fi
        data_dir="$game_dir/My Dystopian Robot Girlfriend_Data"
        if [ ! -d "$data_dir" ]; then
            warn_ "that location does not contain My Dystopian Robot Girlfriend_Data"
            continue
        fi

        if "$recovery_python" tools/recover_assets.py --game "$game_dir"; then
            ok "assets extracted to webapp/assets (local use only)"
            note "optional: serve the repo root and open /tools/bake-items.html to"
            note "bake the wardrobe item tiles into webapp/assets/items/."
            return 0
        fi
        warn_ "extraction failed from that location. Try another path or press Enter to skip."
    done

    return 1
}

GAME_NAME="My Dystopian Robot Girlfriend"

find_game_save_dir() {
    local d
    for d in "$HOME/.config/unity3d/IncontinentCell/$GAME_NAME" \
             "$HOME"/.steam/steam/steamapps/compatdata/*/pfx/drive_c/users/steamuser/AppData/LocalLow/IncontinentCell/"$GAME_NAME" \
             "$HOME"/.local/share/Steam/steamapps/compatdata/*/pfx/drive_c/users/steamuser/AppData/LocalLow/IncontinentCell/"$GAME_NAME"; do
        [ -d "$d" ] && { printf '%s\n' "$d"; return 0; }
    done
    return 1
}

steam_libraries() {
    local root vdf
    for root in "$HOME/.steam/steam" "$HOME/.local/share/Steam" "$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam"; do
        vdf="$root/steamapps/libraryfolders.vdf"
        [ -d "$root/steamapps" ] && printf '%s\n' "$root"
        [ -f "$vdf" ] && sed -n 's/^[[:space:]]*"path"[[:space:]]*"\(.*\)"[[:space:]]*$/\1/p' "$vdf"
    done | awk '!seen[$0]++'
}

# the native build or the windows one under proton, whichever is there
find_bridge_game_dir() {
    local d lib
    is_game_dir() { [ -f "$1/$GAME_NAME.x86_64" ] || [ -f "$1/$GAME_NAME.exe" ]; }
    [ -n "${JUN_GAME_DIR:-}" ] && is_game_dir "$JUN_GAME_DIR" && { printf '%s\n' "$JUN_GAME_DIR"; return 0; }
    while IFS= read -r lib; do
        d="$lib/steamapps/common/$GAME_NAME"
        is_game_dir "$d" && { printf '%s\n' "$d"; return 0; }
    done < <(steam_libraries)
    # the itch zip unpacks as factorial-omega-linux-64 wherever they put it
    for d in "$HOME"/.config/itch/apps/*/ "$HOME"/{Downloads,Desktop,Documents,Documenti,Games,games}/{,*/}factorial-omega-*-64/; do
        d="${d%/}"
        is_game_dir "$d" && { printf '%s\n' "$d"; return 0; }
    done
    return 1
}

# $1 = game folder. proton keeps the save in that app's own prefix
game_save_dir() {
    local game="$1" lib acf id
    if [ -f "$game/$GAME_NAME.exe" ] && [ ! -f "$game/$GAME_NAME.x86_64" ]; then
        lib="$(dirname "$(dirname "$(dirname "$game")")")"
        acf="$(grep -l "\"installdir\"[[:space:]]*\"$GAME_NAME\"" "$lib"/steamapps/appmanifest_*.acf 2>/dev/null | head -n1)"
        id="${acf##*appmanifest_}"; id="${id%.acf}"
        [ -n "$id" ] && { printf '%s\n' "$lib/steamapps/compatdata/$id/pfx/drive_c/users/steamuser/AppData/LocalLow/IncontinentCell/$GAME_NAME"; return 0; }
        return 1
    fi
    printf '%s\n' "$HOME/.config/unity3d/IncontinentCell/$GAME_NAME"
}

fetch_release_asset() {
    local slug tag tmp want name extension="${3:-dll}"
    slug="$(github_slug "$REPO")" && tag="$(git describe --tags --exact-match HEAD 2>/dev/null || release_tag stable)" || return 1
    name="$1-$tag.$extension"
    tmp="$(mktemp -d)"
    if ! curl -fsSL -o "$tmp/asset" "https://github.com/$slug/releases/download/$tag/$name" \
        || ! curl -fsSL -o "$tmp/SHA256SUMS" "https://github.com/$slug/releases/download/$tag/SHA256SUMS"; then
        rm -rf "$tmp"; return 1
    fi
    want="$(awk -v name="$name" '$2 == name || $2 == "*" name { print $1 }' "$tmp/SHA256SUMS")"
    if [ -z "$want" ] || [ "$(sha256sum "$tmp/asset" | cut -d' ' -f1)" != "$want" ]; then
        warn_ "$name doesn't match the SHA256SUMS of $tag"
        rm -rf "$tmp"; return 1
    fi
    if ! mkdir -p "$(dirname "$2")" || ! cp "$tmp/asset" "$2"; then
        rm -rf "$tmp"; return 1
    fi
    rm -rf "$tmp"
    ok "$1 $tag"
}

# native: MelonLoader 0.7.3 (the one JunBridge.LinuxFix was checked
# against) loads through LD_PRELOAD, junos-game.sh does that
install_bridge_mod() {
    local game="$1" tmp process executable native=0 ml=MelonLoader.x64.zip marker=version.dll
    if [ -f "$game/My Dystopian Robot Girlfriend.x86_64" ]; then
        native=1 ml=MelonLoader.Linux.x64.zip marker=MelonLoader.Bootstrap.so
    fi
    if [ "$native" = 1 ]; then
        executable="$(readlink -f "$game/My Dystopian Robot Girlfriend.x86_64")"
        for process in /proc/[0-9]*/exe; do
            if [ "$(readlink "$process" 2>/dev/null)" = "$executable" ]; then
                warn_ "close the game before repairing its mod loader"
                return 1
            fi
        done
    fi
    tmp="$(mktemp -d)"
    fetch_release_asset JunBridge "$tmp/tree/Mods/JunBridge.dll" || { rm -rf "$tmp"; return 1; }
    if [ "$native" = 1 ]; then
        if ! fetch_release_asset JunBridge.LinuxFix "$tmp/tree/Plugins/JunBridge.LinuxFix.dll"; then
            rm -rf "$tmp"; return 1
        fi
    fi
    if [ ! -f "$game/$marker" ]; then
        if ! curl -fsSL -o "$tmp/ml.zip" "https://github.com/LavaGang/MelonLoader/releases/download/v0.7.3/$ml" \
            || ! unzip -oq "$tmp/ml.zip" -d "$tmp/tree"; then
            rm -rf "$tmp"; return 1
        fi
        ok "MelonLoader"
    fi
    if ! cp -a "$tmp/tree/." "$game/"; then
        rm -rf "$tmp"; return 1
    fi
    rm -rf "$tmp"
    if [ "$native" = 0 ]; then
        note "steam launch options for the game: WINEDLLOVERRIDES=\"version=n,b\" %command%"
        return 0
    fi
    cat > "$game/junos-game.sh" <<'LAUNCH'
#!/bin/sh
cd "$(dirname "$0")" || exit 1
LD_PRELOAD="$PWD/MelonLoader.Bootstrap.so${LD_PRELOAD:+:$LD_PRELOAD}" exec "./My Dystopian Robot Girlfriend.x86_64" "$@"
LAUNCH
    chmod +x "$game/junos-game.sh"
    note "start the game with $game/junos-game.sh"
    note "steam launch options: \"$game/junos-game.sh\" # %command%"
}

# MelonLoader builds its Il2CppAssemblies on the game's first start,
# a minute or two. done here so the first real start is a normal one
init_melonloader() {
    local game="$1" log pid i
    log="$game/MelonLoader/Latest.log"
    [ -x "$game/junos-game.sh" ] || return 0
    [ -f "$game/MelonLoader/Il2CppAssemblies/Assembly-CSharp.dll" ] && return 0
    if [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
        note "no screen here, MelonLoader sets itself up the first time you start the game"
        return 0
    fi
    if pgrep -f "$GAME_NAME" >/dev/null 2>&1; then
        note "the game is open, MelonLoader sets itself up the next time it starts"
        return 0
    fi
    step "set up MelonLoader"
    note "the game opens once for a minute or two and closes on its own. leave it be"
    rm -f "$log"
    "$game/junos-game.sh" >/dev/null 2>&1 &
    pid=$!
    for i in $(seq 1 200); do
        sleep 3
        grep -qE '[0-9]+ Mods? loaded' "$log" 2>/dev/null && break
        kill -0 "$pid" 2>/dev/null || break
    done
    sleep 3
    if grep -qE '[0-9]+ Mods? loaded' "$log" 2>/dev/null; then
        ok "MelonLoader is set up"
    else
        warn_ "MelonLoader didn't finish, the game's next start takes a minute longer. why: $log"
    fi
    kill "$pid" 2>/dev/null || true
    pkill -f "$GAME_NAME.x86_64" 2>/dev/null || true
}

install_game_bridge() {
    local saves game
    if ! game="$(find_bridge_game_dir)" && [ -r /dev/tty ] && [ "${JUN_YES:-}" != "1" ]; then
        printf '     %s$%s game folder for the in-game mod %s[Enter = skip the mod]%s %s→%s ' \
            "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r game < /dev/tty || game=""
        game="${game%\'}"; game="${game#\'}"; game="${game%\"}"; game="${game#\"}"
        [ -f "$game" ] && game="$(dirname "$game")"
        [ -d "$game/${GAME_NAME}_Data" ] || game=""
    fi
    set_env GAME_BRIDGE on
    # set before the first save too, the boot sync skips a folder that isn't there yet
    if saves="$(find_game_save_dir)" || saves="$(game_save_dir "${game:-}")"; then
        set_env GAME_SAVE_DIR "$saves"
        if [ -d "$saves" ]; then ok "game saves: $saves"; else note "no saves yet, she reads them from $saves once you've played"; fi
    else
        note "couldn't tell where proton keeps the save. set GAME_SAVE_DIR in .env, the mod syncs either way"
    fi
    if [ -z "${game:-}" ]; then
        note "game folder not found (set JUN_GAME_DIR), so no in-game mod. the boot sync still works"
        return 0
    fi
    set_env JUN_GAME_DIR "$game"
    if install_bridge_mod "$game"; then
        init_melonloader "$game"
        note "once she is up, start the game. it asks you to sign in with your Jun OS account"
    else
        warn_ "in-game mod not installed. the boot sync still works"
    fi
}

# Homebrew's Linux Docker engine is rootless, and its Compose
# plugin lives outside Docker's default plugin directory. so it
# gets symlinked in after Homebrew installs it.
configure_brew_docker() {
    local plugin
    dockerd-rootless-setuptool.sh install
    plugin="$(brew --prefix docker-compose)/lib/docker/cli-plugins/docker-compose"
    mkdir -p "$HOME/.docker/cli-plugins"
    ln -sf "$plugin" "$HOME/.docker/cli-plugins/docker-compose"
}

prepare_docker() {
    if [ "$OS" = "Darwin" ]; then
        ok "launching Docker Desktop"
        open -a Docker 2>/dev/null || true
        return
    fi
    if [ "$PM" = "brew" ]; then
        ok "using rootless Docker"
        return
    fi
    ok "starting the Docker daemon"
    if command -v systemctl >/dev/null 2>&1; then
        $SUDO systemctl enable --now docker 2>/dev/null || true
    elif command -v service >/dev/null 2>&1; then
        $SUDO service docker start 2>/dev/null || true
    fi
    local me; me="$(id -un)"
    [ "$(id -u)" -eq 0 ] && return
    id -nG "$me" 2>/dev/null | tr ' ' '\n' | grep -qx docker && return
    # the docker socket is root. anyone in the docker group can
    # mount / into a container and own the box, no password asked,
    # forever. so it's opt in, and Express asks too, that's the one
    # question it doesn't get to answer for you. a headless run
    # (JUN_YES alone, no tty) has nobody to ask, so it stays on
    # sudo unless JUN_DOCKER_GROUP=1 says otherwise.
    local a=""
    if [ "${JUN_DOCKER_GROUP:-}" = "1" ]; then
        a=y
    elif [ ! -r /dev/tty ] || { [ "${JUN_YES:-}" = "1" ] && [ "$EXPRESS" != 1 ]; }; then
        a=n
    else
        printf '     %s$%s add %s to the docker group? %s(root without a password, for any process running as you)%s %s[y/N]%s %s→%s ' \
            "$OK" "$R" "$me" "$WARN" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        read -r a < /dev/tty || a=""
    fi
    case "$a" in
        y|Y|yes|YES)
            $SUDO usermod -aG docker "$me" 2>/dev/null && ADDED_DOCKER_GROUP="$me"
            command -v sg >/dev/null 2>&1 && NEED_SG=1
            ;;
        *)
            NEED_SUDO=1
            note "docker stays behind sudo - run the scripts as: sudo ./start.sh, sudo ./sync-webapp.sh"
            ;;
    esac
}

docker_run() {
    if [ "$NEED_SG" = 1 ]; then sg docker -c "$*"
    elif [ "$NEED_SUDO" = 1 ]; then $SUDO "$@"
    else "$@"; fi
}

# what this install changed outside its own folder, one key=value
# per line, so uninstall.sh can put it back. the folder outlives a
# re-run, so append and never repeat a key.
record_change() {
    grep -q "^$1=" .install-changes 2>/dev/null && return 0
    printf '%s=%s\n' "$1" "$2" >> .install-changes
}

# rootless Docker (Bazzite/Fedora Atomic, brew) can't bind :80.
# RootlessKit refuses privileged ports unless the host lowers
# ip_unprivileged_port_start. the rootless netns (its own network
# namespace) copies the value when it's created, hence the daemon
# restart.
docker_is_rootless() {
    docker_run docker info -f '{{.SecurityOptions}}' 2>/dev/null | grep -q rootless
}

allow_privileged_ports() {
    docker_is_rootless || return 0
    local start
    start="$(sysctl -n net.ipv4.ip_unprivileged_port_start 2>/dev/null || echo 1024)"
    [ "$start" -le 80 ] && return 0
    note "rootless Docker needs unprivileged ports to start at 80 (for the web UI)"
    note "that's system-wide: any program can then bind 80 and up. ./uninstall.sh puts it back to $start"
    if printf 'net.ipv4.ip_unprivileged_port_start=80\n' \
            | $SUDO tee /etc/sysctl.d/99-jun-unprivileged-ports.conf >/dev/null 2>&1 \
        && $SUDO sysctl -q net.ipv4.ip_unprivileged_port_start=80; then
        record_change unprivileged_port_start "$start"
        systemctl --user restart docker 2>/dev/null || true
        ok "allowed rootless Docker to bind port 80"
    else
        warn_ "couldn't lower net.ipv4.ip_unprivileged_port_start - port 80 will fail."
        note "fix manually: sudo sysctl net.ipv4.ip_unprivileged_port_start=80"
    fi
}

wait_for_docker() {
    printf '     %s…%s waiting for Docker to be ready' "$DIM" "$R"
    local i=0
    while [ "$i" -lt 45 ]; do
        if docker_run docker info >/dev/null 2>&1; then printf ' %sready%s\n' "$OK" "$R"; return 0; fi
        printf '%s.%s' "$DIM" "$R"; sleep 2; i=$((i + 1))
    done
    printf ' %stimeout%s\n' "$WARN" "$R"
    return 1
}

# same hook check as start.sh. rootless also needs no-cgroups,
# its daemon isn't allowed to set up device cgroups
nvidia_toolkit_ready() {
    command -v nvidia-container-runtime-hook >/dev/null 2>&1 \
        || command -v nvidia-cdi-hook >/dev/null 2>&1 \
        || return 1
    [ "$1" = 1 ] || return 0
    grep -Eq '^[[:space:]]*no-cgroups[[:space:]]*=[[:space:]]*true' "$NVIDIA_CONFIG" 2>/dev/null
}

fetch_nvidia_rpm_repo() {
    local tmp rc=0; tmp="$(mktemp)"
    fetch "$NVIDIA_REPO/stable/rpm/nvidia-container-toolkit.repo" "$tmp" \
        && $SUDO install -m 644 "$tmp" /etc/yum.repos.d/nvidia-container-toolkit.repo || rc=$?
    rm -f "$tmp"
    return "$rc"
}

install_nvidia_toolkit() {
    case "$PM" in
        apt-get)
            local key="/usr/share/keyrings/nvidia-container-toolkit-keyring.asc" tmp rc=0
            tmp="$(mktemp)"
            fetch "$NVIDIA_REPO/gpgkey" "$tmp" && $SUDO install -m 644 "$tmp" "$key" || rc=$?
            rm -f "$tmp"
            [ "$rc" -eq 0 ] || return "$rc"
            # shellcheck disable=SC2016 # $(ARCH) is apt's, not ours
            printf 'deb [signed-by=%s] %s/stable/deb/$(ARCH) /\n' "$key" "$NVIDIA_REPO" \
                | $SUDO tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null \
                && $SUDO apt-get update \
                && $SUDO apt-get install -y nvidia-container-toolkit
            ;;
        dnf|yum)
            fetch_nvidia_rpm_repo && $SUDO "$PM" install -y nvidia-container-toolkit ;;
        zypper)
            { $SUDO zypper --non-interactive lr nvidia-container-toolkit >/dev/null 2>&1 \
                || $SUDO zypper --non-interactive ar "$NVIDIA_REPO/stable/rpm/nvidia-container-toolkit.repo"; } \
                && $SUDO zypper --non-interactive --gpg-auto-import-keys install nvidia-container-toolkit
            ;;
        pacman) $SUDO pacman -Sy --noconfirm nvidia-container-toolkit ;;
        rpm-ostree)
            fetch_nvidia_rpm_repo && $SUDO rpm-ostree install nvidia-container-toolkit ;;
    esac
}

configure_nvidia_docker() {
    if [ "$1" = 1 ]; then
        nvidia-ctk runtime configure --runtime=docker --config="${XDG_CONFIG_HOME:-$HOME/.config}/docker/daemon.json" \
            && $SUDO nvidia-ctk config --set nvidia-container-cli.no-cgroups --in-place \
            && systemctl --user restart docker
    else
        $SUDO nvidia-ctk runtime configure --runtime=docker || return
        if command -v systemctl >/dev/null 2>&1; then
            $SUDO systemctl restart docker
        else
            $SUDO service docker restart
        fi
    fi
}

# start.sh only falls back to the CPU without it, this is the fix.
# rootless ALWAYS asks, no-cgroups goes for every Docker on the box
setup_nvidia_toolkit() {
    [ "$OS" = Linux ] && [ "$CLOUD" != 1 ] || return 0
    case "$(printf '%s' "${GPU:-auto}" | tr '[:upper:]' '[:lower:]')" in
        auto|nvidia) ;;
        *) return 0 ;;
    esac
    { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; } || return 0
    local rootless=0 a=""
    docker_is_rootless && rootless=1
    nvidia_toolkit_ready "$rootless" && return 0

    step "NVIDIA Container Toolkit"
    note "Docker can't use your NVIDIA card without it, she'd run on the CPU"
    [ "$rootless" = 1 ] && note "rootless Docker also needs no-cgroups in $NVIDIA_CONFIG, that's for every Docker on this machine"
    case "$(printf '%s' "${JUN_NVIDIA_TOOLKIT:-}" | tr '[:upper:]' '[:lower:]')" in
        1|on|yes|true) a=y ;;
        0|off|no|false) a=n ;;
        *)
            if [ "$rootless" = 0 ] && [ "${JUN_YES:-}" = "1" ]; then
                a=y
            elif [ ! -r /dev/tty ] || { [ "${JUN_YES:-}" = "1" ] && [ "$EXPRESS" != 1 ]; }; then
                a=n
            elif [ "$rootless" = 1 ]; then
                printf '     %s$%s install it and set no-cgroups? %s[y/N]%s %s→%s ' \
                    "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
                read -r a < /dev/tty || a=""
            else
                printf '     %s$%s install it from NVIDIA'"'"'s repo? %s[Y/n]%s %s→%s ' \
                    "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
                read -r a < /dev/tty || a=""
                case "$a" in n|N|no|NO) ;; *) a=y ;; esac
            fi
            ;;
    esac
    case "$a" in
        y|Y|yes|YES) ;;
        *) note "skipped - re-run this installer anytime to add it"; return 0 ;;
    esac
    if [ -z "$SUDO" ] && [ "$(id -u)" -ne 0 ]; then
        warn_ "that needs root, and sudo isn't available. by hand: $NVIDIA_GUIDE_URL"
        return 0
    fi
    [ -n "$SUDO" ] && { note "sudo authentication"; $SUDO -v || return 0; }

    PM="$(pkg_manager)"
    if ! command -v nvidia-container-runtime-hook >/dev/null 2>&1 \
        && ! command -v nvidia-cdi-hook >/dev/null 2>&1; then
        case "$PM" in
            apt-get|dnf|yum|zypper|pacman|rpm-ostree) ;;
            *) warn_ "no package manager I can install it with. by hand: $NVIDIA_GUIDE_URL"; return 0 ;;
        esac
        if ! ( run "install nvidia-container-toolkit" install_nvidia_toolkit ); then
            note "she runs on the CPU until it's in. NVIDIA's guide: $NVIDIA_GUIDE_URL"
            return 0
        fi
        record_change nvidia_toolkit "$PM"
        if [ "$PM" = rpm-ostree ]; then
            ok "layered for the next boot - reboot, then re-run this installer to finish it"
            return 0
        fi
    fi
    if ! ( run "point Docker at it" configure_nvidia_docker "$rootless" ); then
        note "she runs on the CPU until that works. NVIDIA's guide: $NVIDIA_GUIDE_URL"
        return 0
    fi
    [ "$rootless" = 0 ] || record_change nvidia_no_cgroups 1
    wait_for_docker || true
}

confirm_deps() {
    local missing=()
    command -v git >/dev/null 2>&1 || missing+=(git)
    command -v docker >/dev/null 2>&1 || missing+=(docker)
    docker compose version >/dev/null 2>&1 || missing+=(compose)
    step "check dependencies"
    [ ${#missing[@]} -eq 0 ] && { ok "git, Docker + Compose present"; return; }

    warn_ "missing: ${missing[*]}"

    PM="$(pkg_manager)"
    if [ "$PM" = none ]; then
        fail_ "no supported package manager - install manually and re-run:"
        for c in "${missing[@]}"; do printf '       %s%-7s%s %s%s%s\n' "$ACCENT" "$c" "$R" "$DIM" "$(manual_url "$c")" "$R" >&2; done
        exit 1
    fi

    # curl | bash leaves stdin pointing at the pipe, so prompt on
    # /dev/tty.
    local proceed=0
    if [ "${JUN_YES:-}" = "1" ]; then
        proceed=1
    elif [ -r /dev/tty ]; then
        if [ "$PM" = "rpm-ostree" ]; then
            printf '     %s$%s layer %s with %s%s%s? %s[Y/n]%s %s→%s ' \
                "$OK" "$R" "${missing[*]}" "$B" "$PM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        else
            printf '     %s$%s install %s with %s%s%s? %s[Y/n]%s %s→%s ' \
                "$OK" "$R" "${missing[*]}" "$B" "$PM" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
        fi
        read -r answer < /dev/tty || answer=""
        case "$answer" in
            y | Y | yes | YES | "") proceed=1 ;;
            *)
                if [ "$PM" = "rpm-ostree" ] && command -v brew >/dev/null 2>&1; then
                    PM=brew
                    proceed=1
                    note "rpm-ostree declined - using Homebrew instead"
                fi
                ;;
        esac
    else
        fail_ "re-run in an interactive terminal (or set JUN_YES=1) to install them."
        exit 1
    fi
    [ "$proceed" = 1 ] || { note "okay, leaving it to you - install the tools above and re-run."; exit 1; }

    if [ "$PM" != brew ] && [ "$SUDO" = "" ] && [ "$(id -u)" -ne 0 ]; then
        fail_ "installing packages needs root, and sudo isn't available."
        exit 1
    fi

    # sudo auth up front. installs run with their output hidden, and
    # a password prompt stuck behind the spinner just hangs there.
    [ -n "$SUDO" ] && [ "$PM" != brew ] && { note "sudo authentication"; $SUDO -v; }

    # Bazzite and other rpm-ostree systems can't use freshly layered
    # packages until they boot into the deployment rpm-ostree made.
    if [ "$PM" = "rpm-ostree" ]; then
        note "immutable system detected - layering ${missing[*]} for the next boot"
        run "layer ${missing[*]}" install_ostree_deps "${missing[@]}"
        ok "packages layered - reboot, then re-run this installer"
        exit 0
    fi

    for c in "${missing[@]}"; do
        case "$c" in
            git)    run "install git" install_git ;;
            docker) stage_docker_script
                    run "install docker" install_docker
                    [ -n "$DOCKER_SCRIPT" ] && rm -f "$DOCKER_SCRIPT"
                    DOCKER_JUST_INSTALLED=1 ;;
            compose) run "install Docker Compose" install_compose ;;
        esac
    done

    if ! docker compose version >/dev/null 2>&1; then
        fail_ "Docker Compose is still unavailable - install it from $(manual_url compose), then re-run."
        exit 1
    fi

    if [ "$DOCKER_JUST_INSTALLED" = 1 ]; then
        if [ "$PM" = "brew" ] && [ "$OS" != "Darwin" ]; then
            run "configure rootless Docker" configure_brew_docker
            export DOCKER_HOST="${DOCKER_HOST:-unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock}"
        fi
        prepare_docker
    fi
}

# Express alone puts llama.cpp and karaoke back over what they
# picked. a yes skips configure() and .env stays as it is
choose_reuse() {
    local ans
    [ -n "$EXISTING" ] && grep -qE '^AI_PROVIDER=.' "$EXISTING/.env" 2>/dev/null || return 0
    case "$(printf '%s' "${JUN_REUSE:-}" | tr '[:upper:]' '[:lower:]')" in
        0|off|no|false) return 0 ;;
        1|on|yes|true) ;;
        *)
            [ "$HANDOFF" = 1 ] && return 0
            [ -z "${JUN_EXPRESS:-}${JUN_CLOUD:-}${JUN_YES:-}" ] || return 0
            if { true >/dev/tty; } 2>/dev/null; then
                printf '\n     %sfound Jun in %s%s\n' "$B" "$EXISTING" "$R" > /dev/tty
                printf '     %s$%s update her with the settings she has now? %s[Y/n, n = set up again]%s %s→%s ' \
                    "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
                read -r ans < /dev/tty || ans=""
                case "$ans" in n|N|no|NO) JUN_REUSE=0; export JUN_REUSE; return 0 ;; esac
            fi
            ;;
    esac
    REUSE=1; JUN_REUSE=1; JUN_YES=1
    export JUN_REUSE JUN_YES
    ok "updating with the settings in .env"
}

# first thing a non-technical user sees. Express sets JUN_YES=1
# and runs the whole install on detected defaults. the one thing
# it still asks is the game folder, when extraction can't find
# it. Custom walks through the provider/model/voice prompts.
# Cloud is Express minus the model. JUN_EXPRESS=1 / JUN_CLOUD=1
# pick one up front.
choose_install_mode() {
    case "$(printf '%s' "${JUN_CLOUD:-}" | tr '[:upper:]' '[:lower:]')" in
        1|on|yes|true) CLOUD=1 ;;
    esac
    # a handoff child takes the answer the parent already got
    if [ "$HANDOFF" = 1 ]; then
        case "${JUN_EXPRESS:-}" in
            1) EXPRESS=1; return ;;
            0) return ;;
        esac
    fi
    if [ "$CLOUD" = 1 ]; then
        JUN_CLOUD=1; export JUN_CLOUD
        [ "${JUN_YES:-}" = "1" ] && return
        { true >/dev/tty; } 2>/dev/null || return 0
        cloud_mode
        return
    fi
    [ "${JUN_YES:-}" = "1" ] && return
    case "$(printf '%s' "${JUN_EXPRESS:-}" | tr '[:upper:]' '[:lower:]')" in
        1|on|yes|true) JUN_YES=1; JUN_EXPRESS=1; export JUN_YES JUN_EXPRESS; EXPRESS=1; return ;;
    esac
    # a readable /dev/tty node can still fail to open when there's
    # no controlling terminal. so actually open it, the mode bits
    # lie.
    { true >/dev/tty; } 2>/dev/null || return 0
    local ans
    {
        printf '\n     %show should I install?%s\n' "$B" "$R"
        printf '       %s1%s  Express  %s-%s everything with recommended settings %s(default)%s\n' "$ACCENT" "$R" "$DIM" "$R" "$DIM" "$R"
        printf '       %s2%s  Custom   %s-%s pick the provider, model, voice, and more\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '       %s3%s  Cloud    %s-%s everything but the model, she thinks on a Colab/Kaggle notebook. chats go there\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '     %s$%s choice %s[enter = Express]%s %s→%s ' "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R"
    } > /dev/tty
    read -r ans < /dev/tty || ans=""
    case "$ans" in
        2|custom|Custom|CUSTOM) JUN_EXPRESS=0; export JUN_EXPRESS; note "custom install - I'll ask about each option below" ;;
        3|cloud|Cloud|CLOUD) cloud_mode ;;
        *) JUN_YES=1; JUN_EXPRESS=1; export JUN_YES JUN_EXPRESS; EXPRESS=1; ok "express install - using recommended settings" ;;
    esac
}

# a person at the keyboard picked Cloud. Express for the rest.
cloud_mode() {
    CLOUD=1; EXPRESS=1
    JUN_YES=1; JUN_EXPRESS=1; JUN_CLOUD=1; JUN_CLOUD_ASK=1
    export JUN_YES JUN_EXPRESS JUN_CLOUD JUN_CLOUD_ASK
    ok "cloud install - no model here, I'll ask for the notebook's lines"
}

# JUN_REPO allows forks, so non-upstream code needs HTTPS and an
# explicit yes. JUN_ALLOW_FORK=1 is that yes for unattended runs.
# JUN_YES is not.
check_repo_source() {
    case "$REPO" in
        https://*) ;;
        *) fail_ "JUN_REPO must be an https:// URL - refusing to clone $REPO"; exit 1 ;;
    esac
    [ "$REPO" = "$REPO_UPSTREAM" ] && return 0

    warn_ "JUN_REPO points somewhere other than upstream:"
    note "  yours:    $REPO"
    note "  upstream: $REPO_UPSTREAM"
    if [ "${JUN_ALLOW_FORK:-}" = "1" ]; then
        ok "JUN_ALLOW_FORK=1 - installing from the fork"
        return 0
    fi
    if [ ! -r /dev/tty ]; then
        fail_ "set JUN_ALLOW_FORK=1 to install from a fork non-interactively."
        exit 1
    fi
    local a
    printf '     %s$%s clone from it anyway? %s[y/N]%s %s→%s ' \
        "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
    read -r a < /dev/tty || a=""
    case "$a" in y|Y|yes|YES) ;; *) fail_ "aborted."; exit 1 ;; esac
}

# markers, not the folder name. JUN_DIR lets people call it
# whatever they want, and "Jun" on its own could be anything.
is_jun_checkout() {
    [ -f "$1/start.sh" ] && [ -f "$1/docker-compose.yml" ] && [ -d "$1/webapp" ]
}

# people re-run the install one-liner from inside the checkout
# they already have (or from webapp/ two levels down). without
# this we clone Jun/Jun next to it and set up a second stack
# fighting the first one for :80. so: look at $DIR, then walk up
# from here. an explicit JUN_DIR is a decision, it wins.
locate_install() {
    is_jun_checkout "$DIR" && { EXISTING="$DIR"; return 0; }
    [ -n "${JUN_DIR:-}" ] && return 1
    local d="$PWD"
    while :; do
        is_jun_checkout "$d" && { EXISTING="$d"; return 0; }
        [ "$d" = "/" ] && return 1
        d="$(dirname "$d")"
    done
}

# stable = newest full release, latest = newest release of any
# kind (pre-releases count), experimental = main.
channel_name() {
    case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in
        stable|release) echo stable ;;
        latest|prerelease|pre-release|beta|preview) echo latest ;;
        experimental|main|dev|nightly) echo experimental ;;
    esac
}

# JUN_CHANNEL, else what the install recorded in its .env, else
# stable. Custom asks, with that as the default. sets $CHANNEL.
choose_channel() {
    local existing="$1" saved ans c
    if [ -n "${JUN_CHANNEL:-}" ]; then
        CHANNEL="$(channel_name "$JUN_CHANNEL")"
        [ -n "$CHANNEL" ] && return 0
        warn_ "JUN_CHANNEL='$JUN_CHANNEL' isn't a channel (stable, latest or experimental)"
    fi
    CHANNEL=stable
    if [ -n "$existing" ]; then
        saved="$(grep -E '^JUN_CHANNEL=' "$existing/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
        saved="$(channel_name "$saved")"
        if [ -n "$saved" ]; then
            CHANNEL="$saved"
        elif git -C "$existing" symbolic-ref -q HEAD >/dev/null 2>&1; then
            # installs from before channels all sit on main. moving
            # them to a release now could mean OLDER code than they
            # run, so they stay experimental until somebody picks.
            CHANNEL=experimental
        fi
    fi
    [ "${JUN_YES:-}" = "1" ] && return 0
    { true >/dev/tty; } 2>/dev/null || return 0
    {
        printf '\n     %swhich release channel?%s\n' "$B" "$R"
        printf '       %s1%s  Stable        %s-%s the latest full release\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '       %s2%s  Latest        %s-%s the newest release, pre-releases included\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '       %s3%s  Experimental  %s-%s straight from main, can break any day\n' "$ACCENT" "$R" "$DIM" "$R"
        printf '     %s$%s choice %s[enter = %s]%s %s→%s ' "$OK" "$R" "$DIM" "$CHANNEL" "$R" "$ACCENT" "$R"
    } > /dev/tty
    read -r ans < /dev/tty || ans=""
    case "$ans" in
        "") ;;
        1) CHANNEL=stable ;;
        2) CHANNEL=latest ;;
        3) CHANNEL=experimental ;;
        *)
            c="$(channel_name "$ans")"
            if [ -n "$c" ]; then CHANNEL="$c"; else warn_ "unrecognized choice, staying on $CHANNEL"; fi
            ;;
    esac
}

github_slug() {
    printf '%s\n' "$1" | sed -nE 's#^https://github\.com/([^/]+)/([^/]+)/?$#\1/\2#p' | sed 's/\.git$//'
}

gh_get() {  # url file -> http status on stdout, 000 when nothing answered
    curl --proto '=https' --tlsv1.2 -sS --retry 2 -o "$2" -w '%{http_code}' \
        -H 'Accept: application/vnd.github+json' -H 'User-Agent: JunOS-installer' "$1" 2>/dev/null || true
}

# no jq on a fresh box, so the tag comes out with grep and sed
first_tag() {
    grep -o '"tag_name": *"[^"]*"' "$1" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true
}

# the website, not the API, so no 60-an-hour cap. /releases/latest
# 302s to /releases/tag/<newest full release>, or to plain
# /releases when there's no full release. it never sees drafts
# or pre-releases. (releases.atom is NOT a fallback, it lists
# bare tags too, draft ones included)
web_latest_tag() {  # slug
    local loc
    loc="$(curl --proto '=https' --tlsv1.2 -sS -o /dev/null -w '%{redirect_url}' \
        "https://github.com/$1/releases/latest" 2>/dev/null || true)"
    case "$loc" in
        */releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
        *) return 1 ;;
    esac
}

# the tag a release channel points at right now, on stdout.
# returns 1 when no release of that kind exists, 2 when GitHub
# couldn't be asked (offline, rate limited at 60 an hour per IP,
# not a github URL). anonymous requests never see drafts, so a
# draft never gets installed.
release_tag() {
    local slug api body code tag=""
    slug="$(github_slug "$REPO")"
    [ -n "$slug" ] || return 2
    api="https://api.github.com/repos/$slug/releases"
    body="$(mktemp)"
    if [ "$1" = stable ]; then
        code="$(gh_get "$api/latest" "$body")"
        if [ "$code" = 200 ]; then
            tag="$(first_tag "$body")"
            rm -f "$body"
            [ -n "$tag" ] || return 1
            echo "$tag"
            return 0
        fi
        [ "$code" = 404 ] || { rm -f "$body"; release_tag_web "$slug"; return; }
    fi
    code="$(gh_get "$api?per_page=10" "$body")"
    [ "$code" = 200 ] && tag="$(first_tag "$body")"
    rm -f "$body"
    [ "$code" = 200 ] || { release_tag_web "$slug"; return; }
    [ -n "$tag" ] || return 1
    [ "$1" = stable ] && note "no full release yet - using the newest pre-release, $tag" >&2
    echo "$tag"
}

# the API said no. on latest that means a full release where a
# newer pre-release might exist, which is still a release and
# never main. update_checkout won't step back to it either.
release_tag_web() {  # slug
    local tag
    tag="$(web_latest_tag "$1")" || return 2
    note "GitHub's API didn't answer - going by the newest full release, $tag" >&2
    echo "$tag"
}

# the ref to put on disk in $TARGET. empty = leave the code alone.
resolve_target() {  # 1 = fresh install
    local rc=0
    TARGET=""
    if [ -n "${JUN_REF:-}" ]; then TARGET="$JUN_REF"; return 0; fi
    if [ "$CHANNEL" = experimental ]; then TARGET=main; return 0; fi
    TARGET="$(release_tag "$CHANNEL")" || rc=$?
    [ "$rc" = 0 ] && return 0
    TARGET=""
    # main is NEVER the fallback. it's whatever got pushed today,
    # and the only way onto it is asking for experimental.
    if [ "$rc" = 1 ]; then
        [ "$1" = 1 ] && fail_ "no release of Jun is published yet"
        [ "$1" = 1 ] || note "no $CHANNEL release published yet - keeping the code that's on disk"
    else
        warn_ "couldn't ask GitHub which release is current"
        [ "$1" = 1 ] && note "offline, or rate limited (GitHub answers 60 of these an hour per IP). give it a bit and run this again."
        [ "$1" = 1 ] || note "keeping the code that's on disk"
    fi
    [ "$1" = 1 ] || return 0
    note "JUN_REF=<tag> installs a release you name, JUN_CHANNEL=experimental installs main on purpose."
    exit 1
}

# semver-ish. v1.2.0 beats v1.2.0-beta, two pre-releases of the
# same version compare as plain strings. a -security tag sorts
# right above the same tag without it, or an install on v1.0.4
# would read v1.0.4-security as a pre-release, stay put, and
# start.sh would refuse to boot it forever.
version_lt() {
    local a="${1#v}" b="${2#v}" as=0 bs=0 ac bc ap="" bp="" i x y
    case "$a" in *-security) a="${a%-security}"; as=1 ;; esac
    case "$b" in *-security) b="${b%-security}"; bs=1 ;; esac
    ac="${a%%-*}"; bc="${b%%-*}"
    [ "$ac" != "$a" ] && ap="${a#*-}"
    [ "$bc" != "$b" ] && bp="${b#*-}"
    for i in 1 2 3 4; do
        x="$(printf '%s' "$ac" | cut -d. -f"$i")"; y="$(printf '%s' "$bc" | cut -d. -f"$i")"
        case "$x" in ''|*[!0-9]*) x=0 ;; esac
        case "$y" in ''|*[!0-9]*) y=0 ;; esac
        [ "$x" -lt "$y" ] && return 0
        [ "$x" -gt "$y" ] && return 1
    done
    if [ "$ap" != "$bp" ]; then
        [ -z "$ap" ] && return 1
        [ -z "$bp" ] && return 0
        [[ "$ap" < "$bp" ]]
        return
    fi
    [ "$as" -lt "$bs" ]
}

git_move() {  # dir is_tag
    if [ "$TARGET" = main ]; then
        # a tag clone only fetches that one tag. point origin at main
        # again so a later plain `git pull` works too.
        git -C "$1" config --replace-all remote.origin.fetch '+refs/heads/main:refs/remotes/origin/main' &&
        git -C "$1" fetch -q --depth 1 origin &&
        git -C "$1" checkout -q -B main origin/main &&
        git -C "$1" branch -q --set-upstream-to=origin/main main
    elif [ "$2" = 1 ]; then
        git -C "$1" fetch -q --depth 1 origin "+refs/tags/$TARGET:refs/tags/$TARGET" &&
        git -C "$1" -c advice.detachedHead=false checkout -q "refs/tags/$TARGET"
    else
        git -C "$1" fetch -q --depth 1 origin "$TARGET" &&
        git -C "$1" -c advice.detachedHead=false checkout -q --detach FETCH_HEAD
    fi
}

# moves an existing checkout to $TARGET. a tree with edits in it
# stays exactly where it is, same as the old pull --ff-only did.
update_checkout() {  # dir is_tag
    local d="$1" is_tag="$2" branch current log
    if [ -n "$(git -C "$d" status --porcelain --untracked-files=no 2>/dev/null || echo x)" ]; then
        warn_ "files in $d were changed by hand - keeping the code that's on disk"
        note "see what changed with: git -C $d status"
        return 0
    fi
    branch="$(git -C "$d" symbolic-ref -q --short HEAD 2>/dev/null || true)"
    if [ "$TARGET" = main ] && [ -n "$branch" ]; then
        if git -C "$d" pull -q --ff-only >/dev/null 2>&1; then
            ok "up to date with $branch"
        else
            # local commits or a branch of their own. not a reason to
            # stop, the rest still fixes .env, deps and the stack.
            warn_ "couldn't fast-forward $branch - keeping the code that's on disk"
            note "pull it yourself with: git -C $d pull"
        fi
        return 0
    fi
    current="$(git -C "$d" describe --tags --exact-match HEAD 2>/dev/null || true)"
    if [ -n "$current" ] && [ "$current" = "$TARGET" ]; then
        ok "already on $TARGET"
        return 0
    fi
    if [ "$is_tag" = 1 ] && [ -n "$current" ] && version_lt "$TARGET" "$current"; then
        ok "on $current, newer than $CHANNEL ($TARGET) - staying until it catches up"
        return 0
    fi
    if [ "$is_tag" = 1 ] && [ -n "$branch" ]; then
        warn_ "moving from $branch to the $TARGET release - that can be older code than you had."
        note "your chats and settings stay. JUN_CHANNEL=experimental takes you back."
    fi
    log="$(mktemp)"
    if git_move "$d" "$is_tag" >"$log" 2>&1; then
        ok "now on $TARGET"
    else
        sed 's/^/       /' "$log" >&2
        warn_ "couldn't move to $TARGET - keeping the code that's on disk"
    fi
    rm -f "$log"
}

# into a side folder first, renamed once git is done. a clone
# that died halfway (Ctrl+C, flaky wifi) otherwise leaves a folder
# that isn't a checkout but isn't empty either, and every re-run
# after that trips over it.
clone_into() {
    if [ -e "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ]; then
        fail_ "$DIR already exists and isn't a Jun install"
        note "move or rename it (or set JUN_DIR to another folder), then run this again."
        exit 1
    fi
    rm -rf "$DIR.partial"
    rmdir "$DIR" 2>/dev/null || true
    run "$REPO ($TARGET)" git -c advice.detachedHead=false clone -q --depth 1 --branch "$TARGET" "$REPO" "$DIR.partial"
    mv "$DIR.partial" "$DIR"
}

# the installer that came with the code finishes the job, not
# whichever copy got started (main's one-liner, a stale saved
# copy). a release's start.sh and .env keys match ITS install.sh.
# the child skips the banner, the questions already answered and
# the git step, and gets what confirm_deps found out about docker.
# installers from before JUN_HANDOFF don't know any of that, those
# we don't hand to.
hand_off() {
    [ "$HANDOFF" = 1 ] && return 0
    # an installer from before Cloud reads JUN_YES=1 as plain
    # Express and pulls her into a local ollama. 8 GB, for nothing
    if [ "$CLOUD" = 1 ] && ! grep -q JUN_CLOUD install.sh 2>/dev/null; then
        fail_ "the Jun this installs doesn't know cloud installs yet"
        note "re-run with JUN_CHANNEL=experimental, or pick Express or Custom"
        exit 1
    fi
    { [ -f install.sh ] && grep -q JUN_HANDOFF install.sh; } || return 0
    # an older one reads our JUN_YES=1 as Express and redoes .env
    [ "$REUSE" = 1 ] && ! grep -q JUN_REUSE install.sh && return 0
    [ -f "$0" ] && cmp -s "$0" install.sh && return 0
    export JUN_HANDOFF=1 JUN_DIR="$PWD" JUN_CHANNEL="$CHANNEL" \
        JUN_NEED_SG="$NEED_SG" JUN_NEED_SUDO="$NEED_SUDO" \
        JUN_DOCKER_JUST_INSTALLED="$DOCKER_JUST_INSTALLED"
    exec bash ./install.sh
}

# the start menu's bridge switch. the code doesn't move
if [ "${JUN_ONLY:-}" = bridge ]; then
    is_jun_checkout . && [ -f .env ] || { fail_ "run JUN_ONLY=bridge from an installed Jun folder"; exit 1; }
    JUN_GAME_DIR="${JUN_GAME_DIR:-$(env_value JUN_GAME_DIR)}"
    step "game bridge"
    install_game_bridge
    exit $?
fi

[ "$HANDOFF" = 1 ] || banner
EXISTING=""
locate_install || true
choose_reuse
[ "$REUSE" = 1 ] || choose_install_mode
# nothing gets cloned when she's already here, so the fork
# warning has nothing to warn about.
[ -n "$EXISTING" ] || check_repo_source
confirm_deps

if [ "$HANDOFF" = 1 ]; then
    CHANNEL="$(channel_name "${JUN_CHANNEL:-}")"
elif [ -z "$REF" ]; then
    choose_channel "$EXISTING"
    ok "channel $CHANNEL"
fi

if [ -n "$EXISTING" ]; then
    DIR="$EXISTING"
    step "existing install"
    ok "found Jun in $DIR - updating instead of cloning"
    if [ "$HANDOFF" = 1 ]; then
        ok "code already updated"
    elif [ ! -d "$DIR/.git" ]; then
        note "not a git checkout, nothing to pull - re-running setup on what's here"
    else
        resolve_target 0
        if [ -n "$TARGET" ]; then
            is_tag=0
            [ -z "$REF" ] && [ "$TARGET" != main ] && is_tag=1
            update_checkout "$DIR" "$is_tag"
        fi
    fi
else
    resolve_target 1
    step "clone repository"
    clone_into
fi

cd "$DIR"
# .env holds the OpenRouter key once someone types one in, so it
# is the owner's business and nobody else's. chmod on every run,
# not just the first. an .env from an older install is exactly
# the one still sitting there world-readable.
[ -f .env ] || cp .env.example .env
chmod 600 .env 2>/dev/null || true
[ -n "$ADDED_DOCKER_GROUP" ] && record_change docker_group "$ADDED_DOCKER_GROUP"
[ -n "$CHANNEL" ] && set_env JUN_CHANNEL "$CHANNEL"
hand_off

configure

step "asset policy"
warn_ "Jun's Live2D model & textures belong to the creator of"
warn_ "My Dystopian Robot Girlfriend. tools/recover_assets.py rebuilds"
warn_ "them from YOUR game copy, for personal use only - do NOT"
warn_ "republish them (public fork, release, mirror). See the NOTICE file."

# extract only from the user's own game install. nothing
# downloads or leaves the box. Express enables it, Custom asks.
# JUN_EXTRACT=1 forces it, 0 opts out. no game means
# recover_assets.py leaves placeholders and install continues.
extract=0
case "$(printf '%s' "${JUN_EXTRACT:-}" | tr '[:upper:]' '[:lower:]')" in
    1|on|yes|true) extract=1 ;;
    0|off|no|false) extract=0 ;;
    *)
        if [ "$REUSE" = 1 ]; then
            [ -d webapp/assets ] || extract=1
        elif [ "${JUN_YES:-}" = "1" ]; then
            extract=1
        elif [ -r /dev/tty ]; then
            printf '     %s$%s extract them now from your game install? %s[y/N]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
            read -r e < /dev/tty || e=""
            case "$e" in y|Y|yes|YES) extract=1 ;; esac
        fi
        ;;
esac
if [ "$extract" = 1 ]; then
    install_asset_recovery || true
else
    note "skipped - re-run this installer with JUN_EXTRACT=1 anytime to extract."
fi

step "game bridge"
# JUN_BRIDGE=1 forces it, 0 opts out. otherwise only when asked
bridge=0
case "$(printf '%s' "${JUN_BRIDGE:-}" | tr '[:upper:]' '[:lower:]')" in
    1|on|yes|true) bridge=1 ;;
    0|off|no|false) bridge=0 ;;
    *)
        if [ "$REUSE" = 1 ]; then
            case "$(env_value GAME_BRIDGE)" in on|1|true|yes) bridge=1 ;; esac
        elif [ -r /dev/tty ] && { [ "${JUN_YES:-}" != "1" ] || [ "$EXPRESS" = 1 ]; }; then
            printf '     %s$%s bridge her with your game save (affection, outfit, what happened)? %s[y/N]%s %s→%s ' \
                "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
            read -r b < /dev/tty || b=""
            case "$b" in y|Y|yes|YES) bridge=1 ;; esac
        fi
        ;;
esac
if [ "$bridge" = 1 ]; then
    install_game_bridge || true
else
    note "skipped - re-run this installer with JUN_BRIDGE=1 anytime."
fi

[ "$DOCKER_JUST_INSTALLED" = 1 ] && wait_for_docker || true

if docker_run docker info >/dev/null 2>&1; then
    setup_nvidia_toolkit
    step "boot stack"
    allow_privileged_ports
    run_live "build & start containers" docker_run ./start.sh
    if docker_run docker ps --format '{{.Names}}' | grep -qx omega-ollama; then
        note "model pull (first run can take a while):"
        # `logs -f` never exits on its own. awk bails once the
        # entrypoint reports the pull outcome, and SIGPIPE kills
        # the follow.
        docker_run docker logs -f omega-ollama 2>&1 \
            | awk '{ print "       " $0; fflush() } /models ready|pull failed/ { exit }' || true
        ok "models ready"
        note "loading her into memory:"
        if docker_run docker exec omega-php php /var/www/omega/api/prewarm.php 2>&1 | sed 's/^/       /'; then
            ok "she's awake"
        else
            warn_ "she'll load on your first message instead (a minute or two)"
        fi
    elif docker_run docker ps --format '{{.Names}}' | grep -qx omega-llamacpp; then
        note "model download (first run can take a while)"
        waited=0 had_mb=0
        until docker_run docker exec omega-llamacpp curl -fsS http://127.0.0.1:8080/v1/models 2>/dev/null \
                | grep -q '"value":"loaded"'; do
            [ "$waited" -lt 1800 ] || break
            mb="$(docker_run docker exec omega-llamacpp du -sm /root/.cache/llama.cpp 2>/dev/null | cut -f1)"
            if [ -t 1 ] && [ "${mb:-0}" -gt "$had_mb" ]; then
                printf '\r       %s%s MB downloaded%s\033[K' "$DIM" "$mb" "$R"
                had_mb="$mb"
            fi
            sleep 5; waited=$((waited + 5))
        done
        [ "$had_mb" -gt 0 ] && printf '\r\033[K'
        if [ "$waited" -lt 1800 ]; then
            ok "she's awake"
        else
            warn_ "still loading after 30 minutes - she'll answer once ./start.sh logs llamacpp says the model is loaded"
        fi
    fi
    # has to run here, not in configure(). every row it measures is
    # a real generation, so the models have to be pulled and the
    # stack has to be up.
    if [ "${MTP_AUTOTUNE:-0}" = 1 ]; then
        step "tune multi-token prediction"
        docker_run ./mtp-autotune.sh || warn_ "autotune failed - drafting 1 token ahead, re-run ./mtp-autotune.sh anytime"
    fi
    # her cert is signed by a CA made on this box, for local names
    # and private addresses only. trusting it is what makes the
    # browser warning go away for good, even when the LAN address
    # changes. JUN_TRUST_CA=1 forces it, 0 opts out.
    trust=1
    case "$(printf '%s' "${JUN_TRUST_CA:-}" | tr '[:upper:]' '[:lower:]')" in
        1|on|yes|true) trust=1 ;;
        0|off|no|false) trust=0 ;;
        *)
            [ "$REUSE" = 1 ] && trust=0
            if [ "${JUN_YES:-}" != "1" ] && [ -r /dev/tty ]; then
                printf '     %s$%s trust her certificate on this machine, so the browser stops warning? %s[Y/n]%s %s→%s ' \
                    "$OK" "$R" "$DIM" "$R" "$ACCENT" "$R" > /dev/tty
                read -r t < /dev/tty || t=""
                case "$t" in n|N|no|NO) trust=0 ;; esac
            fi
            ;;
    esac
    step "trust her certificate"
    if [ "$trust" = 0 ]; then
        note "skipped - ./start.sh trust does it anytime."
    elif docker_run ./start.sh trust 2>&1 | sed 's/^/       /'; then
        ok "trusted"
    else
        warn_ "not everywhere, see above. ./start.sh trust tries again."
    fi

    reg_key="$(env_value OMEGA_REGISTRATION_KEY)"
    printf '\n'
    step "access keys"
    if [ -n "$reg_key" ]; then
        ok "registration  $reg_key"
        note "give this to the people you invite - empty OMEGA_REGISTRATION_KEY in .env to open signup to anyone."
    else
        note "registration key is empty, anyone who reaches the page can sign up."
    fi
    printf '\n   %s$%s %s%sready%s %s-%s open %shttps://localhost%s\n' \
        "$OK" "$R" "$B" "$OK" "$R" "$DIM" "$R" "$B$ACCENT" "$R"
    if [ "$CLOUD" = 1 ]; then
        note "the notebook's URL and key change every run. ./start.sh asks for the new lines when the old ones stop answering"
    fi
    printf '   %sstop:%s ./start.sh stop   %s·%s   %sstatus:%s ./start.sh status\n\n' \
        "$DIM" "$R" "$DIM" "$R" "$DIM" "$R"
    [ "$NEED_SUDO" = 1 ] && note "you're not in the docker group, so prefix those with sudo."
    if [ -r /dev/tty ] && [ "${JUN_NO_BROWSER:-}" != 1 ] && [ "$(id -u)" -ne 0 ]; then
        if [ "$OS" = Darwin ]; then
            open https://localhost >/dev/null 2>&1 || true
        elif [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1; then
            xdg-open https://localhost >/dev/null 2>&1 || true
        fi
    fi
else
    printf '\n'
    warn_ "Docker isn't reachable yet - finish its setup and run ./start.sh from $DIR."
    if [ "$NEED_SG" = 1 ]; then
        note "you may also need to log out and back in for the 'docker' group to apply."
    fi
    exit 0
fi

#                                 -                                 
#                                 ==:                               
#                        :=--.    ---                               
#                          :--==- :=:                               
#                               .:::::::=-==-:                      
#                          ::::::::::-==---:-==-=:                  
#                      .-=====-::-:-==--==-==--==-=-                
#                    :-=---=--=====--==-==-==-==--==-=              
#                   -=-===-==--=--==--==--=--==-==-:-=-:            
#                  =-==--==-==-==--. =-==-==-=--==-== -=-           
#                 -==-==--=--==-= ::==--==-==-==--=--= ::-          
#                ::-=--=:-==--=::-::=:=--=--=-==-==-==: ::-         
#               ::::::-::--==- -:: =-  .--==-=--==-=:::: ::.        
#               :::::::::---= :--.::.=  ::::::::::::::::: ::        
#              :::::::::::-- --:: :: @*  :::::::::::::::: ::.       
#              :::::::::::::.:::::: @@:   :::::::::::::::: ::       
#              ::::::::::::::::: : @%%%@@# ::::::::::::::: ::       
#             .::::::::::::.:::: %@@.   %@@:.:::::::..:::: ::       
#              ::::::::::::.::::=% :+:## %@%%* :  . #  : . ::       
#              :::::::::::::::::% @@== @*%@@%@@% @=.@   :::::       
#       -====== :::::::::::: ::. -+===-@@%%@@%#:=:= % .:::: :       
#   :==== ------ :::::.:::::::-.:-   %@%%@@%@@#-  %** :::::         
# :====== :------. :::: ::::: -:*@*%%%@@%@@%%@%:@%@%@ :::::         
#  -========:.   .:-  :: :::: .-=**@@@%@@%%@@%@@@%@# :::::          
#    ================.     ::: : -*%%@%%@#%*=:*%@@  :::::.          
#     ------------========   ::. **- *@@%@@@%@@: ::::::::           
# .==-::---==================:  ***** :   =#     :::  ::            
# ===-==============.    .=======:     .         ::                 
# .=============:  ========: -======.  :: =.                        
#  ==========-- .============ =   -=== :: ===                       
#  .=======--: ===============:=####* - : =====                     
#  --------: :=================-:#%%%%:#% -=-===.                   
#   :----:  :=============-=====- #%%%%%%% =.=====                  
#    .--:-::=================:==== %%%%%%%%  ======:                
#       :- =====-:============== -= %%%%%%%* :-=======              
#         ======-:-================: *%%%%%%:::::::-====            
#         =======:-===================- *%%%% ::=========           
#        ========- ::==================== %%*  ::::-=====-.         
#        ======:==: :::==================-:#=  .:-============-     
#        =======:==:..:::-==============**#=    ::::::-==========   
#        =========.-:: :-:::==========%%===#%%==-  ::::::::-=======: