/**
* pgh admin commands
*
* Subcommands: users, user, user-update, identities, identity-revoke,
* identity-restore, challenges, rate-reset, stats, fix-identities, merge, link, promote,
* repo-register, repo-add, repo-scan
*/
import { table, jsonOut, log, error } from '../output.js'
import { loadConfig, saveConfig } from '../config.js'
export async function adminCommand (client, sub, args, flags) {
switch (sub) {
case 'users':
return adminUsers(client, flags)
case 'user':
return adminUser(client, args[0], flags)
case 'user-update':
return adminUserUpdate(client, args[0], flags)
case 'identities':
return adminIdentities(client, flags)
case 'identity-revoke':
return adminIdentityRevoke(client, args[0], flags)
case 'identity-restore':
return adminIdentityRestore(client, args[0], flags)
case 'challenges':
return adminChallenges(client, flags)
case 'rate-reset':
return adminRateReset(client, args[0], flags)
case 'stats':
return adminStats(client, flags)
case 'fix-identities':
return adminFixIdentities(client, flags)
case 'merge':
return adminMerge(client, args[0], args[1], flags)
case 'link':
return adminLink(client, args[0], args[1], flags)
case 'promote':
return adminPromote(client, args[0], flags)
case 'repo-register':
return adminRepoRegister(client, args[0], flags)
case 'repo-add':
return adminRepoAdd(client, args[0], flags)
case 'repo-scan':
return adminRepoScan(client, args[0], flags)
case 'repo-delete':
return adminRepoDelete(client, args[0], flags)
default:
adminHelp()
}
}
async function adminUsers (client, flags) {
const data = await client.get('/api/admin/users')
const users = Array.isArray(data) ? data : (data.users || [])
if (flags.json) {
jsonOut(users)
return
}
table(users, [
{ key: 'userId', label: 'USER ID', width: 20 },
{ key: 'name', label: 'NAME', width: 20 },
{ key: 'globalPermissions', label: 'PERMISSIONS', width: 30 }
])
}
async function adminUser (client, userId, flags) {
if (!userId) error('Usage: pgh admin user <userId>')
const data = await client.get(`/api/admin/users/${encodeURIComponent(userId)}`)
if (flags.json) {
jsonOut(data)
return
}
const u = data.user
log(`User ID: ${u.userId}`)
log(`Name: ${u.name || '-'}`)
log(`Permissions: ${(u.globalPermissions || []).join(', ') || '-'}`)
log(`Identities: ${(u.identities || []).length}`)
log(`Repos: ${(u.repos || []).length}`)
if (data.identities && data.identities.length > 0) {
log(`\nLinked identities:`)
for (const id of data.identities) {
log(` ${id.method || 'unknown'}: ${id.identityId || id.publicKey || '-'}`)
}
}
}
async function adminUserUpdate (client, userId, flags) {
if (!userId) error('Usage: pgh admin user-update <userId> --name <name> [--permissions <perms>]')
const updates = {}
if (flags.name) updates.name = flags.name
if (flags.permissions) updates.globalPermissions = flags.permissions.split(',')
if (Object.keys(updates).length === 0) {
error('No updates provided. Use --name or --permissions.')
}
const data = await client.patch(`/api/admin/users/${encodeURIComponent(userId)}`, updates)
if (flags.json) {
jsonOut(data)
return
}
log(`Updated user ${userId}.`)
}
async function adminIdentities (client, flags) {
const data = await client.get('/api/admin/identities')
if (flags.json) {
jsonOut(data)
return
}
table(data.identities, [
{ key: 'identityId', label: 'IDENTITY', width: 30 },
{ key: 'method', label: 'METHOD', width: 16 },
{ key: 'userId', label: 'USER ID', width: 20 }
])
}
async function adminIdentityRevoke (client, identityId, flags) {
if (!identityId) error('Usage: pgh admin identity-revoke <identityId>')
// Find the userId for this identity first
const idData = await client.get('/api/admin/identities')
const identity = idData.identities.find(i => i.identityId === identityId || i.key === `identity:${identityId}`)
if (!identity) error(`Identity "${identityId}" not found.`)
const data = await client.del(`/api/admin/users/${encodeURIComponent(identity.userId)}/identities/${encodeURIComponent(identityId)}`)
if (flags.json) {
jsonOut(data)
return
}
log(`Identity revoked: ${identityId}`)
}
async function adminIdentityRestore (client, identityId, flags) {
if (!identityId) error('Usage: pgh admin identity-restore <identityId>')
// Re-add identity — need userId
const userId = flags.user || flags.userId
if (!userId) error('Usage: pgh admin identity-restore <identityId> --user <userId>')
const data = await client.post(`/api/admin/users/${encodeURIComponent(userId)}/identities`, {
identityId,
method: flags.method || 'web-identity'
})
if (flags.json) {
jsonOut(data)
return
}
log(`Identity restored: ${identityId} → user ${userId}`)
}
async function adminChallenges (client, flags) {
const data = await client.get('/api/admin/challenges')
if (flags.json) {
jsonOut(data)
return
}
log(`Active: ${data.stats.active}`)
log(`Total: ${data.stats.total}`)
if (data.challenges.length > 0) {
table(data.challenges, [
{ key: 'id', label: 'ID', width: 20 },
{ key: 'expiresAt', label: 'EXPIRES', width: 25 },
{ key: 'used', label: 'USED' }
])
}
}
async function adminRateReset (client, ip, flags) {
const data = await client.post('/api/admin/rate-limit/reset', ip ? { ip } : {})
if (flags.json) {
jsonOut(data)
return
}
log(data.message)
}
async function adminStats (client, flags) {
const data = await client.get('/api/admin/stats')
if (flags.json) {
jsonOut(data)
return
}
log(`Server:`)
log(` Uptime: ${Math.floor(data.server.uptime)}s`)
log(` Memory: ${(data.server.memory.rss / 1024 / 1024).toFixed(1)} MB`)
log(` Repos: ${data.server.repos}`)
log(`Registry:`)
log(` Users: ${data.registry.users}`)
log(` Identities: ${data.registry.identities}`)
log(`Challenges:`)
log(` Active: ${data.challenges.active || 0}`)
log(`Rate limiter:`)
log(` Cached: ${data.rateLimiter.cached}`)
}
async function adminFixIdentities (client, flags) {
const body = flags.user ? { userId: flags.user } : {}
const data = await client.post('/api/admin/fix/user-identities', body)
if (flags.json) {
jsonOut(data)
return
}
log(`Fixed ${data.usersFixed} users, added ${data.identitiesAdded} identities.`)
}
async function adminMerge (client, primary, secondary, flags) {
if (!primary || !secondary) error('Usage: pgh admin merge <primaryUserId> <secondaryUserId>')
// Preview first
const preview = await client.post('/api/admin/users/merge', {
primaryUserId: primary,
secondaryUserId: secondary,
preview: true
})
log(`Merge preview:`)
log(` Primary: ${preview.primary.name || preview.primary.userId} (${preview.primary.identities} identities, ${preview.primary.repos} repos)`)
log(` Secondary: ${preview.secondary.name || preview.secondary.userId} (${preview.secondary.identities} identities, ${preview.secondary.repos} repos)`)
log(` Transfer: ${preview.willTransfer.identities} identities, ${preview.willTransfer.repos} repos`)
if (flags.yes || flags.y) {
// Proceed without confirmation
const data = await client.post('/api/admin/users/merge', {
primaryUserId: primary,
secondaryUserId: secondary,
preview: false
})
if (flags.json) {
jsonOut(data)
return
}
log(`\nMerged ${secondary} → ${primary}`)
log(` Identities transferred: ${data.stats.identitiesTransferred}`)
log(` Permissions merged: ${data.stats.permissionsMerged}`)
} else {
log(`\nRun with --yes to confirm merge.`)
}
}
async function adminLink (client, userId, identityId, flags) {
if (!userId || !identityId) error('Usage: pgh admin link <userId> <identityId>')
const data = await client.post(`/api/admin/users/${encodeURIComponent(userId)}/identities`, {
identityId,
method: flags.method || 'web-identity',
publicKey: flags.publicKey || identityId
})
if (flags.json) {
jsonOut(data)
return
}
log(`Linked identity ${identityId} → user ${userId}`)
}
async function adminPromote (client, identifier, flags) {
if (!identifier) error('Usage: pgh admin promote <publicKey|userId>')
// Detect type: userId = 32 hex chars (16 bytes), publicKey = 64 hex chars (32 bytes)
const body = identifier.length === 32
? { userId: identifier }
: { publicKey: identifier }
// Use localhost-only bootstrap endpoint (no token needed)
const data = await client.post('/api/bootstrap/promote', body)
// Auto-save token if returned (completes bootstrap — no separate token step needed)
if (data.token) {
const cfg = loadConfig()
cfg.token = data.token
saveConfig(cfg)
}
if (flags.json) {
jsonOut(data)
return
}
log(`Promoted ${data.userId} to admin.`)
if (data.token) {
log(`Token saved to config.`)
log(``)
log(` ${data.token}`)
log(``)
log(`Save this token — it cannot be shown again.`)
}
}
async function adminRepoRegister (client, name, flags) {
if (!name) error('Usage: pgh admin repo-register <name>')
const data = await client.post('/api/admin/repos/import', { name })
if (flags.json) {
jsonOut(data)
return
}
log(`Registered repository: ${name}`)
if (data.punchUrl) log(`URL: ${data.punchUrl}`)
if (data.commits) log(`Commits: ${data.commits}`)
}
async function adminRepoAdd (client, name, flags) {
if (!name) error('Usage: pgh admin repo-add <name> --from-path <path> | --clone-url <url>')
const body = { name }
if (flags['from-path'] || flags.fromPath) body.fromPath = flags['from-path'] || flags.fromPath
if (flags['clone-url'] || flags.cloneUrl) body.cloneUrl = flags['clone-url'] || flags.cloneUrl
if (flags.visibility) body.visibility = flags.visibility
if (!body.fromPath && !body.cloneUrl) {
error('Either --from-path or --clone-url is required. Use repo-register for existing bare repos.')
}
const data = await client.post('/api/admin/repos/import', body)
if (flags.json) {
jsonOut(data)
return
}
log(`Added repository: ${name}`)
if (data.punchUrl) log(`URL: ${data.punchUrl}`)
if (data.commits) log(`Commits: ${data.commits}`)
}
async function adminRepoScan (client, scanPath, flags) {
if (!scanPath) error('Usage: pgh admin repo-scan <path>')
const data = await client.post('/api/admin/repos/scan', { path: scanPath })
if (flags.json) {
jsonOut(data)
return
}
if (data.repos) {
for (const repo of data.repos) {
log(`Found: ${repo.name} (${repo.path})`)
}
log(`\nTotal: ${data.repos.length} repos`)
}
}
async function adminRepoDelete (client, name, flags) {
if (!name) error('Usage: pgh admin repo-delete <name> [--delete-bare]')
const url = '/api/admin/repos/' + encodeURIComponent(name) + (flags['delete-bare'] ? '?deleteBareRepo=true' : '')
const data = await client.del(url)
if (flags.json) {
jsonOut(data)
return
}
if (data.success) {
log(`Deleted repository: ${name}`)
if (data.bareDeleted) log(`Bare repo also deleted from disk`)
} else {
error(data.error || 'Failed to delete repository')
}
}
export function adminHelp () {
log(`Usage: pgh admin <subcommand> [args] [flags]
Subcommands:
users List all users
user <id> Show user details + identities
user-update <id> Update user (--name, --permissions)
identities List all identities
identity-revoke <id> Revoke an identity
identity-restore <id> Restore identity (--user required)
challenges List active challenges
rate-reset [ip] Reset rate limits
stats Full server stats
fix-identities Fix user identity arrays (--user optional)
merge <p> <s> Merge accounts (preview, --yes to confirm)
link <userId> <id> Link identity to user
promote <key|userId> Grant admin (publicKey or userId)
repo-register <name> Register existing bare repo in ~/git-repos/
repo-add <name> Clone + register (--from-path or --clone-url)
repo-delete <name> Delete repo from registry (--delete-bare to also rm)
repo-scan <path> Scan directory for bare repos
Flags:
--name NAME Name for user-update
--permissions LIST Comma-separated permissions
--user ID User ID for identity operations
--method METHOD Identity method (default: web-identity)
--yes, -y Confirm merge without preview
--json Output as JSON`)
}