index.js
1 #!/usr/bin/env node 2 /** 3 * Keet CLI - P2P Room Messaging 4 * 5 * A command-line interface for P2P chat rooms using Hyperswarm. 6 * 7 * Usage: 8 * keet-cli # Create new room 9 * keet-cli <invite> # Join existing room 10 * keet-cli --room <name> # Create/join named room 11 * 12 * Commands (in chat): 13 * /invite # Show invite code 14 * /peers # List connected peers 15 * /name <name> # Set your display name 16 * /bridge <url> # Bridge messages to HTTP endpoint 17 * /quit # Exit 18 */ 19 20 import Hyperswarm from 'hyperswarm' 21 import b4a from 'b4a' 22 import crypto from 'crypto' 23 import readline from 'readline' 24 import path from 'path' 25 import os from 'os' 26 import fs from 'fs' 27 import { NostrBridge } from './nostr-bridge.js' 28 29 // Configuration 30 const STORAGE_BASE = path.join(os.homedir(), '.keet-cli') 31 const DEFAULT_NAME = os.userInfo().username || 'anon' 32 33 class KeetRoom { 34 constructor(options = {}) { 35 this.roomName = options.roomName || null 36 this.inviteCode = options.inviteCode || null 37 this.displayName = options.displayName || DEFAULT_NAME 38 this.bridgeUrl = options.bridgeUrl || null 39 this.useNostr = options.useNostr || false 40 41 this.swarm = null 42 this.topic = null 43 this.peers = new Map() 44 this.rl = null 45 this.messageHistory = [] 46 this.nostr = null 47 48 // Message handlers 49 this.onMessage = options.onMessage || null 50 } 51 52 async start() { 53 // Determine the topic for this room 54 if (this.inviteCode) { 55 // Decode invite code to get topic 56 this.topic = Buffer.from(this.inviteCode, 'hex') 57 if (this.topic.length !== 32) { 58 throw new Error('Invalid invite code') 59 } 60 } else if (this.roomName) { 61 // Derive topic from room name 62 this.topic = crypto.createHash('sha256') 63 .update(`keet-cli:room:${this.roomName}`) 64 .digest() 65 } else { 66 // Create random topic 67 this.topic = crypto.randomBytes(32) 68 } 69 70 // Generate invite code 71 this.inviteCode = this.topic.toString('hex') 72 73 // Initialize Nostr if enabled 74 if (this.useNostr) { 75 try { 76 this.nostr = new NostrBridge({ 77 onInvite: (invite) => { 78 console.log(`\r[nostr] Room invite received: ${invite.inviteCode.slice(0, 16)}...`) 79 console.log(` Join with: keet-cli ${invite.inviteCode}`) 80 if (this.rl) this.rl.prompt(true) 81 } 82 }) 83 await this.nostr.init() 84 await this.nostr.connect() 85 86 // Publish our room invite 87 await this.nostr.publishRoomInvite(this.inviteCode, this.roomName) 88 } catch (e) { 89 console.log(`[nostr] Failed to initialize: ${e.message}`) 90 } 91 } 92 93 // Start swarm 94 await this._startSwarm() 95 96 // Print welcome 97 console.log('') 98 console.log('╔══════════════════════════════════════════════════════════╗') 99 console.log('║ KEET CLI - P2P Chat ║') 100 console.log('╚══════════════════════════════════════════════════════════╝') 101 console.log('') 102 console.log(`Room: ${this.roomName || 'Anonymous Room'}`) 103 console.log(`Your name: ${this.displayName}`) 104 console.log('') 105 console.log(`Invite code:`) 106 console.log(` ${this.inviteCode}`) 107 console.log('') 108 console.log('Commands: /invite /peers /name <n> /bridge <url> /quit') 109 console.log('─'.repeat(60)) 110 console.log('') 111 112 // Start CLI 113 this._startCLI() 114 115 return this 116 } 117 118 async _startSwarm() { 119 this.swarm = new Hyperswarm() 120 121 this.swarm.on('connection', (conn, info) => { 122 const peerId = info.publicKey.toString('hex').slice(0, 8) 123 console.log(`\r[+] Peer connected: ${peerId}`) 124 this.peers.set(peerId, { conn, info, buffer: '' }) 125 126 // Send hello 127 this._sendToPeer(conn, { 128 type: 'hello', 129 name: this.displayName 130 }) 131 132 // Handle incoming data 133 conn.on('data', (data) => { 134 this._handlePeerData(peerId, conn, data) 135 }) 136 137 conn.on('close', () => { 138 console.log(`\r[-] Peer disconnected: ${peerId}`) 139 this.peers.delete(peerId) 140 if (this.rl) this.rl.prompt(true) 141 }) 142 143 conn.on('error', (err) => { 144 // Silent - peer errors are common 145 }) 146 147 if (this.rl) this.rl.prompt(true) 148 }) 149 150 // Join the topic 151 const discovery = this.swarm.join(this.topic, { server: true, client: true }) 152 await discovery.flushed() 153 } 154 155 _handlePeerData(peerId, conn, data) { 156 const peer = this.peers.get(peerId) 157 if (!peer) return 158 159 // Buffer incoming data (messages may be split) 160 peer.buffer += data.toString() 161 162 // Process complete JSON messages (newline-delimited) 163 const lines = peer.buffer.split('\n') 164 peer.buffer = lines.pop() // Keep incomplete line in buffer 165 166 for (const line of lines) { 167 if (!line.trim()) continue 168 169 try { 170 const msg = JSON.parse(line) 171 this._handleMessage(peerId, msg) 172 } catch (e) { 173 // Ignore malformed messages 174 } 175 } 176 } 177 178 _handleMessage(peerId, msg) { 179 switch (msg.type) { 180 case 'hello': 181 const peer = this.peers.get(peerId) 182 if (peer) { 183 peer.name = msg.name 184 } 185 console.log(`\r* ${msg.name || peerId} joined`) 186 break 187 188 case 'message': 189 const time = new Date(msg.ts).toLocaleTimeString() 190 const sender = msg.name || peerId 191 console.log(`\r[${time}] <${sender}> ${msg.text}`) 192 193 // Store in history 194 this.messageHistory.push(msg) 195 196 // Bridge if configured 197 if (this.bridgeUrl) { 198 this._bridgeMessage(msg) 199 } 200 201 // Callback if set 202 if (this.onMessage) { 203 this.onMessage(msg) 204 } 205 break 206 207 case 'history-request': 208 // Send recent history to new peer 209 const conn = this.peers.get(peerId)?.conn 210 if (conn) { 211 for (const m of this.messageHistory.slice(-20)) { 212 this._sendToPeer(conn, { type: 'history', ...m }) 213 } 214 } 215 break 216 217 case 'history': 218 // Received history message 219 const htime = new Date(msg.ts).toLocaleTimeString() 220 const hsender = msg.name || 'anon' 221 console.log(`\r[${htime}] <${hsender}> ${msg.text} (history)`) 222 break 223 } 224 225 if (this.rl) this.rl.prompt(true) 226 } 227 228 _sendToPeer(conn, msg) { 229 try { 230 conn.write(JSON.stringify(msg) + '\n') 231 } catch (e) { 232 // Ignore send errors 233 } 234 } 235 236 async _bridgeMessage(msg) { 237 try { 238 const response = await fetch(this.bridgeUrl, { 239 method: 'POST', 240 headers: { 'Content-Type': 'application/json' }, 241 body: JSON.stringify({ 242 type: 'keet_message', 243 room: this.roomName || this.inviteCode?.slice(0, 16), 244 sender: msg.name, 245 text: msg.text, 246 ts: msg.ts 247 }) 248 }) 249 if (!response.ok) { 250 console.log(`\r[bridge] Failed: ${response.status}`) 251 } 252 } catch (e) { 253 console.log(`\r[bridge] Error: ${e.message}`) 254 } 255 } 256 257 broadcast(msg) { 258 const data = JSON.stringify(msg) + '\n' 259 for (const [peerId, peer] of this.peers) { 260 try { 261 peer.conn.write(data) 262 } catch (e) { 263 // Ignore 264 } 265 } 266 } 267 268 async sendMessage(text) { 269 const msg = { 270 type: 'message', 271 name: this.displayName, 272 text: text, 273 ts: Date.now() 274 } 275 276 // Store locally 277 this.messageHistory.push(msg) 278 279 // Broadcast to peers 280 this.broadcast(msg) 281 282 // Show locally 283 const time = new Date(msg.ts).toLocaleTimeString() 284 console.log(`\r[${time}] <${this.displayName}> ${text}`) 285 286 // Bridge if configured 287 if (this.bridgeUrl) { 288 this._bridgeMessage(msg) 289 } 290 } 291 292 _startCLI() { 293 this.rl = readline.createInterface({ 294 input: process.stdin, 295 output: process.stdout, 296 prompt: '> ' 297 }) 298 299 this.rl.prompt() 300 301 this.rl.on('line', async (line) => { 302 const trimmed = line.trim() 303 304 if (!trimmed) { 305 this.rl.prompt() 306 return 307 } 308 309 // Handle commands 310 if (trimmed.startsWith('/')) { 311 await this._handleCommand(trimmed) 312 } else { 313 // Send message 314 await this.sendMessage(trimmed) 315 } 316 317 this.rl.prompt() 318 }) 319 320 this.rl.on('close', async () => { 321 await this.stop() 322 process.exit(0) 323 }) 324 } 325 326 async _handleCommand(cmd) { 327 const parts = cmd.split(' ') 328 const command = parts[0].toLowerCase() 329 330 switch (command) { 331 case '/invite': 332 console.log('') 333 console.log(`Invite code:`) 334 console.log(` ${this.inviteCode}`) 335 console.log('') 336 console.log('Share this with others to join the room.') 337 console.log('They can join with: keet-cli <invite-code>') 338 console.log('') 339 break 340 341 case '/peers': 342 console.log('') 343 console.log(`Connected peers: ${this.peers.size}`) 344 for (const [id, peer] of this.peers) { 345 const name = peer.name || id 346 console.log(` - ${name} (${id})`) 347 } 348 console.log('') 349 break 350 351 case '/name': 352 if (parts[1]) { 353 this.displayName = parts.slice(1).join(' ') 354 console.log(`\rName set to: ${this.displayName}`) 355 356 // Announce to peers 357 this.broadcast({ 358 type: 'hello', 359 name: this.displayName 360 }) 361 } else { 362 console.log(`Current name: ${this.displayName}`) 363 console.log('Usage: /name <your-name>') 364 } 365 break 366 367 case '/bridge': 368 if (parts[1] === 'off') { 369 this.bridgeUrl = null 370 console.log('\rBridge disabled.') 371 } else if (parts[1]) { 372 this.bridgeUrl = parts[1] 373 console.log(`\rBridge URL set: ${this.bridgeUrl}`) 374 console.log('Messages will be forwarded to this endpoint.') 375 } else if (this.bridgeUrl) { 376 console.log(`Current bridge: ${this.bridgeUrl}`) 377 console.log('Use /bridge <url> to change, or /bridge off to disable') 378 } else { 379 console.log('No bridge configured.') 380 console.log('Usage: /bridge http://localhost:7777/event') 381 } 382 break 383 384 case '/history': 385 // Request history from peers 386 this.broadcast({ type: 'history-request' }) 387 console.log('Requested history from peers...') 388 break 389 390 case '/nostr': 391 // Share room via Nostr 392 if (!this.nostr) { 393 console.log('Initializing Nostr...') 394 try { 395 this.nostr = new NostrBridge() 396 await this.nostr.init() 397 await this.nostr.connect() 398 } catch (e) { 399 console.log(`Failed: ${e.message}`) 400 break 401 } 402 } 403 await this.nostr.publishRoomInvite(this.inviteCode, this.roomName) 404 console.log('') 405 console.log('Room invite published to Nostr!') 406 console.log(`Your npub: ${this.nostr.getNpub()}`) 407 console.log('Others can receive it by following your npub.') 408 console.log('') 409 break 410 411 case '/quit': 412 case '/exit': 413 console.log('\rGoodbye!') 414 await this.stop() 415 process.exit(0) 416 break 417 418 case '/help': 419 console.log('') 420 console.log('Commands:') 421 console.log(' /invite Show invite code for this room') 422 console.log(' /peers List connected peers') 423 console.log(' /name <name> Set your display name') 424 console.log(' /bridge <url> Forward messages to HTTP endpoint') 425 console.log(' /bridge off Disable bridge') 426 console.log(' /nostr Share room invite via Nostr (encrypted DM)') 427 console.log(' /history Request message history from peers') 428 console.log(' /quit Exit') 429 console.log('') 430 break 431 432 default: 433 console.log(`Unknown command: ${command}. Try /help`) 434 } 435 } 436 437 async stop() { 438 if (this.rl) { 439 this.rl.close() 440 } 441 if (this.swarm) { 442 await this.swarm.destroy() 443 } 444 } 445 } 446 447 // Headless mode for scripting 448 class HeadlessKeetRoom extends KeetRoom { 449 _startCLI() { 450 // Read from stdin line by line 451 const rl = readline.createInterface({ 452 input: process.stdin, 453 output: process.stdout, 454 terminal: false 455 }) 456 457 rl.on('line', async (line) => { 458 const trimmed = line.trim() 459 if (trimmed && !trimmed.startsWith('/')) { 460 await this.sendMessage(trimmed) 461 } else if (trimmed.startsWith('/')) { 462 await this._handleCommand(trimmed) 463 } 464 }) 465 466 // Keep process alive 467 process.stdin.resume() 468 } 469 } 470 471 // Daemon mode - run in background and bridge to Hypercore 472 class DaemonKeetRoom extends KeetRoom { 473 _startCLI() { 474 // No CLI - just run as daemon 475 console.log('Running in daemon mode...') 476 console.log('Messages will be bridged to:', this.bridgeUrl || '(no bridge set)') 477 console.log('Press Ctrl+C to stop.') 478 479 // Keep process alive 480 process.stdin.resume() 481 } 482 } 483 484 // CLI entry point 485 async function main() { 486 const args = process.argv.slice(2) 487 488 let inviteCode = null 489 let roomName = null 490 let displayName = DEFAULT_NAME 491 let bridgeUrl = null 492 let useNostr = false 493 let mode = 'interactive' 494 495 for (let i = 0; i < args.length; i++) { 496 const arg = args[i] 497 498 if (arg === '--room' || arg === '-r') { 499 roomName = args[++i] 500 } else if (arg === '--name' || arg === '-n') { 501 displayName = args[++i] 502 } else if (arg === '--bridge' || arg === '-b') { 503 bridgeUrl = args[++i] 504 } else if (arg === '--nostr') { 505 useNostr = true 506 } else if (arg === '--headless') { 507 mode = 'headless' 508 } else if (arg === '--daemon' || arg === '-d') { 509 mode = 'daemon' 510 } else if (arg === '--help' || arg === '-h') { 511 console.log(` 512 Keet CLI - P2P Room Messaging 513 514 Usage: 515 keet-cli Create new room 516 keet-cli <invite> Join room by invite code 517 keet-cli --room <name> Create/join named room 518 519 Options: 520 -r, --room <name> Room name (creates deterministic room) 521 -n, --name <name> Your display name (default: ${DEFAULT_NAME}) 522 -b, --bridge <url> Bridge messages to HTTP endpoint 523 --nostr Auto-publish room invite via Nostr encrypted DM 524 -d, --daemon Run as daemon (no interactive CLI) 525 --headless Run without interactive CLI (for scripting) 526 -h, --help Show this help 527 528 Commands (in chat): 529 /invite Show invite code 530 /peers List connected peers 531 /name <name> Set display name 532 /bridge <url> Set bridge endpoint 533 /bridge off Disable bridge 534 /nostr Share room via Nostr 535 /history Request history from peers 536 /quit Exit 537 538 Examples: 539 # Create a new room 540 keet-cli --name "Rick" 541 542 # Join existing room 543 keet-cli <64-char-hex-invite> --name "Rick" 544 545 # Create named room (deterministic - same name = same room) 546 keet-cli --room "sovereign-os" --name "Rick" 547 548 # Bridge messages to Hypercore daemon 549 keet-cli --room "context" --bridge http://localhost:7777/event 550 551 # Auto-publish room invite via Nostr 552 keet-cli --room "sovereign-os" --nostr --name "Rick" 553 554 # Run as daemon (background bridge) 555 keet-cli --room "context" --daemon --bridge http://localhost:7777/event 556 557 # Headless mode (for scripts) 558 echo "Hello from script" | keet-cli --room "test" --headless 559 560 Nostr Integration: 561 Room invites are encrypted and sent as DMs to yourself. 562 Your phone's Nostr client (Damus, Primal) will receive them. 563 Use /nostr in chat to manually publish, or --nostr flag to auto-publish. 564 `) 565 process.exit(0) 566 } else if (!arg.startsWith('-')) { 567 // Assume it's an invite code 568 inviteCode = arg 569 } 570 } 571 572 let RoomClass 573 switch (mode) { 574 case 'headless': 575 RoomClass = HeadlessKeetRoom 576 break 577 case 'daemon': 578 RoomClass = DaemonKeetRoom 579 break 580 default: 581 RoomClass = KeetRoom 582 } 583 584 const room = new RoomClass({ 585 inviteCode, 586 roomName, 587 displayName, 588 bridgeUrl, 589 useNostr 590 }) 591 592 process.on('SIGINT', async () => { 593 console.log('\nShutting down...') 594 await room.stop() 595 process.exit(0) 596 }) 597 598 await room.start() 599 } 600 601 main().catch(err => { 602 console.error('Error:', err.message) 603 process.exit(1) 604 })