OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / server.js

588 lines · javascript ref:
#!/usr/bin/env node
/**
 * pear-git-v3 production entry point.
 *
 * Config comes from CLI flags (matching v1) or environment variables.
 * CLI flags take precedence over env vars.
 *
 * Usage:
 *   node server.js serve [PORT] [OPTIONS]
 *
 * Options:
 *   --storage PATH       State directory (env: PEAR_GIT_STORAGE)
 *   --repos PATH         Bare git repos dir (env: PEAR_GIT_REPOS)
 *   --network            Bind 0.0.0.0 (env: PEAR_GIT_HOST=0.0.0.0)
 *   --vpn                Bind 0.0.0.0 + VPN trusted proxies
 *   --bind IP            Custom bind address
 *   --prefix PATH        URL prefix for reverse proxy (e.g. /git)
 *   --external-url URL   Public URL for clone display
 *   --headless           Suppress interactive output
 *
 * Note: the daemon auto-derives its swarm topic from its own identity
 * (hash(identity.publicKey + "pear-git-v3")) — no flag for that. To
 * join an existing deployment as a mirror, use `pgh device-accept`
 * with a mirror-invite from the source daemon; the invite carries the
 * bootstrap keys + topic atomically.
 */

import process from '#node:process'
import { join, dirname } from '#node:path'
import { homedir } from '#node:os'
import { existsSync, statSync, readFileSync } from '#node:fs'
import { fileURLToPath } from '#node:url'
import { startPearGit } from './index.js'
import { sidecarSockPath, waitForSidecarSock, needsReregister } from './lib/sidecar-wait.js'

const HERE = dirname(fileURLToPath(import.meta.url))

// ── Parse argv ──────────────────────────────────────────────────

const args = process.argv.slice(2)

// Strip leading "serve" subcommand (v1 compat)
if (args[0] === 'serve') args.shift()

if (args.includes('--help') || args.includes('-h')) {
  console.log(`pear-git-v3 daemon

Usage: node server.js [serve] [PORT] [OPTIONS]

Options:
  --storage PATH       State directory (default: ~/pgv3-storage or PEAR_GIT_STORAGE)
  --repos PATH         Bare git repos dir (default: ~/git-repos or PEAR_GIT_REPOS)
  --network            Bind to all interfaces (0.0.0.0)
  --vpn                Bind 0.0.0.0 + VPN trusted proxies
  --bind IP            Custom bind address
  --prefix PATH        URL prefix for reverse proxy (e.g. /git)
  --external-url URL   Public URL for clone display (e.g. https://7rnx.net/git)
  --headless           Suppress interactive output

The daemon auto-derives its swarm topic from its own identity. To join
an existing deployment as a mirror, use \`pgh device-accept\` — the
mirror-invite carries the bootstrap keys + topic atomically.

Environment variables (CLI flags take precedence):
  PEAR_GIT_STORAGE, PEAR_GIT_REPOS, PEAR_GIT_HTTP, PEAR_GIT_HOST`)
  process.exit(0)
}

function getFlag (name) {
  const eq = args.find(a => a.startsWith('--' + name + '='))
  if (eq) return eq.split('=').slice(1).join('=')
  const idx = args.indexOf('--' + name)
  if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('--')) return args[idx + 1]
  return undefined
}

const networkMode = args.includes('--network')
const vpnMode = args.includes('--vpn')
const headless = args.includes('--headless')

const storage = getFlag('storage') || process.env.PEAR_GIT_STORAGE ||
  join(homedir(), 'pgv3-storage')
const reposDir = getFlag('repos') || process.env.PEAR_GIT_REPOS ||
  join(homedir(), 'git-repos')
const prefix = getFlag('prefix') || ''
const externalUrl = getFlag('external-url') || null
const customBind = getFlag('bind') || null
// Port: first bare numeric arg, or env, or default 4040
const portArg = args.find(a => /^\d+$/.test(a))
const httpPort = parseInt(portArg || process.env.PEAR_GIT_HTTP || '4040')

let httpHost = customBind || process.env.PEAR_GIT_HOST || '127.0.0.1'
if (networkMode || vpnMode) httpHost = '0.0.0.0'

const sock = process.env.PEAR_GIT_SOCK || join(storage, 'svc.sock')

// ── Duplicate-daemon guard ──────────────────────────────────────
// On a given machine we want exactly one daemon owning the store.
// Probe well-known socket locations + this machine's hyperident
// default vault to catch an already-running daemon. Refuse to start
// unless --force-duplicate is passed (with a prominent warning).
//
// Rationale: two daemons on one box with different storage dirs
// would create parallel stores, parallel registries, and duplicate
// every release's bytes per daemon. Our dedup model assumes one
// daemon = one store per machine.

const forceDuplicate = args.includes('--force-duplicate')
const wellKnownSocks = [
  join(homedir(), 'pgv3-storage', 'svc.sock'),
  join(homedir(), '.pear-git-v3', 'svc.sock')
]

for (const s of wellKnownSocks) {
  if (s === sock) continue // our own — fine if it doesn't exist
  try {
    if (existsSync(s) && statSync(s).isSocket()) {
      if (!forceDuplicate) {
        console.error('pear-git-v3: another daemon appears to be running.')
        console.error('  existing socket: ' + s)
        console.error('  our socket:      ' + sock)
        console.error('')
        console.error('  Only one daemon per machine is supported by default — running')
        console.error('  two would create parallel stores and duplicate every release\'s')
        console.error('  bytes. To start anyway with a separate storage + port:')
        console.error('')
        console.error('    pear-git-v3 serve <port> --storage <different-path> --force-duplicate')
        console.error('')
        process.exit(1)
      } else {
        console.warn('pear-git-v3: WARNING: --force-duplicate set; another daemon is running at')
        console.warn('  ' + s + '. Releases will not be deduplicated across daemons.')
      }
    }
  } catch {}
}

// ── Build opts ──────────────────────────────────────────────────

const opts = {
  storage,
  uds: sock,
  http: { port: httpPort, host: httpHost },
  reposDir,
  prefix: prefix.replace(/\/+$/, ''), // strip trailing slash
  externalUrl
  // #66: /bootstrap is produced + owned by pgh-sidecar (it tars the local
  // pgh.bootstrap app trees into <store>/bootstrap on install/OTA); the forge
  // only serves that dir statically. No forge-side reconcile flag needed.
}

// ── Mirror bundle (from a prior `pgh device join <invite>` with
// --mirror) — if present, pass verbatim to startPearGit, which calls
// expandDeploymentBundle to thread every field (orgs, identity,
// issueRegistry, repoEventLog, swarm, sourceServerKey) into the
// canonical mounts/mirror/swarm opts. Keeps expansion logic in ONE
// place instead of duplicating it here.
// Prefer origin-bundle.json (canonical). Fall back to mirror-bundle.json
// for pre-Phase-3 deployments.
const originBundlePath = join(storage, 'origin-bundle.json')
const legacyBundlePath = join(storage, 'mirror-bundle.json')
const bundlePath = existsSync(originBundlePath) ? originBundlePath
  : existsSync(legacyBundlePath) ? legacyBundlePath
  : null
if (bundlePath) {
  try {
    const bundle = JSON.parse(readFileSync(bundlePath, 'utf8'))
    opts.deploymentBundle = bundle
    console.log('[origin] booting from bundle at ' + bundlePath)
    console.log('[origin]   source: ' + (bundle.sourceServerKey || '?').slice(0, 16) + '…')
  } catch (err) {
    console.error('[origin] failed to read bundle: ' + err.message)
    process.exit(1)
  }
}

// ── Boot ────────────────────────────────────────────────────────

let node
let shuttingDown = false
// Module-scoped so the `release-ready` RPC handler (pushed by
// pgh-sidecar when it sees a newer release entry for us) can trigger
// the runtime's OTA check without waiting for the 30min poll.
let otaRuntime = null

async function main () {
  // ⛔ BEFORE startPearGit, because that is where the unpatched failures happen: a SIGABRT
  // crash-loop on boot, or a wedge in a microtask chain that never hands the event loop back.
  // Refusing here is a sentence an operator can act on; both alternatives are a daemon that looks
  // broken for reasons nothing names. See lib/autobase-guards.js.
  {
    // ⚠ `#node:fs`, NOT `node:fs`. This daemon runs under BOTH node and bare (`start` / `start:bare`),
    // and the bare build has no `node:` builtins — the import map at the top of package.json is the
    // whole reason every other import in this file is `#node:…`. A plain `node:fs` here would load
    // fine under node and fail at boot under bare, which is the runtime the fleet actually ships.
    const { assertAutobaseGuards } = await import('./lib/autobase-guards.js')
    const root = dirname(fileURLToPath(import.meta.url))
    assertAutobaseGuards({
      read: (rel) => { try { return readFileSync(join(root, rel), 'utf8') } catch { return null } },
      env: process.env
    })
  }

  node = await startPearGit(opts)

  console.log('pear-git-v3 daemon')
  console.log('  storage:  %s', storage)
  console.log('  identity: %s', node.identity.publicKeyHex)
  console.log('  uds:      %s', sock)
  console.log('  http:     http://%s:%d', httpHost, node.svc.http.port)
  if (prefix) console.log('  prefix:   %s', prefix)
  if (externalUrl) console.log('  url:      %s', externalUrl)
  if (node.svc.swarm) {
    // Topic is always derived from this daemon's identity; mirrors
    // join a source's rooms via device-accept, not a startup flag.
    const topicHex = Buffer.from(node.svc.swarm.topic).toString('hex')
    console.log('  swarm:    topic %s (identity-derived)',
      topicHex.slice(0, 16) + '…')
  }
  console.log('  mounts:   %s', node.svc.listMounts().join(', '))
  console.log('  handlers: %d', node.svc.listHandlers().length)

  // Sidecar registration + OTA both need the sidecar's UDS. The daemon
  // is already serving at this point, so don't block boot on it — kick
  // these off in the background once the socket is available.
  ensureSidecarServices().catch((err) => {
    console.log('  sidecar:  service init failed —', err.message)
  })
}

// ── pgh-sidecar registration ────────────────────────────────────
//
// Tell pgh-sidecar we're running so `pgh app resync|status|...` can
// find our UDS. Best-effort — if the sidecar socket is missing, log
// and continue (the daemon runs standalone just fine).

let sidecarAppName = null
let otaStarted = false

// Idempotent OTA start — safe to call from both the boot path and the
// heartbeat (which recovers OTA when the sidecar appears later than our
// initial wait window).
async function ensureOTAStarted () {
  if (otaStarted) return
  otaStarted = true
  try {
    await startOTA()
  } catch (err) {
    otaStarted = false
    console.log('  ota:      start failed —', err.message)
  }
}

async function ensureSidecarServices () {
  const ready = await waitForSidecarSock({ shouldStop: () => shuttingDown })
  if (ready) {
    await registerWithSidecar()
    await ensureOTAStarted()
  } else {
    // The initial wait timed out, but the sidecar may still appear later —
    // e.g. its slow cold-boot store index outlasts our wait window. Do NOT
    // give up permanently: arming the heartbeat below lets registration +
    // OTA self-heal once the socket shows up. (Before this, an early return
    // here left the daemon unregistered until a manual restart.)
    console.log('  sidecar:  not up yet — registration + OTA deferred to heartbeat')
  }
  // Always arm the heartbeat. It re-waits for the socket, (re)registers
  // across sidecar restarts (its app registry is in-memory), and ensures
  // OTA is started once we're registered. Detached loop — not awaited.
  maintainSidecarRegistration()
}

// Heartbeat: every interval, ensure the sidecar still knows us. Handles
// two failure modes the one-shot boot registration can't: (1) the
// sidecar socket vanished (sidecar down) — wait for it and re-register;
// (2) the sidecar restarted and lost its in-memory registry — app-lookup
// no longer finds us, so re-register. Idempotent (last-writer-wins).
const SIDECAR_HEARTBEAT_MS = 30_000

async function maintainSidecarRegistration () {
  while (!shuttingDown) {
    await new Promise((r) => setTimeout(r, SIDECAR_HEARTBEAT_MS))
    if (shuttingDown) return
    try {
      if (!existsSync(sidecarSockPath())) {
        const ready = await waitForSidecarSock({ shouldStop: () => shuttingDown })
        if (ready) { await registerWithSidecar(); if (sidecarAppName) await ensureOTAStarted() }
        continue
      }
      // No name yet (initial register never succeeded) — just try again;
      // registerWithSidecar re-derives the install name.
      if (!sidecarAppName) {
        await registerWithSidecar()
        if (sidecarAppName) await ensureOTAStarted()
        continue
      }
      const { Client } = await import('hyper-svc')
      const c = await Client.connectUds(sidecarSockPath())
      let entry = null
      try { entry = await c.call('app-lookup', { name: sidecarAppName }) }
      finally { try { await c.close() } catch {} }
      if (needsReregister(entry, process.pid)) await registerWithSidecar()
      await ensureOTAStarted()
    } catch {
      // Transient (sidecar mid-restart, connect refused, etc.) — retry
      // on the next tick.
    }
  }
}

async function registerWithSidecar () {
  const sidecarSock = sidecarSockPath()
  if (!existsSync(sidecarSock)) {
    console.log('  sidecar:  not running (no register)')
    return
  }

  // Derive the install name (the `--name` used at pgh install time).
  // Priority: PGH_APP_NAME env (set by `pgh app start`) > .pgh-install.json
  // > package.json name. The env wins for adopted dev installs where
  // the source tree has no install manifest alongside server.js.
  let installName = process.env.PGH_APP_NAME || null
  let version = null
  let treeHash = null
  let runtime = null
  try {
    const installPath = join(HERE, '.pgh-install.json')
    if (existsSync(installPath)) {
      const m = JSON.parse(readFileSync(installPath, 'utf8'))
      if (!installName) installName = m.name
      version = m.version
      treeHash = m.treeHash
      runtime = m.runtime
    } else {
      const pkg = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'))
      if (!installName) installName = pkg.name
      version = pkg.version
    }
  } catch {}
  if (!installName) {
    console.log('  sidecar:  skipped (no app name)')
    return
  }

  try {
    const { Client } = await import('hyper-svc')
    const c = await Client.connectUds(sidecarSock)
    await c.call('app-register', {
      name: installName,
      udsPath: sock,
      pid: process.pid,
      runtime: runtime || 'node',
      version: version || null,
      treeHash: treeHash || null
    })
    await c.close()
    sidecarAppName = installName
    console.log('  sidecar:  registered as', installName)
  } catch (err) {
    console.log('  sidecar:  register failed —', err.message)
  }
}

async function unregisterFromSidecar () {
  if (!sidecarAppName) return
  const sidecarSock = sidecarSockPath()
  if (!existsSync(sidecarSock)) return
  try {
    const { Client } = await import('hyper-svc')
    const c = await Client.connectUds(sidecarSock)
    await c.call('app-unregister', { name: sidecarAppName })
    await c.close()
  } catch {}
}

// ── OTA (pgh-runtime) ───────────────────────────────────────────
//
// Polls the upgrade topic for new releases and materializes them into
// the local tree store. App stays running at the old version; operator
// (or supervisor) restarts the launcher to pick up the new tree. Once
// restarted, the updated server.js loads from the newly-materialized
// install dir automatically.
//
// Uses the app's own .pgh-lock.json (installed next to server.js) as
// the source of truth for currentTreeHash and the upgrade URL — same
// pattern as pgh-hello's index.js.

async function startOTA () {
  const pkgPath = join(HERE, 'package.json')
  const installPath = join(HERE, '.pgh-install.json')   // pgh-materialized installs
  const lockPath = join(HERE, '.pgh-lock.json')         // source/dev checkouts
  if (!existsSync(pkgPath)) {
    console.log('  ota:      skipped (no package.json alongside server.js)')
    return
  }

  let pkg, currentTreeHash
  try {
    pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
    if (existsSync(installPath)) {
      currentTreeHash = JSON.parse(readFileSync(installPath, 'utf8')).treeHash
    } else if (existsSync(lockPath)) {
      currentTreeHash = JSON.parse(readFileSync(lockPath, 'utf8')).rootTreeHash
    }
  } catch (err) {
    console.log('  ota:      skipped (failed to read install metadata — ' + err.message + ')')
    return
  }

  const upgradeUrl = pkg.pgh && pkg.pgh.upgrade
  if (!upgradeUrl || !currentTreeHash) {
    console.log('  ota:      skipped (no pgh.upgrade or install metadata)')
    return
  }

  // pgh://<registryKey>/<name>. registryKey is used only to discover the
  // latest release via the local registry (populated by the publisher
  // through pgh-sidecar — see `pgh app install` line 336 for the same
  // pattern: registryKey gets you the ENTRY, entry.serverKey is the
  // deploy topic where trees live).
  if (!/^pgh:\/\/[0-9a-f]{64}\//.test(upgradeUrl)) {
    console.log('  ota:      skipped (malformed upgrade URL: ' + upgradeUrl + ')')
    return
  }

  try {
    const { PghRuntime } = await import('pgh-runtime')
    const { TreeStore } = await import('pgh/store')
    const { NetworkFetcher } = await import('pgh/network-fetcher')
    const { Client } = await import('hyper-svc')

    // Query pgh-sidecar via UDS for entry.serverKey (the publisher's
    // DeployServer topic). We cannot open ~/.config/pgh/release-registry
    // directly: pgh-sidecar holds an exclusive corestore lock on it.
    // Sidecar exposes `releases-latest` for exactly this.
    const sidecarSock = sidecarSockPath()
    if (!existsSync(sidecarSock)) {
      console.log('  ota:      skipped (no pgh-sidecar socket at ' + sidecarSock + ')')
      return
    }
    let latest = null
    const sidecar = await Client.connectUds(sidecarSock)
    try {
      latest = await sidecar.call('releases-latest', { name: pkg.name })
    } finally {
      try { await sidecar.close() } catch {}
    }

    if (!latest || !latest.serverKey) {
      console.log('  ota:      skipped (no release with serverKey for ' + pkg.name + ')')
      return
    }

    const store = new TreeStore({})
    store.init()

    // Join the DEPLOY topic (publisher's hyperident pubkey). On the
    // publisher machine itself, pgh-sidecar runs the DeployServer on
    // this topic so the swarm rendezvous finds a peer locally.
    const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout: 15000 })

    // Override getLatestRelease to route through pgh-sidecar's UDS.
    // NetworkFetcher's default opens the registry corestore directly,
    // which deadlocks against sidecar's exclusive lock on same-machine
    // setups. The sidecar owns the canonical registry; this is the
    // supported path (same pattern as `pgh releases` CLI).
    fetcher.getLatestRelease = async (name) => {
      const c = await Client.connectUds(sidecarSock)
      try { return await c.call('releases-latest', { name }) }
      finally { try { await c.close() } catch {} }
    }
    // ready() blocks until a peer is found. On the publisher (tabertron)
    // the sidecar DeployServer should show up quickly; on mirrors the
    // publisher over DHT. Run asynchronously so daemon startup isn't
    // gated on peer discovery — PghRuntime's getLatestRelease is a
    // local read that doesn't need the swarm.
    fetcher.ready().then(() => {
      console.log('[ota] fetcher ready (peer found on deploy topic ' + latest.serverKey.slice(0, 12) + ')')
    }).catch((err) => {
      console.log('[ota] no peer on deploy topic yet (' + (err.message || err) + ') — local polling still active')
    })

    const runtime = new PghRuntime({
      name: pkg.name,
      currentTreeHash,
      fetcher,
      store,
      interval: 30 * 60 * 1000
    })
    otaRuntime = runtime

    // Push-based OTA — v2 contract: sidecar does the FULL install
    // (tree fetch + dep closure + materialize + symlink swap) BEFORE
    // calling us here. Our only job is to exit cleanly so the
    // supervisor respawns against the new install tree. No
    // runtime.checkNow() — pgh-runtime only fetches root trees, not
    // dep closures, so using it here would produce a broken install.
    if (node && node.svc && !node.svc.hasHandler('release-ready')) {
      node.svc.handle('release-ready', async (ctx, params = {}) => {
        const ver = (params && params.version) || '?'
        console.log('[ota] release-ready ' + ver + ' — sidecar installed; exiting 75 for supervisor respawn')
        // Exit AFTER responding to the RPC so the caller sees ok.
        setImmediate(() => {
          shutdown().catch(() => {}).then(() => process.exit(75))
        })
        return { ok: true, willRespawn: true }
      })
    }

    runtime.on('updating', (release) => {
      console.log('[ota] updating → ' + release.version + ' (tree ' + release.treeHash.slice(0, 12) + ')')
    })
    runtime.on('updated', (info) => {
      console.log('[ota] tree ready at ' + info.newTreeHash.slice(0, 12) + ' — restart the launcher to apply')
      // Auto-respawn: if PGV3_AUTO_RESPAWN=1 is set (implies an external
      // supervisor is running — systemd Restart=always, tmux respawn-pane,
      // scripts/pgv3-supervisor.sh, etc.) exit cleanly so the supervisor
      // relaunches us against the newly-materialized install tree.
      if (process.env.PGV3_AUTO_RESPAWN === '1') {
        console.log('[ota] PGV3_AUTO_RESPAWN=1 → exiting for supervisor to respawn')
        shutdown().catch(() => process.exit(0))
      }
    })
    runtime.on('error', (err) => {
      console.error('[ota] error: ' + (err.message || err))
    })

    await runtime.start()
    console.log('  ota:      polling via sidecar every 30min (current tree %s…, deploy topic %s…)',
      currentTreeHash.slice(0, 12),
      latest.serverKey.slice(0, 12))
  } catch (err) {
    console.log('  ota:      disabled — ' + err.message)
  }
}

async function shutdown () {
  if (shuttingDown) process.exit(1)
  shuttingDown = true
  console.log('\nshutting down...')
  try { await unregisterFromSidecar() } catch {}
  try { if (node) await node.close() } catch (err) { console.error(err.message) }
  process.exit(0)
}

process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)

// ── last-resort guard ────────────────────────────────────────────
// A forge hosting dozens of repos must not die because one reader hung up.
// Transport-teardown errors reach here from code paths that legitimately
// cannot attach a handler in time (or that we have not found yet) — log and
// keep serving. PREMATURE_CLOSE is the streamx shape of an aborted pipe; it
// crash-looped celltron on 2026-08-07 (see lib/web/pipe-file.js for the
// mechanism and the per-site fix, which is the real remedy — this is only
// defense in depth).
//
// Everything else keeps the previous behaviour of dying loudly, so genuine
// bugs still surface instead of being silently swallowed. Same allow-list
// idiom as pear-git v1 (pear-git-server.js).
const NON_FATAL = new Set(['PREMATURE_CLOSE', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED'])

process.on('uncaughtException', (err) => {
  if (err && NON_FATAL.has(err.code)) {
    console.error('[warn] non-fatal ' + err.code + ': ' + (err.message || err))
    return
  }
  console.error('fatal: uncaught exception:', (err && err.stack) || err)
  process.exit(1)
})

process.on('unhandledRejection', (err) => {
  if (err && NON_FATAL.has(err.code)) {
    console.error('[warn] non-fatal ' + err.code + ' (rejection): ' + (err.message || err))
    return
  }
  console.error('fatal: unhandled rejection:', (err && err.stack) || err)
  process.exit(1)
})

main().catch((err) => {
  // Print the STACK, not just the message. A boot failure used to surface
  // as a bare `fatal: invalid argument` with no indication of which of the
  // ~dozen subsystems threw it, which makes a failed start undiagnosable.
  console.error('fatal:', (err && err.stack) || err)
  process.exit(1)
})