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  })