#!/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 }