start.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# no arguments in a terminal opens the menu. install.sh, systemd and
# scripts have no tty here and still just start her
if [ $# -eq 0 ] && [ -t 0 ] && [ -t 1 ] && [ -z "${JUN_NO_MENU:-}" ] && [ -f tools/start-menu.sh ]; then
exec bash tools/start-menu.sh
fi
case "${1:-}" in start|up) shift ;; esac
# dockerd only offers driver: nvidia when one of these hooks is on
# its PATH at startup. no hook = "could not select device driver"
docker_sees_nvidia() {
command -v nvidia-container-runtime-hook >/dev/null 2>&1 \
|| command -v nvidia-cdi-hook >/dev/null 2>&1 \
|| docker info 2>/dev/null | grep -q '^ *Runtimes:.*nvidia'
}
detect_gpu() {
case "${GPU:-auto}" in
nvidia|amd|cpu) echo "$GPU"; return ;;
esac
if { command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; } \
|| [ -e /proc/driver/nvidia/version ]; then
if docker_sees_nvidia; then
echo nvidia
else
echo "warning: NVIDIA card found but Docker can't use it, the NVIDIA Container Toolkit is missing." >&2
echo " she runs on the CPU for now. re-run install.sh and it offers to add it" >&2
echo cpu
fi
elif [ -e /dev/kfd ] && compgen -G "/dev/dri/renderD*" >/dev/null; then
echo amd
else
echo cpu
fi
}
# Sort by VRAM so the biggest card ends up as device 0. it has to
# be UUIDs and NOT indices, nvidia-smi counts cards in slot order
# while CUDA sorts them fastest first, so index 1 means a
# different card to each of them.
nvidia_visible() {
nvidia-smi --query-gpu=memory.total,uuid --format=csv,noheader,nounits 2>/dev/null \
| sort -t, -k1 -nr | cut -d, -f2 | tr -d ' \r' | paste -sd, - || true
}
nvidia_count() {
nvidia-smi --query-gpu=uuid --format=csv,noheader 2>/dev/null | grep -c . || true
}
# VRAM on the biggest card, in MiB. php has no GPU device of its
# own so this is the ONLY way it finds out, see default_num_ctx()
# in api/lib/providers/context.php.
nvidia_vram_mb() {
nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
| sort -nr | head -n1 | tr -d ' \r' || true
}
amd_vram_mb() {
rocm-smi --showmeminfo vram --csv 2>/dev/null \
| awk -F, 'NR>1 { gsub(/[^0-9]/,"",$2); if ($2 != "") print int($2/1048576) }' \
| sort -nr | head -n1 || true
}
amd_visible() {
rocm-smi --showmeminfo vram --csv 2>/dev/null \
| awk -F, 'NR>1 { gsub(/[^0-9]/,"",$1); gsub(/[^0-9]/,"",$2); if ($2 != "") print $2","$1 }' \
| sort -t, -k1 -nr | cut -d, -f2 | paste -sd, - || true
}
# gfx1031 reads 100301 here. biggest card ON PURPOSE, the override
# hits every card and an iGPU's value breaks the real one next to it
amd_gfx_target() {
local node size best=0 target=""
for node in /sys/class/kfd/kfd/topology/nodes/*; do
[ -r "$node/mem_banks/0/properties" ] || continue
size="$(awk '$1 == "size_in_bytes" { print $2 }' "$node/mem_banks/0/properties")"
if [ "${size:-0}" -gt "$best" ]; then
best="$size"
target="$(awk '$1 == "gfx_target_version" { print $2 }' "$node/properties")"
fi
done
printf '%s' "$target"
}
# only siblings of a target ROCm ships kernels for. RDNA1 and RDNA4
# have none to borrow, a different ISA crashes instead of helping
amd_hsa_override() {
case "$1" in
10030[1-6]) echo 10.3.0 ;;
11000[23]|1105[0-9][0-9]) echo 11.0.0 ;;
esac
}
# the card the MTP tune was measured on, as one string. MTP is
# multi-token prediction, a small drafter model guesses the next
# few tokens (how many = the draft depth) and the chat model
# checks them all in one pass. string is the vendor, then every
# GPU's name and how much VRAM it has. sorted biggest card first,
# so moving cards between slots is not a change, only a real
# swap is.
#
# the AMD half takes VRAM and nothing else. rocm-smi moves its
# product-name columns around between versions, and a name read
# out of the wrong column would make every boot look like a new
# card. missing a swap between two cards of the same size is the
# cheaper mistake. prints nothing when neither tool is here, an
# empty string is how the callers know we could not tell.
gpu_signature() {
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null \
| sed -e 's/\r//' -e 's/[[:space:]]*,[[:space:]]*/:/' \
| sort -t: -k2 -nr \
| awk 'NF { s = s (s ? "," : "") $0 } END { if (s) print "nvidia:" s }'
elif command -v rocm-smi >/dev/null 2>&1; then
rocm-smi --showmeminfo vram --csv 2>/dev/null \
| awk -F, 'NR>1 { gsub(/[^0-9]/,"",$2); if ($2 != "") print int($2/1048576) }' \
| sort -nr \
| awk 'NF { s = s (s ? "," : "") $0 } END { if (s) print "amd:" s }'
fi
}
# the model servers sit behind compose profiles, `ollama` runs
# the Ollama one and `llamacpp` the llama.cpp one. we MERGE with
# whatever the shell already set, like
# COMPOSE_PROFILES=prod ./start.sh, and whatever .env set. never
# replace it. then add what AI_PROVIDER implies on top. that way
# an old .env from before providers existed still boots ollama.
env_get() { sed -n "s/^$1=//p" .env 2>/dev/null | tail -n1 || true; }
add_profile() {
case ",${profiles}," in *,"$1",*) ;; *) profiles="${profiles:+$profiles,}$1" ;; esac
}
# her brain on another machine, mostly colab.ipynb in inference
# only mode. the quick tunnel URL and the gate key are new every
# notebook run, so a link that stopped answering is how we find
# out it changed. ask for Step 3's lines instead of making people
# hand edit .env. only these names get in, 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"
env_set() {
local tmp
tmp="$(mktemp .env.XXXXXX)"
grep -vE "^$1=" .env > "$tmp" 2>/dev/null || true
printf '%s=%s\n' "$1" "$2" >> "$tmp"
chmod 600 "$tmp"
mv "$tmp" .env
}
# "url|key|probe-path" of a model server off this machine, nothing
# when it's ours. loopback and the compose names are ours.
cloud_link() {
local url
case "$(env_get AI_PROVIDER)" in
vllm) printf '%s|%s|/v1/models' "$(env_get VLLM_URL)" "$(env_get VLLM_API_KEY)" ;;
openrouter|llamacpp) ;;
*)
url="$(env_get OLLAMA_URL)"
case "$url" in ''|*://ollama[:/]*|*://localhost[:/]*|*://127.0.0.1[:/]*|*://host.docker.internal[:/]*) ;;
*) printf '%s|%s|/api/tags' "$url" "$(env_get OLLAMA_API_KEY)" ;;
esac
;;
esac
}
cloud_paste() {
local line key n=0
echo "paste the lines Step 3 of the notebook printed, then enter on an empty line."
echo "(just enter keeps what .env has)"
while IFS= read -r line < /dev/tty; do
line="$(printf '%s' "$line" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[ -n "$line" ] || break
key="${line%%=*}"
[ "$key" != "$line" ] || continue
case " $CLOUD_KEYS " in
*" $key "*) env_set "$key" "${line#*=}"; n=$((n + 1)) ;;
*) echo "skipped $key, the notebook never prints that one" ;;
esac
done
[ "$n" -eq 0 ] || echo "saved $n line(s) to .env"
}
# 8 s, a tunnel that's alive answers in under one. a dead quick
# tunnel is a DNS failure or a cloudflare 530, a stale key is the
# gate's 401. same thing to us, the lines are old.
check_cloud_link() {
local url="" key="" probe="" auth=()
IFS='|' read -r url key probe <<<"$(cloud_link)" || true
[ -n "$probe" ] || return 0
command -v curl >/dev/null 2>&1 || return 0
[ -z "$key" ] || auth=(-H "Authorization: Bearer $key")
if [ -n "$url" ] && curl -fsS -o /dev/null -m 8 ${auth[@]+"${auth[@]}"} "${url%/}$probe" 2>/dev/null; then
return 0
fi
if [ -z "$url" ]; then
echo "no link to her model server in .env yet (VLLM_URL is empty)" >&2
else
echo "her model server at $url isn't answering. a new notebook run means a new URL and key" >&2
fi
if [ -t 1 ] && { true >/dev/tty; } 2>/dev/null; then
cloud_paste
else
echo "./start.sh link takes the new lines" >&2
fi
}
is_on() {
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in on|1|true|yes) return 0 ;; esac
return 1
}
# --ignore-security can sit anywhere on the line. it comes out
# here so it never reaches compose as a service name
ignore_security="${JUN_IGNORE_SECURITY:-$(env_get JUN_IGNORE_SECURITY)}"
args=()
for a in "$@"; do
case "$a" in --ignore-security) ignore_security=1 ;; *) args+=("$a") ;; esac
done
set -- ${args[@]+"${args[@]}"}
# install.sh's version_lt() plus one rule. a -security tag sorts
# right above the same tag without it, so v1.0.4-beeeta-security
# beats v1.0.4-beeeta and v1.0.4-security beats v1.0.4.
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" ]
}
# tag<TAB>prerelease, one release a line. no jq on a fresh box.
# tag_name comes before prerelease in every release object and
# a release body can't fake either key, its quotes come escaped
release_rows() {
grep -oE '"(tag_name|prerelease)": *("[^"]*"|true|false)' "$1" \
| awk -F': *' '/"tag_name"/ { gsub(/"/, "", $2); t = $2; next } t != "" { print t "\t" $2; t = "" }'
}
# UPDATE_CHECK=on (the installer asks) and we're on a release
# tag: ask GitHub once what's newer on our channel. a newer
# -security release means the one we run has a hole bad enough
# to stop her over (unauthenticated RCE class, see SECURITY.md),
# so no boot unless --ignore-security / JUN_IGNORE_SECURITY=1.
# anything else newer is one line after boot. it never updates
# anything. main or a branch of your own is skipped, the fix for
# a -security release lands on main first.
update_note=""
update_check() {
local current slug channel body code tag pre newest="" security=""
is_on "${UPDATE_CHECK:-$(env_get UPDATE_CHECK)}" || return 0
command -v curl >/dev/null 2>&1 || return 0
current="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
[ -n "$current" ] || return 0
slug="$(git remote get-url origin 2>/dev/null \
| sed -nE 's#^(https://github\.com/|git@github\.com:)([^/]+/[^/]+)$#\2#p' | sed 's#/$##; s#\.git$##')"
[ -n "$slug" ] || return 0
channel="$(env_get JUN_CHANNEL)"
body="$(mktemp)"
code="$(curl --proto '=https' --tlsv1.2 -sS -m 8 -o "$body" -w '%{http_code}' \
-H 'Accept: application/vnd.github+json' -H 'User-Agent: JunOS-update-check' \
"https://api.github.com/repos/$slug/releases?per_page=20" 2>/dev/null || true)"
if [ "$code" = 200 ]; then
while IFS=$'\t' read -r tag pre; do
[ "$channel" = latest ] || [ "$channel" = experimental ] || [ "$pre" = false ] || continue
version_lt "$current" "$tag" || continue
{ [ -z "$newest" ] || version_lt "$newest" "$tag"; } && newest="$tag"
case "$tag" in *-security) security="$tag" ;; esac
done < <(release_rows "$body")
else
# 60 anonymous API calls an hour per IP. the website's
# /releases/latest redirect has no cap but only knows the
# newest full release
tag="$(curl --proto '=https' --tlsv1.2 -sS -m 8 -o /dev/null -w '%{redirect_url}' \
"https://github.com/$slug/releases/latest" 2>/dev/null || true)"
case "$tag" in
*/releases/tag/?*) tag="${tag##*/releases/tag/}" ;;
*) rm -f "$body"; echo "update check: couldn't reach GitHub, skipped"; return 0 ;;
esac
if version_lt "$current" "$tag"; then
newest="$tag"
case "$tag" in *-security) security="$tag" ;; esac
fi
fi
rm -f "$body"
if [ -n "$security" ]; then
{
echo ""
echo "SECURITY: $security is out and fixes a vulnerability in the version you run ($current)."
echo " details: https://github.com/$slug/releases/tag/$security"
echo " update: ./install.sh from this folder. it only moves the code, chats and settings stay."
} >&2
if is_on "$ignore_security"; then
echo " starting anyway, --ignore-security / JUN_IGNORE_SECURITY is set. update soon." >&2
update_note="reminder: you're on $current with a security fix out ($security). ./install.sh updates her."
return 0
fi
{
echo " NOT starting her on it. if she's still up from before, ./start.sh stop takes her down."
echo " ./start.sh --ignore-security (or JUN_IGNORE_SECURITY=1) starts her anyway, at your own risk."
} >&2
exit 3
fi
[ -z "$newest" ] || update_note="update: $newest is out, you're on $current. ./install.sh from this folder updates her, chats and settings stay."
}
case "${1:-up}" in
link)
{ true >/dev/tty; } 2>/dev/null || { echo "./start.sh link needs a terminal" >&2; exit 1; }
cloud_paste
echo "./start.sh to restart her with them"
exit 0
;;
stop|down|status|ps|logs|trust|phone) ;;
*) check_cloud_link; update_check ;;
esac
profiles="${COMPOSE_PROFILES:-}"
file_profiles="$(env_get COMPOSE_PROFILES)"
# except the model server ones. those come from AI_PROVIDER and
# the URLs below. install.sh writes COMPOSE_PROFILES=ollama, so
# pointing OLLAMA_URL at the colab tunnel later would still boot
# a local ollama nobody talks to, and it'd pull the 12B into it.
# 8 GB. for nothing.
for p in ${file_profiles//,/ }; do
case "$p" in ollama|llamacpp) ;; *) add_profile "$p" ;; esac
done
provider="$(env_get AI_PROVIDER)"; provider="${provider:-ollama}"
llamacpp_url="$(env_get LLAMACPP_URL)"
case "$provider" in
llamacpp)
# We run llama-server ourselves unless you pointed us at your own.
case "${llamacpp_url:-http://llamacpp:8080}" in
http://llamacpp:8080) add_profile llamacpp ;;
esac
llamacpp_model_file="$(env_get LLAMACPP_MODEL_FILE)"
if [ -n "$llamacpp_model_file" ]; then
export LLAMACPP_MODEL_FILE="$llamacpp_model_file"
llamacpp_models_dir="$(env_get LLAMACPP_MODELS_DIR)"
export LLAMACPP_MODELS_DIR="${llamacpp_models_dir:-./models}"
if [ ! -f "$LLAMACPP_MODELS_DIR/$LLAMACPP_MODEL_FILE" ]; then
echo "error: LLAMACPP_MODEL_FILE not found: $LLAMACPP_MODELS_DIR/$LLAMACPP_MODEL_FILE" >&2
exit 1
fi
llamacpp_alias="$(env_get LLAMACPP_MODEL_ALIAS)"
[ -z "$llamacpp_alias" ] || export LLAMACPP_MODEL_ALIAS="$llamacpp_alias"
fi
;;
# vllm is never ours to run, it's the colab/kaggle TPU (or a
# server of your own) behind VLLM_URL
openrouter|vllm) : ;;
*)
# We run ollama ourselves unless you pointed us at your own.
case "$(env_get OLLAMA_URL)" in
''|http://ollama:11434) add_profile ollama ;;
esac
;;
esac
voice="${VOICE:-$(env_get VOICE)}"
case "$(printf '%s' "${voice:-on}" | tr '[:upper:]' '[:lower:]')" in
off|0|false|no) ;;
*) add_profile voice ;;
esac
karaoke="${KARAOKE:-$(env_get KARAOKE)}"
case "$(printf '%s' "$karaoke" | tr '[:upper:]' '[:lower:]')" in
on|1|true|yes) add_profile karaoke ;;
esac
export COMPOSE_PROFILES="$profiles"
echo "AI provider: $provider${profiles:+ (compose profiles: $profiles)}"
case ",${profiles}," in
*,karaoke,*) ;;
*) echo "karaoke: off (set KARAOKE=on in .env to build its sidecar)" ;;
esac
# Compose publishes on BIND_ADDR, loopback unless somebody
# changed it. Say which it is, out loud, every start. "it's only
# on my machine" is the kind of thing people believe long after
# it stopped being true.
bind_addr="${BIND_ADDR:-$(env_get BIND_ADDR)}"
bind_addr="${bind_addr:-127.0.0.1}"
export BIND_ADDR="$bind_addr"
case "$bind_addr" in
127.0.0.1|localhost|::1) echo "listening on: $bind_addr (this machine only)" ;;
*) echo "listening on: $bind_addr - anything that can reach this box can open Jun"
echo "off loopback even the first account needs OMEGA_REGISTRATION_KEY from .env" ;;
esac
# install.sh and install.ps1 both write this on first run, but
# cp .env.example .env && ./start.sh never went through either,
# and that box has open signup until someone notices. same rule
# as the installers, only when the line is MISSING. an empty
# OMEGA_REGISTRATION_KEY= is the operator saying "off".
if [ -f .env ] && ! grep -qE '^OMEGA_REGISTRATION_KEY=' .env; then
reg_key="$(openssl rand -hex 16 2>/dev/null || head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n')"
printf 'OMEGA_REGISTRATION_KEY=%s\n' "$reg_key" >> .env
echo "registration key: $reg_key (written to .env, the first account skips it while listening on loopback only, everyone after needs it)"
fi
# same shape for the header php shows the tts/karaoke sidecars.
# an empty SIDECAR_SECRET= is honoured too, the sidecar then runs
# on its Host allowlist alone and warns about it at startup.
if [ -f .env ] && ! grep -qE '^SIDECAR_SECRET=' .env; then
printf 'SIDECAR_SECRET=%s\n' "$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n')" >> .env
fi
# nginx and php both refuse a Host they don't know (444 and 421),
# so opening the phone at https://192.168.1.42 needs that exact
# address in the allowlist. the containers can't work it out
# themselves, all they see is the docker bridge, so we read the
# host's own private v4 addresses here and hand them down. only
# when BIND_ADDR is off loopback: on the default install nothing
# outside this box can connect anyway, so widening the list buys
# nothing. 10.*, 172.16-31.* and 192.168.* only, and never the
# docker bridges, or we'd be naming addresses that aren't ours to
# answer for. DHCP moves these, so a new lease means a restart.
# OMEGA_EXTRA_HOSTS stays for anything we can't guess: an mDNS
# name, a tailscale address, whatever the proxy calls you.
lan_hosts() {
command -v ip >/dev/null 2>&1 || return 0
ip -4 -o addr show scope global up 2>/dev/null | awk '
$2 ~ /^(docker|br-|veth|virbr)/ { next }
{ split($4, a, "/")
if (a[1] ~ /^10\./ || a[1] ~ /^192\.168\./ || a[1] ~ /^172\.(1[6-9]|2[0-9]|3[01])\./) print a[1] }'
}
case "$bind_addr" in
127.0.0.1|127.*|localhost|::1) ;;
*)
extra_hosts="${OMEGA_EXTRA_HOSTS:-$(env_get OMEGA_EXTRA_HOSTS)}"
detected="$(lan_hosts | tr '\n' ' ')"
# commas out FIRST. php takes either, nginx's server_name only
# takes spaces and would happily register a host called
# "jun.local,".
extra_hosts="$(printf '%s %s' "$extra_hosts" "$detected" | tr ',' ' ' | tr -s ' ' | sed 's/^ //; s/ $//')"
export OMEGA_EXTRA_HOSTS="$extra_hosts"
[ -z "$detected" ] || echo "reachable as: $(printf '%s' "$detected" | sed 's/ $//; s/[^ ]*/https:\/\/&/g')"
;;
esac
tls_mode="${TLS_MODE:-$(env_get TLS_MODE)}"
tls_mode_normalized="$(printf '%s' "${tls_mode:-off}" | tr '[:upper:]' '[:lower:]')"
case "$tls_mode_normalized" in
off|"") ;;
*) case "$bind_addr" in
127.0.0.1|localhost|::1)
echo "note: TLS_MODE=$tls_mode but we only listen on $bind_addr, so Let's Encrypt can't reach the challenge. set BIND_ADDR=0.0.0.0 in .env." ;;
esac ;;
esac
export TLS_MODE="${tls_mode:-off}"
tts_device="${TTS_DEVICE:-$(env_get TTS_DEVICE)}"
export TTS_DEVICE="${tts_device:-cpu}"
# The GPU overlays build the karaoke sidecar with a CUDA or ROCm
# torch. when separation is set to run on the CPU we pin the CPU
# index instead, so we don't pull down a multi-GB wheel for
# hardware nobody asked to use.
sep_device="${SEP_DEVICE:-$(env_get SEP_DEVICE)}"
sep_device="${sep_device:-auto}"
karaoke_torch_index="${KARAOKE_TORCH_INDEX:-$(env_get KARAOKE_TORCH_INDEX)}"
if [ "$(printf '%s' "$sep_device" | tr '[:upper:]' '[:lower:]')" = cpu ] \
&& [ -z "$karaoke_torch_index" ]; then
karaoke_torch_index=https://download.pytorch.org/whl/cpu
fi
export SEP_DEVICE="$sep_device"
[ -z "$karaoke_torch_index" ] || export KARAOKE_TORCH_INDEX="$karaoke_torch_index"
gpu="$(detect_gpu)"
files=(-f docker-compose.yml)
[ -z "${LLAMACPP_MODEL_FILE:-}" ] || files+=(-f docker-compose.llamacpp-local.yml)
game_save_dir="${GAME_SAVE_DIR:-$(env_get GAME_SAVE_DIR)}"
if [ -n "$game_save_dir" ] && [ -d "$game_save_dir" ]; then
export GAME_SAVE_DIR="$game_save_dir"
GAME_SAVE_GID="$(stat -c '%g' "$game_save_dir")"
export GAME_SAVE_GID
files+=(-f docker-compose.bridge.yml)
fi
case "$gpu" in
nvidia)
files+=(-f docker-compose.nvidia.yml)
if ! command -v nvidia-smi >/dev/null 2>&1; then
echo "warning: NVIDIA selected but nvidia-smi not found - you also need" >&2
echo " nvidia-container-toolkit installed, or run GPU=cpu ./start.sh" >&2
fi
ngpus="$(nvidia_count)"
if [ "${ngpus:-0}" -gt 0 ]; then
export NVIDIA_GPU_COUNT="$ngpus"
fi
probed_vram_mb="$(nvidia_vram_mb)"
;;
amd)
files+=(-f docker-compose.amd.yml)
# the container has to be in the host groups that own the GPU
# device nodes, or it can't open them.
vgid="$(getent group video | cut -d: -f3 || true)"
rgid="$(stat -c '%g' /dev/dri/renderD* 2>/dev/null | head -n1 || true)"
[ -n "$rgid" ] || rgid="$(getent group render | cut -d: -f3 || true)"
export VIDEO_GID="${vgid:-44}"
export RENDER_GID="${rgid:-105}"
probed_vram_mb="$(amd_vram_mb)"
if [ -z "${HSA_OVERRIDE_GFX_VERSION+x}" ] && ! grep -q '^HSA_OVERRIDE_GFX_VERSION=' .env 2>/dev/null; then
hsa_override="$(amd_hsa_override "$(amd_gfx_target)")"
[ -z "$hsa_override" ] || export HSA_OVERRIDE_GFX_VERSION="$hsa_override"
fi
;;
esac
# A hand-set value always wins: probing reports the whole card,
# which is wrong when something else on the machine permanently
# owns part of it.
vram_mb="${OMEGA_GPU_VRAM_MB:-$(env_get OMEGA_GPU_VRAM_MB)}"
vram_mb="${vram_mb:-${probed_vram_mb:-}}"
[ -z "$vram_mb" ] || export OMEGA_GPU_VRAM_MB="$vram_mb"
devices="${GPU_DEVICES:-$(env_get GPU_DEVICES)}"
case "$devices" in
""|auto)
case "$gpu" in
nvidia) devices="$(nvidia_visible)" ;;
amd) devices="$(amd_visible)" ;;
*) devices="" ;;
esac
;;
all) devices="" ;;
esac
if [ -n "$devices" ]; then
case "$gpu" in
nvidia) export CUDA_VISIBLE_DEVICES="$devices" ;;
amd) export HIP_VISIBLE_DEVICES="$devices" ROCR_VISIBLE_DEVICES="$devices" GGML_VK_VISIBLE_DEVICES="$devices" ;;
esac
fi
tp="${TENSOR_PARALLEL:-$(env_get TENSOR_PARALLEL)}"
case "$(printf '%s' "$tp" | tr '[:upper:]' '[:lower:]')" in
on|1|true|yes) tp=on ;;
*) tp=off ;;
esac
if [ "$tp" = on ]; then
export OLLAMA_SCHED_SPREAD="${OLLAMA_SCHED_SPREAD:-1}"
if [ "$gpu" = nvidia ]; then
export LLAMA_ARG_SPLIT_MODE="${LLAMA_ARG_SPLIT_MODE:-row}"
fi
fi
echo "GPU detected: $gpu"
if [ "$gpu" = amd ]; then
echo " video gid=$VIDEO_GID, render gid=$RENDER_GID${HSA_OVERRIDE_GFX_VERSION:+, HSA_OVERRIDE_GFX_VERSION=$HSA_OVERRIDE_GFX_VERSION}"
fi
if [ -n "$devices" ]; then
echo " devices (largest VRAM first): $devices"
fi
if [ "$tp" = on ]; then
echo " tensor parallelism: on"
fi
if [ -n "${OMEGA_GPU_VRAM_MB:-}" ]; then
echo " vram: ${OMEGA_GPU_VRAM_MB} MiB"
fi
# ollama picks the layer split at load time and then it's pinned
# (see default_num_ctx() and the keep_alive=-1 pin in
# api/lib/providers/). so a model that loads while the karaoke
# sidecar's CUDA torch is still initialising stays mostly on the
# CPU. that's ~1000x slower on prefill, the pass that reads the
# prompt in. karaoke waits until the model server answers, then
# until she's loaded (wait_for_ollama_model).
wait_for_ollama() {
local i status
for i in $(seq 1 90); do
status="$(docker inspect -f '{{.State.Health.Status}}' omega-ollama 2>/dev/null || true)"
[ "$status" = healthy ] && return 0
sleep 2
done
echo "warning: omega-ollama did not report healthy; starting karaoke anyway" >&2
}
# "is she even on the right card" is the first thing anyone asks
# after an install, and the only thing that actually knows is
# ollama's own startup log. a box with an iGPU next to a real one
# is where this bites: we hand down a device list sorted biggest
# VRAM first, ollama picks from it, and nothing has ever said out
# loud which one it took. so say it. waits up to 20s for the
# line, then gives up without a word. the model server pulling
# an 8 GB fine-tune on first boot is not an error.
report_gpu_placement() {
local i line
[ "$gpu" != cpu ] || return 0
case ",${profiles}," in
*,ollama,*) ;;
*,llamacpp,*) report_llamacpp_devices; return 0 ;;
*) return 0 ;;
esac
for i in $(seq 1 10); do
line="$(docker logs omega-ollama 2>&1 | grep 'inference compute' || true)"
[ -n "$line" ] && break
sleep 2
done
[ -n "$line" ] || return 0
echo "ollama is running on:"
printf '%s\n' "$line" | awk '{
lib = ""; nm = ""; tot = "";
if (match($0, /library=[^ ]+/)) lib = substr($0, RSTART + 8, RLENGTH - 8);
# 0.32 moved the card name to description= and left name=CUDA0
# unquoted. older builds only have name="...". take either.
if (match($0, /description="[^"]*"/)) nm = substr($0, RSTART + 13, RLENGTH - 14);
else if (match($0, /name="[^"]*"/)) nm = substr($0, RSTART + 6, RLENGTH - 7);
if (match($0, /total="[^"]*"/)) tot = substr($0, RSTART + 7, RLENGTH - 8);
# a CPU-only line is description=cpu, unquoted, so nm stays empty
if (lib == "cpu") print " CPU. no GPU at all, ollama gave up on the card";
else if (nm != "") printf " %s (%s%s)\n", nm, lib, (tot != "" ? ", " tot : "");
}'
if printf '%s\n' "$line" | grep -q 'library=cpu'; then
echo " nvidia-smi working != CUDA working. a stale /etc/cdi/nvidia.yaml hands the" >&2
echo " container the wrong nvidia-uvm major (it moves between boots) and cuInit dies" >&2
echo " with 999. regen it: sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml" >&2
return 0
fi
echo " wrong card? pin it with GPU_DEVICES= in .env, biggest-VRAM-first is only our guess."
}
# same question on llama.cpp. it doesn't log the card at its
# default verbosity any more, but --list-devices asks the exact
# backend the server runs on, inside its container with its
# CUDA_VISIBLE_DEVICES, and prints `CUDA0: <name> (<MiB>, ...)` or
# `(none)`.
report_llamacpp_devices() {
local devs
devs="$(docker exec omega-llamacpp /app/llama-server --list-devices 2>/dev/null \
| sed -n '/Available devices/,$p' | tail -n +2 || true)"
[ -n "$devs" ] || return 0
echo "llama.cpp is running on:"
if printf '%s\n' "$devs" | grep -q '(none)'; then
echo " CPU. no GPU at all, llama.cpp found no card"
[ "$gpu" = nvidia ] && echo " nvidia-smi working != CUDA working. regen the CDI spec: sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml" >&2
return 0
fi
printf '%s\n' "$devs" | sed 's/^ */ /'
echo " wrong card? pin it with GPU_DEVICES= in .env, biggest-VRAM-first is only our guess."
}
# llama-server runs in router mode and loads a model on its first
# request, which would make the first chat a 2-12 GB download. so
# load her right after `up`, same moment ollama pulls hers.
llamacpp_chat_model() {
local file alias hf
file="$(env_get LLAMACPP_MODEL_FILE)"
if [ -n "$file" ]; then
alias="$(env_get LLAMACPP_MODEL_ALIAS)"
echo "${alias:-jun-local}"
return
fi
hf="$(env_get LLAMACPP_MODEL_HF)"
hf="${hf:-efficiencyx/Jun-LoRA-E2B-GGUF:Q4_K_M}"
echo "${hf%%,*}"
}
# loaded / loading / unloaded, empty when the router doesn't answer.
# no jq on a fresh box, the JSON is split one model per line
llamacpp_status() {
docker exec omega-llamacpp curl -fsS http://127.0.0.1:8080/v1/models 2>/dev/null \
| sed 's/{"id":/\n&/g' | grep -F "{\"id\":\"$1\"" | grep -o '"value":"[a-z]*"' | head -n1 | cut -d'"' -f4 || true
}
load_llamacpp() {
local model i
case ",${profiles}," in *,llamacpp,*) ;; *) return 0 ;; esac
model="$(llamacpp_chat_model)"
for i in $(seq 1 45); do
docker exec omega-llamacpp curl -fsS http://127.0.0.1:8080/health >/dev/null 2>&1 && break
sleep 2
done
[ "$(llamacpp_status "$model")" = loaded ] && return 0
docker exec omega-llamacpp curl -fsS -X POST http://127.0.0.1:8080/models/load \
-H 'Content-Type: application/json' -d "{\"model\":\"$model\"}" >/dev/null 2>&1 \
|| echo "warning: llama.cpp didn't take the load for $model, she loads on the first message instead" >&2
echo "llama.cpp: loading $model (the first start downloads it, 2-12 GB. ./start.sh logs llamacpp to watch)"
}
# for the karaoke stagger. the model has to be ON the card before
# karaoke's torch starts grabbing VRAM, see wait_for_ollama
wait_for_llamacpp_model() {
local model i
model="$(llamacpp_chat_model)"
for i in $(seq 1 450); do
[ "$(llamacpp_status "$model")" = loaded ] && return 0
sleep 2
done
echo "warning: $model still isn't loaded after 15 minutes; starting karaoke anyway" >&2
}
# The draft depth in .env is a measurement, and it only describes
# the card it was measured on. Swap the GPU and the number in
# there is about hardware that left the building, so hold the
# stamp the tuner wrote against what is in the box now and measure
# again when the two don't match.
mtp_recheck() {
local want tuned sig drafter server ready i
# On the llamacpp side the tuner restarts the stack through
# ./start.sh, and that is us. without this we start a tune inside
# a tune, forever.
[ -z "${MTP_AUTOTUNE_RUNNING:-}" ] || return 0
want="${MTP_AUTOTUNE:-$(env_get MTP_AUTOTUNE)}"
case "$(printf '%s' "$want" | tr '[:upper:]' '[:lower:]')" in
off|0|false|no) return 0 ;;
esac
# No stamp means no tune ever finished on this box, so there is
# nothing to compare and nothing to nag about.
tuned="$(env_get MTP_TUNED_GPU)"
[ -n "$tuned" ] || return 0
sig="$(gpu_signature)"
[ -n "$sig" ] || return 0
[ "$sig" != "$tuned" ] || return 0
case "$provider" in
llamacpp) drafter="$(env_get LLAMACPP_MTP)"; server=llamacpp ;;
ollama|'') drafter="$(env_get OLLAMA_MTP)"; server=ollama ;;
*) return 0 ;;
esac
# MTP is off, so there is no depth to measure. the tuner would
# only die on it and we would come back here every single boot.
[ -n "$drafter" ] || return 0
# Point us at your own model server and none of this is ours to
# tune, there is no omega- container to wait on and the tuner
# wants one anyway. Both waits below would just run out their
# clocks, every boot, and tell you nothing.
case ",${profiles}," in *,"$server",*) ;; *) return 0 ;; esac
echo ""
echo "the GPU changed since MTP was tuned:"
echo " tuned on: $tuned"
echo " here now: $sig"
echo "the draft depth in .env was measured on a card that is not in this box any"
echo "more, so we are measuring it again. a few minutes on ollama, considerably"
echo "longer on llamacpp where every depth needs a llama-server restart."
echo "set MTP_AUTOTUNE=off in .env to skip this."
# ollama's healthcheck is /api/tags, and that answers the moment
# the server is up, long before ollama-entrypoint.sh has finished
# pulling the chat model and the drafter. The tuner needs the
# drafter blob on disk or it dies with "could not find the
# drafter blob", so wait untill ollama admits it has one.
ready=
case "$provider" in
llamacpp)
for i in $(seq 1 60); do
if [ "$(docker inspect -f '{{.State.Health.Status}}' omega-llamacpp 2>/dev/null || true)" = healthy ]; then
ready=1; break
fi
sleep 5
done
;;
*)
wait_for_ollama
for i in $(seq 1 60); do
if docker exec omega-ollama ollama show --modelfile "$drafter" >/dev/null 2>&1; then
ready=1; break
fi
sleep 5
done
;;
esac
# Leave the stamp stale on purpose. it is the only thing that
# makes the next boot try again, and a pull that is still running
# now is probably done by then.
if [ -z "$ready" ]; then
echo "warning: the drafter is not here yet, so the re-tune is skipped." >&2
echo " run ./mtp-autotune.sh by hand once it has finished pulling." >&2
return 0
fi
if ! MTP_AUTOTUNE_RUNNING=1 ./mtp-autotune.sh; then
echo "warning: autotune did not finish, run ./mtp-autotune.sh by hand" >&2
fi
}
staged_up() {
case ",${profiles}," in
*,karaoke,*) ;;
*) return 1 ;;
esac
case ",${profiles}," in
*,ollama,*|*,llamacpp,*) ;;
*) return 1 ;;
esac
[ "$#" -eq 0 ]
}
# /api/tags answers long before she's on the card, so that alone
# lets karaoke's torch in mid-load. php-entrypoint.sh already
# started the same warm, a second one just waits for that load
wait_for_ollama_model() {
wait_for_ollama
docker exec omega-php php /var/www/omega/api/prewarm.php \
|| echo "warning: she didn't load ahead of time; starting karaoke anyway" >&2
}
wait_for_model_server() {
case ",${profiles}," in
*,llamacpp,*) load_llamacpp; wait_for_llamacpp_model ;;
*) wait_for_ollama_model ;;
esac
}
# No exec here. it replaces the shell, so nothing after the
# compose call gets to run, mtp_recheck included. set -e still
# takes a compose failure out on the spot, with compose's own
# exit code.
# never writes while the game runs, it'd save its own copy over ours on exit
bridge_sync() {
is_on "$(env_get GAME_BRIDGE)" || return 0
if [ -z "${GAME_SAVE_GID:-}" ]; then
echo "bridge: no GAME_SAVE_DIR folder, only the mod syncs"
return 0
fi
local args=()
if is_on "$(env_get GAME_SAVE_WRITE)"; then
if pgrep -fi "My Dystopian Robot Girlfriend" >/dev/null 2>&1; then
echo "bridge: the game is running, its save is left alone"
else
args+=(--write)
fi
fi
docker exec omega-php php /var/www/omega/api/bridge-sync.php "${args[@]}" || true
}
# only for a person at a desktop: a terminal, a display, not root
# (sudo ./start.sh would start the browser as root)
open_browser() {
[ -t 1 ] && [ "${JUN_NO_BROWSER:-}" != 1 ] && [ "$(id -u)" -ne 0 ] || return 0
case "$(printf '%s' "${TLS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" in off|'') ;; *) return 0 ;; esac
if [ "$(uname -s)" = 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
}
up_and_check() {
set -x; docker compose "${files[@]}" up -d --build "$@"
{ set +x; } 2>/dev/null
report_gpu_placement
load_llamacpp
bridge_sync
bridge_mod_config || true
mtp_recheck
check_trusted_ca
[ -z "$update_note" ] || echo "$update_note"
open_browser
}
# runtime/jun-ca.pem is the copy of the CA we put in the trust
# stores. the real one lives in the nginx volume, so this copy is
# also what uninstall.sh takes back out after the volume is gone.
trusted_ca=runtime/jun-ca.pem
# healthy, not just running. 10-pick-config.sh mints the CA before
# nginx starts, and the healthcheck only passes once nginx is up.
current_ca() {
for _ in $(seq 1 60); do
[ "$(docker inspect -f '{{.State.Health.Status}}' "$(docker compose "${files[@]}" ps -q nginx)" 2>/dev/null)" = healthy ] && break
sleep 1
done
docker compose "${files[@]}" exec -T nginx cat /etc/nginx/selfsigned/ca.pem 2>/dev/null
}
# a host outside the CA's names (or 10 years) makes a new CA, and
# every browser that trusted the old one is back to the warning.
check_trusted_ca() {
[ -s "$trusted_ca" ] || return 0
current_ca | cmp -s - "$trusted_ca" && return 0
echo "the local CA changed since it was trusted. run ./start.sh trust to swap it in the trust stores" >&2
}
trust_ca() {
local new
new="$(mktemp runtime/jun-ca.XXXXXX)"
if ! current_ca >"$new" || [ ! -s "$new" ]; then
rm -f "$new"
echo "no local CA from nginx. start her first (./start.sh), it makes one on boot" >&2
return 1
fi
if [ -s "$trusted_ca" ] && ! cmp -s "$new" "$trusted_ca"; then
tools/trust-ca.sh remove "$trusted_ca" || true
fi
# kept even when some stores said no. the ones that said yes still
# have it, and uninstall.sh needs this copy to take it back out.
local rc=0
tools/trust-ca.sh install "$new" || rc=$?
mv "$new" "$trusted_ca"
chmod 644 "$trusted_ca"
[ "$rc" -eq 0 ] && echo "trusted. restart the browser if it was open"
return "$rc"
}
urlenc() {
local s="$1"
s="${s//%/%25}"; s="${s//&/%26}"; s="${s//=/%3D}"; s="${s//+/%2B}"; s="${s//#/%23}"
s="${s//\?/%3F}"; s="${s//:/%3A}"; s="${s//\//%2F}"; s="${s// /%20}"
printf '%s' "$s"
}
# no ufw on purpose, docker's published ports skip it. firewalld
# doesn't (fedora, bazzite), and the phone just says can't reach
open_lan_firewall() {
local h ifc z zones="" sudo="" ans done_zones
command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null || return 0
for h in $(lan_hosts); do
ifc="$(ip -4 -o addr show to "$h" 2>/dev/null | awk '{print $2; exit}')"
z="$(firewall-cmd --get-zone-of-interface="$ifc" 2>/dev/null || firewall-cmd --get-default-zone 2>/dev/null)"
if [ -z "$z" ]; then
echo "couldn't read firewalld's zone for $ifc. if the phone can't reach her: sudo firewall-cmd --add-service=https && sudo firewall-cmd --permanent --add-service=https"
continue
fi
firewall-cmd --zone="$z" --query-service=https >/dev/null 2>&1 && continue
firewall-cmd --zone="$z" --query-port=443/tcp >/dev/null 2>&1 && continue
case " $zones " in *" $z "*) ;; *) zones="${zones:+$zones }$z" ;; esac
done
[ -n "$zones" ] || return 0
[ "$(id -u)" -eq 0 ] || sudo=sudo
echo "firewalld blocks https (443) in zone $zones, the phone won't get through"
ans=n
if { true >/dev/tty; } 2>/dev/null && { [ -z "$sudo" ] || command -v sudo >/dev/null 2>&1; }; then
printf 'open it there (needs sudo)? [Y/n] ' >/dev/tty
read -r ans </dev/tty || ans=n
fi
case "$ans" in
''|y|Y|yes)
for z in $zones; do
if $sudo firewall-cmd --quiet --zone="$z" --add-service=https &&
$sudo firewall-cmd --quiet --permanent --zone="$z" --add-service=https; then
done_zones="$(sed -n 's/^firewalld_https=//p' .install-changes 2>/dev/null | head -n1)"
case " $done_zones " in *" $z "*) ;; *)
{ grep -v '^firewalld_https=' .install-changes 2>/dev/null || true
printf 'firewalld_https=%s\n' "${done_zones:+$done_zones }$z"; } > .install-changes.tmp
mv .install-changes.tmp .install-changes ;;
esac
echo "opened https in firewalld zone $z"
else
echo "couldn't change zone $z. run: sudo firewall-cmd --zone=$z --add-service=https && sudo firewall-cmd --permanent --zone=$z --add-service=https" >&2
fi
done ;;
*)
for z in $zones; do
echo "to let her through: sudo firewall-cmd --zone=$z --add-service=https && sudo firewall-cmd --permanent --zone=$z --add-service=https"
done ;;
esac
}
# ./start.sh phone: the QR the android app scans for LAN mode. asks
# before turning LAN_INFERENCE on, BIND_ADDR=0.0.0.0 opens her up
phone_pair() {
local need=() token on_now hosts h ca="" link ans
token="$(env_get JUN_LAN_TOKEN)"
is_on "$(env_get LAN_INFERENCE)" || need+=("LAN_INFERENCE=on")
[ "${#token}" -ge 32 ] || need+=("a new JUN_LAN_TOKEN")
case "$(env_get BIND_ADDR)" in ''|127.*|localhost|::1) need+=("BIND_ADDR=0.0.0.0") ;; esac
if [ "${#need[@]}" -gt 0 ]; then
echo "the phone needs: ${need[*]}"
echo "BIND_ADDR=0.0.0.0 lets every device on this network reach her login page, and her model with the token"
{ true >/dev/tty; } 2>/dev/null || { echo "set those in .env and run ./start.sh phone again" >&2; return 1; }
printf 'write them to .env and restart her? [Y/n] ' >/dev/tty
read -r ans </dev/tty || ans=n
case "$ans" in ''|y|Y|yes) ;; *) return 1 ;; esac
env_set LAN_INFERENCE on
if [ "${#token}" -lt 32 ]; then
token="$(openssl rand -hex 32 2>/dev/null || head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
env_set JUN_LAN_TOKEN "$token"
fi
case "$(env_get BIND_ADDR)" in ''|127.*|localhost|::1) env_set BIND_ADDR 0.0.0.0 ;; esac
fi
# what php runs with, not what .env says. an edit nobody restarted
# for would hand the phone a token php has never heard of
on_now="$(docker exec omega-php printenv JUN_LAN_TOKEN 2>/dev/null || true)"
if [ "$on_now" != "$token" ] || ! is_on "$(docker exec omega-php printenv LAN_INFERENCE 2>/dev/null)"; then
echo "restarting her with LAN mode on..."
env -u OMEGA_EXTRA_HOSTS BIND_ADDR="$(env_get BIND_ADDR)" ./start.sh || return 1
fi
open_lan_firewall
# a real certificate (any TLS_MODE but off) only names DOMAIN,
# and the phone trusts it like any website. no CA to pin
case "$(printf '%s' "${TLS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" in
off|'')
hosts="$(lan_hosts | tr '\n' ' ') $(env_get OMEGA_EXTRA_HOSTS | tr ',' ' ')"
ca="$(docker exec omega-nginx cat /etc/nginx/selfsigned/ca.pem 2>/dev/null |
docker exec -i omega-php php -r 'echo openssl_x509_fingerprint(stream_get_contents(STDIN), "sha256");' 2>/dev/null || true)"
[ -n "$ca" ] || { echo "couldn't read her local CA from nginx. is she up? ./start.sh status" >&2; return 1; } ;;
*) hosts="$(env_get DOMAIN)" ;;
esac
link="junos://pair?mode=lan&name=$(urlenc "$(hostname 2>/dev/null || echo PC)")&token=$token"
[ -z "$ca" ] || link="$link&ca=$ca"
for h in $hosts; do link="$link&url=$(urlenc "https://$h/api/model.php")"; done
case "$link" in *"&url="*) ;; *) echo "no private address on this machine for the phone to reach" >&2; return 1 ;; esac
echo
docker exec -i omega-php php -- "$link" ansi < tools/qr.php || return 1
echo
echo "scan it in the Jun OS app (LAN, then Scan), or with the phone's camera."
echo "no camera? get this link onto the phone and tap Paste link in the app:"
echo " $link"
echo "same wifi as this PC. if the phone stops reaching her later (new address, new CA), scan a fresh one."
echo "the code holds the token: anyone who scans it gets her model. a new JUN_LAN_TOKEN in .env cuts everyone off."
}
# JunBridge reads ApiUrl from its cfg and JunOS's own CA from
# JunBridge.ca.pem, so a new CA or DOMAIN reaches the game next start
bridge_mod_config() {
local game cfg url line
game="$(env_get JUN_GAME_DIR)"
[ -n "$game" ] && [ -f "$game/Mods/JunBridge.dll" ] || return 1
cfg="$game/UserData/JunBridge.cfg"
mkdir -p "$game/UserData"
case "$(printf '%s' "${TLS_MODE:-off}" | tr '[:upper:]' '[:lower:]')" in
off|'')
url="https://localhost"
current_ca >"$game/UserData/JunBridge.ca.pem" 2>/dev/null || rm -f "$game/UserData/JunBridge.ca.pem" ;;
*)
url="https://$(env_get DOMAIN)"
rm -f "$game/UserData/JunBridge.ca.pem" ;;
esac
line="ApiUrl = \"$url\""
if [ -f "$cfg" ] && grep -q '^ApiUrl *=' "$cfg"; then
sed -i "s|^ApiUrl *=.*|$line|" "$cfg"
elif [ -f "$cfg" ] && grep -q '^\[Jun\]' "$cfg"; then
sed -i "s|^\[Jun\]\$|&\n$line|" "$cfg"
else
{ printf '[Jun]\n%s\n\n' "$line"; [ ! -f "$cfg" ] || cat "$cfg"; } >"$cfg.tmp" && mv "$cfg.tmp" "$cfg"
fi
}
# a bare first word is a lifecycle subcommand. anything else (a
# flag like --build, or service names) goes straight through to
# `up -d`.
case "${1:-up}" in
stop|down) shift; set -x; exec docker compose "${files[@]}" down "$@" ;;
restart) shift; docker compose "${files[@]}" down
up_and_check "$@" ;;
status|ps) shift; set -x; exec docker compose "${files[@]}" ps "$@" ;;
logs) shift; set -x; exec docker compose "${files[@]}" logs -f "$@" ;;
trust) mkdir -p runtime; trust_ca ;;
phone) phone_pair ;;
bridge) if bridge_mod_config; then
echo "JunBridge points at her. start the game, it asks you to sign in"
else
echo "no JunBridge mod in JUN_GAME_DIR. re-run install.sh with JUN_BRIDGE=1" >&2; exit 1
fi ;;
*) if staged_up "$@"; then
set -x
docker compose "${files[@]}" up -d --build --scale karaoke=0
{ set +x; } 2>/dev/null
wait_for_model_server
set -x
fi
up_and_check "$@" ;;
esac