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