OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / distribute / hypertps-win64 / index.js

976 lines · javascript ref:
// HyperTPS Launcher
//
// Starts keet-daemon in-process and launches the Godot game binary.
// Mesh networking is native via HyperswarmMultiplayerPeer GDExtension.
// pgh provides P2P OTA updates (content-addressed) when installed via pgh.
//
// Usage: bare index.js [--debug] [--no-updates] [--storage <path>]
//        bare index.js identity create [--keet-storage <path>]
//        bare index.js --daemon-only [--pin-room <roomId> ...]
//          (headless always-on peer: keet-daemon only, no game; --pin-room
//           opens + indexes a room's game-state StateBase so the peer is a
//           persistence anchor for that room. Repeatable.)

import subprocess from 'bare-subprocess'
import fs from 'fs'
import path from 'path'
import os from 'os'
import process from 'process'
import Pipe from 'bare-pipe'

// keet-daemon is loaded lazily — see loadKeetDaemon() below. The previous
// top-level await import('./daemon/daemon.js') runs BEFORE flattenIfNeeded(),
// so on a fresh pgh install (symlinked node_modules) the @holepunchto/keet-core
// import inside keet-daemon fails with MODULE_NOT_FOUND and `startDaemon`
// stays null for the rest of the process. Bundlebee's resolver only works
// against a flat layout. Loading after flatten fixes both the normal
// `--dedicated` path and `identity create`.
let startDaemon = null
let _keetDaemonLoaded = false
async function loadKeetDaemon () {
  if (_keetDaemonLoaded) return startDaemon
  _keetDaemonLoaded = true
  try {
    const mod = await import('./daemon/daemon.js')
    startDaemon = mod.startDaemon
  } catch (err) {
    console.error('[launcher] keet-daemon module not loadable:', err.message)
    if (err.code) console.error('[launcher]   code:', err.code)
    if (err.stack) console.error('[launcher]   stack:', err.stack.split('\n').slice(0, 5).join('\n'))
  }
  return startDaemon
}

const isPear = typeof Pear !== 'undefined'
const DAEMON_SOCKET = '/tmp/keet-daemon.sock'
const rawArgs = isPear ? (Pear.config.args || []) : Bare.argv.slice(2)
const debugMode = rawArgs.includes('--debug')
const noUpdates = rawArgs.includes('--no-updates')
const dedicatedMode = rawArgs.includes('--dedicated')
// --daemon-only: a REAL headless game — starts keet-daemon AND launches the
// Godot game with --headless, so the full game logic runs (on_game_started,
// world-init seed, authority election, gamestate). It differs from graphical
// only in presentation, and from --dedicated only in having no TUI. This is the
// headless peer/bot/anchor mode that can actually participate in gameplay.
//   --no-game: a PURE daemon with no Godot game — the identity-create bootstrap
//   (a child that only needs UDS), or an explicit passive StateBase anchor.
const daemonOnlyMode = rawArgs.includes('--daemon-only')
const noGame = rawArgs.includes('--no-game')

// --pin-room <roomId> (repeatable): open + index the given room's per-room
// StateBase at boot so a headless --daemon-only peer acts as an always-on
// game-state persistence anchor. See pinStateBases(). StateBase is otherwise
// lazy — a bare daemon never indexes a room on its own.
const pinRooms = []
// --anchor-room <roomId> (repeatable): like --pin-room, but this daemon also
// CLAIMS the room's SOLE indexer slot (move-indexer) so an always-on admin
// anchor finalizes the room's game-state even when the original creator is
// offline. Requires this daemon's identity to be a room admin, and the current
// indexer online once to sign the handoff. Anchor rooms are also pinned.
const anchorRooms = new Set()
for (let i = 0; i < rawArgs.length; i++) {
  if (rawArgs[i] === '--pin-room' && i + 1 < rawArgs.length) pinRooms.push(rawArgs[i + 1])
  if (rawArgs[i] === '--anchor-room' && i + 1 < rawArgs.length) { pinRooms.push(rawArgs[i + 1]); anchorRooms.add(rawArgs[i + 1]) }
}

// --storage <path> overrides the app storage dir (game/identity state)
const storageIdx = rawArgs.indexOf('--storage')
const customStorage = storageIdx !== -1 ? rawArgs[storageIdx + 1] : null

// `identity create` subcommand — bootstrap keet-core identity on fresh storage.
// Interactive password prompt (no flag — keeps password out of shell history /
// ps output). --keet-storage <path> isolates the daemon to a non-default path
// for testing.
const isIdentityCreate = rawArgs[0] === 'identity' && rawArgs[1] === 'create'
const keetStorageIdx = rawArgs.indexOf('--keet-storage')
const keetStorageOverride = keetStorageIdx !== -1 ? rawArgs[keetStorageIdx + 1] : null
// --fresh: allocate a brand-new identity-N directory (via
// identity-manager.create) and switch the daemon's core to it BEFORE
// running vault.setup. Use this when the current active identity-0's
// storage is in a broken state (stale rooms / autobase errors during
// _runBootOnline) — switching to fresh storage avoids the cascade.
const freshIdentityMode = rawArgs.includes('--fresh')

// Optional bootstrap flags. --display-name and --username drive the
// post-vault registry step non-interactively (used by tests). --skip-registry
// bypasses the network-touching identity.register call entirely (used by
// tests, since registering a name in Keet's user registry is a permanent
// global claim).
function getFlagValue (flag) {
  const i = rawArgs.indexOf(flag)
  return i !== -1 && i + 1 < rawArgs.length ? rawArgs[i + 1] : null
}
const cliDisplayName = getFlagValue('--display-name')
const cliUsername = getFlagValue('--username')
const skipRegistry = rawArgs.includes('--skip-registry')

function getAppDir () {
  return path.dirname(new URL(import.meta.url).pathname)
}

function getStorageDir () {
  if (customStorage) return customStorage
  const p = os.platform()
  const name = 'hyper-tps'
  if (p === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', name)
  if (p === 'win32') return path.join(os.homedir(), 'AppData', 'Roaming', name)
  return path.join(os.homedir(), '.config', name)
}

function getPlatformDir () {
  const p = os.platform()
  if (p === 'linux') return 'linux'
  if (p === 'darwin') return 'macos'
  if (p === 'win32') return 'windows'
  return p
}

// Match pgh's detectHost convention (lib/commands/app.js#detectHost).
// Used to find arch-specific binaries laid out by ci.yaml under
// by-arch/<host>/ for multi-arch publishes. Falls back to flat layout
// when by-arch/<host>/ is absent so single-arch installs still work.
function getHost () {
  let host = os.platform() + '-' + os.arch()
  try {
    const osr = fs.readFileSync('/etc/os-release', 'utf8')
    if (/^ID=alpine/m.test(osr)) host += '-musl'
  } catch {}
  return host
}

function resolveBuildDir (appDir, platformDir) {
  const arched = path.join(appDir, 'by-arch', getHost(), 'build', platformDir)
  if (fs.existsSync(arched)) return arched
  return path.join(appDir, 'build', platformDir)
}

function exit (code) {
  if (isPear) Pear.exit(code)
  else Bare.exit(code)
}

// Probe the socket to check if a daemon is actually listening
function probeSocket (socketPath) {
  return new Promise((resolve) => {
    try {
      fs.statSync(socketPath)
    } catch {
      resolve(false) // Socket file doesn't exist
      return
    }

    // File exists — try connecting to verify daemon is alive
    const pipe = new Pipe(socketPath)
    const timer = setTimeout(() => {
      pipe.destroy()
      resolve(false)
    }, 2000)

    pipe.on('connect', () => {
      clearTimeout(timer)
      pipe.destroy()
      resolve(true)
    })

    pipe.on('error', () => {
      clearTimeout(timer)
      resolve(false)
    })
  })
}

// Start keet-daemon in-process if not already running
async function ensureDaemon () {
  // Check if an external daemon is actually alive (not just a stale socket)
  const alive = await probeSocket(DAEMON_SOCKET)
  if (alive) {
    console.log('[launcher] keet-daemon already running (external)')
    return null
  }

  // Remove stale socket if it exists
  try { fs.unlinkSync(DAEMON_SOCKET) } catch {}

  // Start daemon in-process. loadKeetDaemon() is idempotent; we call it
  // here (not at module init) because the keet-core bundle resolver
  // requires a flat node_modules and main() runs flattenIfNeeded()
  // before this point.
  console.log('[launcher] Starting keet-daemon (in-process)...')
  await loadKeetDaemon()
  if (!startDaemon) {
    console.log('[launcher] keet-daemon module not available — expecting external daemon')
    return null
  }

  try {
    const daemon = await startDaemon({
      socketPath: DAEMON_SOCKET,
      debug: debugMode
    })
    console.log('[launcher] keet-daemon ready')
    return daemon
  } catch (err) {
    console.error('[launcher] keet-daemon failed to start:', err.message)
    console.error('[launcher] Game will start without daemon (no chat/rooms)')
    return null
  }
}

// Pin one or more rooms' per-room StateBases so a headless --daemon-only peer
// joins and replicates that room's game-state autobase.
//
// StateBaseV3 (keet-daemon/lib/state-base-v3.js, HyperDB view) is per-room and
// LAZY: the router only opens it (router._getStateBase) on the first state.* RPC
// for a roomId. A bare --daemon-only daemon therefore never joins any room's
// game-state autobase. We drive one `state.info` RPC per room over the daemon
// UDS — the same lazy-open path the game uses. For v3 that call:
//   • derives roomTag = hash(roomId) and opens the deterministic base.key
//     (derived from the room's members-only encryptionKey — NO announce swarm,
//     no bootstrap-key conveyance, no v1 founder election);
//   • joins the replication swarm on base.discoveryKey and replicates the room's
//     linearized view. If this daemon is the elected creator it opens writable;
//     otherwise it opens as a (read-only) member replica.
// The StateBaseV3 instance then lives in the daemon's router map for the process
// lifetime, so the view persists after every player leaves. On restart base.key
// is RE-DERIVED deterministically (state-bases-v3/<tag>), so re-pinning rejoins
// the SAME base. Repeatable: --pin-room A --pin-room B.
//
// NOTE (v3.0b-2 TODO): a non-creator pin currently only REPLICATES read-only —
// it is not yet promoted to an indexer (steady-state addWriter / indexer
// rotation), so it does not provide finality. Membership is required: a daemon
// that isn't a room member can't resolve the encryptionKey, so its state.info
// returns {ok:false,"requires roomSeed"} (cleanly; no crash).
async function pinStateBases (roomIds) {
  if (!roomIds || !roomIds.length) return
  const up = await waitForSocket(DAEMON_SOCKET, 30000)
  if (!up) {
    console.error('[pin-room] daemon UDS did not come up within 30s — cannot pin rooms')
    return
  }
  const client = new KeetUdsClient(DAEMON_SOCKET)
  try {
    await client.connect()
  } catch (err) {
    console.error('[pin-room] connect failed:', err.message)
    return
  }
  for (const roomId of roomIds) {
    try {
      const info = await client.call('state.info', { roomId }, 30000)
      console.log('[pin-room] indexing %s — stateBaseKey=%s writable=%s hasBootstrap=%s',
        roomId.slice(0, 16),
        info && info.stateBaseKey ? info.stateBaseKey.slice(0, 12) : 'pending',
        info && info.writable,
        info && info.hasBootstrap)
      // --anchor-room: CLAIM the sole indexer slot (move-indexer) so this
      // always-on admin anchor finalizes the room even when the creator is
      // offline. Only for anchor rooms (plain --pin-room peers just replicate;
      // we deliberately do NOT grow the indexer set). Needs the current indexer
      // online once to sign the handoff. Best-effort, never fatal.
      if (anchorRooms.has(roomId) && info && info.hasBootstrap) {
        try {
          const j = await client.call('state.join', { roomId, claimIndexer: true }, 35000)
          console.log('[anchor-room] claim %s → writable=%s indexer=%s',
            roomId.slice(0, 16), j && j.writable, j && j.indexer)
        } catch (e) { console.log('[anchor-room] claim %s failed (current indexer offline?): %s', roomId.slice(0, 16), e.message) }
      }
    } catch (err) {
      console.error('[pin-room] failed to pin %s: %s', roomId.slice(0, 16), err.message)
    }
  }
  // StateBase instances now live in the daemon's router map for the process
  // lifetime, independent of this client — close it.
  try { client.close() } catch {}
}

// Background OTA via pgh — content-addressed store + hyper-overlay, NO
// Hyperdrive/blobs. `pgh app update <name>` resolves the latest release from
// package.json's pgh.upgrade, fetches any new tree closure into the content
// store, and ATOMICALLY materializes it into the install dir (scratch build +
// swap). The swap preserves the running process's inodes, so it is safe to run
// while the game is live; the next launch picks up the new tree.
//
// This replaces the former pear-runtime Hyperdrive updater, which kept an
// unbounded append-only blob corestore under ~/.config/hyper-tps/pear-runtime
// (observed at ~9.5 GB on a long-lived install — every release's blocks
// accumulated and were never reclaimed).
function resolvePghBin () {
  // Prefer the user launcher (~/.local/bin/pgh). Fall back to the
  // pgh-installed bare runtime + entry inside the content store. Returns an
  // argv prefix array, or null if pgh is not present on this machine.
  const launcher = path.join(os.homedir(), '.local', 'bin', 'pgh')
  if (fs.existsSync(launcher)) return [launcher]
  const storeRoot = process.env.PGH_STORE_ROOT || path.join(os.homedir(), '.local', 'share', 'store')
  const bare = path.join(storeRoot, 'apps', 'pgh', 'runtime', 'bare')
  const entry = path.join(storeRoot, 'apps', 'pgh', 'bin', 'pgh.bare.js')
  if (fs.existsSync(bare) && fs.existsSync(entry)) return [bare, entry]
  return null
}

function startBackgroundUpdate () {
  if (noUpdates) {
    console.log('[launcher] OTA updates disabled (--no-updates)')
    return
  }

  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(getAppDir(), 'package.json'), 'utf-8'))
    const upgrade = pkg.pgh && pkg.pgh.upgrade
    if (!upgrade) {
      console.log('[launcher] No pgh.upgrade link in package.json, skipping OTA')
      return
    }
    const appName = (pkg.pgh && pkg.pgh.bin) || pkg.name || 'hyper-tps'
    const argv = resolvePghBin()
    if (!argv) {
      console.log('[launcher] pgh CLI not found, skipping OTA')
      return
    }

    console.log(`[launcher] OTA: checking for updates via ${upgrade}`)
    // Detached + best-effort: never blocks or delays the game. pgh's atomic
    // materialize leaves the live install intact if the process is interrupted.
    const proc = subprocess.spawn(argv[0], [...argv.slice(1), 'app', 'update', appName], {
      stdio: 'ignore',
      detached: true
    })
    proc.on('error', (err) => {
      console.error('[launcher] OTA check failed to spawn:', err.message)
    })
    if (proc.unref) proc.unref()
  } catch (err) {
    console.error('[launcher] OTA init failed:', err.message)
    console.error('[launcher] Continuing without OTA updates')
  }
}

// Replace top-level symlinks in node_modules with real copies. Idempotent
// — no-op if already flat. Required workaround for keet-core 4.x's
// Bundlebee resolver expecting flat npm layout while pgh's materializer
// uses content-addressed symlinks.
async function flattenIfNeeded () {
  const subprocess = (await import('bare-subprocess')).default
  const nodeModulesDir = path.join(getAppDir(), 'node_modules')
  if (!fs.existsSync(nodeModulesDir)) return
  // Quick check: any top-level symlink?
  let needsFlatten = false
  for (const name of fs.readdirSync(nodeModulesDir)) {
    if (name === '.pgh') continue
    const p = path.join(nodeModulesDir, name)
    try {
      const st = fs.lstatSync(p)
      if (st.isSymbolicLink()) { needsFlatten = true; break }
      if (name.startsWith('@') && st.isDirectory()) {
        for (const sub of fs.readdirSync(p)) {
          const sst = fs.lstatSync(path.join(p, sub))
          if (sst.isSymbolicLink()) { needsFlatten = true; break }
        }
      }
    } catch {}
    if (needsFlatten) break
  }
  if (!needsFlatten) {
    console.log('[launcher] node_modules already flat — skipping flatten')
    return
  }
  console.log('[launcher] Flattening node_modules (pgh symlinks → real copies)…')
  const scriptPath = path.join(getAppDir(), 'scripts', 'flatten-node-modules.cjs')
  if (!fs.existsSync(scriptPath)) {
    console.error('[launcher] flatten script missing at', scriptPath)
    return
  }
  await new Promise((resolve, reject) => {
    const child = subprocess.spawn('node', [scriptPath, nodeModulesDir], {
      stdio: 'inherit'
    })
    child.on('exit', code => code === 0 ? resolve() : reject(new Error('flatten exit ' + code)))
    child.on('error', reject)
  }).catch(err => {
    console.error('[launcher] flatten failed (continuing anyway):', err.message)
  })
}

let daemonRef = null

// Read a password from the TTY with echo masked to '*'. Matches the godot-keet
// identity_setup_dialog UX: ask once, confirm once, no flag-based password to
// keep it out of shell history / ps output.
async function readPassword (prompt) {
  const tty = (await import('bare-tty')).default
  if (!tty.isTTY(0) || !tty.isTTY(1)) {
    throw new Error('identity create requires an interactive TTY')
  }
  const stdin = new tty.ReadStream(0)
  const stdout = new tty.WriteStream(1)
  const write = (s) => stdout.write(s)
  write(prompt)
  stdin.setRawMode(true)
  return new Promise((resolve, reject) => {
    let buf = ''
    const finish = (fn, val) => {
      try { stdin.setRawMode(false) } catch {}
      stdin.removeListener('data', onData)
      try { stdin.destroy() } catch {}
      write('\n')
      fn(val)
    }
    const onData = (chunk) => {
      const s = chunk.toString('utf-8')
      for (let i = 0; i < s.length; i++) {
        const code = s.charCodeAt(i)
        if (code === 0x03) return finish(reject, new Error('cancelled'))     // Ctrl+C
        if (code === 0x0d || code === 0x0a) return finish(resolve, buf)      // Enter
        if (code === 0x7f || code === 0x08) {                                // Backspace
          if (buf.length > 0) { buf = buf.slice(0, -1); write('\b \b') }
          continue
        }
        if (code >= 0x20 && code !== 0x7f) { buf += s[i]; write('*') }
      }
    }
    stdin.on('data', onData)
  })
}

// Visible-echo line prompt (used for display name / username during
// identity bootstrap). Shares the raw-TTY pattern with readPassword but
// echoes characters as the user types them.
async function readLine (prompt) {
  const tty = (await import('bare-tty')).default
  if (!tty.isTTY(0) || !tty.isTTY(1)) {
    throw new Error('identity create requires an interactive TTY')
  }
  const stdin = new tty.ReadStream(0)
  const stdout = new tty.WriteStream(1)
  const write = (s) => stdout.write(s)
  write(prompt)
  stdin.setRawMode(true)
  return new Promise((resolve, reject) => {
    let buf = ''
    const finish = (fn, val) => {
      try { stdin.setRawMode(false) } catch {}
      stdin.removeListener('data', onData)
      try { stdin.destroy() } catch {}
      write('\n')
      fn(val)
    }
    const onData = (chunk) => {
      const s = chunk.toString('utf-8')
      for (let i = 0; i < s.length; i++) {
        const code = s.charCodeAt(i)
        if (code === 0x03) return finish(reject, new Error('cancelled'))     // Ctrl+C
        if (code === 0x0d || code === 0x0a) return finish(resolve, buf)      // Enter
        if (code === 0x7f || code === 0x08) {                                // Backspace
          if (buf.length > 0) { buf = buf.slice(0, -1); write('\b \b') }
          continue
        }
        if (code >= 0x20 && code !== 0x7f) { buf += s[i]; write(s[i]) }
      }
    }
    stdin.on('data', onData)
  })
}

// Minimal framed-stream UDS client matching keet-daemon's
// transport (4-byte LE length prefix + JSON). Used by `identity create`
// to talk to a child keet-daemon. This matches godot-keet's
// KeetUdsClient on the wire.
class KeetUdsClient {
  constructor (socketPath) {
    this._socketPath = socketPath
    this._pipe = null
    this._framed = null
    this._nextId = 1
    this._pending = new Map()
  }

  async connect () {
    const Pipe = (await import('bare-pipe')).default
    const FramedStream = (await import('framed-stream')).default || (await import('framed-stream'))
    return new Promise((resolve, reject) => {
      const pipe = new Pipe(this._socketPath)
      const onErr = (err) => { pipe.removeAllListeners(); reject(err) }
      pipe.once('error', onErr)
      pipe.once('connect', () => {
        pipe.removeListener('error', onErr)
        this._pipe = pipe
        this._framed = new FramedStream(pipe)
        this._framed.on('data', (buf) => this._onFrame(buf))
        this._framed.on('error', (err) => {
          for (const { reject: rej } of this._pending.values()) rej(err)
          this._pending.clear()
        })
        this._framed.on('close', () => {
          for (const { reject: rej } of this._pending.values()) rej(new Error('connection closed'))
          this._pending.clear()
        })
        resolve()
      })
    })
  }

  _onFrame (buf) {
    let msg
    try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
    const pending = this._pending.get(msg.id)
    if (!pending) return
    this._pending.delete(msg.id)
    if (msg.ok === false || msg.error) pending.reject(new Error(msg.error || 'RPC failed'))
    // keet-daemon's router.handle wraps results as { ok: true, data: <result> }
    // and uds-transport sends `{ id, ...wrapped }` = `{ id, ok, data }`.
    // Unwrap so callers get the handler's raw return shape.
    else pending.resolve(msg.data !== undefined ? msg.data : msg)
  }

  call (method, params = {}, timeoutMs = 30000) {
    const id = this._nextId++
    const payload = JSON.stringify({ id, method, params })
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this._pending.delete(id)
        reject(new Error(`RPC timeout: ${method}`))
      }, timeoutMs)
      this._pending.set(id, {
        resolve: (v) => { clearTimeout(timer); resolve(v) },
        reject: (e) => { clearTimeout(timer); reject(e) }
      })
      this._framed.write(Buffer.from(payload, 'utf-8'))
    })
  }

  close () {
    try { this._framed && this._framed.destroy() } catch {}
    try { this._pipe && this._pipe.destroy() } catch {}
  }
}

async function waitForSocket (socketPath, timeoutMs = 30000) {
  const start = Date.now()
  while (Date.now() - start < timeoutMs) {
    if (await probeSocket(socketPath)) return true
    await new Promise(r => setTimeout(r, 200))
  }
  return false
}

async function bootstrapIdentity () {
  console.log('[identity] Bootstrap mode — generates recovery phrase and sets up vault.')
  console.log('[identity] No game launch, no OTA.')

  // Daemon needs flat node_modules to load keet-core bundles, AND
  // keet-daemon itself must be imported only after the flatten — its
  // top-level `require('@holepunchto/keet-core')` resolution fails on a
  // freshly-materialized pgh install where node_modules is still
  // content-addressed symlinks.
  await flattenIfNeeded()
  await loadKeetDaemon()
  if (!startDaemon) {
    console.error('[identity] keet-daemon module not loadable in launcher build.')
    exit(1); return
  }

  let password, confirm
  try {
    password = await readPassword('Create a password to protect your identity: ')
    if (password.length < 1) { console.error('[identity] Password cannot be empty.'); exit(1); return }
    confirm = await readPassword('Confirm password: ')
  } catch (err) {
    console.error('[identity]', err.message); exit(1); return
  }
  if (password !== confirm) { console.error('[identity] Passwords do not match.'); exit(1); return }

  // Spawn the launcher as a child in --daemon-only mode. This gives us an
  // independent process whose lifecycle we control — if vault.setup hangs,
  // we can kill the child cleanly without affecting our own process.
  console.log('[identity] Spawning child keet-daemon…')
  const launcherPath = new URL(import.meta.url).pathname
  const childArgs = [launcherPath, '--daemon-only', '--no-game', '--no-updates']

  // Tee child stdout/stderr to BOTH the terminal AND a persistent log file
  // so a later identity-manager.switch failure can be diagnosed without
  // depending on terminal scrollback. Path is printed on every failure.
  const daemonLogPath = '/tmp/hyper-tps-daemon-' + Date.now() + '.log'
  const daemonLogFd = fs.openSync(daemonLogPath, 'a')
  fs.writeSync(daemonLogFd, '=== hyper-tps identity create — ' + new Date().toISOString() + ' ===\n')
  console.log('[identity] daemon log: ' + daemonLogPath)

  const child = subprocess.spawn('bare', childArgs, {
    stdio: ['ignore', 'pipe', 'pipe'],
    detached: false
  })
  child.stdout.on('data', (chunk) => {
    try { fs.writeSync(daemonLogFd, chunk) } catch {}
    try { process.stdout.write(chunk) } catch {}
  })
  child.stderr.on('data', (chunk) => {
    try { fs.writeSync(daemonLogFd, chunk) } catch {}
    try { process.stderr.write(chunk) } catch {}
  })

  const childExited = new Promise((resolve) => {
    child.on('exit', (code, signal) => resolve({ code, signal }))
  })

  try {
    console.log('[identity] Waiting for daemon UDS at ' + DAEMON_SOCKET + ' …')
    const up = await Promise.race([
      waitForSocket(DAEMON_SOCKET, 30000),
      childExited.then(({ code, signal }) => {
        throw new Error('child exited before UDS came up (code=' + code + ' signal=' + signal + ')')
      })
    ])
    if (!up) {
      console.error('[identity] Daemon UDS did not come up within 30s.')
      try { child.kill('SIGTERM') } catch {}
      exit(1); return
    }
    console.log('[identity] UDS up. Connecting as client…')

    const client = new KeetUdsClient(DAEMON_SOCKET)
    await client.connect()

    // --fresh: allocate a new identity-N directory and switch the daemon's
    // core to it before vault.setup. This bypasses any broken-room state
    // that may have accumulated in the previous active identity (a known
    // failure mode where keet-core's _ensureMigrated throws before
    // _loadIdentity even runs, and onlineBoot never exit()s — vault.setup
    // then hangs on its onlineBoot.ready() await).
    if (freshIdentityMode) {
      const stepFail = (step, err) => {
        console.error('[identity][fresh] step "' + step + '" failed')
        console.error('  message: ' + (err && err.message))
        if (err && err.name) console.error('  name: ' + err.name)
        if (err && err.code) console.error('  code: ' + err.code)
        if (err && err.cause) console.error('  cause: ' + (err.cause.message || err.cause))
        if (err && err.stack) console.error('  stack:\n' + err.stack)
        console.error('[identity][fresh] daemon log captured at: ' + daemonLogPath)
      }
      try {
        const tList = Date.now()
        const list = await client.call('identity-manager.list', {}, 10000)
        const existing = (list && list.identities) || []
        console.log('[identity][fresh] list ok in ' + (Date.now() - tList) + 'ms (' + existing.length + ' existing)')
        const name = 'hyper-tps-' + Date.now().toString(36)
        console.log('[identity][fresh] calling identity-manager.create name=' + name)
        const tCreate = Date.now()
        let created
        try {
          created = await client.call('identity-manager.create', { name }, 15000)
        } catch (err) { stepFail('identity-manager.create', err); throw err }
        const newId = (created && created.id) || (created && created.entry && created.entry.id)
        if (!newId) { const e = new Error('identity-manager.create returned no id'); stepFail('identity-manager.create', e); throw e }
        console.log('[identity][fresh] create ok in ' + (Date.now() - tCreate) + 'ms, id=' + newId)
        console.log('[identity][fresh] calling identity-manager.switch id=' + newId)
        const tSwitch = Date.now()
        try {
          await client.call('identity-manager.switch', { id: newId }, 30000)
        } catch (err) { stepFail('identity-manager.switch', err); throw err }
        console.log('[identity][fresh] switch ok in ' + (Date.now() - tSwitch) + 'ms. Active identity is now ' + newId)
      } catch (err) {
        console.error('[identity] fresh-identity allocation failed.')
        console.error('[identity] daemon log: ' + daemonLogPath)
        client.close()
        try { child.kill('SIGTERM') } catch {}
        exit(1); return
      }
    }

    // Check vault state first
    try {
      const status = await client.call('vault.status', {}, 10000)
      console.log('[identity] vault status: exists=' + (status && status.exists))
      if (status && status.exists) {
        console.log('[identity] Note: vault already exists. Recovery phrase unchanged;')
        console.log('[identity] only the encryption password will be updated.')
      }
    } catch (err) {
      console.error('[identity] vault.status check failed (continuing):', err.message)
    }

    console.log('[identity] Calling vault.setup (generates mnemonic if needed)…')
    console.log('[identity] timeout 180s — onlineBoot can take a while on first boot')
    const tVault = Date.now()
    // Heartbeat: print a dot every 5s so the user can see the daemon is alive.
    const heartbeat = setInterval(() => {
      const elapsed = Math.floor((Date.now() - tVault) / 1000)
      console.log('[identity] still waiting on vault.setup… ' + elapsed + 's elapsed (daemon log: ' + daemonLogPath + ')')
    }, 5000)
    try {
      await client.call('vault.setup', { password }, 180000)
      clearInterval(heartbeat)
      console.log('[identity] vault.setup ok in ' + (Date.now() - tVault) + 'ms')
    } catch (err) {
      clearInterval(heartbeat)
      console.error('[identity] vault.setup failed after ' + (Date.now() - tVault) + 'ms')
      console.error('  message: ' + (err && err.message))
      if (err && err.name) console.error('  name: ' + err.name)
      if (err && err.code) console.error('  code: ' + err.code)
      if (err && err.cause) console.error('  cause: ' + (err.cause.message || err.cause))
      if (err && err.stack) console.error('  stack:\n' + err.stack)
      console.error('[identity] daemon log: ' + daemonLogPath)
      client.close()
      try { child.kill('SIGTERM') } catch {}
      exit(1); return
    }

    // Post-vault: collect a display name + canonical username and register
    // with Keet's user registry. The display name is the in-profile field
    // surfaced in the TUI; the username (must end in a digit, e.g. "alice0")
    // is the unique handle stored in the keet-registry autobase for global
    // lookup. See godot-keet/scripts/identity_setup_dialog.gd for the
    // reference UX flow.
    try {
      let displayName = cliDisplayName
      let username = cliUsername
      if (!displayName) {
        displayName = (await readLine('Display name (shown to others, e.g. "alice"): ')).trim()
      }
      if (displayName) {
        await client.call('identity.setName', { name: displayName }, 15000)
        console.log('[identity] display name set: ' + displayName)
      }
      if (!skipRegistry) {
        if (!username) {
          while (true) {
            const candidate = (await readLine('Username (must end in a number, e.g. "alice0"): ')).trim()
            if (!candidate) { console.log('[identity] username required, try again'); continue }
            if (!/[0-9]$/.test(candidate)) { console.log('[identity] must end in a digit'); continue }
            try {
              const chk = await client.call('identity.checkName', { username: candidate }, 15000)
              if (chk && chk.available === false) { console.log('[identity] "' + candidate + '" is taken'); continue }
            } catch (err) {
              console.log('[identity] checkName failed (continuing anyway): ' + (err && err.message))
            }
            username = candidate
            break
          }
        }
        try {
          await client.call('identity.register', { username }, 30000)
          console.log('[identity] username registered: ' + username)
        } catch (err) {
          console.error('[identity] identity.register failed: ' + (err && err.message))
        }
      } else {
        console.log('[identity] --skip-registry — not calling identity.register')
      }
    } catch (err) {
      console.error('[identity] name/registry step failed: ' + (err && err.message))
    }

    try {
      const id = await client.call('identity', {})
      if (id && id.publicKey) console.log('[identity] Identity public key:', id.publicKey)
    } catch {}

    client.close()
    console.log('[identity] Done. Stopping child daemon…')
  } finally {
    // SIGTERM, wait up to 3s for the child to exit on its own, then
    // SIGKILL if it's still alive. AWAITED here so we don't fall through
    // to exit(0) and leave a zombie holding the RocksDB lock on the
    // identity-0 store — every retry would then fail with "File
    // descriptor could not be locked" until the user manually kills it.
    try { child.kill('SIGTERM') } catch {}
    const killed = await Promise.race([
      childExited.then(() => true),
      new Promise(r => setTimeout(() => r(false), 3000))
    ])
    if (!killed) {
      try { child.kill('SIGKILL') } catch {}
      await Promise.race([
        childExited,
        new Promise(r => setTimeout(r, 2000))
      ])
    }
    // Last-resort: clean up the socket file if the child crashed mid-listen.
    try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
    // Close the open handles that pipe child stdout/stderr to the log file.
    // Without this, Bare.exit can hang because libuv still considers these
    // pipes / fd as pending references on the event loop. Symptom: parent
    // never returns to the shell after printing the final status lines.
    try { child.stdout && child.stdout.destroy() } catch {}
    try { child.stderr && child.stderr.destroy() } catch {}
    try { child.unref && child.unref() } catch {}
    try { fs.closeSync(daemonLogFd) } catch {}
  }

  console.log('[identity] To export the recovery phrase later, use godot-keet or')
  console.log('[identity] the identity-manager.export RPC.')
  exit(0)
}

async function main () {
  if (isIdentityCreate) {
    await bootstrapIdentity()
    return
  }

  const platformDir = getPlatformDir()

  // Read version from version.txt (written by CI)
  let appVersion = 'dev'
  try {
    appVersion = fs.readFileSync(path.join(getAppDir(), 'version.txt'), 'utf-8').trim()
  } catch {}

  console.log(`[launcher] HyperTPS ${appVersion}`)
  console.log(`[launcher] Platform: ${platformDir}`)

  // Flatten symlinks in node_modules if pgh's content-addressed install
  // left them in place. keet-core 4.x's Bundlebee resolver (used during
  // op encoding for addChatMessage etc.) keys require.cache by realpath,
  // but the bundle resolution map references the npm-flat symlink path —
  // mismatch causes "failed to find module: /node_modules/<x>/index.js"
  // every time the daemon tries to encode an op. Replacing top-level
  // symlinks with real copies (one level deep, scoped/non-scoped) makes
  // Bare cache the modules under the flat paths the bundle expects.
  await flattenIfNeeded()

  // Start daemon in-process (or detect existing external daemon)
  daemonRef = await ensureDaemon()

  // --no-game: PURE daemon, no Godot. The identity-create bootstrap child, or an
  // explicit always-on StateBase anchor/indexer (--pin-room/--anchor-room). Park
  // on the daemon and await SIGTERM. (Without --no-game, --daemon-only falls
  // through below to launch the REAL game headless.)
  if (noGame) {
    if (pinRooms.length) {
      console.log('[launcher] --no-game — pinning ' + pinRooms.length + ' room(s) for game-state persistence')
      await pinStateBases(pinRooms)
    }
    console.log('[launcher] --no-game — daemon up (no game), awaiting SIGTERM')
    Bare.on('exit', () => {
      if (daemonRef) { try { fs.unlinkSync(DAEMON_SOCKET) } catch {} }
    })
    await new Promise(() => {})
    return
  }

  // Kick off background P2P OTA via pgh (content-addressed, no blobs).
  // Bare mode only; non-blocking.
  if (!isPear) {
    startBackgroundUpdate()
  }

  let buildDir

  if (isPear) {
    // The legacy Pear-bundle path (pear-appdrive) has been removed. The app
    // now runs under bare and self-updates via pgh — launch via `bare index.js`.
    console.error('[launcher] Pear runtime mode is no longer supported — run via bare')
    exit(1)
    return
  }
  // Use the local build directory. Prefer the multi-arch layout
  // (by-arch/<host>/build/<platform>/) when present.
  buildDir = resolveBuildDir(getAppDir(), platformDir)

  const gamePath = path.join(buildDir, 'hypertps')

  if (!fs.existsSync(gamePath)) {
    console.error(`[launcher] Game binary not found: ${gamePath}`)
    exit(1)
    return
  }

  // Dedicated peer mode: TUI owns the screen
  if (dedicatedMode) {
    const { default: startTUI } = await import('./lib/tui.js')
    await startTUI({ socketPath: DAEMON_SOCKET, buildDir, gamePath, appVersion, dedicatedMode: true })
    return
  }

  // Launch game binary (mesh networking is native via GDExtension)
  console.log(`[launcher] Launching game: ${gamePath}`)

  const bareEnv = (await import('bare-env')).default
  if (!bareEnv.DISPLAY && os.platform() === 'linux') bareEnv.DISPLAY = ':0'

  // OpenMP runtime policy for the zig-built dc_terrain .so. It links LLVM's
  // libomp (clang's OpenMP), whose DEFAULT active wait-policy busy-spins idle
  // worker threads — hogging cores and starving the render thread, a severe
  // FPS regression vs the native/libgomp debug build. Passive wait lets idle
  // OpenMP threads sleep. Confirmed: this restores FPS on the release .so
  // (the deployed build); the launcher previously never set it, so deployed
  // ran with spinning while local debug builds (libgomp) did not.
  if (!bareEnv.OMP_WAIT_POLICY) bareEnv.OMP_WAIT_POLICY = 'passive'
  if (!bareEnv.OMP_NUM_THREADS) bareEnv.OMP_NUM_THREADS = '4'

  // dc_terrain's GPU mesh pipeline loads its compute shaders from this dir.
  // They ship next to the .so at buildDir/bin/linux/shaders. Pass the ABSOLUTE
  // path so the extension never depends on the process CWD: the GDExtension is
  // dlopen'd with a relative path, so dc_terrain's own dladdr resolution yields
  // a CWD-relative dir ("./bin/linux/shaders") that fails once the CWD changes
  // or vanishes at runtime (observed: getcwd() null on installed builds → GPU
  // meshing CPU-fell-back to ~4fps). This override short-circuits that.
  bareEnv.DC_TERRAIN_SHADER_DIR = path.join(buildDir, 'bin', 'linux', 'shaders')

  // Forward user args (--room, --role, etc.) to Godot after the -- separator
  const forwardArgs = []
  const forwardKeys = ['--room', '--room-id', '--role', '--config', '--diag-path'] // key+value pairs
  const forwardFlags = ['--list-rooms', '--export-config', '--headless', '--benchmark'] // standalone flags
  for (let i = 0; i < rawArgs.length; i++) {
    if (forwardFlags.includes(rawArgs[i])) {
      forwardArgs.push(rawArgs[i])
    } else if (forwardKeys.includes(rawArgs[i]) && i + 1 < rawArgs.length) {
      forwardArgs.push(rawArgs[i], rawArgs[i + 1])
      i++ // skip value
    }
  }

  // --daemon-only runs the game with no display → force --headless.
  const isHeadless = forwardArgs.includes('--headless') || daemonOnlyMode
  const gameArgs = ['--verbose']
  if (isHeadless) gameArgs.push('--headless')
  gameArgs.push('--', `--version=${appVersion}`, ...forwardArgs)
  if (debugMode) gameArgs.push('--debug-collisions')
  console.log(`[launcher] Args: ${gameArgs.join(' ')}`)

  // CWD must NOT live inside the install tree. The background `pgh app update`
  // started above swaps that tree atomically (rm + re-materialize), which
  // unlinks the cwd inode mid-run → getcwd() returns null → Godot's
  // DirAccessUnix fails to construct, which breaks user:// (shader cache is
  // silently lost → cold recompile every launch) and spams change_dir errors.
  // Anchor cwd to a stable dir: the game resolves res:// from the executable
  // path and user:// from the data dir, neither of which depends on cwd.
  const game = subprocess.spawn(gamePath, gameArgs, {
    stdio: 'inherit',
    cwd: os.homedir(),
    // Pass our mutated env (OMP_WAIT_POLICY, DC_TERRAIN_SHADER_DIR, DISPLAY)
    // explicitly. Without this, spawn inherits the raw OS env and silently
    // drops every bareEnv.* we set above — so OMP_WAIT_POLICY never reached
    // the game (FPS regression persisted) and DC_TERRAIN_SHADER_DIR relied on
    // the .so's dladdr fallback instead of our absolute path.
    env: bareEnv
  })

  game.on('exit', async (code) => {
    console.log(`[launcher] Game exited with code ${code}`)
    // Clean up socket if we started the daemon
    if (daemonRef) {
      try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
    }
    // OTA is applied atomically by the background `pgh app update` (it swaps
    // the install tree in place), so the next launch automatically uses the
    // new version — nothing to apply here.
    exit(code || 0)
  })

  const cleanup = () => {
    try { game.kill('SIGTERM') } catch {}
    if (daemonRef) {
      try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
    }
  }

  Bare.on('exit', cleanup)
}

main().catch((err) => {
  console.error('[launcher] Fatal:', err)
  if (daemonRef) {
    try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
  }
  exit(1)
})