OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / cli / index.js

1792 lines · javascript ref:
#!/usr/bin/env node

/**
 * HiveRelay CLI
 *
 * Usage:
 *   p2p-hiverelay setup               Interactive setup wizard (TUI)
 *   p2p-hiverelay init                Initialize HiveRelay + install agent skills
 *   p2p-hiverelay start [options]     Start a relay node
 *   p2p-hiverelay seed <key>          Request seeding for a Pear app
 *   p2p-hiverelay ghostdrive ...      Ghost Drive relay workflows
 *   p2p-hiverelay qvac ...            Manage qvac-backed AI models
 *   p2p-hiverelay status              Show node status
 *   p2p-hiverelay help                Show this help
 */

import minimist from 'minimist'
import gracedown from 'pear-gracedown'
import { RelayNode } from '../core/relay-node/index.js'
import { createLogger } from '../core/logger.js'
import { isValidHexKey } from '../core/constants.js'
import {
  loadConfig,
  saveConfig,
  ensureDirs,
  CONFIG_PATH,
  STORAGE_DIR,
  deriveTokenFromSeed,
  applyOutboxlogNamespaceEnv,
  getStorageCapProvenance,
  markStorageCapExplicit,
  resolveStorageCap
} from '../config/loader.js'
import {
  applyPublicHiveGatewayEnv,
  assertPublicHiveGatewayConcurrency,
  assertPublicHiveGatewayFiniteLimits,
  assertPublicHiveGatewayVersionPins
} from '../config/public-hive-gateway-env.js'
import b4a from 'b4a'
import { existsSync, mkdirSync, cpSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { fileURLToPath } from 'url'
import {
  mainBanner, setupBanner, testnetBanner, statusBanner, helpBanner,
  shutdownBanner, divider, HEX, HEX_DIM, OK, ARROW, paint, C
} from './banner.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'))
const VERSION = pkg.version
const SKILL_SRC = join(__dirname, '..', 'skills', 'SKILL.md')
const VALID_ACCEPT_MODES = ['open', 'review', 'allowlist', 'closed']

const args = minimist(process.argv.slice(2), {
  boolean: ['version', 'auto-heal', 'autoheal'],
  // Keep identity-like values as exact strings. In particular, an app key
  // containing only decimal digits must never be coerced through Number.
  string: [
    'gateway-host',
    'gateway-port',
    'gateway-trusted-proxy-address',
    'gateway-compatibility-host',
    'gateway-max-in-flight',
    'gateway-max-in-flight-per-app',
    'hive-app-host-suffix',
    'hive-app-public-key',
    'hive-app-public-version'
  ]
})
const command = args._[0]

// Handle --version / -v before anything else (no deps, no banner spam).
if (args.version || args.v === true) {
  console.log(pkg.version)
  process.exit(0)
}

const log = createLogger({ name: 'hiverelay-cli' })

// ─── Process crash protection ──────────────────────────────────────

// Filter known-benign errors that shouldn't log as fatal:
//   - ExitPromptError: user hit Ctrl+C inside an inquirer prompt (clean exit)
//   - AbortError: inquirer prompt aborted via signal
function isBenignExit (err) {
  if (!err) return false
  const name = err.name || (err.constructor && err.constructor.name) || ''
  return name === 'ExitPromptError' || name === 'AbortError'
}

// Hypercore / Hyperbee lifecycle errors during async iteration — the
// underlying core closed mid-read, or a block was corrupted. These should
// NOT kill a long-running relay; log and continue.
function isRecoverableHypercoreError (err) {
  if (!err) return false
  const code = err.code || ''
  const type = err.type || (err.constructor && err.constructor.name) || ''
  if (type !== 'HypercoreError') return false
  return (
    code === 'SESSION_CLOSED' ||
    code === 'DECODING_ERROR' ||
    code === 'SNAPSHOT_NOT_AVAILABLE' ||
    code === 'REQUEST_CANCELLED' ||
    code === 'CLOSED'
  )
}

// DHT errors — REQUEST_TIMEOUT and friends are normal noise on the public
// DHT (a peer dropped, a query never resolved, a re-routed response made
// the original time out). The dht-rpc library throws them as unhandled
// rejections rather than emitting events, so they reach this handler and
// (before this filter) caused the relay to exit.
//
// This catches the crash loop observed on relay-us / utah-us on 2026-04-30:
// 13 fatal exits over 2 hours, all DHTError REQUEST_TIMEOUT, each making
// systemd restart the process. Logged at warn level for observability,
// then ignored — exactly like the hypercore lifecycle filter above.
function isRecoverableDHTError (err) {
  if (!err) return false
  const code = err.code || ''
  const type = err.type || (err.constructor && err.constructor.name) || ''
  if (type !== 'DHTError') return false
  return (
    code === 'REQUEST_TIMEOUT' ||
    code === 'REQUEST_CANCELLED' ||
    code === 'REQUEST_DESTROYED' ||
    code === 'NO_NODES_AVAILABLE' ||
    code === 'TOO_MANY_PROBES' ||
    code === 'CONNECTION_RESET'
  )
}

function isRecoverable (err) {
  return isRecoverableHypercoreError(err) || isRecoverableDHTError(err)
}

process.on('uncaughtException', (err) => {
  if (isBenignExit(err)) {
    console.log()
    process.exit(0)
  }
  if (isRecoverable(err)) {
    log.warn({ err: { type: err.type, code: err.code, message: err.message } }, 'recoverable error — continuing')
    return // do NOT exit
  }
  log.fatal({ err }, 'uncaught exception — shutting down')
  process.exit(1)
})

process.on('unhandledRejection', (reason) => {
  if (isBenignExit(reason)) {
    console.log()
    process.exit(0)
  }
  if (isRecoverable(reason)) {
    log.warn({ err: { type: reason.type, code: reason.code, message: reason.message } }, 'recoverable rejection — continuing')
    return // do NOT exit
  }
  log.fatal({ err: reason }, 'unhandled rejection — shutting down')
  process.exit(1)
})

// ─── Commands ──────────────────────────────────────────────────────

const COMMANDS = {
  setup,
  manage,
  tui: manage, // alias for manage — the operator TUI
  init,
  start,
  testnet: startTestnet,
  seed,
  ghostdrive,
  catalog,
  federation,
  qvac,
  status,
  doctor,
  help
}

// These commands parse their own positional args and already print their own
// usage on --help / invalid input. Everything else falls back to global help.
const SUBCOMMAND_HELP_COMMANDS = new Set(['catalog', 'federation', 'qvac', 'ghostdrive'])

async function main () {
  const handler = COMMANDS[command]
  if (!handler) {
    help()
    process.exit(command ? 1 : 0)
  }
  if ((args.help || args.h === true) && !SUBCOMMAND_HELP_COMMANDS.has(command)) {
    help()
    process.exit(0)
  }
  await handler()
}

// ─── setup (interactive TUI) ────────────────────────────────────────

async function setup () {
  const { runSetup } = await import('./setup.js')
  const result = await runSetup()
  if (result && result.startNow) {
    await start()
  }
}

// ─── manage (live management console) ──────────────────────────────

async function manage () {
  const { runManage } = await import('./manage.js')
  const host = args.host || '127.0.0.1'
  const port = args.port ? parseInt(args.port) : 9100
  await runManage(host, port, { apiKey: getApiKey(args) })
}

// ─── catalog (operator catalog control via /api/manage/catalog/*) ──

async function catalog () {
  const { runCatalogCommand } = await import('./catalog.js')
  const code = await runCatalogCommand({
    argv: args,
    positional: args._.slice(1)
  })
  process.exit(code)
}

// ─── federation (cross-relay federation via /api/manage/federation/*) ─

async function federation () {
  const { runFederationCommand } = await import('./federation.js')
  const code = await runFederationCommand({
    argv: args,
    positional: args._.slice(1)
  })
  process.exit(code)
}

// ─── qvac (operator AI model control via /api/manage/ai/models) ───

async function qvac () {
  const { runQvacCommand } = await import('./qvac.js')
  const code = await runQvacCommand({
    argv: args,
    positional: args._.slice(1)
  })
  process.exit(code)
}

// ─── init ───────────────────────────────────────────────────────────

async function init () {
  console.log(setupBanner(VERSION))

  // 1. Create directories and config
  ensureDirs()
  const initOverrides = {}
  if (args.region) initOverrides.region = args.region
  if (args['max-storage']) {
    initOverrides.maxStorageBytes = parseBytesOrExit(args['max-storage'], '--max-storage')
    markStorageCapExplicit(initOverrides, 'cli')
  }
  const config = loadConfig(initOverrides)
  const configPath = saveConfig(config)
  console.log(`  [ok] Config:  ${configPath}`)
  console.log(`  [ok] Storage: ${config.storage}`)

  // 2. Detect agent frameworks
  const home = homedir()
  const hermes = existsSync(join(home, '.hermes'))
  const openclaw = existsSync('/opt/homebrew/lib/node_modules/openclaw') ||
                   existsSync(join(home, '.openclaw'))

  const installed = []

  // 3. Install skill for Hermes
  if (hermes && existsSync(SKILL_SRC)) {
    const dest = join(home, '.hermes', 'skills', 'hiverelay')
    mkdirSync(dest, { recursive: true })
    cpSync(SKILL_SRC, join(dest, 'SKILL.md'))
    installed.push('Hermes')
    console.log(`  [ok] Hermes skill installed: ${dest}/SKILL.md`)
  }

  // 4. Install skill for OpenClaw
  if (openclaw && existsSync(SKILL_SRC)) {
    const ocSkillDir = existsSync(join(home, '.openclaw'))
      ? join(home, '.openclaw', 'skills', 'hiverelay')
      : join('/opt/homebrew/lib/node_modules/openclaw/extensions', 'hiverelay')
    mkdirSync(ocSkillDir, { recursive: true })
    cpSync(SKILL_SRC, join(ocSkillDir, 'SKILL.md'))
    installed.push('OpenClaw')
    console.log(`  [ok] OpenClaw skill installed: ${ocSkillDir}/SKILL.md`)

    const pluginSrc = join(__dirname, '..', 'plugins', 'openclaw')
    if (existsSync(pluginSrc)) {
      const pluginDest = join(ocSkillDir, 'plugin')
      mkdirSync(pluginDest, { recursive: true })
      for (const f of ['index.ts', 'package.json']) {
        if (existsSync(join(pluginSrc, f))) {
          cpSync(join(pluginSrc, f), join(pluginDest, f))
        }
      }
      console.log(`  [ok] OpenClaw plugin installed: ${pluginDest}/`)
    }
  }

  if (!hermes && !openclaw) {
    console.log('  [--] No agent framework detected (Hermes or OpenClaw)')
    console.log('       Skill file is at: skills/SKILL.md — install it manually')
  }

  // 5. Summary
  console.log()
  console.log('  ' + divider('═', C.green))
  console.log('  ' + OK + ' ' + paint(C.green, 'setup complete') + paint(C.dim, ' — welcome to the swarm'))
  console.log('  ' + divider('═', C.green))
  console.log()
  console.log('  ' + paint(C.cyan + '\x1b[1m', '▶ quick start'))
  console.log('    ' + paint(C.magenta, 'p2p-hiverelay start') + '                 ' + paint(C.dim, '# start relay node'))
  console.log('    ' + paint(C.magenta, 'curl localhost:9100/health') + '      ' + paint(C.dim, '# liveness check'))
  console.log('    ' + paint(C.magenta, 'curl localhost:9100/status') + '      ' + paint(C.dim, '# live stats'))
  if (installed.length > 0) {
    console.log()
    console.log('  ' + paint(C.cyan + '\x1b[1m', '▶ agent integration') + paint(C.dim, ` (${installed.join(' + ')})`))
    console.log('    ' + paint(C.magenta, '/hiverelay start') + '                ' + paint(C.dim, '# start via agent skill'))
    console.log('    ' + paint(C.magenta, '/hiverelay status') + '               ' + paint(C.dim, '# check status via agent'))
    console.log('    ' + paint(C.magenta, '/hiverelay seed <key>') + '           ' + paint(C.dim, '# seed an app via agent'))
  }
  console.log()
}

// ─── start ──────────────────────────────────────────────────────────

async function start () {
  const cliOverrides = {}
  if (args.storage) cliOverrides.storage = args.storage
  else if (process.env.HIVERELAY_STORAGE) cliOverrides.storage = process.env.HIVERELAY_STORAGE
  if (args['max-storage']) {
    cliOverrides.maxStorageBytes = parseBytesOrExit(args['max-storage'], '--max-storage')
    markStorageCapExplicit(cliOverrides, 'cli')
  } else if (process.env.HIVERELAY_MAX_STORAGE) {
    const maxStorageBytes = parseBytesOrExit(process.env.HIVERELAY_MAX_STORAGE, 'HIVERELAY_MAX_STORAGE')
    if (!hasPersistedMaxStorage()) {
      cliOverrides.maxStorageBytes = maxStorageBytes
      markStorageCapExplicit(cliOverrides, 'environment')
    }
  }
  if (args['max-connections']) cliOverrides.maxConnections = parseInt(args['max-connections'])
  if (args['max-bandwidth']) cliOverrides.maxRelayBandwidthMbps = parseInt(args['max-bandwidth'])
  if (args.bootstrap) cliOverrides.bootstrapNodes = parseBootstrapNodesOrExit(args.bootstrap)
  if (args['api-host']) cliOverrides.apiHost = String(args['api-host'])
  if (args.relay === false) cliOverrides.enableRelay = false
  if (args.seeding === false) cliOverrides.enableSeeding = false
  if (args.metrics === false) cliOverrides.enableMetrics = false
  if (args.api === false) cliOverrides.enableAPI = false
  if (args['distributed-drive'] === true) cliOverrides.enableDistributedDriveBridge = true
  if (args['distributed-drive'] === false) cliOverrides.enableDistributedDriveBridge = false
  if (args.port) cliOverrides.apiPort = parseInt(args.port)
  if (args.region) cliOverrides.regions = [].concat(args.region)
  if (typeof args.operator === 'string' && args.operator.trim()) {
    cliOverrides.operator = args.operator.trim()
  }
  // --auto-heal — opt into the diversity-enforced replica recruitment scheduler.
  // See docs/WHATS-IN-THE-RELAY.md §3 and the Atomic Blind Custody whitepaper.
  if (args['auto-heal'] === true || args.autoheal === true) {
    cliOverrides.autoHeal = { ...(cliOverrides.autoHeal || {}), enabled: true }
  }
  if (args.tor) {
    if (!cliOverrides.transports) cliOverrides.transports = {}
    cliOverrides.transports.tor = true
    if (typeof args.tor === 'string') {
      if (!cliOverrides.tor) cliOverrides.tor = {}
      cliOverrides.tor.controlPassword = args.tor
    }
  }
  if (args['tor-socks-port']) {
    if (!cliOverrides.tor) cliOverrides.tor = {}
    cliOverrides.tor.socksPort = parseInt(args['tor-socks-port'])
  }
  if (args['tor-control-port']) {
    if (!cliOverrides.tor) cliOverrides.tor = {}
    cliOverrides.tor.controlPort = parseInt(args['tor-control-port'])
  }
  if (args.holesail) {
    if (!cliOverrides.transports) cliOverrides.transports = {}
    cliOverrides.transports.holesail = true
  }
  // --dht-relay-ws OR HIVERELAY_DHT_RELAY_WS=1 enables the WebSocket
  // bridge to HyperDHT. Lets browser/Android clients (which can't speak
  // UDP) reach this relay over wss://. Hardened for privacy in v0.8.16
  // — emitted events carry only a salted hash of the client IP, never
  // the raw IP. See packages/core/transports/dht-relay-ws/index.js
  // header for the full threat model.
  if (args['dht-relay-ws'] === true || process.env.HIVERELAY_DHT_RELAY_WS === '1' ||
      process.env.HIVERELAY_DHT_RELAY_WS === 'true') {
    if (!cliOverrides.transports) cliOverrides.transports = {}
    cliOverrides.transports.dhtRelayWs = true
    // Operators almost always front the transport with TLS at a reverse
    // proxy (Caddy/nginx) on port 443; the relay itself binds plain ws://
    // on localhost. Override port via --dht-relay-ws-port or
    // HIVERELAY_DHT_RELAY_WS_PORT.
    const port = args['dht-relay-ws-port'] || process.env.HIVERELAY_DHT_RELAY_WS_PORT
    if (port) cliOverrides.dhtRelayWsPort = parseInt(port, 10)
    // Default bind to 127.0.0.1 when the transport is enabled — the
    // reverse proxy talks to localhost, and the transport should never
    // be directly internet-exposed.
    if (!cliOverrides.dhtRelayWsHost) {
      cliOverrides.dhtRelayWsHost = process.env.HIVERELAY_DHT_RELAY_WS_HOST || '127.0.0.1'
    }
  }

  // ─── Reverse-proxy / self-hosting-platform UI auth ─────────────────
  // HIVERELAY_UI_EXPOSE_TOKEN=1 embeds the management token into the
  // served dashboard/wizard HTML so the browser UI can authenticate when
  // it reaches the relay through a trusted authenticating proxy (e.g.
  // Umbrel's app_proxy), where the localhost check can never apply. See
  // config/default.js `ui.exposeToken` for the security contract.
  if (process.env.HIVERELAY_UI_EXPOSE_TOKEN === '1' || process.env.HIVERELAY_UI_EXPOSE_TOKEN === 'true') {
    if (!cliOverrides.ui) cliOverrides.ui = {}
    cliOverrides.ui.exposeToken = true
    // Give the UI a working credential without the operator setting one:
    // derive a stable management key from $APP_SEED (deterministic per
    // app-id, survives reinstalls). An explicit key always wins.
    if (!cliOverrides.apiKey && !process.env.HIVERELAY_API_KEY) {
      const derived = deriveTokenFromSeed(process.env.APP_SEED)
      if (derived) cliOverrides.apiKey = derived
    }
  }
  // HIVERELAY_UI_SIMPLE=1 serves the single-page appliance dashboard
  // (blindspark.html) instead of the full multi-tab operator UI. Used by
  // the Blindspark Umbrel packaging; PC operators leave it off and get
  // the full dashboard. See config/default.js `ui.simple`.
  if (process.env.HIVERELAY_UI_SIMPLE === '1' || process.env.HIVERELAY_UI_SIMPLE === 'true') {
    if (!cliOverrides.ui) cliOverrides.ui = {}
    cliOverrides.ui.simple = true
  }
  // HIVERELAY_TRUST_PROXY=1 — relay is behind a reverse proxy, so the
  // localhost auth fallback is disabled (a key is required). Independent
  // of exposeToken for plain reverse-proxy setups.
  if (process.env.HIVERELAY_TRUST_PROXY === '1' || process.env.HIVERELAY_TRUST_PROXY === 'true') {
    cliOverrides.trustProxy = true
  }
  // HIVERELAY_ACCEPT_MODE=<open|review|allowlist|closed> gives packaged
  // home-server installs a declarative sovereignty default. It is a default,
  // not a permanent override: once the operator has saved acceptMode (or the
  // legacy registryAutoAccept) in config.json, their persisted choice wins.
  if (process.env.HIVERELAY_ACCEPT_MODE) {
    const mode = String(process.env.HIVERELAY_ACCEPT_MODE).trim().toLowerCase()
    if (!VALID_ACCEPT_MODES.includes(mode)) {
      console.error('Invalid HIVERELAY_ACCEPT_MODE: must be one of ' + VALID_ACCEPT_MODES.join(', '))
      process.exit(1)
    }
    if (!hasPersistedAcceptMode()) {
      cliOverrides.acceptMode = mode
      delete cliOverrides.registryAutoAccept
    }
  }

  // HIVERELAY_INDEX_SIDECAR_URL — point the relay at a running index sidecar
  // (services/index-sidecar). When set, GET /index/* + /api/index/room are
  // reverse-proxied there so the desktop hits a single gatewayUrl. Off by
  // default; the relay advertises indexRoom once the sidecar publishes it.
  if (process.env.HIVERELAY_INDEX_SIDECAR_URL) {
    cliOverrides.indexSidecarUrl = process.env.HIVERELAY_INDEX_SIDECAR_URL
  }

  // HIVERELAY_GATEWAY_URL — the relay's public HTTP base URL, advertised in the
  // capability doc so the index sidecar can build relay-directory rows (and
  // clients can reach the relay without a hardcoded address).
  if (process.env.HIVERELAY_GATEWAY_URL) {
    cliOverrides.gatewayUrl = process.env.HIVERELAY_GATEWAY_URL
  }

  // ─── Operator subsidy (Phase 1: accrual + signed claims only) ──────
  // Container-friendly enable for the Blindspark packaging; config.json
  // `subsidy.*` is the equivalent for bare installs. Destination can be
  // pre-seeded via env but is normally set from the dashboard.
  if (process.env.HIVERELAY_SUBSIDY_ENABLED === '1' || process.env.HIVERELAY_SUBSIDY_ENABLED === 'true') {
    if (!cliOverrides.subsidy) cliOverrides.subsidy = {}
    cliOverrides.subsidy.enabled = true
    if (process.env.HIVERELAY_SUBSIDY_DESTINATION && !cliOverrides.subsidy.payoutDestination) {
      cliOverrides.subsidy.payoutDestination = process.env.HIVERELAY_SUBSIDY_DESTINATION
    }
  }

  // HIVERELAY_OUTBOXLOG_NAMESPACE — register the app namespace an ENV-driven
  // operator's app signs records under (e.g. Peerit signs 'peerit'). Without
  // it, a fleet/bern box configured purely by ENV (HIVERELAY_OUTBOXLOG=1) had
  // no way to admit its app and every append was rejected `unknown namespace`
  // (400). Precedence (env is a default, persisted config.json wins) is handled
  // by applyOutboxlogNamespaceEnv — see its doc in config/loader.js.
  applyOutboxlogNamespaceEnv(
    cliOverrides,
    process.env.HIVERELAY_OUTBOXLOG_NAMESPACE,
    hasPersistedOutboxlogNamespace()
  )

  try {
    applyPublicHiveGatewayEnv(cliOverrides, args, process.env)
  } catch (err) {
    console.error(err && err.message ? err.message : String(err))
    process.exit(1)
  }

  const config = loadConfig(cliOverrides)
  try {
    assertPublicHiveGatewayConcurrency(config)
    assertPublicHiveGatewayFiniteLimits(config)
    assertPublicHiveGatewayVersionPins(config)
  } catch (err) {
    console.error(err && err.message ? err.message : String(err))
    process.exit(1)
  }

  // The built-in storage directory is owned by HiveRelay and safe to create.
  // A custom path may be an intended future mount, so it must already exist;
  // resolveStorageCap measures the exact path and startup fails closed if that
  // proof cannot be established.
  if (config.storage === STORAGE_DIR) ensureDirs()
  resolveStorageCap(config)
  const storageCap = getStorageCapProvenance(config)
  if (storageCap?.status !== 'resolved') {
    console.log(`  ${ARROW} ${paint(C.red, 'storage startup blocked')} (${storageCap?.reason || 'filesystem unresolved'}; restore the configured path or mount before retrying)`)
  } else if (storageCap.explicit !== true && config.maxStorageBytes < storageCap.requestedBytes) {
    console.log(`  ${ARROW} ${paint(C.yellow, 'default max-storage resolved')} ${formatBytes(config.maxStorageBytes)} (available-space reserve protected)`)
  }

  // Scale an unset storage cap to THIS box's disk (and clamp an over-large
  // explicit cap) so the seeder's adoption gate stops before the volume fills
  // and wedges the node. The fixed 50 GB default is too big for small boxes.
  resolveStorageCap(config)
  if (config._maxStorageBytesClampedFrom != null) {
    console.log(`  ${ARROW} ${paint(C.yellow, 'max-storage clamped')} ${formatBytes(config._maxStorageBytesClampedFrom)} ${ARROW} ${formatBytes(config.maxStorageBytes)} (90% of disk — protects the volume)`)
    delete config._maxStorageBytesClampedFrom
  }

  console.log(mainBanner(VERSION))
  console.log('  ' + ARROW + ' ' + paint(C.cyan, 'starting relay node') + ' ' + paint(C.dim, '...'))
  console.log()

  const node = new RelayNode(config)

  node.on('started', ({ publicKey }) => {
    const pubHex = b4a.toString(publicKey, 'hex')
    log.info({ publicKey: pubHex, port: config.apiPort }, 'relay node started')
    console.log(`  Public Key: ${pubHex}`)
    console.log(`  Storage:    ${config.storage}`)
    console.log(`  Max Store:  ${formatBytes(config.maxStorageBytes)}`)
    console.log(`  Relay:      ${config.enableRelay ? 'enabled' : 'disabled'}`)
    console.log(`  Seeding:    ${config.enableSeeding ? 'enabled' : 'disabled'}`)
    console.log(`  DistDrive:  ${config.enableDistributedDriveBridge ? 'enabled' : 'disabled'}`)
    console.log(`  API:        ${config.enableAPI ? 'http://127.0.0.1:' + config.apiPort : 'disabled'}`)
    console.log(`  Regions:    ${config.regions && config.regions.length ? config.regions.join(', ') : 'all'}`)
    console.log(`  Operator:   ${config.operator || '(unset — falls back to pubkey-as-operator)'}`)
    console.log(`  AutoHeal:   ${config.autoHeal?.enabled ? 'enabled' : 'disabled'}`)
    console.log(`  Custody:    ${config.custody?.enabled !== false ? `enabled (${config.custody?.defaultMode || 'blind'})` : 'disabled'}`)
    if (config.transports && config.transports.tor) {
      console.log(`  Tor:        enabled (SOCKS ${config.tor ? config.tor.socksPort || 9050 : 9050})`)
    }
    console.log()
    console.log('  Node is running. Press Ctrl+C to stop.')
    console.log()
  })

  node.on('tor-ready', ({ onionAddress }) => {
    log.info({ onionAddress }, 'tor hidden service active')
    console.log(`  Onion:      ${onionAddress}`)
  })

  node.on('holesail-ready', ({ connectionKey }) => {
    log.info({ connectionKey }, 'holesail tunnel active')
    console.log(`  Holesail:   ${connectionKey}`)
  })

  node.on('nat-check', ({ publicIp, reachable, action }) => {
    if (reachable) {
      log.info({ publicIp }, 'API publicly reachable — holesail not needed')
    } else {
      log.info({ publicIp, action }, 'API behind NAT — auto-enabling holesail')
      console.log(`  NAT detected (${publicIp}) — enabling holesail tunnel...`)
    }
  })

  node.on('connection', ({ remotePubKey }) => {
    // debug, not info: on a busy relay this fires thousands of times and
    // buries the real signal (seeds, anchors, repairs). Symmetric with
    // 'peer disconnected' just below. Confirmed as primary log-noise
    // source in repro/2026-05-17-v0.8.13-partial-recurrence.md.
    log.debug({ peer: remotePubKey.slice(0, 12) }, 'peer connected')
  })

  node.on('connection-closed', () => {
    log.debug('peer disconnected')
  })

  node.on('connection-error', ({ error }) => {
    log.warn({ err: error }, 'connection error')
  })

  node.on('seeding', ({ appKey, discoveryKey }) => {
    log.info({ appKey: appKey.slice(0, 12), discoveryKey: discoveryKey.slice(0, 12) }, 'seeding app')
  })

  // Eviction audit trail (Phase A): every shed entry is operator-visible
  // with the replica math that justified it.
  node.on('eviction', ({ appKey, bytes, remainingReplicas, target, usedPct }) => {
    log.info({ appKey: (appKey || '').slice(0, 12), freedBytes: bytes, remainingReplicas, target, diskUsedPct: usedPct }, 'evicted over-replicated app')
  })

  node.on('eviction-failed', ({ appKey, error }) => {
    log.warn({ appKey: (appKey || '').slice(0, 12), error }, 'eviction failed')
  })

  node.on('acceptance-reconciled', ({ backfilled }) => {
    log.info({ backfilled }, 'backfilled seed-accept records for held entries (census repair)')
  })

  node.on('eviction-error', ({ error }) => {
    log.warn({ error }, 'eviction sweep error')
  })

  node.on('accounting-error', ({ error }) => {
    log.warn({ error }, 'storage accounting error')
  })

  node.on('settlement-error', ({ error }) => {
    log.error({ err: error }, 'settlement error')
  })

  node.on('health-warning', (details) => {
    // stale-connections is always auto-remediated by self-heal
    // (destroy-stale-connections). It's routine maintenance, not an
    // operator-actionable problem — log at info so it doesn't drown the
    // real warnings (memory/disk/error-rate). The event itself is
    // unchanged so dashboards/metrics still see it as a health-warning.
    // Confirmed log-noise source in the v0.8.13 recurrence repro.
    if (details.check === 'stale-connections') {
      log.info({ health: details },
        `self-heal: reaping ${details.staleCount}/${details.totalConns} stale connections`)
      return
    }
    log.warn({ health: details }, `health warning: ${details.check} — ${details.reason || 'threshold exceeded'}`)
  })

  node.on('health-critical', (details) => {
    log.error({ health: details }, `health CRITICAL: ${details.check} — ${details.reason}`)
  })

  node.on('self-heal-action', (action) => {
    log.info({ action }, `self-heal: ${action.type} (trigger: ${action.check})`)
  })

  node.on('registry-seed-accepted', ({ appKey, publisher, currentRelays }) => {
    log.info({ appKey: appKey.slice(0, 12), publisher: publisher.slice(0, 12), currentRelays }, 'registry: auto-accepted seed request')
  })

  node.on('registry-pending', ({ appKey }) => {
    log.info({ appKey: appKey.slice(0, 12) }, 'registry: new pending request (approval mode)')
  })

  node.on('registry-error', (details) => {
    log.warn({ err: details.error || details }, 'registry error')
  })

  node.on('reseeded', ({ appKey, source }) => {
    log.info({ appKey: appKey.slice(0, 12), source: source || 'log' }, 'reseeded app from persistent log')
  })

  node.on('reseed-error', ({ appKey, error }) => {
    log.debug({ appKey: appKey ? appKey.slice(0, 12) : 'unknown', err: error }, 'reseed attempt failed (normal for catalog-synced apps without peers)')
  })

  let statusInterval = null

  gracedown(async () => {
    if (statusInterval) clearInterval(statusInterval)
    log.info('shutting down')
    console.log(shutdownBanner())
    await node.stop()
    log.info('stopped')
    console.log('  ' + OK + ' ' + paint(C.dim, 'stopped. the swarm remembers.'))
    console.log()
  })

  await node.start()

  // If seed keys provided via CLI, seed them immediately
  const seedKeys = args.seed ? [].concat(args.seed) : []
  const seedMaxStorage = seedKeys.length > 0
    ? parseBytesOrExit(args['seed-max-storage'], '--seed-max-storage')
    : null
  for (const key of seedKeys) {
    await node.seedApp(key, { maxStorage: seedMaxStorage })
  }

  // Print status periodically. Keep the live carriage-return status bar for
  // terminals, but avoid writing it into systemd logs every five seconds.
  if (!args.quiet) {
    const interactiveStatus = process.stdout.isTTY === true
    const statusIntervalMs = interactiveStatus ? 5000 : 60000
    statusInterval = setInterval(() => {
      const status = statusSnapshot(node)
      if (interactiveStatus) {
        process.stdout.write(
          `\r  [status] Apps: ${status.seededApps} | Conns: ${status.connections}` +
          ` | Stored: ${formatBytes(status.storedBytes)}` +
          ` | Served: ${formatBytes(status.servedBytes)}` +
          ` | Circuits: ${status.activeCircuits}` +
          '   '
        )
      } else {
        log.info(status, 'relay status')
      }
    }, statusIntervalMs)
  }
}

// ─── testnet ────────────────────────────────────────────────────────

async function startTestnet () {
  const createTestnet = (await import('@hyperswarm/testnet')).default
  const { HiveRelayClient } = await import('../../client/index.js')
  const Hyperswarm = (await import('hyperswarm')).default
  const { mkdirSync, rmSync } = await import('fs')
  const { tmpdir } = await import('os')
  const { randomBytes } = await import('crypto')

  const nodeCount = parseInt(args.nodes) || 3
  const basePort = parseInt(args.port) || 19100
  const runClient = args.client !== false
  const testId = randomBytes(4).toString('hex')
  const baseDir = join(tmpdir(), `hiverelay-testnet-${testId}`)
  mkdirSync(baseDir, { recursive: true })

  console.log(testnetBanner(VERSION))
  console.log('  ' + HEX + ' ' + paint(C.cyan, 'Testnet ID:   ') + paint(C.white, testId))
  console.log('  ' + HEX + ' ' + paint(C.cyan, 'Relay nodes:  ') + paint(C.white, String(nodeCount)))
  console.log('  ' + HEX + ' ' + paint(C.cyan, 'API ports:    ') + paint(C.white, `${basePort}–${basePort + nodeCount - 1}`))
  console.log('  ' + HEX + ' ' + paint(C.cyan, 'Test client:  ') + paint(C.white, runClient ? 'yes' : 'no'))
  console.log('  ' + HEX_DIM + ' ' + paint(C.dim, 'Storage:      ' + baseDir))
  console.log()
  console.log('  ' + ARROW + ' ' + paint(C.magenta, 'spinning up local DHT') + paint(C.dim, '...'))

  const testnet = await createTestnet(3)
  console.log(`  DHT bootstrap: ${testnet.bootstrap.map(b => b.host + ':' + b.port).join(', ')}`)
  console.log()

  const nodes = []

  for (let i = 0; i < nodeCount; i++) {
    const port = basePort + i
    const storage = join(baseDir, `relay-${i}`)
    mkdirSync(storage, { recursive: true })

    const node = new RelayNode({
      storage,
      enableAPI: true,
      apiPort: port,
      apiHost: '127.0.0.1',
      enableRelay: true,
      enableSeeding: true,
      enableMetrics: true,
      acceptMode: 'open',
      maxConnections: 64,
      bootstrapNodes: testnet.bootstrap,
      shutdownTimeoutMs: 5000
    })

    await node.start()
    const pubKey = b4a.toString(node.swarm.keyPair.publicKey, 'hex')

    console.log(`  [relay ${i}] ${pubKey.slice(0, 16)}...`)
    console.log(`            API: http://127.0.0.1:${port}`)
    console.log(`            Dashboard: http://127.0.0.1:${port}/dashboard`)

    node.on('seeding', ({ appKey }) => {
      console.log(`  [relay ${i}] seeding ${appKey.slice(0, 16)}...`)
    })
    node.on('seed-accepted', ({ appKey }) => {
      console.log(`  [relay ${i}] accepted seed: ${appKey.slice(0, 16)}...`)
    })

    nodes.push({ node, port, pubKey })
  }

  // Flush all swarms
  await Promise.all(nodes.map(({ node }) => node.swarm.flush()))
  console.log()

  let client = null
  let clientSwarm = null

  if (runClient) {
    console.log('  Starting test client...')
    const clientStorage = join(baseDir, 'client')
    mkdirSync(clientStorage, { recursive: true })

    clientSwarm = new Hyperswarm({ bootstrap: testnet.bootstrap })
    client = new HiveRelayClient(clientStorage, {
      swarm: clientSwarm,
      maxRelays: nodeCount,
      seedReplicas: nodeCount,
      autoSeed: true
    })

    client.on('relay-connected', (evt) => {
      console.log(`  [client] relay connected: ${evt.pubkey.slice(0, 16)}...`)
    })
    client.on('seeded', (evt) => {
      console.log(`  [client] seeded! ${evt.acceptances} relay(s) accepted`)
    })

    await client.start()
    await clientSwarm.flush()

    // Wait for relay discovery
    let found = false
    for (let i = 0; i < 30; i++) {
      if (client.relays.size > 0) { found = true; break }
      await new Promise(resolve => setTimeout(resolve, 500))
      await clientSwarm.flush()
    }

    if (found) {
      console.log(`  [client] discovered ${client.relays.size} relay(s)`)
    } else {
      console.log('  [client] no relays discovered (DHT may need more time)')
    }

    console.log()
    console.log('  ─── Test: Publish + Seed ───')
    console.log()

    try {
      const drive = await client.publish([
        { path: '/hello.txt', content: 'Hello from HiveRelay testnet!' },
        { path: '/test.json', content: JSON.stringify({ ts: Date.now(), testnet: testId }) }
      ], { timeout: 10000 })

      const driveKey = b4a.toString(drive.key, 'hex')
      console.log(`  [client] published drive: ${driveKey}`)

      // Seed the drive
      try {
        await client.seed(driveKey, { replicas: nodeCount, timeout: 15000 })
      } catch (e) {
        console.log(`  [client] seed broadcast sent (${e.message})`)
      }

      // Wait briefly for seed accepts
      await new Promise(resolve => setTimeout(resolve, 3000))

      // Read back
      const hello = await client.get(driveKey, '/hello.txt')
      if (hello) {
        console.log(`  [client] read back /hello.txt: "${b4a.toString(hello)}"`)
      }

      const files = await client.list(driveKey, '/')
      console.log(`  [client] files in drive: ${files.join(', ')}`)

      // Relay scores
      const scores = client.getRelayScores()
      if (scores.length > 0) {
        console.log()
        console.log('  ─── Relay Scores ───')
        for (const s of scores) {
          console.log(`    ${s.relay.slice(0, 16)}... latency=${s.latencyMs}ms reliability=${s.reliability} successes=${s.successes}`)
        }
      }
    } catch (err) {
      console.log(`  [client] publish test: ${err.message}`)
    }
  }

  console.log()
  console.log('  ─── Network Ready ───')
  console.log()
  for (const { port } of nodes) {
    console.log(`    http://127.0.0.1:${port}/api/overview`)
  }
  console.log()
  console.log('  SDK connect example:')
  console.log()
  console.log("    import Hyperswarm from 'hyperswarm'")
  console.log("    import { HiveRelayClient } from 'p2p-hiverelay-client'")
  console.log()
  console.log(`    const swarm = new Hyperswarm({ bootstrap: [${testnet.bootstrap.map(b => `{ host: '${b.host}', port: ${b.port} }`).join(', ')}] })`)
  console.log("    const client = new HiveRelayClient('./my-storage', { swarm })")
  console.log('    await client.start()')
  console.log()
  console.log('  Press Ctrl+C to shut down the testnet.')
  console.log()

  // Status ticker
  const statusInterval = setInterval(() => {
    const parts = nodes.map(({ node }, i) => {
      const s = node.getStats()
      return `r${i}:${s.connections}p/${s.seededApps}a`
    })
    const clientPart = client ? ` | client:${client.relays.size}r` : ''
    process.stdout.write(`\r  [testnet] ${parts.join(' | ')}${clientPart}   `)
  }, 5000)

  gracedown(async () => {
    clearInterval(statusInterval)
    console.log(shutdownBanner())
    console.log('  ' + ARROW + ' ' + paint(C.dim, 'tearing down testnet...'))

    if (client) {
      try { await client.destroy() } catch {}
    }
    if (clientSwarm) {
      try { await clientSwarm.destroy() } catch {}
    }

    for (const { node } of nodes) {
      try { await node.stop() } catch {}
    }

    try { await testnet.destroy() } catch {}

    try {
      rmSync(baseDir, { recursive: true, force: true })
      console.log(`  Cleaned up: ${baseDir}`)
    } catch {}

    console.log('  Testnet stopped.')
  })
}

// ─── seed ───────────────────────────────────────────────────────────

const VALID_PRIVACY_TIERS = new Set(['public', 'local-first', 'p2p-only'])
const VALID_CONTENT_TYPES = new Set(['app', 'drive', 'dataset', 'media'])
const VALID_STORAGE_CLASSES = new Set(['persistent', 'temporary'])
const VALID_AVAILABILITY_CLASSES = new Set(['always-on', 'best-effort', 'atomic-handoff'])
const GHOSTDRIVE_DEFAULT_CATEGORIES = ['ghost-drive', 'files']

async function seed () {
  const appKey = args._[1]
  if (!isValidHexKey(appKey, 64)) {
    console.error('Usage: p2p-hiverelay seed <app-key> [options]')
    console.error('  app-key must be 64 hex characters')
    process.exit(1)
  }

  const seedMetadata = collectSeedMetadata(args)
  const relay = getRelayBaseUrl(args)
  const apiKey = getApiKey(args)

  try {
    console.log(`Seeding app on relay ${relay}...`)
    const seedResult = await seedAppViaApi({
      relay,
      apiKey,
      appKey,
      metadata: seedMetadata
    })

    console.log(`  ✓ Registered for local seeding: ${appKey.slice(0, 16)}...`)
    if (seedResult.alreadySeeded) console.log('  Note: already seeded on this relay.')
    if (seedResult.discoveryKey) console.log(`  Discovery key: ${seedResult.discoveryKey}`)
    console.log('  ⏳ Replication runs in the background (typically ~5-30s if peers are online).')
    console.log("     Use 'p2p-hiverelay status' to see anchor state, or watch /api/health-detail.")

    if (shouldPublishRegistry(args)) {
      const publishBody = collectRegistryOptions(args, appKey, seedMetadata)
      const publishResult = await publishSeedRequestViaApi({ relay, apiKey, body: publishBody })
      console.log(`  Registry publish: replicas=${publishResult.replicationFactor || publishBody.replicas}`)
      if (publishResult.requestId) console.log(`  Request ID: ${publishResult.requestId}`)
    }
  } catch (err) {
    console.error('Seeding failed: ' + err.message)
    process.exit(1)
  }
}

// ─── ghostdrive ─────────────────────────────────────────────────────

async function ghostdrive () {
  const action = args._[1]

  if (!action || action === 'help') {
    printGhostDriveHelp()
    return
  }

  if (action === 'discover') {
    await ghostDriveDiscover()
    return
  }

  const driveKey = args._[2]
  if (!isValidHexKey(driveKey, 64)) {
    console.error('Usage: p2p-hiverelay ghostdrive <pin|publish> <drive-key> [options]')
    console.error('  drive-key must be 64 hex characters')
    process.exit(1)
  }

  const relay = getRelayBaseUrl(args)
  const apiKey = getApiKey(args)
  const metadata = collectGhostDriveMetadata(args, driveKey)

  try {
    console.log(`Ghost Drive ${action} via relay ${relay}...`)
    const seedResult = await seedAppViaApi({
      relay,
      apiKey,
      appKey: driveKey,
      metadata
    })

    console.log(`  Pinned drive: ${driveKey.slice(0, 16)}...`)
    if (seedResult.alreadySeeded) console.log('  Note: already pinned on this relay.')
    if (seedResult.discoveryKey) console.log(`  Discovery key: ${seedResult.discoveryKey}`)
    console.log(`  Catalog tags: ${(metadata.categories || []).join(', ') || 'none'}`)

    if (action === 'publish') {
      const publishBody = collectRegistryOptions(args, driveKey, metadata)
      const publishResult = await publishSeedRequestViaApi({ relay, apiKey, body: publishBody })
      console.log(`  Published to registry (replicas=${publishResult.replicationFactor || publishBody.replicas})`)
    }
  } catch (err) {
    console.error(`Ghost Drive ${action} failed: ` + err.message)
    process.exit(1)
  }
}

async function ghostDriveDiscover () {
  const relays = parseRelayList(args)
  const discovered = []

  try {
    for (const relay of relays) {
      const catalog = await relayRequestJson(relay, '/catalog.json?type=drive&page=1&pageSize=500')
      const typedEntriesRaw = [
        ...(Array.isArray(catalog.drives) ? catalog.drives : []),
        ...(Array.isArray(catalog.resources) ? catalog.resources : []),
        ...(Array.isArray(catalog.entries) ? catalog.entries.filter(e => e && e.type === 'drive') : [])
      ]
      const typedEntries = []
      const seenKeys = new Set()
      for (const entry of typedEntriesRaw) {
        const key = entry?.appKey || entry?.driveKey
        if (!key || seenKeys.has(key)) continue
        seenKeys.add(key)
        typedEntries.push(entry)
      }
      const fallbackEntries = Array.isArray(catalog.apps) ? catalog.apps : []
      const candidates = typedEntries.length > 0 ? typedEntries : fallbackEntries
      const ghostApps = candidates.filter(isGhostDriveCatalogEntry)
      for (const app of ghostApps) {
        discovered.push({ relay, app })
      }
      console.log(`  ${relay} → ${ghostApps.length} Ghost Drive entries`)
    }
  } catch (err) {
    console.error('Discovery failed: ' + err.message)
    process.exit(1)
  }

  console.log()
  if (discovered.length === 0) {
    console.log('No Ghost Drive entries found in the queried catalogs.')
    return
  }

  console.log(`Discovered ${discovered.length} Ghost Drive entries:`)
  for (const { relay, app } of discovered) {
    const label = app.name || app.id || app.appKey
    const author = app.author || 'unknown'
    const key = app.appKey || app.driveKey || 'n/a'
    console.log(`  - ${label} (${author})`)
    console.log(`    key: ${key}`)
    console.log(`    relay: ${relay}`)
    if (app.description) console.log(`    ${app.description}`)
  }
}

function printGhostDriveHelp () {
  console.log(`
Ghost Drive Workflow

Usage:
  p2p-hiverelay ghostdrive pin <drive-key> [options]
  p2p-hiverelay ghostdrive publish <drive-key> [options]
  p2p-hiverelay ghostdrive discover [options]

Subcommands:
  pin       Seed/pin a Ghost Drive key on a relay and add discovery metadata
  publish   Pin + publish replication request to the distributed registry
  discover  Query relay catalog(s) for Ghost Drive entries

Options:
  --relay <url>                Relay URL (repeat for discover; default: http://127.0.0.1:9100)
  --host <ip> --port <n>       Alternative to --relay
  --api-key <key>              API key (or use HIVERELAY_API_KEY env)
  --type <kind>                app | drive | dataset | media (ghostdrive defaults to drive)
  --parent-key <hex>           Parent content key for mounted drive resources
  --mount-path <path>          Mount path for parent resources (example: /data)
  --name <text>                Catalog name
  --description <text>         Catalog description
  --author <text>              Catalog author
  --categories <a,b,c>         Catalog categories (default: ghost-drive,files)
  --privacy-tier <tier>        public | local-first | p2p-only (default: public)
  --blind                      Mark content as blind/opaque relay custody
  --storage-class <class>      persistent | temporary
  --availability-class <class> always-on | best-effort | atomic-handoff
  --replicas <n>               Publish target replication factor (default: 3)
  --geo <region>               Publish geo preference (e.g. NA,EU,AS)
  --ttl <days>                 Publish TTL days (default: 30)
  --max-storage <size>         Publish max bytes for storage matching
`)
}

async function seedAppViaApi ({ relay, apiKey, appKey, metadata }) {
  const body = { appKey }
  if (metadata.type) body.type = metadata.type
  if (metadata.appId) body.appId = metadata.appId
  if (metadata.version) body.version = metadata.version
  if (metadata.parentKey) body.parentKey = metadata.parentKey
  if (metadata.mountPath) body.mountPath = metadata.mountPath
  if (metadata.name) body.name = metadata.name
  if (metadata.description !== undefined) body.description = metadata.description
  if (metadata.author) body.author = metadata.author
  if (metadata.categories && metadata.categories.length > 0) body.categories = metadata.categories
  if (metadata.privacyTier) body.privacyTier = metadata.privacyTier
  if (metadata.blind === true) body.blind = true
  if (metadata.storageClass) body.storageClass = metadata.storageClass
  if (metadata.availabilityClass) body.availabilityClass = metadata.availabilityClass
  return relayRequestJson(relay, '/seed', {
    method: 'POST',
    body,
    apiKey
  })
}

async function publishSeedRequestViaApi ({ relay, apiKey, body }) {
  return relayRequestJson(relay, '/registry/publish', {
    method: 'POST',
    body,
    apiKey
  })
}

async function relayRequestJson (relay, path, opts = {}) {
  const method = opts.method || 'GET'
  const headers = { Accept: 'application/json' }
  if (opts.apiKey) headers.Authorization = 'Bearer ' + opts.apiKey
  if (opts.body !== undefined) headers['Content-Type'] = 'application/json'

  let res
  try {
    res = await fetch(relay + path, {
      method,
      headers,
      body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
    })
  } catch (err) {
    throw new Error(`cannot reach relay ${relay}: ${err.message}`)
  }

  const payload = await parseResponseBody(res)
  if (!res.ok) {
    const reason = payload && payload.error ? payload.error : `${res.status} ${res.statusText}`
    throw new Error(reason)
  }
  return payload
}

async function parseResponseBody (res) {
  const text = await res.text()
  if (!text) return {}
  try {
    return JSON.parse(text)
  } catch (_) {
    return { raw: text }
  }
}

function collectSeedMetadata (argv) {
  const metadata = {}
  if (typeof argv.type === 'string' && argv.type.trim()) metadata.type = parseContentTypeOrExit(argv.type)
  if (typeof argv['app-id'] === 'string' && argv['app-id'].trim()) metadata.appId = argv['app-id'].trim()
  if (typeof argv.version === 'string' && argv.version.trim()) metadata.version = argv.version.trim()
  if (typeof argv['parent-key'] === 'string' && argv['parent-key'].trim()) {
    const parentKey = argv['parent-key'].trim().toLowerCase()
    if (!isValidHexKey(parentKey, 64)) {
      console.error('Invalid --parent-key: must be 64 hex characters')
      process.exit(1)
    }
    metadata.parentKey = parentKey
  }
  if (typeof argv['mount-path'] === 'string' && argv['mount-path'].trim()) {
    const mountPath = argv['mount-path'].trim()
    if (!mountPath.startsWith('/')) {
      console.error('Invalid --mount-path: must start with "/"')
      process.exit(1)
    }
    metadata.mountPath = mountPath
  }
  if ((metadata.parentKey || metadata.mountPath) && !metadata.type) {
    metadata.type = 'drive'
  }
  if ((metadata.parentKey || metadata.mountPath) && metadata.type !== 'drive') {
    console.error('--parent-key and --mount-path are only valid when --type drive')
    process.exit(1)
  }
  if (typeof argv.name === 'string' && argv.name.trim()) metadata.name = argv.name.trim()
  if (typeof argv.description === 'string') metadata.description = argv.description
  if (typeof argv.author === 'string' && argv.author.trim()) metadata.author = argv.author.trim()
  const categories = parseCategories(argv.categories, null)
  if (categories) metadata.categories = categories
  if (argv['privacy-tier'] !== undefined) {
    metadata.privacyTier = parsePrivacyTierOrExit(argv['privacy-tier'])
  }
  if (argv.blind === true) metadata.blind = true
  if (argv['storage-class'] !== undefined) {
    metadata.storageClass = parseStorageClassOrExit(argv['storage-class'])
  }
  if (argv['availability-class'] !== undefined) {
    metadata.availabilityClass = parseAvailabilityClassOrExit(argv['availability-class'])
  }
  return metadata
}

function collectGhostDriveMetadata (argv, driveKey) {
  const metadata = collectSeedMetadata(argv)
  metadata.type = 'drive'
  if (!metadata.appId) metadata.appId = `ghost-drive-${driveKey.slice(0, 16)}`
  if (!metadata.name) metadata.name = `Ghost Drive ${driveKey.slice(0, 8)}`
  if (metadata.description === undefined) {
    metadata.description = 'Ghost Drive content pinned on HiveRelay for always-on availability and discovery.'
  }
  if (!metadata.author) metadata.author = 'ghost-drive-user'
  metadata.categories = parseCategories(argv.categories, GHOSTDRIVE_DEFAULT_CATEGORIES)
  if (!metadata.privacyTier) metadata.privacyTier = 'public'
  if (metadata.blind === true && argv['privacy-tier'] === undefined) metadata.privacyTier = 'p2p-only'
  if (metadata.blind === true && argv['storage-class'] === undefined) metadata.storageClass = 'temporary'
  if (metadata.blind === true && argv['availability-class'] === undefined) metadata.availabilityClass = 'atomic-handoff'
  return metadata
}

function collectRegistryOptions (argv, appKey, metadata = {}) {
  const body = {
    appKey,
    replicas: argv.replicas ? parseInt(argv.replicas) : 3,
    ttlDays: argv.ttl ? parseInt(argv.ttl) : 30,
    privacyTier: argv['privacy-tier'] !== undefined
      ? parsePrivacyTierOrExit(argv['privacy-tier'])
      : (metadata.privacyTier || 'public')
  }

  if (metadata.type) body.contentType = metadata.type
  if (metadata.parentKey) body.parentKey = metadata.parentKey
  if (metadata.mountPath) body.mountPath = metadata.mountPath
  if (metadata.blind === true) body.blind = true
  if (metadata.storageClass) body.storageClass = metadata.storageClass
  if (metadata.availabilityClass) body.availabilityClass = metadata.availabilityClass

  if (argv.geo !== undefined) {
    const geo = parseCsvValues(argv.geo)
    body.geo = geo.length > 1 ? geo : geo[0]
  }
  if (argv['max-storage'] !== undefined) body.maxStorageBytes = parseBytes(String(argv['max-storage']))
  if (argv['discovery-key'] !== undefined) {
    const discoveryKeys = parseCsvValues(argv['discovery-key']).map(v => v.toLowerCase())
    for (const key of discoveryKeys) {
      if (!isValidHexKey(key, 64)) {
        throw new Error('discovery-key entries must be 64 hex characters')
      }
    }
    body.discoveryKeys = discoveryKeys
  }
  return body
}

function shouldPublishRegistry (argv) {
  return argv.publish === true ||
    argv.registry === true ||
    argv.replicas !== undefined ||
    argv.geo !== undefined ||
    argv.ttl !== undefined ||
    argv['max-storage'] !== undefined ||
    argv['discovery-key'] !== undefined
}

function getApiKey (argv) {
  const key = typeof argv['api-key'] === 'string' ? argv['api-key'].trim() : ''
  return key || process.env.HIVERELAY_API_KEY || null
}

function getRelayBaseUrl (argv) {
  if (typeof argv.relay === 'string' && argv.relay.trim()) {
    return normalizeRelayUrl(argv.relay)
  }
  const host = argv.host || '127.0.0.1'
  const port = argv.port ? parseInt(argv.port) : 9100
  return `http://${host}:${port}`
}

function parseRelayList (argv) {
  if (argv.relay === undefined) return [getRelayBaseUrl(argv)]
  const values = [].concat(argv.relay)
    .map(v => normalizeRelayUrl(v))
    .filter(Boolean)
  return [...new Set(values)]
}

function normalizeRelayUrl (value) {
  const raw = String(value || '').trim()
  if (!raw) return ''
  if (raw.startsWith('http://') || raw.startsWith('https://')) {
    return raw.replace(/\/+$/, '')
  }
  return `http://${raw.replace(/\/+$/, '')}`
}

function isGhostDriveCatalogEntry (app) {
  if (!app || typeof app !== 'object') return false
  if (app.type && String(app.type).toLowerCase() !== 'drive') return false
  const categories = Array.isArray(app.categories)
    ? app.categories.map(c => String(c).toLowerCase())
    : []
  if (categories.includes('ghost-drive')) return true
  const id = String(app.id || '').toLowerCase()
  if (id.startsWith('ghost-drive')) return true
  const name = String(app.name || '').toLowerCase()
  return name.includes('ghost drive')
}

function parseCategories (input, fallback = null) {
  const values = parseCsvValues(input)
  if (values.length === 0) return fallback
  return [...new Set(values)]
}

function parseCsvValues (input) {
  if (input === undefined || input === null) return []
  return []
    .concat(input)
    .flatMap(v => String(v).split(','))
    .map(v => v.trim())
    .filter(Boolean)
}

function parsePrivacyTierOrExit (value) {
  const tier = String(value || '').trim().toLowerCase()
  if (!VALID_PRIVACY_TIERS.has(tier)) {
    console.error('Invalid privacy tier: ' + value)
    console.error('Valid tiers: public, local-first, p2p-only')
    process.exit(1)
  }
  return tier
}

function parseContentTypeOrExit (value) {
  const type = String(value || '').trim().toLowerCase()
  if (!VALID_CONTENT_TYPES.has(type)) {
    console.error('Invalid content type: ' + value)
    console.error('Valid types: app, drive, dataset, media')
    process.exit(1)
  }
  return type
}

function parseStorageClassOrExit (value) {
  const storageClass = String(value || '').trim().toLowerCase()
  if (!VALID_STORAGE_CLASSES.has(storageClass)) {
    console.error('Invalid storage class: ' + value)
    console.error('Valid storage classes: persistent, temporary')
    process.exit(1)
  }
  return storageClass
}

function parseAvailabilityClassOrExit (value) {
  const availabilityClass = String(value || '').trim().toLowerCase()
  if (!VALID_AVAILABILITY_CLASSES.has(availabilityClass)) {
    console.error('Invalid availability class: ' + value)
    console.error('Valid availability classes: always-on, best-effort, atomic-handoff')
    process.exit(1)
  }
  return availabilityClass
}

function parseBootstrapNodesOrExit (input) {
  try {
    const nodes = []
    for (const raw of parseCsvValues(input)) {
      const sep = raw.lastIndexOf(':')
      if (sep <= 0 || sep === raw.length - 1) throw new Error('expected host:port')
      const host = raw.slice(0, sep)
      const port = Number(raw.slice(sep + 1))
      if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
        throw new Error('invalid host or port')
      }
      nodes.push({ host, port })
    }
    if (nodes.length === 0) throw new Error('empty bootstrap list')
    return nodes
  } catch (err) {
    console.error('Invalid bootstrap list: ' + input)
    console.error('Expected comma-separated host:port entries, e.g. 127.0.0.1:49737')
    process.exit(1)
  }
}

// ─── status ─────────────────────────────────────────────────────────

async function status () {
  const port = args.port || 9100
  console.log(statusBanner())
  try {
    const res = await fetch(`http://127.0.0.1:${port}/status`)
    const stats = await res.json()
    const row = (label, value, color = C.white) =>
      '  ' + HEX + ' ' + paint(C.cyan, label.padEnd(13)) + paint(color, String(value))
    console.log(row('Running:', stats.running, stats.running ? C.green : C.red))
    console.log(row('Public Key:', stats.publicKey || 'N/A', C.magenta))
    console.log(row('Seeded Apps:', stats.seededApps))
    console.log(row('Connections:', stats.connections))
    if (stats.seeder) {
      // Honest measured served total (every replicated core) when the relay
      // exposes it; legacy seeder counter otherwise.
      const served = stats.served ? stats.served.totalBytesServed : (stats.seeder.totalBytesServed || 0)
      console.log(row('Stored:', formatBytes(stats.seeder.totalBytesStored || 0)))
      console.log(row('Served:', formatBytes(served)))
    }
    if (stats.relay) {
      console.log(row('Circuits:', `${stats.relay.activeCircuits} active (${stats.relay.totalCircuitsServed} total)`))
      console.log(row('Relayed:', formatBytes(stats.relay.totalBytesRelayed || 0)))
    }
    console.log()
  } catch {
    console.log('  ' + paint(C.red, '✗') + ' ' + paint(C.dim, `Cannot reach relay node at http://127.0.0.1:${port}`))
    console.log('  ' + paint(C.dim, '// is the node running? start it with: ') + paint(C.cyan, 'p2p-hiverelay start'))
    console.log()
  }
}

// ─── doctor ─────────────────────────────────────────────────────────
//
// Diagnostic command. Reads ~/.hiverelay/config.json + the running
// relay's public catalog, compares against what v0.8.x expects, and
// reports drift. With --fix, writes missing keys to config.json.
//
// Catches the class of problem where you've upgraded the npm package
// but your launchd plist / systemd unit / config still reflects an
// older version's defaults.

async function doctor () {
  const port = args.port || 9100
  const apply = args.fix === true || args.apply === true

  console.log()
  console.log('  ' + paint(C.cyan, '◆ HiveRelay doctor'))
  console.log()

  // ─── 1. Load on-disk config ───
  let fileConfig = {}
  const configPath = CONFIG_PATH
  if (existsSync(configPath)) {
    try { fileConfig = JSON.parse(readFileSync(configPath, 'utf8')) } catch {}
  }

  // ─── 2. Try to reach a running relay ───
  let runtime = null
  let runtimeCatalog = null
  let unreachable = false
  try {
    const res = await fetch(`http://127.0.0.1:${port}/health`)
    if (res.ok) runtime = await res.json()
  } catch {
    unreachable = true
  }
  try {
    const res = await fetch(`http://127.0.0.1:${port}/catalog.json`)
    if (res.ok) runtimeCatalog = await res.json()
  } catch {}

  const row = (icon, label, value, hint = '') => {
    const c = icon === '✓' ? C.green : icon === '⚠' ? C.yellow : icon === '✗' ? C.red : C.dim
    const out = '  ' + paint(c, icon) + ' ' + label.padEnd(28) + (value ? paint(C.white, value) : '')
    console.log(hint ? out + paint(C.dim, '   // ' + hint) : out)
  }

  // ─── 3. Report runtime state ───
  console.log(paint(C.cyan, '  Runtime'))
  if (unreachable) {
    row('✗', 'Relay reachable', `http://127.0.0.1:${port}`, 'is the relay running?')
  } else {
    row('✓', 'Relay reachable', `http://127.0.0.1:${port}`)
    if (runtime) row('✓', 'Health', runtime.ok ? 'ok' : 'degraded')
    if (runtimeCatalog) {
      const region = runtimeCatalog.region
      const operator = runtimeCatalog.operator
      const pubkey = runtimeCatalog.relayKey
      row(pubkey ? '✓' : '✗', 'Public key', pubkey ? pubkey.slice(0, 16) + '…' : '(missing)')
      row(region ? '✓' : '⚠', 'Region declared', region || '(null)', region ? '' : 'set with --region or config.regions')
      row(operator ? '✓' : '⚠', 'Operator declared', operator || '(null)', operator ? '' : 'set with --operator or config.operator — sybil resistance falls back to pubkey-as-operator without it')
    }
  }

  // ─── 4. Report config-file state ───
  console.log()
  console.log(paint(C.cyan, '  Config file'))
  row(configPath ? '✓' : '✗', 'Config path', configPath || '(unknown)')
  row(Object.keys(fileConfig).length ? '✓' : '⚠', 'Config exists', Object.keys(fileConfig).length ? `${Object.keys(fileConfig).length} keys` : '(empty)')

  const recommended = []
  if (!fileConfig.regions || (Array.isArray(fileConfig.regions) && !fileConfig.regions.length)) {
    recommended.push({ key: 'regions', value: ['NA'], reason: 'Region drives AutoHeal diversity scoring on peer relays' })
  }
  if (!fileConfig.operator) {
    const hostname = (await import('os')).hostname() || 'relay'
    recommended.push({ key: 'operator', value: 'hive-foundation-' + hostname.replace(/[^a-z0-9-]/gi, '-').toLowerCase(), reason: 'Operator drives per-operator fairshare cap (sybil resistance)' })
  }
  if (!fileConfig.autoHeal || fileConfig.autoHeal.enabled !== true) {
    recommended.push({ key: 'autoHeal.enabled', value: true, reason: 'Diversity-enforced replica recruitment with cryptographic peer verification' })
  }

  // ─── 5. Drift report ───
  console.log()
  console.log(paint(C.cyan, '  Recommendations'))
  if (recommended.length === 0) {
    row('✓', 'No drift detected', '', 'config and runtime align with v0.8.x defaults')
  } else {
    for (const r of recommended) {
      row('⚠', r.key, JSON.stringify(r.value), r.reason)
    }
  }

  // ─── 6. Apply fixes if requested ───
  if (apply && recommended.length > 0) {
    const next = JSON.parse(JSON.stringify(fileConfig))
    for (const r of recommended) {
      if (r.key === 'regions') next.regions = r.value
      else if (r.key === 'operator') next.operator = r.value
      else if (r.key === 'autoHeal.enabled') {
        next.autoHeal = { ...(next.autoHeal || {}), enabled: r.value }
      }
    }
    try {
      const { writeFileSync } = await import('fs')
      mkdirSync(dirname(configPath), { recursive: true })
      writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n')
      console.log()
      console.log('  ' + paint(C.green, '✓') + ' wrote ' + paint(C.cyan, configPath))
      console.log('  ' + paint(C.dim, '// restart the relay for changes to take effect'))
    } catch (err) {
      console.log()
      console.log('  ' + paint(C.red, '✗') + ' could not write ' + configPath + ': ' + err.message)
    }
  } else if (recommended.length > 0) {
    console.log()
    console.log('  ' + paint(C.dim, '// run with --fix to write the recommendations to ') + paint(C.cyan, configPath || '~/.hiverelay/config.json'))
  }

  console.log()
}

// ─── help ───────────────────────────────────────────────────────────

function help () {
  console.log(helpBanner(VERSION))
  console.log(`
Usage:
  p2p-hiverelay setup               Interactive setup wizard (first-time config)
  p2p-hiverelay tui [options]       Live management TUI (alias: manage)
  p2p-hiverelay manage [options]    Live management console (same as tui)
  p2p-hiverelay init [options]      Initialize config + install agent skills
  p2p-hiverelay start [options]     Start a relay node
  p2p-hiverelay testnet [options]   Spin up a local testnet (DHT + relays + client)
  p2p-hiverelay seed <key>          Seed a key on a relay (optional registry publish)
  p2p-hiverelay ghostdrive ...      Ghost Drive pin/publish/discover workflows
  p2p-hiverelay catalog ...         Operator catalog control (mode/allowlist/approve/reject/remove/pending)
  p2p-hiverelay federation ...      Cross-relay federation (list/follow/mirror/unfollow/republish/unrepublish)
  p2p-hiverelay qvac ...            Manage qvac-backed AI models on a running node
  p2p-hiverelay status              Show node status (queries running node)
  p2p-hiverelay doctor [--fix]      Diagnose config + runtime drift; optionally auto-fix
  p2p-hiverelay help                Show this help

Init Options:
  --region <code>               Set default region (NA, EU, AS, SA, AF, OC)
  --max-storage <size>          Set default max storage

Start Options:
  --storage <path>              Storage directory
  --max-storage <size>          Max storage (e.g., 50GB, 100GB)
  --seed-max-storage <size>     Required finite bound for each --seed app
  --max-connections <n>         Max peer connections (default: 256)
  --max-bandwidth <mbps>        Max relay bandwidth in Mbps (default: 100)
  --region <code>               Region code
  --port <n>                    API port (default: 9100)
  --api-host <host>             API bind host (default: config/default.js)
  --gateway-host <host>         Dedicated data-plane bind host
  --gateway-port <n>            Dedicated data-plane port
  --gateway-trust-proxy[=bool]  Trust forwarded client IP from allowed proxies
  --gateway-trusted-proxy-address <ip,...>
                                 Allowed proxy socket IPs (repeatable)
  --gateway-require-forwarded-sni[=bool]
                                 Require the trusted TLS edge to bind SNI to Host
  --gateway-compatibility-host <host,...>
                                 Allowed non-app Host values (repeatable)
  --gateway-max-in-flight <n>   Total data-plane concurrency limit (1-4096)
  --gateway-max-in-flight-per-app <n>
                                 Per-app concurrency limit (1-4096)
  --hive-app-host-suffix <dns>  App-origin DNS suffix (for example hive.example)
  --hive-app-public-key <hex>   Transitional public app allowlist (repeatable)
  --hive-app-public-version <key=n>
                                 Immutable Hyperdrive version pin (repeatable)
  --bootstrap <host:port,...>   Override DHT bootstrap nodes
  --seed <key>                  Seed a Pear app key on startup
  --distributed-drive           Enable distributed-drive peer bridge (Ghost Drive mode)
  --no-distributed-drive        Disable distributed-drive bridge
  --no-relay                    Disable circuit relay
  --no-seeding                  Disable app seeding
  --no-api                      Disable HTTP API
  --tor [password]               Enable Tor hidden service transport
  --tor-socks-port <n>           Tor SOCKS5 port (default: 9050)
  --tor-control-port <n>         Tor control port (default: 9051)
  --holesail                     Enable Holesail API tunnel (NAT traversal)
  --quiet                       Suppress periodic status output

Manage Options:
  --host <ip>                   Relay host (default: 127.0.0.1)
  --port <n>                    Relay API port (default: 9100)
  --api-key <key>               API key (or use HIVERELAY_API_KEY env)

Seed / Ghost Drive Options:
  --relay <url>                 Relay URL (default: http://127.0.0.1:9100)
  --api-key <key>               API key (or use HIVERELAY_API_KEY env)
  --type <kind>                 app | drive | dataset | media
  --parent-key <hex>            Parent content key (for drive resources)
  --mount-path <path>           Mount path (for drive resources, e.g. /data)
  --app-id <id>                 Optional app id for catalog dedup
  --name <text>                 Catalog name
  --description <text>          Catalog description
  --author <text>               Catalog author
  --categories <a,b,c>          Catalog categories
  --privacy-tier <tier>         public | local-first | p2p-only
  --blind                       Mark content as blind/opaque relay custody
  --storage-class <class>       persistent | temporary
  --availability-class <class>  always-on | best-effort | atomic-handoff
  --publish                     Also publish /registry/publish request
  --replicas <n>                Replication target (default: 3)
  --geo <region>                Geo preference (e.g. NA,EU,AS)
  --ttl <days>                  Registry TTL days (default: 30)
  --discovery-key <hex>         Extra discovery keys for registry publish (repeatable)

Testnet Options:
  --nodes <n>                   Number of relay nodes (default: 3)
  --port <n>                    Base API port (default: 19100)
  --no-client                   Skip launching a test client

Catalog Subcommands:
  catalog mode <open|review|allowlist|closed>          Set catalog acceptance mode
  catalog allowlist <pubkey>[,<pubkey>...]             Replace allowlist (64-hex pubkeys)
  catalog approve <appKey>                             Approve a pending request (64-hex)
  catalog reject <appKey>                              Reject a pending request (64-hex)
  catalog remove <appKey>                              Remove an app from the catalog (64-hex)
  catalog pending                                      List current pending queue

Federation Subcommands:
  federation list                                      List subscriptions and republishes
  federation follow <url> [--pubkey <hex>]             Follow another relay's catalog
  federation mirror <url> [--pubkey <hex>]             Mirror another relay's catalog
  federation unfollow <url>                            Drop a federation subscription
  federation republish <appKey> --source <url> [--channel <name>] [--note <text>]
                                                       Republish an upstream app locally
  federation unrepublish <appKey>                      Remove a republish entry

QVAC Subcommands:
  qvac list                                            List qvac-backed AI models
  qvac register <modelId> --model-src <path-or-id>     Register a qvac model
  qvac register <modelId> --loaded-model-id <id>       Register an already-loaded qvac model
  qvac remove <modelId>                                Remove a qvac model

Management / Catalog / Federation / QVAC Options:
  --api-url <url>               Relay API base URL (default: http://127.0.0.1:9100)
  --api-key <key>               API key (or use HIVERELAY_API_KEY env)

Environment:
  HIVERELAY_LOG_LEVEL           Log level: fatal, error, warn, info, debug, trace
  HIVERELAY_ACCEPT_MODE         Catalog mode: open, review, allowlist, or closed
  HIVERELAY_STORAGE             Storage path used when --storage is absent
  HIVERELAY_MAX_STORAGE         Storage cap until maxStorageBytes is persisted

Examples:
  npx p2p-hiverelay setup                              # Interactive setup wizard
  p2p-hiverelay start --region NA --max-storage 100GB   # Start relay
  p2p-hiverelay tui                                     # Live management TUI
  p2p-hiverelay tui --port 9200 --api-key $HIVERELAY_API_KEY
  p2p-hiverelay testnet                                 # Local testnet (3 relays + client)
  p2p-hiverelay seed <key> --publish --replicas 3       # Seed + publish registry request
  p2p-hiverelay ghostdrive pin <driveKey>               # Pin Ghost Drive key on relay
  p2p-hiverelay ghostdrive publish <driveKey>           # Pin + registry publish
  p2p-hiverelay ghostdrive discover --relay http://...  # Query catalog for Ghost Drive entries
  p2p-hiverelay status                                  # Check running node
`)
}

// ─── utils ──────────────────────────────────────────────────────────

function parseBytes (str) {
  const units = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 }
  const match = String(str || '').trim().match(/^(\d+(?:\.\d+)?)\s*(TB|GB|MB|KB|B)?$/i)
  if (!match) return NaN
  const num = parseFloat(match[1])
  const unit = (match[2] || 'B').toUpperCase()
  return Math.floor(num * (units[unit] || 1))
}

function parseBytesOrExit (value, label) {
  const bytes = parseBytes(value)
  if (!Number.isFinite(bytes) || bytes <= 0) {
    console.error(`Invalid ${label}: expected a positive size such as 10GB, 500MB, or 1TB`)
    process.exit(1)
  }
  return bytes
}

function formatBytes (bytes) {
  if (bytes === 0) return '0 B'
  const units = ['B', 'KB', 'MB', 'GB', 'TB']
  const i = Math.floor(Math.log(bytes) / Math.log(1024))
  return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
}

function statusSnapshot (node) {
  const stats = node.getStats()
  const seeder = stats.seeder || {}
  const relay = stats.relay || {}
  // Prefer the honest measured served total (every replicated core) over
  // seeder.totalBytesServed, which reads ~0 on registry-drive relays.
  const served = stats.served ? stats.served.totalBytesServed : (seeder.totalBytesServed || 0)
  return {
    seededApps: stats.seededApps || 0,
    connections: stats.connections || 0,
    storedBytes: seeder.totalBytesStored || 0,
    servedBytes: served || 0,
    activeCircuits: relay.activeCircuits || 0
  }
}

function hasPersistedAcceptMode () {
  if (!existsSync(CONFIG_PATH)) return false
  try {
    const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
    return !!(parsed && typeof parsed === 'object' && (
      Object.prototype.hasOwnProperty.call(parsed, 'acceptMode') ||
      Object.prototype.hasOwnProperty.call(parsed, 'registryAutoAccept')
    ))
  } catch (_) {
    return false
  }
}

function hasPersistedMaxStorage () {
  if (!existsSync(CONFIG_PATH)) return false
  try {
    const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
    return !!(parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
      Object.prototype.hasOwnProperty.call(parsed, 'maxStorageBytes'))
  } catch (_) {
    return false
  }
}

function hasPersistedOutboxlogNamespace () {
  if (!existsSync(CONFIG_PATH)) return false
  try {
    const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
    return !!(parsed && typeof parsed === 'object' && parsed.outboxlog &&
      typeof parsed.outboxlog === 'object' &&
      typeof parsed.outboxlog.namespace === 'string')
  } catch (_) {
    return false
  }
}

main().catch((err) => {
  log.fatal({ err }, 'fatal startup error')
  process.exit(1)
})