/**
* pgh device <subcommand>
*
* Subcommands:
* invite [--label <name>] — source device prints invite hex, waits
* join <inviteHex> — target device joins, writes linked-device
*
* Security model: we never transfer the mnemonic. We attest a fresh
* device keypair under the source's chain and transfer the device
* keypair + extended chain over an ephemeral blind-pairing channel.
* A compromised target can sign as its own device under the identity;
* the root stays on the source machine.
*/
import process from '#node:process'
import { isAbstract } from '../daemon-client.js'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import { writeFile, mkdir, chmod } from '#node:fs/promises'
import Hyperswarm from 'hyperswarm'
import { loadOrCreateIdentity, LINKED_DEVICE_FILE } from '../identity.js'
import { createInvite, joinInvite } from '../device-linker.js'
import { PghPeerClient } from '../peer-client.js'
const HYPERIDENT_HOME = path.join(homedir(), '.hyperident')
export const usage = `pgh device — link and manage devices on your identity
pgh device invite [--label <name>] Print an invite hex, wait for join
pgh device join <inviteHex> [--force] Accept an invite (writes linked-device)
pgh device list --peer <url> Devices linked to your identity
pgh device revoke <pubkey> --peer <url>
pgh device label <pubkey> <name> --peer <url>
Note: this is PERSONAL device linking. For sister-machine mirroring use
the v3-extended 'pgh device invite --mirror' (needs a running daemon).`
export async function run (_client, args, globals = {}) {
const sub = args[0]
if (!sub) throw new Error(usage)
if (sub === 'invite') return runInvite(args.slice(1), globals)
if (sub === 'join') return runJoin(args.slice(1), globals)
if (sub === 'list') return runList(args.slice(1), globals)
if (sub === 'revoke') return runRevoke(args.slice(1), globals)
if (sub === 'label') return runLabel(args.slice(1), globals)
throw new Error('unknown device subcommand: ' + sub + ' (supported: invite, join, list, revoke, label)')
}
/**
* Pull out --peer <url> from an args array. Returns [url | null, argsWithoutPeer].
* --peer is required for list/revoke/label since there's no local registry.
*/
function extractPeer (args) {
const i = args.indexOf('--peer')
if (i === -1) return [null, args]
const url = args[i + 1]
if (!url) throw new Error('--peer requires a URL (e.g. --peer http://host:4040)')
return [url, [...args.slice(0, i), ...args.slice(i + 2)]]
}
async function runList (args, globals) {
const [peer] = extractPeer(args)
if (!peer) throw new Error('usage: pgh device list --peer <url>')
const client = await PghPeerClient.connect({ url: peer })
try {
const result = await client.call('list-my-devices', {})
const devices = (result && result.devices) || []
if (globals.format === 'json') return devices
if (devices.length === 0) return '(no devices linked to this identity yet)'
const lines = ['devices on peer ' + peer + ':']
for (const d of devices) {
const when = d.firstLinkedAt ? new Date(d.firstLinkedAt).toISOString() : '-'
const seen = d.lastSeenAt ? new Date(d.lastSeenAt).toISOString() : '-'
const status = d.revoked ? 'REVOKED' : 'active'
lines.push(' ' + d.publicKey.slice(0, 16) + '…' +
' label=' + (d.label || '-') +
' status=' + status +
' linked=' + when +
' lastSeen=' + seen)
}
return lines.join('\n')
} finally {
await client.close()
}
}
async function runRevoke (args, globals) {
const [peer, rest] = extractPeer(args)
if (!peer || !rest[0]) throw new Error('usage: pgh device revoke <devicePubkey> --peer <url>')
const devicePubkey = rest[0]
const client = await PghPeerClient.connect({ url: peer })
try {
const result = await client.call('revoke-device', { devicePubkey })
if (globals.format === 'json') return result
return 'revoked: ' + devicePubkey.slice(0, 16) + '…'
} finally {
await client.close()
}
}
async function runLabel (args, globals) {
const [peer, rest] = extractPeer(args)
if (!peer || rest.length < 2) {
throw new Error('usage: pgh device label <devicePubkey> <label> --peer <url>')
}
const devicePubkey = rest[0]
const label = rest.slice(1).join(' ')
const client = await PghPeerClient.connect({ url: peer })
try {
const result = await client.call('label-device', { devicePubkey, label })
if (globals.format === 'json') return result
return 'labeled ' + devicePubkey.slice(0, 16) + '…' + ' as "' + label + '"'
} finally {
await client.close()
}
}
// Resolve a daemon's UDS via pgh-sidecar's app-lookup. Used by --mirror to
// route the invite through the daemon (which is the only place that knows
// the autobase bootstrap keys + repo-tracker map needed for the bundle).
async function daemonClient (appName) {
const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
if (!isAbstract(SIDECAR_SOCK) && !fs.existsSync(SIDECAR_SOCK)) {
throw new Error('pgh-sidecar is not running (no socket at ' + SIDECAR_SOCK + ')')
}
const { Client } = await import('hyper-svc')
const sc = await Client.connectUds(SIDECAR_SOCK)
let entry
try { entry = await sc.call('app-lookup', { name: appName }) } finally { try { await sc.close() } catch {} }
if (!entry || !entry.found) {
throw new Error(`app '${appName}' not registered with pgh-sidecar (is it running?)`)
}
return Client.connectUds(entry.udsPath)
}
async function runMirrorInvite (args, globals) {
// --mirror routes through the daemon's device-invite RPC. The daemon
// builds a deploymentBundle (autobase bootstrap keys + repo-tracker
// map + swarm topic + sourceServerKey), runs blind-pairing itself,
// and returns inviteHex. We poll device-invite-status until linked.
const labelIdx = args.indexOf('--label')
const label = (labelIdx !== -1 && args[labelIdx + 1]) || null
const appIdx = args.indexOf('--app')
const appName = (appIdx !== -1 && args[appIdx + 1]) || 'pear-git-v3'
const quiet = globals.format === 'json'
const client = await daemonClient(appName)
try {
const res = await client.call('device-invite', { mirror: true, label })
const inviteHex = res.inviteHex || res.invite
if (!inviteHex) throw new Error('daemon did not return inviteHex: ' + JSON.stringify(res))
if (!quiet) {
process.stderr.write('pgh device invite --mirror\n')
process.stderr.write(' app: ' + appName + '\n')
process.stderr.write(' label: ' + (label || '(none)') + '\n')
process.stderr.write(' paste this on the target device:\n\n')
process.stderr.write(' pgh device join ' + inviteHex + '\n\n')
process.stderr.write(' waiting (daemon driving blind-pairing)...\n')
}
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 2000))
const status = await client.call('device-invite-status', { inviteHex })
if (!status || status.status === 'waiting') continue
if (status.status === 'linked') {
if (!quiet) {
const dh = status.devicePublicKeyHex || ''
process.stderr.write(' linked device: ' + dh.slice(0, 16) + '… label=' + (label || '(none)') + '\n')
}
return { ok: true, invite: inviteHex, newDevicePubkey: status.devicePublicKeyHex, label }
}
if (status.status === 'failed') {
throw new Error('mirror invite failed: ' + (status.error || 'unknown'))
}
}
throw new Error('mirror invite did not complete within 5 minutes')
} finally {
try { await client.close() } catch {}
}
}
async function runInvite (args, globals) {
// Mirror flag routes through the daemon (it's the only side that knows
// the autobase bootstrap keys + repo map). Pure user-device invite is
// local-only.
if (args.includes('--mirror')) return runMirrorInvite(args, globals)
const labelIdx = args.indexOf('--label')
const label = (labelIdx !== -1 && args[labelIdx + 1]) || null
const identity = await loadOrCreateIdentity(HYPERIDENT_HOME)
if (!identity.chain) {
throw new Error('this pgh identity has no attestation chain — nothing to link from. Run on a device with a hyperident identity.')
}
const swarm = new Hyperswarm()
const quiet = globals.format === 'json'
try {
const { invite, linked, close } = await createInvite({
swarm,
sourceIdentity: identity,
label
})
if (!quiet) {
process.stderr.write('pgh device invite\n')
process.stderr.write(' label: ' + (label || '(none)') + '\n')
process.stderr.write(' paste this on the target device (valid until you Ctrl-C):\n\n')
process.stderr.write(' pgh device join ' + invite + '\n\n')
process.stderr.write(' waiting for a device to connect...\n')
}
const result = await linked
// Drain every swarm connection's write buffer before closing. The
// blind-pairing confirm response (which carries the device bundle to
// the candidate) is enqueued via protomux when the user's onadd
// returns, but by then `linked` has already resolved. Without this
// flush, swarm.destroy() tears down the noise stream before the
// bundle frame is transmitted — candidate's onadd never fires,
// candidate times out at PAIRING_TIMEOUT_MS thinking nothing happened.
//
// secret-stream.flush() awaits Writable.drained(stream) +
// rawStream.flush() which is exactly the right semantics here.
const flushes = []
for (const conn of swarm.connections) {
if (typeof conn.flush === 'function') flushes.push(conn.flush())
}
await Promise.all(flushes).catch(() => {})
try { await close() } catch {}
if (!quiet) {
process.stderr.write(' linked device: ' + result.newDevicePubkeyHex.slice(0, 16) + '… label=' + (result.label || '(none)') + '\n')
}
return {
ok: true,
invite,
newDevicePubkey: result.newDevicePubkeyHex,
label: result.label
}
} finally {
try { await swarm.destroy() } catch {}
}
}
async function runJoin (args, globals) {
const invite = args[0]
if (!invite) throw new Error('usage: pgh device join <inviteHex>')
const forceIdx = args.indexOf('--force')
const force = forceIdx !== -1
if (fs.existsSync(LINKED_DEVICE_FILE) && !force) {
throw new Error(
'linked-device file already exists at ' + LINKED_DEVICE_FILE +
' — pass --force to overwrite'
)
}
const quiet = globals.format === 'json'
const swarm = new Hyperswarm()
try {
if (!quiet) process.stderr.write('pgh device join — connecting...\n')
const payload = await joinInvite({ swarm, invite })
await mkdir(path.dirname(LINKED_DEVICE_FILE), { recursive: true })
await writeFile(LINKED_DEVICE_FILE, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 })
try { await chmod(LINKED_DEVICE_FILE, 0o600) } catch {}
// Mirror-invite side-effect: when the source included a
// deploymentBundle (`pgh device invite --mirror`), persist it to
// <PEAR_GIT_STORAGE>/origin-bundle.json so pear-git-v3 auto-boots
// joined to the source's rooms on next start. Backed-out to
// <storage>/mirror-bundle.json for pre-Phase-3 daemons that still
// read the legacy name.
let originBundlePath = null
if (payload.deploymentBundle) {
const storage = process.env.PEAR_GIT_STORAGE ||
path.join(process.env.HOME || '.', 'pgv3-storage')
await mkdir(storage, { recursive: true })
const bundle = {
// Field names expected by pear-git-v3's server.js +
// expandDeploymentBundle. chainB64 == deviceProof (both names
// present for cross-version compat).
deviceProof: payload.chainB64,
chainB64: payload.chainB64,
rootPubkey: payload.rootPubkeyHex,
devicePublicKey: payload.devicePublicKeyHex,
deviceSecretKey: payload.deviceSecretKeyHex,
...payload.deploymentBundle,
acceptedAt: new Date().toISOString()
}
const json = JSON.stringify(bundle, null, 2) + '\n'
originBundlePath = path.join(storage, 'origin-bundle.json')
await writeFile(originBundlePath, json)
// Compat alias — older server.js looks for mirror-bundle.json too
await writeFile(path.join(storage, 'mirror-bundle.json'), json)
}
if (!quiet) {
process.stderr.write(' wrote ' + LINKED_DEVICE_FILE + '\n')
process.stderr.write(' root pubkey: ' + payload.rootPubkeyHex + '\n')
process.stderr.write(' device pubkey: ' + payload.devicePublicKeyHex + '\n')
process.stderr.write(' label: ' + (payload.label || '(none)') + '\n')
if (originBundlePath) {
process.stderr.write(' origin bundle: ' + originBundlePath + '\n')
}
}
return {
ok: true,
path: LINKED_DEVICE_FILE,
originBundle: originBundlePath,
rootPubkey: payload.rootPubkeyHex,
devicePubkey: payload.devicePublicKeyHex,
label: payload.label
}
} finally {
try { await swarm.destroy() } catch {}
}
}