openvibers/openrestream Commits

deploy/scripts/deploy.sh hands over to ovhost deploy openre (OpenVibe.Host strategy release-layout; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. deploy (release + api in one), release [<ref>] (ovhost deploy openre --prepare-only: releases/<sha12>, npm ci, chown ubuntu; the sha12 alone on stdout as before), api [<sha>] (deploy --to <sha>: switch current, restart openre-api and the coordinator only, roll back if not ready), rollback [<sha>] and plan (or DRY_RUN=1) go to ovhost; workers, status and prune always run deploy-legacy.sh, since ovhost never starts, stops or restarts a worker unit and prunes on its own, never a release a worker generation runs from. ovhost also refuses an API restart while an ingest session is open (--wait-idle, --force). The wrapper asks ovhost capabilities openre first and falls back to deploy-legacy.sh (deploy = release then api, rollback <sha> = api <sha>) when ovhost is missing, too old or the host inventory does not deploy openre with release-layout (OVHOST_LEGACY=1 forces it). test/deploy-wrapper.test.js. README.

OP OpenVibers committed 27 Sept 2026 · fadd8a3200 · 4 files, +272 −97


Changes

ddiff --git a/README.md b/README.md
iindex 92dccec..5ce246f 100644
---- a/README.md
++++ b/README.md
@@@ -146,15 +146,20 @@ The switch to 1935: once Live no longer listens on 1935, set `OPENRE_RTMP_EXTRA_
## Deploying
--Layout: `/opt/openre.stream/releases/<sha>` (full checkouts), `current` → the release the API and coordinator run, env `/etc/openvibe/openre.env` (see [.env.example](.env.example)), store `/var/lib/openre/openre.db`, units in [deploy/systemd/](deploy/systemd/), nginx [deploy/nginx/openre.stream.conf](deploy/nginx/openre.stream.conf), script [deploy/scripts/deploy.sh](deploy/scripts/deploy.sh):
++Layout: `/opt/openre.stream/repo` (the git clone releases are made from), `/opt/openre.stream/releases/<sha12>` (full checkouts with their own `node_modules`, owned by ubuntu), `current` → the release the API and coordinator run, env `/etc/openvibe/openre.env` (see [.env.example](.env.example)), store `/var/lib/openre/openre.db`, units in [deploy/systemd/](deploy/systemd/), nginx [deploy/nginx/openre.stream.conf](deploy/nginx/openre.stream.conf), script [deploy/scripts/deploy.sh](deploy/scripts/deploy.sh). The release and API phases run through `ovhost deploy openre` (OpenVibe.Host, strategy `release-layout`; roadmap WS-N task 11); the script is a thin wrapper that keeps the old subcommands:
```bash
--sudo deploy/scripts/deploy.sh release origin/main # new release dir + npm ci
--sudo deploy/scripts/deploy.sh api # restarts openre-api + coordinator ONLY
--sudo deploy/scripts/deploy.sh workers # starts a new worker generation; old ones drain
--sudo deploy/scripts/deploy.sh status
++sudo deploy/scripts/deploy.sh # ovhost deploy openre: release + api in one
++sudo deploy/scripts/deploy.sh release origin/main # ovhost deploy openre --prepare-only: new release dir + npm ci (prints the sha12)
++sudo deploy/scripts/deploy.sh api [<sha>] # ovhost deploy openre [--to <sha>]: restarts openre-api + coordinator ONLY, rolls back if not ready
++sudo deploy/scripts/deploy.sh rollback [<sha>] # ovhost rollback openre
++sudo deploy/scripts/deploy.sh plan # ovhost plan openre (or DRY_RUN=1)
++sudo deploy/scripts/deploy.sh workers # deploy-legacy.sh: starts a new worker generation; old ones drain
++sudo deploy/scripts/deploy.sh status # deploy-legacy.sh
```
++- ovhost refuses an API restart while an ingest session is open (`--wait-idle` holds it, `--force` goes ahead), records every attempt (`ovhost releases openre`), prunes releases beyond five but never one a worker generation runs from, and announces the release. When ovhost is missing, too old or does not deploy OpenRe with `release-layout`, the wrapper runs [deploy/scripts/deploy-legacy.sh](deploy/scripts/deploy-legacy.sh), the previous script, unchanged (`OVHOST_LEGACY=1` forces it). `workers`, `status` and `prune` always run it.
++
- An **API deploy** never restarts a worker unit (no unit depends on another). Viewers of `openre.stream/play/…` reconnect; encoders, restreams and recordings do not notice.
- A **worker deploy** starts `openre-rtmp-ingest@<sha>` and `openre-restream-worker@<sha>`; older instances are disabled (not stopped) and exit on their own when drained. Never `systemctl restart` a worker instance during a broadcast; `systemctl stop` starts a drain and waits up to 30 min (`TimeoutStopSec`), then kills.
- Worker instances are named after the release. `deploy.sh workers <sha>` for a release that already runs a generation does nothing. An env change that workers read, such as `OPENRE_RTMP_BIND`, takes effect with the next release's generation (docs/cutover.md A3).
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 0000000..a772472
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,110 @@
++#!/usr/bin/env bash
++# ═══════════════════════════════════════════════════════════════
++# OpenRe.Stream deploy (run on the host as root; the lead runs it, never CI).
++#
++# deploy/scripts/deploy.sh release [<git-ref>] check out <ref> (default origin/main) into
++# /opt/openre.stream/releases/<sha>, npm ci
++# deploy/scripts/deploy.sh api [<sha>] point `current` at a release, restart
++# openre-api + openre-session-coordinator.
++# NEVER touches a transport worker.
++# deploy/scripts/deploy.sh workers [<sha>] start a new generation of both workers from a
++# release; older generations drain and exit on
++# their own (sessions are not interrupted)
++# deploy/scripts/deploy.sh status units, generations, open sessions
++# deploy/scripts/deploy.sh prune remove releases no unit uses (keeps 5)
++#
++# Layout: /opt/openre.stream/releases/<sha>/ (full checkout + node_modules), `current` symlink
++# for the API and coordinator, one openre-rtmp-ingest@<sha> / openre-restream-worker@<sha>
++# instance per worker generation. Env: /etc/openvibe/openre.env. Store: /var/lib/openre/openre.db.
++# ═══════════════════════════════════════════════════════════════
++set -euo pipefail
++
++ROOT=/opt/openre.stream
++REPO=https://github.com/OpenVibers/OpenRe.Stream.git
++API=http://127.0.0.1:4500
++NODE_BIN=${NODE_BIN:-node}
++
++say() { printf '[openre-deploy] %s\n' "$*" >&2; }
++die() { say "ERROR: $*"; exit 1; }
++
++latest_release() { ls -1t "$ROOT/releases" 2>/dev/null | head -1; }
++
++cmd_release() {
++ local ref=${1:-origin/main}
++ mkdir -p "$ROOT/releases" "$ROOT/repo"
++ if [ ! -d "$ROOT/repo/.git" ]; then git clone --quiet "$REPO" "$ROOT/repo"; fi
++ git -C "$ROOT/repo" fetch --quiet --tags origin
++ local sha
++ sha=$(git -C "$ROOT/repo" rev-parse --short=12 "$ref")
++ local dir="$ROOT/releases/$sha"
++ if [ -d "$dir" ]; then say "release $sha already exists"; echo "$sha"; return; fi
++ git -C "$ROOT/repo" worktree add --detach --quiet "$dir" "$sha"
++ (cd "$dir" && npm ci --omit=dev --no-audit --no-fund --quiet)
++ chown -R ubuntu:ubuntu "$dir"
++ say "release $sha ready at $dir"
++ echo "$sha"
++}
++
++wait_ready() {
++ for _ in $(seq 1 60); do
++ if curl -sf --max-time 2 "$API/api/ready" >/dev/null; then return 0; fi
++ sleep 1
++ done
++ return 1
++}
++
++cmd_api() {
++ local sha=${1:-$(latest_release)}
++ [ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
++ local prev
++ prev=$(readlink "$ROOT/current" 2>/dev/null || true)
++ ln -sfn "$ROOT/releases/$sha" "$ROOT/current.new" && mv -Tf "$ROOT/current.new" "$ROOT/current"
++ say "current -> $sha (was ${prev:-none}); restarting openre-api and openre-session-coordinator only"
++ systemctl restart openre-api.service
++ systemctl restart openre-session-coordinator.service
++ if ! wait_ready; then
++ say "openre-api not ready after 60 s; rolling back to ${prev:-nothing}"
++ if [ -n "$prev" ]; then
++ ln -sfn "$prev" "$ROOT/current.new" && mv -Tf "$ROOT/current.new" "$ROOT/current"
++ systemctl restart openre-api.service openre-session-coordinator.service
++ fi
++ exit 2
++ fi
++ say "openre-api ready on $sha"
++}
++
++cmd_workers() {
++ local sha=${1:-$(latest_release)}
++ [ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
++ say "starting worker generation from release $sha (older generations drain by themselves)"
++ systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service"
++ # Disable (not stop) older instances so a reboot does not bring them back; they exit on their own.
++ for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' | awk '{print $1}'); do
++ case "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esac
++ done
++}
++
++cmd_status() {
++ systemctl --no-pager --plain list-units 'openre-*' || true
++ curl -s --max-time 3 "$API/api/ready" | "$NODE_BIN" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const r=JSON.parse(s);console.log(JSON.stringify({status:r.status,workers:r.workers,coordinator:r.coordinator,events:r.events},null,2))}catch{console.log(s)}})' || true
++}
++
++cmd_prune() {
++ local keep used
++ used=$( (readlink "$ROOT/current"; systemctl list-units --plain --no-legend 'openre-*@*' | awk '{print $1}' | sed -E 's/.*@(.*)\.service/\1/') | xargs -n1 basename 2>/dev/null | sort -u)
++ keep=$(ls -1t "$ROOT/releases" | head -5)
++ for r in $(ls -1 "$ROOT/releases"); do
++ if echo "$used $keep" | grep -qw "$r"; then continue; fi
++ say "removing release $r"
++ git -C "$ROOT/repo" worktree remove --force "$ROOT/releases/$r"
++ done
++}
++
++case "${1:-}" in
++ release) shift; cmd_release "$@" ;;
++ api) shift; cmd_api "$@" ;;
++ workers) shift; cmd_workers "$@" ;;
++ status) cmd_status ;;
++ prune) cmd_prune ;;
++ *) sed -n '2,20p' "$0"; exit 1 ;;
++esac
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex a772472..bbfc9a8 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,110 +1,92 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
--# OpenRe.Stream deploy (run on the host as root; the lead runs it, never CI).
++# OpenRe.Stream deploy: a thin wrapper around `ovhost deploy openre` (OpenVibe.Host, strategy
++# release-layout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md). Run on the host as root.
#
--# deploy/scripts/deploy.sh release [<git-ref>] check out <ref> (default origin/main) into
--# /opt/openre.stream/releases/<sha>, npm ci
--# deploy/scripts/deploy.sh api [<sha>] point `current` at a release, restart
--# openre-api + openre-session-coordinator.
--# NEVER touches a transport worker.
--# deploy/scripts/deploy.sh workers [<sha>] start a new generation of both workers from a
--# release; older generations drain and exit on
--# their own (sessions are not interrupted)
--# deploy/scripts/deploy.sh status units, generations, open sessions
--# deploy/scripts/deploy.sh prune remove releases no unit uses (keeps 5)
++# deploy/scripts/deploy.sh [deploy [<ref>]] ovhost deploy openre [--to <ref>]: release + api in one
++# deploy/scripts/deploy.sh release [<ref>] ovhost deploy openre --prepare-only [--to <ref>]: check out
++# <ref> (default origin/main) into releases/<sha12>, npm ci,
++# chown ubuntu; prints the sha12 on stdout as before
++# deploy/scripts/deploy.sh api [<sha>] ovhost deploy openre [--to <sha>]: point `current` at that
++# release (made if missing), restart openre-api and
++# openre-session-coordinator ONLY, roll back if not ready
++# deploy/scripts/deploy.sh rollback [<sha>] ovhost rollback openre [--to <sha>]
++# deploy/scripts/deploy.sh plan (or DRY_RUN=1) ovhost plan openre
++# deploy/scripts/deploy.sh workers|status|prune deploy-legacy.sh, unchanged: ovhost never starts, stops or
++# restarts a worker unit (it prunes releases itself, never
++# one a worker generation runs from)
++# --wait-idle / --force after deploy, api or rollback are passed on (ingest sessions refuse an API restart).
#
--# Layout: /opt/openre.stream/releases/<sha>/ (full checkout + node_modules), `current` symlink
--# for the API and coordinator, one openre-rtmp-ingest@<sha> / openre-restream-worker@<sha>
--# instance per worker generation. Env: /etc/openvibe/openre.env. Store: /var/lib/openre/openre.db.
++# Fallback: deploy-legacy.sh (the previous script, unchanged) with the same arguments when ovhost is missing
++# or too old (no `capabilities`, deploy-api < 1), or the host inventory does not deploy openre with strategy
++# release-layout; OVHOST_LEGACY=1 forces it. There `deploy` is `release` then `api`, and `rollback <sha>` is
++# `api <sha>`.
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
--ROOT=/opt/openre.stream
--REPO=https://github.com/OpenVibers/OpenRe.Stream.git
--API=http://127.0.0.1:4500
--NODE_BIN=${NODE_BIN:-node}
++SERVICE=openre
++STRATEGY=release-layout
++ROOT="${OPENRE_ROOT:-/opt/openre.stream}"
++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
say() { printf '[openre-deploy] %s\n' "$*" >&2; }
--die() { say "ERROR: $*"; exit 1; }
--latest_release() { ls -1t "$ROOT/releases" 2>/dev/null | head -1; }
++SUB="${1:-deploy}"
++[ "$#" -gt 0 ] && shift
++[ "${DRY_RUN:-0}" = 1 ] && SUB=plan
++REF=""
++FLAGS=()
++for a in "$@"; do
++ case "$a" in
++ --wait-idle|--force) FLAGS+=("$a") ;;
++ -*) say "unknown option $a"; exit 1 ;;
++ *) [ -z "$REF" ] || { say "one <ref|sha> at most"; exit 1; }; REF="$a" ;;
++ esac
++done
--cmd_release() {
-- local ref=${1:-origin/main}
-- mkdir -p "$ROOT/releases" "$ROOT/repo"
-- if [ ! -d "$ROOT/repo/.git" ]; then git clone --quiet "$REPO" "$ROOT/repo"; fi
-- git -C "$ROOT/repo" fetch --quiet --tags origin
-- local sha
-- sha=$(git -C "$ROOT/repo" rev-parse --short=12 "$ref")
-- local dir="$ROOT/releases/$sha"
-- if [ -d "$dir" ]; then say "release $sha already exists"; echo "$sha"; return; fi
-- git -C "$ROOT/repo" worktree add --detach --quiet "$dir" "$sha"
-- (cd "$dir" && npm ci --omit=dev --no-audit --no-fund --quiet)
-- chown -R ubuntu:ubuntu "$dir"
-- say "release $sha ready at $dir"
-- echo "$sha"
++legacy() {
++ say "$1 — running deploy-legacy.sh (the previous deploy script) instead"
++ case "$SUB" in
++ deploy) bash "$LEGACY" release ${REF:+"$REF"} >/dev/null; exec bash "$LEGACY" api ;;
++ rollback) [ -n "$REF" ] || { say "✗ deploy-legacy.sh needs the release: rollback <sha>"; exit 1; }; exec bash "$LEGACY" api "$REF" ;;
++ plan) say "✗ deploy-legacy.sh has no plan; see deploy-legacy.sh status"; exit 1 ;;
++ *) exec bash "$LEGACY" "$SUB" ${REF:+"$REF"} ;;
++ esac
}
--wait_ready() {
-- for _ in $(seq 1 60); do
-- if curl -sf --max-time 2 "$API/api/ready" >/dev/null; then return 0; fi
-- sleep 1
-- done
-- return 1
++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
}
--cmd_api() {
-- local sha=${1:-$(latest_release)}
-- [ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
-- local prev
-- prev=$(readlink "$ROOT/current" 2>/dev/null || true)
-- ln -sfn "$ROOT/releases/$sha" "$ROOT/current.new" && mv -Tf "$ROOT/current.new" "$ROOT/current"
-- say "current -> $sha (was ${prev:-none}); restarting openre-api and openre-session-coordinator only"
-- systemctl restart openre-api.service
-- systemctl restart openre-session-coordinator.service
-- if ! wait_ready; then
-- say "openre-api not ready after 60 s; rolling back to ${prev:-nothing}"
-- if [ -n "$prev" ]; then
-- ln -sfn "$prev" "$ROOT/current.new" && mv -Tf "$ROOT/current.new" "$ROOT/current"
-- systemctl restart openre-api.service openre-session-coordinator.service
-- fi
-- exit 2
-- fi
-- say "openre-api ready on $sha"
--}
--
--cmd_workers() {
-- local sha=${1:-$(latest_release)}
-- [ -n "$sha" ] && [ -d "$ROOT/releases/$sha" ] || die "no release $sha"
-- say "starting worker generation from release $sha (older generations drain by themselves)"
-- systemctl enable --now "openre-rtmp-ingest@$sha.service" "openre-restream-worker@$sha.service"
-- # Disable (not stop) older instances so a reboot does not bring them back; they exit on their own.
-- for unit in $(systemctl list-units --plain --no-legend 'openre-rtmp-ingest@*' 'openre-restream-worker@*' | awk '{print $1}'); do
-- case "$unit" in *"@$sha.service") ;; *) systemctl disable "$unit" >/dev/null 2>&1 || true; say "draining: $unit";; esac
-- done
--}
--
--cmd_status() {
-- systemctl --no-pager --plain list-units 'openre-*' || true
-- curl -s --max-time 3 "$API/api/ready" | "$NODE_BIN" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const r=JSON.parse(s);console.log(JSON.stringify({status:r.status,workers:r.workers,coordinator:r.coordinator,events:r.events},null,2))}catch{console.log(s)}})' || true
--}
++case "$SUB" in
++ workers|status|prune) exec bash "$LEGACY" "$SUB" ${REF:+"$REF"} ;;
++ deploy|release|api|rollback|plan) ;;
++ *) sed -n '2,24p' "$0"; exit 1 ;;
++esac
--cmd_prune() {
-- local keep used
-- used=$( (readlink "$ROOT/current"; systemctl list-units --plain --no-legend 'openre-*@*' | awk '{print $1}' | sed -E 's/.*@(.*)\.service/\1/') | xargs -n1 basename 2>/dev/null | sort -u)
-- keep=$(ls -1t "$ROOT/releases" | head -5)
-- for r in $(ls -1 "$ROOT/releases"); do
-- if echo "$used $keep" | grep -qw "$r"; then continue; fi
-- say "removing release $r"
-- git -C "$ROOT/repo" worktree remove --force "$ROOT/releases/$r"
-- done
--}
++probe || legacy "$REASON"
--case "${1:-}" in
-- release) shift; cmd_release "$@" ;;
-- api) shift; cmd_api "$@" ;;
-- workers) shift; cmd_workers "$@" ;;
-- status) cmd_status ;;
-- prune) cmd_prune ;;
-- *) sed -n '2,20p' "$0"; exit 1 ;;
++TO=()
++[ -n "$REF" ] && TO=(--to "$REF")
++case "$SUB" in
++ plan) exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE" "${TO[@]}" ;;
++ release)
++ # As before: progress on stderr, the release's sha12 alone on stdout (SHA=$(deploy.sh release)).
++ "${SUDO[@]}" "$OVHOST" deploy "$SERVICE" --prepare-only "${TO[@]}" "${FLAGS[@]}" >&2
++ git -c safe.directory="$ROOT/repo" -C "$ROOT/repo" rev-parse --short=12 "${REF:-origin/main}" ;;
++ rollback) say "ovhost rollback $SERVICE ${TO[*]:-} ${FLAGS[*]:-}"; exec "${SUDO[@]}" "$OVHOST" rollback "$SERVICE" "${TO[@]}" "${FLAGS[@]}" ;;
++ *) say "ovhost deploy $SERVICE ${TO[*]:-} ${FLAGS[*]:-}"; exec "${SUDO[@]}" "$OVHOST" deploy "$SERVICE" "${TO[@]}" "${FLAGS[@]}" ;;
esac
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
nnew file mode 100644
iindex 0000000..fd33a3b
---- /dev/null
++++ b/test/deploy-wrapper.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy openre` (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 asks for; a fake legacy script records the fallback.
++ * - deploy → ovhost deploy openre; release [<ref>] → --prepare-only, and the sha12 alone on stdout;
++ * api [<sha>] → deploy --to <sha>; rollback → ovhost rollback; plan / DRY_RUN=1 → ovhost plan;
++ * - workers, status and prune always run the legacy script (ovhost never touches a worker unit);
++ * - the fallback: deploy is release then api, rollback <sha> is api <sha>.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync, execFileSync } = require('child_process');
++
++const WRAPPER = path.join(__dirname, '..', 'deploy', 'scripts', 'deploy.sh');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'openre-deploy-wrapper-'));
++const log = path.join(tmp, 'calls.log');
++const sh = (cmd) => execFileSync('bash', ['-c', cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
++
++// The clone the release sha is read from (as deploy.sh release printed it before).
++const root = path.join(tmp, 'opt');
++sh(`git init -q -b main "${root}/repo" && cd "${root}/repo" && git config user.email t@t && git config user.name t && echo x > a && git add a && git commit -qm one && git update-ref refs/remotes/origin/main HEAD`);
++const sha12 = sh(`git -C "${root}/repo" rev-parse --short=12 HEAD`).trim();
++
++const ovhost = path.join(tmp, 'ovhost');
++fs.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=openre\\nstrategy=release-layout\\nmanaged=yes\\nlayout=release}"
++ exit 0
++fi
++echo "ovhost $*" >> "${log}"
++echo "[ovhost] progress output"
++exit "\${FAKE_EXIT:-0}"
++`, { mode: 0o755 });
++const legacy = path.join(tmp, 'legacy.sh');
++fs.writeFileSync(legacy, `#!/usr/bin/env bash\necho "legacy $*" >> "${log}"\n[ "$1" = release ] && echo "${sha12}"\nexit 0\n`, { mode: 0o755 });
++
++function run(args = [], env = {}) {
++ fs.rmSync(log, { force: true });
++ const r = spawnSync('bash', [WRAPPER, ...args], { env: { PATH: process.env.PATH, HOME: tmp, OVHOST: ovhost, OVHOST_SUDO: '', DEPLOY_LEGACY: legacy, OPENRE_ROOT: root, ...env }, encoding: 'utf8' });
++ let calls = [];
++ try { calls = fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { /* none */ }
++ return { code: r.status, stdout: r.stdout, out: r.stdout + r.stderr, calls };
++}
++
++assert.deepStrictEqual(run().calls, ['ovhost deploy openre']);
++assert.deepStrictEqual(run(['deploy', 'origin/main']).calls, ['ovhost deploy openre --to origin/main']);
++let r = run(['release']);
++assert.strictEqual(r.code, 0, r.out);
++assert.deepStrictEqual(r.calls, ['ovhost deploy openre --prepare-only']);
++assert.strictEqual(r.stdout, `${sha12}\n`, 'the sha12 alone on stdout; ovhost progress goes to stderr');
++assert.deepStrictEqual(run(['api', sha12]).calls, [`ovhost deploy openre --to ${sha12}`]);
++assert.deepStrictEqual(run(['api', sha12, '--wait-idle']).calls, [`ovhost deploy openre --to ${sha12} --wait-idle`]);
++assert.deepStrictEqual(run(['rollback']).calls, ['ovhost rollback openre']);
++assert.deepStrictEqual(run(['rollback', sha12]).calls, [`ovhost rollback openre --to ${sha12}`]);
++assert.deepStrictEqual(run(['plan']).calls, ['ovhost plan openre']);
++assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan openre']);
++assert.strictEqual(run(['api'], { FAKE_EXIT: '5' }).code, 5, "ovhost's exit code is the wrapper's (5: ingest sessions)");
++for (const sub of ['workers', 'status', 'prune']) assert.deepStrictEqual(run([sub]).calls, [`legacy ${sub}`], `${sub}: always the legacy script`);
++assert.deepStrictEqual(run(['workers', sha12]).calls, [`legacy workers ${sha12}`]);
++assert.strictEqual(run(['nope']).code, 1);
++assert.strictEqual(run(['api', '--bogus']).code, 1);
++
++// Fallback.
++r = run([], { FAKE_OLD: '1' });
++assert.deepStrictEqual(r.calls, ['legacy release', 'legacy api'], 'deploy is release then api');
++assert.match(r.out, /too old/);
++assert.deepStrictEqual(run(['release', 'origin/main'], { OVHOST_LEGACY: '1' }).calls, ['legacy release origin/main']);
++assert.deepStrictEqual(run(['api', sha12], { OVHOST: path.join(tmp, 'missing') }).calls, [`legacy api ${sha12}`]);
++assert.deepStrictEqual(run(['rollback', sha12], { FAKE_CAPS: 'deploy-api=1\\nstrategy=none\\nmanaged=no' }).calls, [`legacy api ${sha12}`]);
++assert.strictEqual(run(['rollback'], { OVHOST_LEGACY: '1' }).code, 1, 'the legacy script needs the release to go back to');
++assert.match(fs.readFileSync(WRAPPER, 'utf8'), /^set -euo pipefail$/m);
++
++fs.rmSync(tmp, { recursive: true, force: true });
++console.log('deploy wrapper: all checks passed');