Files
Skald-Circle/install-nightly.sh
T
dguiducci bb5226a9a9
Nightly Build / build (push) Successful in 7m47s
fix: keep the server running after you log out
A `systemctl --user` unit runs under the per-user manager, which systemd
starts at first login and stops when the user's last session ends — so
closing the SSH session that started Skald killed it, and it never came
up at boot. No crash and nothing in the journal: the whole cgroup is
simply torn down. Both installers now enable lingering after installing
the unit, and update.sh carries the same helper so an installation
predating this fix is healed by an ordinary update. A failure to enable
it only ever warns, with the manual command — it must not abort an
install.

Two things found on the way there:

update.sh matched `case "$OS" in Linux) ... Darwin)`, but $OS had already
been normalized to lowercase at the top of the file, so stop_service and
start_service were both silent no-ops. None of the ordering the file
documents at its head was executing: the tarball went over the running
binary (ETXTBSY, aborting the update mid-way) and the safety-net restart
in cleanup() was a no-op too, leaving the box down.

Neither workflow published install.sh / install-nightly.sh to the web
root, so the scripts served by builds.skaldagent.net were hand-copied and
drifting from the repo — an installer fix would reach every existing box
through update.sh but never a new one. Nightly publishes the nightly
installer, release publishes the release one, both with the same atomic
temp-and-rename the tarballs use.

Also on the unit: dropped `After=docker.service`, which a user manager
silently ignores rather than honouring advisorily, and moved
`Restart=on-failure` to `always` — run.sh exits 0 on any graceful
shutdown, including one nobody asked for, which on-failure reads as a
clean stop. That is also what absorbs the boot race against Docker now
that lingering makes us start at boot.
2026-08-06 11:00:13 +01:00

536 lines
22 KiB
Bash
Executable File

#!/usr/bin/env sh
# install-nightly.sh — install the latest nightly build of Skald Circle
#
# Usage:
# curl -fsSL https://builds.skaldagent.net/install-nightly.sh | bash
#
# Supports Linux (systemd) and macOS ARM64 (launchd).
# Default install dir: ~/.local/share/skald-circle (override with SKALD_DIR).
#
# If Docker is missing, the installer can optionally install it.
set -eu
# ── User overrides ────────────────────────────────────────────────────────────
INSTALL_DIR="${SKALD_DIR:-$HOME/.local/share/skald-circle}"
CHANNEL="${SKALD_CHANNEL:-nightly}"
# ── Detect interactive stdin ──────────────────────────────────────────────────
if [ -t 0 ]; then
IS_INTERACTIVE=true
else
IS_INTERACTIVE=false
fi
# ── Colours (if terminal) ─────────────────────────────────────────────────────
if [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
info() { printf "${GREEN}%s${NC}\n" "$*"; }
warn() { printf "${YELLOW}⚠ %s${NC}\n" "$*"; }
err() { printf "${RED}✖ %s${NC}\n" "$*"; }
header(){ printf "\n${BOLD}%s${NC}\n" "$*"; }
banner(){ printf "\n${CYAN}${BOLD}%s${NC}\n" "$*"; }
# ── Network IP helper ──────────────────────────────────────────────────────────
# Returns the primary non-loopback network IP, or empty string if unavailable.
get_network_ip() {
if command -v ip >/dev/null 2>&1; then
ip route get 1 2>/dev/null | awk '{print $7}'
elif command -v ifconfig >/dev/null 2>&1; then
ifconfig 2>/dev/null | grep -E 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -1
elif command -v hostname >/dev/null 2>&1; then
hostname -I 2>/dev/null | awk '{print $1}'
fi
}
# ── Prompt helpers (work from piped stdin too) ────────────────────────────────
prompt_enter() {
local msg="${1:-Press Enter to continue or Ctrl+C to cancel}"
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r _ || true
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r _ </dev/tty || true
fi
}
prompt_yes_no() {
local msg="${1:-Continue? [Y/n]}"
local ans
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans="y"
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans="y"
else
ans="n"
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
""|"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# Like prompt_yes_no but defaults to NO — for destructive confirmations.
prompt_yes_no_default_no() {
local msg="${1:-Continue? [y/N]}"
local ans=""
if [ "$IS_INTERACTIVE" = true ]; then
printf "%s" "$msg" >&2
read -r ans || ans=""
elif (: </dev/tty) 2>/dev/null; then
printf "%s" "$msg" >/dev/tty
IFS= read -r ans </dev/tty || ans=""
fi
case "$(printf "%s" "$ans" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" in
"y"|"yes") return 0 ;;
*) return 1 ;;
esac
}
# ── Stop a running instance (for reinstall over an existing install) ──────────
# Stops the service and waits, bounded, for the process to exit — so the
# extraction never overwrites a live binary (ETXTBSY on Linux).
stop_existing_service() {
case "$OS" in
linux)
command -v systemctl >/dev/null 2>&1 && \
systemctl --user stop skald-circle.service 2>/dev/null || true
;;
darwin)
command -v launchctl >/dev/null 2>&1 && \
launchctl unload "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
;;
esac
if command -v pgrep >/dev/null 2>&1; then
local i=0
while pgrep -f "${INSTALL_DIR}/bin/skald" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -gt 20 ] && break
sleep 1
done
else
sleep 2
fi
}
# ── systemd user lingering ────────────────────────────────────────────────────
# A `systemctl --user` unit runs under the per-user manager (user@UID.service),
# which systemd starts at first login and STOPS when the user's last session
# ends — taking every user service down with it. So without lingering the server
# dies the moment you close the SSH session that started it, and never comes up
# at boot. Enabling it is the whole difference between "runs while I'm logged
# in" and "is a daemon".
enable_linger() {
local target="${USER:-$(id -un)}"
if ! command -v loginctl >/dev/null 2>&1; then
warn "loginctl not found — cannot enable lingering."
echo " The server will stop when you log out of this machine."
return 0
fi
case "$(loginctl show-user "$target" --property=Linger 2>/dev/null || true)" in
*=yes) info "✔ Lingering already enabled for ${target}"; return 0 ;;
esac
# Enabling linger for yourself is normally allowed without elevation; fall
# back to sudo, non-interactive first so `curl | bash` never blocks on a
# password prompt it has no terminal to answer.
if loginctl enable-linger "$target" 2>/dev/null \
|| sudo -n loginctl enable-linger "$target" 2>/dev/null \
|| { [ "$IS_INTERACTIVE" = true ] && sudo loginctl enable-linger "$target"; }; then
info "✔ Lingering enabled — the server keeps running after you log out"
else
warn "Could not enable lingering for ${target}."
echo " Without it, the server stops as soon as your last session ends"
echo " and does not start at boot. Run this once, as an administrator:"
echo ""
echo " sudo loginctl enable-linger ${target}"
echo ""
fi
}
# ── Docker install helper ─────────────────────────────────────────────────────
install_docker() {
if [ "$OS" = "linux" ]; then
info "▶ Installing Docker via get.docker.com …"
curl -fsSL https://get.docker.com | sh
info "✔ Docker installed"
if command -v usermod >/dev/null 2>&1; then
sudo usermod -aG docker "$USER"
warn "You may need to log out and back in for the docker group to take effect."
fi
info "✔ User added to docker group"
elif [ "$OS" = "darwin" ]; then
if command -v brew >/dev/null 2>&1; then
info "▶ Installing Docker Desktop via Homebrew …"
brew install --cask docker
info "✔ Docker Desktop installed. Open it from Applications to complete setup."
else
err "Homebrew not found. Please install Docker Desktop manually from:"
err " https://docs.docker.com/desktop/setup/install/mac-install/"
err "Then re-run this installer."
exit 1
fi
fi
}
ask_install_docker() {
echo ""
header "🐳 Docker"
echo ""
if command -v docker >/dev/null 2>&1 && docker version >/dev/null 2>&1; then
info "✔ Docker is installed and the daemon is running."
return 0
elif command -v docker >/dev/null 2>&1; then
echo " Docker CLI is present but the daemon is not running."
echo " Please start Docker before starting the server."
echo ""
return 0
else
warn "Docker is required but not found."
echo ""
echo " Skald uses Docker to run sandboxed user containers."
echo " The server will not start without it."
echo ""
if prompt_yes_no " Install Docker now? [Y/n] "; then
install_docker
echo ""
else
warn "Skipping Docker installation."
echo " You can install it later: https://docs.docker.com/engine/install/"
echo ""
fi
fi
}
# ── Optional dependency checks ────────────────────────────────────────────────
check_optional_deps() {
echo ""
header "🔧 Optional dependencies"
echo ""
if command -v python3 >/dev/null 2>&1; then
info "✔ Python 3 found ($(python3 --version 2>&1 | head -1))"
else
warn "Python 3 not found — the TTS plugins and host-run connectors will not work."
echo " Install it from https://www.python.org/downloads/"
echo ""
fi
if command -v node >/dev/null 2>&1; then
info "✔ Node.js found ($(node --version 2>&1 | head -1))"
else
warn "Node.js not found — WhatsApp MCP server will not work."
echo " Install it from https://nodejs.org/ (version 18 or later)"
echo ""
fi
}
# ── Platform detection ────────────────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*) err "Unsupported OS: $OS"; exit 1 ;;
esac
case "$ARCH" in
x86_64)
ARCH="amd64"
if [ "$OS" = "darwin" ]; then
err "Intel Macs are not supported. Apple Silicon (M1+) only."
exit 1
fi
;;
aarch64|arm64)
ARCH="arm64"
;;
*) err "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# ── Dependency checks ─────────────────────────────────────────────────────────
command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; }
if [ "$OS" = "linux" ]; then
command -v systemctl >/dev/null 2>&1 || { warn "systemd not found — service will not be installed automatically."; NOSYSTEMD=1; }
elif [ "$OS" = "darwin" ]; then
command -v launchctl >/dev/null 2>&1 || { err "launchctl not found."; exit 1; }
fi
# ── Banner + fetch latest nightly ─────────────────────────────────────────────
banner "╔══════════════════════════════════════════╗"
banner "║ Skald Circle — Nightly Installer ║"
banner "╚══════════════════════════════════════════╝"
echo ""
info "🔍 Looking up latest nightly build …"
BASE_URL="https://builds.skaldagent.net"
case "$CHANNEL" in
nightly)
TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz"
VERSION="nightly-$(date -u +%Y%m%d)"
DISPLAY_VERSION="nightly"
;;
*)
err "Unknown channel: $CHANNEL. Use SKALD_CHANNEL=nightly (default)."
exit 1
;;
esac
# ── Summary + confirmation ────────────────────────────────────────────────────
echo ""
header "Installation summary"
echo ""
echo " What : Skald Circle (${DISPLAY_VERSION})"
echo " Platform : ${OS}/${ARCH}"
echo " Install to : ${INSTALL_DIR}"
echo " Service : $( [ "$OS" = "linux" ] && echo "systemd (user)" || echo "launchd" )"
echo ""
echo " The installer will check for Docker and offer to install it if missing."
echo " Python 3 and Node.js are optional — needed for some MCP servers."
echo ""
prompt_enter "Press Enter to continue or Ctrl+C to cancel "
# ── Docker check & install ────────────────────────────────────────────────────
ask_install_docker
# ── Optional dependency check ─────────────────────────────────────────────────
check_optional_deps
# ── Existing installation? ────────────────────────────────────────────────────
# This installer targets a fresh install; the supported in-place upgrade path is
# update.sh (keeps your data, restarts the service safely). If an install is
# already here, point the user there and only reinstall over it on request.
if [ -x "$INSTALL_DIR/bin/skald" ]; then
echo ""
warn "An existing installation was found at ${INSTALL_DIR}."
echo " To upgrade in place (keeping your database and config), use:"
echo " ${INSTALL_DIR}/update.sh"
echo ""
if prompt_yes_no_default_no " Reinstall over it instead? Data is kept, the service restarts. [y/N] "; then
info "⏹️ Stopping the running instance before reinstalling …"
stop_existing_service
else
info "Aborted — run ${INSTALL_DIR}/update.sh to upgrade."
exit 0
fi
echo ""
fi
# ── Download & extract ────────────────────────────────────────────────────────
info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …"
mkdir -p "$INSTALL_DIR"
curl -fsSL "$TARBALL_URL" | tar xz -C "$INSTALL_DIR" --strip-components=1
if [ ! -x "$INSTALL_DIR/bin/skald" ]; then
err "Download or extraction failed — skald binary not found."
exit 1
fi
info "✔ Extracted to ${INSTALL_DIR}"
# ── Python venv (best-effort) ─────────────────────────────────────────────────
# Create the venv inline instead of calling run.sh (which also launches the
# server and would hang the installer).
info "🔧 Setting up Python virtual environment …"
VENV_DIR="${INSTALL_DIR}/.venv"
REQUIREMENTS="${INSTALL_DIR}/requirements.txt"
# A venv is usable only if python3 AND pip both work. Ubuntu's `python3 -m venv`
# without the python3-venv package leaves a venv with python3 but no pip — detect
# that and recreate, so a broken venv never survives a restart.
if [ ! -f "$VENV_DIR/bin/python3" ] || ! "$VENV_DIR/bin/python3" -m pip --version >/dev/null 2>&1; then
rm -rf "$VENV_DIR"
if command -v uv >/dev/null 2>&1; then
uv venv --seed "$VENV_DIR" && uv pip install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (uv)" \
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
elif command -v python3 >/dev/null 2>&1; then
python3 -m venv "$VENV_DIR" && "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" \
&& info "✔ Python venv ready (pip)" \
|| warn "Python venv setup failed — the TTS plugins and host-run connectors will be unavailable."
else
warn "python3 not found — the TTS plugins and host-run connectors will be unavailable."
fi
else
info "✔ Python venv already exists"
fi
# ── Install daemon ────────────────────────────────────────────────────────────
if [ "$OS" = "linux" ] && [ -z "${NOSYSTEMD:-}" ]; then
header "⚡ Installing systemd user service …"
mkdir -p "$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/skald-circle.service" <<- SERVICE
[Unit]
Description=Skald Circle (${DISPLAY_VERSION})
Documentation=https://skaldagent.net
# No After=docker.service here: this is a *user* unit, and docker.service is a
# system unit the user manager knows nothing about — the dependency would be
# silently ignored. Docker may therefore still be starting when we do; the
# server fails fast when the daemon is unreachable and Restart brings it back a
# few seconds later, so boot ordering settles itself.
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/run.sh
WorkingDirectory=${INSTALL_DIR}
# always, not on-failure: run.sh exits 0 on any graceful shutdown, including one
# nobody asked for (a stray SIGTERM to the server), which on-failure would treat
# as a clean stop and leave the box down. An explicit "systemctl --user stop"
# is unaffected — systemd never restarts after a requested stop.
Restart=always
RestartSec=5
Environment=SKALD_BIN=${INSTALL_DIR}/bin/skald
Environment=SKALD_SETUP_BIN=${INSTALL_DIR}/bin/skald-setup
[Install]
WantedBy=default.target
SERVICE
systemctl --user daemon-reload
systemctl --user enable --now skald-circle.service
info "✔ Service installed and started"
enable_linger
echo ""
echo " Status: systemctl --user status skald-circle"
echo " Logs: journalctl --user -u skald-circle -f"
elif [ "$OS" = "darwin" ]; then
header "⚡ Installing launchd agent …"
mkdir -p "$HOME/Library/LaunchAgents" "$INSTALL_DIR/logs"
PLIST="$HOME/Library/LaunchAgents/com.skald.circle.plist"
cat > "$PLIST" <<- PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.skald.circle</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/run.sh</string>
</array>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${INSTALL_DIR}/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>${INSTALL_DIR}/logs/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>SKALD_BIN</key>
<string>${INSTALL_DIR}/bin/skald</string>
<key>SKALD_SETUP_BIN</key>
<string>${INSTALL_DIR}/bin/skald-setup</string>
</dict>
</dict>
</plist>
PLIST
# Idempotent: unload any previously-loaded agent first, so a re-install
# reloads the freshly written plist instead of failing on "already loaded".
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load "$PLIST"
info "✔ Agent installed and started"
echo ""
echo " Status: launchctl list com.skald.circle"
echo " Logs: tail -f ${INSTALL_DIR}/logs/stdout.log"
elif [ -n "${NOSYSTEMD:-}" ]; then
warn "systemd not available — start manually: ${INSTALL_DIR}/run.sh"
fi
# ── Welcome + first-run setup ─────────────────────────────────────────────────
echo ""
header "👋 Welcome to Skald Circle!"
if [ -x "$INSTALL_DIR/bin/skald-setup" ]; then
echo ""
echo " The server is running. Now let's create your admin account."
echo " You'll be asked for a username and password."
echo ""
# skald-setup uses the relative path `database/system.db`, so we cd to the
# install directory first. When running via curl | bash the cwd is ~, which
# would create `~/database/system.db` instead of the correct location.
cd "$INSTALL_DIR"
if [ "$IS_INTERACTIVE" = true ]; then
"bin/skald-setup"
elif (: </dev/tty) 2>/dev/null; then
"bin/skald-setup" </dev/tty
else
echo " From a terminal, run:"
echo " cd ${INSTALL_DIR} && ./bin/skald-setup"
fi
echo ""
fi
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""
info "✅ Skald Circle (${DISPLAY_VERSION}) installed successfully!"
echo ""
# Write channel tag for future updates
echo "$CHANNEL" > "$INSTALL_DIR/.release-channel"
# ── Hint about optional deps ──────────────────────────────────────────────────
if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then
echo ""
info "💡 Optional GPU/ML dependencies available:"
info " ${INSTALL_DIR}/requirements-optional.txt"
echo " Install them manually if you use the Orpheus TTS plugin:"
echo " cd ${INSTALL_DIR} && .venv/bin/pip install -r requirements-optional.txt"
fi
echo " Server : ${INSTALL_DIR}/run.sh"
echo " Binary : ${INSTALL_DIR}/bin/skald"
echo " Setup : ${INSTALL_DIR}/bin/skald-setup"
NET_IP="$(get_network_ip)"
ADMIN_URL="${NET_IP:+http://${NET_IP}:9000}"
echo " Admin console: ${ADMIN_URL:-http://localhost:9000} (on the machine, or use the IP above from another device)"
echo " Server IP: ${NET_IP:-localhost} (network address if available)"
echo ""
echo ""
echo " Start: $( [ "$OS" = "linux" ] && echo "systemctl --user start skald-circle" || echo "launchctl start com.skald.circle" )"
echo " Stop: $( [ "$OS" = "linux" ] && echo "systemctl --user stop skald-circle" || echo "launchctl stop com.skald.circle" )"
echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )"
echo ""