#!/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)
})