Files
punktfunk/scripts/install.sh
enricobuehler 5bec450402
ci / bun-nix (pull_request) Successful in 41s
ci / docs-drift (pull_request) Successful in 46s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 1m54s
installer-smoke / smoke (arch) (pull_request) Successful in 49s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
installer-smoke / smoke (fedora-44) (pull_request) Successful in 1m22s
installer-smoke / smoke (debian-13) (pull_request) Successful in 1m45s
android / android (pull_request) Canceled after 3m55s
ci / rust (pull_request) Canceled after 3m56s
install.sh: probe /dev/tty by opening it — a container has the node but no controlling terminal, so -r/-w said yes and the redirect failed (first installer-smoke run); name the matrix jobs by family
2026-08-19 20:57:02 +02:00

385 lines
21 KiB
Bash

#!/bin/sh
# punktfunk guided host installer — PREVIEW.
#
# curl -fsSL https://punktfunk.unom.io/install.sh | sh
# curl -fsSLO https://punktfunk.unom.io/install.sh && sh install.sh --help # read it first
#
# Zero-to-streaming for a Linux host: detect the distro → add the package repo → install → deal
# with a Sunshine/Apollo/Vibeshine already on the box (move one port) → groups → options →
# firewall → start the services → verify → print how to pair. Plain POSIX sh, `read` prompts,
# no TUI. Every prompt has a default so `--yes` (or no terminal) runs unattended.
#
# This is WP4 of the docs-and-onboarding overhaul and is labelled PREVIEW on purpose: the per-distro
# docs pages (https://docs.punktfunk.unom.io/docs/install) remain the documented default until it
# has mileage. The install commands it runs are the ones data/platforms.json states — verbatim, and
# CI (scripts/ci/check-docs-drift.sh) fails if they drift apart. Windows (winget/installer), the
# clients, NixOS and SteamOS have their own paths; this script points at them and stops.
#
# Exit codes: 0 done · 1 unsupported system / a step failed · 2 bad usage.
set -u
DOCS=https://docs.punktfunk.unom.io/docs
USER=${USER:-$(id -un)}; export USER
# ---------------------------------------------------------------------------- options
YES=${PUNKTFUNK_INSTALL_YES:-0}
CHANNEL=${PUNKTFUNK_INSTALL_CHANNEL:-stable}
GAMESTREAM=${PUNKTFUNK_INSTALL_GAMESTREAM:-} # 1/0, empty = ask (default no)
CLIPBOARD=${PUNKTFUNK_INSTALL_CLIPBOARD:-} # 1/0, empty = ask (default no)
PF_GROUP=${PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP:-} # 1/0, empty = ask (default no)
LINGER=${PUNKTFUNK_INSTALL_LINGER:-} # 1/0, empty = ask (default no)
MGMT_PORT=${PUNKTFUNK_INSTALL_MGMT_PORT:-47991} # where the management API moves to on a conflict
START=1
DRY=${PUNKTFUNK_INSTALL_DRY_RUN:-0}
usage() {
cat <<EOF
punktfunk guided host installer (preview)
usage: sh install.sh [options]
-y, --yes no prompts: take every default (also the behaviour without a terminal)
--channel stable|canary package channel (default stable; canary = latest main build)
--gamestream | --no-gamestream also serve stock Moonlight clients (default no — trusted LANs only)
--clipboard | --no-clipboard allow the shared clipboard on this host (default no)
--punktfunk-group | --no-punktfunk-group join the punktfunk group (virtual Steam Deck pad; default no)
--linger | --no-linger start the host at boot with nobody logged in (default no)
--mgmt-port N port to move the management API to if Sunshine/Apollo holds 47990 (default $MGMT_PORT)
--no-start install and configure, but don't enable the services
--dry-run print every command it would run, change nothing
-h, --help this text
Every option has an environment twin for scripted installs: PUNKTFUNK_INSTALL_YES=1,
PUNKTFUNK_INSTALL_CHANNEL, PUNKTFUNK_INSTALL_GAMESTREAM, PUNKTFUNK_INSTALL_CLIPBOARD,
PUNKTFUNK_INSTALL_PUNKTFUNK_GROUP, PUNKTFUNK_INSTALL_LINGER, PUNKTFUNK_INSTALL_MGMT_PORT (1/0 for the flags).
Docs: $DOCS/install
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes) YES=1 ;;
--channel) shift; CHANNEL=${1:-} ;;
--channel=*) CHANNEL=${1#*=} ;;
--gamestream) GAMESTREAM=1 ;; --no-gamestream) GAMESTREAM=0 ;;
--clipboard) CLIPBOARD=1 ;; --no-clipboard) CLIPBOARD=0 ;;
--punktfunk-group) PF_GROUP=1 ;; --no-punktfunk-group) PF_GROUP=0 ;;
--linger) LINGER=1 ;; --no-linger) LINGER=0 ;;
--mgmt-port) shift; MGMT_PORT=${1:-} ;;
--mgmt-port=*) MGMT_PORT=${1#*=} ;;
--no-start) START=0 ;;
--dry-run) DRY=1 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
case "$CHANNEL" in stable|canary) ;; *) echo "--channel must be stable or canary" >&2; exit 2 ;; esac
case "$MGMT_PORT" in ''|*[!0-9]*) echo "--mgmt-port must be a number" >&2; exit 2 ;; esac
# ---------------------------------------------------------------------------- plumbing
say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m ok\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m !!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m xx\033[0m %s\n' "$*" >&2; exit 1; }
# Prompts read from the terminal, not stdin — stdin is the script itself under `curl | sh`.
# No terminal (CI, cron, a pipe) behaves like --yes.
# Probe by opening it: in a container or under a service /dev/tty is a node that exists but
# can't be opened (ENXIO — no controlling terminal), so -r/-w would say yes and the redirect fail.
TTY=/dev/tty
(exec 3<>/dev/tty) 2>/dev/null || { TTY=; YES=1; }
# ask "question" default(y|n) → 0 = yes, 1 = no
ask() {
if [ "$YES" = 1 ]; then [ "$2" = y ]; return; fi
if [ "$2" = y ]; then hint="[Y/n]"; else hint="[y/N]"; fi
printf '\033[1m?\033[0m %s %s ' "$1" "$hint" > "$TTY"
read -r ans < "$TTY" || ans=
case "${ans:-$2}" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
}
# run "shell snippet": print it, run it (-e), and give the package manager the terminal so its own
# confirmation prompt works; under --yes the snippet is made non-interactive first.
run() {
cmd=$1
if [ "$YES" = 1 ]; then
cmd=$(printf '%s' "$cmd" | sed \
-e 's/^sudo apt install /sudo apt install -y /' \
-e 's/^sudo dnf install /sudo dnf install -y /' \
-e 's/^sudo pacman -Syu /sudo pacman -Syu --noconfirm /')
fi
printf ' + %s\n' "$cmd"
[ "$DRY" = 1 ] && return 0
if [ -n "$TTY" ]; then sh -ec "$cmd" < "$TTY"; else sh -ec "$cmd" < /dev/null; fi \
|| die "that step failed — fix it and re-run (the script is safe to repeat), or follow the page by hand: $DOCS_PAGE"
}
# Running as root without sudo (a minimal Debian container): a shim so the verbatim
# `sudo …` lines from platforms.json still work.
if [ "$(id -u)" = 0 ] && ! command -v sudo >/dev/null 2>&1; then
SHIM=$(mktemp -d) && printf '#!/bin/sh\nexec "$@"\n' > "$SHIM/sudo" && chmod +x "$SHIM/sudo" && PATH="$SHIM:$PATH"
fi
HOST_ENV=${XDG_CONFIG_HOME:-$HOME/.config}/punktfunk/host.env
# set_env KEY VALUE — replace or append one line in host.env (created on first use).
set_env() {
if [ "$DRY" = 1 ]; then ok "would set $1=$2 in $HOST_ENV"; return 0; fi
mkdir -p "$(dirname "$HOST_ENV")"
touch "$HOST_ENV"
if grep -q "^$1=" "$HOST_ENV"; then
sed -i "s|^$1=.*|$1=$2|" "$HOST_ENV"
else
printf '%s=%s\n' "$1" "$2" >> "$HOST_ENV"
fi
ok "$1=$2$HOST_ENV"
}
# ---------------------------------------------------------------------------- 0. preflight
cat <<EOF
punktfunk guided host installer — PREVIEW
The per-distro pages stay the documented path; this automates them. Docs: $DOCS/install
Re-running is safe. Ctrl-C stops between steps.
EOF
[ "$(uname -s)" = Linux ] || [ -n "${PUNKTFUNK_INSTALL_OS_RELEASE:-}" ] || die "this installer is for Linux hosts — Windows: $DOCS/windows-host"
[ -n "${SUDO_USER:-}" ] && [ "$(id -u)" = 0 ] && die "run this as your normal user, not under sudo — it calls sudo itself where needed, and the host runs as you (host.env, the services)"
command -v curl >/dev/null 2>&1 || die "curl is required (install it with your package manager first)"
OS_RELEASE=${PUNKTFUNK_INSTALL_OS_RELEASE:-/etc/os-release} # override for testing the detection
[ -r "$OS_RELEASE" ] || die "no /etc/os-release — can't tell which distro this is: $DOCS/install"
. "$OS_RELEASE"
ID=${ID:-}; ID_LIKE=${ID_LIKE:-}; VERSION_ID=${VERSION_ID:-}; PRETTY=${PRETTY_NAME:-$ID}
like() { case " $ID $ID_LIKE " in *" $1 "*) return 0 ;; esac; return 1; }
FAMILY=
DOCS_PAGE=$DOCS/install
if [ "$ID" = nixos ]; then
die "NixOS: add the flake input and enable the module instead — $DOCS/nixos"
elif [ "$ID" = steamos ]; then
die "SteamOS host: the on-device installer builds against the running OS — $DOCS/steamos-host"
elif command -v rpm-ostree >/dev/null 2>&1 || command -v bootc >/dev/null 2>&1 || [ "$ID" = bazzite ]; then
FAMILY=sysext; DOCS_PAGE=$DOCS/bazzite
elif like debian || like ubuntu; then
FAMILY=apt; DOCS_PAGE=$DOCS/debian
[ "$ID" = ubuntu ] && DOCS_PAGE=$DOCS/ubuntu
elif like fedora; then
FAMILY=dnf; DOCS_PAGE=$DOCS/fedora
elif like arch; then
FAMILY=pacman; DOCS_PAGE=$DOCS/arch
else
die "no package repo for '$PRETTY' yet — $DOCS/build-from-source"
fi
say "Detected $PRETTY$FAMILY (guide: $DOCS_PAGE)"
# Version floors the package can't express: below these the install succeeds and nothing can stream.
major=${VERSION_ID%%.*}
case "$ID" in
debian) [ "${major:-0}" -ge 13 ] 2>/dev/null || die "Debian $VERSION_ID is below the glibc floor — Debian 13+ or build from source: $DOCS/build-from-source" ;;
ubuntu) case "$VERSION_ID" in 2[0-5].*) warn "Ubuntu $VERSION_ID installs the package but cannot host — its desktop is too old to create a virtual display ($DOCS/requirements#the-floor-for-a-working-host). Use 26.04+."
ask "Continue anyway?" n || exit 1 ;; esac ;;
linuxmint) case "$VERSION_ID" in 2[0-2]*) warn "Linux Mint $VERSION_ID (Ubuntu 24.04 base) installs the package but cannot host — $DOCS/requirements#cinnamon-linux-mint-and-lmde. LMDE 7 and Mint 23 can."
ask "Continue anyway?" n || exit 1 ;; esac ;;
esac
RPM_GROUP=
if [ "$FAMILY" = dnf ]; then
case "$major" in
44) RPM_GROUP=fedora-44 ;;
43) RPM_GROUP=bazzite ;; # a plain Fedora 43 build of the same package
*) die "no RPM group for Fedora $VERSION_ID yet — $DOCS/build-from-source" ;;
esac
fi
# ---------------------------------------------------------------------------- 1. install
# The snippets below are data/platforms.json's install lines, verbatim (stable channel); canary
# and the Fedora group are edited in. check-docs-drift.sh gate 6 keeps them identical.
if command -v punktfunk-host >/dev/null 2>&1; then
say "punktfunk-host is already installed ($(punktfunk-host --version 2>/dev/null | head -1)) — skipping the install, continuing with setup"
else
say "Installing the host ($CHANNEL channel)"
case "$FAMILY" in
apt)
repo_line='echo "deb [signed-by=/etc/apt/keyrings/punktfunk.asc] https://git.unom.io/api/packages/unom/debian stable main" | sudo tee /etc/apt/sources.list.d/punktfunk.list'
[ "$CHANNEL" = canary ] && repo_line=$(printf '%s' "$repo_line" | sed 's/ stable main/ canary main/')
run 'sudo install -d -m 0755 /etc/apt/keyrings'
run 'curl -fsSL https://git.unom.io/api/packages/unom/debian/repository.key | sudo tee /etc/apt/keyrings/punktfunk.asc >/dev/null'
run "$repo_line"
run 'sudo apt update'
run 'sudo apt install punktfunk-host'
;;
pacman)
repo_line=$(cat <<'LINE'
grep -q '^\[punktfunk\]' /etc/pacman.conf || printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' | sudo tee -a /etc/pacman.conf >/dev/null
LINE
)
# canary: both the grep guard (escaped brackets) and the printf body name the repo
[ "$CHANNEL" = canary ] && repo_line=$(printf '%s' "$repo_line" | sed -e 's/punktfunk\\\]/punktfunk-canary\\]/' -e 's/\[punktfunk\]/[punktfunk-canary]/')
run 'curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key | sudo pacman-key --add -'
run 'sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69'
run "$repo_line"
run 'sudo pacman -Syu punktfunk-host'
if ask "Install the web console and the plugin runner too (punktfunk-web, punktfunk-scripting — optional on Arch, recommended)?" y; then
run 'sudo pacman -Syu punktfunk-web punktfunk-scripting'
fi
;;
dnf)
group=$RPM_GROUP
[ "$CHANNEL" = canary ] && group="$group-canary"
run "$(cat <<'CMD'
sudo tee /etc/yum.repos.d/punktfunk.repo >/dev/null <<'REPO'
[punktfunk]
name=punktfunk
# fedora-44 on Fedora 44; bazzite on Fedora 43 (a plain Fedora 43 build of the same package)
baseurl=https://git.unom.io/api/packages/unom/rpm/fedora-44
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://git.unom.io/api/packages/unom/rpm/repository.key
https://git.unom.io/api/packages/unom/generic/punktfunk-keys/1/RPM-GPG-KEY-punktfunk
REPO
CMD
)"
[ "$group" = fedora-44 ] || run "sudo sed -i 's|/rpm/fedora-44|/rpm/$group|' /etc/yum.repos.d/punktfunk.repo"
run 'sudo dnf install punktfunk'
;;
sysext)
install_line='sudo bash punktfunk-sysext.sh install'
[ "$CHANNEL" = canary ] && install_line="$install_line --channel canary"
run 'curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh'
run "$install_line"
;;
esac
hash -r 2>/dev/null || true
if [ "$DRY" != 1 ]; then
command -v punktfunk-host >/dev/null 2>&1 || die "the install finished but punktfunk-host isn't on PATH — open a new terminal and re-run, or see $DOCS_PAGE"
ok "punktfunk-host $(punktfunk-host --version 2>/dev/null | head -1) installed"
fi
fi
# ---------------------------------------------------------------------------- 2. another host?
# detect-conflicts exits 1 only for a Sunshine-family host that runs or autostarts; dormant
# leftovers print and exit 0. Native-only, the single port both want is the management API's.
say "Checking for Sunshine / Apollo / Vibeshine"
CONFLICT=0
if [ "$DRY" = 1 ] && ! command -v punktfunk-host >/dev/null 2>&1; then
printf ' + %s\n' 'punktfunk-host detect-conflicts'
elif report=$(punktfunk-host detect-conflicts 2>&1); then
ok "${report:-No conflicting game-streaming host detected.}"
else
CONFLICT=1
printf '%s\n' "$report" | sed 's/^/ /'
warn "another streaming host is active on this box — both want TCP 47990 (its web UI, punktfunk's management API)"
if ask "Keep both for now and move punktfunk's management API to port $MGMT_PORT? (No = you stop the other host yourself)" y; then
set_env PUNKTFUNK_MGMT_BIND "0.0.0.0:$MGMT_PORT"
echo " Clients learn the port from discovery; the console and plugins read it from mgmt-endpoint. Details: $DOCS/switching-from-sunshine"
else
MGMT_PORT=
echo " Stop it before you start punktfunk (e.g. sudo systemctl disable --now sunshine) — $DOCS/switching-from-sunshine"
fi
fi
[ "$CONFLICT" = 0 ] && MGMT_PORT=
# ---------------------------------------------------------------------------- 3. groups
say "Controller access"
if id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx input; then
ok "already in the input group"
elif command -v ujust >/dev/null 2>&1; then # Bazzite: the input group is recipe-managed, usermod is the wrong tool
run 'ujust add-user-to-input-group'; RELOGIN=1
elif ! getent group input >/dev/null 2>&1; then
warn "no 'input' group on this system — virtual gamepads need /dev/uinput access; see $DOCS_PAGE"
else
run 'sudo usermod -aG input "$USER"'; RELOGIN=1
fi
if [ -z "$PF_GROUP" ]; then
ask "Also join the punktfunk group? It enables the virtual Steam Deck controller (paddles, trackpads, gyro) by granting usbip attach — only on a machine you trust ($DOCS/gamescope#nobara-and-other-autologin-display-managers)" n && PF_GROUP=1 || PF_GROUP=0
fi
if [ "$PF_GROUP" = 1 ]; then
if id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx punktfunk; then ok "already in the punktfunk group"
else run 'sudo usermod -aG punktfunk "$USER"'; RELOGIN=1; fi
fi
# ---------------------------------------------------------------------------- 4. options
say "Options (host.env — everything here is off by default and reversible)"
if [ -z "$GAMESTREAM" ]; then
ask "Also serve stock Moonlight clients (GameStream compat)? Its pairing runs over plain HTTP — trusted LANs only; Punktfunk's own apps don't need it" n && GAMESTREAM=1 || GAMESTREAM=0
fi
if [ "$GAMESTREAM" = 1 ]; then
[ "$CONFLICT" = 1 ] && warn "with another GameStream host running, only one can bind the Moonlight ports — stop the other first or skip this"
set_env PUNKTFUNK_GAMESTREAM 1
fi
if [ -z "$CLIPBOARD" ]; then
ask "Allow the shared clipboard on this host (each client still opts in per host)?" n && CLIPBOARD=1 || CLIPBOARD=0
fi
[ "$CLIPBOARD" = 1 ] && set_env PUNKTFUNK_CLIPBOARD on
# ---------------------------------------------------------------------------- 5. firewall
# Packages never open ports; they install firewalld services / ufw profiles by these names.
say "Firewall"
if command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
svcs="--add-service=punktfunk-native --add-service=punktfunk-web"
[ "$GAMESTREAM" = 1 ] && svcs="$svcs --add-service=punktfunk-gamestream"
[ -n "$MGMT_PORT" ] && svcs="$svcs --add-port=$MGMT_PORT/tcp"
run 'sudo firewall-cmd --reload'
run "sudo firewall-cmd --permanent $svcs"
run 'sudo firewall-cmd --reload'
elif command -v ufw >/dev/null 2>&1 && grep -qs '^ENABLED=yes' /etc/ufw/ufw.conf; then
run 'sudo ufw allow punktfunk-native'
run 'sudo ufw allow punktfunk-web'
[ "$GAMESTREAM" = 1 ] && run 'sudo ufw allow punktfunk-gamestream'
[ -n "$MGMT_PORT" ] && run "sudo ufw allow $MGMT_PORT/tcp"
else
ok "no active firewall found — nothing to open ($DOCS/ports if you add one later)"
fi
# ---------------------------------------------------------------------------- 6. start
if [ "$START" = 1 ]; then
say "Starting the host and the web console"
if ! systemctl --user show-environment >/dev/null 2>&1; then
warn "no user systemd session here (ssh without a login session?) — run this from a terminal in your desktop session:"
echo " systemctl --user enable --now punktfunk-host punktfunk-web"
START=0
else
systemctl --user daemon-reload 2>/dev/null
units="punktfunk-host"
systemctl --user list-unit-files punktfunk-web.service 2>/dev/null | grep -q '^punktfunk-web.service' && units="$units punktfunk-web"
# The plugin runner fills the game library; apt/dnf/sysext start it themselves, Arch doesn't.
if systemctl --user list-unit-files punktfunk-scripting.service 2>/dev/null | grep -q disabled; then units="$units punktfunk-scripting"; fi
run "systemctl --user enable --now $units"
if [ -z "$LINGER" ]; then
ask "Start the host at boot even with nobody logged in (headless box)?" n && LINGER=1 || LINGER=0
fi
[ "$LINGER" = 1 ] && run 'sudo loginctl enable-linger "$USER"'
fi
fi
# ---------------------------------------------------------------------------- 7. verify + next
say "Checking"
if [ "$START" = 1 ] && [ "$DRY" != 1 ]; then
sleep 2
if systemctl --user is-active --quiet punktfunk-host; then ok "punktfunk-host is running"
else warn "punktfunk-host is not active — journalctl --user -u punktfunk-host -e ($DOCS/troubleshooting#the-linux-host-service-wont-start)"; fi
if command -v ss >/dev/null 2>&1 && ss -lun 2>/dev/null | grep -q ':9777 '; then ok "listening on UDP 9777 (punktfunk/1)"
else warn "nothing on UDP 9777 yet — give it a second, then: journalctl --user -u punktfunk-host -e"; fi
fi
# GPU drivers are the docs pages' job (one step, per distro) — but the one silent failure worth
# calling out: Fedora + NVIDIA with Fedora's own ffmpeg has no NVENC, and the RPM only Recommends
# RPM Fusion's build, so the install succeeded and encoding won't.
if [ "$FAMILY" = dnf ] && grep -qs 0x10de /sys/bus/pci/devices/*/vendor 2>/dev/null && ! rpm -q ffmpeg-libs >/dev/null 2>&1; then
warn "NVIDIA GPU, but RPM Fusion's ffmpeg-libs isn't installed — NVENC won't work until it is: step 1 of $DOCS_PAGE"
fi
ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$ip" ] || ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1); exit}')
cat <<EOF
Done. Next:
1. Open the web console: https://${ip:-<host-ip>}:47992 (the certificate is the host's own — continue past the warning)
password: sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
2. Install a client on the device you stream to ($DOCS/install-client), connect, and click
Approve in the console — or Pair a device for a PIN ($DOCS/pairing).
3. Stream. Ctrl+Alt+Shift+Q hands mouse and keyboard back on desktop clients.
EOF
[ "${RELOGIN:-0}" = 1 ] && echo " Group changes apply after you log out and back in (controllers won't work until then)."
[ "$CONFLICT" = 1 ] && echo " Running next to Sunshine/Apollo: $DOCS/switching-from-sunshine"
echo " Stuck? $DOCS/troubleshooting · this installer is a preview — the full guide is $DOCS_PAGE"
echo