#!/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) })