| … | … | @@ -0,0 +1,3 @@ |
| 1 | node_modules/ | |
| 2 | /tmp/ | |
| 3 | *.log |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | import KeetCoreHyperDB from '@holepunchto/keet-core-hyperdb' | |
| 2 | import path from 'path' | |
| 3 | import os from 'os' | |
| 4 | import fs from 'fs' | |
| 5 | import { Router } from './lib/router.js' | |
| 6 | import { HttpTransport } from './lib/http-transport.js' | |
| 7 | ||
| 8 | export 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 | } |
| … | … | @@ -0,0 +1,505 @@ |
| 1 | import http from 'bare-http1' | |
| 2 | import path from 'path' | |
| 3 | import os from 'os' | |
| 4 | import { getGenericEventName } from './lib/format.js' | |
| 5 | ||
| 6 | // --- Parse Args --- | |
| 7 | const rawArgs = Pear.config.args || [] | |
| 8 | ||
| 9 | let customStorage = null | |
| 10 | let customPort = null | |
| 11 | const args = [] | |
| 12 | for (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 | ||
| 32 | const STORAGE = customStorage || path.join(os.homedir(), '.config', 'keet-cli', 'default') | |
| 33 | const PORT = customPort || 3117 | |
| 34 | const command = args[0] | |
| 35 | ||
| 36 | // --- Help --- | |
| 37 | function printHelp () { | |
| 38 | console.log(` | |
| 39 | keet-cli — Headless Keet P2P chat client (daemon + thin client) | |
| 40 | ||
| 41 | Usage: pear run --dev . [options] <command> [args...] | |
| 42 | ||
| 43 | Options: | |
| 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 | ||
| 48 | Daemon 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 | ||
| 53 | Client 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 | ||
| 70 | Quick 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 | ||
| 86 | if (!command || command === 'help') printHelp() | |
| 87 | ||
| 88 | // --- Daemon Mode --- | |
| 89 | if (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 --- | |
| 141 | try { | |
| 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 | ||
| 191 | if (command !== 'watch') Bare.exit(0) | |
| 192 | ||
| 193 | // ============================================================ | |
| 194 | // Client command handlers | |
| 195 | // ============================================================ | |
| 196 | ||
| 197 | async 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 | ||
| 232 | async 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 | ||
| 249 | async 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 | ||
| 263 | async 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 | ||
| 278 | async 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 | ||
| 292 | async 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 | ||
| 311 | async 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 | ||
| 324 | async 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 | ||
| 347 | async 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 | ||
| 412 | async 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 | ||
| 434 | async 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 | ||
| 447 | async 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 | ||
| 465 | function 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 | ||
| 499 | function get (urlPath) { | |
| 500 | return request('GET', urlPath) | |
| 501 | } | |
| 502 | ||
| 503 | function rpc (method, params) { | |
| 504 | return request('POST', '/rpc', { method, params: params || {} }) | |
| 505 | } |
| … | … | @@ -0,0 +1,38 @@ |
| 1 | export 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 | ||
| 16 | export 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 | ||
| 26 | export 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 | } |
| … | … | @@ -0,0 +1,191 @@ |
| 1 | import http from 'bare-http1' | |
| 2 | ||
| 3 | export 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 | ||
| 157 | function 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 | ||
| 170 | function 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 | ||
| 179 | function 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 | } |
| … | … | @@ -0,0 +1,240 @@ |
| 1 | export 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 | } |
| … | … | @@ -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 | } |