#!/bin/sh # PhotoAI installer for Linux and Mac — run by the PhotoAI app over ssh (or by hand): # # sh install.sh check what this computer has (Docker, memory, graphics card, …) # sh install.sh dirs /home/me the folders in a folder (the app's folder picker) # sh install.sh install KEY=VALUE … download docker-compose.yml, write .env, start PhotoAI # sh install.sh status is PhotoAI running here # sh install.sh prepare DOCKER=1 OLLAMA=1 install Docker (Linux: Docker Engine, Mac: Docker Desktop) and # Ollama (Mac) — needs sudo (--sudo-file) # sh install.sh remote-off turn remote login (ssh) off again, a few seconds later # # install keys: PHOTOAI_DIR (~/photoai) PHOTOS_DIR DRIVES_1 DRIVES_2 PORT (8000) # AI = ollama-container | ollama-amd | ollama | url | none OLLAMA_URL PHOTOAI_IMAGE # EXTRAS = edit,3d,video,music (NVIDIA): ComfyUI and ACE-Step built here, their models downloaded # With --sudo-stdin (first line of stdin) or --sudo-file FILE (read, then deleted) the password # for sudo (Linux, when this user may not use Docker without it). Output lines the app reads: "##KEY value"; others are shown as they are. set -u PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/Applications/Docker.app/Contents/Resources/bin:/snap/bin" export PATH SITE="https://setup.photo.myphonetools.com" # served by the VPS (relay/vps/setup-site.sh) SITE_OLD="https://photo.myphonetools.com/setup" # ldu's home server: tried when the first one can't be reached fetch() { # fetch PATH DEST curl -fsSL -m 60 "$SITE/$1" -o "$2" 2>/dev/null || curl -fsSL -m 60 "$SITE_OLD/$1" -o "$2" } # Progress for the app, one line per file whenever its percent changes: # ##PROGRESS (the app shows a bar per name) AWK_JSON='function js(k, i, v) { i = index($0, "\"" k "\":\""); if (!i) return ""; v = substr($0, i + length(k) + 4); return substr(v, 1, index(v, "\"") - 1) } function jn(k, i) { i = index($0, "\"" k "\":"); if (!i) return -1; return substr($0, i + length(k) + 3) + 0 } function show(n, p, d, t) { if (!(n in last) || p != last[n]) { last[n] = p; printf "##PROGRESS %s %d %.0f %.0f\n", n, p, d, t; fflush() } }' pull_progress() { # reads `docker compose --progress json pull`: one bar per image, the sum of its layers awk "$AWK_JSON"' !/^\{/ { if (NF) { print " " $0; fflush() } next } { id = js("id"); par = js("parent_id"); tx = js("text") if (par == "") { if (substr(id, 1, 6) == "Image ") { img = substr(id, 7); if (js("status") == "Done") { done[img] = 1; bar(img) } else if (!(img in last)) bar(img) } next } img = substr(par, 7); k = img SUBSEP id t = jn("total"); c = jn("current") if (t > 0) tot[k] = t if (tx == "Downloading" && c >= 0) cur[k] = c else if ((k in tot) && tx ~ /complete|Verifying|Extracting|exists/) cur[k] = tot[k] bar(img) } function bar(img, k, a, d, t, p) { d = 0; t = 0 for (k in tot) { split(k, a, SUBSEP); if (a[1] == img) { t += tot[k]; d += cur[k] } } p = (img in done) ? 100 : (t > 0 ? int(d * 100 / t) : 0); if (p > 99 && !(img in done)) p = 99 show(img, p, (img in done) ? t : d, t) }' } ollama_progress() { # ollama_progress MODEL — reads Ollama's /api/pull JSON lines; fails unless it ends in "success" awk -v n="$1" "$AWK_JSON"' /"error"/ { print " " js("error"); fflush(); next } { dg = js("digest"); t = jn("total"); c = jn("completed") if (dg != "" && t > 0) { tot[dg] = t; if (c >= 0) cur[dg] = c } d = 0; T = 0; for (k in tot) { T += tot[k]; d += cur[k] } if (js("status") == "success") { ok = 1; show(n, 100, T, T) } else if (T > 0) { p = int(d * 100 / T); show(n, p > 99 ? 99 : p, d, T) } } END { if (ok) print " success"; exit ok ? 0 : 1 }' } curl_progress() { # curl_progress NAME — reads curl's progress meter (its stderr) awk -v n="$1" "$AWK_JSON"' BEGIN { RS = "\r" } function b(v, u, m) { u = substr(v, length(v)); m = 1 if (u == "k" || u == "K") m = 1024; else if (u == "M") m = 1048576; else if (u == "G") m = 1073741824 if (m > 1) v = substr(v, 1, length(v) - 1); return v * m } $1 ~ /^[0-9]+$/ && NF >= 12 { show(n, $1 + 0, b($4), b($2)) } /curl: \(/ { print " " $0; fflush() }' } ollama_pull() { # ollama_pull MODEL [app|host] — through Ollama's API (the CLI only draws a terminal bar) if [ "${2:-app}" = host ]; then curl -sN -m 7200 http://localhost:11434/api/pull -d "{\"model\":\"$1\"}" | ollama_progress "$1" else # Ollama in its container: the app container reaches it at OLLAMA_URL dk compose exec -T app python -u -c 'import json, os, sys, urllib.request as u r = u.urlopen(u.Request(os.environ["OLLAMA_URL"].rstrip("/") + "/api/pull", json.dumps({"model": sys.argv[1]}).encode()), timeout=600) for l in r: sys.stdout.write(l.decode()); sys.stdout.flush()' "$1" 2>&1 | ollama_progress "$1" fi } OS=$(uname -s) MODE="${1:-check}"; [ $# -gt 0 ] && shift SUDO_PASS="" if [ "${1:-}" = "--sudo-stdin" ]; then shift; IFS= read -r SUDO_PASS || true elif [ "${1:-}" = "--sudo-file" ]; then # a file only this user can read; deleted at once IFS= read -r SUDO_PASS < "$2" || true; rm -f "$2"; shift 2 fi say() { printf '##%s %s\n' "$1" "$2"; } # The Linux versions this installer works with (keep in step with the apps and the guide): # Ubuntu 22.04+, Debian 12+ (apt) · Fedora 40+, Rocky / AlmaLinux / RHEL 9+ (dnf) # sets DISTRO_ID, DISTRO_VER, DISTRO_FAMILY (apt | dnf | ""), LINUX_OK (yes | no) linux_distro() { DISTRO_ID=""; DISTRO_VER=""; DISTRO_FAMILY=""; LINUX_OK=no [ -r /etc/os-release ] || return 0 DISTRO_ID=$(. /etc/os-release; echo "${ID:-}") DISTRO_VER=$(. /etc/os-release; echo "${VERSION_ID:-}") major=${DISTRO_VER%%.*} case "$major" in ''|*[!0-9]*) major=0 ;; esac case "$DISTRO_ID" in ubuntu) DISTRO_FAMILY=apt; [ "$major" -ge 22 ] && LINUX_OK=yes ;; debian) DISTRO_FAMILY=apt; [ "$major" -ge 12 ] && LINUX_OK=yes ;; fedora) DISTRO_FAMILY=dnf; [ "$major" -ge 40 ] && LINUX_OK=yes ;; rocky|almalinux|rhel) DISTRO_FAMILY=dnf; [ "$major" -ge 9 ] && LINUX_OK=yes ;; esac } fail() { say ERROR "$1"; exit 1; } # ── Docker, with sudo when needed (Linux) ──────────────────────────────────── USE_SUDO=0 dk() { if [ "$USE_SUDO" = 1 ]; then printf '%s\n' "$SUDO_PASS" | sudo -S -p '' docker "$@" else docker "$@"; fi } # sets ACC: ok | sudo | denied | stopped | missing (and USE_SUDO) — not in a $(…) subshell docker_access() { ACC=stopped command -v docker >/dev/null 2>&1 || { ACC=missing; return; } if docker info >/dev/null 2>&1; then ACC=ok; return; fi if [ "$OS" = Linux ]; then if [ -n "$SUDO_PASS" ] && printf '%s\n' "$SUDO_PASS" | sudo -S -p '' docker info >/dev/null 2>&1; then USE_SUDO=1; ACC=sudo; return fi if docker info 2>&1 | grep -qi "permission denied"; then ACC=denied; return; fi fi } # Docker Desktop on a Mac keeps registry passwords in the keychain, which an ssh session can't # open ("User interaction is not allowed") — even public images fail. Use a config without it. mac_docker_config() { [ "$OS" = Darwin ] || return 0 ctx=$(docker context show 2>/dev/null || echo default) cfg="$HOME/.photoai-docker" mkdir -p "$cfg" for d in contexts cli-plugins; do [ -e "$HOME/.docker/$d" ] && [ ! -e "$cfg/$d" ] && ln -s "$HOME/.docker/$d" "$cfg/$d" done # Without a credsStore the docker command picks docker-credential-osxkeychain by itself (Docker Desktop # installs it) — once someone has run docker login, that asks the keychain and fails over ssh. So name # a store of our own that never has a password: every pull is anonymous (PhotoAI's images are public). mkdir -p "$cfg/bin" cat > "$cfg/bin/docker-credential-photoai" <<'HELPER' #!/bin/sh case "$1" in get) cat >/dev/null; echo "credentials not found in native keychain"; exit 1 ;; list) echo "{}" ;; *) cat >/dev/null ;; esac HELPER chmod 755 "$cfg/bin/docker-credential-photoai" PATH="$cfg/bin:$PATH"; export PATH printf '{"currentContext":"%s","credsStore":"photoai"}\n' "$ctx" > "$cfg/config.json" DOCKER_CONFIG="$cfg"; export DOCKER_CONFIG } gateway() { if [ "$OS" = Darwin ]; then route -n get default 2>/dev/null | awk '/gateway:/ {print $2; exit}' else ip -4 route show default 2>/dev/null | awk '{print $3; exit}'; fi } lan_ips() { if [ "$OS" = Darwin ]; then for i in en0 en1 en2 en3 en4 en5; do ipconfig getifaddr $i 2>/dev/null; done else # the address used to reach the internet first, then the others (not Docker / k3s bridges) main=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "src") print $(i + 1)}') [ -n "$main" ] && echo "$main" hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -vE "^(172\.(1[7-9]|2[0-9]|3[01])\.|10\.4[23]\.)" | grep -vx "${main:-none}" fi } # ── check ──────────────────────────────────────────────────────────────────── do_check() { say OS "$OS" if [ "$OS" = Darwin ]; then say OS_NAME "macOS $(sw_vers -productVersion 2>/dev/null)" say RAM_GB "$(( $(sysctl -n hw.memsize) / 1073741824 ))" chip=$(sysctl -n machdep.cpu.brand_string 2>/dev/null) say CPU "$chip" say CPU_CORES "$(sysctl -n hw.ncpu 2>/dev/null)" say GPU "$chip" case "$chip" in *Apple*) say GPU_KIND apple ;; *) say GPU_KIND none ;; esac else name=$( (. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-Linux}") ) say OS_NAME "$name" linux_distro say DISTRO "$DISTRO_ID $DISTRO_VER" say DISTRO_FAMILY "$DISTRO_FAMILY" say LINUX_OK "$LINUX_OK" say RAM_GB "$(( ($(awk '/MemTotal/ {print $2}' /proc/meminfo) + 524288) / 1048576 ))" say CPU "$(awk -F: '/model name/ {sub(/^ +/, "", $2); print $2; exit}' /proc/cpuinfo)" say CPU_CORES "$(nproc 2>/dev/null)" if command -v nvidia-smi >/dev/null 2>&1; then say GPU "$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | head -1)" say GPU_KIND nvidia mib=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -dc 0-9) [ -n "$mib" ] && say GPU_VRAM_GB "$(( (mib + 512) / 1024 ))" dpkg -l 2>/dev/null | grep -q nvidia-container-toolkit && say NVIDIA_TOOLKIT yes || say NVIDIA_TOOLKIT no else g=$(lspci 2>/dev/null | grep -iE 'vga|3d' | head -1 | sed 's/.*: //') say GPU "$g" case "$g" in *Radeon\ RX*|*Radeon\ PRO*|*Instinct*|*Navi*) say GPU_KIND amd v=$(cat /sys/class/drm/card*/device/mem_info_vram_total 2>/dev/null | sort -n | tail -1) [ -n "$v" ] && say GPU_VRAM_GB "$(( (v + 536870912) / 1073741824 ))" [ -e /dev/kfd ] && say AMD_ROCM yes || say AMD_ROCM no ;; *) say GPU_KIND none ;; esac fi fi say ARCH "$(uname -m)" say HOME "$HOME" say USER "$(id -un)" docker_access say DOCKER "$ACC" if [ "$ACC" = ok ] || [ "$ACC" = sudo ]; then say DOCKER_VERSION "$(dk version --format '{{.Server.Version}}' 2>/dev/null)" dk compose version >/dev/null 2>&1 && say COMPOSE yes || say COMPOSE no fi if curl -fs -m 3 http://localhost:11434/api/tags >/dev/null 2>&1; then say OLLAMA running elif command -v ollama >/dev/null 2>&1; then say OLLAMA installed else say OLLAMA none; fi for d in "$HOME/Pictures" "$HOME/Photos" /media /mnt /Volumes /srv; do [ -d "$d" ] && say PLACE "$d"; done for ip in $(lan_ips); do say IP "$ip"; done [ -f "$HOME/photoai/docker-compose.yml" ] && say EXISTING "$HOME/photoai" df -k "$HOME" 2>/dev/null | awk 'NR==2 {print "##FREE_GB " int($4/1048576)}' say END check } # ── dirs ───────────────────────────────────────────────────────────────────── do_dirs() { p="${1:-$HOME}" [ -d "$p" ] || fail "There is no folder $p" say PATH "$(cd "$p" && pwd)" for e in "$p"/*/; do [ -d "$e" ] || continue n=$(basename "$e") case "$n" in .*|'$'*|@*|lost+found|System\ Volume\ Information) continue ;; esac say DIR "$n" done say END dirs } # ── install ────────────────────────────────────────────────────────────────── do_install() { PHOTOAI_DIR="$HOME/photoai"; PHOTOS_DIR=""; DRIVES_1=""; DRIVES_2=""; PORT=8000 AI=none; OLLAMA_URL=""; PHOTOAI_IMAGE=""; EXTRAS="" for kv in "$@"; do k=${kv%%=*}; v=${kv#*=} case "$k" in PHOTOAI_DIR|PHOTOS_DIR|DRIVES_1|DRIVES_2|PORT|AI|OLLAMA_URL|PHOTOAI_IMAGE|EXTRAS) eval "$k=\$v" ;; *) fail "Unknown setting $k" ;; esac done case "$PORT" in ''|*[!0-9]*) fail "The port must be a number" ;; esac say STEP "Checking Docker" docker_access case "$ACC" in missing) fail "Docker isn't installed on this computer — install it first (step 1 of the guide)" ;; stopped) fail "Docker is installed but not running — start Docker Desktop (or: sudo systemctl start docker) and try again" ;; denied) fail "This user may not use Docker. Run: sudo usermod -aG docker $(id -un) — then log out and in, or give the app the password for sudo" ;; esac mac_docker_config dk compose version >/dev/null 2>&1 || fail "Docker Compose is missing — update Docker" if [ -n "$PHOTOS_DIR" ] && [ ! -d "$PHOTOS_DIR" ]; then mkdir -p "$PHOTOS_DIR" 2>/dev/null || fail "Can't make the photo folder $PHOTOS_DIR" fi say STEP "Downloading docker-compose.yml into $PHOTOAI_DIR" mkdir -p "$PHOTOAI_DIR" || fail "Can't make $PHOTOAI_DIR" cd "$PHOTOAI_DIR" || fail "Can't open $PHOTOAI_DIR" fetch docker-compose.yml docker-compose.yml.new || fail "Couldn't download docker-compose.yml from $SITE or $SITE_OLD" mv docker-compose.yml.new docker-compose.yml say STEP "Writing the settings (.env)" [ -f .env ] && cp .env .env.bak { echo "# Written by the PhotoAI app on $(date)" [ -n "$PHOTOS_DIR" ] && echo "PHOTOS_DIR=$PHOTOS_DIR" [ -n "$DRIVES_1" ] && echo "DRIVES_1=$DRIVES_1" [ -n "$DRIVES_2" ] && echo "DRIVES_2=$DRIVES_2" [ "$OS" = Linux ] && echo "DRIVES_PROPAGATION=rslave" echo "PORT=$PORT" echo "LAN_IP=$(lan_ips | head -1)" echo "LAN_GATEWAY=$(gateway)" case "$AI" in ollama-container) echo "OLLAMA_URL=http://ollama:11434" ;; ollama-amd) echo "OLLAMA_URL=http://ollama-amd:11434" ;; ollama) echo "OLLAMA_URL=http://host.docker.internal:11434" ;; url) [ -n "$OLLAMA_URL" ] && echo "OLLAMA_URL=$OLLAMA_URL" ;; esac PROFILES="" [ "$AI" = ollama-container ] && PROFILES="ollama" [ "$AI" = ollama-amd ] && PROFILES="ollama-amd" case ",$EXTRAS," in *,edit,*|*,3d,*|*,video,*) PROFILES="${PROFILES:+$PROFILES,}extras"; echo "EDIT_URL=http://comfyui:8188" ;; esac case ",$EXTRAS," in *,music,*) PROFILES="${PROFILES:+$PROFILES,}music"; echo "MUSIC_URL=http://acestep:8000" ;; esac [ -n "$EXTRAS" ] && case "$AI" in ollama*) echo "SHARED_GPU=1"; echo "OLLAMA_KEEP_ALIVE=10m" ;; esac [ -n "$PROFILES" ] && echo "COMPOSE_PROFILES=$PROFILES" [ -n "$PHOTOAI_IMAGE" ] && echo "PHOTOAI_IMAGE=$PHOTOAI_IMAGE" echo "TZ=$( (readlink /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##') || echo UTC)" } > .env COMPOSE_PROFILES=$(sed -n 's/^COMPOSE_PROFILES=//p' .env); export COMPOSE_PROFILES PROFILE="" if [ -n "$EXTRAS" ]; then say STEP "Downloading the extras' build files" for f in comfyui/Dockerfile comfyui/get_models.py comfyui/photoai_nodes.py acestep/Dockerfile acestep/proxy.py acestep/get_music_models.py; do mkdir -p "extras/$(dirname $f)" fetch "extras/$f" "extras/$f" || fail "Couldn't download extras/$f from $SITE or $SITE_OLD" done dk info 2>/dev/null | grep -qi nvidia || [ "$OS" != Linux ] || fail "Docker can't use the NVIDIA card yet — run \"Install Docker\" again (it adds the NVIDIA Container Toolkit), then try again" fi if [ "$AI" = ollama-container ] && [ "$OS" = Linux ] && command -v nvidia-smi >/dev/null 2>&1; then # the NVIDIA card for the Ollama container (needs the NVIDIA Container Toolkit) if dk info 2>/dev/null | grep -qi nvidia; then sed -i 's/^ # deploy:/ deploy:/; s/^ # resources:/ resources:/; s/^ # reservations:/ reservations:/; s/^ # devices:/ devices:/' docker-compose.yml say NOTE "Using the NVIDIA card for the AI" else say NOTE "An NVIDIA card is here but Docker can't use it yet (install the NVIDIA Container Toolkit) — the AI runs on the processor, slowly" fi fi say STEP "Downloading PhotoAI (about 1.5 GB the first time)" if dk compose --progress json version >/dev/null 2>&1; then dk compose --progress json $PROFILE pull 2>&1 | pull_progress || true else # older Compose: no JSON progress dk compose $PROFILE pull 2>&1 | grep -vE '^\s*$' | sed -u 's/^/ /' || true fi if [ -n "$EXTRAS" ]; then SETS=$(echo "$EXTRAS" | tr ',' '\n' | grep -E '^(edit|3d|video)$' | tr '\n' ' ') LOG="$PHOTOAI_DIR/extras-install.log" if [ -n "$SETS" ]; then say STEP "Building ComfyUI for the extras (10–20 minutes the first time)" dk compose build comfyui > "$LOG" 2>&1 || { tail -n 15 "$LOG" | sed 's/^/ /'; fail "Building ComfyUI failed (see $LOG)"; } dk compose run --rm --no-deps comfyui python /opt/get_models.py $SETS 2>&1 | tee "$LOG" | grep --line-buffered -E '^##(STEP|NOTE|ERROR|PROGRESS)' grep -q '^##ERROR' "$LOG" && exit 1 grep -q 'extra models are ready' "$LOG" || fail "Downloading the extras' models stopped (see $LOG)" fi case ",$EXTRAS," in *,music,*) say STEP "Building ACE-Step for music (10–20 minutes the first time)" dk compose build acestep > "$LOG" 2>&1 || { tail -n 15 "$LOG" | sed 's/^/ /'; fail "Building ACE-Step failed (see $LOG)"; } dk compose run --rm --no-deps acestep uv run --no-sync python /app/get_music_models.py 2>&1 | tee "$LOG" | grep --line-buffered -E '^##' grep -q '^##ERROR' "$LOG" && exit 1 grep -q 'music models are ready' "$LOG" || fail "Downloading the music models stopped (see $LOG)" ;; esac fi say STEP "Starting PhotoAI" dk compose $PROFILE up -d 2>&1 | sed -u 's/^/ /' dk compose ps --status running 2>/dev/null | grep -q app || fail "PhotoAI didn't start — see: docker compose logs app" if [ "$AI" = ollama-container ] || [ "$AI" = ollama-amd ]; then svc=ollama; [ "$AI" = ollama-amd ] && svc=ollama-amd for m in qwen2.5vl:7b qwen3:1.7b nomic-embed-text; do say STEP "Downloading the AI model $m" ollama_pull "$m" app || dk compose exec -T $svc ollama pull "$m" 2>&1 | tr '\033' '\n' | grep -E 'success|error' | sed -u 's/^/ /' done elif [ "$AI" = ollama ] && command -v ollama >/dev/null 2>&1; then for m in qwen2.5vl:7b qwen3:1.7b nomic-embed-text; do say STEP "Downloading the AI model $m" ollama_pull "$m" host || ollama pull "$m" 2>&1 | tr '\033' '\n' | grep -E 'success|error' | sed -u 's/^/ /' done fi say STEP "Waiting for PhotoAI to answer" i=0 until curl -fs -m 3 "http://localhost:$PORT/api/auth/status" >/dev/null 2>&1; do i=$((i + 1)); [ $i -gt 90 ] && fail "PhotoAI started but doesn't answer on port $PORT — see: docker compose logs app" sleep 2 done for ip in $(lan_ips); do say IP "$ip"; done say PORT "$PORT" say DONE "PhotoAI is running in $PHOTOAI_DIR" } # ── prepare: install what is missing (the user only turned remote login on) ─── S() { printf '%s\n' "$SUDO_PASS" | sudo -S -p '' "$@"; } need_sudo() { [ "$(id -u)" = 0 ] && { S() { "$@"; }; return 0; } [ -n "$SUDO_PASS" ] || fail "Installing needs the password for sudo" S true 2>/dev/null || fail "sudo refused the password — this user must be an administrator" } wait_for() { # wait_for SECONDS COMMAND… n=$1; shift; i=0 until "$@" >/dev/null 2>&1; do i=$((i + 2)); [ $i -gt "$n" ] && return 1; sleep 2; done } do_prepare() { WANT_DOCKER=0; WANT_OLLAMA=0 for kv in "$@"; do case "$kv" in DOCKER=1) WANT_DOCKER=1 ;; OLLAMA=1) WANT_OLLAMA=1 ;; esac; done need_sudo if [ "$WANT_DOCKER" = 1 ]; then docker_access if [ "$ACC" = missing ]; then if [ "$OS" = Darwin ]; then arch=$(uname -m); [ "$arch" = x86_64 ] && arch=amd64 say STEP "Downloading Docker Desktop (about 600 MB)" curl -fL -m 1800 -o /tmp/photoai-docker.dmg "https://desktop.docker.com/mac/main/$arch/Docker.dmg" 2>&1 | curl_progress Docker-Desktop || true [ -s /tmp/photoai-docker.dmg ] || fail "Couldn't download Docker Desktop" say STEP "Installing Docker Desktop" S hdiutil attach -nobrowse -quiet -mountpoint /Volumes/PhotoAI-Docker /tmp/photoai-docker.dmg || fail "Couldn't open the Docker Desktop download" S /Volumes/PhotoAI-Docker/Docker.app/Contents/MacOS/install --accept-license --user="$(id -un)" 2>&1 | sed 's/^/ /' S hdiutil detach -quiet /Volumes/PhotoAI-Docker; rm -f /tmp/photoai-docker.dmg [ -d /Applications/Docker.app ] || fail "Docker Desktop didn't install" else linux_distro [ "$LINUX_OK" = yes ] || fail "The setup can't install Docker on ${DISTRO_ID:-this Linux} ${DISTRO_VER} — it works with Ubuntu 22.04+, Debian 12+, Fedora 40+ and Rocky / AlmaLinux / RHEL 9+. Install Docker yourself (docs.docker.com/engine/install), then try again" say STEP "Installing Docker Engine (get.docker.com)" curl -fsSL -m 120 https://get.docker.com -o /tmp/photoai-get-docker.sh || fail "Couldn't download the Docker installer" S sh /tmp/photoai-get-docker.sh 2>&1 | grep -vE '^\s*$' | tail -n 15 | sed 's/^/ /' rm -f /tmp/photoai-get-docker.sh command -v docker >/dev/null 2>&1 || fail "Docker didn't install — see the details" S usermod -aG docker "$(id -un)" fi fi if [ "$OS" = Darwin ]; then if ! docker info >/dev/null 2>&1; then say STEP "Starting Docker Desktop" open -g -a Docker 2>/dev/null || open -a /Applications/Docker.app 2>/dev/null wait_for 240 docker info || { say NEED_LOGIN "Docker Desktop didn't start — sign in on the Mac (it starts Docker Desktop), then try again"; say END prepare; return; } fi else S systemctl enable --now docker >/dev/null 2>&1 if command -v nvidia-smi >/dev/null 2>&1 && ! S docker info 2>/dev/null | grep -qi nvidia; then linux_distro say STEP "Letting Docker use the NVIDIA card (NVIDIA Container Toolkit)" if [ "$DISTRO_FAMILY" = apt ]; then curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | S gpg --dearmor --yes -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg 2>/dev/null curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#' | S tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null S apt-get update -qq >/dev/null 2>&1 S apt-get install -y -qq nvidia-container-toolkit 2>&1 | tail -n 3 | sed 's/^/ /' elif [ "$DISTRO_FAMILY" = dnf ]; then curl -fsSL https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | S tee /etc/yum.repos.d/nvidia-container-toolkit.repo >/dev/null S dnf install -y -q nvidia-container-toolkit 2>&1 | tail -n 3 | sed 's/^/ /' else say NOTE "Install the NVIDIA Container Toolkit yourself on this Linux so Docker can use the card" fi S nvidia-ctk runtime configure --runtime=docker >/dev/null 2>&1 && S systemctl restart docker fi wait_for 60 S docker info || fail "Docker is installed but didn't start" fi say NOTE "Docker is ready" fi if [ "$WANT_OLLAMA" = 1 ] && [ "$OS" = Darwin ] && ! curl -fs -m 3 http://localhost:11434/api/tags >/dev/null 2>&1; then if [ ! -d /Applications/Ollama.app ]; then say STEP "Downloading Ollama" { curl -fL -m 900 -o /tmp/photoai-ollama.zip https://ollama.com/download/Ollama-darwin.zip 2>&1 | curl_progress Ollama; [ -s /tmp/photoai-ollama.zip ]; } || fail "Couldn't download Ollama" S ditto -x -k /tmp/photoai-ollama.zip /Applications || fail "Couldn't install Ollama" rm -f /tmp/photoai-ollama.zip fi say STEP "Starting Ollama" open -g -a Ollama 2>/dev/null wait_for 90 curl -fs -m 3 http://localhost:11434/api/tags || say NOTE "Ollama didn't start yet — sign in on the Mac once" fi say DONE prepare } do_remote_off() { need_sudo if [ "$OS" = Darwin ]; then S sh -c 'nohup sh -c "sleep 3; launchctl disable system/com.openssh.sshd; launchctl bootout system/com.openssh.sshd" >/dev/null 2>&1 &' elif command -v systemd-run >/dev/null 2>&1; then S systemd-run --quiet --on-active=3 sh -c 'systemctl disable --now ssh.socket ssh.service sshd.service 2>/dev/null; true' else S sh -c 'nohup sh -c "sleep 3; service ssh stop || service sshd stop" >/dev/null 2>&1 &' fi say DONE "Remote login turns off in a few seconds" } do_status() { docker_access say DOCKER "$ACC" mac_docker_config if [ -f "$HOME/photoai/docker-compose.yml" ]; then cd "$HOME/photoai" && dk compose ps --format '{{.Service}} {{.State}}' 2>/dev/null | sed 's/^/##SERVICE /' fi say END status } case "$MODE" in check) do_check ;; dirs) do_dirs "$@" ;; install) do_install "$@" ;; status) do_status ;; prepare) do_prepare "$@" ;; remote-off) do_remote_off ;; *) fail "Unknown mode $MODE (check, dirs, install, status, prepare, remote-off)" ;; esac