#!/usr/bin/env node import { Command, InvalidArgumentError } from 'commander'; import { multiaddr } from '@multiformats/multiaddr'; import debug from 'debug'; import { getNetworkManager, createLibp2pNode, MemoryRawStorage, MemoryKVStore, RepoClient, stateHoldingsOnReads, ArachnodeFretAdapter, type IRawStorage, type IKVStore } from '@optimystic/db-p2p'; import { FileRawStorage, FileKVStore } from '@optimystic/db-p2p-storage-fs'; import { Diary, NetworkTransactor, BTree, registerDebugModule, type ITransactor, type BlockGets, type GetBlockResults, type ActionBlocks, type BlockActionStatus, type PendRequest, type PendResult, type CommitRequest, type CommitResult } from '@optimystic/db-core'; import * as readline from 'readline'; import * as path from 'path'; import * as fs from 'fs'; // So `enableOptimysticLogging` reaches this package's copy of `debug`, which may be no one else's. registerDebugModule('ref-peer', debug); const logDebug = debug('optimystic:ref-peer'); // Simple local transactor for single-node scenarios class LocalTransactor implements ITransactor { constructor(private storageRepo: any) { } async get(blockGets: BlockGets): Promise { return await this.storageRepo.get(blockGets); } async getStatus(_actionRefs: ActionBlocks[]): Promise { throw new Error("Method not implemented."); } async pend(blockAction: PendRequest): Promise { return await this.storageRepo.pend(blockAction); } async commit(request: CommitRequest): Promise { return await this.storageRepo.commit(request); } async cancel(actionRef: ActionBlocks): Promise { return await this.storageRepo.cancel(actionRef); } } interface NetworkSession { node: any; stores: NodeStores; transactor: NetworkTransactor | LocalTransactor; diaries: Map>; trees: Map>; isConnected: boolean; isSingleNode: boolean; // Track if we're in single-node mode } /** The stores handed to the node, kept because the node never closes a store it was handed (`RawStorageProvider`). */ interface NodeStores { storage: IRawStorage; kvStore: IKVStore; /** For file storage, checkpoints the shared write-ahead log and removes the directory's lock. */ close(): Promise; } /** * The block store, and the node-local state that must survive a restart (the under-replication * ledger). File storage puts both over one base path, where they share one write-ahead log and one * lock: FileRawStorage.listBlockIds yields only directories that hold a meta.json, so the ledger's * `under-replicated/` directory is never read as a block. Memory storage gets a memory key-value * store explicitly โ€” the node's own fallback would warn about losing state across a restart, which a * memory-storage peer loses anyway. */ function openStores(options: { storage?: 'memory' | 'file'; storagePath?: string }): NodeStores { if (options.storage !== 'file') { return { storage: new MemoryRawStorage(), kvStore: new MemoryKVStore(), close: async () => { } }; } const storage = new FileRawStorage(options.storagePath!); const kvStore = new FileKVStore(options.storagePath!); return { storage, kvStore, close: async () => { try { await kvStore.close(); } finally { await storage.close(); } }, }; } class PeerSession { private session: NetworkSession | null = null; private rl: readline.Interface | null = null; private parseStorageCapacity(options: { storageCapacity?: string }): number | undefined { if (!options.storageCapacity) return undefined; const parsed = Number(options.storageCapacity); if (!Number.isFinite(parsed) || parsed <= 0) { throw new Error('--storage-capacity must be a positive number of bytes'); } return parsed; } private parseClusterSize(options: { clusterSize?: string }): number | undefined { if (options.clusterSize === undefined) return undefined; const parsed = Number(options.clusterSize); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error('--cluster-size must be a positive integer'); } return parsed; } private parseAssumedClusterSize(options: { assumedClusterSize?: string }): number | undefined { if (options.assumedClusterSize === undefined) return undefined; const parsed = Number(options.assumedClusterSize); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error('--assumed-cluster-size must be a positive integer'); } return parsed; } private parseRepairCorroborationClusterSize(options: { repairCorroborationClusterSize?: string }): number | undefined { if (options.repairCorroborationClusterSize === undefined) return undefined; const parsed = Number(options.repairCorroborationClusterSize); if (!Number.isInteger(parsed) || parsed <= 0) { throw new Error('--repair-corroboration-cluster-size must be a positive integer'); } return parsed; } private parseSuperMajorityThreshold(options: { superMajorityThreshold?: string }): number | undefined { if (options.superMajorityThreshold === undefined) return undefined; const parsed = Number(options.superMajorityThreshold); if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) { throw new Error('--super-majority-threshold must be a number in (0, 1]'); } return parsed; } private async waitForFretReady(node: any): Promise { try { const fret = (node as any).services?.fret; if (!fret) { console.log('โš ๏ธ FRET service not available'); logDebug('FRET service missing on node'); return; } const hadConn = node.getConnections().length > 0; if (!hadConn) { console.log('๐Ÿ”— No connections yet, skipping FRET warm-up'); logDebug('skipping FRET ready check - no connections'); return; } if (typeof fret.ready !== 'function') { console.log('โš ๏ธ FRET service does not expose ready()'); logDebug('FRET ready() not present, skipping wait'); return; } logDebug('waiting for FRET ready'); await fret.ready(); console.log('โœ… FRET service ready'); // Log FRET diagnostics const knownPeers = typeof fret.listPeers === 'function' ? fret.listPeers() : []; const netSize = typeof fret.getNetworkSizeEstimate === 'function' ? fret.getNetworkSizeEstimate() : undefined; logDebug('FRET diagnostics after ready', { knownPeers: knownPeers.map((p: any) => p.id ?? p.toString()), knownPeerCount: knownPeers.length, networkSizeEstimate: netSize }); console.log(`๐Ÿ“Š FRET knows ${knownPeers.length} peer(s)`); } catch (error) { console.warn('โš ๏ธ FRET readiness check issue:', error); logDebug('FRET readiness check issue', error); } } private async waitForFretConvergence(node: any, minPeers: number, timeoutMs: number): Promise { const fret = (node as any).services?.fret; if (!fret || typeof fret.listPeers !== 'function') { logDebug('FRET convergence check skipped - no listPeers method'); return false; } const start = Date.now(); let lastCount = 0; while (Date.now() - start < timeoutMs) { const peers = fret.listPeers(); const count = Array.isArray(peers) ? peers.length : 0; if (count !== lastCount) { logDebug('FRET convergence progress', { peerCount: count, target: minPeers, peers: peers.map((p: any) => p.id) }); lastCount = count; } if (count >= minPeers) { console.log(`โœ… FRET discovered ${count} peer(s)`); return true; } await new Promise(r => setTimeout(r, 200)); } const finalPeers = fret.listPeers(); const finalCount = Array.isArray(finalPeers) ? finalPeers.length : 0; console.log(`โœ… FRET discovered ${finalCount} peer(s)`); logDebug('FRET convergence completed', { finalPeerCount: finalCount, targetPeers: minPeers, elapsed: timeoutMs, peers: finalPeers.map((p: any) => p.id) }); // Return true if we have at least some peers, even if not the target return finalCount > 0; } private logRingInfo(node: any, totalCapacityOverride?: number): void { const fret = (node as any).services?.fret; if (!fret) { logDebug('ring info unavailable (no FRET service)'); return; } const adapter = new ArachnodeFretAdapter(fret, node.peerId?.toString()); const myInfo = adapter.getMyArachnodeInfo(); if (myInfo) { logDebug('local arachnode info', myInfo); } else { logDebug('no arachnode info published yet'); } const rings = adapter.getKnownRings(); const stats = adapter.getRingStats(); logDebug('discovered ring depths', rings); if (stats.length) { logDebug('ring statistics', stats); } if (totalCapacityOverride) { logDebug('storage capacity override active', { bytes: totalCapacityOverride }); } } private async ensureDiary(name: string): Promise> { const session = this.requireSession(); const existing = session.diaries.get(name); if (existing) return existing; // Diary.createOrOpen uses Collection.createOrOpen under the hood, so this will // attach to an existing diary in the network or create it if missing. const diary = await Diary.createOrOpen(session.transactor, name); session.diaries.set(name, diary); return diary; } async startNetwork(options: { port?: string; wsPort?: string; wsHost?: string; tcp?: boolean; bootstrap?: string; bootstrapFile?: string; id?: string; relay?: boolean; network?: string; fretProfile?: 'edge' | 'core'; storage?: 'memory' | 'file'; storagePath?: string; storageCapacity?: string; clusterSize?: string; assumedClusterSize?: string; repairCorroborationClusterSize?: string; superMajorityThreshold?: string; announceFile?: string; announceAddr?: string[]; appendAnnounceAddr?: string[]; offline?: boolean; }): Promise { if (this.session?.isConnected) { console.log('โš ๏ธ Already connected to network'); return; } console.log('๐Ÿš€ Starting P2P node...'); const resolveEffectiveRelay = (): boolean => { if (options.relay === false) return false; const wsPortRaw = options.wsPort; const wsPortConfigured = wsPortRaw !== undefined && wsPortRaw !== ''; const tcpEnabled = options.tcp !== false; const hasInboundListen = tcpEnabled || wsPortConfigured; return hasInboundListen; }; const effectiveRelay = resolveEffectiveRelay(); logDebug('resolved relay default', { explicit: options.relay, tcp: options.tcp !== false, wsPort: options.wsPort, offline: options.offline, effectiveRelay }); // Validate storage options if (options.storage === 'file' && !options.storagePath) { throw new Error('--storage-path is required when using file storage'); } // Create storage directory if needed if (options.storage === 'file' && options.storagePath) { fs.mkdirSync(options.storagePath, { recursive: true }); } // Resolve bootstrap nodes from CLI args and/or file let bootstrapNodes: string[] = options.bootstrap ? options.bootstrap.split(',') : []; if (options.bootstrapFile) { try { const filePath = path.resolve(options.bootstrapFile); const stat = fs.statSync(filePath); // If it's a directory, wait for mesh-ready.json and use it if (stat.isDirectory()) { const readyFile = path.join(filePath, 'mesh-ready.json'); // Wait for mesh-ready.json (up to 30 seconds) console.log('โณ Waiting for mesh to be ready...'); const waitStart = Date.now(); while (!fs.existsSync(readyFile) && Date.now() - waitStart < 30000) { await new Promise(r => setTimeout(r, 100)); } if (fs.existsSync(readyFile)) { // Use mesh-ready.json which has current node info const readyContents = fs.readFileSync(readyFile, 'utf-8'); const readyJson = JSON.parse(readyContents) as { ready: boolean; nodes: { peerId: string; multiaddrs: string[] }[] }; logDebug('loading bootstrap from mesh-ready', { nodeCount: readyJson.nodes.length }); for (const node of readyJson.nodes) { if (Array.isArray(node.multiaddrs) && node.multiaddrs.length > 0) { // Prefer localhost for same-machine testing const localAddr = node.multiaddrs.find(a => a.includes('/ip4/127.0.0.1/')); bootstrapNodes.push(localAddr ?? node.multiaddrs[0]!); logDebug('loaded bootstrap from mesh-ready', { peerId: node.peerId, addr: localAddr ?? node.multiaddrs[0] }); } } console.log(`๐Ÿ“‹ Loaded ${bootstrapNodes.length} bootstrap address(es) from mesh-ready.json`); } else { console.warn('โš ๏ธ Timeout waiting for mesh-ready.json, falling back to node-*.json files'); // Fallback to old behavior const files = fs.readdirSync(filePath).filter(f => f.startsWith('node-') && f.endsWith('.json')); logDebug('loading bootstrap from directory (fallback)', { dir: filePath, files }); for (const file of files) { const fullPath = path.join(filePath, file); try { const contents = fs.readFileSync(fullPath, 'utf-8'); const json = JSON.parse(contents) as { multiaddrs?: string[]; peerId?: string }; if (Array.isArray(json.multiaddrs) && json.multiaddrs.length > 0) { const localAddr = json.multiaddrs.find(a => a.includes('/ip4/127.0.0.1/')); bootstrapNodes.push(localAddr ?? json.multiaddrs[0]!); logDebug('loaded bootstrap from file', { file, peerId: json.peerId, addr: localAddr ?? json.multiaddrs[0] }); } } catch (err) { logDebug('failed to read bootstrap file', { file: fullPath, error: (err as Error).message }); } } console.log(`๐Ÿ“‹ Loaded ${bootstrapNodes.length} bootstrap address(es) from ${filePath}`); } } else { // Single file - existing logic const contents = fs.readFileSync(filePath, 'utf-8'); const json = JSON.parse(contents) as { multiaddrs?: string[] } | { nodes?: { multiaddrs: string[] }[] }; if (Array.isArray((json as any).nodes)) { for (const n of (json as any).nodes) { if (Array.isArray(n.multiaddrs)) bootstrapNodes.push(...n.multiaddrs); } } else if (Array.isArray((json as any).multiaddrs)) { bootstrapNodes.push(...(json as any).multiaddrs); } } } catch (err) { console.error('โŒ Failed to read bootstrap file:', (err as Error).message); } } const storageCapacityBytes = this.parseStorageCapacity(options); if (storageCapacityBytes) { const humanReadable = storageCapacityBytes >= 1024 * 1024 * 1024 ? `${(storageCapacityBytes / (1024 * 1024 * 1024)).toFixed(2)} GB` : `${storageCapacityBytes} bytes`; console.log(`๐Ÿ“ฆ Storage capacity override set to ${humanReadable}`); logDebug('storage capacity override set', { bytes: storageCapacityBytes, human: humanReadable }); } const clusterSize = this.parseClusterSize(options); if (clusterSize !== undefined) { console.log(`๐Ÿ‘ฅ Cluster size override set to ${clusterSize}`); logDebug('cluster size override set', { clusterSize }); } const assumedClusterSize = this.parseAssumedClusterSize(options); if (assumedClusterSize !== undefined) { console.log(`๐Ÿ›ก๏ธ Assumed cluster size set to ${assumedClusterSize.toString()}`); logDebug('assumed cluster size override set', { assumedClusterSize }); } const repairCorroborationClusterSize = this.parseRepairCorroborationClusterSize(options); if (repairCorroborationClusterSize !== undefined) { console.log(`\u{1F527} Repair corroboration cluster size set to ${repairCorroborationClusterSize.toString()}`); logDebug('repair corroboration cluster size override set', { repairCorroborationClusterSize }); } const superMajorityThreshold = this.parseSuperMajorityThreshold(options); if (superMajorityThreshold !== undefined) { console.log(`๐ŸŽฏ Super-majority threshold set to ${superMajorityThreshold.toString()}`); logDebug('super-majority threshold override set', { superMajorityThreshold }); } logDebug('starting libp2p node', { port: options.port, bootstrapCount: bootstrapNodes.length, storage: options.storage, storagePath: options.storagePath, storageCapacityBytes, clusterSize, assumedClusterSize, repairCorroborationClusterSize, superMajorityThreshold, mode: options.offline ? 'offline' : 'distributed' }); const wsPortNum = options.wsPort !== undefined ? parseInt(options.wsPort, 10) : undefined; if (wsPortNum !== undefined && (!Number.isFinite(wsPortNum) || wsPortNum < 0)) { throw new Error('--ws-port must be a non-negative integer'); } const disableTcp = options.tcp === false; console.log(`๐Ÿ” Circuit-relay server: ${effectiveRelay ? 'on' : 'off'}`); if (effectiveRelay) { console.log('๐Ÿ” Circuit-relay limits: disabled (reference-peer trusted)'); } const stores = openStores(options); const node = await createLibp2pNode({ port: parseInt(options.port || '0'), wsPort: wsPortNum, wsHost: options.wsHost, disableTcp, bootstrapNodes, id: options.id, relay: effectiveRelay, // Reference-peer nodes are trusted local-cluster relays โ€” lift the // upstream 128 KiB / 2 min per-relayed-connection caps so serviceโ†”browser // circuits survive a full e2e run. See db-p2p NodeOptions.relayServerInit. relayServerInit: effectiveRelay ? { reservations: { applyDefaultLimit: false } } : undefined, announceAddrs: options.announceAddr, appendAnnounceAddrs: options.appendAnnounceAddr, networkName: options.network || 'optimystic', fretProfile: options.fretProfile, storage: stores.storage, kvStore: stores.kvStore, clusterSize, clusterPolicy: superMajorityThreshold !== undefined || assumedClusterSize !== undefined || repairCorroborationClusterSize !== undefined ? { superMajorityThreshold, assumedClusterSize, repairCorroborationClusterSize } : undefined, arachnode: { enableRingZulu: true, storage: storageCapacityBytes ? { totalBytes: storageCapacityBytes } : undefined } }).catch(async (err: unknown) => { // The factory stops a node whose wiring failed, but the stores are ours to close. await stores.close().catch(closeErr => logDebug('closing the stores after a failed start', closeErr)); throw err; }); console.log(`โœ… Node started with ID: ${node.peerId.toString()}`); console.log(`๐Ÿ“ก Listening on:`); const addrs: string[] = []; node.getMultiaddrs().forEach((ma: any) => { const s = ma.toString(); addrs.push(s); console.log(` ${s}`); }); // LocalTransactor shares the node's own StorageRepo so offline writes are // visible to the running node (and to any later distributed reads). These come // off the node's typed attachment surface (OptimysticNodeAttachments), so no // presence guard is needed โ€” createLibp2pNode always attaches them. const storageRepo = node.storageRepo; // The node's OWN key network. Do NOT construct a second one: it would resolve a // different cohort width than this node's consensus path and would not carry the // network-membership filter, so peers of a DIFFERENT Optimystic network sharing // these bootstraps would be legal coordinator/cohort candidates. const keyNetwork = node.keyNetwork; // The coordinated repo that includes cluster consensus const coordinatedRepo = node.coordinatedRepo; // Determine operating mode const isOffline = Boolean(options.offline); console.log(`๐Ÿ”ง Mode: ${isOffline ? 'Offline (LocalTransactor)' : 'Distributed (NetworkTransactor)'}`); // Create appropriate transactor based on mode let transactor: NetworkTransactor | LocalTransactor; if (isOffline) { transactor = new LocalTransactor(storageRepo); } else { transactor = new NetworkTransactor({ // The node's own derived budget (30s on an undeclared link), so it grows with the dial // deadline below and a dead coordinator can still be re-picked over slow relayed links. timeoutMs: node.linkDeadlines.transactionTimeoutMs, // A cancel reaches the coordinator through one dial, so it is never given less than one. abortOrCancelTimeoutMs: Math.max(10000, node.linkDeadlines.dialTimeoutMs), // Per-peer dial cap: an unreachable cluster member fails fast // (3s on an undeclared link) so the consensus retry loop can re-pick // within the overall budget instead of one stuck peer monopolizing // it. The node's own derived value, so it follows its link declaration. dialTimeoutMs: node.linkDeadlines.dialTimeoutMs, // A retry waits one declared round trip, doubling, so it does not queue behind the // request it replaces; no wait on an undeclared link. retryBackoffBaseMs: node.linkDeadlines.retryBackoffBaseMs, keyNetwork, getRepo: (peerId) => { return peerId.toString() === node.peerId.toString() ? coordinatedRepo // Use coordinated repo for self to enable cluster consensus // A remote read states what this node holds, so a coordinator need not ask it back. : stateHoldingsOnReads(RepoClient.create(peerId, keyNetwork, `/optimystic/${options.network || 'optimystic'}`), storageRepo); }, // Self is served by the co-located repo above, so a write this node is responsible for // is coordinated here rather than sent to an equally responsible neighbour. localPeerId: node.peerId }); } this.session = { node, stores, transactor, diaries: new Map(), trees: new Map(), isConnected: true, isSingleNode: isOffline }; console.log('โœ… Distributed transaction system initialized'); logDebug('session initialized', { peerId: node.peerId.toString(), offline: isOffline, bootstrap: bootstrapNodes }); // Network manager readiness (service-based) const nm = getNetworkManager(node); await nm.ready(); if (bootstrapNodes.length > 0 && (nm as any).awaitHealthy) { // Require at least 2 connections when multiple bootstrap nodes available // This ensures better mesh connectivity before operations begin const minConnections = Math.min(2, bootstrapNodes.length); const ok = await (nm as any).awaitHealthy(minConnections, 10000); console.log(`๐Ÿงญ Network ${ok ? 'healthy' : 'not healthy yet'} (active connections=${ok ? `>=${minConnections}` : '<' + minConnections})`); logDebug('network manager status', { healthy: ok, status: nm.getStatus?.() }); } else { console.log('๐Ÿงญ Network ready'); logDebug('network manager ready', { status: nm.getStatus?.() }); } // Wait for FRET to be ready await this.waitForFretReady(node); this.logRingInfo(node, storageCapacityBytes); // Optionally announce node info to a file for launchers/mesh setups if (options.announceFile) { try { const info = { peerId: node.peerId.toString(), multiaddrs: addrs, port: parseInt(options.port || '0'), networkName: options.network || 'optimystic', timestamp: Date.now(), pid: process.pid }; fs.mkdirSync(path.dirname(options.announceFile), { recursive: true }); fs.writeFileSync(options.announceFile, JSON.stringify(info, null, 2), 'utf-8'); console.log(`๐Ÿ“ Announced node info to ${options.announceFile}`); } catch (err) { console.error('โŒ Failed to write announce file:', (err as Error).message); } } // Wait for network bootstrap if we have bootstrap nodes if (bootstrapNodes.length > 0) { console.log('๐Ÿ”„ Bootstrapping to network...'); await new Promise(resolve => setTimeout(resolve, 2000)); console.log('โœ… Network bootstrap complete'); this.logRingInfo(node, storageCapacityBytes); // Wait for FRET to discover peers (best effort, non-blocking) // FRET neighbor announcements happen asynchronously and take time to propagate const minPeers = 1; // At least discover one peer if (bootstrapNodes.length > 0) { console.log(`๐Ÿ” Waiting for FRET to discover peers...`); await this.waitForFretConvergence(node, minPeers, 8000); } } } async stopNetwork(): Promise { if (!this.session?.isConnected) { console.log('โš ๏ธ Not connected to network'); return; } console.log('๐Ÿ›‘ Stopping network...'); await this.session.node.stop(); this.session.isConnected = false; await this.session.stores.close(); console.log('โœ… Network stopped'); } private requireSession(): NetworkSession { if (!this.session?.isConnected) { throw new Error('Not connected to network. Start network first.'); } return this.session; } async createDiary(name: string): Promise { const session = this.requireSession(); if (session.diaries.has(name)) { throw new Error(`Diary '${name}' already exists`); } console.log(`๐Ÿ“ Creating diary: ${name}`); const diary = await Diary.createOrOpen(session.transactor, name); session.diaries.set(name, diary); console.log(`โœ… Successfully created diary: ${name}`); } async addEntry(diaryName: string, content: string): Promise { const session = this.requireSession(); const diary = await this.ensureDiary(diaryName); console.log(`๐Ÿ“ Adding entry to diary ${diaryName}: ${content}`); const entry = { content, timestamp: new Date().toISOString(), author: session.node.peerId.toString() }; await diary.append(entry); console.log(`โœ… Successfully added entry to diary: ${diaryName}`); } async listDiaries(): Promise { // NOTE: lists only diaries touched in this process session, not persisted state. // No block-storage-level index of collection names exists, so enumerating persisted // diaries would need a separate registry โ€” a `feat-` ticket if ever required. const session = this.requireSession(); if (session.diaries.size === 0) { console.log('๐Ÿ“ No diaries opened this session'); return; } console.log('๐Ÿ“š Diaries opened this session (not a complete list of persisted diaries):'); for (const [name, _] of session.diaries) { console.log(` - ${name}`); } } async readDiary(diaryName: string): Promise { this.requireSession(); const diary = await this.ensureDiary(diaryName); console.log(`๐Ÿ“– Reading entries from diary: ${diaryName}`); let entryCount = 0; for await (const entry of diary.select()) { entryCount++; console.log(`๐Ÿ“„ Entry ${entryCount}:`); console.log(` Content: ${entry.content}`); console.log(` Timestamp: ${entry.timestamp}`); if (entry.author) { console.log(` Author: ${entry.author}`); } console.log(''); } if (entryCount === 0) { console.log('๐Ÿ“ No entries found in this diary'); } else { console.log(`๐Ÿ“Š Total entries: ${entryCount}`); } } async showStatus(): Promise { if (!this.session?.isConnected) { console.log('โŒ Not connected to network'); return; } const session = this.session; console.log('๐ŸŒ Network Status:'); console.log(` Node ID: ${session.node.peerId.toString()}`); console.log(` Connected: ${session.isConnected}`); console.log(` Diaries: ${session.diaries.size}`); console.log(` Trees: ${session.trees.size}`); const multiaddrs = session.node.getMultiaddrs(); console.log(` Addresses (${multiaddrs.length}):`); multiaddrs.forEach((ma: any) => { console.log(` ${ma.toString()}`); }); } async startInteractive(): Promise { this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'optimystic> ' }); console.log('\n๐ŸŽฎ Interactive mode started. Type "help" for commands, "exit" to quit.'); this.rl.prompt(); this.rl.on('line', async (line) => { const args = line.trim().split(/\s+/); const command = args[0]; try { switch (command) { case 'help': this.showHelp(); break; case 'status': await this.showStatus(); break; case 'create-diary': if (args.length < 2) { console.log('Usage: create-diary '); } else { await this.createDiary(args[1]!); } break; case 'add-entry': if (args.length < 3) { console.log('Usage: add-entry '); } else { const content = args.slice(2).join(' '); await this.addEntry(args[1]!, content); } break; case 'list-diaries': await this.listDiaries(); break; case 'read-diary': if (args.length < 2) { console.log('Usage: read-diary '); } else { await this.readDiary(args[1]!); } break; case 'exit': case 'quit': console.log('๐Ÿ‘‹ Goodbye!'); await this.stopNetwork(); process.exit(0); break; case '': // Empty line, just re-prompt break; default: console.log(`Unknown command: ${command}. Type "help" for available commands.`); break; } } catch (error) { console.error(`โŒ Error: ${error instanceof Error ? error.message : error}`); } this.rl?.prompt(); }); this.rl.on('SIGINT', async () => { console.log('\n๐Ÿ‘‹ Shutting down...'); await this.stopNetwork(); process.exit(0); }); } private showHelp(): void { console.log(` ๐ŸŽฎ Available Commands: help - Show this help message status - Show network and session status create-diary - Create a new diary collection add-entry - Add an entry to a diary list-diaries - List all created diaries read-diary - Read all entries from a diary exit/quit - Exit interactive mode `); } async cleanup(): Promise { if (this.rl) { this.rl.close(); } if (this.session && this.session.isConnected) { await this.stopNetwork(); } } } const program = new Command(); const session = new PeerSession(); // Ensure cleanup on process exit process.on('SIGINT', async () => { await session.cleanup(); process.exit(0); }); process.on('SIGTERM', async () => { await session.cleanup(); process.exit(0); }); program .name('optimystic-reference-peer') .description('Optimystic P2P Database Reference Peer') .version('0.0.1'); /** * Commander reducer for a repeatable multiaddr option: validates on the way in and appends. * * Validating here rather than letting libp2p do it matters because libp2p does NOT parse the * announce set at node construction โ€” it parses lazily inside `getAddresses()`, so a malformed * value surfaces as a raw `InvalidMultiaddrError` stack trace AFTER "Node started" (port already * bound), naming neither the flag nor the bad value. A shell that rewrites POSIX-looking arguments * makes that an easy mistake: MSYS/Git-Bash on Windows turns `/dns4/host/tcp/443/wss` into * `C:/Program Files/Git/dns4/...` before the CLI ever sees it. */ const collectMultiaddr = (flag: string) => (val: string, prev: string[]): string[] => { try { multiaddr(val); } catch (err) { throw new InvalidArgumentError(`${flag} "${val}" is not a valid multiaddr: ${(err as Error).message}`); } return prev.concat([val]); }; /** * Apply the network/storage options shared verbatim by the `interactive`, * `service`, and `run` commands. Extracted so the three stay in lockstep; * command-specific options (e.g. `run`'s --action/--diary/--content) are layered * on by each caller after this helper. Flag strings, descriptions, and defaults * are identical to the previous inline copies, so each command's parsed * `options` object is unchanged. */ function withCommonPeerOptions(cmd: Command): Command { return cmd .option('-p, --port ', 'Port to listen on', '0') .option('--ws-port ', 'WebSocket port to listen on (e.g. 9091); enables /ws listen for browser/RN peers') .option('--ws-host ', 'Interface to bind the WS listener to', '0.0.0.0') .option('--no-tcp', 'Disable the default TCP listen (browser-only bootstrap)') .option('-b, --bootstrap ', 'Comma-separated list of bootstrap nodes') .option('-i, --id ', 'Peer ID') .option('--no-relay', 'Disable circuit-relay-v2 server (default: on for peers with an inbound listen address)') .option('-n, --network ', 'Network name', 'optimystic') .option('--fret-profile ', "FRET profile: 'edge' or 'core'", 'edge') .option('-s, --storage ', 'Storage type: memory or file', 'memory') .option('--storage-path ', 'Path for file storage') .option('--storage-capacity ', 'Override storage capacity in bytes (for ring selection)') .option('--cluster-size ', 'Desired cluster size per key (positive integer)') .option('--assumed-cluster-size ', 'Smallest cohort this deployment can genuinely field. Sets both the membership admission gate\'s fallback floor (used when the node has no confident network-size estimate; defaults to 2 when unset) and the read-repair/reconcile corroboration floor (unless --repair-corroboration-cluster-size is given; falls back to --cluster-size when neither is set). A mesh smaller than --cluster-size needs this (or --repair-corroboration-cluster-size) to repair damaged blocks') .option('--repair-corroboration-cluster-size ', 'Cohort size the read-repair/reconcile corroboration floor alone is measured against (positive integer). Unlike --assumed-cluster-size this does NOT raise the membership admission gate\'s low-confidence write floor, so it tightens repair without risking refused writes. Wins over --assumed-cluster-size for the repair floor; defaults to --assumed-cluster-size, then --cluster-size') .option('--super-majority-threshold ', 'Super-majority threshold as a fraction in (0, 1] (default 0.75)') .option('--offline', 'Run as single-node LocalTransactor (no distributed consensus)') .option('--bootstrap-file ', 'Path to JSON containing bootstrap multiaddrs or node list') .option('--announce-file ', 'Write node info (peerId, multiaddrs) to this JSON file for mesh launchers') .option('--announce-addr ', 'Multiaddr to advertise INSTEAD OF the listen addrs (e.g. behind a TLS proxy: /dns4/host/tcp/443/wss). Repeatable; replaces the advertised set entirely', collectMultiaddr('--announce-addr'), [] as string[]) .option('--append-announce-addr ', 'Multiaddr to advertise IN ADDITION TO the listen addrs. Repeatable; ignored while --announce-addr is set', collectMultiaddr('--append-announce-addr'), [] as string[]); } // Interactive mode - network-first approach withCommonPeerOptions( program .command('interactive') .description('Start interactive mode (default behavior)') ) .action(async (options) => { try { await session.startNetwork(options); await session.startInteractive(); } catch (error) { if (error instanceof Error) { console.error('โŒ Error:', error.message); if (error.stack) console.error(error.stack); } else { console.error('โŒ Error:', error); } process.exit(1); } }); // Headless service node (no REPL); useful for mesh nodes in launch profiles withCommonPeerOptions( program .command('service') .description('Start a headless service node (no interactive prompt)') ) .action(async (options) => { try { await session.startNetwork(options); // Keep process alive process.stdin.resume(); } catch (error) { if (error instanceof Error) { console.error('โŒ Error:', error.message); if (error.stack) console.error(error.stack); } else { console.error('โŒ Error:', error); } process.exit(1); } }); // Single-action mode commands withCommonPeerOptions( program .command('run') .description('Connect to network, run a single action, optionally stay connected') ) .option('--stay-connected', 'Stay connected after action completes') .requiredOption('-a, --action ', 'Action to perform: create-diary, add-entry, list-diaries, read-diary') .option('--diary ', 'Diary name (required for diary operations)') .option('--content ', 'Entry content (required for add-entry)') .action(async (options) => { try { await session.startNetwork(options); // Perform the requested action switch (options.action) { case 'create-diary': if (!options.diary) { throw new Error('--diary is required for create-diary action'); } await session.createDiary(options.diary); break; case 'add-entry': if (!options.diary || !options.content) { throw new Error('--diary and --content are required for add-entry action'); } await session.addEntry(options.diary, options.content); break; case 'list-diaries': await session.listDiaries(); break; case 'read-diary': if (!options.diary) { throw new Error('--diary is required for read-diary action'); } await session.readDiary(options.diary); break; default: throw new Error(`Unknown action: ${options.action}`); } if (options.stayConnected) { console.log('๐Ÿ”„ Staying connected. Starting interactive mode...'); await session.startInteractive(); } else { console.log('โœ… Action completed. Disconnecting...'); await session.stopNetwork(); } } catch (error) { if (error instanceof Error) { console.error('โŒ Error:', error.message); if (error.stack) console.error(error.stack); } else { console.error('โŒ Error:', error); } await session.cleanup(); process.exit(1); } }); // Default to interactive mode if no command specified if (process.argv.length <= 2) { process.argv.push('interactive'); } program.parse();