openvibers/openvibe.games Commits

deploy/scripts/deploy.sh: deploys run through ovhost deploy games (OpenVibe.Host strategy pnpm-build; roadmap WS-N task 11). ovhost does the production procedure (pull, pnpm install --frozen-lockfile, pnpm build, restart openvibe-games) with git as the checkout owner instead of sudo git, restores the tracked dist-types/ a previous build rewrote before the pull, checks every workspace package's dependencies and that better-sqlite3 loads under the host's Node before the restart, polls /api/ready, and on failure restores, reinstalls and rebuilds (the build writes the served client) and restarts again. Players online are reported and reconnect (drain policy report); --wait-idle holds the restart until nobody plays. The wrapper passes --wait-idle, --restart, --force, --rollback and DRY_RUN=1 (ovhost plan games); it asks ovhost capabilities games first and falls back to deploy/scripts/deploy-legacy.sh, the procedure as it was run by hand (sudo git -c safe.directory pull, pnpm install --frozen-lockfile, pnpm build, sudo systemctl restart openvibe-games, then a readiness wait and the best-effort release notification), when ovhost is missing, too old or the host inventory does not deploy games with pnpm-build (OVHOST_LEGACY=1 forces it). apps/server/scripts/deployWrapper.test.ts. README.

OP OpenVibers committed 27 Sept 2026 · e2d9a5d4a6 · 4 files, +222 −0


Changes

ddiff --git a/README.md b/README.md
iindex 108e895..ec02deb 100644
---- a/README.md
++++ b/README.md
@@@ -95,6 +95,19 @@ has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
Deployed at https://openvibe.games (nginx TLS termination → server on :8000,
systemd unit `openvibe-games.service`; play.openvibe.games Host-routes to the same process).
++Deploy on the host with `sudo /opt/openvibe.games/deploy/scripts/deploy.sh`, which runs
++`ovhost deploy games` (OpenVibe.Host, strategy `pnpm-build`; roadmap WS-N task 11): a fast-forward pull as
++the checkout owner (the tracked `dist-types/` a previous build rewrote is restored first),
++`pnpm install --frozen-lockfile`, every workspace package's dependencies checked, `pnpm build`,
++better-sqlite3 loaded under the host's Node, the restart, `/api/ready`, and on failure the checkout
++restored, reinstalled and rebuilt and the server restarted again. Players online are reported and
++reconnect; `--wait-idle` holds the restart until nobody plays, `--rollback` runs `ovhost rollback games`,
++`DRY_RUN=1` prints `ovhost plan games`. Do not pull by hand first (ovhost would find nothing new; pass
++`--restart` if you did). When ovhost is missing, too old or does not deploy Games with that strategy, the
++wrapper runs `deploy/scripts/deploy-legacy.sh`: the procedure as it was run by hand
++(`sudo git -c safe.directory=/opt/openvibe.games pull`, `pnpm install --frozen-lockfile`, `pnpm build`,
++`sudo systemctl restart openvibe-games`).
++
## Platform integration (OpenVibe network)
Games integrates with the rest of OpenVibe at its service boundary only
ddiff --git a/apps/server/scripts/deployWrapper.test.ts b/apps/server/scripts/deployWrapper.test.ts
nnew file mode 100644
iindex 0000000..551e7bb
---- /dev/null
++++ b/apps/server/scripts/deployWrapper.test.ts
@@@ -0,0 +1,105 @@
++import { spawnSync } from 'node:child_process'
++import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { dirname, join } from 'node:path'
++import { fileURLToPath } from 'node:url'
++import { afterAll, describe, expect, it } from 'vitest'
++
++/**
++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy games` (OpenVibe.Host, strategy
++ * pnpm-build; roadmap WS-N task 11), with deploy-legacy.sh (the production procedure as it was run by
++ * hand) as its fallback. A fake ovhost records what the wrapper asks for; a fake legacy script records
++ * the fallback.
++ */
++
++const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
++const WRAPPER = join(ROOT, 'deploy', 'scripts', 'deploy.sh')
++const tmp = mkdtempSync(join(tmpdir(), 'games-deploy-wrapper-'))
++const log = join(tmp, 'calls.log')
++const ovhost = join(tmp, 'ovhost')
++writeFileSync(
++ ovhost,
++ `#!/usr/bin/env bash
++if [ "$1" = capabilities ]; then
++ [ -n "$FAKE_OLD" ] && exit 1
++ printf '%b\\n' "\${FAKE_CAPS:-ovhost=0.3.0\\ndeploy-api=1\\nservice=games\\nstrategy=pnpm-build\\nmanaged=yes\\nlayout=git}"
++ exit 0
++fi
++echo "ovhost $*" >> "${log}"
++exit "\${FAKE_EXIT:-0}"
++`,
++)
++chmodSync(ovhost, 0o755)
++const legacy = join(tmp, 'legacy.sh')
++writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy" >> "${log}"\n`)
++chmodSync(legacy, 0o755)
++
++interface Run {
++ code: number | null
++ out: string
++ calls: string[]
++}
++
++function run(args: string[] = [], env: Record<string, string> = {}): Run {
++ 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: string[] = []
++ try {
++ calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean)
++ } catch {
++ calls = []
++ }
++ return { code: r.status, out: r.stdout + r.stderr, calls }
++}
++
++afterAll(() => rmSync(tmp, { recursive: true, force: true }))
++
++describe('deploy/scripts/deploy.sh', () => {
++ it('maps its flags onto ovhost deploy|rollback|plan games', () => {
++ expect(run().calls).toEqual(['ovhost deploy games'])
++ expect(run(['--wait-idle']).calls).toEqual(['ovhost deploy games --wait-idle'])
++ expect(run(['--restart']).calls).toEqual(['ovhost deploy games --restart'])
++ expect(run(['--rollback']).calls).toEqual(['ovhost rollback games'])
++ expect(run([], { DRY_RUN: '1' }).calls).toEqual(['ovhost plan games'])
++ expect(run([], { FAKE_EXIT: '3' }).code).toBe(3)
++ expect(run(['--nope']).code).toBe(1)
++ })
++
++ it('falls back to deploy-legacy.sh when ovhost is missing, too old or not deploying games with pnpm-build', () => {
++ let r = run([], { FAKE_OLD: '1' })
++ expect(r.calls).toEqual(['legacy'])
++ expect(r.out).toMatch(/too old/)
++ r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=none\\nmanaged=no' })
++ expect(r.calls).toEqual(['legacy'])
++ expect(r.out).toMatch(/does not deploy games with strategy pnpm-build \(none\)/)
++ expect(run([], { OVHOST: join(tmp, 'missing') }).calls).toEqual(['legacy'])
++ // The legacy procedure cannot roll back, wait for idle or plan: nothing runs rather than something else.
++ for (const args of [['--rollback'], ['--wait-idle']]) {
++ r = run(args, { OVHOST_LEGACY: '1' })
++ expect(r.code).toBe(1)
++ expect(r.calls).toEqual([])
++ }
++ })
++
++ it('keeps the production procedure as the fallback', () => {
++ const text = readFileSync(join(ROOT, 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8')
++ for (const step of [
++ 'sudo git -c safe.directory="$REPO" pull',
++ 'pnpm install --frozen-lockfile',
++ 'pnpm build',
++ 'sudo systemctl restart openvibe-games',
++ ]) {
++ expect(text).toContain(step)
++ }
++ expect(readFileSync(WRAPPER, 'utf8')).toMatch(/^set -euo pipefail$/m)
++ })
++})
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 0000000..8fc7032
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,35 @@
++#!/usr/bin/env bash
++# OpenVibe.Games — the production deploy as it was done by hand before `ovhost deploy games`, kept as the
++# fallback of deploy/scripts/deploy.sh (roadmap WS-N task 11). The same commands, in the same order:
++#
++# sudo git -c safe.directory=/opt/openvibe.games pull
++# pnpm install --frozen-lockfile
++# pnpm build
++# sudo systemctl restart openvibe-games
++#
++# plus a readiness wait and the best-effort release notification the other repositories' scripts send. It
++# has no protected-session check, no dependency check and no rollback: ovhost has those.
++set -euo pipefail
++REPO="${REPO:-/opt/openvibe.games}"
++READY_URL="${READY_URL:-http://127.0.0.1:8000/api/ready}"
++cd "$REPO"
++sudo git -c safe.directory="$REPO" pull
++pnpm install --frozen-lockfile
++pnpm build
++sudo systemctl restart openvibe-games
++for _ in $(seq 1 60); do
++ if curl -sf --max-time 2 "$READY_URL" >/dev/null; then echo "[games] ready ($(git -c safe.directory="$REPO" rev-parse --short=12 HEAD))"; break; fi
++ sleep 1
++done
++curl -sf --max-time 2 "$READY_URL" >/dev/null || { echo "[games] ✗ not ready after 60 s: journalctl -u openvibe-games -n 50" >&2; exit 3; }
++# Release notification (WS-P task 9): best effort, skipped without an ovhost that has `announce`.
++OVHOST_BIN=$(command -v "${OVHOST:-ovhost}" 2>/dev/null || true)
++if [ -n "$OVHOST_BIN" ]; then
++ case "$("$OVHOST_BIN" --help 2>/dev/null || true)" in
++ *"announce <service>"*)
++ SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
++ timeout 20 $SUDO "$OVHOST_BIN" announce games 2>&1 || echo "release notification not sent (the deploy stands)" ;;
++ *) echo "release notification skipped: this ovhost has no announce" ;;
++ esac
++fi
++exit 0
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
nnew file mode 100755
iindex 0000000..e47c468
---- /dev/null
++++ b/deploy/scripts/deploy.sh
@@@ -0,0 +1,69 @@
++#!/usr/bin/env bash
++# OpenVibe.Games — deploy: a thin wrapper around `ovhost deploy games` (OpenVibe.Host, strategy pnpm-build;
++# roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host:
++#
++# sudo /opt/openvibe.games/deploy/scripts/deploy.sh ovhost deploy games
++# sudo /opt/openvibe.games/deploy/scripts/deploy.sh --wait-idle … --wait-idle (hold until nobody plays)
++# sudo /opt/openvibe.games/deploy/scripts/deploy.sh --restart … --restart (restart even with nothing new)
++# sudo /opt/openvibe.games/deploy/scripts/deploy.sh --rollback ovhost rollback games
++# DRY_RUN=1 /opt/openvibe.games/deploy/scripts/deploy.sh ovhost plan games
++#
++# ovhost does the production procedure (pull, pnpm install --frozen-lockfile, pnpm build, restart
++# openvibe-games) with git as the checkout owner instead of sudo git; the tracked dist-types/ a previous build
++# rewrote restored from git before the pull; every workspace package's dependencies checked and better-sqlite3
++# loaded under this Node before the restart; /api/ready polled; on failure the checkout restored, reinstalled
++# and rebuilt (the build writes the served client) and the unit restarted again (exit 3). Players online are
++# reported and reconnect (drain policy report); --wait-idle holds the restart until nobody plays. Do not pull
++# by hand first: ovhost would find nothing new (pass --restart if you did).
++#
++# Fallback: deploy-legacy.sh (the procedure as it was run by hand) when ovhost is missing or too old (no
++# `capabilities`, deploy-api < 1), or the host inventory does not deploy games with strategy pnpm-build;
++# OVHOST_LEGACY=1 forces it. It takes no flags: --rollback, --wait-idle and DRY_RUN=1 are refused there.
++set -euo pipefail
++
++SERVICE=games
++STRATEGY=pnpm-build
++HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
++LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
++OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
++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
++
++CMD=deploy
++FLAGS=()
++while [ "$#" -gt 0 ]; do
++ case "$1" in
++ --wait-idle|--restart|--force) FLAGS+=("$1"); shift ;;
++ --rollback) CMD=rollback; shift ;;
++ --) shift; break ;;
++ *) echo "Usage: $0 [--wait-idle] [--restart] [--force] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;
++ esac
++done
++
++legacy() {
++ echo "[games] $1 — running deploy-legacy.sh (the previous deploy procedure) instead"
++ if [ "$CMD" = rollback ] || [ "${DRY_RUN:-0}" = 1 ] || [[ " ${FLAGS[*]:-} " == *" --wait-idle "* ]]; then
++ echo "[games] ✗ deploy-legacy.sh has no --rollback, --wait-idle or DRY_RUN; nothing was done" >&2
++ exit 1
++ fi
++ exec bash "$LEGACY"
++}
++
++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
++}
++
++probe || legacy "$REASON"
++
++if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"; fi
++echo "[games] ovhost $CMD $SERVICE ${FLAGS[*]:-}"
++exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"