// rnx daemon — manage the persistent rnx bridge daemon
//
// wraps `rnx serve` so it starts automatically on login and restarts if
// it crashes. the agent points at the executable that ran `daemon install`,
// so `rnx upgrade` (which replaces that file in place) is picked up by
// `rnx daemon restart` without touching the plist/unit file.
//
// `daemonInstall` is shared by `rnx setup` and the explicit
// `rnx daemon install` machine-bootstrap path.
//
// macOS: ~/Library/LaunchAgents/dev.sootsim.daemon.plist (launchd)
// linux: ~/.config/systemd/user/sootsim-daemon.service (systemd --user)
// win: not yet supported — use `rnx serve` in a persistent shell.
//
// the macOS launchd Label is `dev.sootsim.daemon` (matching the .app
// CFBundleIdentifier).
import { spawnSync } from 'child_process'
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'fs'
import { homedir } from 'os'
import { dirname, resolve } from 'path'
import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
import {
daemonAppDir,
isDaemonLockfileFresh,
readDaemonLockfile,
shouldSkipPersistentDaemon,
rnxHomeDir,
} from '../../src/home-paths'
import { rnxPublicBrand } from '../../src/public-brand'
import { printCommandHelp } from '../help'
import { rnxExit } from '../run-rnx'
import { rnxSelfInvocation, type RnxSelfInvocation } from '../self-invocation'
import { ensureDaemonApp } from './daemon-mac-app'
// minimum seconds between launchd / systemd respawns. without this, a daemon
// that crashes on startup respawns in a tight loop — launchd's default is 10s,
// systemd's previous setting was 3s. bumping to 60s caps the damage of any
// misconfig that still slips past the bridge-host's port-fallback search.
const RESPAWN_THROTTLE_SECONDS = 60
const SERVICE_LABEL_MAC = 'dev.sootsim.daemon'
const LEGACY_SERVICE_LABEL_MAC = 'dev.sootsim.server'
const SERVICE_NAME_LINUX = 'sootsim-daemon'
const LOG_DIR = resolve(homedir(), 'Library/Logs/sootsim')
const LOG_DIR_LINUX = resolve(homedir(), '.local/state/sootsim')
interface DaemonOptions {
port?: number
}
export async function runDaemon(args: string[], opts: DaemonOptions = {}) {
const [sub, ...rest] = args
const port = opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT
if (!sub || sub === '--help' || sub === '-h') {
printHelp()
return
}
switch (sub) {
case 'install':
return daemonInstall({ port, force: rest.includes('--force') })
case 'uninstall':
return daemonUninstall(rest)
case 'status':
return daemonStatus()
case 'restart':
return daemonRestart()
case 'start':
return daemonStart()
case 'stop':
return daemonStop()
case 'logs':
return daemonLogs(rest)
default:
console.error(` unknown daemon subcommand: ${sub}`)
printHelp()
rnxExit(1)
}
}
function printHelp() {
printCommandHelp('daemon')
}
// --- resolve sootsim binary ----------------------------------------------
// launchd and systemd run agents with a stripped PATH (basically just
// /usr/bin:/bin:/usr/sbin:/sbin), so anything that depends on PATH resolution
// never starts. rnxSelfInvocation() answers with absolute paths for both CLI
// shapes; resolve symlinks on the entry script so the service spawns the real
// file rather than a bin shim that would itself need PATH.
function resolveDaemonInvocation(): RnxSelfInvocation {
const invocation = rnxSelfInvocation()
return {
executable: invocation.executable,
prefixArgs: invocation.prefixArgs.map(realPathOrSelf),
}
}
function realPathOrSelf(p: string): string {
try {
return realpathSync(p)
} catch {
return p
}
}
// --- plist / unit generation ---------------------------------------------
function renderPlist(invocation: RnxSelfInvocation, port: number): string {
mkdirSync(LOG_DIR, { recursive: true })
const stdout = resolve(LOG_DIR, 'bridge.out.log')
const stderr = resolve(LOG_DIR, 'bridge.err.log')
// launchd points at the .app launcher (which exec's the real invocation
// in place) rather than at bun/node directly, so Login Items reads the
// bundle's CFBundleDisplayName instead of bun's signer identity.
const { launcherPath } = ensureDaemonApp(invocation, port, { stdout, stderr })
const programArgs = ` ${escapeXml(launcherPath)}`
return `
Label${SERVICE_LABEL_MAC}
ProgramArguments
${programArgs}
RunAtLoad
KeepAlive
ThrottleInterval${RESPAWN_THROTTLE_SECONDS}
ProcessTypeBackground
StandardOutPath${stdout}
StandardErrorPath${stderr}
`
}
function renderSystemdUnit(invocation: RnxSelfInvocation, port: number): string {
mkdirSync(LOG_DIR_LINUX, { recursive: true })
const exec = [invocation.executable, ...invocation.prefixArgs].map(shellQuote).join(' ')
const rnxHome = process.env.RNX_HOME
const environment = rnxHome ? `Environment=${shellQuote(`RNX_HOME=${rnxHome}`)}\n` : ''
return `[Unit]
Description=rnx bridge daemon
After=default.target
[Service]
Type=simple
${environment}ExecStart=${exec} serve --quiet --port ${port}
Restart=always
RestartSec=${RESPAWN_THROTTLE_SECONDS}
[Install]
WantedBy=default.target
`
}
function escapeXml(value: string): string {
return value
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function shellQuote(value: string): string {
return /[^\w@%+=:,./-]/.test(value) ? `'${value.replace(/'/g, `'\\''`)}'` : value
}
// --- platform paths ------------------------------------------------------
function macPlistPath(): string {
return resolve(homedir(), 'Library/LaunchAgents', `${SERVICE_LABEL_MAC}.plist`)
}
function macLegacyPlistPath(userHome = homedir()): string {
return resolve(userHome, 'Library/LaunchAgents', `${LEGACY_SERVICE_LABEL_MAC}.plist`)
}
function linuxUnitPath(): string {
return resolve(homedir(), '.config/systemd/user', `${SERVICE_NAME_LINUX}.service`)
}
// --- subcommands ---------------------------------------------------------
export interface DaemonServiceStatus {
supported: boolean
installed: boolean
path?: string
}
export function getDaemonServiceStatus(): DaemonServiceStatus {
if (shouldSkipPersistentDaemon()) return { supported: false, installed: false }
if (process.platform === 'darwin') {
const path = macPlistPath()
return { supported: true, installed: existsSync(path), path }
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
return { supported: true, installed: existsSync(path), path }
}
return { supported: false, installed: false }
}
export async function daemonInstall({ port, force }: { port: number; force: boolean }) {
if (shouldSkipPersistentDaemon()) {
throw new Error('background daemon setup is unavailable in this environment')
}
const invocation = resolveDaemonInvocation()
console.log(` binary: ${[invocation.executable, ...invocation.prefixArgs].join(' ')}`)
console.log(` port: ${port}`)
if (process.platform === 'darwin') {
const legacy = teardownLegacyMacDaemonArtifacts()
if (!legacy.missing) {
console.log(` removed legacy ${LEGACY_SERVICE_LABEL_MAC} service`)
}
const path = macPlistPath()
if (existsSync(path) && !force) {
console.log(` already installed at ${path}`)
console.log(` pass --force to overwrite, or use 'rnx daemon restart' to reload`)
return
}
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, renderPlist(invocation, port))
console.log(` wrote ${path}`)
// bootstrap into the gui session. launchctl is famous for returning exit
// 0 from `bootstrap` while leaving the service unregistered (a stale
// bootout reference, an in-flight unload, etc.) — kickstart then fails
// with a confusing "service not found" 113. so: bootstrap, immediately
// verify with `launchctl print`, and if the verify fails do a clean
// bootout-then-bootstrap retry before giving up. we surface real stderr
// text from any of those steps so users see what launchd actually said.
const target = `gui/${process.getuid!()}`
const serviceTarget = `${target}/${SERVICE_LABEL_MAC}`
const tryBootstrap = (): { ok: true } | { ok: false; reason: string } => {
const bs = spawnSync('launchctl', ['bootstrap', target, path], {
stdio: 'pipe',
encoding: 'utf8',
})
const bsErr = (bs.stderr || bs.stdout || '').trim()
if (bs.status !== 0) {
return {
ok: false,
reason: `bootstrap exit ${bs.status}${bsErr ? `: ${bsErr}` : ''}`,
}
}
const verify = spawnSync('launchctl', ['print', serviceTarget], {
encoding: 'utf8',
})
if (verify.status !== 0) {
return {
ok: false,
reason: `bootstrap returned 0 but service not registered (print exit ${verify.status})`,
}
}
return { ok: true }
}
let attempt = tryBootstrap()
if (!attempt.ok) {
const first = attempt.reason
// clean reload — bootout any stale reference then bootstrap again.
spawnSync('launchctl', ['bootout', serviceTarget], { stdio: 'ignore' })
attempt = tryBootstrap()
if (!attempt.ok) {
throw new Error(
`launchctl bootstrap failed: ${attempt.reason}` +
(first !== attempt.reason ? ` (initial: ${first})` : '') +
` — try \`rnx daemon uninstall\` then retry, or reboot if launchd state is stuck.`,
)
}
}
// RunAtLoad=true on the plist means bootstrap already started the
// agent. an explicit `launchctl kickstart` is redundant here and on
// some machines fails with 113 ("service not found") when the agent
// crashes immediately and lands in launchd's throttle state — even
// though the service IS registered. instead, poll `launchctl print`
// for a few seconds until we see the service is running, then we're
// done; if it never reaches running, surface the err log path.
const deadline = Date.now() + 4000
let lastState: string | undefined
let lastPid: string | undefined
while (Date.now() < deadline) {
const p = spawnSync('launchctl', ['print', serviceTarget], { encoding: 'utf8' })
if (p.status === 0) {
lastState = p.stdout?.match(/state\s*=\s*(\w+)/)?.[1]
lastPid = p.stdout?.match(/pid\s*=\s*(\d+)/)?.[1]
if (lastState === 'running' || lastPid) break
}
await new Promise((r) => setTimeout(r, 150))
}
if (lastState !== 'running' && !lastPid) {
throw new Error(
`daemon registered but did not reach running state within 4s ` +
`(last state: ${lastState || 'unknown'}). check ${resolve(LOG_DIR, 'bridge.err.log')} ` +
`— typically a stale runtime or a port already bound by another process.`,
)
}
console.log(
` registered (state: ${lastState || 'unknown'}${lastPid ? `, pid ${lastPid}` : ''}, log: ${resolve(LOG_DIR, 'bridge.err.log')})`,
)
return
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
if (existsSync(path) && !force) {
console.log(` already installed at ${path}`)
console.log(` pass --force to overwrite, or use 'rnx daemon restart' to reload`)
return
}
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, renderSystemdUnit(invocation, port))
console.log(` wrote ${path}`)
const reload = spawnSync('systemctl', ['--user', 'daemon-reload'], {
stdio: 'pipe',
encoding: 'utf8',
})
if (reload.status !== 0) {
const msg = (reload.stderr || reload.stdout || '').trim()
throw new Error(`systemctl daemon-reload failed${msg ? `: ${msg}` : ''}`)
}
const enable = spawnSync(
'systemctl',
['--user', 'enable', '--now', SERVICE_NAME_LINUX],
{ stdio: 'pipe', encoding: 'utf8' },
)
if (enable.status !== 0) {
const msg = (enable.stderr || enable.stdout || '').trim()
throw new Error(
`systemctl enable --now failed${msg ? `: ${msg}` : ''}` +
` — try \`rnx daemon uninstall\` then retry.`,
)
}
const isActive = spawnSync('systemctl', ['--user', 'is-active', SERVICE_NAME_LINUX], {
encoding: 'utf8',
})
const state = (isActive.stdout || '').trim() || 'unknown'
console.log(
` registered (state: ${state}, journalctl --user -u ${SERVICE_NAME_LINUX} to tail logs)`,
)
// user-scope systemd units are killed when the user's last session ends.
// on most distros the daemon then disappears at logout — the unit is
// "active" until you log off, then silently dead, and `rnx` looks
// broken when you ssh back in. `loginctl enable-linger` keeps the user
// bus alive across sessions, which is what every other long-running
// user agent (gpg-agent, ssh-agent --systemd) also relies on.
//
// orb's default user is already lingered (orb seeds it); multipass and
// lima fresh users are not. probing `loginctl show-user --property=Linger`
// tells us whether to nudge the user — running enable-linger ourselves
// requires root (`loginctl enable-linger` invokes a polkit action that
// typically needs sudo from a non-root user), so we just print the
// one-liner instead of trying to escalate ourselves.
try {
const username = process.env.USER || process.env.LOGNAME || ''
if (username) {
const linger = spawnSync(
'loginctl',
['show-user', username, '--property=Linger', '--value'],
{ encoding: 'utf8' },
)
const lingerEnabled = (linger.stdout || '').trim().toLowerCase() === 'yes'
if (!lingerEnabled && linger.status === 0) {
console.log(
` note: linger is not enabled for "${username}". the daemon will exit at logout.\n` +
` enable persistence with: sudo loginctl enable-linger ${username}`,
)
}
}
} catch {
// loginctl missing or non-systemd init — silent; the daemon still works
// for the current session even without lingering.
}
return
}
console.error(` background daemon is not supported on ${process.platform}`)
console.error(` run 'rnx serve' in a persistent shell instead`)
rnxExit(1)
}
// removed-path log (idempotent helpers track what they actually touched so the
// caller can report a unified "not installed" / "removed N entries" line).
type TeardownReport = { removed: string[]; missing: boolean }
export function teardownLegacyMacDaemonArtifacts(
options: {
userHome?: string
launchTarget?: string
bootout?: (target: string) => void
} = {},
): TeardownReport {
const userHome = options.userHome ?? homedir()
const plist = macLegacyPlistPath(userHome)
const logDir = resolve(userHome, 'Library/Logs/sootsim')
const removed: string[] = []
if (existsSync(plist)) {
const target = `${
options.launchTarget ?? `gui/${process.getuid?.() ?? 0}`
}/${LEGACY_SERVICE_LABEL_MAC}`
const bootout =
options.bootout ??
((serviceTarget: string) => {
spawnSync('launchctl', ['bootout', serviceTarget], { stdio: 'ignore' })
})
bootout(target)
rmSync(plist, { force: true })
removed.push(plist)
}
for (const name of ['server.err.log', 'server.out.log']) {
const log = resolve(logDir, name)
if (!existsSync(log)) continue
rmSync(log, { force: true })
removed.push(log)
}
return { removed, missing: removed.length === 0 }
}
/** stop + disable + remove the persistent launchd / systemd registration
* and its support files (mac .app bundle, log dir). does NOT touch user
* data under ~/.rnx. idempotent: each entry is checked before remove. */
export function teardownDaemonService(): TeardownReport {
const removed: string[] = []
if (process.platform === 'darwin') {
const legacy = teardownLegacyMacDaemonArtifacts()
removed.push(...legacy.removed)
const plist = macPlistPath()
if (existsSync(plist)) {
spawnSync(
'launchctl',
['bootout', `gui/${process.getuid!()}/${SERVICE_LABEL_MAC}`],
{ stdio: 'ignore' },
)
rmSync(plist, { force: true })
removed.push(plist)
}
// daemon-app holds only the generated .app launcher, so the whole
// directory goes with the service.
const appDir = daemonAppDir()
if (existsSync(appDir)) {
rmSync(appDir, { recursive: true, force: true })
removed.push(appDir)
}
if (existsSync(LOG_DIR)) {
rmSync(LOG_DIR, { recursive: true, force: true })
removed.push(LOG_DIR)
}
return { removed, missing: removed.length === 0 }
}
if (process.platform === 'linux') {
const unit = linuxUnitPath()
if (existsSync(unit)) {
// `disable --now` stops + disables in one call. ignore exit status: a
// unit whose ExecStart no longer exists can return non-zero while
// still successfully disabling the unit.
spawnSync('systemctl', ['--user', 'disable', '--now', SERVICE_NAME_LINUX], {
stdio: 'ignore',
})
rmSync(unit, { force: true })
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
// reset-failed clears the "failed" state that lingers in systemd when
// the unit exited with a missing ExecStart, otherwise `systemctl --user
// status sootsim-daemon` keeps showing failed even after the unit file
// is gone.
spawnSync('systemctl', ['--user', 'reset-failed', SERVICE_NAME_LINUX], {
stdio: 'ignore',
})
removed.push(unit)
}
if (existsSync(LOG_DIR_LINUX)) {
rmSync(LOG_DIR_LINUX, { recursive: true, force: true })
removed.push(LOG_DIR_LINUX)
}
return { removed, missing: removed.length === 0 }
}
// win + others: no persistent daemon mechanism yet; nothing to tear down.
return { removed, missing: true }
}
/** remove EVERYTHING under ~/.rnx: installed runtimes, electron support,
* device profiles and their app storage, live dev-bridge state, lockfile,
* and config. only `rnx daemon uninstall --purge` calls this, because a
* user who asked to remove a background service did not ask to lose the
* simulators they have been working in. idempotent. honors $RNX_HOME. */
export function teardownRnxHome(): TeardownReport {
const removed: string[] = []
const home = rnxHomeDir()
if (existsSync(home)) {
rmSync(home, { recursive: true, force: true })
removed.push(home)
}
return { removed, missing: removed.length === 0 }
}
// `uninstall` removes the background service and nothing else. device
// profiles, their app storage, and live dev-bridge state belong to simulators
// the user may still be working in, so deleting them is something you ask for
// by name with `--purge`.
async function daemonUninstall(rest: string[]) {
const unknown = rest.filter((arg) => arg !== '--purge')
if (unknown.length > 0) {
console.error(` unknown daemon uninstall option: ${unknown[0]}`)
printHelp()
rnxExit(1)
return
}
const purge = rest.includes('--purge')
const service = teardownDaemonService()
for (const path of service.removed) console.log(` removed ${path}`)
if (!purge) {
if (service.missing) console.log(' not installed')
const home = rnxHomeDir()
if (existsSync(home)) {
console.log(` kept ${home} (runtimes, device profiles, open simulators)`)
console.log(
` remove that too with \`${rnxPublicBrand.commandName} daemon uninstall --purge\``,
)
}
return
}
const home = teardownRnxHome()
for (const path of home.removed) console.log(` removed ${path}`)
if (service.missing && home.missing) console.log(' not installed')
}
// the service manager's own word is not proof the bridge works: a daemon that
// crashes on start reads as `active` all through systemd's restart backoff,
// which is how a fully broken daemon reported healthy on the 2026-08-19 drive.
// the lockfile carries a pid and a heartbeat, so it answers the question the
// caller actually asked.
function printBridgeLiveness() {
const lock = readDaemonLockfile()
if (isDaemonLockfileFresh(lock)) {
console.log(` bridge: responding on port ${lock.bridgePort} (pid ${lock.pid})`)
return
}
console.log(
lock
? ' bridge: NOT responding — stale lockfile; check `rnx daemon logs`'
: ' bridge: NOT responding — no lockfile; check `rnx daemon logs`',
)
}
async function daemonStatus() {
if (process.platform === 'darwin') {
const path = macPlistPath()
if (!existsSync(path)) {
console.log(` installed: no`)
return
}
console.log(` installed: yes (${path})`)
const binary = readPlistBinary(path)
if (binary) console.log(` binary: ${binary}`)
const res = spawnSync(
'launchctl',
['print', `gui/${process.getuid!()}/${SERVICE_LABEL_MAC}`],
{ encoding: 'utf8' },
)
if (res.status === 0) {
const state = res.stdout.match(/state\s*=\s*(\w+)/)?.[1]
const pid = res.stdout.match(/pid\s*=\s*(\d+)/)?.[1]
console.log(` state: ${state || 'unknown'}${pid ? ` (pid ${pid})` : ''}`)
} else {
console.log(' state: not running')
}
printBridgeLiveness()
return
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
if (!existsSync(path)) {
console.log(` installed: no`)
return
}
console.log(` installed: yes (${path})`)
const res = spawnSync('systemctl', ['--user', 'is-active', SERVICE_NAME_LINUX], {
encoding: 'utf8',
})
console.log(` state: ${res.stdout.trim() || 'unknown'}`)
printBridgeLiveness()
return
}
console.error(` unsupported platform: ${process.platform}`)
rnxExit(1)
}
async function daemonRestart() {
if (process.platform === 'darwin') {
const path = macPlistPath()
if (!existsSync(path)) {
console.error(' daemon not registered. run `rnx daemon install`')
rnxExit(1)
}
const res = spawnSync(
'launchctl',
['kickstart', '-k', `gui/${process.getuid!()}/${SERVICE_LABEL_MAC}`],
{ stdio: 'pipe', encoding: 'utf8' },
)
if (res.status !== 0) {
console.error(` kickstart failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' restarted')
return
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
if (!existsSync(path)) {
console.error(' daemon not registered. run `rnx daemon install`')
rnxExit(1)
}
const res = spawnSync('systemctl', ['--user', 'restart', SERVICE_NAME_LINUX], {
stdio: 'pipe',
encoding: 'utf8',
})
if (res.status !== 0) {
console.error(` restart failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' restarted')
return
}
console.error(` unsupported platform: ${process.platform}`)
rnxExit(1)
}
async function daemonStart() {
if (process.platform === 'darwin') {
const path = macPlistPath()
if (!existsSync(path)) {
console.error(' daemon not registered. run `rnx daemon install`')
rnxExit(1)
}
const res = spawnSync(
'launchctl',
['kickstart', `gui/${process.getuid!()}/${SERVICE_LABEL_MAC}`],
{ stdio: 'pipe', encoding: 'utf8' },
)
if (res.status !== 0) {
console.error(` start failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' started')
return
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
if (!existsSync(path)) {
console.error(' daemon not registered. run `rnx daemon install`')
rnxExit(1)
}
const res = spawnSync('systemctl', ['--user', 'start', SERVICE_NAME_LINUX], {
stdio: 'pipe',
encoding: 'utf8',
})
if (res.status !== 0) {
console.error(` start failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' started')
return
}
console.error(` unsupported platform: ${process.platform}`)
rnxExit(1)
}
async function daemonStop() {
if (process.platform === 'darwin') {
const path = macPlistPath()
if (!existsSync(path)) {
console.error(' daemon not registered — nothing to stop')
rnxExit(1)
}
const res = spawnSync(
'launchctl',
['bootout', `gui/${process.getuid!()}/${SERVICE_LABEL_MAC}`],
{ stdio: 'pipe', encoding: 'utf8' },
)
if (res.status !== 0) {
console.error(` stop failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' stopped')
return
}
if (process.platform === 'linux') {
const path = linuxUnitPath()
if (!existsSync(path)) {
console.error(' daemon not registered — nothing to stop')
rnxExit(1)
}
const res = spawnSync('systemctl', ['--user', 'stop', SERVICE_NAME_LINUX], {
stdio: 'pipe',
encoding: 'utf8',
})
if (res.status !== 0) {
console.error(` stop failed: ${res.stderr?.trim()}`)
rnxExit(1)
}
console.log(' stopped')
return
}
console.error(` unsupported platform: ${process.platform}`)
rnxExit(1)
}
// tail the daemon's logs. macOS reads the launchd-redirected files directly
// (works for any user); linux reads the systemd journal. journalctl --user
// fails for a freshly-created user not yet in the systemd-journal/adm group
// ("No journal files were opened due to insufficient permissions"), so on that
// error we retry the system-scoped query and, failing that, point the user at
// the sudo form rather than printing a cryptic permission error.
async function daemonLogs(rest: string[]) {
const linesArg = rest.find((a) => /^-n\d+$|^--lines=\d+$|^\d+$/.test(a))
const lines = linesArg ? Number(linesArg.replace(/\D/g, '')) : 200
const follow = rest.includes('-f') || rest.includes('--follow')
if (process.platform === 'darwin') {
const out = resolve(LOG_DIR, 'bridge.out.log')
const err = resolve(LOG_DIR, 'bridge.err.log')
const present = [out, err].filter((p) => existsSync(p))
if (present.length === 0) {
console.error(` no daemon logs yet at ${LOG_DIR}. run \`rnx daemon install\``)
rnxExit(1)
}
const args = follow
? ['-n', String(lines), '-F', ...present]
: ['-n', String(lines), ...present]
spawnSync('tail', args, { stdio: 'inherit' })
return
}
if (process.platform === 'linux') {
const unit = `${SERVICE_NAME_LINUX}.service`
const base = ['--user', '-u', unit, '-n', String(lines), '--no-pager']
const res = spawnSync('journalctl', follow ? [...base, '-f'] : base, {
encoding: 'utf8',
stdio: follow ? 'inherit' : 'pipe',
})
if (follow) return
const combined = `${res.stdout || ''}${res.stderr || ''}`
const blocked = /insufficient permissions|No journal files were opened/i.test(
combined,
)
if (res.status === 0 && !blocked) {
process.stdout.write(res.stdout || '')
return
}
// fresh-user fallback: the system journal holds the user-unit logs even when
// the user can't read --user scope. try it, then fall back to guidance.
const sys = spawnSync(
'journalctl',
[`_SYSTEMD_USER_UNIT=${unit}`, '-n', String(lines), '--no-pager'],
{ encoding: 'utf8' },
)
if (sys.status === 0 && (sys.stdout || '').trim()) {
process.stdout.write(sys.stdout)
return
}
console.error(
` could not read --user journal for ${unit} (this user isn't in the systemd-journal group yet).\n` +
` run: sudo journalctl _SYSTEMD_USER_UNIT=${unit} -n ${lines} --no-pager`,
)
rnxExit(1)
}
console.error(` unsupported platform: ${process.platform}`)
rnxExit(1)
}
function readPlistBinary(path: string): string | null {
try {
const body = readFileSync(path, 'utf8')
const match = body.match(/\s*([^<]+)<\/string>/)
return match?.[1] || null
} catch {
return null
}
}