OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / bin / mirall.js

361 lines · javascript ref:
// mirall — headless CLI for the Mirall P2P engine (pgh-app shape, no Electron).
//   bare bin/mirall.js daemon [--storage DIR] [--downloads DIR] [--sock PATH] [--web PORT]
//   bare bin/mirall.js <command> [--flags]   (talks to a running daemon over hyper-svc UDS)
// --web PORT also serves Mirall's real React UI on 127.0.0.1:PORT (localhost only).
//
// The daemon boots one worker (via cli/host.js) and exposes a hyper-svc UDS Service that
// proxies commands to the worker; each client invocation connects to that UDS, calls, prints.
// bare-sidecar spawns the worker; hyper-svc is the CLI⇄daemon transport (≠ pgh-sidecar).
const fs = require('bare-fs')
const os = require('bare-os')
const path = require('bare-path')
const b4a = require('b4a')
const Signal = require('bare-signals')
const { createHost } = require('../cli/host.js')

// ---- tiny arg parser (positionals + --flags; paparam can replace this later) ----
const raw = Bare.argv.slice(2)
const positional = []
const flags = {}
for (let i = 0; i < raw.length; i++) {
  const a = raw[i]
  if (a.startsWith('--')) {
    if (a.includes('=')) { const [k, v] = a.slice(2).split('='); flags[k] = v }
    else { const k = a.slice(2); const nx = raw[i + 1]; if (nx !== undefined && !nx.startsWith('--')) flags[k] = raw[++i]; else flags[k] = true }
  } else positional.push(a)
}
const [group, sub] = positional
const key = group && sub ? group + ':' + sub : group

const HOME = os.homedir()
const DEFAULT_SOCK = path.join(HOME, '.mirall', 'svc.sock')
const sock = flags.sock || DEFAULT_SOCK

// command → worker RPC mapping (daemon proxies these). `method` = a native daemon method;
// `rpc` = proxied to the worker via the generic 'rpc' passthrough.
const TABLE = {
  status: { method: 'status' },
  'profile:get': { rpc: 'profile:get' },
  'profile:set': { rpc: 'profile:set', map: (f) => ({ displayName: f.name, avatar: null }) },
  'space:list': { rpc: 'spaces:list' },
  'space:create': { rpc: 'space:create', map: (f) => ({ name: f.name }) },
  'space:invite': { rpc: 'space:invite', map: (f) => ({ spaceId: f.space }) },
  'space:join': { rpc: 'space:join', map: (f) => ({ inviteCode: f.invite, name: f.name }) },
  'share:create': { rpc: 'share:create', map: (f) => ({ spaceId: f.space, name: f.name }) },
  'share:mount': { rpc: 'owned-folder:mount', map: (f) => ({ spaceId: f.space, shareId: f.share, mountPath: f.path }) },
  'share:list': { rpc: 'share:list', map: (f) => ({ spaceId: f.space }) },
  mirror: { rpc: 'foreign-folder:mount', map: (f) => ({ spaceId: f.space, shareId: f.share, ownerKey: f.owner, mountPath: f.path }) },
  'files:list': { rpc: 'files:list', map: (f) => ({ spaceId: f.space }) },
  'publish-space': { method: 'publish-space', map: (f) => ({ spaceId: f.space }) },
}

const USAGE = `mirall — headless P2P file transfer CLI

  mirall daemon [--storage DIR] [--downloads DIR] [--sock PATH] [--web PORT]
  mirall status
  mirall whoami                                 print this node's hyperident pubkey (dial address)
  mirall contact add NAME PUBKEY | contact list local petname → hyperident-pubkey map (for --name)
  mirall profile get | profile set --name NAME
  mirall space list | space create --name NAME | space invite --space ID
  mirall space join --invite CODE [--name NAME]
  mirall publish-space --space ID               serve this space's invite over your hyperident address
  mirall dial --name NAME | --peer PUBKEY        fetch a peer's invite by hyperident identity + join
  mirall share create --space ID --name NAME
  mirall share mount --space ID --share ID --path DIR
  mirall share list --space ID
  mirall mirror --space ID --owner KEY --share ID --path DIR
  mirall files list --space ID

  --sock PATH   daemon control socket (default ${DEFAULT_SOCK})
  --web PORT    (daemon) serve Mirall's React UI on http://127.0.0.1:PORT (localhost only)`

const HYPERIDENT_PUB = path.join(HOME, '.hyperident', 'identity.pub')
const CONTACTS = path.join(HOME, '.mirall', 'contacts.json')

function readHyperidentPub () {
  try { return fs.readFileSync(HYPERIDENT_PUB).toString().trim() } catch { return null }
}
function readContacts () {
  try { return JSON.parse(fs.readFileSync(CONTACTS).toString()) } catch { return {} }
}
function writeContacts (c) {
  fs.mkdirSync(path.dirname(CONTACTS), { recursive: true })
  fs.writeFileSync(CONTACTS, JSON.stringify(c, null, 2))
}

// Canonical bytes signed over a rendezvous invite: a domain tag + the space id + the invite code.
// The server (get-invite) and the dialer reconstruct this identically, so the hyperident signature
// binds the invite to the space it claims to be for — a topic-squatter can't swap in a fake invite.
function inviteSignedBytes (space, invite) {
  return b4a.from('mirall-invite/v1\n' + (space || '') + '\n' + (invite || ''), 'utf8')
}
// Vendored hyperident IdentityManager (signs/verifies against the local ~/.hyperident vault via
// keet-identity-key device attestation). Loaded lazily + cached; ESM, so via dynamic import.
let _identityManager = null
async function identityManager () {
  if (!_identityManager) _identityManager = (await import('../vendor/hyperident/lib/identity-manager.js')).IdentityManager
  return _identityManager
}

function runWhoami () {
  const pub = readHyperidentPub()
  if (!pub) return fail('no hyperident identity at ' + HYPERIDENT_PUB + ' (run: hyperident init)')
  out(pub)
  Bare.exit(0)
}
function runContact () {
  const c = readContacts()
  if (sub === 'list') { out(c); Bare.exit(0) }
  if (sub === 'add') {
    const [name, pubkey] = positional.slice(2)
    if (!name || !pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return fail('usage: mirall contact add NAME <64-hex-pubkey>')
    c[name] = pubkey.toLowerCase(); writeContacts(c)
    out({ added: name, pubkey: c[name] }); Bare.exit(0)
  }
  return fail('usage: mirall contact add NAME PUBKEY | mirall contact list')
}

function out (v) { console.log(typeof v === 'string' ? v : JSON.stringify(v, null, 2)) }
function fail (m) { console.error('mirall: ' + m); Bare.exit(1) }

async function runDaemon () {
  const storage = flags.storage || path.join(HOME, '.mirall', 'storage')
  const downloads = flags.downloads || path.join(HOME, '.mirall', 'downloads')
  fs.mkdirSync(path.dirname(sock), { recursive: true })
  // Single-daemon guard: if a LIVE daemon already owns this sock, refuse rather than blindly
  // unlinking it (which would rip the socket out from under the running daemon and leave two
  // processes fighting the single-writer RocksDB lock). Only clear a genuinely stale sock.
  let liveDaemon = false
  try {
    const { Client } = await import('hyper-svc')
    const probe = await Client.connectUds(sock)   // throws if nothing is listening (stale/absent)
    liveDaemon = true
    try { probe.close && probe.close() } catch {}
  } catch {}
  if (liveDaemon && !flags['force-duplicate']) return fail('a mirall daemon is already running on ' + sock + ' (stop it, or pass --force-duplicate with a different --sock to run another)')
  if (!liveDaemon) { try { fs.unlinkSync(sock) } catch {} }   // clear a stale socket from a prior run

  // --bootstrap '[{"host":"127.0.0.1","port":N},…]' points the swarm at a local testnet
  // (deterministic multi-node testing); omit for the public DHT.
  const dhtBootstrap = flags.bootstrap ? JSON.parse(flags.bootstrap) : null
  const { Service } = await import('hyper-svc')

  // Boot the worker with lock-race resilience. The store is a single-writer RocksDB; on a fast
  // stop→start (pgh app restart / OTA respawn) the PREVIOUS worker may still hold the lock for a
  // beat, so a fresh worker can't open the store and never emits worker-ready. Rather than die
  // (the old "spaces vanished" symptom), retry with a fresh worker until the lock frees.
  let host = null
  const MAX_BOOT_ATTEMPTS = 6
  for (let attempt = 1; ; attempt++) {
    host = createHost({ storage, downloads, dhtBootstrap, debug: !!flags.debug, readyTimeout: 12000 })
    try { await host.ready; break }
    catch (err) {
      try { await host.close({ timeoutMs: 2000 }) } catch {}
      if (attempt >= MAX_BOOT_ATTEMPTS) throw new Error('worker did not become ready after ' + attempt + ' attempts — the store may be locked by another mirall daemon on ' + storage + ' (' + err.message + ')')
      console.error('mirall: worker not ready (attempt ' + attempt + '/' + MAX_BOOT_ATTEMPTS + '), retrying — ' + err.message)
      await new Promise((r) => { const t = setTimeout(r, 1500); t.unref && t.unref() })
    }
  }

  // publish-space designation must survive restarts (SEV-2): a bare `let` reset to null on every
  // boot made `dial --name` silently return "no invite" until publish-space was re-run. Persist it
  // beside the store (same dir the engine writes identity.enc to) and restore it on boot.
  const PUBLISHED_FILE = path.join(path.dirname(storage), 'published-space.json')
  let publishedSpaceId = null
  try { publishedSpaceId = (JSON.parse(fs.readFileSync(PUBLISHED_FILE).toString()) || {}).spaceId || null } catch {}

  let webServer = null   // hoisted so the shutdown handler can close them
  let rvSwarm = null

  const svc = new Service({ name: 'mirall', uds: sock })
  // Thin passthrough: the CLI owns the command surface, the daemon just relays to the worker.
  svc.handle('rpc', async (ctx, params) => host.request(params.type, params.args || {}))
  svc.handle('status', async () => ({
    profile: await host.request('profile:get'),
    spaces: await host.request('spaces:list'),
    network: await host.request('network:status:get'),
  }))
  // Designate which space's invite the hyperident rendezvous serves (mirall publish-space) — persisted.
  svc.handle('publish-space', async (ctx, params) => {
    publishedSpaceId = params.spaceId || null
    try {
      if (publishedSpaceId) { fs.mkdirSync(path.dirname(PUBLISHED_FILE), { recursive: true }); fs.writeFileSync(PUBLISHED_FILE, JSON.stringify({ spaceId: publishedSpaceId })) }
      else { try { fs.unlinkSync(PUBLISHED_FILE) } catch {} }
    } catch {}
    return { published: publishedSpaceId }
  })
  await svc.listen()

  // hyperident-keyed invite RENDEZVOUS: serve `get-invite` on topic = hash(ownHyperidentPubkey
  // + salt) so a peer can `mirall dial --name/--peer` and fetch our published space's invite —
  // "connect by identity", no 256-bit paste. Topic derivation matches hyper-svc Client.connectSwarm.
  // The invite is SIGNED with the hyperident identity (device-attestation via keet-identity-key), so
  // a topic-squatter serving a fake invite is rejected by the dialer (which verifies the proof
  // resolves to the exact identity pubkey it dialed). The signature binds space id + invite code.
  const pub = readHyperidentPub()
  if (pub) {
    const [{ default: Hyperswarm }, { default: Protomux }, { default: crypto }, { default: b4a }, { attachRpcServer }] =
      await Promise.all([import('hyperswarm'), import('protomux'), import('hypercore-crypto'), import('b4a'), import('hyper-svc')])
    const topic = crypto.hash(b4a.concat([b4a.from(pub, 'hex'), b4a.from('mirall-invite/v1')]))
    rvSwarm = new Hyperswarm()
    rvSwarm.on('connection', (conn) => {
      const mux = Protomux.from(conn)
      attachRpcServer(mux, async (method) => {
        if (method === 'get-invite') {
          if (!publishedSpaceId) throw new Error('no space published (run: mirall publish-space --space ID)')
          const invite = await host.request('space:invite', { spaceId: publishedSpaceId })
          const IdentityManager = await identityManager()
          const proof = b4a.toString(await IdentityManager.signData(inviteSignedBytes(publishedSpaceId, invite)), 'hex')
          return { invite, space: publishedSpaceId, proof }
        }
        throw new Error('unknown method: ' + method)
      }, { remotePublicKey: conn.remotePublicKey })
    })
    rvSwarm.join(topic, { server: true, client: false })
    rvSwarm.flush().catch(() => {})
  }

  // Optional LOCAL web-UI: serve Mirall's real React bundle (web/) + relay the worker's NDJSON to
  // the browser — SSE (worker→browser, base64 frames) + POST (browser→worker). Bound to 127.0.0.1
  // ONLY (the daemon holds the identity). The browser's web/bridge-shim.js speaks the same
  // window.bridge the renderer expects. Enable with: mirall daemon --web 4173
  if (flags.web) {
    const http = (await import('bare-http1')).default
    const WEB = path.join(__dirname, '..', 'web')
    const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.map': 'application/json', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }
    const sseClients = new Set()
    host.onRaw((chunk) => {                               // worker → all open browsers (raw tap)
      const line = 'data: ' + b4a.toString(chunk, 'base64') + '\n\n'
      for (const res of sseClients) { try { res.write(line) } catch {} }
    })
    webServer = http.createServer((req, res) => {
      const url = (req.url || '/').split('?')[0]
      if (url === '/worker/events') {                     // SSE: stream worker output to the browser
        res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' })
        res.write('\n'); sseClients.add(res); res.on('close', () => sseClients.delete(res)); return
      }
      if (url === '/worker/write' && req.method === 'POST') {   // browser → worker
        const parts = []
        req.on('data', (d) => parts.push(d))
        req.on('end', () => { try { host.sc.write(b4a.concat(parts)) } catch {} res.writeHead(204); res.end() })
        return
      }
      if (url === '/fs/list') {                                 // local folder-picker (LOCAL-ONLY = your own fs)
        const qs = (req.url || '').split('?')[1] || ''
        const m = qs.match(/(?:^|&)path=([^&]*)/)
        let dir
        try { dir = path.resolve(m ? decodeURIComponent(m[1]) : os.homedir()) } catch { dir = os.homedir() }
        let entries = []
        try {
          entries = fs.readdirSync(dir)
            .filter((n) => !n.startsWith('.'))
            .map((n) => { let d = false; try { d = fs.statSync(path.join(dir, n)).isDirectory() } catch {} return { name: n, dir: d } })
            .sort((a, b) => (Number(b.dir) - Number(a.dir)) || a.name.localeCompare(b.name))
        } catch {}
        const parent = path.dirname(dir)
        res.writeHead(200, { 'content-type': 'application/json' })
        res.end(JSON.stringify({ path: dir, parent: parent === dir ? null : parent, entries }))
        return
      }
      const file = path.join(WEB, url === '/' ? 'index.html' : url)
      if (!file.startsWith(WEB)) { res.writeHead(403); res.end(); return }
      try {
        const data = fs.readFileSync(file)
        res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' })
        res.end(data)
      } catch { res.writeHead(404); res.end('not found') }
    })
    webServer.listen(Number(flags.web), '127.0.0.1')
  }

  // Graceful shutdown: drain the worker so it EXITS and releases the RocksDB lock BEFORE this
  // process dies — otherwise a quick restart races the lock (the "spaces vanished" bug). pgh app
  // stop SIGTERMs the process group; bare-signals catches it in the real bare process.
  let shuttingDown = false
  async function gracefulShutdown (sig) {
    if (shuttingDown) return
    shuttingDown = true
    console.log('\nmirall: ' + sig + ' — shutting down…')
    try { if (webServer) webServer.close() } catch {}
    try { if (rvSwarm) await rvSwarm.destroy() } catch {}
    try { if (svc && typeof svc.close === 'function') await svc.close() } catch {}
    try { await host.close() } catch {}   // waits for the worker to exit → RocksDB lock released
    Bare.exit(0)
  }
  for (const sig of ['SIGTERM', 'SIGINT']) {
    try { new Signal(sig).on('signal', () => { gracefulShutdown(sig) }).start() } catch {}
  }

  console.log('mirall daemon up')
  console.log('  storage: ' + storage)
  console.log('  sock:    ' + sock)
  if (flags.web) console.log('  web-ui:  http://127.0.0.1:' + flags.web + '  (LOCAL ONLY)')
  if (pub) console.log('  dial-address (hyperident): ' + pub)
  const prof = await host.request('profile:get')
  console.log('  profile: ' + (prof && prof.displayName ? prof.displayName : '(none — run: mirall profile set --name NAME)'))
  // The UDS server + rendezvous swarm + worker pipe keep the event loop alive.
}

async function runClient () {
  const entry = TABLE[key]
  if (!entry) return fail('unknown command "' + positional.join(' ') + '"\n\n' + USAGE)
  const { Client } = await import('hyper-svc')
  let client
  try { client = await Client.connectUds(sock) }
  catch { return fail('cannot reach daemon at ' + sock + ' (is `mirall daemon` running?)') }
  try {
    const res = entry.method
      ? await client.call(entry.method, entry.map ? entry.map(flags) : {})
      : await client.call('rpc', { type: entry.rpc, args: entry.map ? entry.map(flags) : {} })
    out(res)
  } catch (err) { return fail(err.message) }
  finally { try { client.close && client.close() } catch {} }
  Bare.exit(0)
}

// dial: reach a peer BY hyperident identity (petname → pubkey), fetch its published invite over
// the rendezvous, then hand it to our local daemon to join. "connect by identity", no invite paste.
async function runDial () {
  let pubkey = flags.peer
  if (!pubkey && flags.name) { const c = readContacts(); pubkey = c[flags.name] }
  if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return fail('dial needs --peer <64-hex> or --name <contact> (add: mirall contact add NAME PUBKEY)')
  const { Client } = await import('hyper-svc')
  // 1) reach the peer over the DHT (topic = hash(peerHyperidentPubkey + salt)) + fetch its invite
  let peer, invite, space, proof
  try { peer = await Client.connectSwarm({ serverKey: pubkey, topicSalt: 'mirall-invite/v1', timeout: 45000 }) }
  catch (e) { return fail('could not reach peer ' + pubkey.slice(0, 12) + '… over the DHT: ' + e.message) }
  try { const r = await peer.call('get-invite', {}); invite = r && r.invite; space = r && r.space; proof = r && r.proof }
  catch (e) { try { peer.close && peer.close() } catch {}; return fail('get-invite failed: ' + e.message) }
  try { peer.close && peer.close() } catch {}
  if (!invite) return fail('peer returned no invite (did they run: mirall publish-space --space ID ?)')
  // 1b) VERIFY the invite is signed by the exact identity we dialed — fail closed. A topic-squatter
  // can serve a fake invite, but cannot forge a hyperident device-attestation for `pubkey`.
  if (!proof) return fail('peer returned an UNSIGNED invite — refusing (expected a hyperident signature from ' + pubkey.slice(0, 12) + '…; peer may be running an old mirall or be a rendezvous squatter)')
  let verification = null
  try {
    const IdentityManager = await identityManager()
    verification = await IdentityManager.verifySignature(b4a.from(proof, 'hex'), inviteSignedBytes(space, invite))
  } catch (e) { return fail('invite signature verification errored: ' + e.message) }
  if (!verification) return fail('invite signature is INVALID — refusing (possible rendezvous squatter on ' + pubkey.slice(0, 12) + '…)')
  const signer = b4a.toString(verification.identityPublicKey, 'hex')
  if (signer !== pubkey.toLowerCase()) return fail('invite was signed by ' + signer.slice(0, 12) + '… but we dialed ' + pubkey.slice(0, 12) + '… — refusing (identity mismatch / squatter)')
  // 2) hand the (verified) invite to OUR local daemon → join
  let local
  try { local = await Client.connectUds(sock) } catch { return fail('cannot reach local daemon at ' + sock + ' (is `mirall daemon` running?)') }
  try {
    const joined = await local.call('rpc', { type: 'space:join', args: { inviteCode: invite } })
    out({ dialed: pubkey.slice(0, 16) + '…', joinedSpace: joined && joined.spaceId, name: joined && joined.name })
  } catch (e) { return fail('space:join failed: ' + e.message) }
  finally { try { local.close && local.close() } catch {} }
  Bare.exit(0)
}

;(async () => {
  if (!group || group === 'help' || flags.help) { out(USAGE); Bare.exit(0) }
  if (group === 'whoami') return runWhoami()      // local — no daemon
  if (group === 'contact') return runContact()    // local — no daemon
  if (group === 'dial') return runDial()          // swarm + local daemon
  if (group === 'daemon') return runDaemon()
  return runClient()
})().catch((err) => fail(err.message))