openvibers/openvibe.host Commits

Add ovhost self-update to fast-forward and reinstall the CLI install (#7)

OP OpenVibers committed 2 Oct 2026 · d1bd62a800 · 5 files, +303 −3

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

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

Changes

ddiff --git a/README.md b/README.md
iindex 3852d47..40cc4e4 100644
---- a/README.md
++++ b/README.md
@@@ -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 ci
ovhost --version
ovhost announce <service> [--release <id>] [--commit <sha>] [--origin <url>] [--force] [--dry-run] release notification
ovhost 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 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.
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
ddiff --git a/lib/cli.js b/lib/cli.js
iindex 8e8ff60..8bfe763 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -44,6 +44,10 @@ Usage: ovhost <command> [args] [--inventory <file>] [--json]
current one replaced (release log), with its own node_modules
capabilities [<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 version
announce <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 restart
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 ·
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) => proc
if (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);
ddiff --git a/lib/commands/self-update.js b/lib/commands/self-update.js
nnew file mode 100644
iindex 0000000..20e2f5a
---- /dev/null
++++ b/lib/commands/self-update.js
@@@ -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 };
ddiff --git a/lib/index.js b/lib/index.js
iindex 1f4ea7e..65ddba1 100644
---- a/lib/index.js
++++ b/lib/index.js
@@@ -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,
ddiff --git a/test/self-update.test.js b/test/self-update.test.js
nnew file mode 100644
iindex 0000000..d19a57d
---- /dev/null
++++ b/test/self-update.test.js
@@@ -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 });
++ }),
++]);