Nightly Build / build (push) Successful in 7m50s
Four things found while re-reading the family of scripts around the logout fix. Both installers piped curl straight into tar, so a truncated download half-extracted — and the installer explicitly supports reinstalling over an existing install, which turned an interrupted download into a tree mixing old and new files with no error saying so. They now download to a temp file and verify the archive in a staging dir before writing anything to the install directory: the ordering update.sh has had since it was written, for the same reason. update.sh never removed files deleted upstream. Extracting over the install dir only adds and overwrites, so a renamed page under docs/ kept being mounted read-only into every container for the assistant to read, and a removed command kept being discovered. It now prunes, from the directories the tarball owns end to end (web, commands, skills, docs), whatever the already-verified staging copy does not have. Pruning after the extraction rather than replacing the directory keeps every intermediate state a complete install. agents/ is deliberately excluded: dropping in an agent is a documented extension point, so that directory is not ours alone and pruning it would delete somebody's work. uninstall.sh fed `docker ps -aq --filter 'name=skald-'` to `docker rm -f`. Docker's name filter is a regex matched anywhere in the name, not a prefix, so any unrelated container merely containing "skald-" was force-removed. Anchored to ^skald-. uninstall.sh also matched uname's raw Linux/Darwin while its three siblings normalize to lowercase. It was correct on its own, but being the odd one out of four copy-paste relatives is precisely how update.sh acquired its no-op case arms, so it now normalizes like the others. Finally, the uninstaller reports that lingering is still enabled and how to turn it off, rather than disabling it: it is a persistent per-user setting other user services may rely on by now, so taking it back silently would stop those too.
375 lines
17 KiB
Bash
Executable File
375 lines
17 KiB
Bash
Executable File
#!/usr/bin/env sh
|
|
# update.sh — update Skald Circle to the latest version on the same channel
|
|
#
|
|
# Usage:
|
|
# ~/.local/share/skald-circle/update.sh
|
|
#
|
|
# Reads the .release-channel file written by the installer to determine
|
|
# whether to pull from the release or nightly channel. On release, checks
|
|
# the remote LATEST version first and skips the download if already current.
|
|
#
|
|
# 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
|
|
|
|
# ── Determine install directory ───────────────────────────────────────────────
|
|
if [ -n "${SKALD_DIR:-}" ]; then
|
|
INSTALL_DIR="$SKALD_DIR"
|
|
else
|
|
INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
fi
|
|
|
|
CHANNEL_FILE="${INSTALL_DIR}/.release-channel"
|
|
if [ ! -f "$CHANNEL_FILE" ]; then
|
|
echo "✖ .release-channel not found in ${INSTALL_DIR}" >&2
|
|
echo " This installation was not created by an installer or is too old." >&2
|
|
echo " Please reinstall with:" >&2
|
|
echo " curl -fsSL https://builds.skaldagent.net/install.sh | bash" >&2
|
|
exit 1
|
|
fi
|
|
|
|
CHANNEL="$(tr -d '[:space:]' < "$CHANNEL_FILE")"
|
|
|
|
# ── 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" "$*"; }
|
|
|
|
# ── 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
|
|
|
|
command -v curl >/dev/null 2>&1 || { err "curl is required but not installed."; exit 1; }
|
|
|
|
BASE_URL="https://builds.skaldagent.net"
|
|
|
|
# ── 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 ──────────────────────────────────────────────────────────────
|
|
# NOTE: match the *normalized* OS values set above (linux/darwin), not uname's
|
|
# capitalized output. Getting this wrong turns both stop_service and
|
|
# start_service into silent no-ops, and then none of the ordering this file
|
|
# documents at the top actually happens: the tarball is extracted over the
|
|
# running binary (ETXTBSY on Linux, aborting the update mid-way), and the
|
|
# safety-net restart in cleanup() is a no-op too, so the box stays down.
|
|
stop_service() {
|
|
case "$OS" in
|
|
linux)
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
if systemctl --user is-active skald-circle.service >/dev/null 2>&1; then
|
|
info "⏹️ Stopping service …"
|
|
systemctl --user stop skald-circle.service
|
|
fi
|
|
fi
|
|
;;
|
|
darwin)
|
|
if command -v launchctl >/dev/null 2>&1; then
|
|
if launchctl list com.skald.circle >/dev/null 2>&1; then
|
|
info "⏹️ Stopping agent …"
|
|
launchctl unload "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
;;
|
|
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
|
|
linux)
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
info "▶ Starting service …"
|
|
systemctl --user start skald-circle.service
|
|
fi
|
|
;;
|
|
darwin)
|
|
if command -v launchctl >/dev/null 2>&1; then
|
|
info "▶ Starting agent …"
|
|
launchctl load "$HOME/Library/LaunchAgents/com.skald.circle.plist" 2>/dev/null || true
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ── systemd user lingering ────────────────────────────────────────────────────
|
|
# Same helper the installers run, repeated here so an install predating it gets
|
|
# healed by an ordinary update: a `systemctl --user` unit lives under the
|
|
# per-user manager, which systemd stops when the user's last session ends —
|
|
# so without lingering the server dies at logout and never starts at boot.
|
|
# Idempotent, and a failure is only ever a warning: the update itself is fine.
|
|
ensure_linger() {
|
|
[ "$OS" = "linux" ] || return 0
|
|
|
|
local target="${USER:-$(id -un)}"
|
|
|
|
command -v loginctl >/dev/null 2>&1 || return 0
|
|
|
|
case "$(loginctl show-user "$target" --property=Linger 2>/dev/null || true)" in
|
|
*=yes) return 0 ;;
|
|
esac
|
|
|
|
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 now survives logout and starts at boot"
|
|
else
|
|
warn "Lingering is not enabled for ${target}."
|
|
echo " The server stops when your last session ends. Run this once:"
|
|
echo " sudo loginctl enable-linger ${target}"
|
|
fi
|
|
}
|
|
|
|
# ── 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 ──────────────────────────────────────────────────────────────────────
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
banner "╔══════════════════════════════════════════╗"
|
|
banner "║ Skald Circle — Updater (${DISPLAY_VERSION}) ║"
|
|
banner "╚══════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " Channel : ${CHANNEL}"
|
|
echo " Platform : ${OS}/${ARCH}"
|
|
echo " Install : ${INSTALL_DIR}"
|
|
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
|
|
|
|
# ── Drop files the new build no longer ships ───────────────────────────────
|
|
# Extracting over the install dir only ever adds and overwrites, so anything
|
|
# deleted upstream survives forever: a renamed doc page keeps being mounted
|
|
# read-only into every container for the assistant to read, a removed command
|
|
# keeps being discovered. For the directories the tarball owns end to end we
|
|
# therefore prune whatever the (already verified) staging copy does not have.
|
|
#
|
|
# Pruned AFTER extracting rather than by replacing the directory, so every
|
|
# intermediate state is a complete install and the only files removed are
|
|
# ones the new build has verifiably dropped.
|
|
#
|
|
# agents/ is deliberately not in this list: adding an agent is a documented
|
|
# extension point (agents/<id>/meta.json + AGENT.md), so the directory is not
|
|
# ours alone and pruning it would delete somebody's work — at the price of an
|
|
# upstream-deleted agent lingering. bin/ is out too: two files, both
|
|
# overwritten every time, nothing to reclaim.
|
|
for owned in web commands skills docs; do
|
|
[ -d "$STAGING/$owned" ] && [ -d "${INSTALL_DIR}/$owned" ] || continue
|
|
( cd "${INSTALL_DIR}/$owned" && find . -type f ) | while IFS= read -r rel; do
|
|
rel="${rel#./}"
|
|
[ -e "${STAGING}/${owned}/${rel}" ] || rm -f "${INSTALL_DIR}/${owned}/${rel}"
|
|
done
|
|
find "${INSTALL_DIR}/$owned" -mindepth 1 -type d -empty -delete 2>/dev/null || true
|
|
done
|
|
|
|
# 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 — 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
|
|
|
|
# ── Restart ────────────────────────────────────────────────────────────────
|
|
ensure_linger
|
|
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 "$@"
|