#!/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= 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:-}" exit 1 fi ok "get-docker.sh matches the pinned digest" return 0 fi note "get-docker.sh sha256: ${digest:-}" 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/, 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= 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 # - # ==: # :=--. --- # :--==- :=: # .:::::::=-==-: # ::::::::::-==---:-==-=: # .-=====-::-:-==--==-==--==-=- # :-=---=--=====--==-==-==-==--==-= # -=-===-==--=--==--==--=--==-==-:-=-: # =-==--==-==-==--. =-==-==-=--==-== -=- # -==-==--=--==-= ::==--==-==-==--=--= ::- # ::-=--=:-==--=::-::=:=--=--=-==-==-==: ::- # ::::::-::--==- -:: =- .--==-=--==-=:::: ::. # :::::::::---= :--.::.= ::::::::::::::::: :: # :::::::::::-- --:: :: @* :::::::::::::::: ::. # :::::::::::::.:::::: @@: :::::::::::::::: :: # ::::::::::::::::: : @%%%@@# ::::::::::::::: :: # .::::::::::::.:::: %@@. %@@:.:::::::..:::: :: # ::::::::::::.::::=% :+:## %@%%* : . # : . :: # :::::::::::::::::% @@== @*%@@%@@% @=.@ ::::: # -====== :::::::::::: ::. -+===-@@%%@@%#:=:= % .:::: : # :==== ------ :::::.:::::::-.:- %@%%@@%@@#- %** ::::: # :====== :------. :::: ::::: -:*@*%%%@@%@@%%@%:@%@%@ ::::: # -========:. .:- :: :::: .-=**@@@%@@%%@@%@@@%@# ::::: # ================. ::: : -*%%@%%@#%*=:*%@@ :::::. # ------------======== ::. **- *@@%@@@%@@: :::::::: # .==-::---==================: ***** : =# ::: :: # ===-==============. .=======: . :: # .=============: ========: -======. :: =. # ==========-- .============ = -=== :: === # .=======--: ===============:=####* - : ===== # --------: :=================-:#%%%%:#% -=-===. # :----: :=============-=====- #%%%%%%% =.===== # .--:-::=================:==== %%%%%%%% ======: # :- =====-:============== -= %%%%%%%* :-======= # ======-:-================: *%%%%%%:::::::-==== # =======:-===================- *%%%% ::========= # ========- ::==================== %%* ::::-=====-. # ======:==: :::==================-:#= .:-============- # =======:==:..:::-==============**#= ::::::-========== # =========.-:: :-:::==========%%===#%%==- ::::::::-=======: