OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / packages / pear-git-client / index.js

318 lines · javascript ref:
#!/usr/bin/env bare
/**
 * pear-git-client - Simple P2P git client
 *
 * A lightweight client for cloning git repos over P2P without running a full server.
 *
 * Usage:
 *   pear-git-client servers              - List known servers
 *   pear-git-client add <name> <key>     - Add a server
 *   pear-git-client remove <name>        - Remove a server
 *   pear-git-client browse <server>      - Browse repos on a server (TODO)
 *   pear-git-client clone <url> [dir]    - Clone a repo
 *   pear-git-client discover             - Find servers on LAN
 *   pear-git-client install              - Install git-remote-pear helper
 */

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import process from 'bare-process'
import { spawn } from 'bare-subprocess'

const HOME = os.homedir()
const CONFIG_DIR = path.join(HOME, '.pear-git')
const SERVERS_FILE = path.join(CONFIG_DIR, 'servers.json')
const BIN_DIR = path.join(HOME, '.local', 'bin')

const [,, command, ...args] = process.argv

async function main() {
  await ensureConfigDir()

  switch (command) {
    case 'servers':
    case 'list':
      await listServers()
      break

    case 'add':
      await addServer(args[0], args[1])
      break

    case 'remove':
    case 'rm':
      await removeServer(args[0])
      break

    case 'clone':
      await cloneRepo(args[0], args[1])
      break

    case 'discover':
      await discoverLAN()
      break

    case 'install':
      await installHelper()
      break

    case 'help':
    case '--help':
    case '-h':
    case undefined:
      showHelp()
      break

    default:
      console.error(`Unknown command: ${command}`)
      showHelp()
      process.exit(1)
  }
}

function showHelp() {
  console.log(`
pear-git-client - Simple P2P Git Client

Commands:
  servers                 List known pear-git servers
  add <name> <key>        Add a server (creates petname)
  remove <name>           Remove a server
  clone <url> [dir]       Clone a repo via pear://
  discover                Find pear-git servers on LAN
  install                 Install git-remote-pear helper
  help                    Show this help

Examples:
  # Add a server
  pear-git-client add tabertron 3cf3266a57b3ef069b724a4330200d72b0ad81daea7311b7d2a2369b0469f199

  # Clone using petname
  pear-git-client clone pear://tabertron/wirecore

  # Clone using full key
  pear-git-client clone pear://3cf3266a.../wirecore ./my-wirecore

  # Find servers on local network
  pear-git-client discover
`)
}

async function ensureConfigDir() {
  try {
    await fs.promises.mkdir(CONFIG_DIR, { recursive: true })
  } catch (err) {
    if (err.code !== 'EEXIST') throw err
  }
}

async function loadServers() {
  try {
    const data = await fs.promises.readFile(SERVERS_FILE, 'utf-8')
    return JSON.parse(data)
  } catch (err) {
    if (err.code === 'ENOENT') return {}
    throw err
  }
}

async function saveServers(servers) {
  await fs.promises.writeFile(SERVERS_FILE, JSON.stringify(servers, null, 2))
}

async function listServers() {
  const servers = await loadServers()
  const entries = Object.entries(servers)

  if (entries.length === 0) {
    console.log('No servers configured.')
    console.log('')
    console.log('Add one with:')
    console.log('  pear-git-client add <name> <64-char-hex-key>')
    return
  }

  console.log('Known pear-git servers:')
  console.log('')

  for (const [name, data] of entries) {
    console.log(`  ${name}`)
    console.log(`    Key: ${data.key.slice(0, 16)}...`)
    if (data.description) {
      console.log(`    ${data.description}`)
    }
    console.log('')
  }

  console.log(`Clone with: git clone pear://<name>/<repo>`)
}

async function addServer(name, key) {
  if (!name || !key) {
    console.error('Usage: pear-git-client add <name> <key>')
    console.error('')
    console.error('Example:')
    console.error('  pear-git-client add tabertron 3cf3266a57b3ef069b724a4330200d72b0ad81daea7311b7d2a2369b0469f199')
    process.exit(1)
  }

  if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
    console.error('Invalid name. Use lowercase letters, numbers, and hyphens.')
    process.exit(1)
  }

  if (!/^[a-f0-9]{64}$/i.test(key)) {
    console.error('Invalid key. Must be 64 character hex string.')
    process.exit(1)
  }

  const servers = await loadServers()
  servers[name] = {
    key: key.toLowerCase(),
    addedAt: Date.now()
  }
  await saveServers(servers)

  // Also save to petnames for git-remote-pear compatibility
  const petnamesFile = path.join(CONFIG_DIR, 'remotes.json')
  let petnames = {}
  try {
    petnames = JSON.parse(await fs.promises.readFile(petnamesFile, 'utf-8'))
  } catch {}
  petnames[name] = { key: key.toLowerCase(), addedAt: Date.now() }
  await fs.promises.writeFile(petnamesFile, JSON.stringify(petnames, null, 2))

  console.log(`✓ Added server "${name}"`)
  console.log('')
  console.log(`Clone repos with:`)
  console.log(`  git clone pear://${name}/<repo>`)
}

async function removeServer(name) {
  if (!name) {
    console.error('Usage: pear-git-client remove <name>')
    process.exit(1)
  }

  const servers = await loadServers()
  if (!servers[name]) {
    console.error(`Server "${name}" not found`)
    process.exit(1)
  }

  delete servers[name]
  await saveServers(servers)

  console.log(`✓ Removed server "${name}"`)
}

async function cloneRepo(url, targetDir) {
  if (!url) {
    console.error('Usage: pear-git-client clone <pear://...> [directory]')
    process.exit(1)
  }

  if (!url.startsWith('pear://')) {
    console.error('URL must start with pear://')
    process.exit(1)
  }

  // Resolve petname if needed
  const servers = await loadServers()
  let resolvedUrl = url

  const match = url.match(/^pear:\/\/([^\/]+)\/(.+)$/)
  if (match) {
    const [, keyOrName, repo] = match
    if (!/^[a-f0-9]{64}$/i.test(keyOrName) && servers[keyOrName]) {
      resolvedUrl = `pear://${servers[keyOrName].key}/${repo}`
      console.log(`Resolved ${keyOrName} -> ${servers[keyOrName].key.slice(0, 8)}...`)
    }
  }

  // Build git clone command
  const gitArgs = ['clone', resolvedUrl]
  if (targetDir) {
    gitArgs.push(targetDir)
  }

  console.log(`Cloning ${url}...`)
  console.log('')

  // Spawn git with our helper in PATH
  const scriptDir = path.dirname(new URL(import.meta.url).pathname)
  const helperDir = path.join(scriptDir, '..', 'pear-git-remote', 'bin')

  const gitProcess = spawn('git', gitArgs, {
    stdio: 'inherit',
    env: {
      ...process.env,
      PATH: `${helperDir}:${BIN_DIR}:${process.env.PATH}`
    }
  })

  gitProcess.on('exit', (code) => {
    process.exit(code || 0)
  })
}

async function discoverLAN() {
  console.log('Discovering pear-git servers on LAN...')
  console.log('')

  // Use mDNS to find _pear-git._tcp services
  // This is a simplified version - real implementation would use multicast-dns

  const Hyperswarm = (await import('hyperswarm')).default
  const swarm = new Hyperswarm()

  // TODO: Implement proper mDNS discovery
  // For now, just show a message
  console.log('LAN discovery requires mDNS support.')
  console.log('')
  console.log('Servers announce themselves as:')
  console.log('  _pear-git._tcp on port 5005')
  console.log('')
  console.log('Use: avahi-browse -r _pear-git._tcp')
  console.log('Or:  dns-sd -B _pear-git._tcp')

  await swarm.destroy()
}

async function installHelper() {
  console.log('Installing git-remote-pear helper...')

  await fs.promises.mkdir(BIN_DIR, { recursive: true })

  const helperPath = path.join(BIN_DIR, 'git-remote-pear')
  const scriptDir = path.dirname(new URL(import.meta.url).pathname)
  const actualHelper = path.join(scriptDir, '..', 'git-remote-pear', 'bin', 'git-remote-pear')

  // Create symlink or wrapper
  const wrapper = `#!/usr/bin/env bare
// git-remote-pear - installed by pear-git-client
import('${actualHelper}')
`

  await fs.promises.writeFile(helperPath, wrapper, { mode: 0o755 })

  console.log(`✓ Installed to ${helperPath}`)

  const pathEnv = process.env.PATH || ''
  if (!pathEnv.includes(BIN_DIR)) {
    console.log('')
    console.log('Add to PATH:')
    console.log(`  echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc`)
    console.log('  source ~/.bashrc')
  }

  console.log('')
  console.log('Now use: git clone pear://<key>/<repo>')
}

main().catch(err => {
  console.error('Error:', err.message)
  process.exit(1)
})