OverviewFilesCommitsIssuesDocsReleasesPeers

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

607 lines · javascript ref:
/**
 * pgh repo commands
 *
 * Subcommands: list, info, search, url, storage, clone, scan, import, import-all, create, link
 */

import path from 'bare-path'
import subprocess from 'bare-subprocess'
import { table, jsonOut, log, error } from '../output.js'
import { gitExec } from '../git.js'
import { getServerKey } from '../config.js'
import { P2PClient } from '../p2p-client.js'

export async function repoCommand (client, sub, args, flags) {
  switch (sub) {
    case 'list':
    case 'ls':
      return repoList(client, flags)
    case 'info':
      return repoInfo(client, args[0], flags)
    case 'search':
      return repoSearch(client, args.join(' '), flags)
    case 'url':
      return repoUrl(client, args[0], flags)
    case 'storage':
      return repoStorage(client, args[0], flags)
    case 'clone':
      return repoClone(args[0], flags)
    case 'scan':
      return repoScan(client, args[0], flags)
    case 'import':
      return repoImport(client, args[0], flags)
    case 'import-all':
      return repoImportAll(client, flags)
    case 'migrate':
      return repoMigrate(client, args[0], flags)
    case 'create':
      return repoCreate(args[0], flags)
    case 'link':
      // 2 args = git remote link, 1 arg = Autobase collab link
      if (args[1]) return repoLink(client, args[0], args[1], flags)
      return repoCollabLink(client, args[0], flags)
    case 'join':
      return repoCollabJoin(client, args[0], flags)
    case 'mirror':
      return repoCollabMirror(client, args[0], flags)
    case 'peers':
      return repoCollabPeers(client, args[0], flags)
    case 'unlink':
      return repoCollabUnlink(client, args[0], flags)
    case 'set':
      return repoSet(client, args[0], flags)
    case 'delete':
    case 'rm':
      return repoDelete(client, args[0], flags)
    default:
      repoHelp()
  }
}

async function repoList (client, flags) {
  const repos = await client.get('/api/repos')

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

  table(repos, [
    { key: 'name', label: 'NAME', width: 30 },
    { key: 'commits', label: 'COMMITS' },
    { key: 'branches', label: 'BRANCHES' },
    { key: 'visibility', label: 'VISIBILITY' }
  ])
}

async function repoInfo (client, name, flags) {
  if (!name) error('Usage: pgh repo info <name>')

  const repos = await client.get('/api/repos')
  const repo = repos.find(r => r.name === name)
  if (!repo) error(`Repository "${name}" not found`)

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

  log(`Name:        ${repo.name}`)
  log(`Path:        ${repo.path || '-'}`)
  log(`Commits:     ${repo.commits || 0}`)
  log(`Branches:    ${repo.branches || 0}`)
  log(`Visibility:  ${repo.visibility || 'public'}`)
  log(`Owner:       ${repo.owner || '-'}`)
  log(`Category:    ${repo.category || '-'}`)
  log(`Namespace:   ${repo.namespace || '-'}`)
  if (repo.lastCommit) {
    const lc = repo.lastCommit
    if (typeof lc === 'object') {
      log(`Last Commit: ${lc.hash ? lc.hash.slice(0, 8) : '-'} ${lc.message || ''} (${lc.author || ''})`)
    } else {
      log(`Last Commit: ${lc}`)
    }
  }
}

async function repoSet (client, name, flags) {
  if (!name) return error('Usage: pgh repo set <name> --visibility <public|private|internal> [--category <cat>]')

  const updates = {}
  if (flags.visibility) updates.visibility = flags.visibility
  if (flags.category) updates.category = flags.category

  if (Object.keys(updates).length === 0) {
    return error('Specify at least one flag: --visibility or --category')
  }

  const data = await client.patch(`/api/admin/repos/${encodeURIComponent(name)}`, updates)
  if (data.error) return error(data.error)

  log(`Updated ${name}:`)
  if (data.repo.visibility) log(`  visibility: ${data.repo.visibility}`)
  if (data.repo.category) log(`  category: ${data.repo.category}`)
}

async function repoSearch (client, query, flags) {
  if (!query) error('Usage: pgh repo search <query>')

  const data = await client.get(`/api/repos/search?q=${encodeURIComponent(query)}`)

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

  if (!data.repos || data.repos.length === 0) {
    log(`No repos matching "${query}"`)
    return
  }

  table(data.repos, [
    { key: 'name', label: 'NAME', width: 30 },
    { key: 'commits', label: 'COMMITS' },
    { key: 'category', label: 'CATEGORY' },
    { key: 'relevance', label: 'RELEVANCE' }
  ])
}

async function repoUrl (client, name, flags) {
  if (!name) error('Usage: pgh repo url <name>')

  // Try new /urls endpoint first, fall back to /punch-url
  let data
  try {
    data = await client.get(`/api/repos/${encodeURIComponent(name)}/urls`)
  } catch {
    data = await client.get(`/api/repos/${encodeURIComponent(name)}/punch-url`)
  }

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

  if (data.cloneUrl) log(`Clone (P2P):    ${data.cloneUrl}`)
  if (data.punchUrl) log(`Punch URL:      ${data.punchUrl}`)
  if (data.viewerUrl && data.viewerUrl !== data.punchUrl) log(`Viewer URL:     ${data.viewerUrl}`)
  if (!data.cloneUrl && !data.punchUrl && !data.viewerUrl) log('No P2P URLs available.')
}

async function repoStorage (client, name, flags) {
  if (!name) error('Usage: pgh repo storage <name>')

  const data = await client.get(`/api/repos/${encodeURIComponent(name)}/storage`)

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

  for (const [key, val] of Object.entries(data)) {
    log(`${key}: ${typeof val === 'object' ? JSON.stringify(val) : val}`)
  }
}

async function repoClone (url, flags) {
  if (!url) error('Usage: pgh repo clone <url>')

  const proc = subprocess.spawn('git', ['clone', url])

  proc.stdout.on('data', (d) => process.stdout.write(d))
  proc.stderr.on('data', (d) => process.stderr.write(d))

  await new Promise((resolve, reject) => {
    proc.on('close', (code) => {
      if (code === 0) resolve()
      else reject(new Error(`git clone exited with code ${code}`))
    })
  })
}

async function repoScan (client, scanPath, flags) {
  const query = scanPath ? `?path=${encodeURIComponent(scanPath)}` : ''
  const res = await client._request('GET', `/api/scan${query}`)
  const text = await res.text()

  for (const line of text.split('\n')) {
    if (!line.trim()) continue
    try {
      const obj = JSON.parse(line)
      if (obj.type === 'repo') {
        log(`Found: ${obj.repo.name} (${obj.repo.path})`)
      } else if (obj.type === 'progress') {
        // skip progress messages in non-json mode
      }
      if (flags.json) {
        console.log(line)
      }
    } catch {}
  }
}

async function repoImport (client, name, flags) {
  if (!name) error('Usage: pgh repo import <name>')

  const data = await client.post(`/api/repos/${encodeURIComponent(name)}/import`)

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

  log(`Imported ${name}`)
  if (data.driveKey) log(`Drive key: ${data.driveKey}`)
  if (data.url) log(`URL: ${data.url}`)
}

async function repoImportAll (client, flags) {
  const res = await client._request('GET', '/api/import-git-repos')
  const text = await res.text()

  for (const line of text.split('\n')) {
    if (!line.trim()) continue
    try {
      const obj = JSON.parse(line)
      if (obj.type === 'imported') {
        log(`Imported: ${obj.name}`)
      } else if (obj.type === 'error') {
        console.error(`Error: ${obj.name}: ${obj.error}`)
      } else if (obj.type === 'complete') {
        log(`\nDone: ${obj.imported} imported, ${obj.errors} errors`)
      }
      if (flags.json) {
        console.log(line)
      }
    } catch {}
  }
}

/**
 * Migrate all repos to a new directory
 * Usage: pgh repo migrate <new-path> [--server URL]
 */
async function repoMigrate (client, newPath, flags) {
  if (!newPath) {
    error('Usage: pgh repo migrate <new-path>')
    return
  }

  newPath = path.resolve(newPath)

  log(`Migrating repos to ${newPath}...`)

  const data = await client.post('/api/repos/migrate-path', { newPath })

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

  if (data.error) {
    error(data.error)
    return
  }

  log(`\u2713 Migrated ${data.moved} repos to ${newPath}`)
  if (data.skipped > 0) log(`  Skipped: ${data.skipped} (not in old repos path)`)
  if (data.errors && data.errors.length > 0) {
    log(`  Errors:`)
    for (const e of data.errors) log(`    - ${e}`)
  }
}

/**
 * Create a new repository via P2P admin protocol
 *
 * Usage: pgh repo create <name> [--server-key KEY] [--from-path PATH]
 *        [--visibility public|private|internal] [--category CAT]
 */
async function repoCreate (name, flags) {
  if (!name) {
    error('Usage: pgh repo create <name> --server-key <key> [--from-path <path>] [--visibility public|private] [--category <cat>]')
    return
  }

  const serverKey = getServerKey(flags)

  if (!serverKey) {
    error('Server key required. Use --server-key <key> or set serverKey in ~/.config/pear-git/pgh.json')
    return
  }

  const p2p = new P2PClient({ serverKey, timeout: 60000 })

  try {
    log(`Connecting to server ${serverKey.slice(0, 8)}...`)
    await p2p.init()

    const request = {
      command: 'create-repo',
      name,
      fromPath: flags['from-path'] || flags.fromPath || null,
      visibility: flags.visibility || 'public',
      category: flags.category || null
    }

    const result = await p2p.command(request)

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

    if (result.error) {
      error(result.error)
      return
    }

    log('')
    log(`\u2713 Repository created: ${result.name}`)
    log(`  Path:       ${result.path}`)
    log(`  Visibility: ${result.visibility}`)
    log(`  Commits:    ${result.commits || 0}`)
    log(`  Branches:   ${result.branches || 0}`)

    if (result.punchUrl) {
      log('')
      log(`  Clone:  git clone ${result.punchUrl}`)
    }

    // Auto-link if created from a local path
    const fromPath = flags['from-path'] || flags.fromPath
    if (fromPath && result.path) {
      try {
        const absFrom = path.resolve(fromPath)
        await gitExec(['remote', 'add', 'pear', result.path], absFrom)
        log('')
        log(`  Linked: pear remote added to ${absFrom}`)
        log(`  Push with: git push pear`)
      } catch {
        // Remote may already exist — not an error
      }
    }
  } catch (err) {
    error(`Failed to create repo: ${err.message}`)
  } finally {
    await p2p.destroy()
  }
}

async function repoLink (client, name, targetPath, flags) {
  if (!name || !targetPath) {
    error('Usage: pgh repo link <repo-name> <path-to-working-repo>')
    return
  }

  targetPath = path.resolve(targetPath)

  const repos = await client.get('/api/repos')
  const repo = repos.find(r => r.name === name)
  if (!repo) {
    error(`Repository "${name}" not found on server`)
    return
  }
  if (!repo.path) {
    error(`Repository "${name}" has no local path on server`)
    return
  }

  const barePath = repo.path

  try {
    const existing = await gitExec(['remote', 'get-url', 'pear'], targetPath)
    if (existing === barePath) {
      if (flags.json) {
        jsonOut({ status: 'already-linked', remote: 'pear', url: barePath, path: targetPath })
        return
      }
      log(`Already linked: pear -> ${barePath}`)
      return
    }
    // Remote exists but points elsewhere — update it
    await gitExec(['remote', 'set-url', 'pear', barePath], targetPath)
    if (flags.json) {
      jsonOut({ status: 'updated', remote: 'pear', url: barePath, path: targetPath })
      return
    }
    log(`Updated remote: pear -> ${barePath}`)
  } catch {
    // Remote doesn't exist — add it
    await gitExec(['remote', 'add', 'pear', barePath], targetPath)
    if (flags.json) {
      jsonOut({ status: 'linked', remote: 'pear', url: barePath, path: targetPath })
      return
    }
    log(`Linked: pear -> ${barePath}`)
  }

  log(`Push with: git push pear`)
}

// ---------------------------------------------------------------------------
// Autobase Collaboration Commands
// ---------------------------------------------------------------------------

async function repoCollabLink (client, name, flags) {
  if (!name) {
    error('Usage: pgh repo link <name>')
    return
  }

  const data = await client.post(`/api/repos/${encodeURIComponent(name)}/link`)

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

  if (data.error) {
    error(data.error)
    return
  }

  log(`\u2713 Linked: ${name}`)
  log(`  Bootstrap key: ${data.bootstrapKey}`)
  log(`  Role: ${data.role}`)
  if (data.invite) {
    log('')
    log(`  Invite (share with collaborators):`)
    log(`  ${data.invite}`)
    log('')
    log(`  Join:   pgh repo join ${data.invite}`)
    log(`  Mirror: pgh repo mirror ${data.invite}`)
  }
}

async function repoCollabJoin (client, invite, flags) {
  if (!invite) {
    error('Usage: pgh repo join <invite>')
    return
  }

  log(`Joining via blind-pairing invite...`)
  log(`(Note: full P2P join requires server mode. Using API for local join.)`)

  const data = await client.post('/api/repos/_join', { invite, role: 'writer' }).catch(() => null)

  if (data && data.error) {
    error(data.error)
    return
  }

  if (flags.json) {
    jsonOut(data || { status: 'pending', invite })
    return
  }

  if (data && data.success) {
    log(`\u2713 Joined: ${data.repoName}`)
    log(`  Bootstrap key: ${data.bootstrapKey}`)
    log(`  Role: ${data.role}`)
  } else {
    log(`Invite submitted. Join will complete when owner is online.`)
  }
}

async function repoCollabMirror (client, invite, flags) {
  if (!invite) {
    error('Usage: pgh repo mirror <invite>')
    return
  }

  log(`Mirroring via blind-pairing invite...`)

  const data = await client.post('/api/repos/_join', { invite, role: 'mirror' }).catch(() => null)

  if (flags.json) {
    jsonOut(data || { status: 'pending', invite, role: 'mirror' })
    return
  }

  if (data && data.success) {
    log(`\u2713 Mirroring: ${data.repoName}`)
    log(`  Bootstrap key: ${data.bootstrapKey}`)
  } else {
    log(`Mirror invite submitted. Will complete when owner is online.`)
  }
}

async function repoCollabPeers (client, name, flags) {
  if (!name) {
    error('Usage: pgh repo peers <name>')
    return
  }

  const data = await client.get(`/api/repos/${encodeURIComponent(name)}/peers`)

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

  if (data.error) {
    error(data.error)
    return
  }

  if (!data.writers || data.writers.length === 0) {
    log(`No peers for ${name}`)
    return
  }

  table(data.writers, [
    { key: 'userId', label: 'USER', width: 20 },
    { key: 'role', label: 'ROLE', width: 10 },
    { key: 'serverKey', label: 'SERVER KEY', width: 20 }
  ])
}

async function repoCollabUnlink (client, name, flags) {
  if (!name) {
    error('Usage: pgh repo unlink <name>')
    return
  }

  const data = await client.del(`/api/repos/${encodeURIComponent(name)}/unlink`)

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

  if (data.error) {
    error(data.error)
    return
  }

  log(`\u2713 Unlinked: ${name}`)
}

async function repoDelete (client, name, flags) {
  if (!name) error('Usage: pgh repo delete <name>')

  const data = await client.delete(`/api/admin/repos/${encodeURIComponent(name)}`)

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

  log(`Deleted ${name} from registry`)
}

export function repoHelp () {
  log(`Usage: pgh repo <subcommand> [args] [flags]

Subcommands:
  list                 List all repositories
  info <name>          Show repository details
  search <query>       Search repositories
  url <name>           Get P2P URLs (git+pear://, punch://)
  storage <name>       Show storage stats
  clone <url>          Clone a repository
  scan [path]          Scan for repositories
  import <name>        Import a repository
  import-all           Import all git repos
  create <name>        Create repo via P2P (requires --server-key)
  migrate <path>       Migrate repos to new directory
  delete <name>        Remove repo from registry (admin only)
  link <name> <path>   Add 'pear' remote to working repo
  link <name>          Create Autobase overlay + display invite
  join <invite>        Join linked repo via blind-pairing invite
  mirror <invite>      Join as read-only mirror
  peers <name>         List writers/mirrors for linked repo
  unlink <name>        Remove Autobase overlay

Create Flags:
  --server-key KEY     Server Noise public key (hex)
  --from-path PATH     Clone from existing local path
  --visibility VIS     public (default), private, or internal
  --category CAT       Repository category

Other Flags:
  --json               Output as JSON
  --server URL         Server URL (for HTTP commands)`)
}