OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / cli.js

652 lines · javascript ref:
// 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)
}