OverviewFilesCommitsIssuesDocsReleasesPeers

keet-cli commits / 2e48af9

Initial commit: keet-daemon headless P2P chat client
Daemon architecture - single process holds RocksDB lock, exposes HTTP API. Multiple clients (CLI, curl, Claude) connect concurrently via localhost:3117. Features: - 17 RPC methods: identity, rooms, messages, members, DMs, admin - SSE streaming for live message watching - Thin CLI client (HTTP to daemon) - Raw API passthrough for exploring Keet core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2e48af905480401f5cb67e524f8f67287f6572b8
by plankatron <fleeky@gmail.com> · 2026-02-14T02:54:13.000Z

Changed files (7) +1069 -0

.gitignore +3 -0
……@@ -0,0 +1,3 @@
1node_modules/
2/tmp/
3*.log
daemon.js +54 -0
……@@ -0,0 +1,54 @@
1import KeetCoreHyperDB from '@holepunchto/keet-core-hyperdb'
2import path from 'path'
3import os from 'os'
4import fs from 'fs'
5import { Router } from './lib/router.js'
6import { HttpTransport } from './lib/http-transport.js'
7
8export async function startDaemon (opts = {}) {
9 const storage = opts.storage || path.join(os.homedir(), '.config', 'keet-cli', 'default')
10 const port = opts.port || 3117
11 const pidFile = opts.pidFile || '/tmp/keet-daemon.pid'
12
13 console.log('[keet-daemon] Starting... storage:', storage)
14 console.log('[keet-daemon] Port:', port)
15
16 // Init Keet core (holds exclusive RocksDB lock)
17 const KeetCore = KeetCoreHyperDB.default || KeetCoreHyperDB
18 const { keet: core } = KeetCore.from(storage, {
19 experimental: false,
20 devMirrors: false,
21 devUserRegistry: false,
22 swarming: true,
23 production: true,
24 onerror: err => console.error('[keet-daemon] Core error:', err)
25 })
26
27 const backend = core.backendAPI
28 await backend.ready()
29 console.log('[keet-daemon] Backend ready')
30
31 // Router + HTTP transport
32 const router = new Router(backend, core)
33 const http = new HttpTransport(router, { port })
34 await http.start()
35
36 // Write PID file
37 try {
38 fs.writeFileSync(pidFile, String(Bare.pid))
39 } catch (err) {
40 console.error('[keet-daemon] Warning: could not write PID file:', err.message)
41 }
42
43 console.log(`[keet-daemon] Ready — HTTP :${port}, PID ${Bare.pid}`)
44 console.log('[keet-daemon] Endpoints:')
45 console.log(` GET http://localhost:${port}/rpc/rooms`)
46 console.log(` GET http://localhost:${port}/rpc/status`)
47 console.log(` GET http://localhost:${port}/rpc/identity`)
48 console.log(` GET http://localhost:${port}/rpc/messages/:roomId?limit=20`)
49 console.log(` GET http://localhost:${port}/rpc/members/:roomId`)
50 console.log(` GET http://localhost:${port}/rpc/watch/:roomId (SSE)`)
51 console.log(` POST http://localhost:${port}/rpc {"method":"...","params":{...}}`)
52
53 return { router, http, core, pidFile }
54}
index.js +505 -0
……@@ -0,0 +1,505 @@
1import http from 'bare-http1'
2import path from 'path'
3import os from 'os'
4import { getGenericEventName } from './lib/format.js'
5
6// --- Parse Args ---
7const rawArgs = Pear.config.args || []
8
9let customStorage = null
10let customPort = null
11const args = []
12for (let i = 0; i < rawArgs.length; i++) {
13 if (rawArgs[i] === '--storage' && rawArgs[i + 1]) {
14 customStorage = rawArgs[++i]
15 } else if (rawArgs[i].startsWith('--storage=')) {
16 customStorage = rawArgs[i].split('=')[1]
17 } else if (rawArgs[i] === '--profile' && rawArgs[i + 1]) {
18 const profileName = rawArgs[++i]
19 customStorage = path.join(os.homedir(), '.config', 'keet-cli', profileName)
20 } else if (rawArgs[i].startsWith('--profile=')) {
21 const profileName = rawArgs[i].split('=')[1]
22 customStorage = path.join(os.homedir(), '.config', 'keet-cli', profileName)
23 } else if (rawArgs[i] === '--port' && rawArgs[i + 1]) {
24 customPort = parseInt(rawArgs[++i])
25 } else if (rawArgs[i].startsWith('--port=')) {
26 customPort = parseInt(rawArgs[i].split('=')[1])
27 } else {
28 args.push(rawArgs[i])
29 }
30}
31
32const STORAGE = customStorage || path.join(os.homedir(), '.config', 'keet-cli', 'default')
33const PORT = customPort || 3117
34const command = args[0]
35
36// --- Help ---
37function printHelp () {
38 console.log(`
39keet-cli — Headless Keet P2P chat client (daemon + thin client)
40
41Usage: pear run --dev . [options] <command> [args...]
42
43Options:
44 --profile <name> Use named profile (each profile = separate identity)
45 --storage <path> Use custom storage path
46 --port <number> Daemon port (default: 3117)
47
48Daemon Commands:
49 daemon Start daemon (foreground, use tmux for background)
50 daemon stop Stop running daemon
51 daemon status Check if daemon is running
52
53Client Commands (require running daemon):
54 identity Show current identity
55 identity recovery Show recovery mnemonic phrase
56 identity restore <phrase> Restore from mnemonic
57 rooms List recent rooms
58 room <roomId> Show room info
59 join <link|token> Join room via invite link or raw token
60 create <title> Create a new room
61 invite <roomId> Create an invite for a room
62 send <roomId> <message> Send a chat message
63 read <roomId> [count] Read recent messages (default: 20)
64 watch <roomId> Watch for new messages (SSE stream)
65 members <roomId> List room members
66 leave <roomId> Leave a room
67 status Daemon status (uptime, rooms, identity)
68 help Show this help
69
70Quick Start:
71 # Terminal 1: start daemon
72 pear run --dev . daemon
73
74 # Terminal 2: use CLI
75 pear run --dev . rooms
76 pear run --dev . send abc123 "Hello!"
77 pear run --dev . watch abc123
78
79 # Or use curl directly
80 curl -s localhost:3117/rpc/rooms | jq .
81 curl -sN localhost:3117/rpc/watch/abc123
82`)
83 Bare.exit(0)
84}
85
86if (!command || command === 'help') printHelp()
87
88// --- Daemon Mode ---
89if (command === 'daemon') {
90 const sub = args[1]
91
92 if (sub === 'stop') {
93 try {
94 const result = await rpc('shutdown')
95 if (result.ok) {
96 console.log('[keet-cli] Daemon shutting down')
97 } else {
98 console.error('[keet-cli] Error:', result.error)
99 }
100 } catch (err) {
101 if (err.code === 'ECONNREFUSED') {
102 console.log('[keet-cli] Daemon is not running')
103 } else {
104 console.error('[keet-cli] Error:', err.message)
105 }
106 }
107 Bare.exit(0)
108 }
109
110 if (sub === 'status') {
111 try {
112 const result = await get('/rpc/status')
113 if (result.ok) {
114 console.log('[keet-cli] Daemon is running')
115 console.log(` Uptime: ${result.data.uptime}s`)
116 console.log(` Rooms: ${result.data.rooms}`)
117 if (result.data.identity) {
118 console.log(` Identity: ${result.data.identity.name || result.data.identity.memberId}`)
119 }
120 } else {
121 console.log('[keet-cli] Daemon is not running')
122 }
123 } catch (err) {
124 if (err.code === 'ECONNREFUSED') {
125 console.log('[keet-cli] Daemon is not running')
126 } else {
127 console.error('[keet-cli] Error:', err.message)
128 }
129 }
130 Bare.exit(0)
131 }
132
133 // Start daemon (foreground)
134 const { startDaemon } = await import('./daemon.js')
135 await startDaemon({ storage: STORAGE, port: PORT })
136 // Keep alive
137 await new Promise(() => {})
138}
139
140// --- Client Mode ---
141try {
142 switch (command) {
143 case 'identity':
144 await clientIdentity(args.slice(1))
145 break
146 case 'rooms':
147 await clientRooms()
148 break
149 case 'room':
150 await clientRoom(args[1])
151 break
152 case 'join':
153 await clientJoin(args[1])
154 break
155 case 'create':
156 await clientCreate(args.slice(1).join(' '))
157 break
158 case 'invite':
159 await clientInvite(args[1])
160 break
161 case 'send':
162 await clientSend(args[1], args.slice(2).join(' '))
163 break
164 case 'read':
165 await clientRead(args[1], parseInt(args[2]) || 20)
166 break
167 case 'watch':
168 await clientWatch(args[1])
169 break
170 case 'members':
171 await clientMembers(args[1])
172 break
173 case 'leave':
174 await clientLeave(args[1])
175 break
176 case 'status':
177 await clientStatus()
178 break
179 default:
180 console.error(`Unknown command: ${command}`)
181 printHelp()
182 }
183} catch (err) {
184 if (err.code === 'ECONNREFUSED') {
185 console.error('[keet-cli] Daemon not running. Start with: pear run --dev . daemon')
186 } else {
187 console.error('[keet-cli] Error:', err.message || err)
188 }
189}
190
191if (command !== 'watch') Bare.exit(0)
192
193// ============================================================
194// Client command handlers
195// ============================================================
196
197async function clientIdentity (subArgs) {
198 const sub = subArgs[0]
199 if (sub === 'recovery') {
200 const result = await rpc('identity.recovery')
201 if (result.ok) {
202 console.log('[keet-cli] Recovery phrase:', result.data.phrase)
203 } else {
204 console.error('[keet-cli] Error:', result.error)
205 }
206 return
207 }
208 if (sub === 'restore') {
209 const phrase = subArgs.slice(1).join(' ')
210 if (!phrase) {
211 console.error('Usage: identity restore <mnemonic phrase>')
212 return
213 }
214 const result = await rpc('identity.restore', { phrase })
215 if (result.ok) {
216 console.log('[keet-cli] Identity restored!')
217 } else {
218 console.error('[keet-cli] Error:', result.error)
219 }
220 return
221 }
222 // Default: show identity
223 const result = await get('/rpc/identity')
224 if (result.ok && result.data) {
225 console.log('[keet-cli] Current identity:')
226 console.log(JSON.stringify(result.data, null, 2))
227 } else {
228 console.log('[keet-cli] No identity found.')
229 }
230}
231
232async function clientRooms () {
233 const result = await get('/rpc/rooms')
234 if (!result.ok) {
235 console.error('[keet-cli] Error:', result.error)
236 return
237 }
238 const rooms = result.data
239 if (!rooms || rooms.length === 0) {
240 console.log('[keet-cli] No rooms. Use "join <key>" or "create <title>"')
241 return
242 }
243 console.log(`[keet-cli] ${rooms.length} room(s):`)
244 for (const room of rooms) {
245 console.log(` ${room.roomId} ${room.title}`)
246 }
247}
248
249async function clientRoom (roomId) {
250 if (!roomId) {
251 console.error('Usage: room <roomId>')
252 return
253 }
254 const result = await rpc('room', { roomId })
255 if (result.ok) {
256 console.log('[keet-cli] Room info:')
257 console.log(JSON.stringify(result.data, null, 2))
258 } else {
259 console.error('[keet-cli] Error:', result.error)
260 }
261}
262
263async function clientJoin (invite) {
264 if (!invite) {
265 console.error('Usage: join <inviteLink or rawToken>')
266 return
267 }
268 console.log('[keet-cli] Joining room...')
269 const result = await rpc('room.join', { invite })
270 if (result.ok) {
271 console.log('[keet-cli] Room joined!', result.data.status || '')
272 console.log(JSON.stringify(result.data, null, 2))
273 } else {
274 console.error('[keet-cli] Error:', result.error)
275 }
276}
277
278async function clientCreate (title) {
279 if (!title) {
280 console.error('Usage: create <room title>')
281 return
282 }
283 const result = await rpc('room.create', { title })
284 if (result.ok) {
285 console.log('[keet-cli] Room created!')
286 console.log(JSON.stringify(result.data, null, 2))
287 } else {
288 console.error('[keet-cli] Error:', result.error)
289 }
290}
291
292async function clientInvite (roomId) {
293 if (!roomId) {
294 console.error('Usage: invite <roomId>')
295 return
296 }
297 const result = await rpc('room.invite', { roomId })
298 if (result.ok) {
299 console.log('[keet-cli] Invitation created:')
300 if (result.data.token) {
301 console.log(' Token:', result.data.token)
302 console.log(' Join with: keet-cli join', result.data.token)
303 } else {
304 console.log(JSON.stringify(result.data, null, 2))
305 }
306 } else {
307 console.error('[keet-cli] Error:', result.error)
308 }
309}
310
311async function clientSend (roomId, text) {
312 if (!roomId || !text) {
313 console.error('Usage: send <roomId> <message text>')
314 return
315 }
316 const result = await rpc('send', { roomId, text })
317 if (result.ok) {
318 console.log('[keet-cli] Message sent!')
319 } else {
320 console.error('[keet-cli] Error:', result.error)
321 }
322}
323
324async function clientRead (roomId, count) {
325 if (!roomId) {
326 console.error('Usage: read <roomId> [count]')
327 return
328 }
329 const result = await get(`/rpc/messages/${encodeURIComponent(roomId)}?limit=${count}`)
330 if (!result.ok) {
331 console.error('[keet-cli] Error:', result.error)
332 return
333 }
334 const messages = result.data
335 if (!messages || messages.length === 0) {
336 console.log('[keet-cli] No messages yet.')
337 return
338 }
339 for (const msg of messages) {
340 const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '??:??'
341 const deleted = msg.deleted ? ' [DELETED]' : ''
342 const system = msg.system ? ` [${getGenericEventName(msg.system)}]` : ''
343 console.log(`[${time}] ${msg.memberId}: ${msg.text}${deleted}${system}`)
344 }
345}
346
347async function clientWatch (roomId) {
348 if (!roomId) {
349 console.error('Usage: watch <roomId>')
350 return
351 }
352 console.log(`[keet-cli] Watching ${roomId}... (Ctrl+C to stop)`)
353
354 const req = http.request({
355 hostname: 'localhost',
356 port: PORT,
357 path: `/rpc/watch/${encodeURIComponent(roomId)}`,
358 method: 'GET'
359 }, res => {
360 let buffer = ''
361 res.on('data', chunk => {
362 buffer += chunk.toString()
363 // Parse SSE events (separated by double newline)
364 const parts = buffer.split('\n\n')
365 buffer = parts.pop() // keep incomplete tail
366 for (const part of parts) {
367 const lines = part.split('\n')
368 let eventType = null
369 let data = null
370 for (const line of lines) {
371 if (line.startsWith('event: ')) eventType = line.slice(7)
372 if (line.startsWith('data: ')) data = line.slice(6)
373 }
374 if (eventType === 'ready') {
375 console.log('--- live ---')
376 continue
377 }
378 if (data) {
379 try {
380 const msg = JSON.parse(data)
381 const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '??:??'
382 const deleted = msg.deleted ? ' [DELETED]' : ''
383 const system = msg.system ? ` [${getGenericEventName(msg.system)}]` : ''
384 if (msg.text || msg.system) {
385 console.log(`[${time}] ${msg.memberId}: ${msg.text}${deleted}${system}`)
386 }
387 } catch {
388 console.log('[event]', data)
389 }
390 }
391 }
392 })
393 res.on('end', () => {
394 console.log('[keet-cli] Watch stream ended')
395 Bare.exit(0)
396 })
397 })
398
399 req.on('error', err => {
400 if (err.code === 'ECONNREFUSED') {
401 console.error('[keet-cli] Daemon not running. Start with: pear run --dev . daemon')
402 } else {
403 console.error('[keet-cli] Error:', err.message)
404 }
405 Bare.exit(1)
406 })
407
408 req.end()
409 await new Promise(() => {})
410}
411
412async function clientMembers (roomId) {
413 if (!roomId) {
414 console.error('Usage: members <roomId>')
415 return
416 }
417 const result = await get(`/rpc/members/${encodeURIComponent(roomId)}`)
418 if (!result.ok) {
419 console.error('[keet-cli] Error:', result.error)
420 return
421 }
422 const members = result.data
423 if (!members || members.length === 0) {
424 console.log('[keet-cli] No members found.')
425 return
426 }
427 console.log(`[keet-cli] ${members.length} member(s):`)
428 for (const m of members) {
429 const online = m.online ? ' (online)' : ''
430 console.log(` ${m.name} (${m.role})${online}`)
431 }
432}
433
434async function clientLeave (roomId) {
435 if (!roomId) {
436 console.error('Usage: leave <roomId>')
437 return
438 }
439 const result = await rpc('room.leave', { roomId })
440 if (result.ok) {
441 console.log('[keet-cli] Left room.')
442 } else {
443 console.error('[keet-cli] Error:', result.error)
444 }
445}
446
447async function clientStatus () {
448 const result = await get('/rpc/status')
449 if (result.ok) {
450 console.log('[keet-cli] Daemon status:')
451 console.log(` Uptime: ${result.data.uptime}s`)
452 console.log(` Rooms: ${result.data.rooms}`)
453 if (result.data.identity) {
454 console.log(` Identity: ${result.data.identity.name || result.data.identity.memberId}`)
455 }
456 } else {
457 console.error('[keet-cli] Error:', result.error)
458 }
459}
460
461// ============================================================
462// HTTP helpers
463// ============================================================
464
465function request (method, urlPath, body) {
466 return new Promise((resolve, reject) => {
467 const headers = {}
468 let payload = null
469 if (body) {
470 payload = JSON.stringify(body)
471 headers['Content-Type'] = 'application/json'
472 headers['Content-Length'] = Buffer.byteLength(payload)
473 }
474
475 const req = http.request({
476 hostname: 'localhost',
477 port: PORT,
478 path: urlPath,
479 method,
480 headers
481 }, res => {
482 let data = ''
483 res.on('data', chunk => { data += chunk.toString() })
484 res.on('end', () => {
485 try {
486 resolve(JSON.parse(data))
487 } catch {
488 resolve({ ok: false, error: 'Invalid JSON response', raw: data })
489 }
490 })
491 })
492
493 req.on('error', reject)
494 if (payload) req.write(payload)
495 req.end()
496 })
497}
498
499function get (urlPath) {
500 return request('GET', urlPath)
501}
502
503function rpc (method, params) {
504 return request('POST', '/rpc', { method, params: params || {} })
505}
lib/format.js +38 -0
……@@ -0,0 +1,38 @@
1export function getGenericEventName (code) {
2 const events = {
3 1: 'MEMBER_JOINED',
4 2: 'CALL_STARTED',
5 3: 'AVATAR_CHANGED',
6 4: 'INVITATION_CREATED',
7 5: 'MEMBER_LEFT',
8 6: 'TITLE_CHANGED',
9 7: 'MEMBER_REMOVED',
10 8: 'CALL_HANGUP',
11 9: 'CALL_MISSED'
12 }
13 return events[code] || `EVENT_${code}`
14}
15
16export function formatMessage (msg) {
17 const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '??:??'
18 const sender = msg.memberId || 'unknown'
19 const text = msg.chat?.text || msg.text || msg.message?.text || ''
20 const deleted = msg.deleted ? ' [DELETED]' : ''
21 const isSystem = msg.message?.generic ? ` [${getGenericEventName(msg.message.generic)}]` : ''
22 if (!text && !isSystem) return null
23 return `[${time}] ${sender}: ${text}${deleted}${isSystem}`
24}
25
26export function formatChatEvent (event) {
27 if (!event) return null
28 if (event.chat) {
29 const time = event.timestamp ? new Date(event.timestamp).toLocaleTimeString() : '??:??'
30 const sender = event.memberId || 'unknown'
31 const text = event.chat.text || ''
32 const deleted = event.deleted ? ' [DELETED]' : ''
33 return `[${time}] ${sender}: ${text}${deleted}`
34 } else if (event.message?.generic) {
35 return `[system] ${getGenericEventName(event.message.generic)}`
36 }
37 return `[event] ${JSON.stringify(event)}`
38}
lib/http-transport.js +191 -0
……@@ -0,0 +1,191 @@
1import http from 'bare-http1'
2
3export class HttpTransport {
4 constructor (router, opts = {}) {
5 this.router = router
6 this.port = opts.port || 3117
7 this.server = null
8 }
9
10 start () {
11 return new Promise((resolve, reject) => {
12 this.server = http.createServer((req, res) => this._onRequest(req, res))
13 this.server.on('error', reject)
14 this.server.listen(this.port, () => {
15 console.log(`[keet-daemon] HTTP listening on :${this.port}`)
16 resolve()
17 })
18 })
19 }
20
21 stop () {
22 if (this.server) this.server.close()
23 }
24
25 async _onRequest (req, res) {
26 res.setHeader('Access-Control-Allow-Origin', '*')
27 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
28 res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
29
30 if (req.method === 'OPTIONS') {
31 res.writeHead(204)
32 res.end()
33 return
34 }
35
36 const url = req.url
37 const [path, queryStr] = url.split('?', 2)
38
39 try {
40 // SSE: GET /rpc/watch/:roomId
41 if (req.method === 'GET' && path.startsWith('/rpc/watch/')) {
42 const roomId = decodeURIComponent(path.slice('/rpc/watch/'.length))
43 return this._handleSSE(req, res, roomId)
44 }
45
46 // Convenience GETs
47 if (req.method === 'GET' && path === '/rpc/rooms') {
48 return this._json(res, await this.router.handle('rooms'))
49 }
50
51 if (req.method === 'GET' && path === '/rpc/status') {
52 return this._json(res, await this.router.handle('status'))
53 }
54
55 if (req.method === 'GET' && path === '/rpc/identity') {
56 return this._json(res, await this.router.handle('identity'))
57 }
58
59 if (req.method === 'GET' && path.startsWith('/rpc/messages/')) {
60 const roomId = decodeURIComponent(path.slice('/rpc/messages/'.length))
61 const query = parseQuery(queryStr)
62 const limit = parseInt(query.limit) || 20
63 return this._json(res, await this.router.handle('read', { roomId, limit }))
64 }
65
66 if (req.method === 'GET' && path.startsWith('/rpc/members/')) {
67 const roomId = decodeURIComponent(path.slice('/rpc/members/'.length))
68 return this._json(res, await this.router.handle('members', { roomId }))
69 }
70
71 // Generic RPC: POST /rpc
72 if (req.method === 'POST' && path === '/rpc') {
73 const body = await readBody(req)
74 let parsed
75 try {
76 parsed = JSON.parse(body)
77 } catch {
78 return this._json(res, { ok: false, error: 'Invalid JSON' }, 400)
79 }
80
81 const { method, params } = parsed
82 if (!method) {
83 return this._json(res, { ok: false, error: 'Missing "method" in request body' }, 400)
84 }
85
86 const result = await this.router.handle(method, params || {})
87 return this._json(res, result)
88 }
89
90 // 404
91 this._json(res, { ok: false, error: 'Not found' }, 404)
92 } catch (err) {
93 this._json(res, { ok: false, error: err.message || String(err) }, 500)
94 }
95 }
96
97 async _handleSSE (req, res, roomId) {
98 res.writeHead(200, {
99 'Content-Type': 'text/event-stream',
100 'Cache-Control': 'no-cache',
101 Connection: 'keep-alive'
102 })
103
104 // Send recent messages first
105 const recent = await this.router.handle('read', { roomId, limit: 10 })
106 if (recent.ok && recent.data) {
107 for (const msg of recent.data) {
108 res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`)
109 }
110 }
111 res.write('event: ready\ndata: {}\n\n')
112
113 // Subscribe to live messages
114 const stream = this.router.subscribe(roomId)
115
116 if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
117 ;(async () => {
118 try {
119 for await (const event of stream) {
120 if (!event) continue
121 const msg = normalizeEvent(event)
122 if (msg) res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`)
123 }
124 } catch (err) {
125 res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`)
126 }
127 res.end()
128 })()
129 } else if (stream && typeof stream.on === 'function') {
130 stream.on('data', event => {
131 if (!event) return
132 const msg = normalizeEvent(event)
133 if (msg) res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`)
134 })
135 stream.on('error', err => {
136 res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`)
137 })
138 stream.on('close', () => res.end())
139 }
140
141 // Cleanup on client disconnect
142 res.on('close', () => {
143 if (stream && typeof stream.destroy === 'function') stream.destroy()
144 })
145 }
146
147 _json (res, data, status) {
148 const body = JSON.stringify(data)
149 res.writeHead(status || 200, {
150 'Content-Type': 'application/json',
151 'Content-Length': Buffer.byteLength(body)
152 })
153 res.end(body)
154 }
155}
156
157function normalizeEvent (event) {
158 const text = event.chat?.text || event.text || ''
159 const system = event.message?.generic || null
160 if (!text && !system) return null
161 return {
162 memberId: event.memberId || 'unknown',
163 text,
164 timestamp: event.timestamp || Date.now(),
165 deleted: event.deleted || false,
166 system
167 }
168}
169
170function readBody (req) {
171 return new Promise((resolve, reject) => {
172 let body = ''
173 req.on('data', chunk => { body += chunk.toString() })
174 req.on('end', () => resolve(body))
175 req.on('error', reject)
176 })
177}
178
179function parseQuery (str) {
180 const params = {}
181 if (!str) return params
182 for (const pair of str.split('&')) {
183 const idx = pair.indexOf('=')
184 if (idx === -1) {
185 params[decodeURIComponent(pair)] = ''
186 } else {
187 params[decodeURIComponent(pair.slice(0, idx))] = decodeURIComponent(pair.slice(idx + 1))
188 }
189 }
190 return params
191}
lib/router.js +240 -0
……@@ -0,0 +1,240 @@
1export class Router {
2 constructor (backend, core) {
3 this.backend = backend
4 this.core = core
5 this._startTime = Date.now()
6 }
7
8 async handle (method, params = {}) {
9 try {
10 const data = await this._dispatch(method, params)
11 return { ok: true, data }
12 } catch (err) {
13 return { ok: false, error: err.message || String(err) }
14 }
15 }
16
17 async _dispatch (method, params) {
18 switch (method) {
19 case 'identity': return this._identity()
20 case 'identity.recovery': return this._identityRecovery()
21 case 'identity.restore': return this._identityRestore(params)
22 case 'identity.setName': return this._identitySetName(params)
23 case 'rooms': return this._rooms()
24 case 'room': return this._room(params)
25 case 'room.create': return this._roomCreate(params)
26 case 'room.join': return this._roomJoin(params)
27 case 'room.invite': return this._roomInvite(params)
28 case 'room.leave': return this._roomLeave(params)
29 case 'send': return this._send(params)
30 case 'read': return this._read(params)
31 case 'members': return this._members(params)
32 case 'members.role': return this._changeMemberRole(params)
33 case 'dm.request': return this._dmRequest(params)
34 case 'watch': return this._watch(params)
35 case 'raw': return this._raw(params)
36 case 'status': return this._status()
37 case 'shutdown': return this._shutdown()
38 default: throw new Error(`Unknown method: ${method}`)
39 }
40 }
41
42 // --- Identity ---
43
44 async _identity () {
45 const identity = await this.backend.api.core.getIdentity()
46 return identity || null
47 }
48
49 async _identityRecovery () {
50 const phrase = await this.backend.api.core.getRecoveryPhrase()
51 return { phrase }
52 }
53
54 async _identityRestore (params) {
55 if (!params.phrase) throw new Error('Missing required param: phrase')
56 await this.backend.api.core.restoreIdentity(params.phrase)
57 return { ok: true }
58 }
59
60 async _identitySetName (params) {
61 if (!params.name) throw new Error('Missing required param: name')
62 await this.backend.api.core.updateIdentityProfile({ displayName: params.name })
63 return { ok: true, name: params.name }
64 }
65
66 // --- Rooms ---
67
68 async _rooms () {
69 const result = await this.backend.api.core.getRecentRooms()
70 const rooms = Array.isArray(result) ? result : (result?.rooms || result?.data || [])
71 return rooms.map(room => ({
72 roomId: room.roomId || room.id || room.key || '?',
73 title: room.config?.title || room.title || '(untitled)'
74 }))
75 }
76
77 async _room (params) {
78 if (!params.roomId) throw new Error('Missing required param: roomId')
79 return this.backend.api.core.getRoomInfo(params.roomId)
80 }
81
82 async _roomCreate (params) {
83 if (!params.title) throw new Error('Missing required param: title')
84 return this.backend.api.core.createRoom({ title: params.title })
85 }
86
87 async _roomJoin (params) {
88 if (!params.invite) throw new Error('Missing required param: invite')
89 const invite = params.invite
90 const isLink = invite.includes('://')
91
92 try {
93 if (isLink) {
94 const result = await this.backend.api.core.startPairingRoom(invite)
95 return { status: 'pairing', ...(result || {}) }
96 } else {
97 const result = await this.backend.api.core.pairRoom(invite)
98 return { status: 'joined', ...(result || {}) }
99 }
100 } catch (err) {
101 if (isLink) {
102 const result = await this.backend.api.core.pairRoom(invite)
103 return { status: 'joined', ...(result || {}) }
104 }
105 throw err
106 }
107 }
108
109 async _roomInvite (params) {
110 if (!params.roomId) throw new Error('Missing required param: roomId')
111 const opts = {}
112 if (params.role !== undefined) opts.role = params.role
113 const result = await this.backend.api.core.createInvitation(params.roomId, opts)
114 if (typeof result === 'string') {
115 return { token: result }
116 }
117 return result
118 }
119
120 async _roomLeave (params) {
121 if (!params.roomId) throw new Error('Missing required param: roomId')
122 await this.backend.api.core.leaveRoom(params.roomId)
123 return { ok: true }
124 }
125
126 // --- Messages ---
127
128 async _send (params) {
129 if (!params.roomId) throw new Error('Missing required param: roomId')
130 if (!params.text) throw new Error('Missing required param: text')
131 const result = await this.backend.api.core.addChatMessage(params.roomId, params.text, {})
132 return result || { ok: true }
133 }
134
135 async _read (params) {
136 if (!params.roomId) throw new Error('Missing required param: roomId')
137 const limit = params.limit || 20
138 const result = await this.backend.api.core.getChatMessages(params.roomId, {
139 limit,
140 reverse: true
141 })
142 const messages = Array.isArray(result) ? result : (result?.messages || result?.data || result?.events || [])
143 return [...(messages || [])].reverse().map(msg => ({
144 memberId: msg.memberId || 'unknown',
145 text: msg.chat?.text || msg.text || msg.message?.text || '',
146 timestamp: msg.timestamp || null,
147 deleted: msg.deleted || false,
148 system: msg.message?.generic || null
149 })).filter(m => m.text || m.system)
150 }
151
152 async _watch () {
153 // watch is handled at the transport level (SSE / socket push)
154 throw new Error('watch must be handled by transport layer')
155 }
156
157 // --- Members ---
158
159 async _changeMemberRole (params) {
160 if (!params.roomId) throw new Error('Missing required param: roomId')
161 if (!params.memberId) throw new Error('Missing required param: memberId')
162 if (params.role === undefined) throw new Error('Missing required param: role (1=admin, 2=mod, 3=peer)')
163 await this.backend.api.core.changeMemberRole(params.roomId, {
164 memberId: params.memberId,
165 role: params.role
166 })
167 return { ok: true }
168 }
169
170 async _members (params) {
171 if (!params.roomId) throw new Error('Missing required param: roomId')
172 const result = await this.backend.api.core.getMembers(params.roomId)
173 const members = Array.isArray(result) ? result : (result?.members || result?.data || [])
174 return (members || []).map(m => {
175 let role = 'peer'
176 if (m.status?.isAdmin) role = 'admin'
177 else if (m.status?.isModerator) role = 'mod'
178 else if (m.role === 1) role = 'admin'
179 else if (m.role === 2) role = 'mod'
180 else if (m.role === 4) role = 'read-only'
181 return {
182 memberId: m.memberId || m.id || '?',
183 name: m.displayName || m.name || m.memberId || '?',
184 role,
185 online: m.status?.isActive || m.online || false,
186 canDM: m.canDM || false
187 }
188 })
189 }
190
191 // --- DMs ---
192
193 async _dmRequest (params) {
194 if (!params.roomId) throw new Error('Missing required param: roomId')
195 if (!params.memberId) throw new Error('Missing required param: memberId')
196 const message = params.message || ''
197 const result = await this.backend.api.core.sendDMRequest(
198 params.roomId,
199 params.memberId,
200 { message, receivers: [params.memberId] }
201 )
202 return result || { ok: true }
203 }
204
205 // --- Raw API passthrough ---
206
207 async _raw (params) {
208 if (!params.fn) throw new Error('Missing required param: fn (e.g. "getMembers")')
209 const fn = this.backend.api.core[params.fn]
210 if (typeof fn !== 'function') throw new Error(`Not a function: api.core.${params.fn}`)
211 const args = params.args || []
212 return fn.apply(this.backend.api.core, args)
213 }
214
215 // --- Streaming ---
216
217 subscribe (roomId) {
218 return this.backend.api.core.subscribeChatMessages(roomId)
219 }
220
221 // --- System ---
222
223 async _status () {
224 const identity = await this.backend.api.core.getIdentity()
225 const rooms = await this._rooms()
226 return {
227 uptime: Math.floor((Date.now() - this._startTime) / 1000),
228 rooms: rooms.length,
229 identity: identity ? { memberId: identity.memberId, name: identity.displayName } : null
230 }
231 }
232
233 async _shutdown () {
234 setTimeout(async () => {
235 await this.core.close()
236 Bare.exit(0)
237 }, 500)
238 return { ok: true, message: 'shutting down' }
239 }
240}
package.json +38 -0
……@@ -0,0 +1,38 @@
1{
2 "name": "keet-cli",
3 "version": "0.1.0",
4 "type": "module",
5 "description": "Headless CLI client for Keet P2P chat rooms",
6 "main": "index.js",
7 "pear": {
8 "name": "keet-cli",
9 "type": "terminal"
10 },
11 "imports": {
12 "events": {
13 "bare": "bare-events",
14 "default": "events"
15 },
16 "path": {
17 "bare": "bare-path",
18 "default": "path"
19 },
20 "fs": {
21 "bare": "bare-fs",
22 "default": "fs"
23 },
24 "fs/*": {
25 "bare": "bare-fs/*",
26 "default": "fs/*"
27 },
28 "process": {
29 "bare": "bare-process",
30 "default": "process"
31 },
32 "os": {
33 "bare": "bare-os",
34 "default": "os"
35 }
36 },
37 "dependencies": {}
38}