// favela-gateway CLI — `favela-gateway <verb> [key=value …]` against the RUNNING gateway's control socket.
//
// No pgh in between: this is the gateway's own operator surface (hyper-social #80). It only ever talks to
// a running gateway; it never opens a store, binds a port or boots anything (see bin/favela-gateway.js).
export const USAGE = `favela-gateway — the favela gateway (bsky membrane + fediverse bridge)
favela-gateway run the gateway (pgh does this; refuses if one is already running)
favela-gateway metrics [--json] health: roles, peers, bsky's view of us, fediverse delivery
favela-gateway metrics refresh=true probe bsky now instead of using the 5-minute cache
favela-gateway alerts [refresh=true] open alerts, each with the command that fixes it
favela-gateway status roles and ports
favela-gateway help the verbs the running gateway serves
favela-gateway <verb> [key=value …] any verb, e.g. probe-front did=did:plc:…, invite, notifications
favela-gateway admin … the fediverse role's operator CLI (ap-bridge admin)`
// key=value → typed params. Numbers, booleans, null and JSON objects/arrays are parsed; anything else is
// a string. A bare word is refused rather than guessed at.
export function parseParams (args) {
const out = {}
for (const a of args) {
const i = a.indexOf('=')
if (i <= 0) throw new Error(`expected key=value, got "${a}"`)
const k = a.slice(0, i)
const v = a.slice(i + 1)
let val = v
if (/^(true|false|null)$/.test(v) || /^-?\d+(\.\d+)?$/.test(v) || /^[[{]/.test(v)) {
try { val = JSON.parse(v) } catch { val = v }
}
out[k] = val
}
return out
}
async function defaultConnect (sock) {
const { Client } = await import('hyper-svc')
return Client.connectUds(sock)
}
const ago = (ts, now) => {
if (!ts) return 'never'
const s = Math.max(0, Math.round((now - ts) / 1000))
if (s < 90) return s + 's ago'
if (s < 90 * 60) return Math.round(s / 60) + 'm ago'
if (s < 36 * 3600) return Math.round(s / 3600) + 'h ago'
return Math.round(s / 86400) + 'd ago'
}
const mb = (n) => (n == null ? '?' : Math.round(n / 1e6) + ' MB')
const dur = (s) => (s >= 3600 ? Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm' : Math.floor(s / 60) + 'm' + (s % 60) + 's')
/** The `metrics` answer as an operator reads it: what is up, what bsky sees, what is wrong. */
/** Open alerts first: the part an operator must act on, with the command that fixes each. */
export function renderAlerts (a, { now = Date.now() } = {}) {
const L = []
if (!a) return 'alerts: the watcher is not running'
L.push(`alerts (last check ${ago(a.lastCheckAt, now)})`)
if (!a.open.length) L.push(' none open')
for (const x of a.open) {
L.push(` ${x.severity === 'critical' ? 'CRITICAL' : 'warning '} ${x.title} (open ${ago(x.since, now).replace(' ago', '')})`)
L.push(` ${x.detail}`)
L.push(` fix: ${x.fix}`)
}
return L.join('\n')
}
export function renderMetrics (m, { now = Date.now() } = {}) {
const L = []
L.push(`favela-gateway ${m.version || '?'} up ${dur(m.process.uptimeS)} rss ${mb(m.process.rss)} heap ${mb(m.process.heapUsed)}/${mb(m.process.heapTotal)}`)
L.push('')
if (m.alerts) { L.push(renderAlerts(m.alerts, { now })); L.push('') }
L.push('roles')
for (const [name, r] of Object.entries(m.roles)) L.push(` ${name.padEnd(10)} ${r.up ? 'up' : 'DOWN ' + (r.error || '')}`)
if (m.membrane) {
const g = m.membrane.guard || {}
L.push('')
L.push(`bsky (host ${m.bsky ? m.bsky.hostname : '?'}, probed ${m.bsky ? ago(m.bsky.at, now) : 'never'})`)
if (m.bsky) {
const r = m.bsky.relay
L.push(` relay ${r.error ? 'UNREACHABLE ' + r.error : (r.status || '?')} relay seq ${r.seq == null ? '?' : r.seq} ours ${m.membrane.relay.emitted}`)
}
L.push(` guard ${g.configured ? 'last pass ' + ago(g.lastAt, now) + (g.last && g.last.relayStatus ? ' (saw ' + g.last.relayStatus + ')' : '') + ', heals ' + (g.heals || 0) + (g.lastHealAt ? ' (last ' + ago(g.lastHealAt, now) + ')' : '') : 'OFF (no crawl relay configured)'}`)
L.push('')
L.push('served DIDs')
const byDid = new Map(((m.bsky && m.bsky.dids) || []).map((d) => [d.did, d]))
for (const d of m.membrane.dids) {
const b = byDid.get(d.did) || {}
const sync = b.inSync === true ? 'in sync' : b.inSync === false ? 'relay ' + b.relayRev : 'relay ?'
const av = b.appview ? (b.appview.resolves ? `appview ok (${b.appview.postsCount == null ? '?' : b.appview.postsCount} posts, newest ${b.appview.newestIndexedAt ? b.appview.newestIndexedAt.slice(0, 10) : '?'})` : 'APPVIEW DOES NOT RESOLVE') : 'appview ?'
L.push(` ${(d.handle || b.handle || d.did).padEnd(24)} head ${d.rev || '?'} ${sync}`)
L.push(` ${''.padEnd(24)} peers ${d.live}/${d.fronts} live, last append ${ago(d.lastAppendAt, now)}${d.forkRejected ? ', ' + d.forkRejected + ' rival-line rejections' : ''}${d.denied ? ', ' + d.denied + ' denied' : ''}`)
L.push(` ${''.padEnd(24)} ${av}`)
}
}
if (m.lens) {
L.push('')
L.push('lens (bsky custom feeds)')
for (const f of m.lens.feeds) {
L.push(` ${f.id.padEnd(24)} ${f.communityDid ? f.posts + ' posts from ' + f.members + ' members, newest ' + (f.newest ? f.newest.slice(0, 10) : '?') : 'WAITING for the room (community DID not replicated yet)'}${f.curation !== 'roster' ? ' curation ' + f.curation + ' NOT IMPLEMENTED — serving roster' : ''}`)
if (f.unfronted.length) L.push(` ${''.padEnd(24)} NOT FRONTED (their posts are missing): ${f.unfronted.join(', ')}`)
for (const p of f.perMember) if (p.error) L.push(` ${''.padEnd(24)} ${p.did}: ${p.error}`)
}
}
if (m.fediverse) {
const f = m.fediverse
L.push('')
L.push('fediverse')
for (const u of f.users) L.push(` ${('@' + u.username).padEnd(24)} ${u.followers} follower${u.followers === 1 ? '' : 's'}, ${u.delivered} delivered${u.primary ? ' (primary)' : u.held ? '' : ', NOT HELD'}`)
L.push(` queue ${f.queue.pending} pending, ${f.queue.dead} dead${f.queue.oldestPendingAt ? ', oldest ' + ago(f.queue.oldestPendingAt, now) : ''}`)
for (const e of f.queue.failing) L.push(` ${e.host}: ${e.attempts} attempts — ${e.lastError || '?'}`)
L.push(` inbound ${f.inbound.count}, last ${ago(f.inbound.lastReceivedAt, now)}`)
L.push(` registered ${f.registrations}`)
}
const problems = (m.bsky && m.bsky.problems) || []
L.push('')
if (problems.length) {
L.push('PROBLEMS')
for (const p of problems) L.push(' - ' + p)
} else {
L.push('no problems reported')
}
return L.join('\n')
}
/** Returns the process exit code. */
export async function runCli (argv, { sock, out = (s) => console.log(s), err = (s) => console.error(s), connect = defaultConnect } = {}) {
const [verb, ...rest] = argv
if (!verb || verb === '--help' || verb === '-h') { out(USAGE); return 0 }
const json = rest.includes('--json')
let params
try { params = parseParams(rest.filter((a) => a !== '--json')) } catch (e) { err(e.message); err(USAGE); return 2 }
let c
try { c = await connect(sock) } catch (e) {
if (e && (e.code === 'ENOENT' || e.code === 'ECONNREFUSED' || /ENOENT|ECONNREFUSED/.test(e.message))) {
err(`favela-gateway is not running (no control socket at ${sock}). Start it with: pgh app start favela-gateway`)
} else {
err('could not reach the gateway: ' + ((e && e.message) || e))
}
return 1
}
try {
if (verb === 'help') {
const r = await c.call('verbs', {})
out(USAGE)
out('\nverbs served by this gateway: ' + r.verbs.join(', '))
return 0
}
const r = await c.call(verb, params)
if (verb === 'metrics' && !json) out(renderMetrics(r))
else if (verb === 'alerts' && !json && r && r.open) out(renderAlerts(r))
else out(JSON.stringify(r, null, 2))
return r && r.ok === false ? 1 : 0
} catch (e) {
err((e && e.message) || String(e))
return 1
} finally {
try { await c.close() } catch {}
}
}
export default runCli