openvibers/openvibe.host Commits

List unset env names and next setup steps after ovhost service add (#11)

OP OpenVibers committed 4 Oct 2026 · 9014e68224 · 6 files, +397 −3

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Host/pull/11

Changes

ddiff --git a/README.md b/README.md
iindex 1d65f35..0abb0f2 100644
---- a/README.md
++++ b/README.md
@@@ -120,6 +120,7 @@ ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--inst
ovhost rollback <service> [--to <sha|release id>] [--wait-idle] [--force] [--no-announce]
ovhost capabilities [<service>] key=value lines the deploy wrappers probe (deploy-api, strategy, managed)
ovhost self-update [--dry-run] update the CLI install (/usr/local/lib/openvibe-host) itself: fetch origin main, --ff-only merge, npm ci
++ovhost service add <id> [--dry-run] first setup: inventory entry, checkout, 0600 env file, database if DATABASE_URL is declared
ovhost data provision <service> [--dry-run] run roles/data/add-service.sh from the install: PostgreSQL database, roles and Valkey user (names only)
ovhost data switch <service> [--sqlite <file>] [--dry-run] run roles/data/switch-service.sh: stop, back up, import, deploy (docs/data-provisioning.md)
ovhost --version
@@@ -139,6 +140,8 @@ ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
Every command accepts `--json` and `--inventory <file>`. `drill` exits `0` passed, `1` refused (not root, port in use, no backup, unsupported), `2` failed. `self-update` exits `0` updated (or already up to date, or `--dry-run`), `1` refused (not root, dirty tree, not on `main`, diverged), `2` the checkout moved but `npm ci` failed. Other exit codes: `0` ok · `1` usage/precondition (including a held lock) · `2` validation failed, nothing restarted · `3` not ready, rolled back and serving · `4` rollback failed, **manual intervention** · `5` protected sessions active (refused, or `--wait-idle` gave up) · `6` frozen (`ovhost freeze`; `--force` goes through, rollbacks are never frozen).
++`service add` runs as root against `/etc/openvibe/host.json`. It copies `services.<id>` from the installed CLI's `host.example.json`, drops `_note`, and backs up the live inventory before changing it. The example entry may set `cloneUrl` to a credential-free GitHub HTTPS URL; otherwise the repository is `https://github.com/OpenVibers/OpenVibe.<Capitalized id>.git`. The command refuses an existing entry, occupied checkout or env file. It holds the inventory lock (`<stateDir>/locks/_inventory.lock`) from reading the inventory to the end, and refuses a symlinked `.env.example`, which it reads with the checkout owner's permissions. It clones as the checkout owner, creates the entry's `envFile` (`/etc/openvibe/<id>.env`, 0600) as a copy of the checkout's `.env.example` with production `NODE_ENV`, `HOST` and `PORT`. It sets `BASE_URL` when the inventory vhost, origin or env example supplies a public HTTPS origin; otherwise it leaves an existing empty `BASE_URL` for the operator and does not add one to services without it. An explicitly required `BASE_URL` needs an inventory vhost or origin before setup begins. The tracked `.env.example` is never changed. It runs `data provision` if that example declares `DATABASE_URL`. Output lists env names but never values. `--dry-run` shows the plan without changing files. It then lists the env names still empty (leaving out the production overrides and, after provisioning, `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL`, `VALKEY_PREFIX`). When the example declares `OV_OAUTH_CLIENT_ID` or `OV_OAUTH_CLIENT_SECRET`, it prints one root command for the Network service principal. The command runs `server/setup/service-principal.js` through `systemd-run` with Network's env file (`services.network.envFile`, default `/etc/openvibe/network.env`) in Network's checkout (`services.network.repo`, default `/opt/openvibe.network`). It uses `rotate` when `list` already shows the id and `create` otherwise, so the principal never lands in a development database (the secret is never printed). It ends with `then: ovhost validate <id>; ovhost deploy <id> --restart`; ovhost runs neither. If a later step fails, the inventory backup path is printed; completed setup steps are not automatically undone.
++
**Deploy strategies** (roadmap WS-N task 11). The inventory entry's `strategy` picks how a service deploys: `git-checkout` (the default: an in-place checkout, every service above), `multi-app` (Tools), `static-build` (Sites), `pnpm-build` (Games) or `release-layout` (Live, OpenRe: `releases/<id>` behind a `current` symlink, prepared while the old release serves, `--prepare-only` to stop after preparing). Each strategy carries the rules of the script it replaces: preflight checks, build output restored from git, untracked lockfiles, `unitsMatch`, `/release.json` naming the new sha, the socket rule. [docs/deploy-strategies.md](docs/deploy-strategies.md) has the engines, every inventory field, the rule-by-rule mapping from each script, what was not ported and why, the production cutover checklist and the proposed inventory diff.
`deploy --browser-check` checks the service's public site in headless Chrome after a deploy that went through. It runs [`scripts/browser-check.js`](docs/browser-check.md) for that one site. The check is report only: it never changes the exit code or the release record. It needs Chrome where ovhost runs.
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 7f27852..34bd167 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -48,6 +48,9 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
from its own real path): fetch origin main, merge --ff-only,
npm ci --omit=dev; refuses a dirty tree, a branch other than
main, or a non-fast-forward; --dry-run prints the plan (root)
++ service add <id> [--dry-run] copy the example inventory entry, clone the checkout, create
++ its 0600 env file, and provision PostgreSQL when declared;
++ print remaining setup (names only, never values)
data provision <service> [--dry-run] run roles/data/add-service.sh from the ovhost install: the
service's PostgreSQL database, roles and Valkey user
(idempotent; prints names only, never values)
@@@ -234,6 +237,20 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
else if (r.updated) log(`updated ${r.installDir} (${r.lockfileRestored ? 'lockfile restored; ' : ''}nothing restarted)`);
return 0;
}
++ case 'service': {
++ if (pos[1] !== 'add' || !pos[2] || pos[3]) throw new UsageError('usage: ovhost service add <id> [--dry-run]');
++ const r = await lib.serviceAdd.add(ctx, pos[2], { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'] });
++ if (json) emit(r);
++ else if (r.dryRun) for (const step of r.plan) out(`would: ${step}`);
++ else {
++ out(`added ${r.service}; inventory backup: ${r.backup}`);
++ out(`checkout: ${r.repo}; env file: ${r.envFile} (0600; set ${r.envNames.join(', ')})`);
++ if (r.provisioned) out(`data provision ${r.service}: complete`);
++ if (r.unset.length) out(`still empty in ${r.envFile}: ${r.unset.join(', ')}`);
++ for (const step of r.remaining) out(step);
++ }
++ return 0;
++ }
case 'data': {
// The data role's own scripts, run from ovhost's install (ADR-035): the agent pipeline
// reaches ovhost, not the shell. Output is redacted and the script's exit code passes through.
ddiff --git a/lib/commands/service-add.js b/lib/commands/service-add.js
nnew file mode 100644
iindex 0000000..a6b959b
---- /dev/null
++++ b/lib/commands/service-add.js
@@@ -0,0 +1,135 @@
++'use strict';
++/** First-time service setup. The example inventory is read from this CLI install. */
++const path = require('path');
++const { normalise } = require('../inventory');
++const { OpError, EXIT } = require('../release-ops');
++const { installDirFor } = require('./self-update');
++const data = require('./data');
++const lock = require('../lock');
++const { parseExample, parseNames } = require('../envfile');
++
++function refuse(message) { throw new OpError(message, EXIT.USAGE); }
++function checkedUrl(raw, id) {
++ const url = raw.cloneUrl || `https://github.com/OpenVibers/OpenVibe.${id[0].toUpperCase()}${id.slice(1)}.git`;
++ let u;
++ try { u = new URL(url); } catch { refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL`); }
++ if (u.protocol !== 'https:' || u.hostname !== 'github.com' || u.username || u.password || u.search || u.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(u.pathname)) refuse(`services.${id}.cloneUrl must be a GitHub HTTPS repository URL without credentials`);
++ return u.href;
++}
++function baseUrl(svc, example) {
++ const vhost = svc.nginx && svc.nginx.vhost;
++ if (vhost && /^[a-z0-9.-]+\.conf$/i.test(vhost)) return `https://${vhost.slice(0, -5)}`;
++ if (svc.origin) return svc.origin;
++ const match = String(example).match(/^\s*BASE_URL\s*=\s*['"]?(https:\/\/[^\s'"/]+)[^\r\n]*$/m);
++ if (match) return match[1];
++ return null;
++}
++function productionEnv(example, svc) {
++ const values = { NODE_ENV: 'production', HOST: '127.0.0.1', PORT: String(svc.port) };
++ const url = baseUrl(svc, example);
++ if (url) values.BASE_URL = url;
++ const found = new Set();
++ const lines = String(example).split(/\r?\n/).map((line) => {
++ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
++ if (!m || !(m[1] in values)) return line;
++ found.add(m[1]);
++ return `${m[1]}=${values[m[1]]}`;
++ });
++ for (const [name, value] of Object.entries(values)) if (!found.has(name)) lines.push(`${name}=${value}`);
++ return lines.join('\n').replace(/\n*$/, '\n');
++}
++const OVERRIDES = ['NODE_ENV', 'HOST', 'PORT', 'BASE_URL'];
++const PROVISIONED = ['DATABASE_URL', 'DATABASE_DIRECT_URL', 'VALKEY_URL', 'VALKEY_PREFIX'];
++/** Names still empty in the new env file, and the commands that finish the setup. Never values. */
++function remainingSetup(id, envFile, envText, provisioned, overridden, netRepo, netEnv) {
++ const skip = new Set([...overridden, ...(provisioned ? PROVISIONED : [])]);
++ const names = parseNames(envText);
++ const unset = names.filter((n) => n.empty && !skip.has(n.name)).map((n) => n.name);
++ const remaining = [];
++ if (names.some((n) => n.name === 'OV_OAUTH_CLIENT_SECRET' || n.name === 'OV_OAUTH_CLIENT_ID')) {
++ // systemd-run parses Network's root-only env file the way its unit does: never source it with
++ // sh (values with & or $ would break). /usr/bin/env node matches how the units start node.
++ const run = `sudo systemd-run --quiet --wait --pipe --collect -p EnvironmentFile=${netEnv} -p WorkingDirectory=${netRepo} /usr/bin/env node server/setup/service-principal.js`;
++ remaining.push(`service principal (Network's production env; create, or rotate if Network already has ${id}): if ${run} list | grep -Eq '^${id}[[:space:]]'; then verb=rotate; else verb=create; fi; ${run} "$verb" ${id} --env-file ${envFile} # secret never printed`);
++ }
++ remaining.push(`then: ovhost validate ${id}; ovhost deploy ${id} --restart`);
++ return { unset, remaining };
++}
++async function add(ctx, id, { selfPath, dryRun = false } = {}) {
++ if (dryRun) return setup(ctx, id, { selfPath, dryRun });
++ if (!(await ctx.exec.isRoot())) refuse('service add must run as root');
++ // One inventory-wide lock across the read and the write: two adds never build on the same old inventory.
++ const held = await lock.acquire(ctx.exec, ctx.inv, '_inventory');
++ try { return await setup(ctx, id, { selfPath, dryRun }); } finally { await held.release(); }
++}
++async function setup(ctx, id, { selfPath, dryRun }) {
++ const { exec, inv } = ctx;
++ if (!/^[a-z][a-z0-9-]{0,30}$/.test(id || '')) refuse('service add needs a valid <id> (lowercase letters, digits, hyphens; max 31)');
++ if (inv.file !== '/etc/openvibe/host.json') refuse('service add requires /etc/openvibe/host.json (no --inventory override)');
++ const examplePath = path.join(installDirFor(selfPath).dir, 'host.example.json');
++ const exampleText = await exec.readFile(examplePath);
++ if (exampleText == null) refuse(`${examplePath} is missing from the ovhost install`);
++ let example, current;
++ try { example = JSON.parse(exampleText); } catch { refuse(`${examplePath} is not valid JSON`); }
++ const currentText = await exec.readFile(inv.file, { privileged: true });
++ try { current = JSON.parse(currentText); } catch { refuse(`${inv.file} is not valid JSON`); }
++ if (Object.hasOwn(current.services || {}, id)) refuse(`services.${id} already exists in ${inv.file}`);
++ const source = example.services && example.services[id];
++ if (!source) refuse(`services.${id} is absent from ${examplePath}`);
++ const { _note, ...entry } = source;
++ const candidate = { ...current, services: { ...current.services, [id]: entry } };
++ const services = normalise(candidate).services;
++ const svc = services[id];
++ if (svc.layout !== 'git') refuse(`services.${id} uses a release layout; first setup needs a plain checkout`);
++ if (!svc.envFile || !svc.port || !svc.owner) refuse(`services.${id} needs envFile, port and owner`);
++ if (svc.envFile !== `/etc/openvibe/${id}.env` && entry.envFile !== `/etc/openvibe/${id}.env`) refuse(`services.${id}.envFile must be /etc/openvibe/${id}.env for data provision`);
++ if (Array.isArray(svc.env.required) && svc.env.required.includes('BASE_URL') && !baseUrl(svc, '')) refuse(`services.${id} requires BASE_URL but has no nginx vhost or origin in the example inventory`);
++ const net = services.network;
++ const netRepo = (net && net.repo) || '/opt/openvibe.network';
++ const netEnv = (net && net.envFile) || '/etc/openvibe/network.env';
++ const url = checkedUrl(entry, id);
++ const checkout = await exec.stat(svc.repo);
++ if (checkout && (checkout.isSymlink || !checkout.isDir || (await exec.readdir(svc.repo) || []).length)) refuse(`${svc.repo} is not an empty directory`);
++ if (await exec.stat(svc.envFile)) refuse(`${svc.envFile} already exists`);
++ const inventoryStat = await exec.stat(inv.file);
++ if (!inventoryStat || !inventoryStat.isFile || inventoryStat.isSymlink) refuse(`${inv.file} must be a regular file`);
++ const backup = `${inv.file}.bak.${new Date(exec.now()).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`;
++ if (await exec.stat(backup)) refuse(`${backup} already exists`);
++ if (dryRun) return { service: id, dryRun: true, plan: [`back up ${inv.file} to ${backup} and add services.${id} without _note`, `clone ${url} into ${svc.repo} as ${svc.owner}`, `create ${svc.envFile} (0600) from checkout ${svc.env.example}; set NODE_ENV, HOST, PORT, and BASE_URL when a public origin is known`, `data provision ${id} if ${svc.env.example} declares DATABASE_URL`, `list the env names still empty; when ${svc.env.example} declares OV_OAUTH_CLIENT_ID/SECRET, give the Network service-principal command (run with Network's production env; create, or rotate when it exists); then validate and deploy`] };
++ const copy = await exec.run('cp', ['-p', '--', inv.file, backup], { privileged: true });
++ if (copy.code !== 0) refuse(`could not back up ${inv.file}`);
++ try {
++ await exec.writeFile(inv.file, `${JSON.stringify(candidate, null, 2)}\n`, { privileged: true, mode: inventoryStat.mode });
++ // writeFile replaces bytes in place. Restore ownership/mode if an executor used a new inode.
++ const owner = await exec.run('chown', [`${inventoryStat.uid}:${inventoryStat.gid}`, inv.file], { privileged: true });
++ if (owner.code !== 0) throw new Error(`could not restore ownership of ${inv.file}`);
++ if (!checkout) await exec.mkdir(svc.repo, { owner: svc.owner, mode: 0o750 });
++ else {
++ const own = await exec.run('chown', [`${svc.owner}:${svc.owner}`, svc.repo], { privileged: true });
++ if (own.code !== 0) throw new Error(`could not give ${svc.owner} the empty checkout directory`);
++ }
++ const cloned = await exec.run('git', ['clone', '--branch', svc.branch, '--', url, svc.repo], { as: svc.owner, timeoutMs: 600000 });
++ if (cloned.code !== 0) throw new Error(`git clone failed for ${id}`);
++ // The checkout is untrusted: a symlinked example could point at a root-only secret, so refuse a
++ // symlink and read it with the owner's permissions (covers symlinked parent directories too).
++ const exampleFile = path.join(svc.repo, svc.env.example);
++ const exampleStat = await exec.stat(exampleFile);
++ if (!exampleStat || exampleStat.isSymlink || !exampleStat.isFile) throw new Error(`${svc.env.example} must be a regular file in ${svc.repo}`);
++ const read = await exec.run('cat', ['--', exampleFile], { as: svc.owner });
++ if (read.code !== 0) throw new Error(`could not read ${svc.env.example} as ${svc.owner}`);
++ const envExample = read.stdout;
++ const envText = productionEnv(envExample, svc);
++ const overridden = baseUrl(svc, envExample) ? OVERRIDES : OVERRIDES.slice(0, 3);
++ await exec.writeFile(svc.envFile, envText, { privileged: true, mode: 0o600 });
++ const provisionDatabase = parseExample(envExample).declared.includes('DATABASE_URL');
++ if (provisionDatabase) {
++ const r = await data.provision({ ...ctx, inv: { ...inv, services: { ...inv.services, [id]: svc } } }, id, { selfPath });
++ if (!r.ok) throw new Error(`data provision ${id} failed (exit ${r.code})`);
++ }
++ return { service: id, dryRun: false, backup, repo: svc.repo, envFile: svc.envFile, envNames: overridden, provisioned: provisionDatabase, ...remainingSetup(id, svc.envFile, envText, provisionDatabase, overridden, netRepo, netEnv) };
++ } catch (err) {
++ throw new Error(`${err.message}; setup is incomplete; inventory backup: ${backup}`);
++ }
++}
++
++module.exports = { add, productionEnv };
ddiff --git a/lib/index.js b/lib/index.js
iindex eacbe7a..5abe334 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -12,6 +12,7 @@ const offsite = require('./offsite');
const { snapshot } = require('./commands/snapshot');
const { selfUpdate } = require('./commands/self-update');
const data = require('./commands/data');
++const serviceAdd = require('./commands/service-add');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const certs = require('./certs');
@@@ -47,6 +48,7 @@ module.exports = {
snapshot,
selfUpdate,
data,
++ serviceAdd,
drill,
DrillError,
releases,
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 70f9946..ab25574 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -18,6 +18,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const files = new Map(); // path -> { type: 'file'|'dir'|'symlink', content, mode, owner, target }
const calls = [];
const repos = new Map(); // repoPath -> repo
++ const remoteRepos = new Map(); // URL -> files for a new clone
const units = new Map(); // unit -> state
const http = new Map(); // url -> fn({ headers, method, body }) -> { status, body } (request() also passes method and body)
const alivePids = new Set();
@@@ -25,7 +26,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
let clock = start;
let seq = 0;
const host = {
-- files, calls, repos, units, http, alivePids, listeners,
++ files, calls, repos, remoteRepos, units, http, alivePids, listeners,
nginxTest: () => ({ code: 0, stderr: 'nginx: configuration file /etc/nginx/nginx.conf test is successful' }),
npm: null, // (cwd, argv, as) -> undefined | { code, stderr } ; default installs package.json deps
npmRewritesLockfile: true,
@@@ -183,6 +184,18 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
return matches.length === 1 ? matches[0] : null;
}
function gitCmd(args, opts) {
++ if (args[0] === 'clone') {
++ const url = args[args.length - 2];
++ const dest = args[args.length - 1];
++ const source = remoteRepos.get(url);
++ if (!source || (files.has(path.resolve(dest)) && (files.get(path.resolve(dest)).type !== 'dir' || [...files.keys()].some((p) => p.startsWith(`${path.resolve(dest)}/`))))) return { code: 128, stdout: '', stderr: 'clone source missing or destination occupied' };
++ const branch = args[args.indexOf('--branch') + 1];
++ const repo = host.createRepo(dest, { owner: opts.as || 'root', branch });
++ const sha = repo.commit(source);
++ repo.publish(sha);
++ repo.checkout(sha);
++ return { code: 0, stdout: '', stderr: '' };
++ }
const repo = repos.get(args[1]) || repos.get(resolveLinks(args[1]));
if (!repo) return { code: 128, stdout: '', stderr: 'not a git repository' };
let i = 2;
@@@ -434,6 +447,13 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
case 'systemctl': return systemctlCmd(args);
case 'nginx': return { stdout: '', ...host.nginxTest() };
case 'rm': rmTree(args[args.length - 1]); return { code: 0, stdout: '', stderr: '' };
++ case 'cat': {
++ // Reads as opts.as: another user's file needs the world-read bit.
++ const e = get(args[args.length - 1]);
++ if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: 'cat: No such file or directory' };
++ if (opts.as && opts.as !== 'root' && e.owner !== opts.as && !(e.mode & 0o004)) return { code: 1, stdout: '', stderr: 'cat: Permission denied' };
++ return { code: 0, stdout: String(e.content), stderr: '' };
++ }
case 'install': {
const src = args[args.length - 2];
const dest = args[args.length - 1];
@@@ -448,7 +468,8 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
const target = resolveLinks(args[args.length - 1]);
const e = files.get(target);
if (!e) return { code: 1, stdout: '', stderr: 'chown: no such file' };
-- const who = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
++ const whoRaw = args.filter((a) => !a.startsWith('-'))[0].split(':')[0];
++ const who = whoRaw === '0' ? 'root' : whoRaw;
if (args.includes('-R')) { for (const [k, v] of files) if (k === target || k.startsWith(`${target}/`)) v.owner = who; } else e.owner = who;
return { code: 0, stdout: '', stderr: '' };
}
@@@ -560,7 +581,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
}
const e = get(src);
if (!e || e.type !== 'file') return { code: 1, stdout: '', stderr: `cp: cannot stat '${src}': No such file or directory` };
-- put(dest, e.content, { owner: opts.as || user, mode: 0o644 });
++ put(dest, e.content, { owner: args.includes('-p') ? e.owner : opts.as || user, mode: args.includes('-p') ? e.mode : 0o644 });
return { code: 0, stdout: '', stderr: '' };
}
default: return { code: 127, stdout: '', stderr: `fake host: no ${cmd}` };
ddiff --git a/test/service-add.test.js b/test/service-add.test.js
nnew file mode 100644
iindex 0000000..b287141
---- /dev/null
++++ b/test/service-add.test.js
@@@ -0,0 +1,216 @@
++'use strict';
++const assert = require('assert');
++const { scenario, test, runTests, SECRET, lifecycleDoc } = require('./helpers');
++const { main } = require('../lib/cli');
++
++const SELF = '/opt/ovhost-install/bin/ovhost';
++const URL = 'https://github.com/OpenVibers/OpenVibe.Bot.git';
++
++function setup() {
++ const host = scenario();
++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify({ services: { bot: {
++ _note: 'example-only note', repo: '/opt/openvibe.bot', owner: 'ubuntu', units: [],
++ envFile: '/etc/openvibe/bot.env', env: { required: 'from-example' }, port: 4630,
++ nginx: { vhost: 'openvibe.bot.conf' }, lifecycle: lifecycleDoc(),
++ databases: [{ name: 'bot', engine: 'postgresql', database: 'ov_bot' }],
++ } } }));
++ host.remoteRepos.set(URL, { '.env.example': `NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nBASE_URL=https://example.invalid\nDATABASE_URL=\nOV_OAUTH_CLIENT_SECRET=\nEXAMPLE_SECRET=${SECRET}\n` });
++ host.put('/opt/ovhost-install/roles/data/add-service.sh', '#!/bin/sh\n', { mode: 0o755 });
++ const provision = [];
++ const run = host.exec.run;
++ host.exec.run = async (cmd, args, opts) => {
++ if (cmd.endsWith('/roles/data/add-service.sh')) {
++ provision.push({ cmd, args, opts });
++ return { code: 0, stdout: 'DATABASE_URL and VALKEY_URL set (values not shown)', stderr: '' };
++ }
++ return run(cmd, args, opts);
++ };
++ const cli = async (...argv) => {
++ const lines = [];
++ const code = await main(argv, { exec: host.exec, out: (line) => lines.push(line), env: {}, selfPath: SELF });
++ return { code, out: lines.join('\n') };
++ };
++ return { host, cli, provision };
++}
++
++runTests([
++ test('dry run gives a names-only plan and changes nothing', async () => {
++ const { host, cli, provision } = setup();
++ const before = host.read('/etc/openvibe/host.json');
++ const result = await cli('service', 'add', 'bot', '--dry-run');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.match(result.out, /clone .*OpenVibe\.Bot\.git/);
++ assert.match(result.out, /data provision bot/);
++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
++ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);
++ assert.deepStrictEqual(provision, []);
++ assert.doesNotMatch(result.out, /development|example\.invalid|sk_live_/);
++ }),
++ test('adds inventory, checkout, production env and data with no values in output', async () => {
++ const { host, cli, provision } = setup();
++ const result = await cli('service', 'add', 'bot', '--json');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.doesNotMatch(result.out, /NODE_ENV=production|127\.0\.0\.1|4630|https:\/\/openvibe\.bot|sk_live_/);
++ const live = JSON.parse(host.read('/etc/openvibe/host.json'));
++ assert.ok(live.services.bot);
++ assert.ok(!Object.hasOwn(live.services.bot, '_note'));
++ const backup = JSON.parse(result.out).backup;
++ assert.ok(host.read(backup));
++ assert.strictEqual((await host.exec.stat(backup)).mode, 0o640);
++ assert.strictEqual((await host.exec.stat('/etc/openvibe/host.json')).mode, 0o640);
++ assert.strictEqual((await host.exec.stat('/etc/openvibe/bot.env')).mode, 0o600);
++ assert.strictEqual((await host.exec.stat('/opt/openvibe.bot')).owner, 'ubuntu');
++ const env = host.read('/etc/openvibe/bot.env');
++ assert.match(env, /^NODE_ENV=production$/m);
++ assert.match(env, /^HOST=127\.0\.0\.1$/m);
++ assert.match(env, /^PORT=4630$/m);
++ assert.match(env, /^BASE_URL=https:\/\/openvibe\.bot$/m);
++ assert.match(env, new RegExp(SECRET));
++ assert.deepStrictEqual(provision.map((r) => r.args), [['bot']]);
++ assert.strictEqual(provision[0].opts.privileged, true);
++ assert.ok(host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone' && c.as === 'ubuntu'));
++ const r = JSON.parse(result.out);
++ assert.deepStrictEqual(r.unset, ['OV_OAUTH_CLIENT_SECRET']);
++ const step = r.remaining.join('\n');
++ assert.ok(step.includes('sudo systemd-run'), step);
++ assert.ok(step.includes('-p EnvironmentFile=/etc/openvibe/network.env'), step);
++ assert.ok(step.includes('-p WorkingDirectory=/opt/openvibe.network'), step);
++ assert.ok(step.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), step);
++ assert.ok(step.includes('then verb=rotate; else verb=create; fi'), step);
++ assert.ok(step.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), step);
++ assert.match(r.remaining.join('\n'), /ovhost validate bot; ovhost deploy bot --restart/);
++ const repeat = await cli('service', 'add', 'bot');
++ assert.strictEqual(repeat.code, 1);
++ assert.match(repeat.out, /already exists/);
++ }),
++ test('text output names what is still empty and the remaining steps, never values', async () => {
++ const { cli } = setup();
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.match(result.out, /^still empty in \/etc\/openvibe\/bot\.env: OV_OAUTH_CLIENT_SECRET$/m);
++ assert.ok(result.out.includes('sudo systemd-run'), result.out);
++ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/network.env'), result.out);
++ assert.ok(result.out.includes('-p WorkingDirectory=/opt/openvibe.network'), result.out);
++ assert.ok(result.out.includes("service-principal.js list | grep -Eq '^bot[[:space:]]'"), result.out);
++ assert.ok(result.out.includes('then verb=rotate; else verb=create; fi'), result.out);
++ assert.ok(result.out.includes('"$verb" bot --env-file /etc/openvibe/bot.env'), result.out);
++ assert.match(result.out, /^then: ovhost validate bot; ovhost deploy bot --restart$/m);
++ assert.doesNotMatch(result.out, new RegExp(`NODE_ENV=production|127\\.0\\.0\\.1|4630|https://openvibe\\.bot|${SECRET}`));
++ }),
++ test("the principal step uses the inventory's Network checkout and env file", async () => {
++ const { host, cli } = setup();
++ const doc = JSON.parse(host.read('/etc/openvibe/host.json'));
++ doc.services.network = { repo: '/srv/net', owner: 'ubuntu', units: [], envFile: '/etc/openvibe/net-prod.env', port: 4000, lifecycle: lifecycleDoc() };
++ host.put('/etc/openvibe/host.json', JSON.stringify(doc, null, 2), { mode: 0o640, owner: 'root' });
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.ok(result.out.includes('-p EnvironmentFile=/etc/openvibe/net-prod.env'), result.out);
++ assert.ok(result.out.includes('-p WorkingDirectory=/srv/net'), result.out);
++ assert.doesNotMatch(result.out, new RegExp(SECRET));
++ }),
++ test('refuses an occupied checkout before changing the inventory', async () => {
++ const { host, cli } = setup();
++ host.put('/opt/openvibe.bot/keep.txt', 'keep');
++ const before = host.read('/etc/openvibe/host.json');
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 1);
++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
++ assert.strictEqual(host.read('/opt/openvibe.bot/keep.txt'), 'keep');
++ }),
++ test('a wiki-like service without a public URL adds successfully and omits BASE_URL', async () => {
++ const { host, cli, provision } = setup();
++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
++ example.services.wiki = {
++ repo: '/opt/openvibe.wiki', owner: 'ubuntu', units: [],
++ envFile: '/etc/openvibe/wiki.env', env: { required: 'from-example' }, port: 4800,
++ lifecycle: lifecycleDoc(),
++ };
++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
++ host.remoteRepos.set('https://github.com/OpenVibers/OpenVibe.Wiki.git', {
++ '.env.example': 'NODE_ENV=development\nHOST=0.0.0.0\nPORT=3000\nSITE_TITLE=Wiki\n',
++ });
++ const result = await cli('service', 'add', 'wiki', '--json');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.deepStrictEqual(JSON.parse(result.out).envNames, ['NODE_ENV', 'HOST', 'PORT']);
++ assert.doesNotMatch(host.read('/etc/openvibe/wiki.env'), /^BASE_URL=/m);
++ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.wiki);
++ assert.deepStrictEqual(provision, []);
++ }),
++ test('an unresolved optional BASE_URL remains empty and is listed for setup', async () => {
++ const { host, cli } = setup();
++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
++ delete example.services.bot.nginx;
++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
++ host.remoteRepos.set(URL, { '.env.example': 'NODE_ENV=development\nBASE_URL=\n' });
++ const result = await cli('service', 'add', 'bot', '--json');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.ok(JSON.parse(result.out).unset.includes('BASE_URL'));
++ assert.match(host.read('/etc/openvibe/bot.env'), /^BASE_URL=$/m);
++ }),
++ test('an explicitly required URL without an inventory origin is refused before changes', async () => {
++ const { host, cli } = setup();
++ const example = JSON.parse(host.read('/opt/ovhost-install/host.example.json'));
++ delete example.services.bot.nginx;
++ example.services.bot.env.required = ['BASE_URL'];
++ host.put('/opt/ovhost-install/host.example.json', JSON.stringify(example));
++ const before = host.read('/etc/openvibe/host.json');
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 1);
++ assert.match(result.out, /requires BASE_URL/);
++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
++ assert.strictEqual(host.read('/opt/openvibe.bot/.env.example'), null);
++ }),
++ test('reports an incomplete setup and its backup when provisioning fails', async () => {
++ const { host, cli } = setup();
++ const run = host.exec.run;
++ host.exec.run = async (cmd, args, opts) => cmd.endsWith('/roles/data/add-service.sh')
++ ? { code: 2, stdout: `DATABASE_URL=${SECRET}`, stderr: `password=${SECRET}` }
++ : run(cmd, args, opts);
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 2);
++ assert.match(result.out, /setup is incomplete; inventory backup:/);
++ assert.doesNotMatch(result.out, new RegExp(SECRET));
++ assert.ok(JSON.parse(host.read('/etc/openvibe/host.json')).services.bot);
++ }),
++ test('a symlinked .env.example is refused and its target never reaches the env file', async () => {
++ const { host, cli } = setup();
++ host.put('/etc/openvibe/network.env', `OV_SECRET=${SECRET}\n`, { mode: 0o600 });
++ const run = host.exec.run;
++ host.exec.run = async (cmd, args, opts) => {
++ const r = await run(cmd, args, opts);
++ if (cmd === 'git' && args[0] === 'clone') {
++ await host.exec.removeFile('/opt/openvibe.bot/.env.example');
++ await host.exec.symlink('/etc/openvibe/network.env', '/opt/openvibe.bot/.env.example');
++ }
++ return r;
++ };
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 2, result.out);
++ assert.match(result.out, /must be a regular file/);
++ assert.strictEqual(host.read('/etc/openvibe/bot.env'), null);
++ assert.doesNotMatch(result.out, new RegExp(SECRET));
++ }),
++ test('reads .env.example as the checkout owner', async () => {
++ const { host, cli } = setup();
++ const result = await cli('service', 'add', 'bot');
++ assert.strictEqual(result.code, 0, result.out);
++ assert.ok(host.calls.some((c) => c.cmd === 'cat' && c.args.at(-1) === '/opt/openvibe.bot/.env.example' && c.as === 'ubuntu'));
++ assert.ok(!host.reads.includes('/opt/openvibe.bot/.env.example'));
++ }),
++ test('a held inventory lock refuses the add and leaves the inventory alone; success releases it', async () => {
++ const { host, cli } = setup();
++ const lockFile = '/var/lib/openvibe-host/locks/_inventory.lock';
++ host.put(lockFile, JSON.stringify({ pid: 4242, at: 'earlier' }));
++ host.alivePids.add(4242);
++ const before = host.read('/etc/openvibe/host.json');
++ const held = await cli('service', 'add', 'bot');
++ assert.strictEqual(held.code, 1, held.out);
++ assert.match(held.out, /another ovhost operation on _inventory is running/);
++ assert.strictEqual(host.read('/etc/openvibe/host.json'), before);
++ assert.ok(!host.calls.some((c) => c.cmd === 'git' && c.args[0] === 'clone'));
++ host.alivePids.delete(4242);
++ const ok = await cli('service', 'add', 'bot');
++ assert.strictEqual(ok.code, 0, ok.out);
++ assert.strictEqual(host.read(lockFile), null);
++ }),
++]);