openvibers/openvibe.live Commits

deploy/scripts/deploy.sh hands over to ovhost deploy live (OpenVibe.Host strategy release-layout; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. The wrapper maps --wait-idle, --restart, --rollback (ovhost rollback live), DRY_RUN=1 (ovhost plan live) and READY_TIMEOUT, and refuses --force, which meant 'discard local tracked changes' here but drops live streams in ovhost. It asks ovhost capabilities live first and runs deploy-legacy.sh with the same arguments, saying why, when ovhost is missing, too old (no capabilities, deploy-api < 1) or the host inventory does not deploy live with release-layout (OVHOST_LEGACY=1 forces it), so a deploy never breaks while the host is cut over. test/deploy-wrapper.test.js covers the mapping and the fallback; test/deploy-sim.test.js now runs deploy-legacy.sh; test/systemd-units.test.js keeps the listener check and the changed-socket restart assertions on deploy-legacy.sh and checks the wrapper only hands over to release-layout, whose socket rule OpenVibe.Host tests. docs/deploy.md, deploy/README.md, README.md.

OP OpenVibers committed 27 Sept 2026 · f0edbc0384 · 8 files, +707 −473


Changes

ddiff --git a/README.md b/README.md
iindex 3c6ef387..e1068966 100644
---- a/README.md
++++ b/README.md
@@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
## Deployment
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
--- `deploy/scripts/deploy.sh` classifies changes: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).
++- `deploy/scripts/deploy.sh` runs `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`), with `deploy-legacy.sh` (the previous script) as its fallback: static-only deploys never restart; server changes restart behind the systemd socket with readiness checks and rollback. Details: [docs/deploy.md](docs/deploy.md).
- nginx config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
---
ddiff --git a/deploy/README.md b/deploy/README.md
iindex 2a0bcf8a..be07a064 100644
---- a/deploy/README.md
++++ b/deploy/README.md
@@@ -2,7 +2,8 @@
How deploys work: [../docs/deploy.md](../docs/deploy.md).
--- `scripts/deploy.sh` — classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing; `--wait-idle`, `--rollback`, `DRY_RUN=1`
++- `scripts/deploy.sh` — hands over to `ovhost deploy live` (OpenVibe.Host, strategy `release-layout`): `--wait-idle`, `--restart`, `--rollback` (`ovhost rollback live`), `DRY_RUN=1` (`ovhost plan live`); falls back to `deploy-legacy.sh` when ovhost is missing, too old or does not manage Live
++- `scripts/deploy-legacy.sh` — the previous deploy script, unchanged: classifies a change (static / server / dependencies / schema / units) and does the least disruptive thing
- `scripts/migrate-to-releases.sh` — one-time move to the release layout (`releases/`, `current`, `shared/data`)
- `scripts/post-deploy-check.sh` — smoke checks on the host
- `systemd/openvibe-live.service` + `.socket` — current (git checkout) layout, socket activation
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 00000000..030b2866
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,478 @@
++#!/usr/bin/env bash
++# ═══════════════════════════════════════════════════════════════
++# OpenVibe.Live — deploy
++#
++# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.
++#
++# sudo deploy/scripts/deploy.sh deploy origin/main if it moved
++# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)
++# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed
++# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release
++# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes
++# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing
++#
++# What a change costs (see docs/deploy.md):
++# docs/, README etc., tests no process restart; the new files are just in place
++# public/ no restart — the server re-reads HTML and assets (content-hashed)
++# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live
++# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect
++# package-lock.json dependencies installed BEFORE anything is interrupted
++# deploy/systemd/ units installed, daemon-reload, restart
++# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
++# the script says so
++#
++# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
++# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
++#
++# Two layouts:
++# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
++# shared/data. A deploy builds a new release while the old one serves; switching is an
++# atomic symlink rename; rollback switches back to a release that still has its own
++# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.
++# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).
++#
++# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
++# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle
++# ═══════════════════════════════════════════════════════════════
++set -euo pipefail
++
++FORCE_DEPLOY=false
++ALWAYS_RESTART=false
++WAIT_IDLE=false
++ROLLBACK=false
++while [ "$#" -gt 0 ]; do
++ case "$1" in
++ --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
++ --restart) ALWAYS_RESTART=true; shift ;;
++ --wait-idle) WAIT_IDLE=true; shift ;;
++ --rollback) ROLLBACK=true; shift ;;
++ --) shift; break ;;
++ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;
++ esac
++done
++
++BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
++SERVICE="${SERVICE:-openvibe-live}"
++SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
++SYSTEMCTL="${SYSTEMCTL:-systemctl}"
++SS="${SS:-ss}"
++SOCKET_CHANGED=false
++# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
++SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
++SITE_URL="${SITE_URL:-https://openvibe.live}"
++API_URL="${API_URL:-http://127.0.0.1:3000}"
++GIT_REMOTE="${GIT_REMOTE:-origin}"
++GIT_BRANCH="${GIT_BRANCH:-main}"
++READY_PATH="${READY_PATH:-/api/ready}"
++UPDATES_PATH="${UPDATES_PATH:-/updates}"
++BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
++READY_TIMEOUT="${READY_TIMEOUT:-90}"
++IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"
++KEEP_RELEASES="${KEEP_RELEASES:-5}"
++NPM="${NPM:-npm}"
++DRY_RUN="${DRY_RUN:-0}"
++# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.
++LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"
++
++say() { echo "[Deploy] $*"; }
++die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }
++run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
++
++exec 9>"$LOCK_FILE"
++flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
++
++if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi
++
++echo "╔══════════════════════════════════════╗"
++echo "║ OpenVibe.Live — deploy ║"
++echo "╚══════════════════════════════════════╝"
++say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
++
++# ── Classification ───────────────────────────────────────────────────────────
++# Prints one word per category that the changed file list touches.
++classify() {
++ local files="$1" cats=""
++ add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }
++ while IFS= read -r f; do
++ [ -n "$f" ] || continue
++ case "$f" in
++ package.json|package-lock.json) add deps; add server ;;
++ server/db/migrations.js|server/db/schema.sql) add schema; add server ;;
++ server/*|vendor/*|.env.example) add server ;;
++ public/*) add static ;;
++ deploy/systemd/*) add systemd ;;
++ deploy/nginx/*) add nginx ;;
++ docs/*) add static ;;
++ *) add none ;;
++ esac
++ done <<< "$files"
++ echo "$cats"
++}
++has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
++
++# Whether package.json changed in a way that needs an install (scripts/description edits do not).
++deps_changed() {
++ local from="$1" to="$2" dir="$3"
++ git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0
++ local a b
++ a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')
++ b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')
++ [ "$a" != "$b" ]
++}
++
++# ── Live-stream guard ────────────────────────────────────────────────────────
++# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.
++live_count() {
++ curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \
++ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \
++ || echo 0
++}
++wait_for_idle() {
++ [ "$WAIT_IDLE" = true ] || return 0
++ local waited=0 quiet=0
++ while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do
++ local n; n=$(live_count)
++ if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi
++ [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }
++ say "$n live stream(s); holding the restart (${waited}s waited)"
++ [ "$DRY_RUN" = "1" ] && return 0
++ sleep 60; waited=$((waited+60))
++ done
++ die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5
++}
++
++wait_ready() {
++ local deadline=$(( $(date +%s) + READY_TIMEOUT ))
++ while [ "$(date +%s)" -lt "$deadline" ]; do
++ if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
++ $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
++ sleep 1; printf '.'
++ done
++ printf '\n'; return 1
++}
++
++notify_chat() {
++ local summary="$1"
++ curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
++ -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
++ -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
++ >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
++}
++
++install_units() {
++ local unit_dir="$1" changed=false
++ install_unit() {
++ local src="$1" dest="$2"
++ [ -f "$src" ] || return 0
++ if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
++ say "installing $(basename "$dest")"
++ run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"
++ changed=true
++ fi
++ }
++ install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
++ local socket_before=false
++ [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
++ install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
++ # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
++ # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
++ # restarts the socket itself. Left alone, the service binds the port on its own and every later
++ # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
++ [ "$socket_before" = true ] && SOCKET_CHANGED=true
++ install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
++ if [ "$changed" = true ]; then
++ run $SYSTEMCTL daemon-reload
++ run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true
++ run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true
++ fi
++}
++
++syntax_check() {
++ local dir="$1" files="$2" checked=0
++ while IFS= read -r f; do
++ case "$f" in
++ *.js) [ -f "$dir/$f" ] || continue
++ node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2
++ checked=$((checked+1)) ;;
++ *.json) [ -f "$dir/$f" ] || continue
++ node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;
++ esac
++ done <<< "$files"
++ say "syntax-checked $checked changed JS file(s)"
++}
++
++backup_db() {
++ # A schema migration is about to run on restart: take a consistent online copy first.
++ local db="$1"
++ [ -f "$db" ] || { say "no database at $db to back up"; return 0; }
++ local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"
++ run mkdir -p "$(dirname "$out")"
++ say "schema changed — backing up $(basename "$db") to $out"
++ if [ "$DRY_RUN" != "1" ]; then
++ (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \
++ || die "database backup failed; nothing was restarted" 2
++ fi
++}
++
++# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
++# descriptor was dropped still reads active while the service binds the port itself.
++socket_held() {
++ local listen port
++ listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
++ port=${listen##*:}
++ [ -n "$port" ] || return 1
++ $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
++}
++
++restart_and_verify() {
++ local on_fail="$1" socketed=false
++ if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
++ socketed=true
++ $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
++ fi
++ local start; start=$(date +%s)
++ if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
++ # The socket must be rebound: stop the service (it may hold the port itself), restart the
++ # socket, start the service on it. HTTP is refused for that moment, once.
++ say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
++ $SYSTEMCTL stop "$SERVICE" 9>&- || true
++ $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
++ $SYSTEMCTL start "$SERVICE" 9>&-
++ SOCKET_CHANGED=false
++ else
++ [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
++ say "restarting ${SERVICE}…"
++ $SYSTEMCTL restart "$SERVICE" 9>&-
++ fi
++ printf '[Deploy] waiting for readiness'
++ if wait_ready; then
++ printf '\n'; say "ready after $(( $(date +%s) - start ))s"
++ if [ "$socketed" = true ] && ! socket_held; then
++ say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
++ fi
++ return 0
++ fi
++ say "✗ not ready within ${READY_TIMEOUT}s"
++ journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true
++ $on_fail
++}
++
++# ── Release notification (WS-P task 9) ───────────────────────────────────────
++# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now
++# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
++# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
++# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
++OVHOST="${OVHOST:-ovhost}"
++announce_release() {
++ [ "$DRY_RUN" = "1" ] && return 0
++ command -v "$OVHOST" >/dev/null 2>&1 || return 0
++ local help
++ help=$("$OVHOST" --help 2>/dev/null || true)
++ case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
++ timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
++}
++
++# ═══════════════════════════════════════════════════════════════
++# Release layout
++# ═══════════════════════════════════════════════════════════════
++if [ "$LAYOUT" = release ]; then
++ REPO="$BASE_DIR/repo"
++ CURRENT=$(readlink -f "$BASE_DIR/current")
++ CUR_ID=$(basename "$CURRENT")
++ CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)
++ say "current release: $CUR_ID (${CUR_SHA:0:8})"
++
++ switch_to() {
++ # Atomic: create the new link beside the old one and rename over it.
++ local target="$1"
++ run ln -sfn "$target" "$BASE_DIR/.current.tmp"
++ run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"
++ }
++
++ if [ "$ROLLBACK" = true ]; then
++ PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)
++ [ -n "$PREV" ] || die "no previous release to roll back to"
++ PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)
++ CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")
++ say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"
++ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
++ switch_to "$PREV"
++ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then
++ install_units "$PREV/deploy/systemd/release"
++ wait_for_idle
++ restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'
++ else
++ say "static-only difference — switched without a restart"
++ fi
++ # Keep the release we left so it can be re-selected; just move it out of "newest".
++ touch -h "$PREV"
++ say "rolled back to $(basename "$PREV") ✅"
++ announce_release
++ exit 0
++ fi
++
++ say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
++ run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
++ TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")
++ if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then
++ say "already at ${TARGET_SHA:0:8}; nothing to deploy."
++ exit 0
++ fi
++
++ CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)
++ CATS=$(classify "$CHANGED")
++ [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"
++ COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")
++ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
++ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
++ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
++
++ NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"
++ NEW="$BASE_DIR/releases/$NEW_ID"
++ say "preparing release $NEW_ID while $CUR_ID keeps serving…"
++ run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null
++ [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"
++ run ln -sfn ../../shared/data "$NEW/data"
++
++ if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then
++ say "dependencies changed — installing into the new release (nothing interrupted yet)…"
++ (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }
++ else
++ # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the
++ # new release still owns its own tree (npm never writes into an installed package in place).
++ say "dependencies unchanged — linking node_modules from $CUR_ID"
++ run cp -al "$CURRENT/node_modules" "$NEW/node_modules"
++ fi
++
++ if has "$CATS" nginx; then
++ say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"
++ say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."
++ fi
++
++ [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
++
++ NEEDS_RESTART=false
++ if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
++
++ if [ "$NEEDS_RESTART" = true ]; then
++ wait_for_idle
++ has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"
++ FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
++ notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
++ switch_to "$NEW"
++ install_units "$NEW/deploy/systemd/release"
++ rollback_release() {
++ say "rolling back to $CUR_ID"
++ switch_to "$CURRENT"
++ install_units "$CURRENT/deploy/systemd/release"
++ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
++ printf '[Deploy] waiting for rollback'
++ if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi
++ die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4
++ }
++ restart_and_verify rollback_release
++ say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
++ else
++ switch_to "$NEW"
++ # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and
++ # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.
++ sleep 3
++ curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }
++ say "switched to $NEW_ID without a restart (change classes:${CATS})"
++ fi
++
++ # Prune old releases (never the current one, never the one we just left).
++ ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do
++ [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue
++ [ "$old" = "$CURRENT" ] && continue
++ say "pruning $(basename "$old")"
++ git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"
++ done
++ git -C "$REPO" worktree prune 2>/dev/null || true
++ say "deployed $CUR_ID → $NEW_ID ✅"
++ announce_release
++ exit 0
++fi
++
++# ═══════════════════════════════════════════════════════════════
++# Legacy layout (git checkout in place)
++# ═══════════════════════════════════════════════════════════════
++[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"
++REPO_DIR="$BASE_DIR"
++cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
++OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
++say "current commit: ${OLD_HASH:0:8}"
++
++if [ "$FORCE_DEPLOY" = true ]; then
++ say "force: discarding local tracked changes (untracked files are kept)"
++ run git reset --hard HEAD
++elif ! git diff --quiet || ! git diff --cached --quiet; then
++ die "working tree has uncommitted changes — commit them, or re-run with --force"
++fi
++
++say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
++run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
++TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
++if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
++ say "already at ${TARGET_HASH:0:8}; nothing to deploy."
++ exit 0
++fi
++
++CHANGED=""; CATS=" server"
++if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
++ CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
++ CATS=$(classify "$CHANGED")
++ COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
++ COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
++ say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
++ printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
++else
++ COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
++fi
++
++NEEDS_RESTART=false
++if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
++
++# In place, the old process keeps running against files that are about to change underneath it, so
++# anything that needs a restart waits for idle BEFORE the checkout moves.
++[ "$NEEDS_RESTART" = true ] && wait_for_idle
++
++say "validating the target revision…"
++run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }
++NEW_HASH=$(git rev-parse HEAD)
++[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"
++
++if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then
++ say "dependencies changed — installing (service still running)…"
++ say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."
++ run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
++fi
++has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."
++
++if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi
++
++if [ "$NEEDS_RESTART" != true ]; then
++ say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
++ say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
++ announce_release
++ exit 0
++fi
++
++install_units "$REPO_DIR/deploy/systemd"
++has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"
++FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
++notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
++rollback_legacy() {
++ say "rolling back to ${OLD_HASH:0:8}"
++ git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
++ $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
++ printf '[Deploy] waiting for rollback'
++ if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
++ die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
++}
++restart_and_verify rollback_legacy
++curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3
++say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
++say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
++say "done ✅"
++announce_release
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 030b2866..546f3e49 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,478 +1,92 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
--# OpenVibe.Live — deploy
++# OpenVibe.Live — deploy (a thin wrapper around `ovhost deploy live`)
#
--# Fetch, classify what changed, prepare, and do the least disruptive thing that makes it live.
++# OpenVibe.Host's ovhost (/usr/local/bin/ovhost, strategy "release-layout", roadmap WS-N task 11) now
++# does what this script did: a new release is prepared in releases/<time>-<sha8> while `current` serves
++# (npm ci only when dependencies changed, else node_modules hard-linked; node --check on changed files),
++# `current` switches atomically, public/ and docs/ switch without a restart, the socket unit stays held by
++# systemd (rebound only when its unit file changed or pid 1 does not hold :3000), readiness gates the
++# restart, a release that is not ready is switched back (exit 3), old releases are pruned, and the release
++# is announced. See OpenVibe.Host docs/deploy-strategies.md.
#
--# sudo deploy/scripts/deploy.sh deploy origin/main if it moved
--# sudo deploy/scripts/deploy.sh --wait-idle hold a restart until nobody is live (max 8h)
--# sudo deploy/scripts/deploy.sh --restart restart even if nothing changed
--# sudo deploy/scripts/deploy.sh --rollback release layout: switch back to the previous release
--# sudo deploy/scripts/deploy.sh --force legacy layout: discard local tracked changes
--# DRY_RUN=1 deploy/scripts/deploy.sh print the plan, change nothing
++# sudo deploy/scripts/deploy.sh ovhost deploy live
++# sudo deploy/scripts/deploy.sh --wait-idle ovhost deploy live --wait-idle (hold a restart until nobody is live)
++# sudo deploy/scripts/deploy.sh --restart ovhost deploy live --restart
++# sudo deploy/scripts/deploy.sh --rollback ovhost rollback live (the release the current one replaced)
++# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan live (changes nothing)
++# READY_TIMEOUT=<s> --ready-timeout <s>
#
--# What a change costs (see docs/deploy.md):
--# docs/, README etc., tests no process restart; the new files are just in place
--# public/ no restart — the server re-reads HTML and assets (content-hashed)
--# server/, vendor/, package* restart; HTTP connections queue on the systemd socket, but live
--# WebSocket, WHIP, WebRTC and RTMP sessions are dropped and reconnect
--# package-lock.json dependencies installed BEFORE anything is interrupted
--# deploy/systemd/ units installed, daemon-reload, restart
--# deploy/nginx/ never auto-installed (the live config is generated elsewhere);
--# the script says so
++# --force meant "discard local tracked changes" in the legacy in-place layout. ovhost never discards changes,
++# and its --force drops live streams (and passes a freeze), so the wrapper refuses it: run
++# `sudo ovhost deploy live --force` yourself when that is what you mean.
#
--# After a deploy or rollback that went live: `ovhost announce live` (release notification to open tabs;
--# best effort, skipped without an ovhost that has `announce`, never changes the exit code).
++# Fallback: deploy-legacy.sh (the previous script, unchanged) runs instead, with the same arguments, when
++# ovhost is missing or too old (no `capabilities`, deploy-api < 1), or the host inventory does not deploy
++# live with strategy release-layout. OVHOST_LEGACY=1 forces it; OVHOST=<path> picks another ovhost.
#
--# Two layouts:
--# release /opt/openvibe.live/releases/<id> (code + node_modules), current -> releases/<id>,
--# shared/data. A deploy builds a new release while the old one serves; switching is an
--# atomic symlink rename; rollback switches back to a release that still has its own
--# node_modules. Set up once with deploy/scripts/migrate-to-releases.sh.
--# legacy /opt/openvibe.live is a git checkout (what production runs until migrated).
--#
--# Exit codes: 0 success · 1 usage/precondition · 2 validation failed · 3 not ready after restart
--# (rolled back) · 4 rollback itself failed — MANUAL INTERVENTION · 5 gave up waiting for idle
++# Exit codes are ovhost's, the same as before: 0 ok · 1 usage/precondition · 2 validation failed, nothing
++# restarted · 3 not ready, rolled back · 4 rollback failed (manual intervention) · 5 protected sessions ·
++# 6 frozen (ovhost freeze).
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
--FORCE_DEPLOY=false
--ALWAYS_RESTART=false
--WAIT_IDLE=false
--ROLLBACK=false
++SERVICE=live
++STRATEGY=release-layout
++HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
++LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
++OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
++# ovhost runs as root; tests set OVHOST_SUDO= to call it directly.
++if [ "${OVHOST_SUDO-auto}" = auto ]; then if [ "$(id -u)" -eq 0 ]; then SUDO=(); else SUDO=(sudo); fi; elif [ -n "${OVHOST_SUDO}" ]; then SUDO=("$OVHOST_SUDO"); else SUDO=(); fi
++
++say() { echo "[Deploy] $*"; }
++
++ORIG=("$@")
++CMD=deploy
++FLAGS=()
++FORCE=false
while [ "$#" -gt 0 ]; do
case "$1" in
-- --force|-f|--ignore-local-changes) FORCE_DEPLOY=true; shift ;;
-- --restart) ALWAYS_RESTART=true; shift ;;
-- --wait-idle) WAIT_IDLE=true; shift ;;
-- --rollback) ROLLBACK=true; shift ;;
++ --wait-idle) FLAGS+=(--wait-idle); shift ;;
++ --restart) FLAGS+=(--restart); shift ;;
++ --rollback) CMD=rollback; shift ;;
++ --force|-f|--ignore-local-changes) FORCE=true; shift ;;
--) shift; break ;;
-- *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] [--force]"; exit 1 ;;
++ *) echo "Usage: $0 [--wait-idle] [--restart] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;
esac
done
++[ -n "${READY_TIMEOUT:-}" ] && FLAGS+=(--ready-timeout "$READY_TIMEOUT")
--BASE_DIR="${BASE_DIR:-/opt/openvibe.live}"
--SERVICE="${SERVICE:-openvibe-live}"
--SYSTEMD_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
--SYSTEMCTL="${SYSTEMCTL:-systemctl}"
--SS="${SS:-ss}"
--SOCKET_CHANGED=false
--# Unit files land in /etc: when systemctl is being run through sudo, so is the install.
--SUDO_INSTALL=""; case "$SYSTEMCTL" in sudo*) SUDO_INSTALL="sudo";; esac; [ "$(id -u)" -eq 0 ] && SUDO_INSTALL=""
--SITE_URL="${SITE_URL:-https://openvibe.live}"
--API_URL="${API_URL:-http://127.0.0.1:3000}"
--GIT_REMOTE="${GIT_REMOTE:-origin}"
--GIT_BRANCH="${GIT_BRANCH:-main}"
--READY_PATH="${READY_PATH:-/api/ready}"
--UPDATES_PATH="${UPDATES_PATH:-/updates}"
--BROADCAST_ENDPOINT="${BROADCAST_ENDPOINT:-/api/admin/broadcast}"
--READY_TIMEOUT="${READY_TIMEOUT:-90}"
--IDLE_MAX_SECONDS="${IDLE_MAX_SECONDS:-28800}"
--KEEP_RELEASES="${KEEP_RELEASES:-5}"
--NPM="${NPM:-npm}"
--DRY_RUN="${DRY_RUN:-0}"
--# Not /tmp: fs.protected_regular stops root from reopening a lock a plain user created there.
--LOCK_FILE="${LOCK_FILE:-$BASE_DIR/.deploy.lock}"
--
--say() { echo "[Deploy] $*"; }
--die() { echo "[Deploy] ✗ $1" >&2; exit "${2:-1}"; }
--run() { if [ "$DRY_RUN" = "1" ]; then echo " would run: $*"; else "$@"; fi; }
--
--exec 9>"$LOCK_FILE"
--flock -n 9 || die "another deploy is already running (lock: $LOCK_FILE)"
--
--if [ -d "$BASE_DIR/releases" ] && [ -L "$BASE_DIR/current" ]; then LAYOUT=release; else LAYOUT=legacy; fi
--
--echo "╔══════════════════════════════════════╗"
--echo "║ OpenVibe.Live — deploy ║"
--echo "╚══════════════════════════════════════╝"
--say "base=$BASE_DIR layout=$LAYOUT service=$SERVICE branch=$GIT_BRANCH dry_run=$DRY_RUN"
--
--# ── Classification ───────────────────────────────────────────────────────────
--# Prints one word per category that the changed file list touches.
--classify() {
-- local files="$1" cats=""
-- add() { case " $cats " in *" $1 "*) ;; *) cats="$cats $1" ;; esac; }
-- while IFS= read -r f; do
-- [ -n "$f" ] || continue
-- case "$f" in
-- package.json|package-lock.json) add deps; add server ;;
-- server/db/migrations.js|server/db/schema.sql) add schema; add server ;;
-- server/*|vendor/*|.env.example) add server ;;
-- public/*) add static ;;
-- deploy/systemd/*) add systemd ;;
-- deploy/nginx/*) add nginx ;;
-- docs/*) add static ;;
-- *) add none ;;
-- esac
-- done <<< "$files"
-- echo "$cats"
--}
--has() { case " $1 " in *" $2 "*) return 0 ;; *) return 1 ;; esac; }
--
--# Whether package.json changed in a way that needs an install (scripts/description edits do not).
--deps_changed() {
-- local from="$1" to="$2" dir="$3"
-- git -C "$dir" diff --quiet "$from" "$to" -- package-lock.json || return 0
-- local a b
-- a=$(git -C "$dir" show "$from:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("x")}})')
-- b=$(git -C "$dir" show "$to:package.json" 2>/dev/null | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log(JSON.stringify([j.dependencies,j.optionalDependencies,j.engines]))}catch{console.log("y")}})')
-- [ "$a" != "$b" ]
--}
--
--# ── Live-stream guard ────────────────────────────────────────────────────────
--# A restart drops every RTMP/WHIP/WebRTC publisher. Socket activation keeps HTTP accepting, nothing more.
--live_count() {
-- curl -sf --max-time 5 "${API_URL}/api/streams" 2>/dev/null \
-- | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);console.log((j.streams||[]).filter(s=>s.is_live).length)}catch{console.log(0)}})' \
-- || echo 0
--}
--wait_for_idle() {
-- [ "$WAIT_IDLE" = true ] || return 0
-- local waited=0 quiet=0
-- while [ "$waited" -lt "$IDLE_MAX_SECONDS" ]; do
-- local n; n=$(live_count)
-- if [ "$n" = "0" ]; then quiet=$((quiet+1)); else quiet=0; fi
-- [ "$quiet" -ge 2 ] && { say "no one live for two checks — proceeding"; return 0; }
-- say "$n live stream(s); holding the restart (${waited}s waited)"
-- [ "$DRY_RUN" = "1" ] && return 0
-- sleep 60; waited=$((waited+60))
-- done
-- die "still live after ${IDLE_MAX_SECONDS}s; nothing was restarted" 5
--}
--
--wait_ready() {
-- local deadline=$(( $(date +%s) + READY_TIMEOUT ))
-- while [ "$(date +%s)" -lt "$deadline" ]; do
-- if curl -sf --max-time 5 "${API_URL}${READY_PATH}" >/dev/null 2>&1; then return 0; fi
-- $SYSTEMCTL is-active --quiet "$SERVICE" || { say "service is not running"; return 1; }
-- sleep 1; printf '.'
-- done
-- printf '\n'; return 1
--}
--
--notify_chat() {
-- local summary="$1"
-- curl -sf -X POST "${API_URL}${BROADCAST_ENDPOINT}" \
-- -H "Content-Type: application/json" -H "Authorization: Bearer ${ADMIN_TOKEN:-}" \
-- -d "{\"type\":\"update\",\"summary\":$(printf '%s' "$summary" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify(d.trim())))'),\"url\":\"${SITE_URL}${UPDATES_PATH}\"}" \
-- >/dev/null 2>&1 && say "chat notified" || say "chat notification skipped"
++legacy() {
++ say "$1 — running deploy-legacy.sh (the previous deploy script) instead"
++ exec bash "$LEGACY" "${ORIG[@]}"
}
--install_units() {
-- local unit_dir="$1" changed=false
-- install_unit() {
-- local src="$1" dest="$2"
-- [ -f "$src" ] || return 0
-- if [ ! -f "$dest" ] || ! cmp -s "$src" "$dest"; then
-- say "installing $(basename "$dest")"
-- run $SUDO_INSTALL install -m 0644 -D "$src" "$dest"
-- changed=true
-- fi
-- }
-- install_unit "$unit_dir/${SERVICE}.service" "$SYSTEMD_DIR/${SERVICE}.service"
-- local socket_before=false
-- [ -f "$SYSTEMD_DIR/${SERVICE}.socket" ] && [ -f "$unit_dir/${SERVICE}.socket" ] && ! cmp -s "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket" && socket_before=true
-- install_unit "$unit_dir/${SERVICE}.socket" "$SYSTEMD_DIR/${SERVICE}.socket"
-- # A socket unit changed under a running socket: after daemon-reload systemd may drop its descriptor
-- # ("no socket file descriptors are open ... not functional until restarted"); restart_and_verify then
-- # restarts the socket itself. Left alone, the service binds the port on its own and every later
-- # restart refuses connections (what happened from 2026-09-25 to 2026-09-26).
-- [ "$socket_before" = true ] && SOCKET_CHANGED=true
-- install_unit "$unit_dir/${SERVICE}.service.d/socket.conf" "$SYSTEMD_DIR/${SERVICE}.service.d/socket.conf"
-- if [ "$changed" = true ]; then
-- run $SYSTEMCTL daemon-reload
-- run $SYSTEMCTL enable "${SERVICE}.socket" >/dev/null 2>&1 || true
-- run $SYSTEMCTL enable "$SERVICE" >/dev/null 2>&1 || true
-- fi
++# Does this ovhost deploy live with the strategy this wrapper hands over to? Sets REASON when not.
++REASON=""
++probe() {
++ if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi
++ if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
++ local caps api
++ if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi
++ api=$(printf '%s\n' "$caps" | sed -n 's/^deploy-api=//p')
++ case "$api" in ''|*[!0-9]*) REASON="this ovhost reports no deploy-api (too old)"; return 1 ;; esac
++ if [ "$api" -lt 1 ]; then REASON="this ovhost's deploy-api is $api, 1 is needed"; return 1; fi
++ if ! printf '%s\n' "$caps" | grep -qx "strategy=$STRATEGY"; then REASON="the host inventory does not deploy $SERVICE with strategy $STRATEGY ($(printf '%s\n' "$caps" | sed -n 's/^strategy=//p'))"; return 1; fi
++ if ! printf '%s\n' "$caps" | grep -qx "managed=yes"; then REASON="ovhost does not manage $SERVICE"; return 1; fi
++ return 0
}
--syntax_check() {
-- local dir="$1" files="$2" checked=0
-- while IFS= read -r f; do
-- case "$f" in
-- *.js) [ -f "$dir/$f" ] || continue
-- node --check "$dir/$f" >/dev/null 2>&1 || die "syntax error in $f" 2
-- checked=$((checked+1)) ;;
-- *.json) [ -f "$dir/$f" ] || continue
-- node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$dir/$f" 2>/dev/null || die "invalid JSON in $f" 2 ;;
-- esac
-- done <<< "$files"
-- say "syntax-checked $checked changed JS file(s)"
--}
--
--backup_db() {
-- # A schema migration is about to run on restart: take a consistent online copy first.
-- local db="$1"
-- [ -f "$db" ] || { say "no database at $db to back up"; return 0; }
-- local out; out="$(dirname "$db")/backups/live-$(date +%Y%m%d-%H%M%S).db"
-- run mkdir -p "$(dirname "$out")"
-- say "schema changed — backing up $(basename "$db") to $out"
-- if [ "$DRY_RUN" != "1" ]; then
-- (cd "$2" && node -e 'const D=require("better-sqlite3");new D(process.argv[1],{readonly:true}).backup(process.argv[2]).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})' "$db" "$out") \
-- || die "database backup failed; nothing was restarted" 2
-- fi
--}
++probe || legacy "$REASON"
--# Does systemd (pid 1) hold the socket unit's listener? `is-active` is not enough: a socket unit whose
--# descriptor was dropped still reads active while the service binds the port itself.
--socket_held() {
-- local listen port
-- listen=$($SYSTEMCTL show "${SERVICE}.socket" -p Listen --value 2>/dev/null | head -1 | awk '{print $1}')
-- port=${listen##*:}
-- [ -n "$port" ] || return 1
-- $SS -Hltnp "sport = :$port" 2>/dev/null | grep -q '"systemd",pid=1,'
--}
--
--restart_and_verify() {
-- local on_fail="$1" socketed=false
-- if $SYSTEMCTL list-unit-files "${SERVICE}.socket" >/dev/null 2>&1 && $SYSTEMCTL is-enabled --quiet "${SERVICE}.socket" 2>/dev/null; then
-- socketed=true
-- $SYSTEMCTL is-active --quiet "${SERVICE}.socket" || $SYSTEMCTL start "${SERVICE}.socket" || true
-- fi
-- local start; start=$(date +%s)
-- if [ "$socketed" = true ] && { [ "$SOCKET_CHANGED" = true ] || ! socket_held; }; then
-- # The socket must be rebound: stop the service (it may hold the port itself), restart the
-- # socket, start the service on it. HTTP is refused for that moment, once.
-- say "socket unit $([ "$SOCKET_CHANGED" = true ] && echo changed || echo 'is not holding its listener') — stopping ${SERVICE}, restarting the socket"
-- $SYSTEMCTL stop "$SERVICE" 9>&- || true
-- $SYSTEMCTL restart "${SERVICE}.socket" || say "✗ could not restart ${SERVICE}.socket"
-- $SYSTEMCTL start "$SERVICE" 9>&-
-- SOCKET_CHANGED=false
-- else
-- [ "$socketed" = true ] && say "socket unit holds the listener — new HTTP connections queue through the restart"
-- say "restarting ${SERVICE}…"
-- $SYSTEMCTL restart "$SERVICE" 9>&-
-- fi
-- printf '[Deploy] waiting for readiness'
-- if wait_ready; then
-- printf '\n'; say "ready after $(( $(date +%s) - start ))s"
-- if [ "$socketed" = true ] && ! socket_held; then
-- say "✗ socket activation is not in effect: systemd does not hold the listener, so restarts refuse connections (check journalctl -u ${SERVICE}.socket)"
-- fi
-- return 0
-- fi
-- say "✗ not ready within ${READY_TIMEOUT}s"
-- journalctl -u "$SERVICE" -n 40 --no-pager 2>/dev/null | sed 's/^/ /' || true
-- $on_fail
--}
--
--# ── Release notification (WS-P task 9) ───────────────────────────────────────
--# OpenVibe.Host publishes host.release.published to OpenVibe.Events, so open tabs check /release.json now
--# instead of at their next poll. ovhost announces the release Live's own /release.json reports, once per
--# release. Best effort: skipped when ovhost is missing or has no `announce`, 20 s at most, and it never
--# fails a deploy (see OpenVibe.Host docs/release-notifications.md).
--OVHOST="${OVHOST:-ovhost}"
--announce_release() {
-- [ "$DRY_RUN" = "1" ] && return 0
-- command -v "$OVHOST" >/dev/null 2>&1 || return 0
-- local help
-- help=$("$OVHOST" --help 2>/dev/null || true)
-- case "$help" in *"announce <service>"*) ;; *) say "release notification skipped: this ovhost has no announce"; return 0 ;; esac
-- timeout 20 "$OVHOST" announce live 9>&- 2>&1 | sed 's/^/ /' || say "release notification not sent (the deploy stands)"
--}
--
--# ═══════════════════════════════════════════════════════════════
--# Release layout
--# ═══════════════════════════════════════════════════════════════
--if [ "$LAYOUT" = release ]; then
-- REPO="$BASE_DIR/repo"
-- CURRENT=$(readlink -f "$BASE_DIR/current")
-- CUR_ID=$(basename "$CURRENT")
-- CUR_SHA=$(git -C "$CURRENT" rev-parse HEAD 2>/dev/null || echo none)
-- say "current release: $CUR_ID (${CUR_SHA:0:8})"
--
-- switch_to() {
-- # Atomic: create the new link beside the old one and rename over it.
-- local target="$1"
-- run ln -sfn "$target" "$BASE_DIR/.current.tmp"
-- run mv -Tf "$BASE_DIR/.current.tmp" "$BASE_DIR/current"
-- }
--
-- if [ "$ROLLBACK" = true ]; then
-- PREV=$(ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | grep -vx "$CURRENT" | head -1 || true)
-- [ -n "$PREV" ] || die "no previous release to roll back to"
-- PREV_SHA=$(git -C "$PREV" rev-parse HEAD 2>/dev/null || echo none)
-- CATS=$(classify "$(git -C "$REPO" diff --name-only "$PREV_SHA" "$CUR_SHA" 2>/dev/null || echo server/)")
-- say "rolling back to $(basename "$PREV") (${PREV_SHA:0:8}); change classes:${CATS}"
-- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
-- switch_to "$PREV"
-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then
-- install_units "$PREV/deploy/systemd/release"
-- wait_for_idle
-- restart_and_verify 'die "ROLLBACK RELEASE IS NOT READY — MANUAL INTERVENTION REQUIRED" 4'
-- else
-- say "static-only difference — switched without a restart"
-- fi
-- # Keep the release we left so it can be re-selected; just move it out of "newest".
-- touch -h "$PREV"
-- say "rolled back to $(basename "$PREV") ✅"
-- announce_release
-- exit 0
-- fi
--
-- say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
-- run git -C "$REPO" fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
-- TARGET_SHA=$(git -C "$REPO" rev-parse "${GIT_REMOTE}/${GIT_BRANCH}")
-- if [ "$TARGET_SHA" = "$CUR_SHA" ] && [ "$ALWAYS_RESTART" != true ]; then
-- say "already at ${TARGET_SHA:0:8}; nothing to deploy."
-- exit 0
-- fi
--
-- CHANGED=$(git -C "$REPO" diff --name-only "$CUR_SHA" "$TARGET_SHA" 2>/dev/null || true)
-- CATS=$(classify "$CHANGED")
-- [ "$TARGET_SHA" = "$CUR_SHA" ] && CATS=" server"
-- COMMIT_LOG=$(git -C "$REPO" --no-pager log --oneline "${CUR_SHA}..${TARGET_SHA}" 2>/dev/null || echo "Restart requested")
-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
--
-- NEW_ID="$(date +%Y%m%d-%H%M%S)-${TARGET_SHA:0:8}"
-- NEW="$BASE_DIR/releases/$NEW_ID"
-- say "preparing release $NEW_ID while $CUR_ID keeps serving…"
-- run git -C "$REPO" worktree add --detach "$NEW" "$TARGET_SHA" >/dev/null
-- [ "$DRY_RUN" = "1" ] || syntax_check "$NEW" "$CHANGED"
-- run ln -sfn ../../shared/data "$NEW/data"
--
-- if [ "$DRY_RUN" != "1" ] && deps_changed "$CUR_SHA" "$TARGET_SHA" "$REPO"; then
-- say "dependencies changed — installing into the new release (nothing interrupted yet)…"
-- (cd "$NEW" && $NPM ci --omit=dev --no-audit --no-fund) || { git -C "$REPO" worktree remove --force "$NEW" || true; die "npm ci failed; the current release is untouched" 2; }
-- else
-- # Same lockfile: hard-link the current release's node_modules — instant, no extra disk, and the
-- # new release still owns its own tree (npm never writes into an installed package in place).
-- say "dependencies unchanged — linking node_modules from $CUR_ID"
-- run cp -al "$CURRENT/node_modules" "$NEW/node_modules"
-- fi
--
-- if has "$CATS" nginx; then
-- say "note: deploy/nginx changed and is NOT installed by this script — copy it to /etc/nginx/sites-available,"
-- say " run nginx -t and reload (OpenVibe.Network's generator mirrors it)."
-- fi
--
-- [ "$DRY_RUN" = "1" ] && { say "dry run complete — nothing was changed."; exit 0; }
--
-- NEEDS_RESTART=false
-- if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
--
-- if [ "$NEEDS_RESTART" = true ]; then
-- wait_for_idle
-- has "$CATS" schema && backup_db "$BASE_DIR/shared/data/live.db" "$NEW"
-- FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
-- notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
-- switch_to "$NEW"
-- install_units "$NEW/deploy/systemd/release"
-- rollback_release() {
-- say "rolling back to $CUR_ID"
-- switch_to "$CURRENT"
-- install_units "$CURRENT/deploy/systemd/release"
-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
-- printf '[Deploy] waiting for rollback'
-- if wait_ready; then printf '\n'; say "rolled back to $CUR_ID and serving"; exit 3; fi
-- die "ROLLBACK FAILED: $CUR_ID is not ready either — MANUAL INTERVENTION REQUIRED" 4
-- }
-- restart_and_verify rollback_release
-- say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
-- else
-- switch_to "$NEW"
-- # The running process reads public/ and docs/ through the `current` link (OV_APP_ROOT), and
-- # re-hashes assets within ~2s. Confirm the site answers; no process was restarted.
-- sleep 3
-- curl -sf --max-time 10 "${API_URL}${READY_PATH}" >/dev/null || { switch_to "$CURRENT"; die "site not ready after switching — switched back to $CUR_ID" 3; }
-- say "switched to $NEW_ID without a restart (change classes:${CATS})"
-- fi
--
-- # Prune old releases (never the current one, never the one we just left).
-- ls -1dt "$BASE_DIR"/releases/*/ 2>/dev/null | sed 's:/$::' | tail -n +$((KEEP_RELEASES+1)) | while read -r old; do
-- [ "$old" = "$(readlink -f "$BASE_DIR/current")" ] && continue
-- [ "$old" = "$CURRENT" ] && continue
-- say "pruning $(basename "$old")"
-- git -C "$REPO" worktree remove --force "$old" 2>/dev/null || rm -rf "$old"
-- done
-- git -C "$REPO" worktree prune 2>/dev/null || true
-- say "deployed $CUR_ID → $NEW_ID ✅"
-- announce_release
-- exit 0
++if [ "$FORCE" = true ]; then
++ echo "[Deploy] ✗ --force meant 'discard local tracked changes' (legacy layout). ovhost never discards changes, and its" >&2
++ echo "[Deploy] --force drops live streams and passes a freeze. Run: sudo $OVHOST ${CMD} ${SERVICE} --force if that is what you mean." >&2
++ exit 1
fi
--
--# ═══════════════════════════════════════════════════════════════
--# Legacy layout (git checkout in place)
--# ═══════════════════════════════════════════════════════════════
--[ "$ROLLBACK" = true ] && die "--rollback needs the release layout (deploy/scripts/migrate-to-releases.sh)"
--REPO_DIR="$BASE_DIR"
--cd "$REPO_DIR" || die "repo not found at $REPO_DIR"
--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo none)
--say "current commit: ${OLD_HASH:0:8}"
--
--if [ "$FORCE_DEPLOY" = true ]; then
-- say "force: discarding local tracked changes (untracked files are kept)"
-- run git reset --hard HEAD
--elif ! git diff --quiet || ! git diff --cached --quiet; then
-- die "working tree has uncommitted changes — commit them, or re-run with --force"
++if [ "${DRY_RUN:-0}" = 1 ]; then
++ say "ovhost plan $SERVICE (DRY_RUN=1: nothing changes)"
++ exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"
fi
--
--say "fetching ${GIT_REMOTE}/${GIT_BRANCH}…"
--run git fetch --quiet "$GIT_REMOTE" "$GIT_BRANCH"
--TARGET_HASH=$(git rev-parse "${GIT_REMOTE}/${GIT_BRANCH}" 2>/dev/null || echo "$OLD_HASH")
--if [ "$OLD_HASH" = "$TARGET_HASH" ] && [ "$ALWAYS_RESTART" != true ]; then
-- say "already at ${TARGET_HASH:0:8}; nothing to deploy."
-- exit 0
--fi
--
--CHANGED=""; CATS=" server"
--if [ "$OLD_HASH" != "$TARGET_HASH" ]; then
-- CHANGED=$(git diff --name-only "$OLD_HASH" "$TARGET_HASH" || true)
-- CATS=$(classify "$CHANGED")
-- COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${TARGET_HASH}" 2>/dev/null || echo "Update deployed")
-- COMMIT_COUNT=$(printf '%s\n' "$COMMIT_LOG" | grep -c . || true)
-- say "$COMMIT_COUNT new commit(s); change classes:${CATS:- none}"
-- printf '%s\n' "$COMMIT_LOG" | sed 's/^/ /'
--else
-- COMMIT_LOG="Restart requested"; COMMIT_COUNT=0
--fi
--
--NEEDS_RESTART=false
--if has "$CATS" server || has "$CATS" systemd || [ "$ALWAYS_RESTART" = true ]; then NEEDS_RESTART=true; fi
--
--# In place, the old process keeps running against files that are about to change underneath it, so
--# anything that needs a restart waits for idle BEFORE the checkout moves.
--[ "$NEEDS_RESTART" = true ] && wait_for_idle
--
--say "validating the target revision…"
--run git merge --ff-only "$TARGET_HASH" >/dev/null 2>&1 || { [ "$DRY_RUN" = "1" ] || die "fast-forward to ${TARGET_HASH:0:8} failed — diverged history" 2; }
--NEW_HASH=$(git rev-parse HEAD)
--[ -n "$CHANGED" ] && [ "$DRY_RUN" != "1" ] && syntax_check "$REPO_DIR" "$CHANGED"
--
--if [ "$DRY_RUN" != "1" ] && [ "$OLD_HASH" != "$TARGET_HASH" ] && deps_changed "$OLD_HASH" "$TARGET_HASH" "$REPO_DIR"; then
-- say "dependencies changed — installing (service still running)…"
-- say "warning: legacy layout — a rollback would NOT restore the previous node_modules. Migrate to releases."
-- run $NPM ci --omit=dev --no-audit --no-fund || die "npm ci failed" 2
--fi
--has "$CATS" nginx && say "note: deploy/nginx changed — not installed here; copy it, nginx -t, reload (see docs/deploy.md)."
--
--if [ "$DRY_RUN" = "1" ]; then say "dry run complete — nothing was changed. restart needed: $NEEDS_RESTART"; exit 0; fi
--
--if [ "$NEEDS_RESTART" != true ]; then
-- say "change classes:${CATS} — no restart needed; the server re-reads public/ and docs/ from disk."
-- say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} without interrupting anyone ✅"
-- announce_release
-- exit 0
--fi
--
--install_units "$REPO_DIR/deploy/systemd"
--has "$CATS" schema && backup_db "$REPO_DIR/data/live.db" "$REPO_DIR"
--FIRST_LINE=$(printf '%s\n' "$COMMIT_LOG" | head -1 | sed 's/^[a-f0-9]* //')
--notify_chat "$([ "$COMMIT_COUNT" -le 1 ] && echo "Update: ${FIRST_LINE}" || echo "${COMMIT_COUNT} updates deployed — ${FIRST_LINE}")"
--rollback_legacy() {
-- say "rolling back to ${OLD_HASH:0:8}"
-- git reset --hard "$OLD_HASH" >/dev/null 2>&1 || die "ROLLBACK FAILED: could not reset to $OLD_HASH" 4
-- $SYSTEMCTL restart "$SERVICE" 9>&- || die "ROLLBACK FAILED: service would not restart" 4
-- printf '[Deploy] waiting for rollback'
-- if wait_ready; then printf '\n'; say "rolled back to ${OLD_HASH:0:8} and serving"; exit 3; fi
-- die "ROLLBACK FAILED: ${OLD_HASH:0:8} is not ready either — MANUAL INTERVENTION REQUIRED" 4
--}
--restart_and_verify rollback_legacy
--curl -sf --max-time 5 "${API_URL}/api/health" >/dev/null || die "health endpoint not responding after readiness" 3
--say "deployed ${OLD_HASH:0:8} → ${NEW_HASH:0:8} (${COMMIT_COUNT} commit(s))"
--say "note: established WebSocket, WHIP, RTMP and WebRTC sessions were reconnected, not preserved."
--say "done ✅"
--announce_release
++say "ovhost $CMD $SERVICE ${FLAGS[*]:-}"
++exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"
ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex 97623a05..63cc1cd0 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -1,14 +1,28 @@
# Deploying OpenVibe.Live
--`deploy/scripts/deploy.sh` decides what a change needs and does the least disruptive thing.
++Live deploys with `ovhost deploy live` (OpenVibe.Host, `/usr/local/bin/ovhost`, strategy
++`release-layout`; roadmap WS-N task 11). `deploy/scripts/deploy.sh` is a thin wrapper that maps its old
++flags onto ovhost, so the commands stay the same:
```bash
--sudo /opt/openvibe.live/deploy/scripts/deploy.sh # deploy origin/main if it moved
--sudo /opt/openvibe.live/deploy/scripts/deploy.sh --wait-idle # hold a restart until nobody is live
--sudo /opt/openvibe.live/deploy/scripts/deploy.sh --rollback # release layout: previous release
--DRY_RUN=1 /opt/openvibe.live/deploy/scripts/deploy.sh # print the plan only
++cd /opt/openvibe.live/current
++sudo deploy/scripts/deploy.sh # ovhost deploy live: origin/main if it moved
++sudo deploy/scripts/deploy.sh --wait-idle # ovhost deploy live --wait-idle: hold a restart until nobody is live
++sudo deploy/scripts/deploy.sh --restart # ovhost deploy live --restart
++sudo deploy/scripts/deploy.sh --rollback # ovhost rollback live: the release the current one replaced
++DRY_RUN=1 deploy/scripts/deploy.sh # ovhost plan live: print the plan only
```
++The wrapper checks `ovhost capabilities live` first (deploy API 1, strategy `release-layout`, managed).
++When ovhost is missing or too old, or the host inventory does not deploy Live with that strategy, it says
++why and runs `deploy/scripts/deploy-legacy.sh`, the previous script, unchanged and with the same
++arguments. `OVHOST_LEGACY=1` forces the fallback; `OVHOST=<path>` picks another ovhost. `--force`
++meant "discard local tracked changes" in the legacy layout; ovhost's `--force` drops live streams, so the
++wrapper refuses it (run `sudo ovhost deploy live --force` yourself if that is what you mean). Everything
++below is what both do; ovhost also records every attempt in its release log (`ovhost releases live`) and
++refuses a frozen service (`ovhost freeze`, exit 6). What ovhost checks is in OpenVibe.Host
++`docs/deploy-strategies.md` and `test/strategy-release-layout.test.js`.
++
## What each kind of change costs
| Files changed | Action | Interruption |
@@@ -65,16 +79,19 @@ but could not restore previous `node_modules`.
- The last 5 releases are kept.
- Content-hashed assets from the previous release are still served under their old hashes, so a page
rendered before the switch never loads JavaScript from after it.
--- **Release notification.** After a deploy or `--rollback` that went live, the script runs
-- `ovhost announce live` (OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the
++- **Release notification.** After a deploy or `--rollback` that went live, ovhost announces it (the
++ legacy script runs `ovhost announce live`; OpenVibe.Host, WS-P task 9). ovhost publishes `host.release.published` for the
release `/release.json` now reports, once per release, to OpenVibe.Events. Open tabs (openvibe-shared
1.17.0 release-watch) then check `/release.json` within about 20 s instead of at their next poll. It is
best effort: skipped when `ovhost` is missing or has no `announce` (set `OVHOST` for another path), 20 s at
most, and it never changes the exit code. A static-only switch keeps the running release, so there is
nothing new to announce. The credentials and the setup are in OpenVibe.Host `docs/release-notifications.md`.
--`test/deploy-sim.test.js` runs the script against a simulated host (real git, fake systemctl) and
--checks each of these behaviours.
++`test/deploy-sim.test.js` runs `deploy-legacy.sh` against a simulated host (real git, fake systemctl)
++and checks each of these behaviours; `test/deploy-wrapper.test.js` checks the wrapper's flag mapping and
++its fallback. ovhost's own tests (OpenVibe.Host `test/strategy-release-layout.test.js`) cover the same
++behaviours for `ovhost deploy live`, plus the socket rule: pid 1 must hold :3000, and the socket unit is
++restarted only when its unit file changed or systemd does not hold the listener.
## Assets and caching
ddiff --git a/test/deploy-sim.test.js b/test/deploy-sim.test.js
iindex e0c67f2c..df14e45b 100644
---- a/test/deploy-sim.test.js
++++ b/test/deploy-sim.test.js
@@@ -1,5 +1,6 @@
/**
-- * deploy/scripts/deploy.sh against a simulated host.
++ * deploy/scripts/deploy-legacy.sh (the release-layout deploy script from before `ovhost deploy live`;
++ * deploy/scripts/deploy.sh now hands over to ovhost and falls back to it) against a simulated host.
*
* A temp directory stands in for /opt/openvibe.live with a real git "origin", the release layout
* (repo, releases/<id>, current, shared/data), a fake `systemctl` that runs a tiny app, and a fake
@@@ -23,7 +24,7 @@ const path = require('path');
const http = require('http');
const { execFileSync, spawnSync } = require('child_process');
--const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
++const DEPLOY = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-sim-'));
const PORT = 20000 + Math.floor(Math.random() * 20000);
const sh = (cmd, opts = {}) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
nnew file mode 100644
iindex 00000000..e1e7e468
---- /dev/null
++++ b/test/deploy-wrapper.test.js
@@@ -0,0 +1,116 @@
++/**
++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy live` (OpenVibe.Host, strategy
++ * release-layout; roadmap WS-N task 11), with deploy-legacy.sh (the previous script, unchanged) as its
++ * fallback. A fake ovhost records what the wrapper asked for; a fake legacy script records the fallback.
++ * - the flags map: (none) → deploy live, --wait-idle, --restart, --rollback → rollback live,
++ * DRY_RUN=1 → plan live, READY_TIMEOUT → --ready-timeout;
++ * - --force (legacy: discard local changes) is refused on the ovhost path, never passed on;
++ * - the fallback runs, with the original arguments and a message, when ovhost is missing, has no
++ * `capabilities`, reports deploy-api 0, does not deploy live with release-layout, or OVHOST_LEGACY=1;
++ * - deploy-legacy.sh is the old script: it still holds the listener check and the socket restart.
++ *
++ * node test/deploy-wrapper.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++
++const WRAPPER = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-deploy-wrapper-'));
++const log = path.join(tmp, 'calls.log');
++
++// Fake ovhost: `capabilities live` prints what FAKE_CAPS says (or fails with FAKE_OLD); anything else is recorded.
++const ovhost = path.join(tmp, 'ovhost');
++fs.writeFileSync(ovhost, `#!/usr/bin/env bash
++if [ "$1" = capabilities ]; then
++ [ -n "$FAKE_OLD" ] && { echo "unknown command \\"capabilities\\""; exit 1; }
++ printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nstrategies=git-checkout,multi-app,static-build,pnpm-build,release-layout\\nservice=live\\nstrategy=release-layout\\nmanaged=yes\\nlayout=release}"
++ exit 0
++fi
++echo "ovhost $*" >> "${log}"
++exit "\${FAKE_EXIT:-0}"
++`, { mode: 0o755 });
++const legacy = path.join(tmp, 'legacy.sh');
++fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy $* DRY_RUN=\${DRY_RUN:-}" >> "${log}"\nexit 0\n`, { mode: 0o755 });
++
++const run = (args = [], env = {}) => {
++ fs.rmSync(log, { force: true });
++ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, ...env }, encoding: 'utf8' });
++ let calls = [];
++ try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
++ return { code: r.status, out: r.stdout + r.stderr, calls };
++};
++
++let failures = 0;
++function check(name, fn) {
++ try { fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', String(e.message).split('\n').slice(0, 12).join('\n ')); }
++}
++
++check('the flags map onto ovhost deploy|rollback|plan live', () => {
++ assert.deepStrictEqual(run().calls, ['ovhost deploy live']);
++ assert.deepStrictEqual(run(['--wait-idle']).calls, ['ovhost deploy live --wait-idle']);
++ assert.deepStrictEqual(run(['--restart', '--wait-idle']).calls, ['ovhost deploy live --restart --wait-idle']);
++ assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback live']);
++ assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan live']);
++ assert.deepStrictEqual(run([], { READY_TIMEOUT: '120' }).calls, ['ovhost deploy live --ready-timeout 120']);
++ const r = run(['--wait-idle']);
++ assert.match(r.out, /\[Deploy\] ovhost deploy live --wait-idle/);
++});
++
++check("ovhost's exit code is the wrapper's (3: not ready, rolled back)", () => {
++ assert.strictEqual(run([], { FAKE_EXIT: '3' }).code, 3);
++ assert.strictEqual(run([], { FAKE_EXIT: '5' }).code, 5);
++});
++
++check('--force is refused on the ovhost path (it would drop live streams), never passed on', () => {
++ const r = run(['--force']);
++ assert.strictEqual(r.code, 1, r.out);
++ assert.deepStrictEqual(r.calls, []);
++ assert.match(r.out, /--force meant 'discard local tracked changes'/);
++});
++
++check('an unknown flag is a usage error', () => {
++ const r = run(['--nope']);
++ assert.strictEqual(r.code, 1);
++ assert.deepStrictEqual(r.calls, []);
++});
++
++check('the fallback runs deploy-legacy.sh with the original arguments, and says why', () => {
++ let r = run(['--wait-idle'], { OVHOST: path.join(tmp, 'missing-ovhost') });
++ assert.strictEqual(r.code, 0, r.out);
++ assert.deepStrictEqual(r.calls, ['legacy --wait-idle DRY_RUN=']);
++ assert.match(r.out, /ovhost not found .* — running deploy-legacy\.sh/);
++
++ r = run(['--force'], { FAKE_OLD: '1' });
++ assert.deepStrictEqual(r.calls, ['legacy --force DRY_RUN='], 'the legacy script keeps its own --force');
++ assert.match(r.out, /no 'capabilities' \(too old\)/);
++
++ r = run([], { FAKE_CAPS: 'ovhost=0.2.9\\ndeploy-api=0\\nstrategy=release-layout\\nmanaged=yes' });
++ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
++ assert.match(r.out, /deploy-api is 0, 1 is needed/);
++
++ r = run([], { FAKE_CAPS: 'ovhost=0.3.0\\ndeploy-api=1\\nstrategy=none\\nmanaged=no' });
++ assert.deepStrictEqual(r.calls, ['legacy DRY_RUN=']);
++ assert.match(r.out, /does not deploy live with strategy release-layout \(none\)/);
++
++ r = run(['--rollback'], { OVHOST_LEGACY: '1', DRY_RUN: '1' });
++ assert.deepStrictEqual(r.calls, ['legacy --rollback DRY_RUN=1']);
++});
++
++check('deploy-legacy.sh is the previous script: listener check, changed-socket restart, release layout', () => {
++ const text = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
++ assert.match(text, /socket_held\(\)/);
++ assert.match(text, /SOCKET_CHANGED=true/);
++ assert.match(text, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/);
++ assert.match(text, /KEEP_RELEASES/);
++ const wrapper = fs.readFileSync(WRAPPER, 'utf8');
++ assert.ok(wrapper.split('\n').length < 120, 'the wrapper stays thin');
++ assert.match(wrapper, /^set -euo pipefail$/m);
++});
++
++fs.rmSync(tmp, { recursive: true, force: true });
++if (failures) { console.log(`\n${failures} failure(s)`); process.exit(1); }
++console.log('\ndeploy wrapper: all checks passed');
ddiff --git a/test/systemd-units.test.js b/test/systemd-units.test.js
iindex 5bb46f6a..9fe1e4e4 100644
---- a/test/systemd-units.test.js
++++ b/test/systemd-units.test.js
@@@ -43,10 +43,17 @@ assert.strictEqual(load({ NODE_ENV: 'production', LISTEN_HOST: '10.0.0.5', BASE_
assert.strictEqual(load({ NODE_ENV: 'development' }).listenHost, '0.0.0.0', 'development keeps HOST (default 0.0.0.0)');
assert.strictEqual(load({ NODE_ENV: 'production', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.live' }).host, '0.0.0.0', 'HOST still builds URLs');
--// deploy.sh checks that systemd really holds the listener, and restarts a socket unit that changed.
--const deploy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
--assert.match(deploy, /socket_held\(\)/, 'deploy.sh has the listener check');
--assert.match(deploy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
--assert.match(deploy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
++// Deploys check that systemd really holds the listener, and restart a socket unit that changed. Since
++// WS-N task 11 deploy.sh hands over to `ovhost deploy live` (OpenVibe.Host strategy release-layout:
++// systemd.socketHeld + rebindSocket, test/strategy-release-layout.test.js there) and falls back to
++// deploy-legacy.sh, the previous script, which still does it itself.
++const legacy = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
++assert.match(legacy, /socket_held\(\)/, 'deploy-legacy.sh has the listener check');
++assert.match(legacy, /SOCKET_CHANGED=true/, 'a changed socket unit is noted');
++assert.match(legacy, /\$SYSTEMCTL restart "\$\{SERVICE\}\.socket"/, 'and restarted');
++const wrapper = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
++assert.match(wrapper, /^STRATEGY=release-layout$/m, 'deploy.sh only hands over to an ovhost that deploys live with release-layout (the socket rule)');
++assert.match(wrapper, /deploy-api/, 'and checks the deploy API version before it does');
++assert.match(wrapper, /exec bash "\$LEGACY"/, 'anything else falls back to deploy-legacy.sh');
console.log('systemd units: all checks passed');