diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 7369585..47d1546 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -52,7 +52,15 @@ jobs: - name: Deploy to builds.skaldagent.net run: | cd "${GITHUB_WORKSPACE:-.}" - mkdir -p /var/www/builds.skaldagent.net/nightly - cp dist/*.tar.gz /var/www/builds.skaldagent.net/nightly/ + DEST=/var/www/builds.skaldagent.net/nightly + mkdir -p "$DEST" + # Nightly reuses a fixed filename, so publish atomically: copy to a + # temp name on the same filesystem, then rename over the target. A + # concurrent download never sees a half-written tarball. + for f in dist/*.tar.gz; do + name="$(basename "$f")" + cp "$f" "$DEST/.$name.tmp" + mv -f "$DEST/.$name.tmp" "$DEST/$name" + done echo "[nightly] Deployed:" - ls -lh /var/www/builds.skaldagent.net/nightly/ + ls -lh "$DEST/" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a4ce55a..e2f623d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -85,11 +85,22 @@ jobs: VERSION="${{ steps.extract-version.outputs.version }}" TARGET="/var/www/builds.skaldagent.net/releases/${VERSION}" mkdir -p "$TARGET" - cp dist/*.tar.gz "$TARGET/" + # Publish each tarball atomically (temp name + rename) so a client can + # never fetch a half-written file. + for f in dist/*.tar.gz; do + name="$(basename "$f")" + cp "$f" "$TARGET/.$name.tmp" + mv -f "$TARGET/.$name.tmp" "$TARGET/$name" + done echo "[release] Deployed $VERSION:" ls -lh "$TARGET/" - name: Update latest version pointer run: | - echo "${{ steps.extract-version.outputs.version }}" > /var/www/builds.skaldagent.net/releases/LATEST - echo "[release] Updated releases/LATEST → ${{ steps.extract-version.outputs.version }}" + VERSION="${{ steps.extract-version.outputs.version }}" + DEST=/var/www/builds.skaldagent.net/releases + # Flip LATEST atomically — install.sh/update.sh read it to decide + # whether to upgrade, so it must never be observed empty or partial. + printf '%s\n' "$VERSION" > "$DEST/.LATEST.tmp" + mv -f "$DEST/.LATEST.tmp" "$DEST/LATEST" + echo "[release] Updated releases/LATEST → $VERSION" diff --git a/ci/package-macos.sh b/ci/package-macos.sh index e943bbc..6d248e2 100755 --- a/ci/package-macos.sh +++ b/ci/package-macos.sh @@ -90,19 +90,26 @@ fi echo "[package-macos] Uploading to ${REMOTE_HOST}..." if [ "$MODE" = "release" ]; then - # Create remote directory and copy tarball - ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/releases/${VERSION}" - scp dist/skald-circle-${VERSION}-darwin-arm64.tar.gz \ - "${REMOTE_HOST}:${REMOTE_BASE}/releases/${VERSION}/" + TARBALL="skald-circle-${VERSION}-darwin-arm64.tar.gz" + RDIR="${REMOTE_BASE}/releases/${VERSION}" + ssh "$REMOTE_HOST" "mkdir -p ${RDIR}" - # Update LATEST pointer - echo "$VERSION" | ssh "$REMOTE_HOST" "cat > ${REMOTE_BASE}/releases/LATEST" + # Publish atomically: scp to a temp name, then rename over the target so a + # concurrent download never sees a half-transferred tarball. + scp "dist/${TARBALL}" "${REMOTE_HOST}:${RDIR}/.${TARBALL}.tmp" + ssh "$REMOTE_HOST" "mv -f ${RDIR}/.${TARBALL}.tmp ${RDIR}/${TARBALL}" + + # Flip LATEST atomically (write temp + rename) — clients read it to decide + # whether to upgrade, so it must never be observed empty or partial. + ssh "$REMOTE_HOST" "printf '%s\n' '${VERSION}' > ${REMOTE_BASE}/releases/.LATEST.tmp && mv -f ${REMOTE_BASE}/releases/.LATEST.tmp ${REMOTE_BASE}/releases/LATEST" echo "[package-macos] ✅ Release ${VERSION} deployed + LATEST updated." else - # Nightly — copy into nightly/ directory - ssh "$REMOTE_HOST" "mkdir -p ${REMOTE_BASE}/nightly" - scp dist/skald-circle-nightly-darwin-arm64.tar.gz \ - "${REMOTE_HOST}:${REMOTE_BASE}/nightly/" + # Nightly — atomic publish into nightly/ (fixed filename, reused each run). + TARBALL="skald-circle-nightly-darwin-arm64.tar.gz" + NDIR="${REMOTE_BASE}/nightly" + ssh "$REMOTE_HOST" "mkdir -p ${NDIR}" + scp "dist/${TARBALL}" "${REMOTE_HOST}:${NDIR}/.${TARBALL}.tmp" + ssh "$REMOTE_HOST" "mv -f ${NDIR}/.${TARBALL}.tmp ${NDIR}/${TARBALL}" echo "[package-macos] ✅ Nightly deployed." fi diff --git a/ci/verify-version.sh b/ci/verify-version.sh index e099e8e..d3c6061 100755 --- a/ci/verify-version.sh +++ b/ci/verify-version.sh @@ -5,7 +5,7 @@ # branch. Runs in the repo root after checkout. # # Usage: -# ./scripts/verify-version.sh \ +# ./ci/verify-version.sh \ # --builds-dir /var/www/builds.skaldagent.net # # Exit codes: diff --git a/install-nightly.sh b/install-nightly.sh index 98dbcca..7ef694e 100755 --- a/install-nightly.sh +++ b/install-nightly.sh @@ -84,6 +84,49 @@ prompt_yes_no() { 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/null; then + printf "%s" "$msg" >/dev/tty + IFS= read -r ans /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 +} + # ── Docker install helper ───────────────────────────────────────────────────── install_docker() { if [ "$OS" = "linux" ]; then @@ -237,6 +280,26 @@ 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" @@ -354,6 +417,9 @@ elif [ "$OS" = "darwin" ]; then 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" diff --git a/install.sh b/install.sh index 01b8273..aee48c0 100755 --- a/install.sh +++ b/install.sh @@ -87,6 +87,49 @@ prompt_yes_no() { 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/null; then + printf "%s" "$msg" >/dev/tty + IFS= read -r ans /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 +} + # ── Docker install helper ───────────────────────────────────────────────────── install_docker() { if [ "$OS" = "linux" ]; then @@ -242,6 +285,26 @@ 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 ${VERSION} …" mkdir -p "$INSTALL_DIR" @@ -359,6 +422,9 @@ elif [ "$OS" = "darwin" ]; then 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" diff --git a/uninstall.sh b/uninstall.sh index ee3391a..34e134a 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -46,11 +46,25 @@ echo " This will permanently delete Skald Circle and all its data:" echo " ${INSTALL_DIR}" echo "" -if [ -t 0 ]; then - printf "%s " "Are you sure? Type 'yes' to continue: " +# Confirm before a destructive delete. Read from the terminal even when stdin is +# piped (curl | sh); if there's no terminal at all, require SKALD_YES=1 so an +# install is never wiped with no confirmation. +if [ "${SKALD_YES:-}" = "1" ]; then + : +elif [ -t 0 ]; then + printf "%s " "Are you sure? Type 'yes' to continue:" read -r CONFIRM [ "$CONFIRM" = "yes" ] || { echo "Aborted."; exit 0; } echo "" +elif (: /dev/null; then + printf "%s " "Are you sure? Type 'yes' to continue:" >/dev/tty + IFS= read -r CONFIRM /dev/null 2>&1 && docker info >/dev/null 2>&1; then + CONTAINERS="$(docker ps -aq --filter 'name=skald-' 2>/dev/null || true)" + if [ -n "$CONTAINERS" ]; then + info "🐳 Removing Skald Docker containers …" + # shellcheck disable=SC2086 # word-splitting is intentional (multiple IDs) + docker rm -f $CONTAINERS >/dev/null 2>&1 || true + fi + IMAGES="$(docker images -q skald-runtime 2>/dev/null || true)" + if [ -n "$IMAGES" ]; then + info "🐳 Removing Skald runtime image …" + # shellcheck disable=SC2086 + docker rmi -f $IMAGES >/dev/null 2>&1 || true + fi +elif command -v docker >/dev/null 2>&1; then + warn "Docker daemon not reachable — skipping container cleanup." + warn "Remove leftovers later with: docker rm -f \$(docker ps -aq --filter name=skald-)" +fi + # ── Remove installation directory ───────────────────────────────────────────── if [ -d "$INSTALL_DIR" ]; then info "🗑️ Removing installation directory …" diff --git a/update.sh b/update.sh index c0adbaa..2c1ec84 100755 --- a/update.sh +++ b/update.sh @@ -8,7 +8,20 @@ # whether to pull from the release or nightly channel. On release, checks # the remote LATEST version first and skips the download if already current. # -# Stops the service before extracting, then restarts it afterwards. +# Robustness notes (why the flow looks the way it does): +# * The tarball is downloaded and validated in a *staging* directory BEFORE +# the running service is touched — a broken download never takes the app +# down. +# * The service is stopped and we WAIT until the process is actually gone +# before extracting: overwriting a live binary in place fails with ETXTBSY +# (Linux) or on a running Mach-O (macOS), which would abort the update with +# the service left down. +# * The whole flow runs inside main(), invoked on the very last line, so the +# shell has parsed the entire script into memory before `tar` overwrites +# update.sh with its own new copy (the tarball ships this script). Without +# this, the shell would read garbage for the tail and never restart. +# * A trap restarts the service if the update fails after the stop, so a +# failed update never leaves the box down. set -eu @@ -28,7 +41,7 @@ if [ ! -f "$CHANNEL_FILE" ]; then exit 1 fi -CHANNEL="$(cat "$CHANNEL_FILE" | tr -d '[:space:]')" +CHANNEL="$(tr -d '[:space:]' < "$CHANNEL_FILE")" # ── Colours (if terminal) ───────────────────────────────────────────────────── if [ -t 1 ]; then @@ -74,46 +87,13 @@ esac command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; } -# ── Determine download URL ──────────────────────────────────────────────────── BASE_URL="https://builds.skaldagent.net" -case "$CHANNEL" in - release) - LATEST="$(curl -fsSL "${BASE_URL}/releases/LATEST" | head -1 | tr -d '[:space:]')" - if [ -z "$LATEST" ]; then - err "Could not fetch latest release version from ${BASE_URL}/releases/LATEST" - exit 1 - fi - - VERSION_FILE="${INSTALL_DIR}/.release-version" - if [ -f "$VERSION_FILE" ]; then - CURRENT="$(cat "$VERSION_FILE" | tr -d '[:space:]')" - if [ "$CURRENT" = "$LATEST" ]; then - info "✔ Already up to date (${CURRENT})." - exit 0 - fi - info "🚀 Update available: ${CURRENT} → ${LATEST}" - else - info "🚀 Installing latest release: ${LATEST}" - fi - - VERSION="$LATEST" - TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz" - DISPLAY_VERSION="$VERSION" - ;; - - nightly) - TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz" - DISPLAY_VERSION="nightly" - info "🚀 Updating to latest nightly build …" - ;; - - *) - err "Unknown channel in ${CHANNEL_FILE}: '${CHANNEL}'" - err "Expected 'release' or 'nightly'." - exit 1 - ;; -esac +# ── Global state (referenced by the EXIT trap) ──────────────────────────────── +TMP_TARBALL="" +STAGING="" +STOPPED=0 # set once the service has been stopped +STARTED=0 # set once it has been (re)started # ── Stop service ────────────────────────────────────────────────────────────── stop_service() { @@ -137,6 +117,31 @@ stop_service() { esac } +# ── Wait until the server process is actually gone ──────────────────────────── +# systemctl stop is synchronous, but launchctl unload kills the process group +# asynchronously — so we poll for the real binary before overwriting it. +wait_until_stopped() { + # Match the server binary by path prefix. Deliberately unanchored: the + # server runs with no args today, but we'd rather over-match (a harmless + # extra wait) than miss a still-running process and race the extraction. + # `skald-setup` only runs at first-run setup, never during an update. + pat="${INSTALL_DIR}/bin/skald" + if command -v pgrep >/dev/null 2>&1; then + i=0 + while pgrep -f "$pat" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -gt 20 ]; then + warn "Server still running after ~20s; proceeding anyway." + return 0 + fi + sleep 1 + done + else + # No pgrep: give the service manager a moment to tear the process down. + sleep 3 + fi +} + # ── Start service ───────────────────────────────────────────────────────────── start_service() { case "$OS" in @@ -155,72 +160,148 @@ start_service() { esac } +# ── Cleanup + safety net ────────────────────────────────────────────────────── +# Runs on every exit. Removes temp files and, if the update died after the +# service was stopped but before it came back up, makes a best-effort restart so +# the box is never left down. +cleanup() { + [ -n "${TMP_TARBALL:-}" ] && rm -f "$TMP_TARBALL" 2>/dev/null || true + [ -n "${STAGING:-}" ] && rm -rf "$STAGING" 2>/dev/null || true + if [ "$STOPPED" = "1" ] && [ "$STARTED" != "1" ]; then + warn "Update failed after the service was stopped — attempting to restart it …" + start_service || true + fi +} +trap cleanup EXIT + # ── Main ────────────────────────────────────────────────────────────────────── -banner "╔══════════════════════════════════════════╗" -banner "║ Skald Circle — Updater (${DISPLAY_VERSION}) ║" -banner "╚══════════════════════════════════════════╝" -echo "" -echo " Channel : ${CHANNEL}" -echo " Platform : ${OS}/${ARCH}" -echo " Install : ${INSTALL_DIR}" -echo "" +main() { + # ── Resolve download URL + version (may exit early if already current) ───── + case "$CHANNEL" in + release) + LATEST="$(curl -fsSL "${BASE_URL}/releases/LATEST" | head -1 | tr -d '[:space:]')" + if [ -z "$LATEST" ]; then + err "Could not fetch latest release version from ${BASE_URL}/releases/LATEST" + exit 1 + fi -stop_service + VERSION_FILE="${INSTALL_DIR}/.release-version" + if [ -f "$VERSION_FILE" ]; then + CURRENT="$(tr -d '[:space:]' < "$VERSION_FILE")" + if [ "$CURRENT" = "$LATEST" ]; then + info "✔ Already up to date (${CURRENT})." + exit 0 + fi + info "🚀 Update available: ${CURRENT} → ${LATEST}" + else + info "🚀 Installing latest release: ${LATEST}" + fi -# Download & extract -TMP_TARBALL="$(mktemp -t skald-update.XXXXXX.tar.gz)" -trap 'rm -f "$TMP_TARBALL"' EXIT + VERSION="$LATEST" + TARBALL_URL="${BASE_URL}/releases/${VERSION}/skald-circle-${VERSION}-${OS}-${ARCH}.tar.gz" + DISPLAY_VERSION="$VERSION" + ;; -info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …" -curl -fsSL -o "$TMP_TARBALL" "$TARBALL_URL" + nightly) + TARBALL_URL="${BASE_URL}/nightly/skald-circle-nightly-${OS}-${ARCH}.tar.gz" + DISPLAY_VERSION="nightly" + info "🚀 Updating to latest nightly build …" + ;; -info "📦 Extracting …" -tar xzf "$TMP_TARBALL" -C "$INSTALL_DIR" --strip-components=1 + *) + err "Unknown channel in ${CHANNEL_FILE}: '${CHANNEL}'" + err "Expected 'release' or 'nightly'." + exit 1 + ;; + esac -if [ ! -x "$INSTALL_DIR/bin/skald" ]; then - err "Extraction failed — skald binary not found." - exit 1 -fi - -# Update version file for release channel -if [ "$CHANNEL" = "release" ]; then - echo "$VERSION" > "$INSTALL_DIR/.release-version" -fi - -# Rebuild Python venv (best effort — new deps may have appeared) -info "🔧 Rebuilding Python virtual environment …" -VENV_DIR="${INSTALL_DIR}/.venv" -REQUIREMENTS="${INSTALL_DIR}/requirements.txt" - -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 — Python MCP servers 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 — Python MCP servers will be unavailable." -else - warn "python3 not found — Python MCP servers will be unavailable." -fi - -start_service - -# ── Hint about optional deps ────────────────────────────────────────────────── -if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then - 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" + banner "╔══════════════════════════════════════════╗" + banner "║ Skald Circle — Updater (${DISPLAY_VERSION}) ║" + banner "╚══════════════════════════════════════════╝" + echo "" + echo " Channel : ${CHANNEL}" + echo " Platform : ${OS}/${ARCH}" + echo " Install : ${INSTALL_DIR}" echo "" -fi -# ── Done ────────────────────────────────────────────────────────────────────── -echo "" -info "✅ Skald Circle updated to ${DISPLAY_VERSION}!" -echo "" -echo " Status: $( [ "$OS" = "linux" ] && echo "systemctl --user status skald-circle" || echo "launchctl list com.skald.circle" )" -echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )" -echo " Update: ${INSTALL_DIR}/update.sh" -echo "" + # ── Download + validate BEFORE touching the running service ──────────────── + # A broken download or a bad archive must never take the app down: we only + # stop the service once we hold a known-good tarball. + TMP_TARBALL="$(mktemp -t skald-update.XXXXXX.tar.gz)" + + info "↓ Downloading Skald Circle (${DISPLAY_VERSION}) …" + curl -fsSL -o "$TMP_TARBALL" "$TARBALL_URL" + + info "🔎 Verifying archive …" + STAGING="$(mktemp -d -t skald-update-staging.XXXXXX)" + tar xzf "$TMP_TARBALL" -C "$STAGING" --strip-components=1 + if [ ! -x "$STAGING/bin/skald" ]; then + err "Downloaded archive is invalid — skald binary not found." + err "The running server was left untouched." + exit 1 + fi + + # ── Stop the service and wait for the process to actually exit ───────────── + stop_service + STOPPED=1 + wait_until_stopped + + # ── Install (binary is no longer busy) ───────────────────────────────────── + info "📦 Installing update …" + tar xzf "$TMP_TARBALL" -C "$INSTALL_DIR" --strip-components=1 + + if [ ! -x "$INSTALL_DIR/bin/skald" ]; then + err "Extraction failed — skald binary not found." + exit 1 + fi + + # Update version file for release channel + if [ "$CHANNEL" = "release" ]; then + echo "$VERSION" > "$INSTALL_DIR/.release-version" + fi + + # ── Rebuild Python venv (best effort — new deps may have appeared) ───────── + info "🔧 Rebuilding Python virtual environment …" + VENV_DIR="${INSTALL_DIR}/.venv" + REQUIREMENTS="${INSTALL_DIR}/requirements.txt" + + 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 — Python MCP servers 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 — Python MCP servers will be unavailable." + else + warn "python3 not found — Python MCP servers will be unavailable." + fi + + # ── Restart ──────────────────────────────────────────────────────────────── + start_service + STARTED=1 + + # ── Hint about optional deps ─────────────────────────────────────────────── + if [ -f "${INSTALL_DIR}/requirements-optional.txt" ]; then + 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" + echo "" + fi + + # ── Done ─────────────────────────────────────────────────────────────────── + echo "" + info "✅ Skald Circle updated to ${DISPLAY_VERSION}!" + echo "" + echo " Status: $( [ "$OS" = "linux" ] && echo "systemctl --user status skald-circle" || echo "launchctl list com.skald.circle" )" + echo " Logs: $( [ "$OS" = "linux" ] && echo "journalctl --user -u skald-circle -f" || echo "tail -f ${INSTALL_DIR}/logs/stdout.log" )" + echo " Update: ${INSTALL_DIR}/update.sh" + echo "" +} + +# Invoked on the very last line: by the time `tar` overwrites this file on disk +# (the tarball ships update.sh), the shell has already parsed the whole script, +# so the restart tail always runs. Do not add executable code below this line. +main "$@"