OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / pear-git-server.js

1674 lines · javascript ref:
#!/usr/bin/env bare

/**
 * Pear Git Server
 *
 * Complete P2P git forge with:
 * - HTTP gateway (subdirectory support)
 * - Filesystem scanning
 * - LAN discovery
 * - P2P replication
 *
 * Refactored to use modular routes for maintainability.
 */

// Load Node.js compatibility for Bare runtime
await import('bare-node-runtime/global')

import http from 'bare-http1'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import Hyperswarm from 'hyperswarm'
// Protomux and compact-encoding no longer needed here —
// writer exchange handled by MetadataBase internally

// Core components
import { MetadataBase } from './lib/metadata-base.js'
import { RepoScanner } from './lib/repo-scanner.js'
import { RepoRegistry } from './lib/repo-registry.js'
import { LANDiscovery } from './lib/lan-discovery.js'
import { GitProtocolHandler } from './lib/git-protocol.js'
import { BackendManager } from './lib/backend-manager.js'
import { AuthManager } from './lib/auth-manager.js'
import Hyperblobs from 'hyperblobs'

// P2P modules
import { ServerIdentity } from './lib/p2p/identity/server-identity.js'
import { TopicManager } from './lib/p2p/discovery/topic-manager.js'
import { GitP2PHandler } from './lib/p2p/protocol/git-p2p-handler.js'
import { BrowseClient } from './lib/p2p/discovery/browse-client.js'

// Deploy
import { PearDeployer } from './lib/ci/pear-deployer.js'

// Storage locator (prevents identity loss on CWD change)
import { StorageLocator } from './lib/storage-locator.js'

// Namespaces
import { NamespaceManager } from './lib/local-namespace-manager.js'

// Unified Autobase facades (replace separate Autobase instances)
import { IdentityFacade } from './lib/facades/identity-facade.js'
import { GroupFacade } from './lib/facades/group-facade.js'
import { DeployFacade } from './lib/facades/deploy-facade.js'
import { RepoFacade } from './lib/facades/repo-facade.js'

// Router and response helpers
import { Router } from './lib/routes/index.js'
import { rateLimited, html } from './lib/response.js'

// Route modules (consolidated)
import { registerAllRoutes } from './lib/routes/register-all.js'

const isPear = typeof Pear !== 'undefined'
const rawArgs = isPear ? (Pear.config.args || []) : process.argv.slice(2)
// Pear passes ["--", "serve", "5006"] — strip leading "--" separator
const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs

// Self-update config — detect if running from an installed copy (not source)
// Self-update is event-driven: after GitSync syncs the pear-git bare repo,
// GitSyncClientManager pulls the release branch from the LOCAL bare repo
// and exits for restart. No timers, no polling.
const isServerMode = args[0] === 'serve' || args[0] === 'web'
let selfUpdateConfig = null
if (!isPear && isServerMode) {
  const appDir = path.dirname(new URL(import.meta.url).pathname)
  try {
    const updaterPath = path.join(appDir, '.pear-git-updater.json')
    const config = JSON.parse(fs.readFileSync(updaterPath, 'utf-8'))
    selfUpdateConfig = {
      appDir,
      appRepoName: config.repoName || 'pear-git'
    }
    console.log(`[OTA] Self-update enabled for ${selfUpdateConfig.appRepoName} (event-driven, no polling)`)
  } catch {
    // No .pear-git-updater.json → running from source, no self-update
  }
}

/**
 * Pear Git Server - Main class
 */
class PearGitServer {
  constructor(options = {}) {
    this.port = options.port || 5005
    this.pathPrefix = options.pathPrefix || ''
    this.bindAddresses = Array.isArray(options.bindAddress)
      ? options.bindAddress
      : [options.bindAddress || 'localhost']
    this.defaultNamespace = options.defaultNamespace || os.hostname()
    this.externalUrl = options.externalUrl || null // e.g. 'https://7rnx.net/git'
    this.headless = options.headless || false
    this._bootstrap = options.bootstrap || null // DHT bootstrap nodes (null = public DHT, set for testing)
    this._selfRepoName = options.selfRepoName || 'pear-git' // Guard against self-deploy

    // Storage — priority: explicit option > locator breadcrumb > absolute default
    const locatorPath = StorageLocator.read()
    this.storage = options.storage || locatorPath || PearGitServer.resolveDefaultStorage()
    this._explicitStorage = !!options.storage

    if (locatorPath && path.resolve(this.storage) !== path.resolve(locatorPath)) {
      console.warn(`[Storage] WARNING: Resolved storage ${path.resolve(this.storage)} differs from locator ${locatorPath}`)
      console.warn(`[Storage] The locator path has a valid identity. Consider using --storage=${locatorPath}`)
    }
    this.store = new Corestore(this.storage)

    // Registry
    this.registryCore = this.store.get({ name: 'registry' })
    this.registry = new Hyperbee(this.registryCore, {
      keyEncoding: 'utf-8',
      valueEncoding: 'json'
    })

    // Components
    this.scanner = new RepoScanner({
      registry: this.registry,
      storage: this.storage,
      store: this.store
    })

    const defaultReposPath = path.join(os.homedir(), 'git-repos')
    this.repoRegistry = new RepoRegistry({
      registry: this.registry,
      gitReposPath: options.gitReposPath || defaultReposPath
    })

    this.lanDiscovery = new LANDiscovery({
      port: this.port,
      repos: []
    })

    this.gitProtocol = new GitProtocolHandler({
      gitReposPath: options.gitReposPath || defaultReposPath
    })

    // P2P networking — three swarms for protocol separation:
    // 1. gitSwarm: git protocol over Hyperswarm (TopicManager + GitP2PHandler)
    // 2. browseSwarm: P2P browse requests (BrowseClient outgoing connections)
    // 3. driveSwarm: Hypercore/corestore replication (HyperdriveBackend)
    // Each swarm MUST be separate: corestore.replicate() sends Protomux framing
    // that corrupts raw streams, and shared swarms cause data delivery failures
    // between BrowseClient and TopicManager (connection dedup + listener conflicts).
    const swarmOpts = this._bootstrap ? { bootstrap: this._bootstrap } : {}
    this.swarm = new Hyperswarm(swarmOpts) // git P2P transport
    this.swarm.on('error', (err) => {
      console.error('[GitSwarm] Error:', err.message)
    })

    this.browseSwarm = new Hyperswarm(swarmOpts) // browse client (outgoing only)
    this.browseSwarm.on('error', (err) => {
      console.error('[BrowseSwarm] Error:', err.message)
    })

    this.driveSwarm = new Hyperswarm(swarmOpts) // Hyperdrive replication
    this.driveSwarm.on('error', (err) => {
      console.error('[DriveSwarm] Error:', err.message)
    })
    this._driveConnections = [] // Track connections for late GitSyncManager attachment
    this.driveSwarm.on('connection', (conn) => {
      const peerKey = conn.remotePublicKey ? conn.remotePublicKey.toString('hex').slice(0, 8) : 'unknown'
      console.log(`[DriveSwarm] Connection from ${peerKey} (isInitiator: ${conn.isInitiator})`)
      conn.on('close', () => {
        console.log(`[DriveSwarm] Connection CLOSED ${peerKey}`)
      })
      conn.on('error', (err) => {
        console.log(`[DriveSwarm] Connection ERROR ${peerKey}: ${err.message}`)
      })
      const stream = this.store.replicate(conn)

      // MetadataBase has its own Hyperswarm — no wakeup/exchange needed on driveSwarm

      if (this.gitSyncManager) {
        this.gitSyncManager.attachToConnection(conn, stream)
      } else {
        // GitSyncManager not ready yet — queue for later attachment
        this._driveConnections.push({ conn, stream })
        conn.on('close', () => {
          this._driveConnections = this._driveConnections.filter(c => c.conn !== conn)
        })
      }
    })

    this.backendManager = new BackendManager({
      gitReposPath: options.gitReposPath || defaultReposPath,
      registry: this.repoRegistry,
      corestore: this.store,
      hyperswarm: this.driveSwarm, // drives use the replication swarm
      defaultStrategy: 'direct-git'
    })

    // Facades — initialized in ready() after MetadataBase is loaded
    this.identityRegistry = null
    this.deployRegistry = null
    this.namespaceRegistry = null

    // AuthManager initialized in ready() after identity facade is loaded
    this.authManager = null
    this._trustedProxies = options.trustedProxies

    // Hyperblobs for issue image storage (server-local, shared across all repos)
    const blobCore = this.store.get({ name: 'issue-images' })
    this.issueImageBlobs = new Hyperblobs(blobCore)

    this.deployer = new PearDeployer()

    this.namespaceManager = new NamespaceManager({
      registry: this.registry,
      repoRegistry: this.repoRegistry
    })

    this.authEnabled = options.authEnabled !== false
    this.p2pEnabled = options.p2pEnabled !== false

    // P2P components (initialized in ready())
    this.serverIdentity = null
    this.topicManager = null
    this.gitP2PHandler = null

    // Linked repos (Autobase collaboration overlay)
    this.linkedRepos = new Map()

    // Deploy lock — prevents concurrent auto-deploys per repo
    this._deployLocks = new Map()

    this.repos = []
    this._ready = false

    // Initialize router
    this.router = new Router(this)
    this.setupRoutes()
  }

  // ── Property Groups ──────────────────────────────────────
  // Makes dependencies visible when routes access server.auth.*, server.config.*, etc.
  // All properties remain directly on `this` for backward compatibility.

  get auth () {
    return { authManager: this.authManager, identityRegistry: this.identityRegistry, authEnabled: this.authEnabled }
  }

  get config () {
    return { pathPrefix: this.pathPrefix, bindAddresses: this.bindAddresses, port: this.port, externalUrl: this.externalUrl, defaultNamespace: this.defaultNamespace, storage: this.storage, headless: this.headless, bootstrap: this._bootstrap }
  }

  get repoData () {
    return { repoRegistry: this.repoRegistry, repos: this.repos, linkedRepos: this.linkedRepos, scanner: this.scanner, issueImageBlobs: this.issueImageBlobs, backendManager: this.backendManager, gitProtocol: this.gitProtocol }
  }

  get p2p () {
    return { serverIdentity: this.serverIdentity, topicManager: this.topicManager, gitP2PHandler: this.gitP2PHandler, browseClient: this.browseClient, swarm: this.swarm, browseSwarm: this.browseSwarm, driveSwarm: this.driveSwarm, gitSyncManager: this.gitSyncManager, p2pEnabled: this.p2pEnabled }
  }

  get deployData () {
    return { deployer: this.deployer, deployRegistry: this.deployRegistry }
  }

  get namespaceData () {
    return { namespaceManager: this.namespaceManager, namespaceRegistry: this.namespaceRegistry }
  }

  get coreData () {
    return { registry: this.registry, store: this.store }
  }

  /**
   * Resolve the default storage path (absolute, CWD-independent).
   * Under Pear runtime: Pear.config.storage + '/pear-git-data'
   * Otherwise: ~/.config/pear-git/server-data/
   */
  static resolveDefaultStorage () {
    const isPearRuntime = typeof Pear !== 'undefined'
    if (isPearRuntime && Pear.config && Pear.config.storage) {
      return path.join(Pear.config.storage, 'pear-git-data')
    }
    return path.join(os.homedir(), '.config', 'pear-git', 'server-data')
  }

  /**
   * Setup all routes
   */
  setupRoutes() {
    registerAllRoutes(this.router, {
      headless: this.headless,
      gitReposPath: this.repoRegistry.gitReposPath
    })
  }

  /**
   * Initialize server
   */
  async ready() {
    await this.registry.ready()

    // Initialize unified MetadataBase — single Autobase for ALL shared metadata
    // MetadataBase: own Corestore + own Hyperswarm (keet-daemon pattern)
    const metadataConfig = await this.registry.get('config:metadataBase')
    const metadataBootstrapKey = metadataConfig?.value?.bootstrapKey || null
    this.metadataBase = new MetadataBase({
      storagePath: this.storage,
      bootstrapKey: metadataBootstrapKey,
      swarmOpts: this._bootstrap ? { bootstrap: this._bootstrap } : {}
    })
    await this.metadataBase.ready()
    if (!metadataConfig) {
      await this.registry.put('config:metadataBase', {
        bootstrapKey: this.metadataBase.bootstrapKeyHex,
        createdAt: Date.now()
      })
      console.log('[Metadata] Created:', this.metadataBase.bootstrapKeyHex?.substring(0, 16))
    } else {
      console.log('[Metadata] Loaded:', this.metadataBase.bootstrapKeyHex?.substring(0, 16))
    }

    // Legacy discovery keys for old peers still on separate Autobases
    await this._joinLegacyDiscoveryKeys()

    // Initialize facades over unified MetadataBase (same APIs, one Autobase)
    this.identityRegistry = new IdentityFacade({ metadataBase: this.metadataBase })
    this.deployRegistry = new DeployFacade({ metadataBase: this.metadataBase })
    this.namespaceRegistry = new GroupFacade({
      metadataBase: this.metadataBase,
      repoRegistry: this.repoRegistry
    })
    console.log('[Facades] Identity, Deploy, Groups initialized on unified MetadataBase')

    // Watch for ref-update events from federation peers.
    // When a remote server deploys, the ref-update propagates via Autobase
    // replication (automatic, reliable, tiny). We then fetch just the delta.
    const selfKey = this.serverIdentity ? this.serverIdentity.getPublicKeyHex() : null
    this.metadataBase.on('deploy', async (op) => {
      if (op.type !== 'ref-update') return
      if (op.serverKey === selfKey) return // self-deploy
      const repoPath = path.join(this.repoRegistry.gitReposPath, op.repoName + '.git')
      try { await fs.promises.stat(repoPath) } catch { return }
      console.log(`[RefSync] ${op.repoName} v${op.version} from ${(op.serverKey || '').slice(0, 8)}`)
      const fetchUrl = `punch://${op.serverKey}/${op.repoName}`
      try {
        const child = subprocess.spawn('git', [
          '-C', repoPath, 'fetch', fetchUrl, `${op.ref}:${op.ref}`
        ], { stdio: ['ignore', 'pipe', 'pipe'] })
        const result = await new Promise((resolve) => {
          let stderr = ''
          child.stderr.on('data', (d) => { stderr += d.toString() })
          child.on('exit', (code) => resolve({ code, stderr }))
          child.on('error', () => resolve({ code: -1, stderr: 'spawn error' }))
        })
        if (result.code === 0) console.log(`[RefSync] ${op.repoName}: synced to ${op.sha.slice(0, 12)}`)
        else console.error(`[RefSync] ${op.repoName}: failed — ${result.stderr.split('\n').pop()}`)
      } catch (err) {
        console.error(`[RefSync] ${op.repoName}: ${err.message}`)
      }
    })

    // Now that identity facade is ready, initialize auth
    this.authManager = new AuthManager({
      registry: this.registry,
      identityRegistry: this.identityRegistry,
      trustedProxies: this._trustedProxies
    })

    await this._migrateIdentityData()
    await this.issueImageBlobs.ready()

    // Write storage locator breadcrumb so future boots can find this path
    try {
      await StorageLocator.write(this.storage)
      console.log(`[Storage] Active: ${path.resolve(this.storage)}`)
    } catch (err) {
      console.warn('[Storage] Failed to write locator:', err.message)
    }

    // Migration warning: if CWD has a different ./pear-git-storage, warn
    try {
      const cwdStorage = path.resolve('./pear-git-storage')
      const activeStorage = path.resolve(this.storage)
      if (cwdStorage !== activeStorage) {
        const identityFile = path.join(cwdStorage, 'server-identity.json')
        fs.statSync(identityFile)
        // If we get here, there's a stale storage in CWD
        console.warn(`[Storage] WARNING: Stale storage found at ${cwdStorage}`)
        console.warn(`[Storage] Active storage is ${activeStorage}`)
        console.warn('[Storage] The CWD storage will NOT be used. Move or delete it to suppress this warning.')
      }
    } catch {
      // No stale storage in CWD — this is the normal case
    }

    // Setup authentication
    if (this.authEnabled) {
      const adminToken = await this.authManager.createDefaultAdmin()
      if (adminToken) {
        console.log('')
        console.log('⚠️  FIRST TIME SETUP - ADMIN TOKEN CREATED')
        console.log('=' .repeat(60))
        console.log('Save this token securely (shown only once):')
        console.log('')
        console.log(`  ${adminToken.token}`)
        console.log('')
        console.log('Use with: Authorization: Bearer <token>')
        console.log('Or: ?token=<token>')
        console.log('=' .repeat(60))
        console.log('')
      }

      const status = await this.authManager.getStatus()
      console.log(`✓ Auth enabled: ${status.activeTokens} active tokens`)
    } else {
      console.log('⚠️  Auth DISABLED - server is open to all!')
    }

    // Auto-register local peer Noise key as admin identity.
    // This allows P2P push from the same machine (git push punch://).
    await this._registerLocalPeerKey()

    // Initialize deployer (connects to Pear sidecar)
    try {
      await this.deployer.ready()
      console.log('✓ Pear deployer connected')
    } catch (err) {
      console.log('⚠️  Pear deployer unavailable:', err.message)
    }

    // Deploy registry is now a facade over MetadataBase (no separate Autobase)

    // Ensure git repos directory exists
    try {
      await fs.promises.stat(this.repoRegistry.gitReposPath)
      console.log(`✓ Git repos directory: ${this.repoRegistry.gitReposPath}`)
    } catch {
      await fs.promises.mkdir(this.repoRegistry.gitReposPath, { recursive: true })
      console.log(`✓ Created git repos directory: ${this.repoRegistry.gitReposPath}`)
    }

    // Persist gitReposPath in registry for discoverability
    await this.registry.put('config:gitReposPath', { path: this.repoRegistry.gitReposPath })

    // Auto-scan for new repos on startup
    console.log('Scanning for git repositories...')
    const scannedRepos = await this.repoRegistry.scan({
      paths: [this.repoRegistry.gitReposPath],
      maxDepth: 1
    })

    if (scannedRepos.length > 0) {
      await this.repoRegistry.registerAll(scannedRepos)
      console.log(`✓ Registered ${scannedRepos.length} repositories`)
    }

    // Load all repos from registry
    this.repos = await this.repoRegistry.listAll()
    console.log(`✓ Loaded ${this.repos.length} repositories`)

    // Write repo catalog to MetadataBase (peers discover repos through this)
    if (this.metadataBase) {
      for (const repo of this.repos) {
        await this.metadataBase.set('repo', repo.name, {
          description: repo.description || null,
          visibility: repo.visibility || 'public',
          commits: repo.commits || 0
        })
      }
      console.log(`✓ Synced ${this.repos.length} repos to MetadataBase`)

      // Watch for new repos from peers — create bare repos + fetch
      this._watchMetadataRepos()
    }

    // Namespace registry is now a facade over MetadataBase (no separate Autobase)
    // Check if this server was removed by a peer
    try {
      const selfKey = this.serverIdentity
        ? this.serverIdentity.getPublicKeyHex()
        : null
      const selfPeerId = selfKey ? selfKey.slice(0, 16) : null
      if (selfPeerId) {
        const selfWriter = await this.namespaceRegistry.getWriter(selfPeerId)
        if (selfWriter && selfWriter.removedAt) {
          console.log('⚠️  This server was removed from the shared registry — peer:{peerId} marked removed')
        }
      }
      const nsList = await this.namespaceRegistry.list()
      console.log(`✓ Groups loaded (${nsList.length} namespaces)`)
    } catch (err) {
      console.error('⚠️  Failed to load groups:', err.message)
    }

    // Identity replication handled by MetadataBase's own Hyperswarm

    // Initialize RepoFacades — per-repo issue/ref/label views over unified MetadataBase
    // No separate Autobases, no separate discovery keys, no separate writer exchanges
    for (const repo of this.repos) {
      try {
        const repoFacade = new RepoFacade({
          metadataBase: this.metadataBase,
          repoName: repo.name
        })

        // Attach server-local image storage
        repoFacade.setImageStore(this.issueImageBlobs, this.registry)

        this.linkedRepos.set(repo.name, repoFacade)

        // Watch for ref changes from remote peers via MetadataBase replication
        this._watchLinkedRepoRefs(repo.name, repoFacade)
      } catch (err) {
        console.error(`  Warning: Failed to initialize RepoFacade for ${repo.name}:`, err.message)
      }
    }
    console.log(`✓ Initialized ${this.linkedRepos.size} repo facades on unified MetadataBase`)

    // One-time migration: move issues from old Hyperbee registry to LinkedRepo Autobases
    await this._migrateIssuesToLinkedRepos()

    // Auto-reconsolidate groups on startup
    try {
      const nsBackend = this.namespaceRegistry || this.namespaceManager
      if (nsBackend && nsBackend.reconsolidateGroups) {
        const result = await nsBackend.reconsolidateGroups(this.repos)
        if (result.moved > 0 || result.deleted > 0) {
          console.log(`✓ Reconsolidated groups: ${result.moved} moved, ${result.deleted} empty deleted`)
        }
      }
    } catch (err) {
      console.error('⚠️  Group reconsolidation failed:', err.message)
    }

    // Update LAN discovery repos count
    this.lanDiscovery.repos = this.repos

    // Start LAN discovery
    await this.lanDiscovery.start()

    // Server is ready for HTTP requests
    this._ready = true

    // P2P and Hyperdrive seeding run in background — HTTP starts immediately
    this._backgroundInit = this._initBackground().catch(err => {
      console.error('[Server] Background init failed:', err)
    })
  }

  /**
   * Watch MetadataBase for new repos from peers.
   * When a peer appends a repo: entry, check if we have it locally.
   * If not, create a bare repo and fetch via GitSync.
   */
  _watchMetadataRepos () {
    let checking = false
    this.metadataBase._base.on('update', async () => {
      if (checking) return
      checking = true
      try {
        const remoteRepos = await this.metadataBase.list('repo')
        const localNames = new Set(this.repos.map(r => r.name))

        for (const remote of remoteRepos) {
          if (localNames.has(remote.id)) continue

          // New repo from peer — create bare repo
          const repoPath = path.join(this.repoRegistry.gitReposPath, remote.id + '.git')
          try {
            await fs.promises.stat(repoPath)
          } catch {
            // Doesn't exist — create it
            const { spawn: spawnGit } = await import('bare-subprocess')
            await new Promise((resolve, reject) => {
              const child = spawnGit('git', ['init', '--bare', repoPath])
              child.on('exit', code => code === 0 ? resolve() : reject(new Error('git init failed')))
            })
            console.log(`[Metadata] Created new repo from peer: ${remote.id}`)

            // Register it
            await this.repoRegistry.register({
              name: remote.id,
              description: remote.description,
              visibility: remote.visibility
            })
            this.repos = await this.repoRegistry.listAll()

            // Fetch from federation peer
            if (this.gitSyncClientManager) {
              const fedPeers = []
              for await (const entry of this.registry.createReadStream({ gt: 'federation:', lt: 'federation:\xff' })) {
                if (entry.value.status === 'active') fedPeers.push(entry.value.serverKey)
              }
              if (fedPeers.length > 0) {
                console.log(`[Metadata] Fetching ${remote.id} from ${fedPeers[0].slice(0, 8)}`)
                this.gitSyncClientManager._syncRepo(remote.id, repoPath, fedPeers[0]).catch(err => {
                  console.error(`[Metadata] Fetch failed for ${remote.id}: ${err.message}`)
                })
              }
            }
          }
        }
      } catch (err) {
        // Non-fatal
      } finally {
        checking = false
      }
    })
  }

  /**
   * Auto-register the local peer Noise key as an admin identity.
   * Reads ~/.config/pear-git/peer-identity.json (created by pgh/git-remote-punch)
   * and registers it in the MetadataBase so P2P push from this machine works.
   */
  async _registerLocalPeerKey () {
    try {
      const peerIdentityPath = path.join(os.homedir(), '.config', 'pear-git', 'peer-identity.json')
      let peerData
      try {
        peerData = JSON.parse(fs.readFileSync(peerIdentityPath, 'utf-8'))
      } catch {
        return // No peer identity — pgh hasn't been used yet
      }

      if (!peerData.publicKey) return

      const publicKeyHex = peerData.publicKey

      // Check if noise-key reverse lookup exists (not just identity record)
      const existing = await this.identityRegistry.getIdentityByNoiseKey(publicKeyHex)
      if (existing) {
        console.log(`[P2P] Local peer key already registered (${publicKeyHex.slice(0, 12)}...)`)
        return
      }

      // Find an admin user to link to
      const allUsers = await this.identityRegistry.listAllUsers()
      const adminUser = allUsers.find(u =>
        u.globalPermissions && u.globalPermissions.includes('admin')
      )

      if (!adminUser) {
        console.log('[P2P] No admin user found — cannot auto-register local peer key')
        return
      }

      // Register (or repair) the Noise key identity — ensures reverse lookup exists
      await this.identityRegistry.repairIdentity({
        identityId: `noise:${publicKeyHex}`,
        method: 'noise-key',
        userId: adminUser.userId,
        credentials: { publicKey: publicKeyHex }
      })

      console.log(`✓ Local peer key registered for P2P push (${publicKeyHex.slice(0, 12)}... → ${adminUser.userId.slice(0, 8)})`)
    } catch (err) {
      console.error('[P2P] Failed to auto-register local peer key:', err.message)
    }
  }

  /**
   * Join old Autobase discovery keys so legacy peers (pre-unified code) can connect.
   * Derives discovery keys from stored bootstrap keys using hypercore-crypto.
   * Remove this method after all federation peers have updated.
   */
  async _joinLegacyDiscoveryKeys () {
    const crypto = await import('hypercore-crypto')
    const discoveryKey = crypto.default ? crypto.default.discoveryKey : crypto.discoveryKey
    const legacyKeys = ['config:identityRegistry', 'config:deployRegistry', 'config:namespaceRegistry']
    let joined = 0

    for (const configKey of legacyKeys) {
      try {
        const config = await this.registry.get(configKey)
        if (!config?.value?.bootstrapKey) continue
        const keyBuf = Buffer.from(config.value.bootstrapKey, 'hex')
        const dk = discoveryKey(keyBuf)
        this.driveSwarm.join(dk)
        joined++
      } catch {}
    }

    if (joined > 0) {
      console.log(`[Compat] Joined ${joined} legacy discovery keys for pre-unified peers`)
    }
  }

  /**
   * Watch a LinkedRepo for ref changes from remote peers.
   * When the Autobase update event fires, compare refs in the
   * LinkedRepo view against local git refs. If they differ,
   * trigger a git fetch from the peer who updated them.
   */
  _watchLinkedRepoRefs (repoName, linked) {
    let syncing = false
    let cooldownUntil = 0
    linked._base.on('update', async () => {
      if (syncing) return
      if (Date.now() < cooldownUntil) return
      syncing = true
      try {
        const linkedRefs = await linked.listRefs()
        if (linkedRefs.length === 0) return

        const repoPath = this.repoRegistry.gitReposPath + '/' + repoName + '.git'
        const { readRefs } = await import('./lib/git/ref-reader.js')
        let localRefs
        try {
          localRefs = await readRefs(repoPath)
        } catch (err) {
          if (err.code === 'ENOENT') return // repo doesn't exist locally yet
          throw err
        }

        // Compare: any ref in LinkedRepo that differs from local git?
        let needsSync = false
        for (const { ref, hash } of linkedRefs) {
          if (localRefs[ref] !== hash) {
            needsSync = true
            break
          }
        }

        if (needsSync && this.gitSyncClientManager) {
          // Find which peer to fetch from — use the ref updater's key
          // or fall back to any known federation peer
          const fedPeers = []
          for await (const entry of this.registry.createReadStream({ gt: 'federation:', lt: 'federation:\xff' })) {
            if (entry.value.status === 'active') fedPeers.push(entry.value.serverKey)
          }
          if (fedPeers.length > 0) {
            console.log(`[RefSync] ${repoName}: refs changed in LinkedRepo, fetching from ${fedPeers[0].slice(0, 8)}`)
            try {
              await this.gitSyncClientManager._syncRepo(repoName, repoPath, fedPeers[0])
            } catch (err) {
              console.error(`[RefSync] Fetch failed for ${repoName}: ${err.message}`)
              // Back off 60s after failure to prevent retry storms
              cooldownUntil = Date.now() + 60_000
            }
          }
        }
      } catch (err) {
        // Non-fatal — ref sync is best-effort
      } finally {
        syncing = false
      }
    })
  }

  // _exchangeWriters removed — MetadataBase.exchangeWriters() handles the single writer exchange

  /**
   * Graceful shutdown — close all resources
   */
  async close () {
    console.log('[Server] Shutting down...')

    // Stop HTTP servers
    if (this._httpServers) {
      for (const s of this._httpServers) s.close()
    }

    // Stop Protomux-based git sync
    if (this.gitSyncManager) {
      try { await this.gitSyncManager.destroy() } catch {}
    }
    if (this.gitSyncClientManager) {
      try { await this.gitSyncClientManager.destroy() } catch {}
    }

    // Stop LAN discovery
    try { await this.lanDiscovery.stop() } catch {}

    // Clear deploy locks
    this._deployLocks.clear()

    // Close unified MetadataBase (single Autobase for all metadata)
    if (this.metadataBase) {
      try { await this.metadataBase.close() } catch (err) {
        console.error('[Server] Error closing MetadataBase:', err.message)
      }
    }

    // Destroy auth manager intervals
    try {
      if (this.authManager.challengeManager) this.authManager.challengeManager.destroy()
      if (this.authManager.rateLimiter) this.authManager.rateLimiter.destroy()
    } catch {}

    // Destroy Hyperswarm instances
    try { await this.swarm.destroy() } catch (err) {
      console.error('[Server] Error destroying gitSwarm:', err.message)
    }
    try { await this.browseSwarm.destroy() } catch (err) {
      console.error('[Server] Error destroying browseSwarm:', err.message)
    }
    try { await this.driveSwarm.destroy() } catch (err) {
      console.error('[Server] Error destroying driveSwarm:', err.message)
    }

    // Close Corestore (flushes storage)
    try { await this.store.close() } catch (err) {
      console.error('[Server] Error closing corestore:', err.message)
    }

    console.log('[Server] Shutdown complete')
  }

  /**
   * One-time migration: move issues from old Hyperbee registry to LinkedRepo Autobases.
   * The old IssueManager stored issues at `issue:{repoName}:{number}` and labels at
   * `label:{repoName}:{name}` in this.registry (single-writer Hyperbee).
   * Safe to call on every boot — skips if migration flag is set.
   */
  async _migrateIssuesToLinkedRepos () {
    const migrationDone = await this.registry.get('config:issueMigration')
    if (migrationDone) return

    let totalIssues = 0
    let totalLabels = 0
    let totalComments = 0

    for (const repo of this.repos) {
      const linked = this.linkedRepos.get(repo.name)
      if (!linked || !linked.writable) continue

      // Migrate labels first (issues may reference them)
      for await (const entry of this.registry.createReadStream({
        gt: `label:${repo.name}:`,
        lt: `label:${repo.name}:\xff`
      })) {
        const label = entry.value
        try {
          await linked.createLabel(
            label.name,
            label.color || '#ccc',
            label.description || '',
            label.displayName || label.name
          )
          totalLabels++
        } catch (err) {
          // Label may already exist if partial migration ran
          if (!err.message.includes('already exists')) {
            console.error(`  Warning: Failed to migrate label ${label.name} for ${repo.name}:`, err.message)
          }
        }
      }

      // Migrate issues
      for await (const entry of this.registry.createReadStream({
        gt: `issue:${repo.name}:`,
        lt: `issue:${repo.name}:\xff`
      })) {
        const issue = entry.value
        try {
          const issueId = await linked.createIssue(
            issue.title,
            issue.description || '',
            issue.createdBy || 'migrated',
            {
              labels: issue.labels || [],
              images: issue.images || []
            }
          )

          // If issue was closed, update status
          if (issue.status === 'closed') {
            await linked.updateIssue(issueId, { status: 'closed' }, issue.createdBy || 'migrated')
          }

          // Migrate comments
          if (issue.comments && issue.comments.length > 0) {
            for (const comment of issue.comments) {
              await linked.createComment(
                issueId,
                comment.text || comment.body || '',
                comment.author || comment.userId || 'migrated'
              )
              totalComments++
            }
          }

          totalIssues++
        } catch (err) {
          console.error(`  Warning: Failed to migrate issue #${issue.number} for ${repo.name}:`, err.message)
        }
      }
    }

    await this.registry.put('config:issueMigration', {
      completedAt: Date.now(),
      stats: { issues: totalIssues, labels: totalLabels, comments: totalComments }
    })

    if (totalIssues > 0 || totalLabels > 0) {
      console.log(`✓ Migrated ${totalIssues} issues, ${totalLabels} labels, ${totalComments} comments to Autobase`)
    }
  }

  /**
   * Adopt a remote peer's issue Autobase key for a repo.
   * Called by GitSyncClientManager when a full-sync-manifest includes an issueKey.
   *
   * For mirror peers: always adopt (share all issue Autobases).
   * For peer mode: only adopt for granted repos.
   */
  async _adoptIssueAutobase (repoName, remoteKey, peerServerKey) {
    const existing = this.linkedRepos.get(repoName)

    // Already using the same key — nothing to do
    if (existing && existing.bootstrapKeyHex === remoteKey) return

    // Check peer mode — only mirrors auto-adopt all repos
    if (peerServerKey) {
      const peerRecord = await this.registry.get(`federation:${peerServerKey}`)
      if (peerRecord && peerRecord.value) {
        const mode = peerRecord.value.mode
        if (mode === 'peer') {
          // Peer mode: only adopt for granted repos
          const granted = peerRecord.value.grantedRepos || []
          if (!granted.includes(repoName) && !granted.includes('*')) return
        }
        // mirror mode: adopt all
      }
    }

    // If we have a local Autobase with data, don't overwrite — log mismatch
    if (existing) {
      try {
        const counts = await existing.getIssueCounts()
        if (counts.total > 0) {
          console.log(`[IssueSync] Mismatch for ${repoName}: local has ${counts.total} issues, remote key differs. Keeping local.`)
          return
        }
      } catch {}

      // Local Autobase is empty — safe to replace
      try { await existing.close() } catch {}
    }

    // Create new LinkedRepo with remote peer's key
    try {
      const linked = new LinkedRepo({
        corestore: this.store,
        bootstrapKey: remoteKey,
        repoName
      })
      await linked.ready()

      // Persist the adopted key
      await this.registry.put(`config:issueAutobase:${repoName}`, {
        bootstrapKey: linked.bootstrapKeyHex,
        adoptedFrom: peerServerKey,
        adoptedAt: Date.now()
      })

      // Attach image store and join driveSwarm
      linked.setImageStore(this.issueImageBlobs, this.registry)
      this.driveSwarm.join(linked.discoveryKey)
      this.linkedRepos.set(repoName, linked)

      console.log(`[IssueSync] Adopted issue Autobase for ${repoName} from ${peerServerKey.slice(0, 8)}`)
    } catch (err) {
      console.error(`[IssueSync] Failed to adopt issue Autobase for ${repoName}: ${err.message}`)
    }
  }

  /**
   * One-time migration: copy users and identities from old Hyperbee
   * (this.registry) into the new Autobase identity registry.
   * Safe to call on every boot — skips if data already exists.
   */
  async _migrateIdentityData () {
    // Skip migration if registry isn't writable (joined remote, not yet synced)
    if (!this.identityRegistry.writable) return

    // If the Autobase registry already has users, migration was done
    const existingUsers = await this.identityRegistry.listAllUsers()
    if (existingUsers.length > 0) return

    // Collect old user data from Hyperbee
    const oldUsers = []
    for await (const entry of this.registry.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
      oldUsers.push(entry.value)
    }

    const oldIdentities = []
    for await (const entry of this.registry.createReadStream({ gte: 'identity:', lt: 'identity:\xff' })) {
      oldIdentities.push(entry.value)
    }

    if (oldUsers.length === 0 && oldIdentities.length === 0) return

    console.log(`[Migration] Migrating ${oldUsers.length} users and ${oldIdentities.length} identities to Autobase...`)

    // Replay user creation with preserved userIds
    for (const user of oldUsers) {
      try {
        await this.identityRegistry.createUser({
          userId: user.userId,
          name: user.name,
          email: user.email,
          profilePicture: user.profilePicture,
          permissions: user.globalPermissions,
          repos: user.repos
        })
      } catch (err) {
        console.warn(`[Migration] User ${(user.userId || 'unknown').substring(0, 8)}: ${err.message}`)
      }
    }

    // Replay identity creation with preserved links
    for (const identity of oldIdentities) {
      try {
        await this.identityRegistry.createIdentity({
          identityId: identity.identityId,
          method: identity.method,
          userId: identity.userId,
          credentials: identity.credentials
        })
      } catch (err) {
        console.warn(`[Migration] Identity ${(identity.identityId || 'unknown').substring(0, 8)}: ${err.message}`)
      }
    }

    console.log('[Migration] Identity data migration complete')
  }

  /**
   * Background initialization (P2P + Hyperdrive seeding + auto-seed)
   * Runs after HTTP is already listening.
   */
  async _initBackground() {
    try {
      if (this.p2pEnabled) {
        await this.initP2P()
      }

      // Hyperdrive seeding removed — Hyperdrive backend is deprecated.
      // seedHyperdrives() was blocking _initBackground (and thus GitSync)
      // when no sidecar was running. No repos use hyperdrive strategy.

      // Resume seeding for repos with autoSeed enabled (non-blocking — don't let it block ServerSync)
      this._resumeAutoSeed().catch(err => {
        console.error('[AutoSeed] Error:', err.message)
      })

      // ServerSync removed — replaced by Protomux-based git sync
      // (full-sync-request/manifest on connection + push-triggered ref-update)

      // Start Protomux-based git sync (attaches to driveSwarm connections)
      try {
        const { GitSyncClientManager } = await import('./lib/p2p/sync/git-sync-client-manager.js')
        const selfServerKey = this.serverIdentity ? this.serverIdentity.getPublicKeyHex() : null
        this.gitSyncClientManager = new GitSyncClientManager({
          reposDir: this.repoRegistry.gitReposPath,
          repoRegistry: this.repoRegistry,
          repos: this.repos,
          linkedRepos: this.linkedRepos,
          serverKey: selfServerKey,
          topicManager: this.topicManager || null,
          appDir: selfUpdateConfig ? selfUpdateConfig.appDir : null,
          appRepoName: selfUpdateConfig ? selfUpdateConfig.appRepoName : null,
          onIssueKeyReceived: (repoName, remoteKey, peerServerKey) => {
            return this._adoptIssueAutobase(repoName, remoteKey, peerServerKey)
          }
        })
        console.log('[GitSync] Client manager initialized')

        const { GitSyncManager } = await import('./lib/p2p/sync/git-sync-manager.js')
        this.gitSyncManager = new GitSyncManager({
          reposDir: this.repoRegistry.gitReposPath,
          registry: this.registry,
          serverKey: selfServerKey,
          linkedRepos: this.linkedRepos,
          metadataKey: this.metadataBase?.bootstrapKeyHex || null,
          maxConcurrent: 5,
          clientManager: this.gitSyncClientManager
        })
        console.log('[GitSync] Manager initialized')

        // Attach to any driveSwarm connections that arrived before manager was ready
        if (this._driveConnections && this._driveConnections.length > 0) {
          for (const { conn, stream } of this._driveConnections) {
            if (!conn.destroyed) {
              this.gitSyncManager.attachToConnection(conn, stream)
            }
          }
          console.log(`[GitSync] Attached to ${this._driveConnections.length} existing connection(s)`)
          this._driveConnections = []
        }
      } catch (err) {
        console.error('[GitSync] Failed to start:', err.message)
      }
    } catch (err) {
      console.error('⚠️  Background init error:', err.message)
    }
  }

  /**
   * Resume Pear app seeding for repos with deploy.autoSeed enabled.
   * Creates minimal seed dirs (just package.json) on the fly if missing.
   */
  async _resumeAutoSeed() {
    if (!this.deployer || !this.deployer._ready) return

    const seedable = this.repos.filter(r =>
      r.deploy && r.deploy.enabled && r.deploy.autoSeed && r.deploy.channel
    )

    if (seedable.length === 0) return

    let seeded = 0
    for (const repo of seedable) {
      try {
        const config = repo.deploy
        // Ensure minimal seed dir exists (just package.json — ~50 bytes)
        // Actual seeded data lives in ~/.config/pear/by-dkey/, not here
        const deployDir = path.join(this.storage, 'deploy', repo.name)
        const pkgPath = path.join(deployDir, 'package.json')

        try {
          await fs.promises.stat(pkgPath)
        } catch {
          // Create minimal package.json for seeder
          try { await fs.promises.mkdir(deployDir, { recursive: true }) } catch {}
          const pkg = {
            name: config.channel,
            pear: { name: config.channel, type: config.pearType || 'terminal' }
          }
          await fs.promises.writeFile(pkgPath, JSON.stringify(pkg) + '\n')
          console.log(`[AutoSeed] Created minimal seed dir for ${repo.name}`)
        }

        await this.deployer.seed(config.channel, { dir: deployDir })
        seeded++
        console.log(`[AutoSeed] Resumed seeding: ${repo.name} (${config.channel})`)
      } catch (err) {
        console.error(`[AutoSeed] Failed to seed ${repo.name}:`, err.message)
      }
    }

    if (seeded > 0) {
      console.log(`✓ Auto-seed: ${seeded} app${seeded !== 1 ? 's' : ''} resumed`)
    }
  }

  /**
   * Announce identity registry on Hyperswarm for P2P replication.
   * Other pear-git servers with the same bootstrap key will discover
   * each other and replicate the Autobase automatically.
   *
   * Replication itself is handled by the driveSwarm 'connection' handler
   * which calls this.store.replicate(conn) — the namespaced corestore
   * for the identity registry is part of the same root store.
   */
  async _initIdentityReplication () {
    if (!this.identityRegistry || !this.identityRegistry.discoveryKey) return
    if (this._identityReplicationStarted) return

    const topic = this.identityRegistry.discoveryKey
    this.driveSwarm.join(topic)
    this._identityReplicationStarted = true

    console.log('[P2P] Identity registry replicating on:', topic.toString('hex').substring(0, 16))
  }

  /**
   * Initialize P2P networking
   */
  async initP2P() {
    try {
      this.serverIdentity = new ServerIdentity({
        storagePath: this.storage,
        explicitStorage: this._explicitStorage
      })
      await this.serverIdentity.init()

      const proofStatus = this.serverIdentity.getProofStatus()
      this.identitySecured = proofStatus.hasProof
      if (proofStatus.hasProof) {
        console.log(`  Keet identity: ${proofStatus.identityPublicKey.slice(0, 16)}...`)
      } else {
        console.log('  Keet identity: NOT SET \u2014 run `pgh identity setup`')
      }


      this.gitP2PHandler = new GitP2PHandler({
        reposPath: this.repoRegistry.gitReposPath,
        authManager: this.authManager,
        repoRegistry: this.repoRegistry,
        registry: this.registry,
        serverIdentity: this.serverIdentity,
        backendManager: this.backendManager
      })

      this.topicManager = new TopicManager(this.serverIdentity, {
        swarm: this.swarm,
        onConnection: (conn, peerInfo) => {
          this.gitP2PHandler.handleConnection(conn, peerInfo)
        }
      })
      await this.topicManager.init()

      // Set topicManager on handler (needed for admin create-repo announcements)
      this.gitP2PHandler.topicManager = this.topicManager
      // Set topicManager on GitSync client (needed for announcing runtime-created repos)
      if (this.gitSyncClientManager) this.gitSyncClientManager._topicManager = this.topicManager
      // Auto-announce new repos on DHT when registered at runtime
      this.repoRegistry.onRepoRegistered = (repoName) => this.topicManager.announceRepo(repoName)
      // Set server ref so admin create-repo can update in-memory repos cache
      this.gitP2PHandler.server = this

      // Browse client for remote repo viewing — uses its own swarm to avoid
      // connection dedup + data delivery issues with the shared git swarm
      this.browseClient = new BrowseClient(this.browseSwarm)

      const publicRepos = this.repos.filter(r => r.visibility !== 'private')
      const repoNames = publicRepos.map(r => r.name)

      // Announce admin topic for P2P admin commands
      await this.topicManager.announceAdminTopic()

      if (repoNames.length > 0) {
        await this.topicManager.announceRepos(repoNames)
        console.log(`✓ P2P enabled: ${repoNames.length} repos + admin topic announced`)
        console.log(`  Server key: ${this.serverIdentity.getPublicKeyHex().slice(0, 16)}...`)
      } else {
        console.log('✓ P2P enabled (admin topic announced, no public repos)')
      }
    } catch (err) {
      console.error('⚠️  P2P initialization failed:', err.message)
      this.p2pEnabled = false
    }
  }

  /**
   * Handle HTTP requests
   */
  async handleRequest(req, res) {
    if (!this._ready) {
      res.statusCode = 503
      res.setHeader('Content-Type', 'text/html')
      res.setHeader('Retry-After', '5')
      res.end('<html><head><meta http-equiv="refresh" content="5"><style>body{background:#0d1117;color:#c9d1d9;font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}div{text-align:center}h1{font-size:24px;margin-bottom:12px}.spinner{display:inline-block;width:40px;height:40px;border:3px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin 1s linear infinite;margin-bottom:20px}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><div><div class="spinner"></div><h1>Starting up...</h1><p>Loading repositories. This page will refresh automatically.</p></div></body></html>')
      return
    }

    try {
      const dispatched = await this.router.dispatch(req, res)

      if (!dispatched) {
        this.serve404(res)
      }
    } catch (err) {
      // Handle rate limiting errors
      if (err.code === 'RATE_LIMIT') {
        console.warn(`[RateLimit] ${this.authManager.getClientIp(req)} exceeded limit`)
        rateLimited(res, err.retryAfter, err.resetAt)
        return
      }

      console.error('Request error:', err)
      res.statusCode = 500
      res.end('Internal Server Error')
    }
  }

  /**
   * Serve 404
   */
  serve404(res) {
    html(res, '<h1>404 Not Found</h1>', 404)
  }

  /**
   * Start server
   */
  async start() {
    const handler = this.handleRequest.bind(this)
    this._httpServers = []

    let bannerPrinted = false
    const printBanner = () => {
      if (bannerPrinted) return
      bannerPrinted = true

      const localIPs = this.lanDiscovery.getLocalIPs()

      console.log('')
      console.log(this.headless ? 'Pear Git Server (headless)' : 'Pear Git Server')
      console.log('==================')
      console.log('')
      console.log(`Repositories: ${this.repos.length}`)
      console.log('')
      console.log('Listening on:')
      for (const addr of this.bindAddresses) {
        console.log(`  http://${addr}:${this.port}${this.pathPrefix}/`)
      }
      console.log('')
      console.log('Git Clone Examples:')
      const serverKey = this.serverIdentity && this.serverIdentity.publicKey
        ? this.serverIdentity.getPublicKeyHex()
        : null
      if (serverKey) {
        console.log(`  P2P:    git clone punch://${serverKey.slice(0, 8)}.../<repo>`)
        console.log(`  Alt:    git clone git+pear://${serverKey.slice(0, 8)}.../<repo>`)
      } else if (this.p2pEnabled) {
        console.log(`  P2P:    (initializing in background...)`)
      }
      // Pick the best LAN address from actual bind addresses
      const lanAddr = this.bindAddresses.find(a => a !== 'localhost' && a !== '127.0.0.1' && a !== '::1')
      let lanHost = lanAddr || 'localhost'
      if (lanHost === '0.0.0.0' || lanHost === '::') {
        try { lanHost = os.hostname() } catch (e) { lanHost = 'localhost' }
      }
      console.log(`  LAN:    git clone http://${lanHost}:${this.port}${this.pathPrefix}/user/repo.git`)
      console.log('')

      if (serverKey) {
        console.log('P2P Server Key (for petnames):')
        console.log(`  ${serverKey}`)
        console.log('')
      }
    }

    for (const addr of this.bindAddresses) {
      const srv = http.createServer(handler)
      try { srv.timeout = 30000 } catch {} // bare-http1 may have read-only timeout
      this._httpServers.push(srv)
      srv.listen(this.port, addr, printBanner)
    }

    return this._httpServers[0]
  }
}

// Main entry point
async function main() {
  const mode = args[0]

  // Command routing — works under both `bare` and `pear run`
  switch (mode) {
    case 'web':
    case 'serve':
      break // fall through to server startup below

    case 'install':
      await runInstall()
      return

    case 'helper':
      // Passthrough mode for wrapper scripts: pear run pear://HASH helper <remote-helper-args>
      await runHelper(args.slice(1))
      return

    case 'pgh':
      // CLI mode: pear run pear://HASH pgh <pgh-args>
      await runPgh(args.slice(1))
      return

    default:
      console.log(`
Pear Git - P2P Git Forge v0.2.0

Usage:
  pear-git serve [port] [options]    Start the forge (heavy peer)
  pear-git install                   Install CLI tools (light peer)

${isPear ? `  pear run pear://HASH serve         Start forge via Pear
  pear run pear://HASH install       Install CLI tools via Pear
` : `  bare pear-git-server.js serve       Start forge via Bare
`}
Server Options:
  --storage PATH Server data directory (auto-detected via locator or ~/.config/pear-git/server-data/)
  --repos PATH   Path to bare git repos (default: ~/git-repos)
  --vpn          Bind to localhost + VPN IP (dual-bind)
  --network      Bind to all interfaces (0.0.0.0) - WARNING: insecure
  --prefix PATH  Path prefix for subdirectory hosting (e.g., /git)
  --bind IP      Custom bind address
  --external-url URL  Public HTTPS URL for clone display (e.g., https://7rnx.net/git)

Examples:
  ${isPear ? 'pear run pear://HASH' : 'bare pear-git-server.js'} serve                    # Localhost:5005
  ${isPear ? 'pear run pear://HASH' : 'bare pear-git-server.js'} serve 5005 --repos /srv/git-repos
  ${isPear ? 'pear run pear://HASH' : 'bare pear-git-server.js'} serve 5005 --vpn --prefix=/git
      `)
      process.exit(0)
  }

  // Parse options
  const headlessMode = args.includes('--headless')
  const vpnMode = args.includes('--vpn')
  const networkMode = args.includes('--network')
  const prefixArg = args.find(a => a.startsWith('--prefix='))
  const pathPrefix = prefixArg ? prefixArg.split('=')[1] : ''
  const storageArg = args.find(a => a.startsWith('--storage='))
  const storageIdx = args.indexOf('--storage')
  const storagePath = storageArg ? storageArg.split('=')[1]
    : (storageIdx !== -1 && args[storageIdx + 1]) ? args[storageIdx + 1]
    : undefined
  const reposArg = args.find(a => a.startsWith('--repos='))
  const reposIdx = args.indexOf('--repos')
  const gitReposPath = reposArg ? reposArg.split('=')[1]
    : (reposIdx !== -1 && args[reposIdx + 1]) ? args[reposIdx + 1]
    : undefined
  const bindArg = args.find(a => a.startsWith('--bind='))
  const bindIdx = args.indexOf('--bind')
  const customBind = bindArg ? bindArg.split('=')[1]
    : (bindIdx !== -1 && args[bindIdx + 1]) ? args[bindIdx + 1]
    : undefined
  const extUrlArg = args.find(a => a.startsWith('--external-url='))
  const extUrlIdx = args.indexOf('--external-url')
  const externalUrl = extUrlArg ? extUrlArg.split('=')[1]
    : (extUrlIdx !== -1 && args[extUrlIdx + 1]) ? args[extUrlIdx + 1]
    : undefined

  let bindAddress = customBind || 'localhost'
  let trustedProxies = ['127.0.0.1', '::1', '::ffff:127.0.0.1']

  if (vpnMode && !customBind) {
    // VPN mode: bind 0.0.0.0 (all interfaces, includes localhost)
    // Previously ['localhost', '0.0.0.0'] caused EADDRINUSE collision
    let vpnHost
    try { vpnHost = os.hostname() } catch (e) { vpnHost = 'localhost' }
    bindAddress = '0.0.0.0'
    trustedProxies.push('10.22.0.1', '10.22.0.2')
    console.log('VPN mode: all interfaces (' + vpnHost + ')')
  } else if (networkMode && !customBind) {
    bindAddress = '0.0.0.0'
    console.log('Network mode: Accessible from LAN')
  } else {
    console.log('Localhost mode: Secure local-only access')
  }

  const port = parseInt(args.find(a => a.match(/^\d+$/)) || 5005)

  // Create server
  const server = new PearGitServer({
    port,
    bindAddress,
    pathPrefix,
    storage: storagePath,
    gitReposPath,
    trustedProxies,
    externalUrl,
    headless: headlessMode
  })

  await server.ready()
  await server.start()

  // Graceful shutdown on signals
  let shuttingDown = false
  const shutdown = async (signal) => {
    if (shuttingDown) return
    shuttingDown = true
    console.log(`\n[Server] Received ${signal}, shutting down...`)
    const forceTimeout = setTimeout(() => {
      console.error('[Server] Force exit after 10s timeout')
      process.exit(1)
    }, 10000)
    try {
      await server.close()
    } catch (err) {
      console.error('[Server] Error during shutdown:', err)
    }
    clearTimeout(forceTimeout)
    process.exit(0)
  }

  process.on('SIGTERM', () => shutdown('SIGTERM'))
  process.on('SIGINT', () => shutdown('SIGINT'))
}

// Global error handlers — prevent silent crashes
// _suppressAutobaseErrors: set during LinkedRepo init to skip corrupted repos
let _suppressAutobaseErrors = false

process.on('unhandledRejection', (err) => {
  if (_suppressAutobaseErrors) {
    console.error('[WARN] Suppressed rejection during LinkedRepo init:', err.message)
    return
  }
  console.error('[FATAL] Unhandled rejection:', err)
})

process.on('uncaughtException', (err) => {
  if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE' || err.code === 'ETIMEDOUT' || err.code === 'ECONNREFUSED')) {
    console.error('[WARN] Connection error (non-fatal):', err.code, err.message)
    return
  }
  if (_suppressAutobaseErrors) {
    console.error('[WARN] Suppressed exception during LinkedRepo init:', err.message)
    return
  }
  console.error('[FATAL] Uncaught exception:', err)
  process.exit(1)
})

/**
 * Install command — writes wrapper scripts to ~/.local/bin/
 * Light peers use this to get git-remote-punch and pgh without running the forge.
 */
async function runInstall() {
  // Check required system dependencies
  const missing = []
  for (const cmd of ['git', 'npm', 'curl', 'tar']) {
    try {
      await new Promise((resolve, reject) => {
        const proc = subprocess.spawn('which', [cmd], { stdio: 'ignore' })
        proc.on('close', (code) => code === 0 ? resolve() : reject())
        proc.on('error', reject)
      })
    } catch {
      missing.push(cmd)
    }
  }

  if (missing.length > 0) {
    console.error('Missing required dependencies: ' + missing.join(', '))
    console.error('')
    console.error('Install them first:')
    console.error('  sudo apt install ' + missing.join(' '))
    console.error('')
    process.exit(1)
  }

  const selfDir = isPear ? null : path.dirname(process.argv[1] || '.')

  // Install dependencies if needed
  if (selfDir) {
    const nmDir = path.join(selfDir, 'node_modules')
    let hasNodeModules = false
    try { await fs.promises.stat(nmDir); hasNodeModules = true } catch {}

    if (!hasNodeModules) {
      console.log('Installing dependencies...')
      await new Promise((resolve, reject) => {
        const proc = subprocess.spawn('npm', ['install', '--production'], {
          cwd: selfDir,
          stdio: 'inherit'
        })
        proc.on('close', (code) => code === 0 ? resolve() : reject(new Error('npm install failed')))
        proc.on('error', reject)
      })
      console.log('')
    }
  }

  const installDir = path.join(os.homedir(), '.local', 'bin')

  // Ensure install directory exists
  try {
    await fs.promises.mkdir(installDir, { recursive: true })
  } catch (err) {
    if (err.code !== 'EEXIST') throw err
  }

  // Determine how to invoke ourselves
  let pearKey = null
  if (isPear && typeof Pear !== 'undefined' && Pear.config && Pear.config.link) {
    pearKey = Pear.config.link
  }

  const wrappers = []

  if (pearKey) {
    // Pear mode — wrappers call back via pear run
    wrappers.push({
      name: 'git-remote-punch',
      content: `#!/bin/sh\nexec pear run "${pearKey}" helper "$@"\n`
    })
    wrappers.push({
      name: 'pgh',
      content: `#!/bin/sh\nexec pear run --checkout=latest "${pearKey}" pgh "$@"\n`
    })
  } else {
    // Bare mode — wrappers call bare directly
    const binDir = path.join(selfDir, 'bin')
    wrappers.push({
      name: 'git-remote-punch',
      content: `#!/bin/sh\nexec bare "${path.join(binDir, 'git-remote-punch')}" "$@"\n`
    })
    wrappers.push({
      name: 'pgh',
      content: `#!/bin/sh\nexec bare "${path.join(binDir, 'pgh')}" "$@"\n`
    })
  }

  for (const w of wrappers) {
    const dest = path.join(installDir, w.name)
    await fs.promises.writeFile(dest, w.content, { mode: 0o755 })
    console.log(`  Installed: ${dest}`)
  }

  console.log('')
  console.log('CLI tools installed.')

  // Check if ~/.local/bin is in PATH
  const pathEnv = process.env.PATH || ''
  if (!pathEnv.split(':').some(p => p === installDir || p === installDir + '/')) {
    console.log('')
    console.log(`Add to your shell profile:`)
    console.log(`  export PATH="$HOME/.local/bin:$PATH"`)
  }

  console.log('')
  console.log('Next: run the setup wizard')
  console.log('  pgh identity setup')
  console.log('')
  console.log('Usage:')
  console.log('  git clone punch://serverkey/repo    Clone via P2P')
  console.log('  pgh repo list                       List repos')
  console.log('  pgh push                            Push to pear remote')
}

/**
 * Helper mode — run git-remote-punch with forwarded args.
 * Called by wrapper: pear run pear://HASH helper <args>
 *
 * Uses import.meta.url for path resolution so it works under both
 * bare (filesystem) and pear run (Pear cache directory).
 */
async function runHelper(helperArgs) {
  const __dirname = path.dirname(new URL(import.meta.url).pathname)
  const helperScript = path.join(__dirname, 'bin', 'git-remote-punch')

  const child = subprocess.spawn('bare', [helperScript, ...helperArgs], {
    stdio: 'inherit'
  })

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

  child.on('error', (err) => {
    console.error(`Helper error: ${err.message}`)
    process.exit(128)
  })
}

/**
 * pgh CLI mode — run the pgh CLI in-process.
 * Called by wrapper: pear run pear://HASH pgh <args>
 *
 * Runs in-process via dynamic import instead of spawning a subprocess.
 * This avoids path resolution issues under Pear runtime where the app
 * files live in Pear's cache, not the user's CWD.
 */
async function runPgh(pghArgs) {
  // Pass args via global — process.argv is read-only in Pear runtime
  globalThis.__pghArgs = pghArgs
  await import('./bin/pgh')
}

// Only run main() when executed directly, not when imported as a module
const isMain = import.meta.url === `file://${process.argv[1]}` ||
  import.meta.url === new URL(process.argv[1], 'file://').href
if (isMain) {
  main().catch(err => {
    console.error('Fatal error:', err)
    process.exit(1)
  })
}

export { PearGitServer }