openvibers/openvibe.host Commits
Add ovhost self-update to fast-forward and reinstall the CLI install (#7)
Changes
@@@ -119,6 +119,7 @@ ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]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 ciovhost --versionovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notificationovhost releases <service> [--limit <n>]@@@ -134,7 +135,7 @@ ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>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. 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).++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).**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.@@@ -235,6 +236,7 @@ This was done on 2026-09-23 (the CLI and inventory are installed); the steps sta4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.6. **Scheduled backups:** see [docs/backups.md](docs/backups.md#installing-on-the-host-operator) (bucket, key, `/etc/openvibe/backup.env`, `openvibe-backup.timer`).++7. **Updating the CLI itself:** a deploy of this repository replaces `/opt/openvibe.host` (the Stage B service), not the root-owned install at `/usr/local/lib/openvibe-host`, so after a merge to `main` run `sudo ovhost self-update`. It finds its own install directory from its real path (`/usr/local/bin/ovhost` → `/usr/local/lib/openvibe-host/bin/ovhost`), fetches `origin main`, refuses a dirty tree, a branch other than `main` or a divergence, merges `--ff-only` and runs `npm ci --omit=dev --no-audit --no-fund` there; `--dry-run` prints the plan first. It restarts nothing — the next `ovhost` invocation runs the new code. Without it the installed CLI silently runs behind `main` (on 2026-10-02 it was 16 commits behind: no `env-names --set`, no `logs`, no Bot vhost).## Stage B: tenant static hosting@@@ -44,6 +44,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]current one replaced (release log), with its own node_modulescapabilities [<service>] key=value lines for deploy wrappers: ovhost version, strategies,and whether <service> is managed and by which strategy++ self-update [--dry-run] update the ovhost CLI install itself (/usr/local/lib/openvibe-host,++ 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)version | --version ovhost's versionannounce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]publish host.release.published to OpenVibe.Events (public), so open@@@ -121,6 +125,8 @@ Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restartrolled back · 4 rollback failed (manual intervention) · 5 protected sessions active · 6 frozen ·announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·++self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty, not main, diverged) ·++2 npm ci failed (the checkout is at the new sha; dependencies are not) ·backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed`;@@@ -173,7 +179,7 @@ function announcedSummary(a) {return rest;}--async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {++async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck, selfPath = null } = {}) {let parsed;try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }const { pos, opts } = parsed;@@@ -189,7 +195,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc// The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.let inv;try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {-- const noInventoryOk = cmd === 'dns' || (cmd === 'capabilities' && !pos[1]);++ const noInventoryOk = cmd === 'dns' || cmd === 'self-update' || (cmd === 'capabilities' && !pos[1]);if (!noInventoryOk || opts.inventory || !(err instanceof InventoryError)) throw err;inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };}@@@ -208,6 +214,17 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => procif (json) emit(row); else for (const l of lines) out(l);return 0;}++ case 'self-update': {++ // Update the CLI install itself (its own real path), never a service: fetch, --ff-only++ // merge origin/main, npm ci. No unit is touched.++ const r = await lib.selfUpdate(ctx, { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'] });++ if (json) { emit(r); return 0; }++ if (r.upToDate) out(`ovhost ${short(r.oldSha)} is up to date (${r.remote}/${r.branch})`);++ else out(`ovhost ${short(r.oldSha)} → ${short(r.newSha)} (${r.commits} commit${r.commits === 1 ? '' : 's'})${r.dryRun ? ' (dry run: nothing changed)' : ''}`);++ if (r.dryRun && !r.upToDate) out(` would: git fetch ${r.remote} ${r.branch}; git merge --ff-only ${r.remote}/${r.branch}; npm ci --omit=dev --no-audit --no-fund in ${r.installDir}`);++ else if (r.updated) log(`updated ${r.installDir} (${r.lockfileRestored ? 'lockfile restored; ' : ''}nothing restarted)`);++ return 0;++ }case 'validate': {const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });if (json) emit(r);nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * ovhost self-update [--dry-run]: update the ovhost CLI install itself —++ * /usr/local/lib/openvibe-host, the root-owned checkout /usr/local/bin/ovhost links to++ * (README "Installing on the host"). Deploying the Host service updates /opt/openvibe.host only,++ * so without this the installed CLI silently runs behind main (it was 16 commits behind on++ * 2026-10-02: no `env-names --set`, no `logs`, no Bot vhost).++ *++ * The install directory comes from ovhost's own real path, so it works whatever the symlink is:++ * /usr/local/bin/ovhost -> /usr/local/lib/openvibe-host/bin/ovhost => /usr/local/lib/openvibe-host++ *++ * It fetches origin/main, refuses a dirty tree, a branch other than main, or a divergence, merges++ * --ff-only and runs `npm ci --omit=dev --no-audit --no-fund` in the install directory. npm++ * rewriting the (checked-in) lockfile is undone: the install must stay exactly at the merged sha.++ * Nothing is restarted: ovhost is a CLI, and the next invocation runs the new code.++ */++const fs = require('fs');++const path = require('path');++const { git } = require('../git');++const { OpError, EXIT } = require('../release-ops');++++const REMOTE = 'origin';++const BRANCH = 'main';++const NPM_CI = ['ci', '--omit=dev', '--no-audit', '--no-fund'];++++/** <install>/bin/ovhost from a path to the invoked script, following the /usr/local/bin symlink. */++function installDirFor(selfPath) {++ let real = selfPath;++ try { real = fs.realpathSync(selfPath); } catch { /* not on disk here (tests): use the path as given */ }++ return { self: real, dir: path.dirname(path.dirname(real)) };++}++++async function selfUpdate(ctx, { selfPath, dryRun = false } = {}) {++ const { exec, log } = ctx;++ const { self, dir } = installDirFor(selfPath || '');++ if (path.basename(self) !== 'ovhost' || path.basename(path.dirname(self)) !== 'bin') {++ throw new OpError(`${self || "ovhost's own path"} is not <install>/bin/ovhost — self-update only updates the installed CLI (README "Installing on the host")`, EXIT.USAGE);++ }++ // The install is root-owned and npm ci rewrites node_modules in it: without root both git and npm++ // trip over permissions (or leave root-owned objects behind), exactly what lib/git.js warns about.++ if (!(await exec.isRoot())) throw new OpError('self-update must run as root (sudo ovhost self-update): the install is root-owned and it replaces node_modules', EXIT.USAGE);++ const st = await exec.stat(dir);++ if (!st || !st.isDir) throw new OpError(`${dir} is not a directory — cannot self-update`, EXIT.USAGE);++++ const svc = { owner: st.owner || 'root', repo: dir, remote: REMOTE, branch: BRANCH };++ const g = git(exec, svc);++ let oldSha;++ try { oldSha = await g.head(); } catch (err) { throw new OpError(`${dir} is not a git checkout: ${err.message.split('\n')[0]}`, EXIT.USAGE); }++++ const branch = await g.branch();++ if (branch !== BRANCH) throw new OpError(`${dir} is on branch "${branch}", not "${BRANCH}" — refusing to self-update (put it back on ${BRANCH} by hand)`, EXIT.USAGE);++ const dirty = await g.dirty();++ if (dirty.length) {++ const shown = dirty.slice(0, 5).join(', ');++ throw new OpError(`${dir} has local changes (${shown}${dirty.length > 5 ? `, +${dirty.length - 5} more` : ''}) — refusing to self-update; nothing was fetched or changed`, EXIT.USAGE);++ }++++ log(`fetching ${REMOTE} ${BRANCH} in ${dir}`);++ try { await g.fetch(); } catch (err) { throw new OpError(`git fetch ${REMOTE} ${BRANCH} failed in ${dir}: ${err.message}`, EXIT.USAGE); }++ const newSha = await g.revParse(`${REMOTE}/${BRANCH}`);++ if (!newSha) throw new OpError(`${REMOTE}/${BRANCH} not found in ${dir}`, EXIT.USAGE);++++ const commits = newSha === oldSha ? [] : await g.log(oldSha, newSha);++ const result = { installDir: dir, branch, remote: REMOTE, oldSha, newSha, commits: commits.length, upToDate: newSha === oldSha, dryRun, updated: false, lockfileRestored: false, npm: null };++++ if (result.upToDate) {++ if (!dryRun) log(`${dir} is already at ${REMOTE}/${BRANCH}`);++ return result;++ }++++ const ff = await exec.run('git', ['-C', dir, 'merge-base', '--is-ancestor', oldSha, newSha], { as: svc.owner });++ if (ff.code !== 0) throw new OpError(`${dir} has diverged from ${REMOTE}/${BRANCH} (${oldSha.slice(0, 12)} is not an ancestor of ${newSha.slice(0, 12)}) — refusing, not a fast-forward; fix it by hand`, EXIT.USAGE);++++ if (dryRun) return result;++++ try { await g.ffMerge(newSha); } catch (err) { throw new OpError(`git merge --ff-only ${newSha.slice(0, 12)} failed in ${dir}: ${err.message}`, EXIT.USAGE); }++ result.updated = true;++++ log(`installing dependencies (npm ${NPM_CI.join(' ')})`);++ const npm = await exec.run('npm', NPM_CI, { as: svc.owner, cwd: dir, timeoutMs: 20 * 60 * 1000 });++ result.npm = { code: npm.code };++ if (npm.code !== 0) {++ const why = String(npm.stderr || npm.stdout).trim().split('\n').slice(-3).join(' / ');++ throw new OpError(`the code is now at ${newSha.slice(0, 12)}, but "npm ci --omit=dev --no-audit --no-fund" failed (exit ${npm.code}) in ${dir}: ${why} — re-run it there by hand`, EXIT.VALIDATION);++ }++++ // npm ci normally does not touch the lockfile, but if it did the install would no longer be the++ // merged tree (and the next self-update would refuse it as dirty).++ if ((await g.dirty()).includes('package-lock.json')) {++ await g.checkoutFile('package-lock.json');++ result.lockfileRestored = true;++ log('package-lock.json was rewritten by npm; restored to the merged version');++ }++ return result;++}++++module.exports = { selfUpdate, NPM_CI, REMOTE, BRANCH };@@@ -10,6 +10,7 @@ const { status } = require('./commands/status');const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');const offsite = require('./offsite');const { snapshot } = require('./commands/snapshot');++const { selfUpdate } = require('./commands/self-update');const { drill, DrillError } = require('./commands/drill');const releases = require('./releases');const certs = require('./certs');@@@ -43,6 +44,7 @@ module.exports = {incidents: require('./incidents'),dns: require('./dns'),snapshot,++ selfUpdate,drill,DrillError,releases,nnew file mode 100644@@@ -0,0 +1,182 @@++'use strict';++/**++ * `ovhost self-update [--dry-run]`: update the CLI install itself, driven against the fake host.++ * Nothing here touches the real machine: the install is a fake repo at /usr/local/lib/openvibe-host++ * and every git/npm call is recorded in host.calls, so the tests assert exactly what would run.++ */++const assert = require('assert');++const path = require('path');++const { scenario, test, runTests } = require('./helpers');++const { main } = require('../lib/cli');++const { NPM_CI } = require('../lib/commands/self-update');++++const DIR = '/usr/local/lib/openvibe-host';++const SELF = `${DIR}/bin/ovhost`;++++function cli(host, ...argv) {++ const lines = [];++ return main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, selfPath: SELF })++ .then((code) => ({ code, out: lines.join('\n') }));++}++++function installRepo(host, { branch = 'main' } = {}) {++ const repo = host.createRepo(DIR, { owner: 'root', branch });++ const sha = repo.commit({++ 'package.json': JSON.stringify({ name: 'openvibe-host', version: '0.3.0', dependencies: { express: '^4.21.2' } }),++ 'package-lock.json': '{"lockfileVersion":3,"packages":{}}',++ 'bin/ovhost': '#!/usr/bin/env node\n',++ 'lib/cli.js': 'old();\n',++ }, { message: 'installed' });++ repo.publish(sha);++ repo.checkout(sha);++ return { repo, sha };++}++++const gitCalls = (host) => host.calls.filter((c) => c.cmd === 'git');++const npmCalls = (host) => host.calls.filter((c) => c.cmd === 'npm');++++runTests([++ test('self-update fetches, fast-forwards and runs npm ci in its own install dir', async () => {++ const host = scenario();++ const { repo, sha } = installRepo(host);++ const next = repo.commit({ 'lib/cli.js': 'new();\n', 'lib/self-update.js': 'x();\n' }, { message: 'env-names --set, logs, Bot vhost' });++ repo.publish(next);++++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /ovhost [0-9a-f]{12} → [0-9a-f]{12} \(1 commit\)/);++ assert.strictEqual(repo.head, next);++ assert.ok(gitCalls(host).some((c) => c.args.includes('fetch')), 'fetches origin');++ assert.ok(gitCalls(host).some((c) => c.args.includes('merge') && c.args.includes('--ff-only') && c.args.includes(next)), 'merges --ff-only the fetched sha');++ const npm = npmCalls(host);++ assert.strictEqual(npm.length, 1, 'one npm call');++ assert.deepStrictEqual(npm[0].args, NPM_CI);++ assert.strictEqual(npm[0].cwd, DIR);++ assert.strictEqual(npm[0].as, 'root');++ // The fake npm rewrites package-lock.json; self-update puts the merged one back.++ assert.strictEqual(host.read(path.join(DIR, 'package-lock.json')), '{"lockfileVersion":3,"packages":{}}');++ // A CLI update never touches a unit.++ assert.deepStrictEqual(host.calls.filter((c) => c.cmd === 'systemctl'), []);++ }),++++ test('up to date: nothing is merged and nothing is installed', async () => {++ const host = scenario();++ installRepo(host);++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /ovhost [0-9a-f]{12} is up to date \(origin\/main\)/);++ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);++ assert.deepStrictEqual(npmCalls(host), []);++ }),++++ test('--dry-run prints the plan and changes nothing', async () => {++ const host = scenario();++ const { repo, sha } = installRepo(host);++ const next = repo.commit({ 'lib/cli.js': 'new();\n' }, { message: 'change' });++ repo.publish(next);++++ const r = await cli(host, 'self-update', '--dry-run');++ assert.strictEqual(r.code, 0, r.out);++ assert.match(r.out, /\(1 commit\) \(dry run: nothing changed\)/);++ assert.match(r.out, /would: git fetch origin main; git merge --ff-only origin\/main; npm ci --omit=dev --no-audit --no-fund in /);++ assert.strictEqual(repo.head, sha, 'HEAD is untouched');++ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);++ assert.deepStrictEqual(npmCalls(host), []);++ }),++++ test('a dirty tree is refused before anything is fetched', async () => {++ const host = scenario();++ const { repo, sha } = installRepo(host);++ host.put(path.join(DIR, 'lib/cli.js'), 'hand-edited();\n');++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /has local changes \(lib\/cli\.js\)/);++ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('fetch')), []);++ assert.strictEqual(repo.head, sha);++ }),++++ test('a branch other than main is refused', async () => {++ const host = scenario();++ installRepo(host, { branch: 'release' });++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /on branch "release", not "main"/);++ }),++++ test('a divergence (not a fast-forward) is refused and nothing is merged', async () => {++ const host = scenario();++ const { repo, sha } = installRepo(host);++ const local = repo.commit({ 'lib/cli.js': 'local();\n' }, { message: 'hand edit' });++ repo.checkout(local);++ const remote = repo.commit({ 'lib/cli.js': 'remote();\n' }, { parent: sha, message: 'upstream' });++ repo.publish(remote);++++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /diverged from origin\/main .* not a fast-forward/);++ assert.strictEqual(repo.head, local);++ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);++ assert.deepStrictEqual(npmCalls(host), []);++ }),++++ test('a failed npm ci exits 2 and says the checkout is at the new sha', async () => {++ const host = scenario();++ const { repo } = installRepo(host);++ const next = repo.commit({ 'lib/cli.js': 'new();\n' }, { message: 'change' });++ repo.publish(next);++ host.npm = () => ({ code: 1, stderr: 'npm ERR! network timeout\n' });++++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 2, r.out);++ assert.match(r.out, /npm ci --omit=dev --no-audit --no-fund" failed \(exit 1\)/);++ assert.match(r.out, /re-run it there by hand/);++ assert.strictEqual(repo.head, next, 'the checkout did move; only dependencies are missing');++ }),++++ test('not root: refused before any git or npm call', async () => {++ const host = scenario();++ installRepo(host);++ host.exec.isRoot = async () => false;++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /must run as root \(sudo ovhost self-update\)/);++ assert.deepStrictEqual(gitCalls(host), []);++ assert.deepStrictEqual(npmCalls(host), []);++ }),++++ test('a real path that is not <install>/bin/ovhost is refused', async () => {++ const host = scenario();++ installRepo(host);++ const code = await main(['self-update'], { exec: host.exec, out: () => {}, env: {}, selfPath: '/usr/local/bin/ovhost' });++ assert.strictEqual(code, 1);++ assert.deepStrictEqual(npmCalls(host), []);++ }),++++ test('an install dir that is not a git checkout is refused', async () => {++ const host = scenario();++ host.ensureDir(`${DIR}/bin`, 'root');++ host.put(SELF, '#!/usr/bin/env node\n', { owner: 'root', mode: 0o755 });++ const r = await cli(host, 'self-update');++ assert.strictEqual(r.code, 1, r.out);++ assert.match(r.out, /is not a git checkout/);++ }),++++ test('--json reports the shas, the commit count and what npm was run', async () => {++ const host = scenario();++ const { repo, sha } = installRepo(host);++ const mid = repo.commit({ 'a.js': 'a();\n' }, { message: 'one' });++ const next = repo.commit({ 'b.js': 'b();\n' }, { parent: mid, message: 'two' });++ repo.publish(next);++++ const r = await cli(host, 'self-update', '--json');++ assert.strictEqual(r.code, 0, r.out);++ const doc = JSON.parse(r.out);++ assert.strictEqual(doc.installDir, DIR);++ assert.strictEqual(doc.oldSha, sha);++ assert.strictEqual(doc.newSha, next);++ assert.strictEqual(doc.commits, 2);++ assert.strictEqual(doc.updated, true);++ assert.strictEqual(doc.lockfileRestored, true);++ assert.deepStrictEqual(doc.npm, { code: 0 });++ }),++]);
@@ -119,6 +119,7 @@ ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]
@@ -119,6 +119,7 @@ ovhost plan <service> [--to <sha>] [--no-fetch] [--restart]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]
ovhost deploy <service> [--wait-idle] [--force] [--restart] [--to <sha>] [--install-units] [--ready-timeout <s>] [--browser-check] [--no-announce] [--prepare-only]
ovhost rollback <service> [--to <sha|release id>] [--wait-idle] [--force] [--no-announce]
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 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 --version
ovhost --version
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost releases <service> [--limit <n>]
ovhost releases <service> [--limit <n>]
@@ -134,7 +135,7 @@ ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>
@@ -134,7 +135,7 @@ ovhost restore-download <service> <run|latest> [--out <dir>] [--from-host <name>
ovhost drill <service> [--backup <dir>] [--keep] restore drill (root only)
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. 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).
+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).
**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 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.
@@ -235,6 +236,7 @@ This was done on 2026-09-23 (the CLI and inventory are installed); the steps sta
@@ -235,6 +236,7 @@ This was done on 2026-09-23 (the CLI and inventory are installed); the steps sta
4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.
4. **Run as root:** `sudo ovhost …`. `ovhost` drops to `ubuntu` for git/npm/SQLite (`runuser`) and needs root for `systemctl`, `nginx -t`, reading `/etc/openvibe/*.env` (names only) and writing vhosts. For least privilege, give operator accounts a sudoers rule for `/usr/local/bin/ovhost` alone instead of a general `ALL`. Do **not** grant it to the `ubuntu` service user if that account has no sudo today.
5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.
5. **First run:** `sudo ovhost status`, then `sudo ovhost validate <each service>`, then `sudo ovhost plan live`, all read-only apart from `git fetch`. Adopt `deploy` one service at a time, starting with one that has no protected sessions.
6. **Scheduled backups:** see [docs/backups.md](docs/backups.md#installing-on-the-host-operator) (bucket, key, `/etc/openvibe/backup.env`, `openvibe-backup.timer`).
6. **Scheduled backups:** see [docs/backups.md](docs/backups.md#installing-on-the-host-operator) (bucket, key, `/etc/openvibe/backup.env`, `openvibe-backup.timer`).
+7. **Updating the CLI itself:** a deploy of this repository replaces `/opt/openvibe.host` (the Stage B service), not the root-owned install at `/usr/local/lib/openvibe-host`, so after a merge to `main` run `sudo ovhost self-update`. It finds its own install directory from its real path (`/usr/local/bin/ovhost` → `/usr/local/lib/openvibe-host/bin/ovhost`), fetches `origin main`, refuses a dirty tree, a branch other than `main` or a divergence, merges `--ff-only` and runs `npm ci --omit=dev --no-audit --no-fund` there; `--dry-run` prints the plan first. It restarts nothing — the next `ovhost` invocation runs the new code. Without it the installed CLI silently runs behind `main` (on 2026-10-02 it was 16 commits behind: no `env-names --set`, no `logs`, no Bot vhost).
## Stage B: tenant static hosting
## Stage B: tenant static hosting
@@ -44,6 +44,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
@@ -44,6 +44,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
current one replaced (release log), with its own node_modules
current one replaced (release log), with its own node_modules
capabilities [<service>] key=value lines for deploy wrappers: ovhost version, strategies,
capabilities [<service>] key=value lines for deploy wrappers: ovhost version, strategies,
and whether <service> is managed and by which strategy
and whether <service> is managed and by which strategy
+ self-update [--dry-run] update the ovhost CLI install itself (/usr/local/lib/openvibe-host,
+ 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)
version | --version ovhost's version
version | --version ovhost's version
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run]
publish host.release.published to OpenVibe.Events (public), so open
publish host.release.published to OpenVibe.Events (public), so open
@@ -121,6 +125,8 @@ Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restart
@@ -121,6 +125,8 @@ Exit codes: 0 ok · 1 usage/precondition · 2 validation failed (nothing restart
rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active · 6 frozen ·
rolled back · 4 rollback failed (manual intervention) · 5 protected sessions active · 6 frozen ·
announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·
announce: 0 sent, already sent or dry run · 1 usage or not configured · 2 publishing failed ·
drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·
drill: 0 passed · 1 precondition (not root, port in use, no backup, unsupported) · 2 drill failed ·
+self-update: 0 updated, up to date or dry run · 1 precondition (not root, dirty, not main, diverged) ·
+2 npm ci failed (the checkout is at the new sha; dependencies are not) ·
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
backup --all / offsite: 0 everything succeeded · 1 precondition (config, key, lock) · 2 something failed
`;
`;
@@ -173,7 +179,7 @@ function announcedSummary(a) {
@@ -173,7 +179,7 @@ function announcedSummary(a) {
return rest;
return rest;
}
}
-async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck } = {}) {
+async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => process.stdout.write(`${s}\n`), env = process.env, s3 = lib.offsite.createClient, browserCheck = runBrowserCheck, selfPath = null } = {}) {
let parsed;
let parsed;
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
try { parsed = parseArgs(argv); } catch (err) { out(err.message); out(USAGE); return 1; }
const { pos, opts } = parsed;
const { pos, opts } = parsed;
@@ -189,7 +195,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -189,7 +195,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
// The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.
// The DNS adapter runs on the operator's workstation (where the Cloudflare token is), which has no inventory.
let inv;
let inv;
try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {
try { inv = await lib.inventory.load(exec, { file: opts.inventory, env }); } catch (err) {
- const noInventoryOk = cmd === 'dns' || (cmd === 'capabilities' && !pos[1]);
+ const noInventoryOk = cmd === 'dns' || cmd === 'self-update' || (cmd === 'capabilities' && !pos[1]);
if (!noInventoryOk || opts.inventory || !(err instanceof InventoryError)) throw err;
if (!noInventoryOk || opts.inventory || !(err instanceof InventoryError)) throw err;
inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };
inv = { dns: { dnsOnly: lib.dns.DEFAULT_DNS_ONLY }, services: {} };
}
}
@@ -208,6 +214,17 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
@@ -208,6 +214,17 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
if (json) emit(row); else for (const l of lines) out(l);
if (json) emit(row); else for (const l of lines) out(l);
return 0;
return 0;
}
}
+ case 'self-update': {
+ // Update the CLI install itself (its own real path), never a service: fetch, --ff-only
+ // merge origin/main, npm ci. No unit is touched.
+ const r = await lib.selfUpdate(ctx, { selfPath: selfPath || process.argv[1], dryRun: !!opts['dry-run'] });
+ if (json) { emit(r); return 0; }
+ if (r.upToDate) out(`ovhost ${short(r.oldSha)} is up to date (${r.remote}/${r.branch})`);
+ else out(`ovhost ${short(r.oldSha)} → ${short(r.newSha)} (${r.commits} commit${r.commits === 1 ? '' : 's'})${r.dryRun ? ' (dry run: nothing changed)' : ''}`);
+ if (r.dryRun && !r.upToDate) out(` would: git fetch ${r.remote} ${r.branch}; git merge --ff-only ${r.remote}/${r.branch}; npm ci --omit=dev --no-audit --no-fund in ${r.installDir}`);
+ else if (r.updated) log(`updated ${r.installDir} (${r.lockfileRestored ? 'lockfile restored; ' : ''}nothing restarted)`);
+ return 0;
+ }
case 'validate': {
case 'validate': {
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
const r = await lib.validate(ctx, needService(), { manifestFile: opts.manifest });
if (json) emit(r);
if (json) emit(r);
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * ovhost self-update [--dry-run]: update the ovhost CLI install itself —
+ * /usr/local/lib/openvibe-host, the root-owned checkout /usr/local/bin/ovhost links to
+ * (README "Installing on the host"). Deploying the Host service updates /opt/openvibe.host only,
+ * so without this the installed CLI silently runs behind main (it was 16 commits behind on
+ * 2026-10-02: no `env-names --set`, no `logs`, no Bot vhost).
+ *
+ * The install directory comes from ovhost's own real path, so it works whatever the symlink is:
+ * /usr/local/bin/ovhost -> /usr/local/lib/openvibe-host/bin/ovhost => /usr/local/lib/openvibe-host
+ *
+ * It fetches origin/main, refuses a dirty tree, a branch other than main, or a divergence, merges
+ * --ff-only and runs `npm ci --omit=dev --no-audit --no-fund` in the install directory. npm
+ * rewriting the (checked-in) lockfile is undone: the install must stay exactly at the merged sha.
+ * Nothing is restarted: ovhost is a CLI, and the next invocation runs the new code.
+ */
+const fs = require('fs');
+const path = require('path');
+const { git } = require('../git');
+const { OpError, EXIT } = require('../release-ops');
+
+const REMOTE = 'origin';
+const BRANCH = 'main';
+const NPM_CI = ['ci', '--omit=dev', '--no-audit', '--no-fund'];
+
+/** <install>/bin/ovhost from a path to the invoked script, following the /usr/local/bin symlink. */
+function installDirFor(selfPath) {
+ let real = selfPath;
+ try { real = fs.realpathSync(selfPath); } catch { /* not on disk here (tests): use the path as given */ }
+ return { self: real, dir: path.dirname(path.dirname(real)) };
+}
+
+async function selfUpdate(ctx, { selfPath, dryRun = false } = {}) {
+ const { exec, log } = ctx;
+ const { self, dir } = installDirFor(selfPath || '');
+ if (path.basename(self) !== 'ovhost' || path.basename(path.dirname(self)) !== 'bin') {
+ throw new OpError(`${self || "ovhost's own path"} is not <install>/bin/ovhost — self-update only updates the installed CLI (README "Installing on the host")`, EXIT.USAGE);
+ }
+ // The install is root-owned and npm ci rewrites node_modules in it: without root both git and npm
+ // trip over permissions (or leave root-owned objects behind), exactly what lib/git.js warns about.
+ if (!(await exec.isRoot())) throw new OpError('self-update must run as root (sudo ovhost self-update): the install is root-owned and it replaces node_modules', EXIT.USAGE);
+ const st = await exec.stat(dir);
+ if (!st || !st.isDir) throw new OpError(`${dir} is not a directory — cannot self-update`, EXIT.USAGE);
+
+ const svc = { owner: st.owner || 'root', repo: dir, remote: REMOTE, branch: BRANCH };
+ const g = git(exec, svc);
+ let oldSha;
+ try { oldSha = await g.head(); } catch (err) { throw new OpError(`${dir} is not a git checkout: ${err.message.split('\n')[0]}`, EXIT.USAGE); }
+
+ const branch = await g.branch();
+ if (branch !== BRANCH) throw new OpError(`${dir} is on branch "${branch}", not "${BRANCH}" — refusing to self-update (put it back on ${BRANCH} by hand)`, EXIT.USAGE);
+ const dirty = await g.dirty();
+ if (dirty.length) {
+ const shown = dirty.slice(0, 5).join(', ');
+ throw new OpError(`${dir} has local changes (${shown}${dirty.length > 5 ? `, +${dirty.length - 5} more` : ''}) — refusing to self-update; nothing was fetched or changed`, EXIT.USAGE);
+ }
+
+ log(`fetching ${REMOTE} ${BRANCH} in ${dir}`);
+ try { await g.fetch(); } catch (err) { throw new OpError(`git fetch ${REMOTE} ${BRANCH} failed in ${dir}: ${err.message}`, EXIT.USAGE); }
+ const newSha = await g.revParse(`${REMOTE}/${BRANCH}`);
+ if (!newSha) throw new OpError(`${REMOTE}/${BRANCH} not found in ${dir}`, EXIT.USAGE);
+
+ const commits = newSha === oldSha ? [] : await g.log(oldSha, newSha);
+ const result = { installDir: dir, branch, remote: REMOTE, oldSha, newSha, commits: commits.length, upToDate: newSha === oldSha, dryRun, updated: false, lockfileRestored: false, npm: null };
+
+ if (result.upToDate) {
+ if (!dryRun) log(`${dir} is already at ${REMOTE}/${BRANCH}`);
+ return result;
+ }
+
+ const ff = await exec.run('git', ['-C', dir, 'merge-base', '--is-ancestor', oldSha, newSha], { as: svc.owner });
+ if (ff.code !== 0) throw new OpError(`${dir} has diverged from ${REMOTE}/${BRANCH} (${oldSha.slice(0, 12)} is not an ancestor of ${newSha.slice(0, 12)}) — refusing, not a fast-forward; fix it by hand`, EXIT.USAGE);
+
+ if (dryRun) return result;
+
+ try { await g.ffMerge(newSha); } catch (err) { throw new OpError(`git merge --ff-only ${newSha.slice(0, 12)} failed in ${dir}: ${err.message}`, EXIT.USAGE); }
+ result.updated = true;
+
+ log(`installing dependencies (npm ${NPM_CI.join(' ')})`);
+ const npm = await exec.run('npm', NPM_CI, { as: svc.owner, cwd: dir, timeoutMs: 20 * 60 * 1000 });
+ result.npm = { code: npm.code };
+ if (npm.code !== 0) {
+ const why = String(npm.stderr || npm.stdout).trim().split('\n').slice(-3).join(' / ');
+ throw new OpError(`the code is now at ${newSha.slice(0, 12)}, but "npm ci --omit=dev --no-audit --no-fund" failed (exit ${npm.code}) in ${dir}: ${why} — re-run it there by hand`, EXIT.VALIDATION);
+ }
+
+ // npm ci normally does not touch the lockfile, but if it did the install would no longer be the
+ // merged tree (and the next self-update would refuse it as dirty).
+ if ((await g.dirty()).includes('package-lock.json')) {
+ await g.checkoutFile('package-lock.json');
+ result.lockfileRestored = true;
+ log('package-lock.json was rewritten by npm; restored to the merged version');
+ }
+ return result;
+}
+
+module.exports = { selfUpdate, NPM_CI, REMOTE, BRANCH };
@@ -10,6 +10,7 @@ const { status } = require('./commands/status');
@@ -10,6 +10,7 @@ const { status } = require('./commands/status');
const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');
const { backup, backupAll, readRun, writeRun, writeMetrics, BackupError } = require('./commands/backup');
const offsite = require('./offsite');
const offsite = require('./offsite');
const { snapshot } = require('./commands/snapshot');
const { snapshot } = require('./commands/snapshot');
+const { selfUpdate } = require('./commands/self-update');
const { drill, DrillError } = require('./commands/drill');
const { drill, DrillError } = require('./commands/drill');
const releases = require('./releases');
const releases = require('./releases');
const certs = require('./certs');
const certs = require('./certs');
@@ -43,6 +44,7 @@ module.exports = {
@@ -43,6 +44,7 @@ module.exports = {
incidents: require('./incidents'),
incidents: require('./incidents'),
dns: require('./dns'),
dns: require('./dns'),
snapshot,
snapshot,
+ selfUpdate,
drill,
drill,
DrillError,
DrillError,
releases,
releases,
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+'use strict';
+/**
+ * `ovhost self-update [--dry-run]`: update the CLI install itself, driven against the fake host.
+ * Nothing here touches the real machine: the install is a fake repo at /usr/local/lib/openvibe-host
+ * and every git/npm call is recorded in host.calls, so the tests assert exactly what would run.
+ */
+const assert = require('assert');
+const path = require('path');
+const { scenario, test, runTests } = require('./helpers');
+const { main } = require('../lib/cli');
+const { NPM_CI } = require('../lib/commands/self-update');
+
+const DIR = '/usr/local/lib/openvibe-host';
+const SELF = `${DIR}/bin/ovhost`;
+
+function cli(host, ...argv) {
+ const lines = [];
+ return main(argv, { exec: host.exec, out: (s) => lines.push(s), env: {}, selfPath: SELF })
+ .then((code) => ({ code, out: lines.join('\n') }));
+}
+
+function installRepo(host, { branch = 'main' } = {}) {
+ const repo = host.createRepo(DIR, { owner: 'root', branch });
+ const sha = repo.commit({
+ 'package.json': JSON.stringify({ name: 'openvibe-host', version: '0.3.0', dependencies: { express: '^4.21.2' } }),
+ 'package-lock.json': '{"lockfileVersion":3,"packages":{}}',
+ 'bin/ovhost': '#!/usr/bin/env node\n',
+ 'lib/cli.js': 'old();\n',
+ }, { message: 'installed' });
+ repo.publish(sha);
+ repo.checkout(sha);
+ return { repo, sha };
+}
+
+const gitCalls = (host) => host.calls.filter((c) => c.cmd === 'git');
+const npmCalls = (host) => host.calls.filter((c) => c.cmd === 'npm');
+
+runTests([
+ test('self-update fetches, fast-forwards and runs npm ci in its own install dir', async () => {
+ const host = scenario();
+ const { repo, sha } = installRepo(host);
+ const next = repo.commit({ 'lib/cli.js': 'new();\n', 'lib/self-update.js': 'x();\n' }, { message: 'env-names --set, logs, Bot vhost' });
+ repo.publish(next);
+
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /ovhost [0-9a-f]{12} → [0-9a-f]{12} \(1 commit\)/);
+ assert.strictEqual(repo.head, next);
+ assert.ok(gitCalls(host).some((c) => c.args.includes('fetch')), 'fetches origin');
+ assert.ok(gitCalls(host).some((c) => c.args.includes('merge') && c.args.includes('--ff-only') && c.args.includes(next)), 'merges --ff-only the fetched sha');
+ const npm = npmCalls(host);
+ assert.strictEqual(npm.length, 1, 'one npm call');
+ assert.deepStrictEqual(npm[0].args, NPM_CI);
+ assert.strictEqual(npm[0].cwd, DIR);
+ assert.strictEqual(npm[0].as, 'root');
+ // The fake npm rewrites package-lock.json; self-update puts the merged one back.
+ assert.strictEqual(host.read(path.join(DIR, 'package-lock.json')), '{"lockfileVersion":3,"packages":{}}');
+ // A CLI update never touches a unit.
+ assert.deepStrictEqual(host.calls.filter((c) => c.cmd === 'systemctl'), []);
+ }),
+
+ test('up to date: nothing is merged and nothing is installed', async () => {
+ const host = scenario();
+ installRepo(host);
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /ovhost [0-9a-f]{12} is up to date \(origin\/main\)/);
+ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);
+ assert.deepStrictEqual(npmCalls(host), []);
+ }),
+
+ test('--dry-run prints the plan and changes nothing', async () => {
+ const host = scenario();
+ const { repo, sha } = installRepo(host);
+ const next = repo.commit({ 'lib/cli.js': 'new();\n' }, { message: 'change' });
+ repo.publish(next);
+
+ const r = await cli(host, 'self-update', '--dry-run');
+ assert.strictEqual(r.code, 0, r.out);
+ assert.match(r.out, /\(1 commit\) \(dry run: nothing changed\)/);
+ assert.match(r.out, /would: git fetch origin main; git merge --ff-only origin\/main; npm ci --omit=dev --no-audit --no-fund in /);
+ assert.strictEqual(repo.head, sha, 'HEAD is untouched');
+ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);
+ assert.deepStrictEqual(npmCalls(host), []);
+ }),
+
+ test('a dirty tree is refused before anything is fetched', async () => {
+ const host = scenario();
+ const { repo, sha } = installRepo(host);
+ host.put(path.join(DIR, 'lib/cli.js'), 'hand-edited();\n');
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /has local changes \(lib\/cli\.js\)/);
+ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('fetch')), []);
+ assert.strictEqual(repo.head, sha);
+ }),
+
+ test('a branch other than main is refused', async () => {
+ const host = scenario();
+ installRepo(host, { branch: 'release' });
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /on branch "release", not "main"/);
+ }),
+
+ test('a divergence (not a fast-forward) is refused and nothing is merged', async () => {
+ const host = scenario();
+ const { repo, sha } = installRepo(host);
+ const local = repo.commit({ 'lib/cli.js': 'local();\n' }, { message: 'hand edit' });
+ repo.checkout(local);
+ const remote = repo.commit({ 'lib/cli.js': 'remote();\n' }, { parent: sha, message: 'upstream' });
+ repo.publish(remote);
+
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /diverged from origin\/main .* not a fast-forward/);
+ assert.strictEqual(repo.head, local);
+ assert.deepStrictEqual(gitCalls(host).filter((c) => c.args.includes('merge')), []);
+ assert.deepStrictEqual(npmCalls(host), []);
+ }),
+
+ test('a failed npm ci exits 2 and says the checkout is at the new sha', async () => {
+ const host = scenario();
+ const { repo } = installRepo(host);
+ const next = repo.commit({ 'lib/cli.js': 'new();\n' }, { message: 'change' });
+ repo.publish(next);
+ host.npm = () => ({ code: 1, stderr: 'npm ERR! network timeout\n' });
+
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 2, r.out);
+ assert.match(r.out, /npm ci --omit=dev --no-audit --no-fund" failed \(exit 1\)/);
+ assert.match(r.out, /re-run it there by hand/);
+ assert.strictEqual(repo.head, next, 'the checkout did move; only dependencies are missing');
+ }),
+
+ test('not root: refused before any git or npm call', async () => {
+ const host = scenario();
+ installRepo(host);
+ host.exec.isRoot = async () => false;
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /must run as root \(sudo ovhost self-update\)/);
+ assert.deepStrictEqual(gitCalls(host), []);
+ assert.deepStrictEqual(npmCalls(host), []);
+ }),
+
+ test('a real path that is not <install>/bin/ovhost is refused', async () => {
+ const host = scenario();
+ installRepo(host);
+ const code = await main(['self-update'], { exec: host.exec, out: () => {}, env: {}, selfPath: '/usr/local/bin/ovhost' });
+ assert.strictEqual(code, 1);
+ assert.deepStrictEqual(npmCalls(host), []);
+ }),
+
+ test('an install dir that is not a git checkout is refused', async () => {
+ const host = scenario();
+ host.ensureDir(`${DIR}/bin`, 'root');
+ host.put(SELF, '#!/usr/bin/env node\n', { owner: 'root', mode: 0o755 });
+ const r = await cli(host, 'self-update');
+ assert.strictEqual(r.code, 1, r.out);
+ assert.match(r.out, /is not a git checkout/);
+ }),
+
+ test('--json reports the shas, the commit count and what npm was run', async () => {
+ const host = scenario();
+ const { repo, sha } = installRepo(host);
+ const mid = repo.commit({ 'a.js': 'a();\n' }, { message: 'one' });
+ const next = repo.commit({ 'b.js': 'b();\n' }, { parent: mid, message: 'two' });
+ repo.publish(next);
+
+ const r = await cli(host, 'self-update', '--json');
+ assert.strictEqual(r.code, 0, r.out);
+ const doc = JSON.parse(r.out);
+ assert.strictEqual(doc.installDir, DIR);
+ assert.strictEqual(doc.oldSha, sha);
+ assert.strictEqual(doc.newSha, next);
+ assert.strictEqual(doc.commits, 2);
+ assert.strictEqual(doc.updated, true);
+ assert.strictEqual(doc.lockfileRestored, true);
+ assert.deepStrictEqual(doc.npm, { code: 0 });
+ }),
+]);