// 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