OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders / src / terminal / main.js

610 lines · javascript ref:
/** @typedef {import('pear-interface')} */ /* global Pear, Bare */
'use strict'

// p2pbuilders terminal app — HN-shaped feed driven by our Bare backend.
// Runs as a Pear terminal application:
//
//   pear run pear://<key>
//
// No GUI, no webview. Same backend (ops, PoW, indexer, swarm, antispam,
// edit/delete, profiles) as the desktop/iOS paths.

// Resolve process via direct conditional (not via subpath imports — Pear's
// bundled Bare may not honor package.json "imports" yet).
const process = (typeof Bare !== 'undefined') ? require('bare-process') : global.process

console.log('p2pbuilders terminal starting…')

// ---- ADMIN -----------------------------------------------------------------
// The single identity allowed to pin posts and delete other users' posts.
// To set this up:
//   1. run the app once (`pear run pear://<key>`)
//   2. at the prompt type `me` — copy the 64-char hex pubkey
//   3. paste it below, re-stage (`pear stage --no-ask dev .`), redistribute
const ADMIN_PUBKEY = '16752aeb998f33904cfa0165526d7f217590e85096a964a07bd29c347edbe86d'

// ---- PINNED POSTS ----------------------------------------------------------
// opIds listed here render at the top of the feed with a 📌 label, above
// the sorted hot/new/top results. Edit this list, re-stage, restart.
// To get an opId: submit the post, the PoW-mint line prints the opId.
const PINNED_OPIDS = [
  '16752aeb998f33904cfa0165526d7f217590e85096a964a07bd29c347edbe86d0000000000000001'
]

const { path } = require('../backend/_rt')
const { Node } = require('../backend/node')
const { Indexer } = require('../backend/indexer')
const { createRPC } = require('../backend/rpc')
const b4a = require('b4a')

// ---- ANSI helpers -----------------------------------------------------------

const ANSI = {
  reset: '\x1b[0m',
  bold: '\x1b[1m',
  dim: '\x1b[2m',
  orange: '\x1b[38;5;208m',
  cyan: '\x1b[36m',
  green: '\x1b[32m',
  red: '\x1b[31m',
  gray: '\x1b[90m',
  clear: '\x1b[2J\x1b[H'
}

const short = (s, n = 8) => (s ? s.slice(0, n) : '')
const fmtAgo = (ts) => {
  const s = Math.floor((Date.now() - ts) / 1000)
  if (s < 45) return `${s}s`
  if (s < 60 * 45) return `${Math.floor(s / 60)}m`
  if (s < 60 * 60 * 22) return `${Math.floor(s / 3600)}h`
  return `${Math.floor(s / 86400)}d`
}

// ---- Relay seeding ---------------------------------------------------------
// Asks the public HiveRelay network to pin our own user core and every
// peer we track. Fire-and-forget; errors only go to the background log
// so the foreground UI stays clean.

async function startRelaySeeder ({ node, storage }) {
  // p2p-hiverelay is an ESM module; we dynamic-import so the CommonJS
  // terminal entry can still load it cleanly.
  const { HiveRelayClient } = await import('p2p-hiverelay-client')
  const seederStorage = path.join(storage, '..', 'p2pbuilders-relay-client')
  const client = new HiveRelayClient(seederStorage)
  await client.start()

  const seeded = new Set()
  async function tryseed (pubkey) {
    const hex = b4a.toString(pubkey, 'hex')
    if (seeded.has(hex)) return
    seeded.add(hex)
    try {
      const acc = await client.seed(pubkey, { replicas: 3, timeout: 20000 })
      if (acc.length) {
        // quiet by default — visible through `relays` command below
      }
    } catch {
      seeded.delete(hex) // allow retry
    }
  }

  // Seed our own core now, and every pubkey we track as they come in.
  await tryseed(node.pubkey)
  node.on('track', (pubkey) => { tryseed(pubkey).catch(() => {}) })
  // Also re-scan periodically in case we missed a track event
  setInterval(() => {
    tryseed(node.pubkey).catch(() => {})
    for (const core of node._tracked.values()) {
      tryseed(core.key).catch(() => {})
    }
  }, 5 * 60 * 1000).unref?.()

  // Expose the client so a `relays` command can print status.
  return client
}

// ---- Boot -------------------------------------------------------------------

async function main () {
  const storage = (typeof Pear !== 'undefined' && Pear.config?.storage)
    ? path.join(Pear.config.storage, 'p2pbuilders')
    : path.join(require('../backend/_rt').os.tmpdir(), `p2pbuilders-term-${Date.now()}`)

  process.stdout.write(`${ANSI.orange}p2pbuilders${ANSI.reset} ${ANSI.dim}booting at ${storage}${ANSI.reset}\n`)

  console.log('[boot] opening node…')
  const node = await Node.openDisk(storage, { swarm: {} })
  console.log('[boot] node ready, pubkey prefix:', b4a.toString(node.pubkey, 'hex').slice(0, 16))
  console.log('[boot] opening indexer…')
  const indexer = new Indexer(node, {
    admin: ADMIN_PUBKEY ? b4a.from(ADMIN_PUBKEY, 'hex') : null
  })
  await indexer.ready()
  console.log('[boot] indexer ready')
  const rpc = createRPC({ node, indexer })
  console.log('[boot] rpc wired')

  // Hiverelay persistence — seed our own core + every peer we track onto
  // the public relay network so posts survive when their author goes offline.
  // Runs in the background; any failure is logged but non-fatal.
  let relayClient = null
  startRelaySeeder({ node, storage }).then(c => { relayClient = c }).catch(err => {
    console.log(`${ANSI.dim}[relay] seeding disabled: ${err.message}${ANSI.reset}`)
  })

  // Auto-join the front board so we discover peers
  try { await node.joinBoard('front') } catch {}

  const myPubkey = b4a.toString(node.pubkey, 'hex')
  const amAdmin = !!ADMIN_PUBKEY && myPubkey.toLowerCase() === ADMIN_PUBKEY.toLowerCase()

  // First-launch: if no profile nickname, prompt once
  const profile = await indexer.getProfile(node.pubkey)
  let nick = profile?.nick || null

  process.stdout.write(`${ANSI.dim}your id: ${myPubkey}${amAdmin ? ' ' + ANSI.orange + '(admin)' + ANSI.reset : ''}${ANSI.reset}\n\n`)

  // Current view state
  const state = {
    sort: 'hot',       // 'hot' | 'new' | 'top'
    posts: [],         // raw enriched posts from the RPC
    _display: [],      // what's actually on screen: pinned + rest
    threadId: null,    // opId hex when viewing a thread
    threadPost: null,
    threadComments: [],

    // Compose mode — when set, stdin lines are collected into the body
    // instead of being parsed as commands. Used for multi-line posts/replies.
    compose: null      // { kind: 'post'|'reply', step: 'title'|'body',
                       //   title?: string, bodyLines: string[],
                       //   parentOpId?: string }
  }

  // ---- rendering ----
  function header () {
    const peers = node.swarm ? node.swarm.peerCount : 0
    const tracked = node._tracked ? node._tracked.size : 0
    process.stdout.write(
      `${ANSI.orange}${ANSI.bold}p2pbuilders${ANSI.reset} ` +
      `${ANSI.dim}· ${state.sort} · ${peers} peers · tracking ${tracked} · ${nick || short(myPubkey, 8)}${ANSI.reset}\n` +
      `${ANSI.gray}${'─'.repeat(64)}${ANSI.reset}\n`
    )
  }

  function renderPostLine (p, idx, pinned = false) {
    const num = String(idx).padStart(3)
    const score = (p.score >= 0 ? '+' : '') + p.score
    const author = p.authorNick || short(p.author, 8)
    const pin = pinned ? ` ${ANSI.orange}📌${ANSI.reset}` : ''
    const meta = `${ANSI.dim}${score} pts · ${p.commentCount || 0} cm · ${fmtAgo(p.ts)} · ${author}${ANSI.reset}`
    const link = p.link ? ` ${ANSI.cyan}${p.link}${ANSI.reset}` : ''
    process.stdout.write(`${ANSI.gray}${num}.${ANSI.reset}${pin} ${ANSI.bold}${p.title || '(no title)'}${ANSI.reset}${link}\n      ${meta}\n`)
  }

  function renderFeed () {
    process.stdout.write(ANSI.clear)
    header()

    // Split pinned from the rest. Keep declared order within pinned; keep sort
    // order (hot/new/top) within non-pinned.
    const pinnedSet = new Set(PINNED_OPIDS.map((s) => s.toLowerCase()))
    const pinned = []
    const rest = []
    for (const p of state.posts) {
      if (pinnedSet.has(p.opId.toLowerCase())) pinned.push(p)
      else rest.push(p)
    }
    // Also preserve the order declared in PINNED_OPIDS.
    pinned.sort((a, b) => PINNED_OPIDS.indexOf(a.opId) - PINNED_OPIDS.indexOf(b.opId))

    // Display indices are continuous across sections so `open <n>` still works.
    state._display = [...pinned, ...rest]

    if (!state._display.length) {
      process.stdout.write(`${ANSI.dim}  no posts yet. try: submit "hello"${ANSI.reset}\n`)
    } else {
      if (pinned.length) {
        process.stdout.write(`${ANSI.orange}${ANSI.dim}─── pinned ───${ANSI.reset}\n`)
        pinned.forEach((p, i) => renderPostLine(p, i + 1, true))
        if (rest.length) process.stdout.write(`${ANSI.gray}${ANSI.dim}─── ${state.sort} ───${ANSI.reset}\n`)
      }
      rest.forEach((p, i) => renderPostLine(p, pinned.length + i + 1, false))
    }

    process.stdout.write(`\n${ANSI.dim}commands: submit · open <n> · up/down <n> · delete <n> · sort <hot|new|top> · r · help · quit${ANSI.reset}\n`)
  }

  function renderThread () {
    process.stdout.write(ANSI.clear)
    header()
    const p = state.threadPost
    if (!p) { process.stdout.write(`${ANSI.red}thread gone${ANSI.reset}\n`); return }
    const score = (p.score >= 0 ? '+' : '') + p.score
    const author = p.authorNick || short(p.author, 8)
    process.stdout.write(`${ANSI.bold}${p.title || '(no title)'}${ANSI.reset}\n`)
    if (p.link) process.stdout.write(`${ANSI.cyan}${p.link}${ANSI.reset}\n`)
    process.stdout.write(`${ANSI.dim}${score} pts · ${fmtAgo(p.ts)} · ${author}${ANSI.reset}\n\n`)
    if (p.body) process.stdout.write(p.body + '\n\n')
    process.stdout.write(`${ANSI.gray}── ${state.threadComments.length} comment(s) ──${ANSI.reset}\n`)
    state.threadComments.forEach((c, i) => {
      const a = c.authorNick || short(c.author, 8)
      process.stdout.write(`\n${ANSI.gray}[${i + 1}]${ANSI.reset} ${a} ${ANSI.dim}· ${fmtAgo(c.ts)} · ${c.score >= 0 ? '+' : ''}${c.score}${ANSI.reset}\n`)
      process.stdout.write(c.body + '\n')
    })
    process.stdout.write(`\n${ANSI.dim}commands: reply <body> | up | down | back | refresh | help | quit${ANSI.reset}\n`)
  }

  async function refreshFeed () {
    state.posts = await rpc.dispatch('listPosts', { board: 'front', sort: state.sort, limit: 50 })
    renderFeed()
  }

  async function refreshThread () {
    const p = await rpc.dispatch('getPost', { opId: state.threadId })
    if (!p) { state.threadId = null; await refreshFeed(); return }
    state.threadPost = p
    state.threadComments = await rpc.dispatch('listCommentTree', { rootOpId: state.threadId })
    // flatten and sort by ts asc for simple rendering
    state.threadComments.sort((a, b) => a.ts - b.ts)
    renderThread()
  }

  // Auto-redraw when new data arrives from peers
  let redrawTimer = null
  const scheduleRedraw = () => {
    if (redrawTimer) return
    redrawTimer = setTimeout(async () => {
      redrawTimer = null
      if (state.threadId) await refreshThread()
      else await refreshFeed()
    }, 500)
  }
  node.on('track', scheduleRedraw)

  // ---- REPL (stdin line buffer, portable between Bare and Node) ----
  const composePrompt = () => { process.stdout.write(`${ANSI.dim}… ${ANSI.reset}`) }
  const prompt = () => {
    if (state.compose) return composePrompt()
    process.stdout.write(`${ANSI.orange}›${ANSI.reset} `)
  }
  function printLine (s) { process.stdout.write(s + '\n'); prompt() }

  let stdinBuf = ''
  if (process.stdin.setEncoding) process.stdin.setEncoding('utf8')
  if (process.stdin.ref) process.stdin.ref()          // keep event loop alive
  if (process.stdin.resume) process.stdin.resume()   // start reading
  process.stdin.on('data', (chunk) => {
    stdinBuf += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
    let nl
    while ((nl = stdinBuf.indexOf('\n')) >= 0) {
      const line = stdinBuf.slice(0, nl).replace(/\r$/, '')
      stdinBuf = stdinBuf.slice(nl + 1)
      if (state.compose) handleCompose(line)
      else handle(line)
    }
  })
  const shutdown = async () => {
    process.stdout.write('\n' + ANSI.dim + 'goodbye.' + ANSI.reset + '\n')
    try { await indexer.close() } catch {}
    try { await node.close() } catch {}
    if (typeof Pear !== 'undefined' && Pear.exit) Pear.exit(0)
    else process.exit(0)
  }
  process.stdin.on('end', shutdown)
  if (typeof Pear !== 'undefined' && Pear.teardown) Pear.teardown(shutdown)

  function parseCmd (line) {
    const s = line.trim()
    if (!s) return null
    // Primitive parse: first word + rest
    const i = s.indexOf(' ')
    if (i < 0) return { cmd: s.toLowerCase(), rest: '' }
    return { cmd: s.slice(0, i).toLowerCase(), rest: s.slice(i + 1).trim() }
  }

  function parseQuoted (s) {
    // "title" rest-as-body
    const m = s.match(/^"([^"]*)"\s*(.*)$/)
    if (m) return { title: m[1], rest: m[2] }
    // fallback: split on first space
    const i = s.indexOf(' ')
    if (i < 0) return { title: s, rest: '' }
    return { title: s.slice(0, i), rest: s.slice(i + 1) }
  }

  const isUrl = (s) => /^https?:\/\//.test(s)

  // ---- compose mode: accumulates a multi-line body for submit/reply ----

  function enterCompose (kind, { parentOpId = null } = {}) {
    state.compose = {
      kind,                   // 'post' or 'reply'
      step: kind === 'post' ? 'title' : 'body',
      title: '',
      bodyLines: [],
      parentOpId
    }
    process.stdout.write(
      `\n${ANSI.orange}─── ${kind === 'post' ? 'new post' : 'new reply'} ───${ANSI.reset}\n` +
      `${ANSI.dim}type ${ANSI.reset}.${ANSI.dim} on a blank line to submit · type ${ANSI.reset}:q${ANSI.dim} to cancel${ANSI.reset}\n`
    )
    if (kind === 'post') process.stdout.write(`${ANSI.dim}title:${ANSI.reset} `)
    else process.stdout.write(`${ANSI.dim}write your reply…${ANSI.reset}\n`)
  }

  function exitCompose (msg) {
    state.compose = null
    if (msg) process.stdout.write(msg + '\n')
    prompt()
  }

  async function handleCompose (line) {
    const c = state.compose
    if (!c) { prompt(); return }

    const trimmed = line.trim()
    if (trimmed === ':q' || trimmed === 'cancel') {
      return exitCompose(`${ANSI.dim}cancelled.${ANSI.reset}`)
    }

    if (c.step === 'title') {
      if (!trimmed) {
        process.stdout.write(`${ANSI.red}title can't be empty · ${ANSI.reset}${ANSI.dim}title:${ANSI.reset} `)
        return
      }
      c.title = trimmed
      c.step = 'body'
      process.stdout.write(`${ANSI.dim}body (multi-line OK):${ANSI.reset}\n`)
      composePrompt()
      return
    }

    // c.step === 'body'
    if (line === '.') {
      if (c.bodyLines.length === 0) {
        process.stdout.write(`${ANSI.red}body is empty · write something or type ':q' to cancel${ANSI.reset}\n`)
        composePrompt()
        return
      }
      const body = c.bodyLines.join('\n').replace(/\s+$/, '')
      process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
      const t0 = Date.now()
      try {
        if (c.kind === 'post') {
          // Detect if the whole body is just a URL (single line) — treat as link post.
          const trim = body.trim()
          const isSingleLineLink = !trim.includes('\n') && isUrl(trim)
          const link = isSingleLineLink ? trim : null
          const text = link ? '' : body
          const { opId } = await rpc.dispatch('createPost', { title: c.title, body: text, link })
          await refreshFeed()
          process.stdout.write(`${ANSI.green}posted in ${Date.now() - t0}ms${ANSI.reset}\n`)
          process.stdout.write(`${ANSI.dim}opId: ${ANSI.reset}${opId}\n`)
        } else {
          await rpc.dispatch('createComment', { parentOpId: c.parentOpId, body })
          await refreshThread()
          process.stdout.write(`${ANSI.green}replied in ${Date.now() - t0}ms${ANSI.reset}\n`)
        }
      } catch (err) {
        process.stdout.write(`\n${ANSI.red}error: ${err.message}${ANSI.reset}\n`)
      }
      return exitCompose()
    }

    // Normal line — buffer it and stay in body mode.
    c.bodyLines.push(line)
    composePrompt()
  }

  async function handle (line) {
    const parsed = parseCmd(line)
    if (!parsed) { prompt(); return }
    const { cmd, rest } = parsed
    try {
      switch (cmd) {
        case 'help':
        case '?':
          printLine(`
  ${ANSI.bold}feed commands${ANSI.reset}
    submit                         new post (multi-line editor)
    submit "title" <body>          new post (one-liner)
    open <n>                       open thread for post #n
    up <n>   down <n>              vote on post #n
    sort <hot|new|top>             change feed sort
    refresh  r                     reload feed

  ${ANSI.bold}thread commands${ANSI.reset}
    reply                          reply (multi-line editor)
    reply <body>                   reply (one-liner)
    up   down                      vote on the post (in thread)
    back b                         back to feed

  ${ANSI.bold}compose editor${ANSI.reset}
    <your text>                    any line joins the body
    .                              submit (on its own line)
    :q                             cancel

  ${ANSI.bold}moderation${ANSI.reset}
    delete <n>                     delete a post (author or admin)
    delete                         delete current thread (in thread)

  ${ANSI.bold}identity + infra${ANSI.reset}
    nick <name>                    set your nickname
    me                             show your pubkey + stats
    relays                         where am i pinned? (or current thread author)
    opid <n>                       copy a post's opId
    quit q                         exit
${amAdmin ? '\n  ' + ANSI.orange + '(admin) ' + ANSI.reset + 'you can delete anyone\'s posts + comments.' : ''}
`)
          break
        case 'q':
        case 'quit':
        case 'exit':
          await shutdown()
          return
        case 'me': {
          const stats = await rpc.dispatch('getStats')
          printLine(`  pubkey: ${stats.pubkey}\n  peers: ${stats.peerCount} · tracked: ${stats.trackedUsers} · boards: ${stats.boards}`)
          break
        }
        case 'nick': {
          const name = rest.trim()
          if (!name) { printLine('usage: nick <name>'); break }
          await rpc.dispatch('setProfile', { nick: name })
          nick = name
          printLine(`${ANSI.green}nickname set: ${name}${ANSI.reset}`)
          break
        }
        case 'sort': {
          const s = rest.trim()
          if (!['hot', 'new', 'top'].includes(s)) { printLine('usage: sort hot|new|top'); break }
          state.sort = s
          await refreshFeed()
          prompt()
          return
        }
        case 'r':
        case 'refresh':
          if (state.threadId) await refreshThread()
          else await refreshFeed()
          prompt()
          return
        case 'submit': {
          // `submit` with no args → enter multi-line compose mode.
          // `submit "title" body` → classic one-liner still works.
          if (!rest.trim()) {
            enterCompose('post')
            return
          }
          const { title, rest: body } = parseQuoted(rest)
          if (!title) { printLine('usage: submit (multi-line) | submit "title" <body-or-url>'); break }
          const link = isUrl(body.trim()) ? body.trim() : null
          const text = link ? '' : body
          process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
          const t0 = Date.now()
          const { opId } = await rpc.dispatch('createPost', { title, body: text, link })
          await refreshFeed()
          // Print the opId AFTER the feed clear so it stays visible.
          process.stdout.write(`${ANSI.green}posted in ${Date.now() - t0}ms${ANSI.reset}\n`)
          process.stdout.write(`${ANSI.dim}opId (copy for pinning): ${ANSI.reset}${opId}\n`)
          prompt()
          return
        }
        case 'compose':
        case 'c': {
          enterCompose('post')
          return
        }
        case 'open': {
          const n = parseInt(rest, 10)
          if (!n || !state._display[n - 1]) { printLine(`no post #${n}`); break }
          state.threadId = state._display[n - 1].opId
          await refreshThread()
          prompt()
          return
        }
        case 'back':
        case 'b':
          state.threadId = null
          state.threadPost = null
          state.threadComments = []
          await refreshFeed()
          prompt()
          return
        case 'up':
        case 'down': {
          const dir = cmd === 'up' ? 1 : -1
          let targetOpId
          if (state.threadId) targetOpId = state.threadId
          else {
            const n = parseInt(rest, 10)
            if (!n || !state._display[n - 1]) { printLine(`no post #${n}`); break }
            targetOpId = state._display[n - 1].opId
          }
          await rpc.dispatch('vote', { targetOpId, dir })
          printLine(`${ANSI.green}voted ${cmd}${ANSI.reset}`)
          if (state.threadId) await refreshThread()
          else await refreshFeed()
          return
        }
        case 'relays': {
          if (!relayClient) { printLine(`${ANSI.dim}relay client not connected yet…${ANSI.reset}`); break }
          const targetHex = state.threadPost?.author || b4a.toString(node.pubkey, 'hex')
          const keyBuf = b4a.from(targetHex, 'hex')
          process.stdout.write(`${ANSI.dim}querying…${ANSI.reset}`)
          try {
            const acc = await relayClient.seed(keyBuf, { replicas: 5, timeout: 15000 })
            process.stdout.write('\r                   \r')
            printLine(`${ANSI.dim}pinned on ${acc.length} relay(s) for ${b4a.toString(keyBuf, 'hex').slice(0, 12)}…:${ANSI.reset}`)
            for (const a of acc) {
              const pk = b4a.toString(a.relayPubkey, 'hex').slice(0, 12)
              printLine(`  ${a.region.padEnd(5)} ${pk}…`)
            }
          } catch (err) {
            printLine(`${ANSI.red}relay query failed: ${err.message}${ANSI.reset}`)
          }
          return
        }
        case 'opid': {
          const n = parseInt(rest, 10)
          const post = state._display[n - 1]
          if (!post) { printLine(`no post #${n}`); break }
          printLine(`${ANSI.dim}opId #${n}:${ANSI.reset} ${post.opId}`)
          break
        }
        case 'delete':
        case 'rm': {
          let target, author
          if (state.threadId) {
            target = state.threadId
            author = state.threadPost?.author
          } else {
            const n = parseInt(rest, 10)
            const post = state._display[n - 1]
            if (!post) { printLine(`no post #${n}`); break }
            target = post.opId
            author = post.author
          }
          const isAuthor = author && author.toLowerCase() === myPubkey.toLowerCase()
          if (!isAuthor && !amAdmin) { printLine(`${ANSI.red}only the author (or admin) can delete${ANSI.reset}`); break }
          await rpc.dispatch('deleteOp', { opId: target })
          printLine(`${ANSI.green}tombstoned (hidden — signed history remains)${ANSI.reset}`)
          if (state.threadId) { state.threadId = null; state.threadPost = null }
          await refreshFeed()
          prompt()
          return
        }
        case 'reply': {
          if (!state.threadId) { printLine('no thread open. use: open <n>'); break }
          // reply with no args → enter multi-line compose mode (as a reply)
          if (!rest.trim()) {
            enterCompose('reply', { parentOpId: state.threadId })
            return
          }
          process.stdout.write(`${ANSI.dim}minting pow…${ANSI.reset}`)
          const t0 = Date.now()
          await rpc.dispatch('createComment', { parentOpId: state.threadId, body: rest })
          process.stdout.write(`\r${ANSI.green}replied in ${Date.now() - t0}ms${ANSI.reset}       \n`)
          await refreshThread()
          prompt()
          return
        }
        default:
          printLine(`unknown: ${cmd}. try 'help'`)
      }
    } catch (err) {
      printLine(`${ANSI.red}error: ${err.message}${ANSI.reset}`)
    }
    prompt()
  }

  await refreshFeed()
  prompt()
}

main().catch((err) => {
  console.error('[boot] fatal error:', (err && err.stack) || String(err))
  if (typeof Bare !== 'undefined' && Bare.exit) Bare.exit(1)
  else process.exit(1)
})