OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community / lib / cli.js

267 lines · javascript ref:
// favela-community CLI — the mod-side tool that DRIVES the write layer. An owner creates + manages a
// community; a mod joins and (once admitted) co-writes; anyone reads keylessly. Runs under Bare.
//
// State persists in a corestore (--storage, default ~/.config/favela-community/store) + a small JSON index
// (rooms.json in the parent dir) mapping roomKey → the corestore NAMESPACE used for it. The namespace is
// load-bearing: the autobase local writer key is derived from (corestore, namespace), so reopening a room
// with the SAME namespace is what keeps the owner/mod WRITABLE across invocations — a different namespace
// would mint a fresh, un-admitted writer. The index makes that identity stable.
//
// One-shot commands operate on the LOCAL replica (fast, no swarm). `serve` (and `--sync` on reads) join the
// swarm to replicate with peers + favela-web. ⚠ Corestore holds an exclusive lock — don't run a one-shot
// command against the SAME --storage while `serve` is running (that's what a control-socket daemon, favela
// roadmap step 3, is for).
import process from 'bare-process'
import { join, dirname } from 'bare-path'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'bare-fs'
import Corestore from 'corestore'
import Hyperswarm from 'hyperswarm'
import { CommunityRoom } from './community-room.js'
import { CommunityDiscovery } from './discovery.js'

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const isKey = (k) => /^[0-9a-f]{64}$/i.test(String(k || ''))
const normKey = (k) => String(k || '').toLowerCase()

function parse (args) {
  const positional = []
  const flags = {}
  for (let i = 0; i < args.length; i++) {
    const a = args[i]
    if (a.startsWith('--')) {
      const eq = a.indexOf('=')
      if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1) } else {
        const next = args[i + 1]
        if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++ } else flags[a.slice(2)] = true
      }
    } else positional.push(a)
  }
  return { positional, flags }
}

const USAGE = `favela-community — co-owned community rooms (the mod-side write tool)

Owner:
  create [--name NAME]                      create a community; prints its public roomKey
  admit <roomKey> <modLocalKey> [--role writer|owner] [--id NAME]
                                            admit a mod as a co-writer (default role=writer, non-indexer)

Mod (co-writer):
  join <roomKey>                            open a room as a mod; prints THIS peer's localKey (give it to the owner)
  local-key <roomKey>                       print this peer's local writer key for a room

Writer ops (owner or admitted mod):
  add-member <roomKey> <did>
  remove-member <roomKey> <did>
  ban <roomKey> <did>
  unban <roomKey> <did>
  set-meta <roomKey> <key> <value>

Read (anyone, keyless):
  members <roomKey> [--sync]                list member DIDs (roster minus banned)
  mods <roomKey> [--sync]                   list co-writers
  meta <roomKey> [--sync]                   community metadata

Live:
  serve <roomKey> [--announce] [--name NAME] [--topic a,b]
                                            keep the room open + swarm-joined (seed + stay writable);
                                            --announce advertises it for discovery by name/topic while up
Discovery:
  discover <name-or-topic>                  find rooms published under a name or topic → roomKeys (keyless)

Options:
  --storage PATH   corestore dir (default ~/.config/favela-community/store)
  --sync           join the swarm + wait for replication before reading (for a room hosted elsewhere)
  --timeout MS     sync/admission wait (default 15000)
  --json           machine-readable output
`

// ── persistence: storage + the roomKey→namespace index ──
function storagePath (flags) {
  if (flags.storage) return String(flags.storage)
  const home = process.env.HOME || process.env.USERPROFILE || '.'
  return join(home, '.config', 'favela-community', 'store')
}
function indexPath (storage) { return join(dirname(storage), 'rooms.json') }
function loadIndex (storage) {
  const p = indexPath(storage)
  try { return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {} } catch { return {} }
}
function saveIndex (storage, index) {
  const dir = dirname(indexPath(storage))
  try { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) } catch {}
  writeFileSync(indexPath(storage), JSON.stringify(index, null, 2))
}
// Namespace for a room. A recorded owner/mod entry keeps its writer identity; an unknown room gets an
// ephemeral read namespace (a fresh read replica — never writable).
function nsFor (index, key) { return (index[key] && index[key].namespace) || `read:${key}` }

async function makeSwarm (room) {
  const swarm = new Hyperswarm()
  swarm.on('connection', (conn) => room.base.replicate(conn)) // base.replicate = store.replicate + wakeup
  swarm.join(room.discoveryKey, { client: true, server: true })
  await swarm.flush()
  return swarm
}

// run(args) → exit code. io is injectable for tests.
export async function run (args, io = {}) {
  const out = io.out || ((s) => console.log(s))
  const err = io.err || ((s) => console.error(s))
  const { positional, flags } = parse(args)
  const cmd = positional[0]
  if (!cmd || cmd === 'help' || flags.help) { out(USAGE); return cmd ? 0 : 1 }

  const storage = storagePath(flags)
  const index = loadIndex(storage)
  const json = !!flags.json
  const timeout = Number(flags.timeout) || 15000
  const emit = (obj, human) => { out(json ? JSON.stringify(obj) : human) }

  let store = null
  let room = null
  let swarm = null
  const cleanup = async () => {
    try { if (swarm) await swarm.destroy() } catch {}
    try { if (room) await room.close() } catch {}
    try { if (store) await store.close() } catch {}
  }

  try {
    // ── create (owner) ──
    if (cmd === 'create') {
      const ns = flags.namespace ? String(flags.namespace) : `owner-${Date.now()}`
      store = new Corestore(storage)
      room = await CommunityRoom.create(store, { namespace: ns, name: flags.name ? String(flags.name) : null })
      index[normKey(room.key)] = { namespace: ns, role: 'owner', name: flags.name || null }
      saveIndex(storage, index)
      emit({ roomKey: room.key, localKey: room.localKey, role: 'owner' },
        `created community ${room.key}\n  role: owner (sole indexer)\n  share this roomKey; run 'serve ${room.key}' to publish it`)
      return 0
    }

    // ── join (mod) ──
    if (cmd === 'join') {
      const key = normKey(positional[1])
      if (!isKey(key)) { err('join: a 64-hex roomKey is required'); return 1 }
      let ns = index[key] && index[key].namespace
      if (!ns) { ns = `mod-${Date.now()}`; index[key] = { namespace: ns, role: 'mod' }; saveIndex(storage, index) }
      store = new Corestore(storage)
      room = await CommunityRoom.open(store, key, { namespace: ns })
      emit({ roomKey: key, localKey: room.localKey, writable: room.writable },
        `joined ${key} as a mod\n  your localKey: ${room.localKey}\n  give it to the owner:  favela-community admit ${key} ${room.localKey}\n  then run 'serve ${key}' — you become writable once the owner admits + you sync`)
      return 0
    }

    // ── admit (owner adds a mod) ──
    if (cmd === 'admit') {
      const key = normKey(positional[1])
      const modKey = normKey(positional[2])
      if (!isKey(key) || !isKey(modKey)) { err('admit: usage: admit <roomKey> <modLocalKey>'); return 1 }
      store = new Corestore(storage)
      room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
      if (!room.writable) { err(`admit: not a writer for ${key} (only the owner / an admitted mod can admit)`); return 1 }
      const role = flags.role === 'owner' ? 'owner' : 'writer'
      await room.addMod(modKey, { role, userId: flags.id ? String(flags.id) : undefined })
      swarm = await makeSwarm(room) // give the mod a window to sync the admission
      await sleep(1500)
      emit({ roomKey: key, admitted: modKey, role }, `admitted ${modKey} as ${role}${role === 'owner' ? ' (INDEXER — ensure an ODD count >=3; N=2 deadlocks)' : ''}`)
      return 0
    }

    if (cmd === 'local-key') {
      const key = normKey(positional[1])
      if (!isKey(key)) { err('local-key: a 64-hex roomKey is required'); return 1 }
      store = new Corestore(storage)
      room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
      emit({ roomKey: key, localKey: room.localKey, writable: room.writable }, room.localKey)
      return 0
    }

    // ── writer ops ──
    if (['add-member', 'remove-member', 'ban', 'unban', 'set-meta'].includes(cmd)) {
      const key = normKey(positional[1])
      if (!isKey(key)) { err(`${cmd}: a 64-hex roomKey is required`); return 1 }
      store = new Corestore(storage)
      room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
      if (!room.writable) { err(`${cmd}: not a writer for ${key} — create it here, or 'join' + have the owner admit you`); return 1 }
      try {
        if (cmd === 'add-member') await room.addMember(positional[2])
        else if (cmd === 'remove-member') await room.removeMember(positional[2])
        else if (cmd === 'ban') await room.ban(positional[2])
        else if (cmd === 'unban') await room.unban(positional[2])
        else if (cmd === 'set-meta') await room.setMeta(positional[2], positional[3])
      } catch (e) { err(`${cmd}: ${e.message}`); return 1 }
      if (flags.sync) { swarm = await makeSwarm(room); await sleep(1500) } // best-effort propagate
      emit({ roomKey: key, cmd, arg: positional[2], value: positional[3] }, `${cmd} ${positional[2] || ''}${positional[3] ? ' ' + positional[3] : ''} — ok`)
      return 0
    }

    // ── reads (keyless) ──
    if (['members', 'mods', 'meta', 'key'].includes(cmd)) {
      const key = normKey(positional[1])
      if (!isKey(key)) { err(`${cmd}: a 64-hex roomKey is required`); return 1 }
      if (cmd === 'key') { emit({ roomKey: key }, key); return 0 }
      store = new Corestore(storage)
      room = await CommunityRoom.openReadonly(store, key, { namespace: nsFor(index, key) })
      if (flags.sync) {
        swarm = await makeSwarm(room)
        const end = Date.now() + timeout
        while (Date.now() < end) { if ((await room.members()).length) break; await sleep(250) }
      }
      if (cmd === 'members') { const m = await room.members(); emit({ roomKey: key, members: m }, m.length ? m.join('\n') : '(no members)') }
      else if (cmd === 'mods') { const w = await room.mods(); emit({ roomKey: key, mods: w }, w.length ? w.map((x) => `${x.userId} (${x.role})`).join('\n') : '(no co-writers)') }
      else if (cmd === 'meta') { const mt = await room.meta(); emit({ roomKey: key, meta: mt }, Object.keys(mt).length ? Object.entries(mt).map(([k, v]) => `${k}: ${v}`).join('\n') : '(no metadata)') }
      return 0
    }

    // ── discover (keyless, one-shot DHT lookup) ──
    if (cmd === 'discover') {
      const query = positional[1]
      if (!query) { err('discover: usage: discover <name-or-topic>'); return 1 }
      swarm = new Hyperswarm()
      if (swarm.dht && swarm.dht.ready) await swarm.dht.ready()
      const rooms = await new CommunityDiscovery(swarm.dht).discover(query)
      emit({ query, rooms }, rooms.length
        ? rooms.map((r) => `${r.roomKey}  ${r.name || '(unnamed)'}${(r.topics && r.topics.length) ? '  #' + r.topics.join(' #') : ''}`).join('\n')
        : '(no rooms found)')
      return 0
    }

    // ── serve (stay live) ──
    if (cmd === 'serve') {
      const key = normKey(positional[1])
      if (!isKey(key)) { err('serve: a 64-hex roomKey is required'); return 1 }
      store = new Corestore(storage)
      room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
      swarm = await makeSwarm(room)
      const members = await room.members()
      out(`serving ${key}\n  writable: ${room.writable} · members: ${members.length}\n  discovery: ${room.discoveryKey ? Buffer.from(room.discoveryKey).toString('hex').slice(0, 16) + '…' : '?'}\n  (Ctrl-C to stop)`)
      // Optional: advertise the room for discovery while we're serving (announce stays live as long as this
      // process is up — the DHT announce record refreshes from this node). Name defaults to the room's meta.
      if (flags.announce || flags.name || flags.topic) {
        const name = flags.name ? String(flags.name) : ((await room.meta()).name || '')
        const topics = flags.topic ? String(flags.topic).split(',').map((s) => s.trim()).filter(Boolean) : []
        try {
          const r = await new CommunityDiscovery(swarm.dht).announce({ roomKey: key, name, topics })
          out(`  announced for discovery as: ${r.labels.join(', ') || '(none)'}`)
        } catch (e) { err(`  announce failed: ${e.message}`) }
      }
      let stop
      const done = new Promise((resolve) => { stop = resolve })
      const onSig = () => stop()
      process.on('SIGINT', onSig); process.on('SIGTERM', onSig)
      await done
      out('\nstopping…')
      return 0
    }

    err(`unknown command: ${cmd}\n\n${USAGE}`)
    return 1
  } finally {
    await cleanup()
  }
}

export default run