OverviewFilesCommitsIssuesDocsReleasesPeers

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

418 lines · javascript ref:
/**
 * pgh server commands
 *
 * Manage server identity transfer and federation sync.
 *
 * Subcommands: export-mnemonic, link, join, status
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import { ServerIdentity } from '../../p2p/identity/server-identity.js'
import { ServerLinker } from '../../p2p/identity/server-linker.js'
import { StorageLocator } from '../../storage-locator.js'
import { log, error, jsonOut } from '../output.js'

const PEAR_GIT_CONFIG_DIR = path.join(os.homedir(), '.config', 'pear-git')
const DEFAULT_STORAGE = path.join(PEAR_GIT_CONFIG_DIR, 'server-data')
const DEFAULT_REPOS_DIR = path.join(os.homedir(), 'git-repos')

/**
 * Resolve storage path from flags, locator, or default
 */
function resolveStorage (flags) {
  if (flags.storage) return path.resolve(flags.storage)
  const locator = StorageLocator.readRaw()
  if (locator) return locator
  return DEFAULT_STORAGE
}

/**
 * Load server identity from storage
 */
async function loadServerIdentity (storagePath) {
  const identity = new ServerIdentity({ storagePath })
  await identity.init()
  return identity
}

/**
 * Get list of repo names from --repos flag or default ~/git-repos/
 */
function listRepoNames (flags) {
  const reposDir = flags.repos || DEFAULT_REPOS_DIR
  try {
    const entries = fs.readdirSync(reposDir)
    return entries.filter(e => e.endsWith('.git')).map(e => e.replace(/\.git$/, ''))
  } catch {
    return []
  }
}

export async function serverCommand (client, sub, args, flags) {
  switch (sub) {
    case 'export-mnemonic':
      return serverExportMnemonic(flags)
    case 'link':
      return serverLink(flags)
    case 'join':
      return serverJoin(args, flags)
    case 'status':
      return serverStatus(flags)
    case 'identity-key':
      return serverIdentityKey(client, flags)
    case 'identity-link':
      return serverIdentityLink(client, args, flags)
    case 'identity-add-writer':
      return serverIdentityAddWriter(client, args, flags)
    default:
      serverHelp()
  }
}

/**
 * Export server identity as BIP39 mnemonic
 */
async function serverExportMnemonic (flags) {
  const storagePath = resolveStorage(flags)

  let identity
  try {
    identity = await loadServerIdentity(storagePath)
  } catch (err) {
    error('Failed to load server identity: ' + err.message)
    return
  }

  const mnemonic = identity.toMnemonic()

  if (flags.json) {
    jsonOut({
      serverKey: identity.getPublicKeyHex(),
      mnemonic
    })
    return
  }

  log('Server Identity Mnemonic')
  log('\u2500'.repeat(40))
  log(`Server key: ${identity.getPublicKeyHex().slice(0, 16)}...`)
  log('')
  log('Recovery mnemonic (24 words):')
  log(`  ${mnemonic}`)
  log('')
  log('Store this securely. Anyone with this mnemonic can impersonate your server.')
}

/**
 * Create a link invite for server-to-server identity transfer
 */
async function serverLink (flags) {
  const storagePath = resolveStorage(flags)

  let identity
  try {
    identity = await loadServerIdentity(storagePath)
  } catch (err) {
    error('Failed to load server identity: ' + err.message)
    return
  }

  const repoList = listRepoNames(flags)

  const linker = new ServerLinker({
    serverIdentity: identity,
    storagePath
  })

  try {
    const { inviteHex, waitForPeer } = await linker.createInvite({
      repoList,
      deployKeys: {}
    })

    log('Server link invite created.')
    log('')
    log('Invite code (share with the target server):')
    log(`  ${inviteHex}`)
    log('')
    log(`Repos included: ${repoList.length}`)
    if (repoList.length > 0) {
      for (const name of repoList.slice(0, 10)) {
        log(`  - ${name}`)
      }
      if (repoList.length > 10) {
        log(`  ... and ${repoList.length - 10} more`)
      }
    }
    log('')
    log('On the target server, run:')
    log(`  pgh server join ${inviteHex.slice(0, 16)}...`)
    log('')
    log('Waiting for target server to connect...')

    const result = await waitForPeer()
    log('')
    log('\u2713 Target server connected.')
    if (result.targetServerKey) {
      log(`  Target key: ${result.targetServerKey.slice(0, 16)}...`)
    }
  } catch (err) {
    error('Link failed: ' + err.message)
  } finally {
    await linker.close()
  }
}

/**
 * Join with an invite from a source server
 */
async function serverJoin (args, flags) {
  const inviteHex = args[0]
  if (!inviteHex) {
    error('Usage: pgh server join <invite-hex>')
    return
  }

  const storagePath = resolveStorage(flags)

  const linker = new ServerLinker({ storagePath })

  log('Connecting to source server...')

  try {
    const result = await linker.joinWithInvite(inviteHex)

    if (!result.mnemonic) {
      error('No mnemonic received from source server')
      return
    }

    // Restore identity from received mnemonic
    const identity = new ServerIdentity({ storagePath })
    await identity.initFromMnemonic(result.mnemonic)

    log('')
    log('\u2713 Server identity received and restored.')
    log(`  Server key: ${result.serverKey || identity.getPublicKeyHex()}`)
    log('')

    if (result.repoList && result.repoList.length > 0) {
      log(`Repos on source server (${result.repoList.length}):`)
      for (const name of result.repoList) {
        log(`  - ${name}`)
      }
    }

    log('')
    log('Next steps:')
    log('  1. Copy git repos from source server to ~/git-repos/')
    log('  2. Start this server: bare pear-git-server.js serve')
    log('  3. Verify with: pgh server status')
  } catch (err) {
    error('Join failed: ' + err.message)
  } finally {
    await linker.close()
  }
}

/**
 * Show server identity status
 */
async function serverStatus (flags) {
  const storagePath = resolveStorage(flags)

  let identity
  try {
    identity = await loadServerIdentity(storagePath)
  } catch (err) {
    if (flags.json) {
      jsonOut({ loaded: false, error: err.message })
    } else {
      error('No server identity found at ' + storagePath)
      log('Start the server first, or run: pgh server join <invite>')
    }
    return
  }

  const proofStatus = identity.getProofStatus()

  if (flags.json) {
    jsonOut({
      loaded: true,
      serverKey: identity.getPublicKeyHex(),
      storagePath,
      hasProof: proofStatus.hasProof,
      identityPublicKey: proofStatus.identityPublicKey
    })
    return
  }

  log('Server Identity Status')
  log('\u2500'.repeat(40))
  log(`Server key:    ${identity.getPublicKeyHex()}`)
  log(`Storage:       ${storagePath}`)
  log(`Keet identity: ${proofStatus.hasProof ? '\u2713' : '\u2717'}`)
  if (proofStatus.identityPublicKey) {
    log(`Identity key:  ${proofStatus.identityPublicKey.slice(0, 16)}...`)
  }

  // Check repos
  const reposDir = flags.repos || DEFAULT_REPOS_DIR
  try {
    const entries = fs.readdirSync(reposDir)
    const repos = entries.filter(e => e.endsWith('.git'))
    log(`Repos:         ${repos.length} (${reposDir})`)
  } catch {
    log(`Repos:         ? (${reposDir} not found)`)
  }
}

/**
 * Show this server's identity registry key (for P2P linking)
 */
async function serverIdentityKey (client, flags) {
  try {
    const data = await client.get('/api/admin/federation/identity-key')

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

    log('Identity Registry')
    log('\u2500'.repeat(50))
    log(`Bootstrap key: ${data.bootstrapKey}`)
    log(`Discovery key: ${data.discoveryKey || 'n/a'}`)
    log(`Local key:     ${data.localKey || 'n/a'}`)
    log('')
    log('To link another server to this identity registry:')
    log(`  1. On the other server:  pgh server identity-link ${data.bootstrapKey}`)
    log('  2. Copy the localKey from that output')
    log('  3. On THIS server:       pgh server identity-add-writer <localKey>')
  } catch (err) {
    error('Failed to get identity key: ' + err.message)
  }
}

/**
 * Link this server to a remote identity registry
 */
async function serverIdentityLink (client, args, flags) {
  const bootstrapKey = args[0]
  if (!bootstrapKey) {
    error('Usage: pgh server identity-link <bootstrap-key>')
    log('')
    log('Get the bootstrap key from the other server:')
    log('  pgh server identity-key')
    return
  }

  log(`Joining identity registry: ${bootstrapKey.substring(0, 16)}...`)

  try {
    const result = await client.post('/api/admin/federation/identity-join', {
      bootstrapKey
    })

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

    if (result.success) {
      log('')
      log('\u2713 Joined identity registry')
      log(`  Bootstrap key: ${bootstrapKey.substring(0, 16)}...`)
      log(`  Local key:     ${result.localKey}`)
      log('')
      log('Now add this server as a writer on the remote server:')
      log(`  pgh server identity-add-writer ${result.localKey}`)
    } else {
      error('Join failed: ' + (result.error || result.message || 'unknown'))
    }
  } catch (err) {
    error('Join failed: ' + err.message)
  }
}

/**
 * Add a remote server as a writer to this identity registry
 */
async function serverIdentityAddWriter (client, args, flags) {
  const writerKey = args[0]
  if (!writerKey) {
    error('Usage: pgh server identity-add-writer <writer-key>')
    log('')
    log('The writer key is the localKey from the remote server.')
    log('Get it by running on the remote server:')
    log('  pgh server identity-key')
    return
  }

  log(`Adding writer: ${writerKey.substring(0, 16)}...`)

  try {
    const result = await client.post('/api/admin/federation/identity-add-writer', {
      writerKey
    })

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

    if (result.success) {
      log('\u2713 Writer added to identity registry')
      log(`  Key: ${writerKey.substring(0, 16)}...`)
      log('')
      log('Both servers now share the same identity Autobase.')
      log('Users and identities will replicate automatically.')
    } else {
      error('Failed: ' + (result.error || 'unknown'))
    }
  } catch (err) {
    error('Failed to add writer: ' + err.message)
  }
}

export function serverHelp () {
  log(`Usage: pgh server <subcommand> [args] [--flags]

Subcommands:
  export-mnemonic              Export server identity as 24-word mnemonic
  link                         Create invite for server identity transfer (source)
  join <invite-hex>            Join via invite from source server (target)
  status                       Show server identity info

  identity-key                 Show identity registry key (for P2P linking)
  identity-link <key>          Join another server's identity registry
  identity-add-writer <key>    Add a remote server as identity writer

Flags:
  --json               Output as JSON
  --storage PATH       Override storage path
  --repos PATH         Path to bare git repos (default: ~/git-repos/)

Server Identity Transfer:
  Transfer a server identity to a new machine so both servers share
  the same punch:// URLs. Uses blind-pairing for secure P2P transfer.

  Source: pgh server link
  Target: pgh server join <invite-hex>

  Or export mnemonic manually:
  Source: pgh server export-mnemonic
  Target: Restore via API or direct file copy

P2P Identity Linking:
  Share identity registry between servers so users are recognized on both.
  Both servers can create users/identities — Autobase handles sync.

  1. On server A:  pgh server identity-key         (get bootstrap key)
  2. On server B:  pgh server identity-link <key>   (join, get localKey)
  3. On server A:  pgh server identity-add-writer <localKey>
  4. On server B:  pgh server identity-add-writer <server-A-localKey>`)
}