OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / cli / commands / peer.js

626 lines · javascript ref:
/**
 * 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`)
}