openvibers/openvibe.network Commits

deploy/scripts/deploy.sh hands over to ovhost deploy network --install-units (OpenVibe.Host strategy git-checkout; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. ovhost does what it did (fast-forward pull, npm install --omit=dev when the lockfile changed, deploy/systemd/openvibe-network.service installed when it differs with a daemon-reload, restart) as the checkout owner, checks every dependency before the restart, polls /api/ready, rolls back automatically, records the release and honours freezes; a restart with nothing new now needs --restart. The wrapper passes --restart, --wait-idle, --force, --rollback and DRY_RUN=1 (ovhost plan network); it asks ovhost capabilities network first and runs deploy-legacy.sh, saying why, when ovhost is missing, too old or does not manage network (OVHOST_LEGACY=1 forces it). test/deploy-wrapper.test.js (in npm test). README: a Deploying section.

OP OpenVibers committed 27 Sept 2026 · d69ec602b3 · 5 files, +232 −94


Changes

ddiff --git a/README.md b/README.md
iindex c8aa0b21..280cddff 100644
---- a/README.md
++++ b/README.md
@@@ -53,6 +53,7 @@ openvibe.network (port 4000)
│ └── my.html # Account management (profile, sessions, notifications)
├── deploy/
│ ├── nginx/ # openvibe.network.conf
++│ ├── scripts/ # deploy.sh (runs `ovhost deploy network`), deploy-legacy.sh (its fallback)
│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)
└── .env.example
```
@@@ -84,6 +85,19 @@ The server auto-creates the SQLite database and seeds OAuth2 clients on first ru
---
++## Deploying
++
++`sudo /opt/openvibe.network/deploy/scripts/deploy.sh` runs `ovhost deploy network --install-units`
++(OpenVibe.Host, strategy `git-checkout`; roadmap WS-N task 11): a fast-forward pull as the checkout owner,
++`npm install --omit=dev` when the lockfile or dependency fields changed, every dependency checked before
++anything restarts, `deploy/systemd/openvibe-network.service` installed when it differs (daemon-reload), the
++restart, `/api/ready` polled, an automatic rollback if it does not come up, and the release announced.
++`--restart`, `--wait-idle`, `--force` and `--rollback` (`ovhost rollback network`) are passed on; `DRY_RUN=1`
++prints `ovhost plan network`. When ovhost is missing, too old or does not manage Network, the wrapper runs
++`deploy/scripts/deploy-legacy.sh`, the previous script, unchanged (`OVHOST_LEGACY=1` forces it).
++
++---
++
## TLS / Certbot Deploy Support
`openvibe.network` includes a built-in deploy subsystem under `server/deploy/` that can manage Let’s Encrypt certificates and preview Nginx config.
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 00000000..8e066919
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,105 @@
++#!/usr/bin/env bash
++# ═══════════════════════════════════════════════════════════════════════
++# OpenVibe.Network — Deploy Script
++# Pulls latest from GitHub, updates the systemd service unit, and restarts the service.
++#
++# Usage (from the server):
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh
++#
++# Or remotely:
++# ssh openvibe.network "sudo /opt/openvibe.network/deploy/scripts/deploy.sh"
++# ═══════════════════════════════════════════════════════════════════════
++set -euo pipefail
++
++REPO_DIR="${REPO_DIR:-/opt/openvibe.network}"
++SERVICE="${SERVICE:-openvibe-network}"
++SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"
++SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"
++SITE_URL="${SITE_URL:-https://openvibe.network}"
++API_URL="${API_URL:-http://127.0.0.1:4000}"
++GIT_REMOTE="${GIT_REMOTE:-origin}"
++GIT_BRANCH="${GIT_BRANCH:-main}"
++HEALTH_PATH="${HEALTH_PATH:-/api/health}"
++DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"
++
++cd "$REPO_DIR"
++
++echo "╔══════════════════════════════════════╗"
++echo "║ OpenVibe.Network Deploy Script ║"
++echo "╚══════════════════════════════════════╝"
++echo ""
++
++# 1. Record current commit before pull
++OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")
++echo "[Deploy] Current commit: ${OLD_HASH:0:8}"
++
++# 2. Pull latest from GitHub
++echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."
++git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
++NEW_HASH=$(git rev-parse HEAD)
++echo "[Deploy] New commit: ${NEW_HASH:0:8}"
++
++# 2b. Install dependencies when the lockfile changed (e.g. a new openvibe-contracts release).
++if [ "$OLD_HASH" = "none" ] || ! git diff --quiet "$OLD_HASH" "$NEW_HASH" -- package-lock.json; then
++ # install (not ci): updates node_modules in place instead of deleting it under the running service
++ echo "[Deploy] package-lock.json changed: npm install --omit=dev"
++ npm install --omit=dev --no-audit --no-fund
++fi
++
++# 3. Update systemd service config if the repo includes one
++UNIT_UPDATED=false
++if [ -f "$SERVICE_UNIT_SOURCE" ]; then
++ echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"
++ if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then
++ echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"
++ sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"
++ sudo chmod 644 "$SERVICE_UNIT_DEST"
++ UNIT_UPDATED=true
++ else
++ echo "[Deploy] Service unit is already up to date."
++ fi
++ if [ "$UNIT_UPDATED" = true ]; then
++ echo "[Deploy] Reloading systemd daemon..."
++ sudo systemctl daemon-reload
++ sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true
++ fi
++else
++ echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."
++fi
++
++# 4. Check if there are actually new commits
++if [ "$OLD_HASH" = "$NEW_HASH" ]; then
++ echo "[Deploy] Already up to date — no new commits."
++ echo "[Deploy] Restarting service anyway..."
++ sudo systemctl restart "$SERVICE"
++ echo "[Deploy] Done. ✅"
++ exit 0
++fi
++
++# 5. Get commit log between old and new
++COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")
++COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')
++echo "[Deploy] ${COMMIT_COUNT} new commit(s):"
++echo "$COMMIT_LOG"
++echo ""
++
++# 6. Restart the service
++echo "[Deploy] Restarting ${SERVICE}..."
++sudo systemctl restart "$SERVICE"
++
++# 7. Wait for service to come back up
++if [ "$COMMIT_COUNT" -gt 0 ]; then
++ echo -n "[Deploy] Waiting for server..."
++ for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do
++ sleep 1
++ if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then
++ echo " up! ✅"
++ break
++ fi
++ echo -n "."
++ done
++fi
++
++echo ""
++echo "[Deploy] Deployment complete! 🎉"
++echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 8e066919..5e36e86f 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,105 +1,66 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════
--# OpenVibe.Network — Deploy Script
--# Pulls latest from GitHub, updates the systemd service unit, and restarts the service.
++# OpenVibe.Network — deploy: a thin wrapper around `ovhost deploy network --install-units`
++# (OpenVibe.Host, strategy git-checkout; roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md).
#
--# Usage (from the server):
--# sudo /opt/openvibe.network/deploy/scripts/deploy.sh
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh ovhost deploy network --install-units
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh --restart … --restart (the old script restarted even
++# with nothing new; ovhost does only when asked)
++# sudo /opt/openvibe.network/deploy/scripts/deploy.sh --rollback ovhost rollback network
++# DRY_RUN=1 /opt/openvibe.network/deploy/scripts/deploy.sh ovhost plan network
#
--# Or remotely:
--# ssh openvibe.network "sudo /opt/openvibe.network/deploy/scripts/deploy.sh"
++# ovhost does what this script did, as the checkout owner: pull (fast-forward only), npm install --omit=dev when
++# the lockfile or dependency fields changed (then the lockfile is restored), deploy/systemd/openvibe-network.service
++# installed when it differs (daemon-reload), restart, /api/ready polled. New: every dependency must resolve before
++# the restart, a release that does not come up is rolled back (exit 3), every attempt is in `ovhost releases
++# network`, freezes are honoured (exit 6) and the release is announced.
++#
++# Fallback: deploy-legacy.sh (the previous script, unchanged) when ovhost is missing or too old (no
++# `capabilities`, deploy-api < 1), or the host inventory does not manage network; OVHOST_LEGACY=1 forces it.
# ═══════════════════════════════════════════════════════════════════════
set -euo pipefail
--REPO_DIR="${REPO_DIR:-/opt/openvibe.network}"
--SERVICE="${SERVICE:-openvibe-network}"
--SERVICE_UNIT_SOURCE="${SERVICE_UNIT_SOURCE:-$REPO_DIR/deploy/systemd/${SERVICE}.service}"
--SERVICE_UNIT_DEST="${SERVICE_UNIT_DEST:-/etc/systemd/system/${SERVICE}.service}"
--SITE_URL="${SITE_URL:-https://openvibe.network}"
--API_URL="${API_URL:-http://127.0.0.1:4000}"
--GIT_REMOTE="${GIT_REMOTE:-origin}"
--GIT_BRANCH="${GIT_BRANCH:-main}"
--HEALTH_PATH="${HEALTH_PATH:-/api/health}"
--DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-15}"
--
--cd "$REPO_DIR"
--
--echo "╔══════════════════════════════════════╗"
--echo "║ OpenVibe.Network Deploy Script ║"
--echo "╚══════════════════════════════════════╝"
--echo ""
--
--# 1. Record current commit before pull
--OLD_HASH=$(git rev-parse HEAD 2>/dev/null || echo "none")
--echo "[Deploy] Current commit: ${OLD_HASH:0:8}"
--
--# 2. Pull latest from GitHub
--echo "[Deploy] Pulling from ${GIT_REMOTE} ${GIT_BRANCH}..."
--git pull "$GIT_REMOTE" "$GIT_BRANCH" --ff-only
--NEW_HASH=$(git rev-parse HEAD)
--echo "[Deploy] New commit: ${NEW_HASH:0:8}"
--
--# 2b. Install dependencies when the lockfile changed (e.g. a new openvibe-contracts release).
--if [ "$OLD_HASH" = "none" ] || ! git diff --quiet "$OLD_HASH" "$NEW_HASH" -- package-lock.json; then
-- # install (not ci): updates node_modules in place instead of deleting it under the running service
-- echo "[Deploy] package-lock.json changed: npm install --omit=dev"
-- npm install --omit=dev --no-audit --no-fund
--fi
--
--# 3. Update systemd service config if the repo includes one
--UNIT_UPDATED=false
--if [ -f "$SERVICE_UNIT_SOURCE" ]; then
-- echo "[Deploy] Found service unit source: ${SERVICE_UNIT_SOURCE}"
-- if [ ! -f "$SERVICE_UNIT_DEST" ] || ! cmp -s "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"; then
-- echo "[Deploy] Installing updated service unit to ${SERVICE_UNIT_DEST}"
-- sudo cp "$SERVICE_UNIT_SOURCE" "$SERVICE_UNIT_DEST"
-- sudo chmod 644 "$SERVICE_UNIT_DEST"
-- UNIT_UPDATED=true
-- else
-- echo "[Deploy] Service unit is already up to date."
-- fi
-- if [ "$UNIT_UPDATED" = true ]; then
-- echo "[Deploy] Reloading systemd daemon..."
-- sudo systemctl daemon-reload
-- sudo systemctl enable "$SERVICE" >/dev/null 2>&1 || true
-- fi
--else
-- echo "[Deploy] No service unit file found at ${SERVICE_UNIT_SOURCE}, skipping systemd update."
--fi
++SERVICE=network
++STRATEGY=git-checkout
++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
--# 4. Check if there are actually new commits
--if [ "$OLD_HASH" = "$NEW_HASH" ]; then
-- echo "[Deploy] Already up to date — no new commits."
-- echo "[Deploy] Restarting service anyway..."
-- sudo systemctl restart "$SERVICE"
-- echo "[Deploy] Done. ✅"
-- exit 0
--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 [--restart] [--wait-idle] [--force] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;
++ esac
++done
--# 5. Get commit log between old and new
--COMMIT_LOG=$(git --no-pager log --oneline "${OLD_HASH}..${NEW_HASH}" 2>/dev/null || echo "Update deployed")
--COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l | tr -d ' ')
--echo "[Deploy] ${COMMIT_COUNT} new commit(s):"
--echo "$COMMIT_LOG"
--echo ""
++legacy() {
++ echo "[Deploy] $1 — running deploy-legacy.sh (the previous deploy script) instead"
++ if [ "$CMD" = rollback ] || [ "${DRY_RUN:-0}" = 1 ]; then echo "[Deploy] ✗ deploy-legacy.sh has no --rollback or DRY_RUN; nothing was done" >&2; exit 1; fi
++ exec bash "$LEGACY"
++}
--# 6. Restart the service
--echo "[Deploy] Restarting ${SERVICE}..."
--sudo systemctl restart "$SERVICE"
++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
++}
--# 7. Wait for service to come back up
--if [ "$COMMIT_COUNT" -gt 0 ]; then
-- echo -n "[Deploy] Waiting for server..."
-- for i in $(seq 1 "${DEPLOY_TIMEOUT}"); do
-- sleep 1
-- if curl -sf "${API_URL}${HEALTH_PATH}" > /dev/null 2>&1; then
-- echo " up! ✅"
-- break
-- fi
-- echo -n "."
-- done
--fi
++probe || legacy "$REASON"
--echo ""
--echo "[Deploy] Deployment complete! 🎉"
--echo "[Deploy] ${COMMIT_COUNT} commit(s) deployed: ${OLD_HASH:0:8} → ${NEW_HASH:0:8}"
++if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"; fi
++[ "$CMD" = deploy ] && FLAGS+=(--install-units)
++echo "[Deploy] ovhost $CMD $SERVICE ${FLAGS[*]}"
++exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"
ddiff --git a/package.json b/package.json
iindex fed20db3..0ce74a57 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/test/deploy-wrapper.test.js b/test/deploy-wrapper.test.js
nnew file mode 100644
iindex 00000000..c1bef53a
---- /dev/null
++++ b/test/deploy-wrapper.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++/**
++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy network --install-units` (OpenVibe.Host,
++ * strategy git-checkout; 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.
++ */
++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(), 'network-deploy-wrapper-'));
++const log = path.join(tmp, 'calls.log');
++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=network\\nstrategy=git-checkout\\nmanaged=yes\\nlayout=git}"
++ 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" >> "${log}"\n`, { mode: 0o755 });
++
++function 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 };
++}
++
++// The unit file is installed on every deploy, as the old script did.
++assert.deepStrictEqual(run().calls, ['ovhost deploy network --install-units']);
++assert.deepStrictEqual(run(['--restart']).calls, ['ovhost deploy network --restart --install-units']);
++assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback network']);
++assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan network']);
++assert.strictEqual(run([], { FAKE_EXIT: '3' }).code, 3, "ovhost's exit code is the wrapper's");
++assert.strictEqual(run(['--nope']).code, 1);
++
++let r = run([], { FAKE_OLD: '1' });
++assert.deepStrictEqual(r.calls, ['legacy']);
++assert.match(r.out, /too old/);
++r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=git-checkout\\nmanaged=no' });
++assert.deepStrictEqual(r.calls, ['legacy']);
++assert.match(r.out, /does not manage network/);
++assert.deepStrictEqual(run([], { OVHOST: path.join(tmp, 'missing') }).calls, ['legacy']);
++assert.strictEqual(run(['--rollback'], { OVHOST_LEGACY: '1' }).code, 1, 'the legacy script has no rollback');
++const legacyText = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
++assert.match(legacyText, /git pull "\$GIT_REMOTE" "\$GIT_BRANCH" --ff-only/, 'deploy-legacy.sh is the previous script');
++assert.match(fs.readFileSync(WRAPPER, 'utf8'), /^set -euo pipefail$/m);
++
++fs.rmSync(tmp, { recursive: true, force: true });
++console.log('deploy wrapper: all checks passed');