openvibers/openvibe.tools Commits

deploy/scripts/deploy.sh hands over to ovhost deploy tools (OpenVibe.Host strategy multi-app; roadmap WS-N task 11). The old script is kept, unchanged, as deploy/scripts/deploy-legacy.sh. ovhost does what it did (untracked lockfiles the release tracks removed first, per-app installs with apps/_* skipped, every dependency resolves, the jobs runtime in img/audio/docs and the guard in every app load, data/ made, every openvibe-tools* unit restarted) and adds an automatic rollback, /api/ready with the new sha on the gateway's /release.json and every unit active, freezes and the release log. The wrapper passes --wait-idle, --restart, --force, --rollback and DRY_RUN=1 (ovhost plan tools); it asks ovhost capabilities tools first and runs deploy-legacy.sh, saying why, when ovhost is missing, too old or the host inventory does not deploy tools with multi-app (OVHOST_LEGACY=1 forces it), refusing --rollback, --wait-idle and DRY_RUN there rather than ignoring them. apps/_shared/test/deploy-wrapper.test.js; apps/_shared/test/version.test.js checks the apps/_* skip in deploy-legacy.sh and that the wrapper only hands over to multi-app. README.

OP OpenVibers committed 27 Sept 2026 · 57682322e0 · 5 files, +215 −76


Changes

ddiff --git a/README.md b/README.md
iindex 3258a74..7feb818 100644
---- a/README.md
++++ b/README.md
@@@ -63,10 +63,15 @@ Public, cacheable outputs on the apex: `/` (every tool by family, planned tools
`/<family>-tools`, `/tool/<id>`, `/all-tools`, `/search?q=`, `/sitemap.xml`, `/robots.txt`,
`/llms.txt`, `/api/catalog.json`, `/terms`, `/privacy`, `/dmca`. All of it renders without JavaScript.
--Deploy with `deploy/scripts/deploy.sh` (it refreshes the copied shared package inside each app). Once the
--gateway is healthy, it runs `ovhost announce tools`. OpenVibe.Host then publishes `host.release.published`
--to OpenVibe.Events, and open tabs check `/release.json` within seconds instead of at their next poll. This
--is best effort: it is skipped without an `ovhost` that has `announce`, and it never fails the deploy.
++Deploy with `deploy/scripts/deploy.sh`: it runs `ovhost deploy tools` (OpenVibe.Host, strategy
++`multi-app`; `--wait-idle`, `--restart`, `--rollback`, `DRY_RUN=1` for `ovhost plan tools`). ovhost pulls
++as the checkout owner, installs in each app whose dependencies changed (apps/_* are packages, not apps),
++checks every dependency resolves and that the jobs runtime and the guard load, restarts every
++`openvibe-tools*` unit, waits for the gateway's `/api/ready` and its `/release.json` to name the new sha
++with every unit active, rolls back if they do not, and announces the release (`host.release.published`
++to OpenVibe.Events, so open tabs check `/release.json` within seconds). When ovhost is missing, too old or
++does not deploy Tools with that strategy, the wrapper runs `deploy/scripts/deploy-legacy.sh`, the previous
++script, unchanged (`OVHOST_LEGACY=1` forces it).
## Tool registry API (ADR-027)
@@@ -533,8 +538,8 @@ The full OAuth round-trip needs OpenVibe.Network running on port 4000
(`apps/<name>/deploy/systemd/`). Resource bounds: `MemoryMax=2G` (img, audio,
docs), `1G` (yt), `768M` (gateway, text, maps, food); `TasksMax=256`; `Nice=5`
for img, audio, docs and yt. Every app writes `data/guard.db` (the gateway
-- too: `ReadWritePaths=/opt/openvibe.tools/apps/gateway/data`; `deploy.sh`
-- creates each `data/` before restarting).
++ too: `ReadWritePaths=/opt/openvibe.tools/apps/gateway/data`; the deploy
++ (`ovhost deploy tools`, or `deploy-legacy.sh`) creates each `data/` before restarting).
- Nginx: satellites have specific `server_name` blocks; the gateway's
wildcard `*.openvibe.tools` block catches everything else. TLS via
`/etc/letsencrypt/live/openvibe.tools/`.
ddiff --git a/apps/_shared/test/deploy-wrapper.test.js b/apps/_shared/test/deploy-wrapper.test.js
nnew file mode 100644
iindex 0000000..481cbd9
---- /dev/null
++++ b/apps/_shared/test/deploy-wrapper.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * deploy/scripts/deploy.sh is a thin wrapper around `ovhost deploy tools` (OpenVibe.Host, strategy
++ * multi-app; 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(), 'tools-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=tools\\nstrategy=multi-app\\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 };
++}
++
++assert.deepStrictEqual(run().calls, ['ovhost deploy tools']);
++assert.deepStrictEqual(run(['--wait-idle']).calls, ['ovhost deploy tools --wait-idle']);
++assert.deepStrictEqual(run(['--rollback']).calls, ['ovhost rollback tools']);
++assert.deepStrictEqual(run([], { DRY_RUN: '1' }).calls, ['ovhost plan tools']);
++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'], 'too old: the legacy script');
++assert.match(r.out, /too old/);
++r = run([], { FAKE_CAPS: 'deploy-api=1\\nstrategy=git-checkout\\nmanaged=yes' });
++assert.deepStrictEqual(r.calls, ['legacy'], 'another strategy: the legacy script');
++assert.match(r.out, /does not deploy tools with strategy multi-app \(git-checkout\)/);
++r = run([], { OVHOST: path.join(tmp, 'missing') });
++assert.deepStrictEqual(r.calls, ['legacy']);
++for (const [args, env] of [[['--rollback'], {}], [['--wait-idle'], {}], [[], { DRY_RUN: '1' }]]) {
++ r = run(args, { OVHOST_LEGACY: '1', ...env });
++ assert.strictEqual(r.code, 1, `${args.join(' ')} ${JSON.stringify(env)}: the legacy script cannot, so nothing runs`);
++ assert.deepStrictEqual(r.calls, []);
++}
++assert.match(fs.readFileSync(WRAPPER, 'utf8'), /^set -euo pipefail$/m);
++
++fs.rmSync(tmp, { recursive: true, force: true });
++console.log('deploy wrapper: all checks passed');
ddiff --git a/apps/_shared/test/version.test.js b/apps/_shared/test/version.test.js
iindex f9cbb87..dd5ec6a 100644
---- a/apps/_shared/test/version.test.js
++++ b/apps/_shared/test/version.test.js
@@@ -50,11 +50,14 @@ assert.throws(() => requireShared('next', { appsDir: dir, version: '1.0.0' }), /
assert.throws(() => require('../release').toolsRelease('no-such-app', require), /declares no "openvibeToolsShared" range/, 'toolsRelease refuses an undeclared app before anything else');
fs.rmSync(dir, { recursive: true, force: true });
--// deploy.sh walks apps/*/ to install, resolve and preflight each app; _shared has a package.json now
++// The deploy walks apps/*/ to install, resolve and preflight each app; _shared has a package.json now
// but is not an app (2026-09-26: the guard preflight tried to load better-sqlite3 from it and aborted).
--const deploy = fs.readFileSync(path.join(APPS, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8');
--const loops = deploy.split('\n').map((l, i, all) => [l, all[i + 1] || '']).filter(([l]) => /^for app in apps\/\*\/; do/.test(l));
--assert.ok(loops.length >= 3, 'deploy.sh has its app loops');
--for (const [, next] of loops) assert.match(next, /case "\$app" in apps\/_\*\) continue ;; esac/, 'each app loop in deploy.sh skips apps/_*');
++// deploy.sh hands over to `ovhost deploy tools` (OpenVibe.Host strategy multi-app, whose default
++// skipPackages is apps/_*) and falls back to deploy-legacy.sh, whose app loops skip it themselves.
++const legacy = fs.readFileSync(path.join(APPS, '..', 'deploy', 'scripts', 'deploy-legacy.sh'), 'utf8');
++const loops = legacy.split('\n').map((l, i, all) => [l, all[i + 1] || '']).filter(([l]) => /^for app in apps\/\*\/; do/.test(l));
++assert.ok(loops.length >= 3, 'deploy-legacy.sh has its app loops');
++for (const [, next] of loops) assert.match(next, /case "\$app" in apps\/_\*\) continue ;; esac/, 'each app loop in deploy-legacy.sh skips apps/_*');
++assert.match(fs.readFileSync(path.join(APPS, '..', 'deploy', 'scripts', 'deploy.sh'), 'utf8'), /^STRATEGY=multi-app$/m, 'deploy.sh hands over only to the multi-app strategy (apps/_* skipped)');
console.log('_shared version: all checks passed');
ddiff --git a/deploy/scripts/deploy-legacy.sh b/deploy/scripts/deploy-legacy.sh
nnew file mode 100755
iindex 0000000..8970924
---- /dev/null
++++ b/deploy/scripts/deploy-legacy.sh
@@@ -0,0 +1,69 @@
++#!/usr/bin/env bash
++# Deploy OpenVibe.Tools on the host: pull, install dependencies in each app whose package.json
++# changed (that includes a new openvibe-shared release tag), restart the units, check health, then
++# announce the release to open tabs (`ovhost announce tools`; best effort, never fails the deploy).
++set -euo pipefail
++cd "$(dirname "$0")/../.."
++BEFORE=$(git rev-parse HEAD)
++# npm leaves untracked lockfiles in apps that did not track one; once the repo starts tracking it,
++# `git pull` refuses to overwrite it. Those files are generated, so remove exactly those before pulling.
++git fetch -q origin
++for f in $(git diff --name-only HEAD "origin/$(git rev-parse --abbrev-ref HEAD)" -- 'apps/*/package-lock.json'); do
++ if [ -f "$f" ] && ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then echo "removing untracked $f (the release tracks it)"; rm -f "$f"; fi
++done
++git pull -q --ff-only; AFTER=$(git rev-parse HEAD)
++for app in apps/*/; do
++ case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
++ [ -f "$app/package.json" ] || continue
++ if git diff --name-only "$BEFORE" "$AFTER" -- "$app/package.json" | grep -q . || [ ! -d "$app/node_modules" ]; then (cd "$app" && npm install --omit=dev --no-audit --no-fund --loglevel=error); fi
++done
++# Every app must resolve its dependencies before anything restarts. A dependency that used to be a
++# file: link can leave an empty directory npm treats as installed (2026-09-23: vendor/openvibe-shared
++# kept an untracked lockfile, every unit crash-looped on 'openvibe-shared/brand'); reinstall those.
++for app in apps/*/; do
++ case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
++ [ -f "$app/package.json" ] || continue
++ for dep in $(node -e 'console.log(Object.keys(require("./"+process.argv[1]+"package.json").dependencies||{}).join(" "))' "$app"); do
++ if [ ! -f "$app/node_modules/$dep/package.json" ]; then
++ echo "reinstalling $dep in $app (missing package.json)"; rm -rf "$app/node_modules/$dep"
++ (cd "$app" && npm install --omit=dev --no-audit --no-fund --loglevel=error)
++ [ -f "$app/node_modules/$dep/package.json" ] || { echo "ABORT: $app cannot resolve $dep; nothing restarted" >&2; exit 1; }
++ fi
++ done
++done
++# Apps that run jobs (apps/_shared/jobs, required by relative path) hand it their own better-sqlite3,
++# openvibe-contracts and openvibe-sdk (the tools.job.* outbox relay): check they load under this Node (a
++# native-module ABI mismatch shows up here, not in a crash loop) and that the shared runtime itself loads.
++for app in img audio docs; do
++ (cd "apps/$app" && node -e "const D=require('better-sqlite3'); new D(':memory:').close(); require('openvibe-contracts'); require('openvibe-sdk'); require('../_shared/jobs')") \
++ || { echo "ABORT: apps/$app cannot load the jobs runtime; nothing restarted" >&2; exit 1; }
++done
++# Every app keeps its guard (apps/_shared/guard) state in data/guard.db through its own better-sqlite3;
++# the units' ReadWritePaths name each data directory, which must exist before systemd starts them.
++for app in apps/*/; do
++ case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
++ [ -f "$app/package.json" ] || continue
++ mkdir -p "$app/data"
++ (cd "$app" && node -e "const D=require('better-sqlite3'); new D(':memory:').close(); require('../_shared/guard')") \
++ || { echo "ABORT: $app cannot load the guard; nothing restarted" >&2; exit 1; }
++done
++UNITS=$(systemctl list-unit-files 'openvibe-tools*' --no-legend | awk '{print $1}')
++sudo systemctl restart $UNITS
++sleep 5
++for u in $UNITS; do printf '%-34s %s\n' "$u" "$(systemctl is-active "$u")"; done
++curl -fsS -o /dev/null -H 'Host: openvibe.tools' http://127.0.0.1:4001/api/health && echo "gateway healthy ($BEFORE → $AFTER)" || exit $?
++# Release notification (roadmap WS-P task 9): OpenVibe.Host publishes host.release.published for the release
++# the apps' /release.json reports, once per release, so open tabs check it now instead of at their next
++# poll. Best effort: skipped without an ovhost whose --help has `announce <service>`, 20 s at most, and it
++# never changes the exit code. ovhost reads Host's credentials as root (OpenVibe.Host
++# docs/release-notifications.md).
++OVHOST_BIN=$(command -v "${OVHOST:-ovhost}" 2>/dev/null || true)
++if [ -n "$OVHOST_BIN" ]; then
++ case "$("$OVHOST_BIN" --help 2>/dev/null || true)" in
++ *"announce <service>"*)
++ SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
++ timeout 20 $SUDO "$OVHOST_BIN" announce tools 2>&1 || echo "release notification not sent (the deploy stands)" ;;
++ *) echo "release notification skipped: this ovhost has no announce" ;;
++ esac
++fi
++exit 0
ddiff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
iindex 8970924..0590eb0 100755
---- a/deploy/scripts/deploy.sh
++++ b/deploy/scripts/deploy.sh
@@@ -1,69 +1,71 @@
#!/usr/bin/env bash
--# Deploy OpenVibe.Tools on the host: pull, install dependencies in each app whose package.json
--# changed (that includes a new openvibe-shared release tag), restart the units, check health, then
--# announce the release to open tabs (`ovhost announce tools`; best effort, never fails the deploy).
++# OpenVibe.Tools — deploy: a thin wrapper around `ovhost deploy tools` (OpenVibe.Host, strategy multi-app;
++# roadmap WS-N task 11; OpenVibe.Host docs/deploy-strategies.md).
++#
++# ovhost now does what this script did, as the checkout owner: untracked apps/*/package-lock.json files the
++# release tracks are removed before the merge; npm install in each app whose lockfile or dependencies changed
++# (apps/_* are packages, not apps); every dependency resolves (reinstalled once, else ABORT with nothing
++# restarted); each app's data/ exists; the jobs runtime loads in img/audio/docs and the guard in every app;
++# every openvibe-tools* unit restarts; the gateway answers /api/ready with Host: openvibe.tools and its
++# /release.json names the new sha, every unit is active; the release is announced. New: a release that does
++# not come up is rolled back (the checkout, its dependencies, a second restart; exit 3), and every attempt
++# is in `ovhost releases tools`.
++#
++# deploy/scripts/deploy.sh ovhost deploy tools
++# deploy/scripts/deploy.sh --wait-idle ovhost deploy tools --wait-idle (hold until no tool job runs)
++# deploy/scripts/deploy.sh --restart ovhost deploy tools --restart
++# deploy/scripts/deploy.sh --rollback ovhost rollback tools
++# DRY_RUN=1 deploy/scripts/deploy.sh ovhost plan tools
++#
++# ovhost runs as root (sudo is used when this runs as another user). 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 deploy tools with strategy multi-app; OVHOST_LEGACY=1 forces it. The legacy
++# script takes no flags: --rollback, --wait-idle and DRY_RUN=1 are refused rather than ignored there.
set -euo pipefail
--cd "$(dirname "$0")/../.."
--BEFORE=$(git rev-parse HEAD)
--# npm leaves untracked lockfiles in apps that did not track one; once the repo starts tracking it,
--# `git pull` refuses to overwrite it. Those files are generated, so remove exactly those before pulling.
--git fetch -q origin
--for f in $(git diff --name-only HEAD "origin/$(git rev-parse --abbrev-ref HEAD)" -- 'apps/*/package-lock.json'); do
-- if [ -f "$f" ] && ! git ls-files --error-unmatch "$f" >/dev/null 2>&1; then echo "removing untracked $f (the release tracks it)"; rm -f "$f"; fi
++
++SERVICE=tools
++STRATEGY=multi-app
++HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
++LEGACY="${DEPLOY_LEGACY:-$HERE/deploy-legacy.sh}"
++OVHOST="${OVHOST:-/usr/local/bin/ovhost}"
++if [ "${OVHOST_SUDO-auto}" = auto ]; then if [ "$(id -u)" -eq 0 ]; then SUDO=(); else SUDO=(sudo); fi; elif [ -n "${OVHOST_SUDO}" ]; then SUDO=("$OVHOST_SUDO"); else SUDO=(); fi
++
++CMD=deploy
++FLAGS=()
++while [ "$#" -gt 0 ]; do
++ case "$1" in
++ --wait-idle|--restart|--force) FLAGS+=("$1"); shift ;;
++ --rollback) CMD=rollback; shift ;;
++ --) shift; break ;;
++ *) echo "Usage: $0 [--wait-idle] [--restart] [--force] [--rollback] (DRY_RUN=1 for the plan)"; exit 1 ;;
++ esac
done
--git pull -q --ff-only; AFTER=$(git rev-parse HEAD)
--for app in apps/*/; do
-- case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
-- [ -f "$app/package.json" ] || continue
-- if git diff --name-only "$BEFORE" "$AFTER" -- "$app/package.json" | grep -q . || [ ! -d "$app/node_modules" ]; then (cd "$app" && npm install --omit=dev --no-audit --no-fund --loglevel=error); fi
--done
--# Every app must resolve its dependencies before anything restarts. A dependency that used to be a
--# file: link can leave an empty directory npm treats as installed (2026-09-23: vendor/openvibe-shared
--# kept an untracked lockfile, every unit crash-looped on 'openvibe-shared/brand'); reinstall those.
--for app in apps/*/; do
-- case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
-- [ -f "$app/package.json" ] || continue
-- for dep in $(node -e 'console.log(Object.keys(require("./"+process.argv[1]+"package.json").dependencies||{}).join(" "))' "$app"); do
-- if [ ! -f "$app/node_modules/$dep/package.json" ]; then
-- echo "reinstalling $dep in $app (missing package.json)"; rm -rf "$app/node_modules/$dep"
-- (cd "$app" && npm install --omit=dev --no-audit --no-fund --loglevel=error)
-- [ -f "$app/node_modules/$dep/package.json" ] || { echo "ABORT: $app cannot resolve $dep; nothing restarted" >&2; exit 1; }
++
++legacy() {
++ echo "[tools-deploy] $1 — running deploy-legacy.sh (the previous deploy script) instead"
++ if [ "$CMD" = rollback ] || [ "${DRY_RUN:-0}" = 1 ] || [[ " ${FLAGS[*]:-} " == *" --wait-idle "* ]]; then
++ echo "[tools-deploy] ✗ deploy-legacy.sh has no --rollback, --wait-idle or DRY_RUN; nothing was done" >&2
++ exit 1
fi
-- done
--done
--# Apps that run jobs (apps/_shared/jobs, required by relative path) hand it their own better-sqlite3,
--# openvibe-contracts and openvibe-sdk (the tools.job.* outbox relay): check they load under this Node (a
--# native-module ABI mismatch shows up here, not in a crash loop) and that the shared runtime itself loads.
--for app in img audio docs; do
-- (cd "apps/$app" && node -e "const D=require('better-sqlite3'); new D(':memory:').close(); require('openvibe-contracts'); require('openvibe-sdk'); require('../_shared/jobs')") \
-- || { echo "ABORT: apps/$app cannot load the jobs runtime; nothing restarted" >&2; exit 1; }
--done
--# Every app keeps its guard (apps/_shared/guard) state in data/guard.db through its own better-sqlite3;
--# the units' ReadWritePaths name each data directory, which must exist before systemd starts them.
--for app in apps/*/; do
-- case "$app" in apps/_*) continue ;; esac # apps/_shared is a package the apps require, not an app
-- [ -f "$app/package.json" ] || continue
-- mkdir -p "$app/data"
-- (cd "$app" && node -e "const D=require('better-sqlite3'); new D(':memory:').close(); require('../_shared/guard')") \
-- || { echo "ABORT: $app cannot load the guard; nothing restarted" >&2; exit 1; }
--done
--UNITS=$(systemctl list-unit-files 'openvibe-tools*' --no-legend | awk '{print $1}')
--sudo systemctl restart $UNITS
--sleep 5
--for u in $UNITS; do printf '%-34s %s\n' "$u" "$(systemctl is-active "$u")"; done
--curl -fsS -o /dev/null -H 'Host: openvibe.tools' http://127.0.0.1:4001/api/health && echo "gateway healthy ($BEFORE → $AFTER)" || exit $?
--# Release notification (roadmap WS-P task 9): OpenVibe.Host publishes host.release.published for the release
--# the apps' /release.json reports, once per release, so open tabs check it now instead of at their next
--# poll. Best effort: skipped without an ovhost whose --help has `announce <service>`, 20 s at most, and it
--# never changes the exit code. ovhost reads Host's credentials as root (OpenVibe.Host
--# docs/release-notifications.md).
--OVHOST_BIN=$(command -v "${OVHOST:-ovhost}" 2>/dev/null || true)
--if [ -n "$OVHOST_BIN" ]; then
-- case "$("$OVHOST_BIN" --help 2>/dev/null || true)" in
-- *"announce <service>"*)
-- SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"
-- timeout 20 $SUDO "$OVHOST_BIN" announce tools 2>&1 || echo "release notification not sent (the deploy stands)" ;;
-- *) echo "release notification skipped: this ovhost has no announce" ;;
-- esac
--fi
--exit 0
++ exec bash "$LEGACY"
++}
++
++REASON=""
++probe() {
++ if [ "${OVHOST_LEGACY:-0}" = 1 ]; then REASON="OVHOST_LEGACY=1"; return 1; fi
++ if ! command -v "$OVHOST" >/dev/null 2>&1; then REASON="ovhost not found ($OVHOST)"; return 1; fi
++ local caps api
++ if ! caps=$("${SUDO[@]}" "$OVHOST" capabilities "$SERVICE" 2>/dev/null); then REASON="this ovhost has no 'capabilities' (too old) or no inventory entry for $SERVICE"; return 1; fi
++ api=$(printf '%s\n' "$caps" | sed -n 's/^deploy-api=//p')
++ case "$api" in ''|*[!0-9]*) REASON="this ovhost reports no deploy-api (too old)"; return 1 ;; esac
++ if [ "$api" -lt 1 ]; then REASON="this ovhost's deploy-api is $api, 1 is needed"; return 1; fi
++ if ! printf '%s\n' "$caps" | grep -qx "strategy=$STRATEGY"; then REASON="the host inventory does not deploy $SERVICE with strategy $STRATEGY ($(printf '%s\n' "$caps" | sed -n 's/^strategy=//p'))"; return 1; fi
++ if ! printf '%s\n' "$caps" | grep -qx "managed=yes"; then REASON="ovhost does not manage $SERVICE"; return 1; fi
++ return 0
++}
++
++probe || legacy "$REASON"
++
++if [ "${DRY_RUN:-0}" = 1 ]; then exec "${SUDO[@]}" "$OVHOST" plan "$SERVICE"; fi
++echo "[tools-deploy] ovhost $CMD $SERVICE ${FLAGS[*]:-}"
++exec "${SUDO[@]}" "$OVHOST" "$CMD" "$SERVICE" "${FLAGS[@]}"