OverviewFilesCommitsIssuesDocsReleasesPeers

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

422 lines · javascript ref:
/**
 * 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`)
}