// favela-web CLI subcommands — pairing + device management from the `favela-web` launcher, so a user
// runs a clean verb instead of the raw `bare bin/favela-pair.js` incantation:
//
// favela-web pair [--label "my laptop"] mint a BROWSER pairing invite (a delegation), print the link
// favela-web invite <did> mint a DEVICE invite (co-owner bundle) — for a phone/node
// favela-web devices list the DID's delegated browser sub-keys (+ revoked)
// favela-web revoke <sub-key|prefix> [--restore] revoke (or restore) a device — kills its sign-in
// favela-web fronts [<did>] [--json] which co-owner's firehose each DID follows, and why
// favela-web lens [--json] lens-import config + live cache/breaker counters
// favela-web status both of the above, plus the /status URL
// favela-web conformance [--offline] [--json] what we hold vs what bsky indexes (READ-ONLY)
// favela-web heal [--json] list every diagnostic and repair this node has
// favela-web repair-strongrefs [--write] add the missing cid to invalid strongRefs (DRY RUN default)
// favela-web repair-bytes [<did>] [--limit N] [--write]
// fetch + verify bytes we indexed but never stored (DRY RUN)
// favela-web head [<did>] [--json] our rev vs the authoritative repo head
// favela-web diff [<did>] [--json] exactly what we and the authority disagree on
// favela-web backfill [<did>] [--limit N] [--write]
// pull the authoritative repo and fill what is missing (DRY RUN)
// favela-web collections [--json] every collection by class: tracked / ignored / UNKNOWN
//
// All three talk to the LOCAL hpp control surface over the DEDICATED pairing writer identity (277037…,
// which is in the hpp writers allowlist) — NOT the server's poster (2c5499…) — so they never collide on
// the DHT with a running favela-web daemon. Dispatched from bin/favela-web.js BEFORE the server boots.
import process from 'bare-process'
import { join } from 'bare-path'
import { OverlayRepoStore } from 'atproto-store/lib/overlay-repo-store.js'
import { Writer } from './writer.js'
import { createPairInvite } from './pairing.js'
import { roomKey16 } from './blob-serve.js'
import { repairStrongRefs } from './repair-strongrefs.js'
import { checkConformance } from './conformance.js'
// ⛔ NO DEFAULT. This fell back to the author's LIVE wj62 hpp control key, so running this on a box
// with no local socket dialled production over the DHT — the accident lib/writer.js:56-62 already
// records ('a post landed in wj62 from a test run'). Unset now means unreachable, which is correct.
const CONTROL = (process.env.FAVELA_CONTROL_KEY || '').trim() || null
const CLI_SEED = process.env.FAVELA_PAIR_SEED || 'favela-pair-tabertron-2026-07-16' // dedicated, allowlisted (277037…)
const EXTERNAL = (process.env.FAVELA_EXTERNAL_URL || 'http://localhost:4041').replace(/\/$/, '')
export const CLI_COMMANDS = ['pair', 'invite', 'devices', 'revoke', 'fronts', 'lens', 'status', 'conformance', 'heal', 'head', 'diff', 'backfill', 'collections', 'repair-strongrefs', 'repair-bytes']
// `fronts` / `lens` / `status` read the RUNNING daemon over its local HTTP surface rather than building a
// router of their own. That is not a shortcut: the state worth inspecting (which front was chosen, what
// each offers, live cache and breaker counters) exists only inside the serving process. A second router
// would answer a different question — what a fresh client would pick right now — which is precisely NOT
// what you want when diagnosing a node that has been wrong for two days.
const LOCAL = (args) => flagVal(args, '--url') ||
process.env.FAVELA_LOCAL_URL ||
('http://127.0.0.1:' + (process.env.FAVELA_PORT || '4041'))
async function getJson (args, path) {
const base = LOCAL(args).replace(/\/$/, '')
if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
try {
const res = await fetch(base + path, { headers: { accept: 'application/json' } })
if (!res.ok) { console.error('\n ✗ ' + base + path + ' → HTTP ' + res.status + '\n'); process.exit(1) }
return await res.json()
} catch (e) {
console.error('\n ✗ cannot reach favela-web at ' + base + ' — is it running? (pgh app status favela-web)')
console.error(' ' + (e && e.message) + '\n')
process.exit(1)
}
}
async function frontsCmd (args) {
const did = args.find((a) => a.startsWith('did:')) || null
const d = await getJson(args, '/api/fronts' + (did ? '?did=' + encodeURIComponent(did) : ''))
if (args.includes('--json')) { console.log(JSON.stringify(d, null, 2)); return }
console.log('')
for (const e of d.dids || []) {
// The alarm condition, stated once per DID rather than left for the reader to compute from the table.
const alarm = e.frontCount > 1 && (e.onHighestRev === false || (e.behindBy || 0) > 0)
console.log(' ' + e.did)
console.log(' following ' + (e.chosen || 'none') + ' · ' + e.frontCount + ' front(s)' +
' · highest-rev: ' + (e.onHighestRev ? 'yes' : 'no') +
(e.behindBy ? ' · BEHIND BY ' + e.behindBy + ' frames' : ''))
for (const f of e.fronts || []) {
console.log(' ' + (f.chosen ? '→ ' : ' ') + (f.short || '') +
' rev=' + (f.rev || '—') +
' firehose=' + (f.firehoseLength == null ? '—' : f.firehoseLength) +
' live=' + (f.live ? 'y' : 'n') +
' verified=' + (f.verified ? 'y' : 'n'))
}
if (alarm) console.log(' ⚠ following a line that is not the freshest — the shape of a frozen index.')
console.log('')
}
if (!(d.dids || []).length) console.log(' (no DIDs)\n')
}
async function lensCmd (args) {
const d = await getJson(args, '/api/lens')
if (args.includes('--json')) { console.log(JSON.stringify(d, null, 2)); return }
console.log('\n lens import: ' + (d.enabled ? 'ON' : 'OFF'))
for (const l of d.lenses || []) console.log(' ' + l.name + ' → ' + l.base + (l.proxy ? ' (proxy ' + l.proxy + ')' : ''))
for (const e of d.errors || []) console.log(' ⚠ ' + e)
const s = d.stats
if (s) {
console.log(' hits=' + s.hits + ' misses=' + s.misses + ' fetches=' + s.fetches +
' failures=' + s.failures + ' timeouts=' + s.timeouts + ' coalesced=' + s.coalesced +
' short-circuited=' + s.shortCircuited + ' cache=' + s.cacheSize)
if ((s.open || []).length) console.log(' ⚠ breaker OPEN for: ' + s.open.join(', '))
}
console.log('')
}
async function statusCmd (args) {
await frontsCmd(args)
await lensCmd(args)
console.log(' web: ' + LOCAL(args).replace(/\/$/, '') + '/status\n')
}
const flagVal = (args, name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : null }
const hasFlag = (args, name) => args.includes(name)
const ymd = (ms) => { try { return new Date(ms).toISOString().slice(0, 10) } catch { return '?' } }
async function connect () {
const w = new Writer({ controlKey: CONTROL, writerSeed: CLI_SEED })
if (!(await w.ready({ timeout: 30000 }))) {
console.error('\n ✗ no hpp connection — is the local hpp running? (pgh app status hyper-proto-peer)\n')
try { await w.close() } catch {}
process.exit(1)
}
return w
}
async function pairCmd (args) {
const label = flagVal(args, '--label')
const w = await connect()
const source = await createPairInvite({ onSubkey: (sk) => w.delegate(sk, { label }) })
console.log('\n Pair a browser to this DID — open this on the device you want to sign in:\n')
console.log(' ' + EXTERNAL + '/login#pair=' + source.inviteHex + '\n')
console.log(' …or paste this invite into the login box:\n')
console.log(' ' + source.inviteHex + '\n')
console.log(' Waiting for your browser to pair (Ctrl-C to cancel)…\n')
try {
const r = await source.waitForPeer()
console.log(' ✓ paired — signed in as ' + r.delegation.did + (label ? ' ("' + label + '")' : '') + '\n')
} catch (e) { console.error(' ✗ ' + e.message + '\n') }
try { await source.close() } catch {}
try { await w.close() } catch {}
}
// ── invite — mint a DEVICE invite, not a browser one ────────────────────────────────────────────────
//
// ⛔ THIS IS NOT `pair`. `pair` delegates a BROWSER sub-key: the browser holds no key and every signature
// is still made by the hpp. A phone running favela-android is a NODE — it needs the co-owner bundle
// (shared signing key + chained attestation), because the room's secrets are DERIVED from that key and
// it cannot even open the DID's StateBase without it.
//
// The two are easy to confuse and the confusion is SILENT: paste a `pair` invite into the phone and
// delegate() rejects its 64-hex keet device key (it wants a 66-hex secp256k1 sub-key), the source never
// confirms, and the phone waits out its five-minute timeout with no error. Hence a separate verb.
//
// Runs against the LOCAL config, like the record-store commands above — the bundle carries a secret, so
// it is minted by the machine that already holds it.
async function inviteCmd (args) {
const did = args.find((a) => a.startsWith('did:'))
if (!did) { console.error('\n usage: favela-web invite <did> (the DID this machine already hosts)\n'); process.exit(1) }
const { mintAdoptInvite } = await import('hyper-proto-peer/lib/adopt.js')
const configFile = flagVal(args, '--config') || null
let src
try {
// Approval IS the invocation: coownerInvite mutates the roster and the bundle carries the signing
// key, so mintAdoptInvite refuses to fire without approve(). Running this, on the machine that holds
// the DID, is that approval — same posture as `pair` auto-delegating.
src = await mintAdoptInvite({ did, approve: () => true, configFile })
} catch (e) { console.error('\n ✗ ' + e.message + '\n'); process.exit(1) }
console.log('\n Device invite for ' + did + ' — paste this into the joining device:\n')
console.log(' ' + src.inviteHex + '\n')
console.log(' ⚠ It carries the SHARED SIGNING KEY. Single-use, and whoever reads it first can post as you.')
console.log(' Keep this command running — it holds the tunnel open.\n')
try {
const r = await src.waitForPeer()
if (r) console.log(' ✓ joined — device ' + String(r.devicePublicKey).slice(0, 16) + '… roster now ' + (r.members ? r.members.length : '?') + ' device(s)\n')
else console.log(' ✗ refused — nothing was written\n')
} catch (e) { console.error(' ✗ ' + e.message + '\n') }
try { await src.close() } catch {}
console.log(' Restart this hpp and the joiner so both pick up the new roster.\n')
}
async function devicesCmd () {
const w = await connect()
const devices = await w.listDevices()
if (!devices.length) console.log('\n no browsers paired yet — run `favela-web pair`\n')
else {
console.log('\n Delegated browser devices for this DID:\n')
for (const d of devices) {
console.log(' ' + d.subKey.slice(0, 20) + '… ' + (d.revoked ? 'REVOKED' : 'active ') + ' ' + ymd(d.addedAt) + ' ' + (d.label || ''))
}
console.log('\n revoke one with: favela-web revoke <sub-key-prefix>\n')
}
try { await w.close() } catch {}
}
async function revokeCmd (args) {
const restore = hasFlag(args, '--restore')
const key = args.find((a) => !a.startsWith('--'))
if (!key) { console.error('\n usage: favela-web revoke <sub-key-or-prefix> [--restore]\n'); process.exit(1) }
const w = await connect()
const devices = await w.listDevices()
const match = devices.find((d) => d.subKey === key.toLowerCase() || d.subKey.startsWith(key.toLowerCase()))
if (!match) console.error(' (no match in the registry for "' + key + '" — sending the raw key)')
const target = match ? match.subKey : key.toLowerCase()
await w.revokeDevice(target, { restore })
console.log('\n ✓ ' + (restore ? 'restored' : 'revoked') + ' ' + target.slice(0, 20) + '…\n')
try { await w.close() } catch {}
}
// ── repair + conformance ─────────────────────────────────────────────────────────────────────────
//
// WHY THESE ARE HERE AT ALL. `lib/repair-strongrefs.js` (0.3.3) and `lib/conformance.js` (0.3.4) both
// shipped as libraries with tests and NO WAY TO RUN THEM — the only caller was a test file. The repair
// module's own header says it: "A defect whose repair is un-runnable is not fixed." It then shipped
// un-runnable. #23 lesson 7 is the same sentence from the other direction. This closes both.
//
// The record SOURCE is the shared content-addressed byte store — the same `OverlayRepoStore` +
// `roomKey16(did)` construction bin/favela-web.js:217-220 uses, so the CLI reads the exact bytes hpp
// wrote rather than a re-encode. Re-encoding is what made a byte-exactness guard verify a fetch against
// itself and miss the divergence on disk (#23 root cause 3).
// ⚠ THE DAEMON'S ENV IS NOT THE CLI'S ENV. pgh sets ATPROTO_OVERLAY_ROOT from the install record's
// defaults.env when the SUPERVISOR spawns the daemon; a `favela-web <cmd>` typed at a shell inherits none
// of it. The first cut of this fell back to a favela-local path that does not exist, opened an empty
// store, and reported "checked 0 · 0 findings" — a health report produced by reading the wrong directory.
// A conformance tool that answers "all clean" when it read nothing is worse than absent, so the default
// now points at hpp's real record dir and an empty read is a LOUD failure (assertRead below).
const HPP_RECORDS = process.env.HOME + '/.config/hyper-proto-peer/data/atproto-repo'
const OVERLAY_ROOT = () => process.env.ATPROTO_OVERLAY_ROOT ||
(process.env.FAVELA_STORE_ROOT ? join(process.env.FAVELA_STORE_ROOT, 'atproto-repo') : HPP_RECORDS)
// A zero read is nearly always the wrong root, not an empty repo. Say which path was read and stop, rather
// than returning a clean bill of health for a directory that may not even exist.
function assertRead (n, did) {
if (n > 0) return
console.error('\n ✗ read 0 records for ' + did + '\n root: ' + OVERLAY_ROOT() +
'\n That is almost certainly the wrong directory rather than an empty repo.' +
'\n Set ATPROTO_OVERLAY_ROOT to hpp\'s record dir and re-run:' +
'\n ATPROTO_OVERLAY_ROOT=' + HPP_RECORDS + ' favela-web <command>\n')
process.exit(1)
}
function recordStoreOpener () {
const root = OVERLAY_ROOT()
const cache = new Map()
return async (did) => {
if (!cache.has(did)) cache.set(did, await new OverlayRepoStore(join(root, roomKey16(did)), { did }).ready())
return cache.get(did)
}
}
// `list()` is an async generator; both consumers want an array per collection.
async function collectRecords (store, collection) {
const out = []
try { for await (const r of store.list(collection)) out.push(r) } catch { /* absent collection dir */ }
return out
}
// The DID to operate on: --did wins, else whatever the local hpp is primary for.
async function resolveDid (args, w) {
const explicit = flagVal(args, '--did')
if (explicit) return explicit
try { const st = await w.state(); if (st && st.did) return st.did } catch { /* fall through */ }
console.error('\n ✗ could not determine the DID — pass --did did:plc:…\n')
process.exit(1)
}
async function conformanceCmd (args) {
const offline = hasFlag(args, '--offline')
const w = await connect()
const did = await resolveDid(args, w)
const getStore = recordStoreOpener()
let store
try { store = await getStore(did) } catch (e) {
console.error('\n ✗ cannot open the record store at ' + OVERLAY_ROOT() + '\n ' + e.message + '\n')
try { await w.close() } catch {}
process.exit(1)
}
if (!offline && typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
const r = await checkConformance({
did,
listRecords: (c) => collectRecords(store, c),
// No doFetch => the offline STRUCTURAL layer only. That layer is deterministic and needs no network,
// which is what makes it CI-able (#23 short-term item).
doFetch: offline ? null : globalThis.fetch
})
if (hasFlag(args, '--json')) { console.log(JSON.stringify(r, null, 2)); try { await w.close() } catch {}; return }
const s = r.summary || {}
assertRead(s.checked || 0, did)
console.log('\n conformance · ' + did + (offline ? ' (structural only)' : ''))
console.log(' checked ' + (s.checked != null ? s.checked : '?') + ' · structural findings ' +
r.structural.length + ' · network findings ' + r.network.length + '\n')
for (const f of r.structural) console.log(' ✗ STRUCTURAL ' + f.collection + '/' + f.rkey + ' ' + f.code + (f.field ? ' [' + f.field + ']' : ''))
for (const f of r.network) console.log(' ' + (f.state === 'missing' ? '✗ MISSING ' : '· pending ') + ' ' + f.collection + '/' + f.rkey)
// An absence is a FINDING, not a verdict — the module is emphatic about this and the CLI must not
// upgrade it to one. bsky's AppView is eventually consistent and its relay is non-archival.
if (r.network.length) console.log('\n note: `pending` is inside the grace window; `missing` is past it and worth investigating — neither is proof of corruption.')
if (!r.structural.length && !r.network.length) console.log(' ✓ nothing to report')
console.log('')
try { await w.close() } catch {}
}
async function repairStrongRefsCmd (args) {
const write = hasFlag(args, '--write')
const w = await connect()
const did = await resolveDid(args, w)
const getStore = recordStoreOpener()
let store
try { store = await getStore(did) } catch (e) {
console.error('\n ✗ cannot open the record store at ' + OVERLAY_ROOT() + '\n ' + e.message + '\n')
try { await w.close() } catch {}
process.exit(1)
}
const r = await repairStrongRefs({
listRecords: (c) => collectRecords(store, c),
getRecordStore: getStore,
// ⛔ WRITES GO THROUGH hpp, NEVER store.put(). hpp is the AUTHORITY for these bytes: it hashes what
// it writes and commits that hash to the autobase. A direct store.put() here would make this CLI a
// SECOND WRITER into hpp's canonical record store — which is verbatim the 2026-07-31 clobber that
// cost two records permanently (#23 root cause 2). putRecord goes over the hpp control surface, so
// hpp does the encode, the hash and the pointer.
applyWrite: (op) => w.putRecord(op.collection, op.rkey, op.record, { action: op.action || 'update' }),
write
})
assertRead(r.checked || 0, did)
console.log('\n repair-strongrefs · ' + did + (write ? '' : ' (DRY RUN — pass --write to apply)'))
console.log(' checked ' + r.checked + ' · broken ' + r.broken + ' · ' +
(write ? 'repaired ' + r.repaired : 'would repair ' + r.entries.filter((e) => e.state === 'would-repair').length) +
' · unresolvable ' + r.unresolvable + '\n')
for (const e of r.entries) {
const mark = e.state === 'unresolvable' ? '✗ UNRESOLVABLE' : (e.state === 'repaired' ? '✓ repaired ' : '· would repair')
console.log(' ' + mark + ' ' + e.collection + '/' + e.rkey)
for (const u of e.uris || []) console.log(' → ' + u)
}
if (!r.entries.length) console.log(' ✓ every strongRef already carries a cid')
// Unresolvable is not a failure of the repair — it is the guard refusing to invent a cid. Say so, or
// the next operator reads it as a bug and goes looking for one.
if (r.unresolvable) console.log('\n note: unresolvable = the subject bytes are not held here, so no cid can be proven.\n The record is left INVALID rather than rewritten with a guessed cid.')
console.log('')
try { await w.close() } catch {}
}
// ---- healing ---------------------------------------------------------------------------------------
//
// Every repair in this app is registered HERE, so `favela-web heal` can list them and no healing verb
// can exist that an operator has to read the source to discover. That was the actual failure mode
// behind the 2026-08-06 audit: the capability to notice a byte gap existed, the capability to close it
// did not, and nothing in the CLI said so.
//
// The shared contract for every entry: READ-ONLY by default, `--write` to apply, and a report that
// distinguishes "nothing to do" from "could not". Diagnostics are listed too, because in practice you
// run them first and the split between "look" and "fix" is not where an operator's question sits.
export const HEALERS = [
{ name: 'backfill', kind: 'repair', writes: true, blurb: 'pull the authoritative repo (getRepo) and index/store whatever we are missing' },
{ name: 'repair-bytes', kind: 'repair', writes: true, blurb: 'fetch + verify record bytes this node indexed but never stored' },
{ name: 'repair-strongrefs', kind: 'repair', writes: true, blurb: 'add the missing cid to invalid strongRefs (writes go THROUGH hpp)' },
{ name: 'head', kind: 'check', writes: false, blurb: 'our rev vs the authoritative repo head — the cheap staleness check' },
{ name: 'diff', kind: 'check', writes: false, blurb: 'exactly what we and the authoritative repo disagree on' },
{ name: 'collections', kind: 'check', writes: false, blurb: 'every collection by class — tracked / ignored-by-policy / UNKNOWN (what we drop)' },
{ name: 'conformance', kind: 'check', writes: false, blurb: 'what we hold vs what bsky indexes (the OUTBOUND question)' },
{ name: 'fronts', kind: 'check', writes: false, blurb: "which co-owner's firehose each DID follows, and why" },
{ name: 'lens', kind: 'check', writes: false, blurb: 'lens-import config + live cache/breaker counters' }
]
// repair-bytes runs INSIDE the daemon (it holds the index store's lock) over the UDS control surface —
// never HTTP, because this daemon binds 0.0.0.0 and this writes.
async function repairBytesCmd (args) {
const write = hasFlag(args, '--write')
const did = args.find((a) => a.startsWith('did:')) || null
const limit = Number(flagVal(args, '--limit')) || 0
// ⛔ The default must be the daemon's REAL socket, not a relative `data/svc.sock`. The daemon gets
// FAVELA_STORE_ROOT from its install record when the SUPERVISOR spawns it; a CLI call inherits none of
// that, so a relative default resolves against the operator's cwd and never exists — the same class of
// silent-wrong-path bug as the record root above, and it cost this command its first invocation.
const sock = flagVal(args, '--sock') || process.env.FAVELA_SOCK ||
(process.env.FAVELA_STORE_ROOT
? process.env.FAVELA_STORE_ROOT + '/svc.sock'
: join(process.env.HOME || '', '.config/favela-web/data/svc.sock'))
const { Client } = await import('hyper-svc')
let client
try {
client = await Client.connectUds(sock)
} catch (e) {
console.error('\n ✗ cannot reach the favela-web control surface at ' + sock)
console.error(' ' + ((e && e.message) || e) + '\n is it running? (pgh app status favela-web)\n')
process.exit(1)
}
let r
try {
// `call`, not `request` — hyper-svc's Client exposes call(op, params); see hpp's lib/diag.js, which
// drives the same kind of control socket.
r = await client.call('repair-bytes', { did, write, limit })
} catch (e) {
console.error('\n ✗ repair-bytes failed: ' + ((e && e.message) || e) + '\n')
process.exit(1)
} finally {
try { await client.close() } catch {}
}
if (args.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return }
console.log('\n repair-bytes' + (write ? '' : ' (DRY RUN — pass --write to apply)'))
console.log(' scanned ' + r.scanned + ' · have bytes ' + r.present + ' · MISSING ' + r.missing +
(write ? ' · written ' + r.written : ''))
if (write && (r.unfetchable || r.unverified || r.reencodeRefused || r.storeErrors)) {
console.log(' unfetchable ' + r.unfetchable + ' · unverified ' + r.unverified +
' · re-encode refused ' + r.reencodeRefused + ' · store errors ' + r.storeErrors)
}
for (const p of r.samples || []) console.log(' · ' + p.collection + '/' + p.rkey)
if (r.samples && r.missing > r.samples.length) console.log(' … and ' + (r.missing - r.samples.length) + ' more')
// Say plainly which DIDs were NOT touched and why — an empty result on a node that hosts everything
// reads like a broken command otherwise.
if (r.skippedHostedLocally && r.skippedHostedLocally.length) {
console.log('\n not repaired (this node HOSTS them — hpp is the authority for their bytes):')
for (const d of r.skippedHostedLocally) console.log(' · ' + d)
}
if (!r.missing) console.log('\n ✓ every indexed record has its bytes')
else if (!write) console.log('\n run with --write to fetch and verify them from the DID\'s peers')
if (write && r.unfetchable) {
console.log('\n note: unfetchable = no peer could produce those bytes (deleted upstream, or lost).')
console.log(' They are reported rather than invented — nothing is written that cannot be proven.')
}
console.log('')
}
// Shared UDS call for the convergence verbs — same socket, same failure message, one place to fix.
async function daemonCall (args, op, params) {
const sock = flagVal(args, '--sock') || process.env.FAVELA_SOCK ||
(process.env.FAVELA_STORE_ROOT
? process.env.FAVELA_STORE_ROOT + '/svc.sock'
: join(process.env.HOME || '', '.config/favela-web/data/svc.sock'))
const { Client } = await import('hyper-svc')
let client
try {
client = await Client.connectUds(sock)
} catch (e) {
console.error('\n ✗ cannot reach the favela-web control surface at ' + sock)
console.error(' ' + ((e && e.message) || e) + '\n is it running? (pgh app status favela-web)\n')
process.exit(1)
}
try {
return await client.call(op, params)
} catch (e) {
console.error('\n ✗ ' + op + ' failed: ' + ((e && e.message) || e) + '\n')
process.exit(1)
} finally {
try { await client.close() } catch {}
}
}
const skippedNote = (r) => {
if (!r.skippedHostedLocally || !r.skippedHostedLocally.length) return
console.log('\n not checked (this node HOSTS them — hpp is the authority for their repo):')
for (const d of r.skippedHostedLocally) console.log(' · ' + d)
}
// head — the cheap staleness question. One call per DID, no CAR fetch.
async function headCmd (args) {
const did = args.find((a) => a.startsWith('did:')) || null
const r = await daemonCall(args, 'repo-head', { did })
if (args.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return }
console.log('')
for (const h of r.heads || []) {
if (h.unreachable) { console.log(' ✗ ' + h.did + ' — ' + h.unreachable); continue }
const mark = h.behind === true ? '⚠ BEHIND' : (h.behind === false ? '✓ current' : '? unknown')
console.log(' ' + mark + ' ' + h.did)
console.log(' authority rev ' + (h.remoteRev || '—') + ' ours ' + (h.localRev || 'NEVER FOLDED'))
if (h.active === false) console.log(' ⚠ repo is not active (status: ' + (h.status || '?') + ')')
}
if (!(r.heads || []).length) console.log(' (no foreign DIDs to check)')
skippedNote(r)
console.log('\n behind? run `favela-web diff` to see exactly what differs.\n')
}
// diff — the question nothing answered before: what exactly do we and the authority disagree on.
async function diffCmd (args) {
const did = args.find((a) => a.startsWith('did:')) || null
const r = await daemonCall(args, 'repo-diff', { did })
if (args.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return }
console.log('')
for (const d of r.dids || []) {
if (d.unreachable) { console.log(' ✗ ' + d.did + ' — ' + d.unreachable); continue }
if (d.refused) { console.log(' ⛔ ' + d.did + ' — repo REFUSED: ' + d.refused + ' (nothing was believed)'); continue }
console.log(' ' + (d.inSync ? '✓ in sync' : '⚠ DRIFTED') + ' ' + d.did + ' rev ' + (d.remoteRev || '—'))
console.log(' authority holds ' + d.remoteRecords + ' record(s)')
for (const [c, n] of Object.entries(d.perCollection || {})) {
const mark = n.authority === n.indexed ? ' ' : '⚠'
console.log(' ' + mark + ' ' + c.padEnd(28) + ' authority ' + n.authority + ' · indexed ' + n.indexed)
}
if (d.ignored) {
const by = Object.entries(d.ignoredByCollection || {}).map(([c, n]) => c + ' ' + n).join(', ')
console.log(' ignored by policy: ' + d.ignored + ' [' + by + '] (see spec/convergence.md §2.1)')
}
// UNKNOWN is the one that should make someone look: a lexicon nobody has classified.
if (d.unknown) {
const by = Object.entries(d.unknownByCollection || {}).map(([c, n]) => c + ' ' + n).join(', ')
console.log(' ⚠ UNKNOWN collection(s): ' + d.unknown + ' [' + by + ']')
console.log(' Not tracked and not on the ignore list — classify it in lib/hosted-dids.js.')
console.log(' `favela-web collections` shows what has already ARRIVED unclassified, across every DID.')
}
const show = (label, list) => {
if (!list || !list.length) return
console.log(' ' + label + ': ' + list.length)
for (const x of list.slice(0, 10)) console.log(' · ' + x.collection + '/' + x.rkey)
if (list.length > 10) console.log(' … and ' + (list.length - 10) + ' more')
}
if (d.blobs) {
const b = d.blobs
const mark = b.missing.length ? '⚠' : ' '
console.log(' ' + mark + ' blobs (referenced)'.padEnd(30) + ' authority ' + b.referenced + ' · here ' + b.present)
for (const m of b.missing.slice(0, 10)) {
const r0 = (m.refs && m.refs[0]) || {}
console.log(' · MISSING ' + m.cid + (r0.collection ? ' ← ' + r0.collection + '/' + r0.rkey + (r0.where ? ' (' + r0.where + ')' : '') : ''))
}
if (b.missing.length > 10) console.log(' … and ' + (b.missing.length - 10) + ' more')
// Say what was NOT checked. An unqualified blob count would read as "blobs are fine".
if (b.orphansChecked) {
console.log(' inventory ' + b.inventory + ' · orphans ' + b.orphans.length +
(b.orphans.length ? ' (held by the authority, referenced by no record)' : ''))
for (const o of b.orphans.slice(0, 5)) console.log(' · ORPHAN ' + o.cid)
if (b.orphans.length > 5) console.log(' … and ' + (b.orphans.length - 5) + ' more')
} else {
console.log(' note: orphan blobs NOT checked — the authority did not answer listBlobs (spec §6)')
}
}
show('MISSING from our index', d.missingFromIndex)
show('indexed but NO BYTES', d.indexedWithoutBytes)
show('we hold, repo does not', d.extraLocal)
}
skippedNote(r)
const drifted = (r.dids || []).some((d) => d.inSync === false)
if (drifted) console.log('\n run `favela-web backfill --write` to pull what is missing.')
console.log('')
}
// backfill — pull it. getRepo, verified, then index what we never indexed and fill byte gaps.
async function backfillCmd (args) {
const write = hasFlag(args, '--write')
const did = args.find((a) => a.startsWith('did:')) || null
const limit = Number(flagVal(args, '--limit')) || 0
// `--since` is deliberately NOT offered. It was, and it worked only because the membrane accepted and
// ignored the parameter; now that the membrane honours it (0.9.45) a diff CAR is unusable here — see
// lib/repo-sync.js. Backfill always pulls the complete repo.
const r = await daemonCall(args, 'repo-backfill', { did, write, limit })
if (args.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return }
console.log('\n backfill' + (write ? '' : ' (DRY RUN — pass --write to apply)'))
for (const d of r.dids || []) {
if (d.unreachable) { console.log(' ✗ ' + d.did + ' — ' + d.unreachable); continue }
if (d.refused) { console.log(' ⛔ ' + d.did + ' — repo REFUSED: ' + d.refused); continue }
const a = d.applied || {}
console.log(' ' + d.did)
// localIndexed was removed in 0.3.43 when diff went per-collection; sum the per-collection view
// rather than printing `undefined` (which is what shipped, and reads as a bug in the data).
const indexedHere = Object.values(d.perCollection || {}).reduce((a, n) => a + (n.indexed || 0), 0)
console.log(' authority ' + d.remoteRecords + ' · indexed here ' + indexedHere +
' · missing ' + (d.missingFromIndex || []).length + ' · byteless ' + (d.indexedWithoutBytes || []).length)
if (write) console.log(' → indexed ' + (a.indexed || 0) + ' · bytes written ' + (a.bytesWritten || 0) +
(a.reencodeRefused ? ' · re-encode refused ' + a.reencodeRefused : '') +
(a.errors ? ' · errors ' + a.errors : ''))
if (d.blobs && d.blobs.missing.length) {
const ba = d.blobsApplied
console.log(' blobs missing ' + d.blobs.missing.length +
(write && ba ? ' → fetched ' + ba.fetched +
(ba.unfetchable ? ' · unfetchable ' + ba.unfetchable : '') +
(ba.hashMismatch ? ' · ⛔ hash mismatch ' + ba.hashMismatch : '') +
(ba.errors ? ' · errors ' + ba.errors : '') : ''))
if (write && ba && ba.hashMismatch) console.log(' ⛔ bytes that did not hash to their CID were REFUSED, not written.')
}
if (a.skippedExtraLocal) {
console.log(' note: ' + a.skippedExtraLocal + ' record(s) we hold are NOT in the repo — reported, never deleted.')
console.log(' This cannot tell a stale local copy from one the authority has not published.')
}
}
skippedNote(r)
if (!write) console.log('\n nothing was written. Re-run with --write to apply.')
console.log('')
}
// collections — the classification ledger (org #33, spec §2). What arrives here, and what we decided
// about it. The three classes are printed even when a class is empty: the shape of the answer is the
// answer, and a bare list of unknowns would not say what the alternative was.
async function collectionsCmd (args) {
const r = await daemonCall(args, 'collections', {})
if (args.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return }
console.log('\n collections · what this node ingests, and what it does not\n')
console.log(' TRACKED — a view indexes it, so convergence can compare it record-for-record')
for (const t of r.tracked || []) {
console.log(' ' + t.collection.padEnd(30) + 'view ' + String(t.view).padEnd(10) + (t.bytes ? 'bytes stored here' : 'index only (no bytes)'))
}
console.log('\n IGNORED BY POLICY — a decision on file (lib/hosted-dids.js). Not a gap.')
for (const g of r.ignored || []) {
console.log(' ' + g.collection + ' seen ' + g.seen + (g.lastSeen ? ' · last ' + g.lastSeen : ' · never seen here'))
console.log(' ' + g.reason)
}
if (!(r.ignored || []).length) console.log(' (none)')
console.log('')
if ((r.unknown || []).length) {
// The one class that should make someone look. Named loudly, with what to do about it — an operator
// who learns a lexicon arrived and not what to do next has been told half of something.
console.log(' ⚠ UNKNOWN — arrived here, no view indexes it, and nobody has classified it')
for (const u of r.unknown) {
console.log(' ' + u.collection + ' ' + u.count + ' op(s) · first ' + u.firstSeen + ' · last ' + u.lastSeen)
for (const d of u.dids || []) console.log(' from ' + d)
if (u.sample) console.log(' e.g. ' + u.collection + '/' + u.sample.rkey)
}
console.log('\n Classify each one: add a view for it, or add it to IGNORED_COLLECTIONS in')
console.log(' lib/hosted-dids.js WITH A REASON (spec/convergence.md §2.1). "We don\'t use it" is not')
console.log(' a reason; "no surface consumes it, and here is what does" is.\n')
} else {
console.log(' ✓ no UNKNOWN collections — everything that has arrived is either tracked or on the ignore list\n')
}
// Say what this did NOT check, per §3. The ledger is what ARRIVED; it cannot speak for a lexicon the
// authority holds that we have simply never been online for.
console.log(' note: this is what has ARRIVED here. A collection the authority holds that we have never')
console.log(' received will not appear — `favela-web diff` asks that question instead.\n')
}
function healCmd (args) {
if (args.includes('--json')) { console.log(JSON.stringify(HEALERS, null, 2)); return }
console.log('\n favela-web healing commands\n')
for (const kind of ['check', 'repair']) {
console.log(' ' + (kind === 'check' ? 'DIAGNOSE (read-only)' : 'REPAIR (dry run unless --write)'))
for (const h of HEALERS.filter((x) => x.kind === kind)) {
console.log(' favela-web ' + h.name.padEnd(20) + h.blurb)
}
console.log('')
}
console.log(' Every repair is READ-ONLY until you pass --write, and none of them overwrites bytes')
console.log(' that are already present — hpp is the authority for the DIDs this node hosts.\n')
}
export async function runCli (argv) {
const [cmd, ...rest] = argv
if (cmd === 'pair') return pairCmd(rest)
if (cmd === 'invite') return inviteCmd(rest)
if (cmd === 'devices') return devicesCmd()
if (cmd === 'revoke') return revokeCmd(rest)
if (cmd === 'fronts') return frontsCmd(rest)
if (cmd === 'lens') return lensCmd(rest)
if (cmd === 'status') return statusCmd(rest)
if (cmd === 'conformance') return conformanceCmd(rest)
if (cmd === 'repair-strongrefs') return repairStrongRefsCmd(rest)
if (cmd === 'repair-bytes') return repairBytesCmd(rest)
if (cmd === 'head') return headCmd(rest)
if (cmd === 'diff') return diffCmd(rest)
if (cmd === 'backfill') return backfillCmd(rest)
if (cmd === 'collections') return collectionsCmd(rest)
if (cmd === 'heal') return healCmd(rest)
console.error('\n favela-web ' + cmd + ': unknown command\n commands: ' + CLI_COMMANDS.join(' | ') + '\n')
process.exit(1)
}