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