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