/**
* 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>`)
}