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