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.
Changes
@@@ -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.nnew file mode 100755@@@ -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}"@@@ -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[@]}"@@@ -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"},nnew file mode 100644@@@ -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');
@@ -53,6 +53,7 @@ openvibe.network (port 4000)
@@ -53,6 +53,7 @@ openvibe.network (port 4000)
│ └── my.html # Account management (profile, sessions, notifications)
│ └── my.html # Account management (profile, sessions, notifications)
├── deploy/
├── deploy/
│ ├── nginx/ # openvibe.network.conf
│ ├── 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)
│ └── systemd/ # openvibe-network.service (EnvironmentFile=/etc/openvibe/network.env)
└── .env.example
└── .env.example
```
```
@@ -84,6 +85,19 @@ The server auto-creates the SQLite database and seeds OAuth2 clients on first ru
@@ -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
## 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.
`openvibe.network` includes a built-in deploy subsystem under `server/deploy/` that can manage Let’s Encrypt certificates and preview Nginx config.
new file mode 100755
new file mode 100755
@@ -0,0 +1,105 @@
@@ -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}"
@@ -1,105 +1,66 @@
@@ -1,105 +1,66 @@
#!/usr/bin/env bash
#!/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
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[@]}"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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');