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