cli.ts
1 #!/usr/bin/env node 2 3 /* 4 Simple CLI client for the ipfs-http-interface Koa server. 5 6 Commands: 7 - health 8 - topics 9 - peers <topic> 10 - publish <topic> <data> [--encoding utf8|base64] 11 - subscribe <topic> 12 13 Environment: 14 - SERVER_URL: base URL of the server (default: http://127.0.0.1:3399) 15 */ 16 17 const SERVER_URL = process.env.SERVER_URL || 'http://127.0.0.1:3399' 18 const API = `${SERVER_URL}/api` 19 20 type Encoding = 'utf8' | 'base64' 21 22 type Cmd = 23 | { name: 'health' } 24 | { name: 'topics' } 25 | { name: 'peers', topic: string } 26 | { name: 'publish', topic: string, data: string, encoding: Encoding } 27 | { name: 'subscribe', topic: string } 28 | { name: 'cat', cid: string, encoding: Encoding } 29 | { name: 'get-json', cid: string } 30 31 // Node 18+ has global fetch and Web Streams 32 async function main() { 33 const cmd = parseArgs(process.argv.slice(2)) 34 if (!cmd) { 35 printUsage() 36 process.exit(1) 37 } 38 39 try { 40 switch (cmd.name) { 41 case 'health': { 42 const res = await httpGet(`${API}/health`) 43 logJson(res) 44 break 45 } 46 case 'topics': { 47 const res = await httpGet(`${API}/pubsub/topics`) 48 logJson(res) 49 break 50 } 51 case 'peers': { 52 const res = await httpGet(`${API}/pubsub/peers/${encodeURIComponent(cmd.topic)}`) 53 logJson(res) 54 break 55 } 56 case 'publish': { 57 const res = await httpPost(`${API}/pubsub/publish`, { 58 topic: cmd.topic, 59 data: cmd.data, 60 encoding: cmd.encoding 61 }) 62 logJson(res) 63 break 64 } 65 case 'subscribe': { 66 await sseSubscribe(`${API}/pubsub/subscribe/${encodeURIComponent(cmd.topic)}`) 67 break 68 } 69 case 'cat': { 70 const res = await httpGet(`${API}/ipfs/cat/${encodeURIComponent(cmd.cid)}?encoding=${encodeURIComponent(cmd.encoding)}`) 71 logJson(res) 72 if (cmd.encoding === 'utf8') { 73 console.log(`\n# data (utf8)\n${res.data}`) 74 } 75 break 76 } 77 case 'get-json': { 78 const res = await httpGet(`${API}/ipfs/cat/${encodeURIComponent(cmd.cid)}?encoding=utf8`) 79 try { 80 const obj = JSON.parse(res.data) 81 logJson(obj) 82 } catch (e) { 83 console.error('Failed to parse JSON:', e) 84 console.log(res.data) 85 } 86 break 87 } 88 } 89 } catch (err: any) { 90 console.error('Error:', err?.message || String(err)) 91 process.exitCode = 1 92 } 93 } 94 95 function parseArgs(argv: string[]): Cmd | null { 96 const [command, ...rest] = argv 97 switch (command) { 98 case 'health': 99 return { name: 'health' } 100 case 'topics': 101 return { name: 'topics' } 102 case 'peers': { 103 const topic = rest[0] 104 if (!topic) return null 105 return { name: 'peers', topic } 106 } 107 case 'publish': { 108 const topic = rest[0] 109 const data = rest[1] 110 if (!topic || typeof data !== 'string') return null 111 let encoding: Encoding = 'utf8' 112 for (let i = 2; i < rest.length; i++) { 113 const token = rest[i] 114 if (token === '--encoding' && rest[i + 1]) { 115 const enc = rest[i + 1] 116 if (enc === 'utf8' || enc === 'base64') encoding = enc 117 i++ 118 continue 119 } 120 } 121 return { name: 'publish', topic, data, encoding } 122 } 123 case 'subscribe': { 124 const topic = rest[0] 125 if (!topic) return null 126 return { name: 'subscribe', topic } 127 } 128 } 129 return null 130 } 131 132 function printUsage() { 133 console.log(`ipfs-http-interface CLI 134 135 Usage: 136 SERVER_URL=<url> node src/cli.ts <command> [...args] 137 138 Commands: 139 health 140 Check server health and IPFS connectivity. 141 142 topics 143 List subscribed pubsub topics on the node. 144 145 peers <topic> 146 List peers for a topic. 147 148 publish <topic> <data> [--encoding utf8|base64] 149 Publish a message to a topic. Data defaults to utf8. Use --encoding base64 to send base64 data. 150 151 subscribe <topic> 152 Subscribe to a topic and print incoming messages as events. 153 154 cat <cid> [--encoding utf8|base64] 155 Fetch IPFS content by CID via server. Returns JSON { cid, data, encoding }. If utf8, prints utf8 data preview. 156 157 get-json <cid> 158 Fetch IPFS content by CID as utf8 JSON and pretty-print it. 159 160 Environment: 161 SERVER_URL Base server URL (default: http://127.0.0.1:3399) 162 `) 163 } 164 165 async function httpGet(url: string) { 166 const res = await fetch(url) 167 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) 168 return res.json() 169 } 170 171 async function httpPost(url: string, body: any) { 172 const res = await fetch(url, { 173 method: 'POST', 174 headers: { 'Content-Type': 'application/json' }, 175 body: JSON.stringify(body) 176 }) 177 if (!res.ok) { 178 let msg = `${res.status} ${res.statusText}` 179 try { msg += `: ${JSON.stringify(await res.json())}` } catch {} 180 throw new Error(msg) 181 } 182 return res.json() 183 } 184 185 function logJson(obj: any) { 186 console.log(JSON.stringify(obj, null, 2)) 187 } 188 189 async function sseSubscribe(url: string) { 190 const res = await fetch(url, { headers: { Accept: 'text/event-stream' } }) 191 if (!res.ok || !res.body) throw new Error(`SSE connect failed: ${res.status} ${res.statusText}`) 192 193 // ReadableStream reader 194 const reader = res.body.getReader() 195 const decoder = new TextDecoder('utf-8') 196 let buffer = '' 197 198 console.log(`# Connected to ${url}`) 199 console.log(`# Press Ctrl+C to exit`) 200 201 // graceful shutdown 202 let abort = false 203 const onSig = () => { abort = true; reader.cancel().catch(() => {}); } 204 process.on('SIGINT', onSig) 205 process.on('SIGTERM', onSig) 206 207 while (!abort) { 208 const { value, done } = await reader.read() 209 if (done) break 210 buffer += decoder.decode(value, { stream: true }) 211 let idx 212 while ((idx = buffer.indexOf('\n\n')) !== -1) { 213 const raw = buffer.slice(0, idx) 214 buffer = buffer.slice(idx + 2) 215 handleSSEEvent(raw) 216 } 217 } 218 219 // flush remaining 220 if (buffer.trim().length > 0) handleSSEEvent(buffer) 221 } 222 223 function handleSSEEvent(chunk: string) { 224 // Parse simple SSE event blocks with lines like: event: <name> and data: <json> 225 const lines = chunk.split(/\r?\n/) 226 let event = 'message' 227 let dataLines: string[] = [] 228 for (const line of lines) { 229 if (line.startsWith('event:')) { 230 event = line.slice(6).trim() 231 } else if (line.startsWith('data:')) { 232 dataLines.push(line.slice(5).trim()) 233 } 234 } 235 const dataStr = dataLines.join('\n') 236 let parsed: any = dataStr 237 try { parsed = JSON.parse(dataStr) } catch {} 238 239 switch (event) { 240 case 'open': 241 console.log(`[open]`, parsed) 242 break 243 case 'message': { 244 // If payload includes base64 data, show decoded preview 245 if (parsed && typeof parsed === 'object' && typeof parsed.data === 'string') { 246 try { 247 const decoded = Buffer.from(parsed.data, 'base64').toString('utf8') 248 console.log(`[message] from=${parsed.from} topic=${parsed.topic} seqno=${parsed.seqno}\n data(utf8): ${decoded}\n data(base64): ${parsed.data}`) 249 } catch { 250 console.log(`[message]`, parsed) 251 } 252 } else { 253 console.log(`[message]`, parsed) 254 } 255 break 256 } 257 case 'end': 258 console.log(`[end]`, parsed) 259 break 260 case 'error': 261 console.error(`[error]`, parsed) 262 break 263 default: 264 console.log(`[${event}]`, parsed) 265 } 266 } 267 268 main().catch((e) => { 269 console.error(e) 270 process.exit(1) 271 })