OverviewFilesCommitsIssuesDocsReleasesPeers

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

563 lines · javascript ref:
/**
 * pgh registry commands
 *
 * Subcommands: share, join, invite, unlink, status, peers
 *
 * Also includes federation and access subcommands for the
 * three-layer separation (federation, access grants, namespaces).
 */

import { jsonOut, log, error } from '../output.js'

export async function registryCommand (client, sub, args, flags) {
  switch (sub) {
    // Legacy registry commands (still functional)
    case 'share':
      return registryShare(client, flags)
    case 'join':
      return registryJoin(client, args[0], flags)
    case 'invite':
      return registryInvite(client, flags)
    case 'unlink':
      return registryUnlink(client, flags)
    case 'status':
      return registryStatus(client, flags)
    case 'peers':
      return registryPeers(client, flags)
    default:
      registryHelp()
  }
}

/**
 * pgh federation commands — server-to-server linking
 */
export async function federationCommand (client, sub, args, flags) {
  switch (sub) {
    case 'invite':
      return federationInvite(client, flags)
    case 'join':
      return federationJoin(client, args[0], flags)
    case 'peers':
      return federationPeers(client, flags)
    case 'revoke':
      return federationRevoke(client, args[0], flags)
    case 'sync':
      return federationSync(client, args[0], flags)
    case 'status':
      return federationStatus(client, flags)
    case 'update':
      return federationUpdate(client, args[0], flags)
    default:
      federationHelp()
  }
}

/**
 * pgh access commands — grant/invite management
 */
export async function accessCommand (client, sub, args, flags) {
  switch (sub) {
    case 'grants':
      return accessGrants(client, flags)
    case 'invites':
      return accessInvites(client, flags)
    case 'grant-group':
      return accessGrantGroup(client, args[0], flags)
    case 'grant-repo':
      return accessGrantRepo(client, args[0], flags)
    case 'join':
      return accessJoin(client, args[0], flags)
    case 'revoke':
      return accessRevoke(client, args[0], flags)
    default:
      accessHelp()
  }
}

// ========================================
// Legacy registry commands
// ========================================

async function registryShare (client, flags) {
  const data = await client.post('/api/registry/share', {})

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (data.error) {
    error(data.error)
    return
  }

  log('\u2713 Namespace registry shared as P2P Autobase')
  log(`  Bootstrap key: ${data.bootstrapKey}`)
  if (data.invite) {
    log('')
    log('Invite (share with peers to join):')
    log(`  ${data.invite}`)
  }
}

async function registryJoin (client, invite, flags) {
  if (!invite) error('Usage: pgh registry join <invite>')

  const data = await client.post('/api/registry/join', { invite })

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (data.error) {
    error(data.error)
    return
  }

  log('\u2713 Joined P2P namespace registry')
  log(`  Bootstrap key: ${data.bootstrapKey}`)
}

async function registryInvite (client, flags) {
  const data = await client.post('/api/registry/invite', {})

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (data.error) {
    error(data.error)
    return
  }

  log('Invite (share with peers to join):')
  log(`  ${data.invite}`)
}

async function registryUnlink (client, flags) {
  const data = await client.del('/api/registry/unlink')

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (data.error) {
    error(data.error)
    return
  }

  log('\u2713 Unlinked from shared registry')
  log('  Registry mode is now: local')
}

async function registryStatus (client, flags) {
  const data = await client.get('/api/registry/status')

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (!data.active) {
    log('Registry mode: local (not shared)')
    log('')
    log('Run `pgh registry share` to create a P2P shared registry.')
    return
  }

  log('Registry mode: shared (P2P Autobase)')
  log(`  Bootstrap key: ${data.bootstrapKey}`)
  log(`  Writable:      ${data.writable}`)
  log(`  Writers:       ${data.writerCount}`)
  log(`  Namespaces:    ${data.namespaceCount}`)
}

async function registryPeers (client, flags) {
  const data = await client.get('/api/registry/peers')

  if (flags.json) {
    jsonOut(data)
    return
  }

  if (data.count === 0) {
    log('No writer peers.')
    return
  }

  log(`Writer peers (${data.count}):`)
  log('')
  for (const w of data.writers) {
    const keyShort = w.serverKey ? w.serverKey.slice(0, 16) + '...' : 'unknown'
    log(`  ${w.peerId.padEnd(20)} ${w.role.padEnd(8)} ${keyShort}`)
  }
}

// ========================================
// Federation commands
// ========================================

async function federationInvite (client, flags) {
  const data = await client.post('/api/federation/invite', {})

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log('\u2713 Federation invite created')
  log(`  Server key: ${data.serverKey}`)
  log('')
  log('Invite (share with another server admin):')
  log(`  ${data.invite}`)
}

async function federationJoin (client, invite, flags) {
  if (!invite) { error('Usage: pgh federation join <invite>'); return }

  const data = await client.post('/api/federation/join', { invite })

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log('\u2713 Federated with server')
  log(`  Server key: ${data.serverKey}`)
  if (data.registryBootstrapKey) {
    log(`  Registry:   ${data.registryBootstrapKey.slice(0, 16)}...`)
  }
}

async function federationPeers (client, flags) {
  const data = await client.get('/api/federation/peers')

  if (flags.json) { jsonOut(data); return }

  if (data.count === 0) {
    log('No federated servers.')
    log('')
    log('Run `pgh federation invite` to create an invite.')
    return
  }

  log(`Federated servers (${data.count}):`)
  log('')
  for (const p of data.peers) {
    const status = p.status === 'active' ? '\u2713' : '\u2717'
    const keyShort = p.serverKey ? p.serverKey.slice(0, 16) + '...' : 'unknown'
    log(`  ${status} ${keyShort}  ${p.displayName || ''}  ${p.status}`)
  }
  if (data.selfKey) {
    log('')
    log(`  This server: ${data.selfKey.slice(0, 16)}...`)
  }
}

async function federationRevoke (client, serverKey, flags) {
  if (!serverKey) { error('Usage: pgh federation revoke <serverKey>'); return }

  const data = await client.del(`/api/federation/peers/${serverKey}`)

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log('\u2713 Federation revoked')
  log(`  Server key: ${serverKey.slice(0, 16)}...`)
}

// ========================================
// Federation sync / status / update
// ========================================

async function federationSync (client, repoName, flags) {
  if (!repoName) {
    // Sync all via ServerSync tick
    const data = await client.post('/api/peer/sync', {})
    if (flags.json) { jsonOut(data); return }
    log(data.message || 'Full sync triggered (ServerSync)')
    return
  }

  // Push-triggered sync for a specific repo
  try {
    const data = await client.post(`/api/peer/sync/${repoName}`, {})
    if (flags.json) { jsonOut(data); return }
    if (data.error) { error(data.error); return }
    log(`\u2713 Sync notification sent for ${repoName}`)
  } catch (err) {
    error('Sync failed: ' + err.message)
  }
}

async function federationStatus (client, flags) {
  try {
    // Get peers + sync log
    const [peerData, syncData] = await Promise.all([
      client.get('/api/peer/list'),
      client.get('/api/peer/sync-log')
    ])

    if (flags.json) { jsonOut({ peers: peerData, syncLog: syncData }); return }

    const peers = peerData.peers || []
    if (peers.length === 0) {
      log('No federation peers.')
      return
    }

    log(`Federation peers (${peers.length}):`)
    log('\u2500'.repeat(72))
    for (const p of peers) {
      const name = p.displayName || p.serverKey.slice(0, 12) + '...'
      const mode = (p.mode || 'mirror').padEnd(7)
      const status = p.status || 'active'
      log(`  ${name.padEnd(16)} ${mode} ${status.padEnd(8)} ${p.serverKey.slice(0, 16)}...`)
    }

    const entries = syncData.entries || []
    if (entries.length > 0) {
      log('')
      log(`Recent sync events (${entries.length}):`)
      log('\u2500'.repeat(72))
      for (const e of entries.slice(0, 15)) {
        const time = new Date(e.timestamp).toLocaleTimeString()
        const dir = e.direction === 'received' ? '\u2190 recv' : '\u2192 sent'
        const ok = e.success ? '\u2713' : '\u2717'
        const peer = (e.serverKey || '').slice(0, 8)
        log(`  ${time}  ${ok} ${dir}  ${e.repoName}  from ${peer}`)
      }
    } else {
      log('\nNo sync events recorded yet.')
    }
  } catch (err) {
    error('Status failed: ' + err.message)
  }
}

async function federationUpdate (client, peerName, flags) {
  if (!peerName) { error('Usage: pgh federation update <peer-name-or-key>'); return }

  try {
    // First try push-sync for pear-git
    log(`Attempting P2P sync of pear-git to ${peerName}...`)
    const syncResult = await client.post('/api/peer/sync/pear-git', {})

    if (syncResult.success) {
      log('\u2713 P2P sync notification sent for pear-git')
      log('  Peer will fetch and update automatically.')

      if (flags.ssh) {
        log('')
        log(`To also restart the remote server:`)
        log(`  ssh ${flags.ssh} "tmux send-keys -t pear-seed:pear-git C-c && sleep 2 && tmux send-keys -t pear-seed:pear-git 'bare pear-git-server.js serve' Enter"`)
      }
      return
    }

    // P2P failed — try SSH fallback
    if (!flags.ssh) {
      log('\u2717 P2P sync failed. Use --ssh to fall back:')
      log(`  pgh federation update ${peerName} --ssh user@host`)
      return
    }

    log(`P2P sync failed, falling back to SSH (${flags.ssh})...`)
    error('SSH fallback not yet implemented. Manual update needed:')
    log(`  ssh ${flags.ssh} "cd <install-path> && git fetch origin release && git reset --hard origin/release"`)
  } catch (err) {
    error('Update failed: ' + err.message)
  }
}

// ========================================
// Access commands
// ========================================

async function accessGrants (client, flags) {
  const params = new URLSearchParams()
  if (flags.group) params.set('groupName', flags.group)
  if (flags.repo) params.set('repoName', flags.repo)
  if (flags.user) params.set('subjectId', flags.user)

  const data = await client.get('/api/access/grants?' + params.toString())

  if (flags.json) { jsonOut(data); return }

  if (data.count === 0) {
    log('No access grants.')
    return
  }

  log(`Access grants (${data.count}):`)
  log('')
  for (const g of data.grants) {
    const subject = g.subjectId ? g.subjectId.slice(0, 16) + '...' : 'unknown'
    const target = g.groupName || g.repoName || 'unknown'
    const revoked = g.revokedAt ? ' [REVOKED]' : ''
    log(`  ${g.subjectType.padEnd(7)} ${subject.padEnd(20)} ${g.permission.padEnd(10)} ${g.grantType}:${target}${revoked}`)
  }
}

async function accessInvites (client, flags) {
  const data = await client.get('/api/access/invites')

  if (flags.json) { jsonOut(data); return }

  if (data.count === 0) {
    log('No active invites.')
    return
  }

  log(`Active invites (${data.count}):`)
  log('')
  for (const inv of data.invites) {
    const target = inv.targetGroup || inv.targetRepo || 'unknown'
    const uses = inv.maxUses > 0 ? `${inv.usedCount}/${inv.maxUses}` : `${inv.usedCount}/unlimited`
    log(`  ${inv.inviteId.padEnd(30)} ${inv.permission.padEnd(10)} ${inv.inviteType}:${target}  uses: ${uses}`)
  }
}

async function accessGrantGroup (client, groupName, flags) {
  if (!groupName) { error('Usage: pgh access grant-group <groupName> [--permission write]'); return }

  const permission = flags.permission || 'write'
  const data = await client.post(`/api/groups/${groupName}/access-invite`, { permission })

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log(`\u2713 Access invite created for group "${groupName}" (${permission})`)
  log('')
  log('Invite (share with user or server admin):')
  log(`  ${data.invite}`)
}

async function accessGrantRepo (client, repoName, flags) {
  if (!repoName) { error('Usage: pgh access grant-repo <repoName> [--permission write]'); return }

  const permission = flags.permission || 'write'
  const data = await client.post(`/api/repos/${repoName}/access-invite`, { permission })

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log(`\u2713 Access invite created for repo "${repoName}" (${permission})`)
  log('')
  log('Invite (share with user or server admin):')
  log(`  ${data.invite}`)
}

async function accessJoin (client, invite, flags) {
  if (!invite) { error('Usage: pgh access join <invite>'); return }

  const body = { invite }
  if (flags.server) body.asServer = true

  const data = await client.post('/api/access/join', body)

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log('\u2713 Access granted')
  if (data.grant) {
    log(`  Permission: ${data.grant.permission}`)
    log(`  Target:     ${data.grant.groupName || data.grant.repoName || 'unknown'}`)
  }
}

async function accessRevoke (client, grantId, flags) {
  if (!grantId) { error('Usage: pgh access revoke <grantId>'); return }

  const data = await client.del(`/api/access/grants/${grantId}`)

  if (flags.json) { jsonOut(data); return }
  if (data.error) { error(data.error); return }

  log('\u2713 Grant revoked')
}

// ========================================
// Help
// ========================================

export function registryHelp () {
  log(`Usage: pgh registry <subcommand> [args] [flags]

Subcommands:
  share                    Share local namespaces as P2P registry
  join <invite>            Join existing P2P namespace registry
  invite                   Create new invite for existing registry
  unlink                   Disconnect from shared registry
  status                   Show registry status
  peers                    List registry writer peers

Flags:
  --json                   Output as JSON

Examples:
  pgh registry share                              # Create shared registry
  pgh registry join abc123...                      # Join with invite
  pgh registry invite                              # Generate invite for peers
  pgh registry unlink                              # Disconnect from shared registry
  pgh registry status                              # Check registry mode
  pgh registry peers                               # List writer peers`)
}

export function federationHelp () {
  log(`Usage: pgh federation <subcommand> [args] [flags]

Server-to-server linking. Federated servers get full read access.

Subcommands:
  invite                   Create federation invite
  join <invite>            Join a federated server
  peers                    List federated servers
  revoke <serverKey>       Revoke federation with a server
  sync [repo]              Push-sync a repo to peers (or all if no repo)
  status                   Show federation peers and recent sync events
  update <peer> [--ssh]    Update a peer's pear-git code (P2P + SSH fallback)

Flags:
  --json                   Output as JSON
  --ssh <user@host>        SSH connection for fallback (federation update)

Examples:
  pgh federation invite                  # Create invite for another server
  pgh federation join abc123...          # Join a federated server
  pgh federation peers                   # List all federated servers
  pgh federation sync hyper-note         # Push-sync hyper-note to all peers
  pgh federation status                  # Show peers + recent sync events
  pgh federation update stortron --ssh fleeky@stortron.local`)
}

export function accessHelp () {
  log(`Usage: pgh access <subcommand> [args] [flags]

Access grants control who can write/moderate/own groups and repos.
Read access is always allowed (federation = full read).

Subcommands:
  grants                   List access grants
  invites                  List active invites
  grant-group <name>       Create access invite for a group
  grant-repo <name>        Create access invite for a repo
  join <invite>            Join using an access invite
  revoke <grantId>         Revoke an access grant

Flags:
  --permission <perm>      Permission level (write|moderator|owner, default: write)
  --server                 Join as server (vs individual user)
  --group <name>           Filter grants by group
  --repo <name>            Filter grants by repo
  --user <id>              Filter grants by user
  --json                   Output as JSON

Examples:
  pgh access grant-group myproject                        # Invite for write access
  pgh access grant-repo core-lib --permission moderator   # Moderator on repo
  pgh access join abc123...                                # Accept invite
  pgh access grants --group myproject                      # List group grants
  pgh access revoke user-abc-1234567890                    # Revoke a grant`)
}