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.
Changes
@@@ -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'swildcard `*.openvibe.tools` block catches everything else. TLS via`/etc/letsencrypt/live/openvibe.tools/`.nnew file mode 100644@@@ -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');@@@ -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');nnew file mode 100755@@@ -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@@@ -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 ;;++ esacdone--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 1fi-- 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[@]}"
@@ -63,10 +63,15 @@ Public, cacheable outputs on the apex: `/` (every tool by family, planned tools
@@ -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`,
`/<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.
`/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)
## Tool registry API (ADR-027)
@@ -533,8 +538,8 @@ The full OAuth round-trip needs OpenVibe.Network running on port 4000
@@ -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,
(`apps/<name>/deploy/systemd/`). Resource bounds: `MemoryMax=2G` (img, audio,
docs), `1G` (yt), `768M` (gateway, text, maps, food); `TasksMax=256`; `Nice=5`
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
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
- Nginx: satellites have specific `server_name` blocks; the gateway's
wildcard `*.openvibe.tools` block catches everything else. TLS via
wildcard `*.openvibe.tools` block catches everything else. TLS via
`/etc/letsencrypt/live/openvibe.tools/`.
`/etc/letsencrypt/live/openvibe.tools/`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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');
@@ -50,11 +50,14 @@ assert.throws(() => requireShared('next', { appsDir: dir, version: '1.0.0' }), /
@@ -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');
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 });
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).
// 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');
console.log('_shared version: all checks passed');
new file mode 100755
new file mode 100755
@@ -0,0 +1,69 @@
@@ -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
@@ -1,69 +1,71 @@
@@ -1,69 +1,71 @@
#!/usr/bin/env bash
#!/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
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
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
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[@]}"