OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / bin / favela.js

364 lines · javascript ref:
#!/usr/bin/env bare
// favela — the CLI. Talks to the daemon over its unix socket; holds no state of its own.
//
// It is a testing surface, not the product surface. MVP.md names `favela-surface` (SSR) and
// `favela-android` (a WebView shell) for that. This exists so the two-peer acceptance tests in
// hyper-social #61 §11.2 can be driven by hand and by hyper-lab.

import os from '#node:os'
import net from '#node:net'
import process from '#node:process'
import { join } from '#node:path'
import { mintFromVault } from '../lib/device.js'
import b4a from 'b4a'
import fs from '#node:fs'

const sock = process.env.FAVELA_SOCK || join(process.env.FAVELA_STORAGE || join(os.homedir(), '.favela'), 'favela.sock')

const USAGE = `favela — a p2p feed of markdown records

  favela whoami                          this node's feed key
  favela link [--vault <path>] [--passphrase <p>]
                                         attest THIS device from your identity vault
  favela devices                         admitted devices, and who owns this feed
  favela unlink <deviceKey>              revoke a device (and everything it attested)
  favela pair                            offer THIS device's credential to another machine
  favela pair --accept <invite>          take an invite and link this machine
  favela pair --cancel                   withdraw an outstanding invite
  favela namespace                       which namespace this node resolves handles in
  favela namespace --join <key>          resolve handles in a different one, from now on
  favela namespace --invite              mint a single-use invite (admins)
  favela namespace --request [--token T] ask to carry this namespace's log
  favela namespace --withdraw <hash>     withdraw an unspent invite
  favela namespace --admit <writerKey>   answer an ask (admins only)
  favela namespace --grant <rootKey>     make an identity an admin here
  favela namespace --revoke <deviceKey>  retire a device, and what it carries
  favela claim <name>                    claim a handle — you get e.g. alice0
  favela rename <oldHandle> <newName>    give one up and take another
  favela release <handle>
  favela handles [query]                 every handle in the namespace, or those matching
  favela resolve <handle>                a handle -> feed, and whether the feed AGREES
  favela profile                         your display name and picture
  favela set-profile [--name X] [--bio "..."] [--avatar <file>] [--banner <file>]
  favela invite [--ttl <ms>]             one-time invite for a browser
  favela browsers                        browsers you have admitted
  favela revoke <publicKey>              retire a browser
  favela post <text>                     write a post
  favela timeline [--limit N]            posts from every feed you hold
  favela follow <feedKey>                record a follow AND hold the feed
  favela unfollow <feedKey>
  favela following                       who you follow
  favela missing                         followed feeds you cannot read
  favela like <feed> <collection> <name>
  favela unlike <feed> <collection> <name>
  favela likes <feed> <collection> <name>
  favela reply <feed> <collection> <name> <text>
  favela replies <feed> <collection> <name>
  favela search <query> [--limit N]      over what you already hold
  favela knock <feedKey>                 ring someone's doorbell
  favela knocks                          who rang yours
  favela admit <feedKey>                 accept a knock — this FOLLOWS them
  favela dismiss <feedKey>
  favela directory                       listings across registries you hold
  favela unresolved                      records you hold pointers for and cannot read

Socket: ${sock}  (override with FAVELA_SOCK)`

function call (cmd, args) {
  return new Promise((resolve, reject) => {
    const socket = net.connect(sock)
    let buf = ''
    socket.on('error', (err) => reject(new Error(
      err.code === 'ENOENT' || err.code === 'ECONNREFUSED'
        ? 'no daemon at ' + sock + ' — is favela-peer running?'
        : err.message)))
    socket.on('connect', () => socket.write(JSON.stringify({ id: 1, cmd, args }) + '\n'))
    socket.on('data', (chunk) => {
      buf += chunk.toString()
      const i = buf.indexOf('\n')
      if (i === -1) return
      let res
      try { res = JSON.parse(buf.slice(0, i)) } catch (err) { return reject(err) }
      socket.destroy()
      res.ok ? resolve(res.value) : reject(new Error(res.error))
    })
  })
}

const flag = (argv, name, dflt) => {
  const i = argv.indexOf('--' + name)
  return i === -1 ? dflt : Number(argv[i + 1])
}
const str = (argv, name) => {
  const i = argv.indexOf('--' + name)
  return i === -1 ? undefined : String(argv[i + 1] == null ? '' : argv[i + 1])
}
// ⛔ This was MISSING while being called twice: `favela pair --cancel` threw
// "has is not defined" from the day it was written. A flag with no parser is the CLI's version of a
// daemon method with no route.
const has = (argv, name) => argv.includes('--' + name)
const stripFlags = (argv) => {
  const out = []
  for (let i = 0; i < argv.length; i++) {
    if (argv[i].startsWith('--')) { i++; continue }
    out.push(argv[i])
  }
  return out
}
const target = (a) => ({ feed: a[0], collection: a[1], name: a[2] })

const fmt = (rows) => rows.map((r) =>
  `${(r.createdAt || '').slice(0, 19)}  ${(r.author || '').slice(0, 8)}  ${r.collection}/${r.name}\n  ${String(r.body || '').trim().replace(/\n/g, '\n  ')}`
).join('\n\n')

async function main () {
  const argv = process.argv.slice(2)
  const cmd = argv[0]
  const a = stripFlags(argv.slice(1))
  if (!cmd || cmd === 'help' || cmd === '--help') { console.log(USAGE); return }

  switch (cmd) {
    case 'whoami': return console.log(JSON.stringify(await call('whoami'), null, 2))
    case 'link': {
      // ⛔ The CLI opens the vault, never the daemon. Boot must not need a passphrase, and the
      // ceremony is the one moment a human is present to supply one.
      const me = await call('whoami')
      if (me.linked) return console.log(JSON.stringify({ ok: true, already: true, device: me.device }))
      const vault = str(argv, 'vault') || join(os.homedir(), '.hyperident', 'identity')
      // ⛔ Minted here, not in the daemon: the vault is opened by a command a human ran, never on a
      // boot path. This is the FIRST device of an identity; a second machine should pair with this
      // one instead, which needs no vault at all.
      // ⛔ COLLECTED BY THE COMMAND, never by the daemon. `mintFromVault` takes the passphrase and
      // never prompts: this is the one moment a human is present, and boot must never need one.
      const cred = await mintFromVault(vault, { passphrase: str(argv, 'passphrase') || null })
      const credential = {
        publicKey: b4a.toString(cred.keyPair.publicKey, 'hex'),
        secretKey: b4a.toString(cred.keyPair.secretKey, 'hex'),
        proof: cred.proof
      }
      return console.log(JSON.stringify(await call('link', { credential }), null, 2))
    }
    case 'devices': return console.log(JSON.stringify(await call('devices'), null, 2))
    case 'pair': {
      if (has(argv, 'cancel')) return console.log(JSON.stringify(await call('pair-cancel')))
      const invite = str(argv, 'accept')
      if (invite !== undefined) {
        if (!invite) throw new Error('pair --accept needs an invite')
        const r = await call('pair-accept', { invite })
        console.log(JSON.stringify(r, null, 2))
        if (r.restart) {
          console.error('adopted feed ' + r.adopted.slice(0, 16) + '… — restart favela-peer to join it')
        }
        return
      }

      const { invite: hex } = await call('pair-offer', {})
      // The invite on stdout and nothing else, so a pipe carries only the thing being carried.
      console.log(hex)
      console.error('run `favela pair --accept <invite>` on the other machine · valid 5 minutes')

      // ⛔ Poll rather than blocking the socket: a request that sat open for five minutes is
      // indistinguishable from a hung daemon, and the daemon is doing the waiting anyway.
      for (;;) {
        await new Promise((r) => setTimeout(r, 1000))
        const st = await call('pair-status')
        if (st.status === 'waiting') continue
        if (st.status === 'linked') {
          console.error('linked: device ' + st.devicePublicKey.slice(0, 16) + '…')
          return
        }
        throw new Error(st.error || ('pairing ' + st.status))
      }
    }
    case 'namespace': {
      const j = str(argv, 'join')
      if (j !== undefined) {
        if (!j) throw new Error('--join needs a namespace key')
        const r = await call('namespace-join', { key: j })
        console.error(r.joined
          ? 'now resolving handles in ' + r.key.slice(0, 16) + '… — the one you left is still on disk'
          : 'already in ' + r.key.slice(0, 16) + '…')
        return console.log(JSON.stringify(await call('namespace'), null, 2))
      }
      if (has(argv, 'invite')) {
        const r = await call('namespace-invite')
        // The token on stdout and nothing else, so a pipe carries only the thing being carried.
        console.log(r.token)
        console.error('single use · the log holds only its hash · withdraw with --withdraw ' + r.hash.slice(0, 16) + '…')
        return
      }
      if (has(argv, 'request')) {
        const token = str(argv, 'token')
        const r = await call('namespace-request', token ? { token } : {})
        console.log(JSON.stringify(r, null, 2))
        if (!token) {
          console.error('\nNo invite token, so this ask waits for an admin to answer it by hand ' +
            '(`favela namespace --admit ' + r.asked.slice(0, 16) + '…`). With `--token` an admin ' +
            'node answers it unattended.')
        }
        return
      }
      const withdraw = str(argv, 'withdraw')
      if (withdraw !== undefined) {
        if (!withdraw) throw new Error('--withdraw needs an invite hash')
        return console.log(JSON.stringify(await call('namespace-uninvite', { hash: withdraw }), null, 2))
      }
      const admit = str(argv, 'admit')
      if (admit !== undefined) {
        if (!admit) throw new Error('--admit needs the writer key from `favela namespace` requests')
        return console.log(JSON.stringify(await call('namespace-admit', { writer: admit }), null, 2))
      }
      const grant = str(argv, 'grant')
      if (grant !== undefined) {
        if (!grant) throw new Error('--grant needs an identity root')
        return console.log(JSON.stringify(await call('namespace-grant', { root: grant }), null, 2))
      }
      const revoke = str(argv, 'revoke')
      if (revoke !== undefined) {
        if (!revoke) throw new Error('--revoke needs a device key')
        return console.log(JSON.stringify(await call('namespace-revoke', { device: revoke }), null, 2))
      }
      const ns = await call('namespace')
      console.log(JSON.stringify(ns, null, 2))
      // ⛔ Said out loud. A namespace nobody else has joined is not broken and does not look
      // different — it is simply yours alone, and nobody can share what they are never told.
      if (ns.admins.length === 1 && !ns.writers.length) {
        console.error('\nThis namespace is yours alone so far. Share the key above; on the other ' +
          'machine run `favela namespace --join <key>` then `--request`, and answer it here with ' +
          '`favela namespace --admit <writerKey>`.')
      }
      if (ns.requests.length) {
        console.error('\n' + ns.requests.length + ' node(s) asking to carry this log — ' +
          '`favela namespace --admit <writerKey>`')
      }
      return
    }
    case 'claim': {
      if (!a[0]) throw new Error('claim needs a name')
      return console.log(JSON.stringify(await call('claim-name', { name: a[0] }), null, 2))
    }
    case 'rename': {
      if (!a[0] || !a[1]) throw new Error('rename needs <oldHandle> <newName>')
      return console.log(JSON.stringify(await call('rename', { handle: a[0], name: a[1] }), null, 2))
    }
    case 'release': {
      if (!a[0]) throw new Error('release needs a handle like alice0')
      return console.log(JSON.stringify(await call('release-name', { handle: a[0] })))
    }
    case 'handles': {
      // With a query this is a search; bare, it is the whole namespace. Both fail closed — an
      // empty namespace says so rather than printing nothing.
      const { handles } = a[0] ? await call('search-names', { query: a[0] }) : await call('handles')
      if (!handles.length) return console.log(a[0] ? '(no handle matches ' + a[0] + ')' : '(no handles claimed)')
      // ⛔ The verdict is printed for anything that is not confirmed. A handle whose feed disagrees
      // is the attack; a handle we cannot check is not the same as one we checked and liked, and a
      // listing that rendered all three identically would hide both.
      return console.log(handles.map((h) =>
        h.handle.padEnd(24) + h.feed.slice(0, 16) + '…' +
        (h.verdict === 'confirmed' ? '' : '  [' + h.verdict + ']')).join('\n'))
    }
    case 'resolve': {
      if (!a[0]) throw new Error('resolve needs a handle like alice0')
      const r = await call('resolve', { handle: a[0] })
      // ⛔ The verdict is printed, never swallowed. A handle whose claim points at a feed founded
      // by someone else is the attack this whole path exists to catch, and a caller that only sees
      // the feed key would never know.
      return console.log(JSON.stringify(r, null, 2))
    }
    case 'profile': return console.log(JSON.stringify(await call('profile'), null, 2))
    case 'set-profile': {
      const name = str(argv, 'name')
      const avatar = str(argv, 'avatar')
      // A banner is the wide header image; --avatar is the round one. Both are optional and
      // independent, and passing neither with --name/--bio leaves both alone.
      const banner = str(argv, 'banner')
      // ⛔ THE BIO HAD NO WAY IN. `peer.setProfile` has taken `{name, avatar, bio}` since it was
      // written and this caller only ever passed the first two, so the supported surface could not
      // set a bio at all — the profile record's body stayed empty on every node, and every
      // projection of it downstream (the AP actor's `summary`, the surface's byline) was empty for
      // a reason that looked like the projection was broken. It was never reached.
      const bio = str(argv, 'bio')
      if (name === undefined && avatar === undefined && bio === undefined && banner === undefined) {
        throw new Error('set-profile needs --name, --bio, --avatar <file> and/or --banner <file>')
      }
      // One call, so a name-and-bio change is ONE record write and not two — the record is
      // overwritten in place, and two writes would briefly publish a profile with only half of it.
      if (name !== undefined || bio !== undefined) {
        const patch = {}
        if (name !== undefined) patch.name = name
        // ⚠ `''` is a REMOVAL, matching how setProfile already treats a blank name: absence asserts
        // nothing, `""` would assert that the bio is empty.
        if (bio !== undefined) patch.bio = bio
        await call('set-profile', patch)
      }
      if (avatar !== undefined) {
        if (!avatar) throw new Error('--avatar needs a file path')
        // ⛔ A PATH, never bytes. Both ends are on one machine — it is a unix socket — and
        // `avatars.put` takes a path. 0.0.15 converted the daemon and the surface and left this
        // caller sending `{ data }` to a handler reading `a.path`, so `set-profile --avatar` has
        // been broken on every installed node since.
        const r = await call('avatar-put', { path: avatar })
        console.error('avatar ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…')
      }
      if (banner !== undefined) {
        if (!banner) throw new Error('--banner needs a file path')
        const r = await call('banner-put', { path: banner })
        console.error('banner ' + r.mimeType + ' ' + Math.round(r.size / 1024) + ' KiB -> ' + r.oid.slice(0, 16) + '…')
      }
      return console.log(JSON.stringify(await call('profile'), null, 2))
    }
    case 'invite': {
      const ttl = flag(argv, 'ttl', undefined)
      const r = await call('invite', Number.isFinite(ttl) ? { ttlMs: ttl } : {})
      // ⛔ Printed once and never stored in the clear — the log holds only its hash.
      console.log(r.token)
      console.error('valid until ' + new Date(r.expiresAt).toISOString() + ' · single use')
      return
    }
    case 'browsers': {
      const { browsers } = await call('browsers')
      if (!browsers.length) return console.log('(no browsers admitted)')
      return console.log(browsers.map((b) => (b.revoked ? 'revoked  ' : 'active   ') + b.publicKey).join('\n'))
    }
    case 'revoke': {
      if (!a[0]) throw new Error('revoke needs a browser public key')
      return console.log(JSON.stringify(await call('browser-revoke', { publicKey: a[0] })))
    }
    case 'unlink': {
      if (!a[0]) throw new Error('unlink needs a device key')
      return console.log(JSON.stringify(await call('unlink', { device: a[0] })))
    }
    case 'post': {
      if (!a.length) throw new Error('post needs text')
      return console.log(JSON.stringify(await call('post', { body: a.join(' ') })))
    }
    case 'timeline': {
      const rows = await call('timeline', { limit: flag(argv, 'limit', 50) })
      return console.log(rows.length ? fmt(rows) : '(nothing yet)')
    }
    case 'search': {
      const r = await call('search', { query: a.join(' '), limit: flag(argv, 'limit', 25) })
      console.log(r.results.length ? fmt(r.results) : '(no matches)')
      // ⛔ Never silent: "no matches" and "no matches, and 12 records I cannot read" differ.
      if (r.unreadable) console.log('\n⚠ ' + r.unreadable + ' record(s) in scope could not be read')
      return
    }
    case 'follow': case 'unfollow': case 'knock': case 'admit': case 'dismiss':
      return console.log(JSON.stringify(await call(cmd, { feed: a[0] })))
    case 'like': case 'unlike':
      return console.log(JSON.stringify(await call(cmd, { target: target(a) })))
    case 'reply':
      return console.log(JSON.stringify(await call('reply', { target: target(a), body: a.slice(3).join(' ') })))
    case 'likes': case 'replies':
      return console.log(fmt(await call(cmd, { target: target(a) })))
    case 'following': case 'missing': case 'knocks': case 'directory': case 'unresolved':
      return console.log(JSON.stringify(await call(cmd), null, 2))
    default:
      console.error('unknown command: ' + cmd + '\n')
      console.log(USAGE)
      process.exit(1)
  }
}

main().catch((err) => { console.error(err.message); process.exit(1) })