install.sh
Rama main · réplica de lectura del repositorio original.
#!/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
# -
# ==:
# :=--. ---
# :--==- :=:
# .:::::::=-==-:
# ::::::::::-==---:-==-=:
# .-=====-::-:-==--==-==--==-=-
# :-=---=--=====--==-==-==-==--==-=
# -=-===-==--=--==--==--=--==-==-:-=-:
# =-==--==-==-==--. =-==-==-=--==-== -=-
# -==-==--=--==-= ::==--==-==-==--=--= ::-
# ::-=--=:-==--=::-::=:=--=--=-==-==-==: ::-
# ::::::-::--==- -:: =- .--==-=--==-=:::: ::.
# :::::::::---= :--.::.= ::::::::::::::::: ::
# :::::::::::-- --:: :: @* :::::::::::::::: ::.
# :::::::::::::.:::::: @@: :::::::::::::::: ::
# ::::::::::::::::: : @%%%@@# ::::::::::::::: ::
# .::::::::::::.:::: %@@. %@@:.:::::::..:::: ::
# ::::::::::::.::::=% :+:## %@%%* : . # : . ::
# :::::::::::::::::% @@== @*%@@%@@% @=.@ :::::
# -====== :::::::::::: ::. -+===-@@%%@@%#:=:= % .:::: :
# :==== ------ :::::.:::::::-.:- %@%%@@%@@#- %** :::::
# :====== :------. :::: ::::: -:*@*%%%@@%@@%%@%:@%@%@ :::::
# -========:. .:- :: :::: .-=**@@@%@@%%@@%@@@%@# :::::
# ================. ::: : -*%%@%%@#%*=:*%@@ :::::.
# ------------======== ::. **- *@@%@@@%@@: ::::::::
# .==-::---==================: ***** : =# ::: ::
# ===-==============. .=======: . ::
# .=============: ========: -======. :: =.
# ==========-- .============ = -=== :: ===
# .=======--: ===============:=####* - : =====
# --------: :=================-:#%%%%:#% -=-===.
# :----: :=============-=====- #%%%%%%% =.=====
# .--:-::=================:==== %%%%%%%% ======:
# :- =====-:============== -= %%%%%%%* :-=======
# ======-:-================: *%%%%%%:::::::-====
# =======:-===================- *%%%% ::=========
# ========- ::==================== %%* ::::-=====-.
# ======:==: :::==================-:#= .:-============-
# =======:==:..:::-==============**#= ::::::-==========
# =========.-:: :-:::==========%%===#%%==- ::::::::-=======: