OverviewFilesCommitsIssuesDocsReleasesPeers

favela-community commits / 8d516a7

favela-community: mod-side CLI that drives the write layer
A Bare CLI (bin/favela-community) an owner/mod uses to run the community write layer. State persists in a corestore + a roomKey→namespace index (rooms.json) so the owner/mod's autobase writer identity stays stable across invocations (reopening with a different namespace would mint a fresh, un-admitted writer). Commands: - create [--name] owner: create a community (sole indexer) - join <roomKey> mod: open + print this peer's localKey - admit <roomKey> <modLocalKey> owner: addMod a co-writer (default writer/non-indexer) - add-member/remove-member/ban/unban/set-meta <roomKey> … writer ops - members/mods/meta/key <roomKey> [--sync] keyless reads - serve <roomKey> stay live (swarm-joined, seed + writable) - local-key <roomKey>, help Options: --storage, --sync, --timeout, --json. lib/cli.js exports run(args, io) (io injectable → testable); bin/favela-community.js wires Bare.argv. base.replicate for the swarm (wakeup). One-shot commands are local; serve/--sync touch the network. ⚠ corestore exclusive lock: don't run a one-shot against the same --storage while serve runs (a control-socket daemon = favela roadmap step 3 removes that). deps: + hyperswarm, corestore (→ runtime), bare-process/path/fs. Proof: test/cli.test.mjs (bare) — owner create/manage/read, writer-identity persists across reopens, un-admitted mod write refused, errors + help. Real bin invocation smoke-verified (create/add-member/members/meta/exit codes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8d516a7b6df9dc28db6b65b57248ed0cf0532fe0
by plankatron <fleeky@gmail.com> · 2026-07-15T03:48:17.000Z

Changed files (5) +635 -8

bin/favela-community.js +9 -0
……@@ -0,0 +1,9 @@
1#!/usr/bin/env bare
2// favela-community CLI entry (Bare). Drives the community write layer — see lib/cli.js for commands.
3import process from 'bare-process'
4import { run } from '../lib/cli.js'
5
6const args = (globalThis.Bare && Array.isArray(globalThis.Bare.argv)) ? globalThis.Bare.argv.slice(2) : process.argv.slice(2)
7run(args)
8 .then((code) => process.exit(code || 0))
9 .catch((e) => { console.error(e && (e.stack || e.message) || e); process.exit(1) })
lib/cli.js +238 -0
……@@ -0,0 +1,238 @@
1// favela-community CLI — the mod-side tool that DRIVES the write layer. An owner creates + manages a
2// community; a mod joins and (once admitted) co-writes; anyone reads keylessly. Runs under Bare.
3//
4// State persists in a corestore (--storage, default ~/.config/favela-community/store) + a small JSON index
5// (rooms.json in the parent dir) mapping roomKey → the corestore NAMESPACE used for it. The namespace is
6// load-bearing: the autobase local writer key is derived from (corestore, namespace), so reopening a room
7// with the SAME namespace is what keeps the owner/mod WRITABLE across invocations — a different namespace
8// would mint a fresh, un-admitted writer. The index makes that identity stable.
9//
10// One-shot commands operate on the LOCAL replica (fast, no swarm). `serve` (and `--sync` on reads) join the
11// swarm to replicate with peers + favela-web. ⚠ Corestore holds an exclusive lock — don't run a one-shot
12// command against the SAME --storage while `serve` is running (that's what a control-socket daemon, favela
13// roadmap step 3, is for).
14import process from 'bare-process'
15import { join, dirname } from 'bare-path'
16import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'bare-fs'
17import Corestore from 'corestore'
18import Hyperswarm from 'hyperswarm'
19import { CommunityRoom } from './community-room.js'
20
21const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
22const isKey = (k) => /^[0-9a-f]{64}$/i.test(String(k || ''))
23const normKey = (k) => String(k || '').toLowerCase()
24
25function parse (args) {
26 const positional = []
27 const flags = {}
28 for (let i = 0; i < args.length; i++) {
29 const a = args[i]
30 if (a.startsWith('--')) {
31 const eq = a.indexOf('=')
32 if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1) } else {
33 const next = args[i + 1]
34 if (next !== undefined && !next.startsWith('--')) { flags[a.slice(2)] = next; i++ } else flags[a.slice(2)] = true
35 }
36 } else positional.push(a)
37 }
38 return { positional, flags }
39}
40
41const USAGE = `favela-community — co-owned community rooms (the mod-side write tool)
42
43Owner:
44 create [--name NAME] create a community; prints its public roomKey
45 admit <roomKey> <modLocalKey> [--role writer|owner] [--id NAME]
46 admit a mod as a co-writer (default role=writer, non-indexer)
47
48Mod (co-writer):
49 join <roomKey> open a room as a mod; prints THIS peer's localKey (give it to the owner)
50 local-key <roomKey> print this peer's local writer key for a room
51
52Writer ops (owner or admitted mod):
53 add-member <roomKey> <did>
54 remove-member <roomKey> <did>
55 ban <roomKey> <did>
56 unban <roomKey> <did>
57 set-meta <roomKey> <key> <value>
58
59Read (anyone, keyless):
60 members <roomKey> [--sync] list member DIDs (roster minus banned)
61 mods <roomKey> [--sync] list co-writers
62 meta <roomKey> [--sync] community metadata
63
64Live:
65 serve <roomKey> keep the room open + swarm-joined (seed + stay writable)
66
67Options:
68 --storage PATH corestore dir (default ~/.config/favela-community/store)
69 --sync join the swarm + wait for replication before reading (for a room hosted elsewhere)
70 --timeout MS sync/admission wait (default 15000)
71 --json machine-readable output
72`
73
74// ── persistence: storage + the roomKey→namespace index ──
75function storagePath (flags) {
76 if (flags.storage) return String(flags.storage)
77 const home = process.env.HOME || process.env.USERPROFILE || '.'
78 return join(home, '.config', 'favela-community', 'store')
79}
80function indexPath (storage) { return join(dirname(storage), 'rooms.json') }
81function loadIndex (storage) {
82 const p = indexPath(storage)
83 try { return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {} } catch { return {} }
84}
85function saveIndex (storage, index) {
86 const dir = dirname(indexPath(storage))
87 try { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) } catch {}
88 writeFileSync(indexPath(storage), JSON.stringify(index, null, 2))
89}
90// Namespace for a room. A recorded owner/mod entry keeps its writer identity; an unknown room gets an
91// ephemeral read namespace (a fresh read replica — never writable).
92function nsFor (index, key) { return (index[key] && index[key].namespace) || `read:${key}` }
93
94async function makeSwarm (room) {
95 const swarm = new Hyperswarm()
96 swarm.on('connection', (conn) => room.base.replicate(conn)) // base.replicate = store.replicate + wakeup
97 swarm.join(room.discoveryKey, { client: true, server: true })
98 await swarm.flush()
99 return swarm
100}
101
102// run(args) → exit code. io is injectable for tests.
103export async function run (args, io = {}) {
104 const out = io.out || ((s) => console.log(s))
105 const err = io.err || ((s) => console.error(s))
106 const { positional, flags } = parse(args)
107 const cmd = positional[0]
108 if (!cmd || cmd === 'help' || flags.help) { out(USAGE); return cmd ? 0 : 1 }
109
110 const storage = storagePath(flags)
111 const index = loadIndex(storage)
112 const json = !!flags.json
113 const timeout = Number(flags.timeout) || 15000
114 const emit = (obj, human) => { out(json ? JSON.stringify(obj) : human) }
115
116 let store = null
117 let room = null
118 let swarm = null
119 const cleanup = async () => {
120 try { if (swarm) await swarm.destroy() } catch {}
121 try { if (room) await room.close() } catch {}
122 try { if (store) await store.close() } catch {}
123 }
124
125 try {
126 // ── create (owner) ──
127 if (cmd === 'create') {
128 const ns = flags.namespace ? String(flags.namespace) : `owner-${Date.now()}`
129 store = new Corestore(storage)
130 room = await CommunityRoom.create(store, { namespace: ns, name: flags.name ? String(flags.name) : null })
131 index[normKey(room.key)] = { namespace: ns, role: 'owner', name: flags.name || null }
132 saveIndex(storage, index)
133 emit({ roomKey: room.key, localKey: room.localKey, role: 'owner' },
134 `created community ${room.key}\n role: owner (sole indexer)\n share this roomKey; run 'serve ${room.key}' to publish it`)
135 return 0
136 }
137
138 // ── join (mod) ──
139 if (cmd === 'join') {
140 const key = normKey(positional[1])
141 if (!isKey(key)) { err('join: a 64-hex roomKey is required'); return 1 }
142 let ns = index[key] && index[key].namespace
143 if (!ns) { ns = `mod-${Date.now()}`; index[key] = { namespace: ns, role: 'mod' }; saveIndex(storage, index) }
144 store = new Corestore(storage)
145 room = await CommunityRoom.open(store, key, { namespace: ns })
146 emit({ roomKey: key, localKey: room.localKey, writable: room.writable },
147 `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`)
148 return 0
149 }
150
151 // ── admit (owner adds a mod) ──
152 if (cmd === 'admit') {
153 const key = normKey(positional[1])
154 const modKey = normKey(positional[2])
155 if (!isKey(key) || !isKey(modKey)) { err('admit: usage: admit <roomKey> <modLocalKey>'); return 1 }
156 store = new Corestore(storage)
157 room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
158 if (!room.writable) { err(`admit: not a writer for ${key} (only the owner / an admitted mod can admit)`); return 1 }
159 const role = flags.role === 'owner' ? 'owner' : 'writer'
160 await room.addMod(modKey, { role, userId: flags.id ? String(flags.id) : undefined })
161 swarm = await makeSwarm(room) // give the mod a window to sync the admission
162 await sleep(1500)
163 emit({ roomKey: key, admitted: modKey, role }, `admitted ${modKey} as ${role}${role === 'owner' ? ' (INDEXER — ensure an ODD count >=3; N=2 deadlocks)' : ''}`)
164 return 0
165 }
166
167 if (cmd === 'local-key') {
168 const key = normKey(positional[1])
169 if (!isKey(key)) { err('local-key: a 64-hex roomKey is required'); return 1 }
170 store = new Corestore(storage)
171 room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
172 emit({ roomKey: key, localKey: room.localKey, writable: room.writable }, room.localKey)
173 return 0
174 }
175
176 // ── writer ops ──
177 if (['add-member', 'remove-member', 'ban', 'unban', 'set-meta'].includes(cmd)) {
178 const key = normKey(positional[1])
179 if (!isKey(key)) { err(`${cmd}: a 64-hex roomKey is required`); return 1 }
180 store = new Corestore(storage)
181 room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
182 if (!room.writable) { err(`${cmd}: not a writer for ${key} — create it here, or 'join' + have the owner admit you`); return 1 }
183 try {
184 if (cmd === 'add-member') await room.addMember(positional[2])
185 else if (cmd === 'remove-member') await room.removeMember(positional[2])
186 else if (cmd === 'ban') await room.ban(positional[2])
187 else if (cmd === 'unban') await room.unban(positional[2])
188 else if (cmd === 'set-meta') await room.setMeta(positional[2], positional[3])
189 } catch (e) { err(`${cmd}: ${e.message}`); return 1 }
190 if (flags.sync) { swarm = await makeSwarm(room); await sleep(1500) } // best-effort propagate
191 emit({ roomKey: key, cmd, arg: positional[2], value: positional[3] }, `${cmd} ${positional[2] || ''}${positional[3] ? ' ' + positional[3] : ''} — ok`)
192 return 0
193 }
194
195 // ── reads (keyless) ──
196 if (['members', 'mods', 'meta', 'key'].includes(cmd)) {
197 const key = normKey(positional[1])
198 if (!isKey(key)) { err(`${cmd}: a 64-hex roomKey is required`); return 1 }
199 if (cmd === 'key') { emit({ roomKey: key }, key); return 0 }
200 store = new Corestore(storage)
201 room = await CommunityRoom.openReadonly(store, key, { namespace: nsFor(index, key) })
202 if (flags.sync) {
203 swarm = await makeSwarm(room)
204 const end = Date.now() + timeout
205 while (Date.now() < end) { if ((await room.members()).length) break; await sleep(250) }
206 }
207 if (cmd === 'members') { const m = await room.members(); emit({ roomKey: key, members: m }, m.length ? m.join('\n') : '(no members)') }
208 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)') }
209 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)') }
210 return 0
211 }
212
213 // ── serve (stay live) ──
214 if (cmd === 'serve') {
215 const key = normKey(positional[1])
216 if (!isKey(key)) { err('serve: a 64-hex roomKey is required'); return 1 }
217 store = new Corestore(storage)
218 room = await CommunityRoom.open(store, key, { namespace: nsFor(index, key) })
219 swarm = await makeSwarm(room)
220 const members = await room.members()
221 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)`)
222 let stop
223 const done = new Promise((resolve) => { stop = resolve })
224 const onSig = () => stop()
225 process.on('SIGINT', onSig); process.on('SIGTERM', onSig)
226 await done
227 out('\nstopping…')
228 return 0
229 }
230
231 err(`unknown command: ${cmd}\n\n${USAGE}`)
232 return 1
233 } finally {
234 await cleanup()
235 }
236}
237
238export default run
package-lock.json +308 -4
……@@ -10,10 +10,15 @@
1010 "dependencies": {
1111 "autobase": "^7.28.0",
1212 "b4a": "^1.8.1",
13 "hyper-rooms": "file:../hyper-rooms"
13 "bare-fs": "^4.0.0",
14 "bare-path": "^3.0.0",
15 "bare-process": "^4.0.0",
16 "corestore": "^7.4.0",
17 "hyper-rooms": "file:../hyper-rooms",
18 "hyperswarm": "^4.11.0"
1419 },
15 "devDependencies": {
16 "corestore": "^7.4.0"
20 "bin": {
21 "favela-community": "bin/favela-community.js"
1722 }
1823 },
1924 "../hyper-rooms": {
……@@ -51,6 +56,15 @@
5156 "unslab": "^1.3.0"
5257 }
5358 },
59 "node_modules/adaptive-timeout": {
60 "version": "1.0.1",
61 "resolved": "https://registry.npmjs.org/adaptive-timeout/-/adaptive-timeout-1.0.1.tgz",
62 "integrity": "sha512-+seGiBtHqbnyZ0Quq/ZGxxwP66153WBABawa07/QeyuPFzbduPBjiGQAyCiriiLDzt3dkbMBskSlfqtRwblK8Q==",
63 "license": "Apache-2.0",
64 "dependencies": {
65 "xache": "^1.2.1"
66 }
67 },
5468 "node_modules/autobase": {
5569 "version": "7.28.1",
5670 "resolved": "https://registry.npmjs.org/autobase/-/autobase-7.28.1.tgz",
……@@ -95,6 +109,12 @@
95109 }
96110 }
97111 },
112 "node_modules/bare-abort": {
113 "version": "2.0.13",
114 "resolved": "https://registry.npmjs.org/bare-abort/-/bare-abort-2.0.13.tgz",
115 "integrity": "sha512-zdc8l88eB11Jsz5rDd6sCAgv2kUFXgdrZWoMlgU6JMkfAi1/uuGFC3IEHswKbIRQTk5H3T5CMuechsXYxiaHlQ==",
116 "license": "Apache-2.0"
117 },
98118 "node_modules/bare-addon-resolve": {
99119 "version": "1.10.0",
100120 "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz",
……@@ -139,6 +159,15 @@
139159 "bare-inspect": "^3.1.2"
140160 }
141161 },
162 "node_modules/bare-env": {
163 "version": "3.0.1",
164 "resolved": "https://registry.npmjs.org/bare-env/-/bare-env-3.0.1.tgz",
165 "integrity": "sha512-BdoLvnzaWR0YyyJreEx4zG2Od0AC/s9JSZ+EXyMZKunpn8zEgR/dGv37hdFUaY1bjDkcRKvcKkFyJIwlJ0CaYA==",
166 "license": "Apache-2.0",
167 "dependencies": {
168 "bare-os": "^3.0.1"
169 }
170 },
142171 "node_modules/bare-events": {
143172 "version": "2.9.1",
144173 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz",
……@@ -177,6 +206,12 @@
177206 }
178207 }
179208 },
209 "node_modules/bare-hrtime": {
210 "version": "2.1.1",
211 "resolved": "https://registry.npmjs.org/bare-hrtime/-/bare-hrtime-2.1.1.tgz",
212 "integrity": "sha512-VMb3tHo05gsnbu3OXTmkDiwTjMlOsbQmKoysKqKEyR09m77TuDrYFbj3Q5GGk10dAKsUHrnXmwCaeJqzVpB5ZA==",
213 "license": "Apache-2.0"
214 },
180215 "node_modules/bare-inspect": {
181216 "version": "3.1.4",
182217 "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz",
……@@ -207,18 +242,85 @@
207242 }
208243 }
209244 },
245 "node_modules/bare-os": {
246 "version": "3.9.3",
247 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz",
248 "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==",
249 "license": "Apache-2.0",
250 "engines": {
251 "bare": ">=1.14.0"
252 }
253 },
210254 "node_modules/bare-path": {
211255 "version": "3.1.1",
212256 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz",
213257 "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==",
214258 "license": "Apache-2.0"
215259 },
260 "node_modules/bare-pipe": {
261 "version": "4.2.2",
262 "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.2.2.tgz",
263 "integrity": "sha512-Wkf6//CmKS8inHrkgi3X/xNMeaGfs2I0ayLM0B8cbPB4DxYHcoes6Ng+muCBfXNIJ9qmEjwDDJjAUz7nRxxjgQ==",
264 "license": "Apache-2.0",
265 "dependencies": {
266 "bare-events": "^2.0.0",
267 "bare-stream": "^2.0.0"
268 },
269 "engines": {
270 "bare": ">=1.16.0"
271 }
272 },
273 "node_modules/bare-posix": {
274 "version": "1.0.1",
275 "resolved": "https://registry.npmjs.org/bare-posix/-/bare-posix-1.0.1.tgz",
276 "integrity": "sha512-b4quA8dyRVvX0aFIAIiev5zFATHwIUGr42Gmt+CqiBNg/bdsNkrj4bbkeotMOMiljJS4DCME5kywJO/TFwKo1A==",
277 "license": "Apache-2.0"
278 },
279 "node_modules/bare-process": {
280 "version": "4.5.1",
281 "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.5.1.tgz",
282 "integrity": "sha512-CaAvy1trputD49mtwfJ6G75vydhnirLrW/F3Sznp4H556e1uZn8YMo9ELicBTrGYy7RBNUgPl9bpB/ERzRCiDw==",
283 "license": "Apache-2.0",
284 "dependencies": {
285 "bare-abort": "^2.0.13",
286 "bare-env": "^3.0.0",
287 "bare-events": "^2.3.1",
288 "bare-hrtime": "^2.0.0",
289 "bare-os": "^3.7.1",
290 "bare-posix": "^1.0.1",
291 "bare-signals": "^5.0.0",
292 "bare-stdio": "^1.0.1"
293 }
294 },
216295 "node_modules/bare-semver": {
217296 "version": "1.1.0",
218297 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz",
219298 "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==",
220299 "license": "Apache-2.0"
221300 },
301 "node_modules/bare-signals": {
302 "version": "5.0.0",
303 "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-5.0.0.tgz",
304 "integrity": "sha512-8Gn8bBFUh2AUCJ9wWWFjSGWIo1HIUSIQnXRJGSm/f7GCDMqsuJhRmR1dT+HtDaDBkeu2l8Koxt2JLurfJ5yHVw==",
305 "license": "Apache-2.0",
306 "dependencies": {
307 "bare-events": "^2.5.3"
308 },
309 "engines": {
310 "bare": ">=1.7.0"
311 }
312 },
313 "node_modules/bare-stdio": {
314 "version": "1.0.3",
315 "resolved": "https://registry.npmjs.org/bare-stdio/-/bare-stdio-1.0.3.tgz",
316 "integrity": "sha512-8BRMx7RWMWCbBmKhyHgBco62J+Pgss7+EPI21L3R9+w0UpwDYWUUcbE0YTOesGyt7M6N7VtoshYbzwrP0pI52Q==",
317 "license": "Apache-2.0",
318 "dependencies": {
319 "bare-fs": "^4.5.2",
320 "bare-pipe": "^4.1.5",
321 "bare-tty": "^5.0.3"
322 }
323 },
222324 "node_modules/bare-stream": {
223325 "version": "2.13.3",
224326 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz",
……@@ -246,6 +348,20 @@
246348 }
247349 }
248350 },
351 "node_modules/bare-tty": {
352 "version": "5.1.2",
353 "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.1.2.tgz",
354 "integrity": "sha512-wDHPU/yVQtrDCUrKVdIVOLmfIw7dFPETz30UayuFlROvgz8Juv2SmISSMhqxYQVO+fVnG/1ZoLcbGh/35eAjDA==",
355 "license": "Apache-2.0",
356 "dependencies": {
357 "bare-events": "^2.2.0",
358 "bare-signals": "^5.0.0",
359 "bare-stream": "^2.0.0"
360 },
361 "engines": {
362 "bare": ">=1.16.0"
363 }
364 },
249365 "node_modules/bare-type": {
250366 "version": "1.1.0",
251367 "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz",
……@@ -260,6 +376,7 @@
260376 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz",
261377 "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==",
262378 "license": "Apache-2.0",
379 "peer": true,
263380 "dependencies": {
264381 "bare-path": "^3.0.0"
265382 }
……@@ -270,6 +387,41 @@
270387 "integrity": "sha512-6RjV/3mSZORlMdpUaQ6rUSpG637cZm0//E54YYGtQg1c1O+AbZP8UTdJ/TchsDZcTVLmyWZcseBfp2HBeXUXOQ==",
271388 "license": "MIT"
272389 },
390 "node_modules/bits-to-bytes": {
391 "version": "1.3.0",
392 "resolved": "https://registry.npmjs.org/bits-to-bytes/-/bits-to-bytes-1.3.0.tgz",
393 "integrity": "sha512-OJoHTpFXS9bXHBCekGTByf3MqM8CGblBDIduKQeeVVeiU9dDWywSSirXIBYGgg3d1zbVuvnMa1vD4r6PA0kOKg==",
394 "license": "ISC",
395 "dependencies": {
396 "b4a": "^1.5.0"
397 }
398 },
399 "node_modules/blind-relay": {
400 "version": "1.6.1",
401 "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.6.1.tgz",
402 "integrity": "sha512-38YmKCl/m9NcTkwueBbcGIt9Zx3IT/Q9Nsluu6C5Gur6PqfE0zWPhPwCzia1hri/g0ICYxrqkgLtEaoWD5WeSQ==",
403 "license": "Apache-2.0",
404 "dependencies": {
405 "b4a": "^1.6.4",
406 "bare-events": "^2.2.0",
407 "bits-to-bytes": "^1.3.0",
408 "compact-encoding": "^3.0.0",
409 "compact-encoding-bitfield": "^1.0.0",
410 "protomux": "^3.5.1",
411 "sodium-universal": "^5.0.0",
412 "streamx": "^2.15.1"
413 }
414 },
415 "node_modules/bogon": {
416 "version": "1.3.0",
417 "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz",
418 "integrity": "sha512-04tu0G/v0f2HPuQlSfhO635WwHDBCaITJ490rhxH9ttUlgPqOirZVKV02YB6NvPf5VkmbqJCm3bnZwrPk/eDSg==",
419 "license": "MIT",
420 "dependencies": {
421 "compact-encoding": "^3.0.0",
422 "compact-encoding-net": "^1.2.0"
423 }
424 },
273425 "node_modules/codecs": {
274426 "version": "3.1.0",
275427 "resolved": "https://registry.npmjs.org/codecs/-/codecs-3.1.0.tgz",
……@@ -288,6 +440,24 @@
288440 "b4a": "^1.3.0"
289441 }
290442 },
443 "node_modules/compact-encoding-bitfield": {
444 "version": "1.1.0",
445 "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz",
446 "integrity": "sha512-F6wliSKHi50BsBStmnsRJAzJgYSIYzdfSe3M0oHj4uQ1RcctJK/NQVD7wF51bj/DBMne2i5gUxrGUFkNZQns5w==",
447 "license": "Apache-2.0",
448 "dependencies": {
449 "compact-encoding": "^3.0.0"
450 }
451 },
452 "node_modules/compact-encoding-net": {
453 "version": "1.3.0",
454 "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz",
455 "integrity": "sha512-HIYjL3aMiSENApR691aMLngXt6rkTHb9HUkEukcx3YwIZpoMUVXHXyjBzoo9kVwC7KakooBA1RRWb46Cu6qtAQ==",
456 "license": "Apache-2.0",
457 "dependencies": {
458 "compact-encoding": "^3.0.0"
459 }
460 },
291461 "node_modules/core-coupler": {
292462 "version": "2.0.0",
293463 "resolved": "https://registry.npmjs.org/core-coupler/-/core-coupler-2.0.0.tgz",
……@@ -301,7 +471,6 @@
301471 "version": "7.11.1",
302472 "resolved": "https://registry.npmjs.org/corestore/-/corestore-7.11.1.tgz",
303473 "integrity": "sha512-X6UsGFyHeAcqffTqgoDV7WDZjhfvolWabwzws6GbdqeXQY0CTM6gNpNMGEi6RGvAdZDPnFaboF5jFBy1lw1rDA==",
304 "dev": true,
305474 "license": "MIT",
306475 "dependencies": {
307476 "b4a": "^1.6.7",
……@@ -336,6 +505,26 @@
336505 "ready-resource": "^1.2.0"
337506 }
338507 },
508 "node_modules/dht-rpc": {
509 "version": "6.27.0",
510 "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz",
511 "integrity": "sha512-NsfgRlFDQnkA2+H+hJXMPLmBOn/TSIIc0cRMV8q42M4xc1uAXWtpvIIQsONF5tYcYC6px0bslrUGideN1h4S4A==",
512 "license": "MIT",
513 "dependencies": {
514 "adaptive-timeout": "^1.0.1",
515 "b4a": "^1.6.1",
516 "bare-events": "^2.2.0",
517 "compact-encoding": "^3.0.0",
518 "compact-encoding-net": "^1.2.0",
519 "fast-fifo": "^1.1.0",
520 "kademlia-routing-table": "^1.0.1",
521 "nat-sampler": "^1.0.1",
522 "sodium-universal": "^5.0.0",
523 "streamx": "^2.13.2",
524 "time-ordered-set": "^2.0.0",
525 "udx-native": "^1.5.3"
526 }
527 },
339528 "node_modules/encryption-encoding": {
340529 "version": "1.0.3",
341530 "resolved": "https://registry.npmjs.org/encryption-encoding/-/encryption-encoding-1.0.3.tgz",
……@@ -507,6 +696,46 @@
507696 "xache": "^1.2.1"
508697 }
509698 },
699 "node_modules/hyperdht": {
700 "version": "6.33.0",
701 "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.33.0.tgz",
702 "integrity": "sha512-veSvVKptjPTu2di3q5IWI62ZDFzEmTj1QSTAkiXW/cg0+lgMUlwfVWB4dX+WI14xNb54vogVIjA/Ph3KiVuRJQ==",
703 "license": "MIT",
704 "dependencies": {
705 "@hyperswarm/secret-stream": "^6.6.2",
706 "b4a": "^1.3.1",
707 "bare-events": "^2.2.0",
708 "blind-relay": "^1.3.0",
709 "bogon": "^1.0.0",
710 "compact-encoding": "^3.0.0",
711 "dht-rpc": "^6.15.1",
712 "hypercore-crypto": "^3.3.0",
713 "hypercore-id-encoding": "^1.2.0",
714 "hyperdht-address": "^1.0.1",
715 "noise-curve-ed": "^2.0.0",
716 "noise-handshake": "^4.0.0",
717 "record-cache": "^1.1.1",
718 "safety-catch": "^1.0.1",
719 "signal-promise": "^1.0.3",
720 "sodium-universal": "^5.0.1",
721 "streamx": "^2.16.1",
722 "unslab": "^1.3.0",
723 "xache": "^1.1.0"
724 },
725 "bin": {
726 "hyperdht": "bin.js"
727 }
728 },
729 "node_modules/hyperdht-address": {
730 "version": "1.1.1",
731 "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.1.1.tgz",
732 "integrity": "sha512-Mu/+7SW2cwvHxMXswa5mOrhrS5BJyntViAuB25k8d8wNtA8eAPs4LaIq6TwN1SS/KQY02h1r+gM8cQeN/k360w==",
733 "license": "Apache-2.0",
734 "dependencies": {
735 "compact-encoding": "^3.0.0",
736 "hyperschema": "^1.20.1"
737 }
738 },
510739 "node_modules/hyperschema": {
511740 "version": "1.21.0",
512741 "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.21.0.tgz",
……@@ -519,6 +748,21 @@
519748 "generate-string": "^1.0.1"
520749 }
521750 },
751 "node_modules/hyperswarm": {
752 "version": "4.17.0",
753 "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.0.tgz",
754 "integrity": "sha512-oe86sK961Ueg7rvDN/veFwG8xH+Iv6vObPhGDkPJcDVxk/NduW41ZhAcVDnHzRbm7S0eLU7WaDUvehOYoKSpRQ==",
755 "license": "MIT",
756 "dependencies": {
757 "b4a": "^1.3.1",
758 "bare-events": "^2.2.0",
759 "hyperdht": "^6.21.0",
760 "safety-catch": "^1.0.2",
761 "shuffled-priority-queue": "^2.1.0",
762 "streamx": "^2.22.1",
763 "unslab": "^1.3.0"
764 }
765 },
522766 "node_modules/index-encoder": {
523767 "version": "3.5.0",
524768 "resolved": "https://registry.npmjs.org/index-encoder/-/index-encoder-3.5.0.tgz",
……@@ -543,6 +787,15 @@
543787 "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
544788 "license": "MIT"
545789 },
790 "node_modules/kademlia-routing-table": {
791 "version": "1.0.6",
792 "resolved": "https://registry.npmjs.org/kademlia-routing-table/-/kademlia-routing-table-1.0.6.tgz",
793 "integrity": "sha512-Ve6jwIlUCYvUzBnXnzVRHDZCFgXURW9gmF3r7n05kZs/2rNbLHXwGdcq0qIaSwdmJCvtosgR4JensnVU65hzNQ==",
794 "license": "MIT",
795 "dependencies": {
796 "bare-events": "^2.2.0"
797 }
798 },
546799 "node_modules/mutexify": {
547800 "version": "1.4.0",
548801 "resolved": "https://registry.npmjs.org/mutexify/-/mutexify-1.4.0.tgz",
……@@ -558,6 +811,12 @@
558811 "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==",
559812 "license": "ISC"
560813 },
814 "node_modules/nat-sampler": {
815 "version": "1.0.1",
816 "resolved": "https://registry.npmjs.org/nat-sampler/-/nat-sampler-1.0.1.tgz",
817 "integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==",
818 "license": "MIT"
819 },
561820 "node_modules/noise-curve-ed": {
562821 "version": "2.1.0",
563822 "resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz",
……@@ -666,6 +925,15 @@
666925 "bare-events": "^2.2.0"
667926 }
668927 },
928 "node_modules/record-cache": {
929 "version": "1.2.0",
930 "resolved": "https://registry.npmjs.org/record-cache/-/record-cache-1.2.0.tgz",
931 "integrity": "sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==",
932 "license": "MIT",
933 "dependencies": {
934 "b4a": "^1.3.1"
935 }
936 },
669937 "node_modules/refcounter": {
670938 "version": "1.0.0",
671939 "resolved": "https://registry.npmjs.org/refcounter/-/refcounter-1.0.0.tgz",
……@@ -726,6 +994,15 @@
726994 "integrity": "sha512-BpSd8VCuCxW9ZitcdIC/vjs3gMaP9bRBL5nkHcyfX2VrS52n13/rHuBA2xJ/S/4DPuRdAO/Bk8pWd8eD/gHCIA==",
727995 "license": "Apache-2.0"
728996 },
997 "node_modules/shuffled-priority-queue": {
998 "version": "2.1.0",
999 "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz",
1000 "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==",
1001 "license": "MIT",
1002 "dependencies": {
1003 "unordered-set": "^2.0.1"
1004 }
1005 },
7291006 "node_modules/signal-promise": {
7301007 "version": "1.0.3",
7311008 "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz",
……@@ -844,6 +1121,12 @@
8441121 "b4a": "^1.6.4"
8451122 }
8461123 },
1124 "node_modules/time-ordered-set": {
1125 "version": "2.0.1",
1126 "resolved": "https://registry.npmjs.org/time-ordered-set/-/time-ordered-set-2.0.1.tgz",
1127 "integrity": "sha512-VJEKmgSN2UiOLB8BpN8Sh2b9LGMHTP5OPrQRpnKjvOheOyzk0mufbjzjKTIG2gO4A+Y+vDJ+0TcLbpUmMLsg8A==",
1128 "license": "MIT"
1129 },
8471130 "node_modules/timeout-refresh": {
8481131 "version": "2.0.1",
8491132 "resolved": "https://registry.npmjs.org/timeout-refresh/-/timeout-refresh-2.0.1.tgz",
……@@ -859,6 +1142,27 @@
8591142 "b4a": "^1.6.0"
8601143 }
8611144 },
1145 "node_modules/udx-native": {
1146 "version": "1.20.7",
1147 "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.20.7.tgz",
1148 "integrity": "sha512-FNG0QpnSt0us2xbLP5mmG5Q0UKdLYjKHuh2eR9VrJSFmoZMzeszYLLhK12+iq5x3eq/rgoGG/lLwPn7IDAr6pg==",
1149 "license": "Apache-2.0",
1150 "dependencies": {
1151 "b4a": "^1.5.0",
1152 "bare-events": "^2.2.0",
1153 "require-addon": "^1.1.0",
1154 "streamx": "^2.22.0"
1155 },
1156 "engines": {
1157 "bare": ">=1.17.4"
1158 }
1159 },
1160 "node_modules/unordered-set": {
1161 "version": "2.0.1",
1162 "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz",
1163 "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==",
1164 "license": "MIT"
1165 },
8621166 "node_modules/unslab": {
8631167 "version": "1.3.0",
8641168 "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz",
package.json +9 -4
……@@ -4,15 +4,20 @@
44 "description": "Co-owned, plaintext community rooms for Favela — membership + moderation on a hyper-rooms Autobase. Read KEYLESSLY by the gateway (favela-web); WRITTEN by mods (hpp / full-node / CLI). One community protocol, shared across surfaces.",
55 "type": "module",
66 "main": "index.js",
7 "bin": {
8 "favela-community": "bin/favela-community.js"
9 },
710 "scripts": {
8 "test": "bare test/community-room.test.mjs"
11 "test": "bare test/community-room.test.mjs && bare test/cli.test.mjs"
912 },
1013 "dependencies": {
1114 "hyper-rooms": "file:../hyper-rooms",
1215 "autobase": "^7.28.0",
16 "corestore": "^7.4.0",
17 "hyperswarm": "^4.11.0",
18 "bare-process": "^4.0.0",
19 "bare-path": "^3.0.0",
20 "bare-fs": "^4.0.0",
1321 "b4a": "^1.8.1"
14 },
15 "devDependencies": {
16 "corestore": "^7.4.0"
1722 }
1823}
test/cli.test.mjs +71 -0
……@@ -0,0 +1,71 @@
1// favela-community CLI — drives run() against a temp storage: the local owner flow (create → manage →
2// read) + the mod-not-admitted guard + arg/error handling. Cross-machine admission/replication is proven
3// by community-room.test.mjs. Bare-native. Run: bare test/cli.test.mjs
4import { run } from '../lib/cli.js'
5
6const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
7const stamp = Date.now()
8const STORE = 'data/cli-' + stamp + '/store' // owner storage
9const STORE2 = 'data/cli2-' + stamp + '/store' // a separate mod's storage
10const DID = 'did:plc:member2222222222222222222'
11
12// capture out/err; return the parsed JSON of the last out line (commands use --json)
13function cap () {
14 const out = []; const err = []
15 return { io: { out: (s) => out.push(String(s)), err: (s) => err.push(String(s)) }, out, err }
16}
17async function cli (args) {
18 const c = cap()
19 const code = await run(args, c.io)
20 let json = null
21 try { json = JSON.parse(c.out[c.out.length - 1]) } catch {}
22 return { code, out: c.out, err: c.err, json }
23}
24
25// ── create (owner) ──
26const created = await cli(['create', '--name', 'Test Community', '--storage', STORE, '--json'])
27assert(created.code === 0 && created.json && /^[0-9a-f]{64}$/.test(created.json.roomKey), 'create → roomKey + exit 0')
28assert(created.json.role === 'owner' && /^[0-9a-f]{64}$/.test(created.json.localKey), 'create reports owner + localKey')
29const KEY = created.json.roomKey
30
31// ── read: empty roster, meta from create ──
32const m0 = await cli(['members', KEY, '--storage', STORE, '--json'])
33assert(m0.code === 0 && Array.isArray(m0.json.members) && m0.json.members.length === 0, 'members (fresh) = []')
34const meta0 = await cli(['meta', KEY, '--storage', STORE, '--json'])
35assert(meta0.json.meta && meta0.json.meta.name === 'Test Community', 'meta.name set at create')
36
37// ── owner writes: add-member, then read it back (writer identity persists via the namespace index) ──
38const add = await cli(['add-member', KEY, DID, '--storage', STORE, '--json'])
39assert(add.code === 0, 'add-member → exit 0 (owner is still writable on reopen)')
40const m1 = await cli(['members', KEY, '--storage', STORE, '--json'])
41assert(m1.json.members.length === 1 && m1.json.members[0] === DID, 'members now = [DID]')
42
43// ── moderation: ban removes from roster ──
44await cli(['ban', KEY, DID, '--storage', STORE, '--json'])
45const m2 = await cli(['members', KEY, '--storage', STORE, '--json'])
46assert(m2.json.members.length === 0, 'ban removes DID from the roster')
47
48// ── set-meta update ──
49await cli(['set-meta', KEY, 'name', 'Renamed', '--storage', STORE, '--json'])
50const meta1 = await cli(['meta', KEY, '--storage', STORE, '--json'])
51assert(meta1.json.meta.name === 'Renamed', 'set-meta updates name')
52
53// ── mod (separate storage): join prints a localKey; writing without admission is refused ──
54const join = await cli(['join', KEY, '--storage', STORE2, '--json'])
55assert(join.code === 0 && /^[0-9a-f]{64}$/.test(join.json.localKey) && join.json.writable === false, 'join → mod localKey, not yet writable')
56const badWrite = await cli(['add-member', KEY, DID, '--storage', STORE2, '--json'])
57assert(badWrite.code === 1 && badWrite.err.some((e) => /not a writer/.test(e)), 'un-admitted mod CANNOT write (add-member refused)')
58
59// ── local-key is stable for the mod (same as join) ──
60const lk = await cli(['local-key', KEY, '--storage', STORE2, '--json'])
61assert(lk.json.localKey === join.json.localKey, 'local-key is stable for a joined mod (namespace index)')
62
63// ── errors / help ──
64const bad = await cli(['members', 'not-a-key', '--storage', STORE, '--json'])
65assert(bad.code === 1 && bad.err.some((e) => /64-hex roomKey/.test(e)), 'bad roomKey → exit 1 + message')
66const help = await cli(['help'])
67assert(help.code === 0 && help.out.some((l) => /favela-community/.test(l) && /create/.test(l)), 'help prints usage')
68const none = await cli([])
69assert(none.code === 1, 'no command → exit 1 (usage)')
70
71console.log('\nFAVELA-COMMUNITY CLI OK (Bare-native): owner create/manage/read + mod-not-admitted guard + errors')