/**
* pgh peer commands
*
* Two levels of peering:
*
* 1. SERVER PEERING (pgh peer add/join/servers/repair)
* Connect two pear-git servers. Two modes:
* - mirror: full replication (all repos, all Autobases)
* - peer: selective (browse remote, sync chosen repos)
*
* 2. REPO PEERING (pgh peer invite/accept/list/revoke/forks)
* Per-repo P2P access. Two models, auto-detected:
* - direct-git: push access to server's repo
* - hyperdrive: fork & pull via Hyperdrive
*
* Subcommands:
* Server: add, join, servers, repair, remove, sync
* Repo: invite, accept, list, revoke, forks, status
*/
import { jsonOut, log, error } from '../output.js'
export async function peerCommand (client, sub, args, flags) {
switch (sub) {
// Server peering commands
case 'add':
return serverAdd(client, args, flags)
case 'join':
return serverJoin(client, args, flags)
case 'servers':
return serverList(client, flags)
case 'repair':
return serverRepair(client, flags)
case 'remove':
return serverRemove(client, args, flags)
case 'sync':
return serverSync(client, flags)
// Repo peering commands (existing)
case 'invite':
return peerInvite(client, args, flags)
case 'accept':
return peerAccept(client, args, flags)
case 'list':
return serverList(client, flags)
case 'invites':
return peerInviteList(client, args, flags)
case 'revoke':
return peerRevoke(client, args, flags)
case 'forks':
return peerForks(client, args, flags)
case 'status':
return serverStatus(client, flags)
default:
peerHelp()
}
}
// ============================================================
// Server Peering Commands
// ============================================================
async function serverAdd (client, args, flags) {
const mode = flags.mirror ? 'mirror' : 'peer'
try {
const result = await client.post('/api/peer/invite', { mode })
if (flags.json) {
jsonOut(result)
return
}
log(`Server peering invite created (${mode} mode):`)
log('')
log('Invite:')
log(result.invite)
log('')
log(`Share this with the other server:`)
log(` pgh peer join ${result.invite}`)
log('')
if (mode === 'mirror') {
log('Mirror mode: both servers will fully replicate (all repos, all registries).')
} else {
log('Peer mode: selective — the other server can browse and sync chosen repos.')
}
log('')
log('Waiting for the other server to join...')
} catch (err) {
error('Failed to create invite: ' + err.message)
}
}
async function serverJoin (client, args, flags) {
const invite = args[0]
if (!invite) {
error('Usage: pgh peer join <invite-hex>')
return
}
try {
const result = await client.post('/api/peer/join', { invite })
if (flags.json) {
jsonOut(result)
return
}
if (!result.success) {
error('Join failed: ' + (result.error || 'unknown error'))
return
}
log(`Joined server (${result.mode} mode):`)
log(` Server: ${result.hostname || result.serverKey.slice(0, 16) + '...'}`)
log(` Key: ${result.serverKey.slice(0, 24)}...`)
log(` Mode: ${result.mode}`)
log('')
const shared = []
if (result.identityRegistryKey) shared.push('identity')
if (result.namespaceRegistryKey) shared.push('namespace')
if (result.deployRegistryKey) shared.push('deploy')
if (shared.length > 0) {
log(` Registries: ${shared.join(', ')}`)
}
log('')
log('Server sync will begin shortly.')
} catch (err) {
error('Failed to join: ' + err.message)
}
}
async function serverList (client, flags) {
try {
const result = await client.get('/api/peer/list')
if (flags.json) {
jsonOut(result)
return
}
const peers = result.peers || []
if (peers.length === 0) {
log('No peered servers')
log('')
log('Create an invite: pgh peer add --mirror')
return
}
log(`Peered Servers (${peers.length}):`)
log('\u2500'.repeat(72))
for (const peer of peers) {
const name = peer.displayName || peer.hostname || peer.serverKey.slice(0, 16) + '...'
const mode = peer.mode || 'peer'
const status = peer.status || 'unknown'
const url = peer.httpUrl || 'no URL'
const statusMark = status === 'active' ? '\u2713' : '\u2717'
log(` ${statusMark} ${name} [${mode}] ${status} ${url}`)
log(` Key: ${peer.serverKey.slice(0, 32)}...`)
}
} catch (err) {
error('Failed to list peers: ' + err.message)
}
}
async function serverStatus (client, flags) {
try {
const result = await client.get('/api/peer/status')
if (flags.json) {
jsonOut(result)
return
}
log('Peering Status:')
log(` Hostname: ${result.hostname}`)
log(` Server key: ${result.selfKey ? result.selfKey.slice(0, 24) + '...' : 'none'}`)
log(` HTTP URL: ${result.httpUrl}`)
log(` Peers: ${result.peerCount}`)
log(` Repos: ${result.repoCount}`)
log(` Sync active: ${result.syncActive ? 'yes' : 'no'}`)
log('')
log('Registries:')
for (const [name, reg] of Object.entries(result.registries || {})) {
const mark = reg.status === 'active' ? '\u2713' : '\u2717'
log(` ${mark} ${name}: ${reg.key ? reg.key.slice(0, 16) + '...' : 'not initialized'}`)
}
if (result.peers && result.peers.length > 0) {
log('')
log('Peers:')
for (const peer of result.peers) {
const name = peer.displayName || peer.hostname || peer.serverKey.slice(0, 16) + '...'
log(` ${peer.status === 'active' ? '\u2713' : '\u2717'} ${name} [${peer.mode || 'peer'}] ${peer.httpUrl || 'no URL'}`)
}
}
} catch (err) {
error('Failed to get status: ' + err.message)
}
}
async function serverRepair (client, flags) {
try {
log('Running peering repair audit...')
log('')
const result = await client.post('/api/peer/repair', { apply: true })
if (flags.json) {
jsonOut(result)
return
}
if (!result.success && result.message) {
log(result.message)
return
}
const repairs = result.repairs || []
if (repairs.length === 0) {
log('All registries properly shared. No repairs needed.')
return
}
for (const r of repairs) {
const statusMark = r.status === 'missing' ? '\u2717' : r.status === 'independent' ? '~' : '\u2713'
const fixMark = r.applied ? ' \u2713 FIXED' : ''
log(` ${statusMark} ${r.type}: ${r.description}${fixMark}`)
if (r.note) log(` ${r.note}`)
}
log('')
log(result.message)
} catch (err) {
error('Repair failed: ' + err.message)
}
}
async function serverRemove (client, args, flags) {
const serverKey = args[0]
if (!serverKey) {
error('Usage: pgh peer remove <server-key>')
return
}
try {
const result = await client.del(`/api/peer/${serverKey}`)
if (result.success) {
log(`Peer ${serverKey.slice(0, 16)}... removed`)
} else {
error('Failed to remove: ' + (result.error || 'not found'))
}
} catch (err) {
error('Failed to remove peer: ' + err.message)
}
}
async function serverSync (client, flags) {
try {
const result = await client.post('/api/peer/sync', {})
if (flags.json) {
jsonOut(result)
return
}
log(result.message || 'Sync triggered')
} catch (err) {
error('Sync failed: ' + err.message)
}
}
// ============================================================
// Repo Peering Commands (existing, unchanged)
// ============================================================
async function peerInvite (client, args, flags) {
const repo = flags.repo || args[0]
if (!repo) {
error('Usage: pgh peer invite --repo <name> [--permissions read,write] [--expires 24h]')
return
}
const permissions = flags.permissions ? flags.permissions.split(',') : ['read']
const expires = flags.expires || '24h'
try {
const result = await client.post('/api/p2p/invites', {
repoName: repo,
permissions,
expires
})
if (flags.json) {
jsonOut(result)
return
}
const strategy = result.strategy || 'direct-git'
const isHyperdrive = strategy === 'hyperdrive'
log(`Invite created (${isHyperdrive ? 'fork & pull' : 'server push'}):`)
log(` Code: ${result.code}`)
log(` Repo: ${repo}`)
log(` Strategy: ${strategy}`)
log(` Permissions: ${permissions.join(', ')}`)
log(` Expires: ${result.expiresAt ? new Date(result.expiresAt).toLocaleString() : 'never'}`)
if (isHyperdrive && result.originDriveKey) {
log(` Drive key: ${result.originDriveKey}`)
}
log('')
log(`Share this code: pgh peer accept ${result.code}`)
log('')
if (isHyperdrive) {
log('The peer will clone a Hyperdrive fork they own.')
log('You will replicate from their fork for pull-based merging.')
log('Git handles merges naturally — each side owns their drive.')
} else {
log('The peer will be able to push directly to this server\'s repo.')
}
} catch (err) {
error('Failed to create invite: ' + err.message)
}
}
async function peerAccept (client, args, flags) {
const code = args[0]
if (!code) {
error('Usage: pgh peer accept <invite-code> [--fork-key <hex>] [--peer-key <hex>]')
return
}
const body = { code }
// Hyperswarm public key for P2P push auth
if (flags['peer-key']) {
body.peerKey = flags['peer-key']
}
// If peer already has a Hyperdrive fork, they can register it
if (flags['fork-key']) {
body.forkDriveKey = flags['fork-key']
}
try {
const result = await client.post('/api/p2p/invites/accept', body)
if (flags.json) {
jsonOut(result)
return
}
if (!result.success) {
error('Invite rejected: ' + (result.error || 'invalid or expired'))
return
}
const strategy = result.strategy || 'direct-git'
const isHyperdrive = strategy === 'hyperdrive'
log(`Invite accepted (${isHyperdrive ? 'fork & pull' : 'server push'}):`)
log(` Repo: ${result.repoName}`)
log(` Strategy: ${strategy}`)
log(` Permissions: ${(result.permissions || []).join(', ')}`)
log(` User ID: ${result.userId}`)
if (isHyperdrive && result.originDriveKey) {
log(` Origin key: ${result.originDriveKey}`)
log('')
if (result.forkRegistered) {
log('Your fork has been registered with the origin server.')
log('The server will replicate from your drive.')
} else {
log('To contribute changes, create a Hyperdrive fork and register it:')
log(` pgh peer forks register --repo ${result.repoName} --drive-key <your-fork-key>`)
log('')
log('Create a fork by cloning the origin drive:')
log(` Origin drive: ${result.originDriveKey}`)
}
} else {
log('')
if (body.peerKey) {
log('Your Hyperswarm key has been registered for P2P push access.')
log('You can now push via: git push punch://<server-key>/<repo>')
} else {
log('You can push to the server\'s repo via HTTP/HTTPS.')
log('For P2P push, re-accept with your Hyperswarm key:')
log(` pgh peer accept ${code} --peer-key <your-swarm-key>`)
}
}
} catch (err) {
error('Failed to accept invite: ' + err.message)
}
}
async function peerInviteList (client, args, flags) {
const repo = flags.repo || args[0]
try {
const url = repo ? `/api/p2p/invites?repo=${repo}` : '/api/p2p/invites'
const result = await client.get(url)
if (flags.json) {
jsonOut(result)
return
}
const invites = result.invites || []
if (invites.length === 0) {
log('No active invites')
return
}
log('Active invites:')
log('\u2500'.repeat(72))
for (const inv of invites) {
const expires = inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : 'never'
const used = inv.usedBy ? `used by ${inv.usedBy.length}` : 'unused'
const strategy = inv.strategy || 'direct-git'
const model = strategy === 'hyperdrive' ? 'fork' : 'push'
log(` ${inv.code.slice(0, 12)}... ${inv.repoName} [${model}] ${(inv.permissions || []).join(',')} ${used} expires: ${expires}`)
}
log(`\nTotal: ${invites.length}`)
} catch (err) {
error('Failed to list invites: ' + err.message)
}
}
async function peerRevoke (client, args, flags) {
const code = args[0]
if (!code) {
error('Usage: pgh peer revoke <invite-code>')
return
}
try {
const result = await client.del(`/api/p2p/invites/${code}`)
if (result.success) {
log(`Invite ${code.slice(0, 12)}... revoked`)
} else {
error('Failed to revoke: ' + (result.error || 'not found'))
}
} catch (err) {
error('Failed to revoke invite: ' + err.message)
}
}
async function peerForks (client, args, flags) {
const sub = args[0]
if (sub === 'register') {
return peerForksRegister(client, flags)
}
if (sub === 'unregister') {
return peerForksUnregister(client, args.slice(1), flags)
}
// Default: list forks
const repo = flags.repo || sub
try {
const url = repo ? `/api/p2p/forks?repo=${repo}` : '/api/p2p/forks'
const result = await client.get(url)
if (flags.json) {
jsonOut(result)
return
}
const forks = result.forks || []
if (forks.length === 0) {
log(repo ? `No forks for ${repo}` : 'No registered forks')
return
}
log('Registered forks:')
log('\u2500'.repeat(72))
for (const fork of forks) {
const age = timeSince(fork.registeredAt)
log(` ${fork.driveKey.slice(0, 16)}... ${fork.repoName} by ${fork.ownerId} ${age} ago`)
}
log(`\nTotal: ${forks.length}`)
log('The server replicates from these forks for pull-based merging.')
} catch (err) {
error('Failed to list forks: ' + err.message)
}
}
async function peerForksRegister (client, flags) {
const repo = flags.repo
const driveKey = flags['drive-key']
if (!repo || !driveKey) {
error('Usage: pgh peer forks register --repo <name> --drive-key <hex>')
return
}
try {
const result = await client.post('/api/p2p/forks', {
repoName: repo,
driveKey
})
if (flags.json) {
jsonOut(result)
return
}
if (result.success) {
log('Fork registered:')
log(` Repo: ${repo}`)
log(` Your key: ${driveKey.slice(0, 16)}...`)
if (result.originDriveKey) {
log(` Origin key: ${result.originDriveKey.slice(0, 16)}...`)
}
log('')
log('The server will replicate from your Hyperdrive fork.')
log('Push changes to your fork — the server pulls them automatically.')
} else {
error('Failed to register fork')
}
} catch (err) {
error('Failed to register fork: ' + err.message)
}
}
async function peerForksUnregister (client, args, flags) {
const key = args[0] || flags['drive-key']
if (!key) {
error('Usage: pgh peer forks unregister <drive-key>')
return
}
try {
const result = await client.del(`/api/p2p/forks/${key}`)
if (result.success) {
log(`Fork ${key.slice(0, 16)}... unregistered`)
} else {
error('Failed to unregister: ' + (result.error || 'not found'))
}
} catch (err) {
error('Failed to unregister fork: ' + err.message)
}
}
function timeSince (ts) {
const seconds = Math.floor((Date.now() - ts) / 1000)
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
return `${Math.floor(seconds / 86400)}d`
}
export function peerHelp () {
log(`Usage: pgh peer <subcommand> [args] [flags]
SERVER PEERING — Connect pear-git servers:
add [--mirror|--peer] Create server peering invite
join <invite> Join a server peer via invite hex
list | servers List peered servers
status Detailed peering status (registries, sync)
repair Detect and fix holes in partially merged state
remove <server-key> Disconnect from a peered server
sync Force immediate sync with all peers
REPO PEERING — Per-repo P2P access:
invite --repo <name> Create invite code for a repo
accept <code> Accept a repo invite code
invites [--repo <name>] List active repo invites
revoke <code> Revoke a repo invite
forks [repo] List registered Hyperdrive forks
forks register Register your fork with the origin server
forks unregister Remove a fork registration
Server Peering Flags:
--mirror Full replication mode (all repos, all registries)
--peer Selective mode (default — browse remote, sync chosen repos)
--json Output as JSON
Repo Peering Flags:
--repo <name> Repository name
--permissions <p> Comma-separated permissions (default: read)
--expires <time> Expiry time (e.g., 24h, 7d, never)
--fork-key <hex> Your Hyperdrive fork key (for accept)
--peer-key <hex> Your Hyperswarm public key (for P2P push auth)
--drive-key <hex> Drive key (for forks register/unregister)
Examples:
pgh peer add --mirror # Create mirror invite
pgh peer join abc123... # Join with invite hex
pgh peer servers # List peered servers
pgh peer status # Detailed peering status
pgh peer repair # Fix partially merged state
pgh peer invite --repo pear-git # Per-repo invite
pgh peer accept abc123def456 # Accept repo invite`)
}