From bfea8a4eeccaf68061163434f487d3452e06104b Mon Sep 17 00:00:00 2001 From: natew Date: Sun, 25 Jan 2026 16:05:43 -1000 Subject: [PATCH 1/5] feat(one): add daemon for multi-app development - Add `one daemon` command that runs a proxy on port 8081 - Dev servers register via IPC socket (~/.one/daemon.sock) - Routes requests based on ?app= query param (sent by RN automatically) - Interactive picker for ambiguous routes (same bundleId) - Auto-detect daemon in `one dev` and use alternate port - Support for CI/Detox with auto-routing and pre-configured routes - Includes test script (scripts/test-daemon.mjs) --- packages/one/src/cli.ts | 40 ++++ packages/one/src/cli/daemon.ts | 166 ++++++++++++++ packages/one/src/cli/dev.ts | 77 ++++++- packages/one/src/daemon/index.ts | 9 + packages/one/src/daemon/ipc.ts | 284 ++++++++++++++++++++++++ packages/one/src/daemon/picker.ts | 222 +++++++++++++++++++ packages/one/src/daemon/proxy.ts | 86 ++++++++ packages/one/src/daemon/registry.ts | 89 ++++++++ packages/one/src/daemon/server.ts | 284 ++++++++++++++++++++++++ packages/one/src/daemon/types.ts | 38 ++++ packages/one/src/daemon/utils.ts | 94 ++++++++ plans/one-daemon.md | 232 ++++++++++++++++++++ scripts/test-daemon.mjs | 325 ++++++++++++++++++++++++++++ 13 files changed, 1941 insertions(+), 5 deletions(-) create mode 100644 packages/one/src/cli/daemon.ts create mode 100644 packages/one/src/daemon/index.ts create mode 100644 packages/one/src/daemon/ipc.ts create mode 100644 packages/one/src/daemon/picker.ts create mode 100644 packages/one/src/daemon/proxy.ts create mode 100644 packages/one/src/daemon/registry.ts create mode 100644 packages/one/src/daemon/server.ts create mode 100644 packages/one/src/daemon/types.ts create mode 100644 packages/one/src/daemon/utils.ts create mode 100644 plans/one-daemon.md create mode 100644 scripts/test-daemon.mjs diff --git a/packages/one/src/cli.ts b/packages/one/src/cli.ts index 2ac03bb2b..861637fdd 100644 --- a/packages/one/src/cli.ts +++ b/packages/one/src/cli.ts @@ -283,6 +283,45 @@ const generateRoutes = defineCommand({ }, }) +const daemonCommand = defineCommand({ + meta: { + name: 'daemon', + version: version, + description: 'Multi-app development server proxy', + }, + args: { + subcommand: { + type: 'positional', + description: 'Subcommand: start, stop, status, route (default: start)', + required: false, + }, + port: { + type: 'string', + description: 'Port to listen on (default: 8081)', + }, + host: { + type: 'string', + description: 'Host to bind to (default: 0.0.0.0)', + }, + app: { + type: 'string', + description: 'Bundle ID for route command', + }, + slot: { + type: 'string', + description: 'Slot number for route command', + }, + project: { + type: 'string', + description: 'Project path for route command', + }, + }, + async run({ args }) { + const { daemon } = await import('./cli/daemon') + await daemon(args) + }, +}) + const subCommands = { dev, clean, @@ -293,6 +332,7 @@ const subCommands = { patch, serve: serveCommand, 'generate-routes': generateRoutes, + daemon: daemonCommand, } // workaround for having sub-commands but also positional arg for naming in the create flow diff --git a/packages/one/src/cli/daemon.ts b/packages/one/src/cli/daemon.ts new file mode 100644 index 000000000..ecf932906 --- /dev/null +++ b/packages/one/src/cli/daemon.ts @@ -0,0 +1,166 @@ +// one daemon CLI command + +import colors from 'picocolors' +import { labelProcess } from './label-process' + +export async function daemon(args: { + subcommand?: string + port?: string + host?: string + app?: string + slot?: string + project?: string +}) { + const subcommand = args.subcommand || 'run' + + switch (subcommand) { + case 'run': + case 'start': + return daemonStart(args) + + case 'stop': + return daemonStop() + + case 'status': + return daemonStatus() + + case 'route': + return daemonRoute(args) + + default: + console.log(`Unknown daemon subcommand: ${subcommand}`) + console.log('Available: start, stop, status, route') + process.exit(1) + } +} + +async function daemonStart(args: { port?: string; host?: string }) { + labelProcess('daemon') + + const { isDaemonRunning } = await import('../daemon/ipc') + + if (await isDaemonRunning()) { + console.log(colors.yellow('Daemon is already running')) + console.log("Use 'one daemon status' to see registered servers") + process.exit(1) + } + + const { startDaemon } = await import('../daemon/server') + + await startDaemon({ + port: args.port ? parseInt(args.port, 10) : undefined, + host: args.host, + }) +} + +async function daemonStop() { + const { isDaemonRunning, getSocketPath, cleanupSocket } = await import('../daemon/ipc') + + if (!(await isDaemonRunning())) { + console.log(colors.yellow('Daemon is not running')) + process.exit(1) + } + + // send shutdown signal via IPC + // for now, just cleanup socket and let user stop the process manually + console.log(colors.yellow('Note: daemon runs in foreground. Press Ctrl+C in the daemon terminal to stop.')) + console.log(colors.dim(`Socket path: ${getSocketPath()}`)) +} + +async function daemonStatus() { + const { isDaemonRunning, getDaemonStatus } = await import('../daemon/ipc') + + if (!(await isDaemonRunning())) { + console.log(colors.yellow('Daemon is not running')) + console.log(colors.dim("Start with 'one daemon'")) + process.exit(1) + } + + try { + const status = await getDaemonStatus() + + console.log(colors.cyan('\n═══════════════════════════════════════════════════')) + console.log(colors.cyan(' one daemon status')) + console.log(colors.cyan('═══════════════════════════════════════════════════\n')) + + if (status.servers.length === 0) { + console.log(colors.dim(' No servers registered')) + } else { + console.log(' Registered servers:') + for (const server of status.servers) { + const shortRoot = server.root.replace(process.env.HOME || '', '~') + console.log( + ` ${colors.green(server.id)} ${server.bundleId} → :${server.port} (${shortRoot})` + ) + } + } + + if (status.routes.length > 0) { + console.log('\n Active routes:') + for (const route of status.routes) { + console.log(` ${route.key} → ${route.serverId}`) + } + } + + console.log('') + } catch (err) { + console.log(colors.red('Failed to get daemon status')) + console.error(err) + process.exit(1) + } +} + +async function daemonRoute(args: { app?: string; slot?: string; project?: string }) { + const { isDaemonRunning, getDaemonStatus, setDaemonRoute, clearDaemonRoute } = await import( + '../daemon/ipc' + ) + + if (!(await isDaemonRunning())) { + console.log(colors.yellow('Daemon is not running')) + process.exit(1) + } + + if (!args.app) { + console.log(colors.red('Missing --app parameter')) + console.log("Usage: one daemon route --app=com.example.app --slot=0") + console.log(" or: one daemon route --app=com.example.app --project=~/myapp") + process.exit(1) + } + + const status = await getDaemonStatus() + + // find the server to route to + let targetServer: (typeof status.servers)[0] | undefined + + if (args.slot !== undefined) { + // route by slot (index in server list) + const slotIndex = parseInt(args.slot, 10) + const matchingServers = status.servers.filter((s) => s.bundleId === args.app) + + if (slotIndex < 0 || slotIndex >= matchingServers.length) { + console.log(colors.red(`Invalid slot: ${args.slot}`)) + console.log(`Available slots for ${args.app}: 0-${matchingServers.length - 1}`) + process.exit(1) + } + + targetServer = matchingServers[slotIndex] + } else if (args.project) { + // route by project path + const normalizedProject = args.project.replace(/^~/, process.env.HOME || '') + targetServer = status.servers.find( + (s) => s.bundleId === args.app && s.root === normalizedProject + ) + + if (!targetServer) { + console.log(colors.red(`No server found for ${args.app} at ${args.project}`)) + process.exit(1) + } + } else { + console.log(colors.red('Missing --slot or --project parameter')) + process.exit(1) + } + + await setDaemonRoute(args.app, targetServer.id) + const shortRoot = targetServer.root.replace(process.env.HOME || '', '~') + console.log(colors.green(`Route set: ${args.app} → ${targetServer.id} (${shortRoot})`)) +} diff --git a/packages/one/src/cli/dev.ts b/packages/one/src/cli/dev.ts index a48d18cd0..5a441946e 100644 --- a/packages/one/src/cli/dev.ts +++ b/packages/one/src/cli/dev.ts @@ -1,8 +1,12 @@ +import colors from 'picocolors' import { setServerGlobals } from '../server/setServerGlobals' import { virtualEntryIdNative } from '../vite/plugins/virtualEntryConstants' import { checkNodeVersion } from './checkNodeVersion' import { labelProcess } from './label-process' +const DEFAULT_PORT = 8081 +const DAEMON_PORT = 8081 + export async function dev(args: { clean?: boolean host?: string @@ -16,17 +20,50 @@ export async function dev(args: { checkNodeVersion() setServerGlobals() + const root = process.cwd() + let daemonServerId: string | undefined + let useDaemon = false + let effectivePort = args.port ? +args.port : DEFAULT_PORT + + // check if daemon is running + const { isDaemonRunning, registerWithDaemon, unregisterFromDaemon } = await import( + '../daemon/ipc' + ) + const { getBundleIdFromConfig, getAvailablePort } = await import('../daemon/utils') + + const daemonRunning = await isDaemonRunning() + const bundleId = getBundleIdFromConfig(root) + + if (daemonRunning && !args.port) { + // daemon is running and no explicit port - register with daemon + if (bundleId) { + // find an available port that's not 8081 (daemon's port) + effectivePort = await getAvailablePort(8082, DAEMON_PORT) + + console.log(colors.cyan(`[daemon] Detected running daemon on :${DAEMON_PORT}`)) + console.log(colors.cyan(`[daemon] Using port :${effectivePort} for this server`)) + + useDaemon = true + } else { + console.log( + colors.yellow( + '[daemon] No bundleIdentifier found in app.json, running standalone on :8081' + ) + ) + } + } + const { dev } = await import('vxrn/dev') const { start, stop } = await dev({ mode: args.mode, clean: args.clean, - root: process.cwd(), + root, debugBundle: args.debugBundle, debug: args.debug, server: { host: args.host, - port: args.port ? +args.port : undefined, + port: effectivePort, }, entries: { native: virtualEntryIdNative, @@ -35,13 +72,43 @@ export async function dev(args: { const { closePromise } = await start() + // register with daemon after server starts + if (useDaemon && bundleId) { + try { + daemonServerId = await registerWithDaemon({ + port: effectivePort, + bundleId, + root, + }) + console.log( + colors.green( + `[daemon] Registered as ${bundleId} (${daemonServerId}) → accessible via :${DAEMON_PORT}` + ) + ) + } catch (err) { + console.log(colors.yellow(`[daemon] Failed to register: ${err}`)) + } + } + + const cleanup = async () => { + // unregister from daemon + if (daemonServerId) { + try { + await unregisterFromDaemon(daemonServerId) + } catch { + // ignore errors during cleanup + } + } + await stop() + } + process.on('beforeExit', () => { - stop() + cleanup() }) process.on('SIGINT', async () => { try { - await stop() + await cleanup() } finally { process.exit(2) } @@ -49,7 +116,7 @@ export async function dev(args: { process.on('SIGTERM', async () => { try { - await stop() + await cleanup() } finally { process.exit(0) } diff --git a/packages/one/src/daemon/index.ts b/packages/one/src/daemon/index.ts new file mode 100644 index 000000000..f55209e27 --- /dev/null +++ b/packages/one/src/daemon/index.ts @@ -0,0 +1,9 @@ +// daemon module exports + +export * from './types' +export * from './registry' +export * from './ipc' +export * from './proxy' +export * from './picker' +export * from './server' +export * from './utils' diff --git a/packages/one/src/daemon/ipc.ts b/packages/one/src/daemon/ipc.ts new file mode 100644 index 000000000..235daa1aa --- /dev/null +++ b/packages/one/src/daemon/ipc.ts @@ -0,0 +1,284 @@ +// IPC socket for daemon communication + +import * as net from 'node:net' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import type { IPCMessage, IPCResponse, DaemonState } from './types' +import { + registerServer, + unregisterServer, + getAllServers, + getAllRoutes, + setRoute, + clearRoute, + findServerById, +} from './registry' + +const SOCKET_DIR = path.join(os.homedir(), '.one') +const SOCKET_PATH = path.join(SOCKET_DIR, 'daemon.sock') + +export function getSocketPath(): string { + return SOCKET_PATH +} + +export function ensureSocketDir(): void { + if (!fs.existsSync(SOCKET_DIR)) { + fs.mkdirSync(SOCKET_DIR, { recursive: true }) + } +} + +export function cleanupSocket(): void { + try { + if (fs.existsSync(SOCKET_PATH)) { + fs.unlinkSync(SOCKET_PATH) + } + } catch { + // ignore + } +} + +export function createIPCServer( + state: DaemonState, + onServerRegistered?: (id: string) => void, + onServerUnregistered?: (id: string) => void +): net.Server { + ensureSocketDir() + cleanupSocket() + + const server = net.createServer((socket) => { + let buffer = '' + + socket.on('data', (data) => { + buffer += data.toString() + + // handle newline-delimited JSON messages + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + + try { + const message: IPCMessage = JSON.parse(line) + const response = handleMessage( + state, + message, + onServerRegistered, + onServerUnregistered + ) + socket.write(JSON.stringify(response) + '\n') + } catch (err) { + const errResponse: IPCResponse = { + type: 'error', + message: err instanceof Error ? err.message : 'Unknown error', + } + socket.write(JSON.stringify(errResponse) + '\n') + } + } + }) + + socket.on('error', (err) => { + console.error('[daemon] IPC socket error:', err.message) + }) + }) + + server.listen(SOCKET_PATH, () => { + // make socket accessible + fs.chmodSync(SOCKET_PATH, 0o666) + }) + + return server +} + +function handleMessage( + state: DaemonState, + message: IPCMessage, + onServerRegistered?: (id: string) => void, + onServerUnregistered?: (id: string) => void +): IPCResponse { + switch (message.type) { + case 'register': { + const registration = registerServer(state, { + port: message.port, + bundleId: message.bundleId, + root: message.root, + }) + onServerRegistered?.(registration.id) + return { type: 'registered', id: registration.id } + } + + case 'unregister': { + unregisterServer(state, message.id) + onServerUnregistered?.(message.id) + return { type: 'unregistered' } + } + + case 'route': { + const server = findServerById(state, message.serverId) + if (!server) { + return { type: 'error', message: `Server not found: ${message.serverId}` } + } + // use bundleId as route key for now + setRoute(state, message.bundleId, message.serverId) + return { type: 'routed' } + } + + case 'route-clear': { + clearRoute(state, message.bundleId) + return { type: 'routed' } + } + + case 'status': { + return { + type: 'status', + servers: getAllServers(state), + routes: getAllRoutes(state), + } + } + + case 'ping': { + return { type: 'pong' } + } + + default: { + return { type: 'error', message: `Unknown message type` } + } + } +} + +// client functions for connecting to daemon + +export async function isDaemonRunning(): Promise { + return new Promise((resolve) => { + if (!fs.existsSync(SOCKET_PATH)) { + resolve(false) + return + } + + const client = net.connect(SOCKET_PATH) + const timeout = setTimeout(() => { + client.destroy() + resolve(false) + }, 1000) + + client.on('connect', () => { + clearTimeout(timeout) + client.write(JSON.stringify({ type: 'ping' }) + '\n') + }) + + client.on('data', (data) => { + clearTimeout(timeout) + try { + const response = JSON.parse(data.toString().trim()) + resolve(response.type === 'pong') + } catch { + resolve(false) + } + client.destroy() + }) + + client.on('error', () => { + clearTimeout(timeout) + resolve(false) + }) + }) +} + +export async function sendIPCMessage(message: IPCMessage): Promise { + return new Promise((resolve, reject) => { + const client = net.connect(SOCKET_PATH) + let buffer = '' + + const timeout = setTimeout(() => { + client.destroy() + reject(new Error('IPC timeout')) + }, 5000) + + client.on('connect', () => { + client.write(JSON.stringify(message) + '\n') + }) + + client.on('data', (data) => { + buffer += data.toString() + const lines = buffer.split('\n') + // keep the last incomplete line in the buffer + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + try { + clearTimeout(timeout) + const response = JSON.parse(line) + client.destroy() + resolve(response) + return + } catch { + // malformed message, continue to next line + } + } + }) + + client.on('error', (err) => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +export async function registerWithDaemon(opts: { + port: number + bundleId: string + root: string +}): Promise { + const response = await sendIPCMessage({ + type: 'register', + ...opts, + }) + + if (response.type === 'registered') { + return response.id + } + + if (response.type === 'error') { + throw new Error(response.message) + } + + throw new Error('Unexpected response from daemon') +} + +export async function unregisterFromDaemon(id: string): Promise { + await sendIPCMessage({ type: 'unregister', id }) +} + +export async function getDaemonStatus(): Promise<{ + servers: { id: string; port: number; bundleId: string; root: string }[] + routes: { key: string; serverId: string }[] +}> { + const response = await sendIPCMessage({ type: 'status' }) + + if (response.type === 'status') { + return { + servers: response.servers, + routes: response.routes, + } + } + + throw new Error('Failed to get daemon status') +} + +export async function setDaemonRoute(bundleId: string, serverId: string): Promise { + const response = await sendIPCMessage({ + type: 'route', + bundleId, + serverId, + }) + + if (response.type === 'error') { + throw new Error(response.message) + } +} + +export async function clearDaemonRoute(bundleId: string): Promise { + await sendIPCMessage({ type: 'route-clear', bundleId }) +} diff --git a/packages/one/src/daemon/picker.ts b/packages/one/src/daemon/picker.ts new file mode 100644 index 000000000..bdf54eef1 --- /dev/null +++ b/packages/one/src/daemon/picker.ts @@ -0,0 +1,222 @@ +// interactive picker for ambiguous routes + +import type { ServerRegistration } from './types' +import { exec } from 'node:child_process' +import { promisify } from 'node:util' +import * as readline from 'node:readline' + +const execAsync = promisify(exec) + +interface PickerContext { + bundleId: string + servers: ServerRegistration[] + onSelect: (server: ServerRegistration, remember: boolean) => void + onCancel: () => void +} + +let activePickerContext: PickerContext | null = null +let rl: readline.Interface | null = null +let stdinDataListener: ((key: Buffer) => void) | null = null + +export async function getBootedSimulators(): Promise< + { name: string; udid: string; state: string }[] +> { + try { + const { stdout } = await execAsync('xcrun simctl list devices booted -j') + const data = JSON.parse(stdout) + const simulators: { name: string; udid: string; state: string }[] = [] + + for (const [_runtime, devices] of Object.entries(data.devices || {})) { + for (const device of devices as any[]) { + if (device.state === 'Booted') { + simulators.push({ + name: device.name, + udid: device.udid, + state: device.state, + }) + } + } + } + return simulators + } catch { + return [] + } +} + +export function showPicker(context: PickerContext): void { + activePickerContext = context + + console.log('\n' + '─'.repeat(60)) + console.log(`🔀 Multiple servers for ${context.bundleId}`) + console.log('─'.repeat(60)) + + // show running simulators for context + getBootedSimulators().then((sims) => { + if (sims.length > 0) { + console.log('\nRunning simulators:') + for (const sim of sims) { + console.log(` • ${sim.name} (${sim.udid.slice(0, 8)}...)`) + } + } + }) + + console.log('\nSelect project:') + context.servers.forEach((server, i) => { + const shortRoot = server.root.replace(process.env.HOME || '', '~') + console.log(` [${i + 1}] ${shortRoot} (port ${server.port})`) + }) + + console.log('\nPress 1-' + context.servers.length + ' to select') + console.log("Or 'r' + number to remember (e.g., 'r1')") + console.log("Press 'c' to cancel\n") + + setupKeyboardInput() +} + +function setupKeyboardInput(): void { + if (rl) return + + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + + let buffer = '' + + stdinDataListener = (key: Buffer) => { + const str = key.toString() + + // ctrl+c + if (str === '\u0003') { + cancelPicker() + return + } + + // escape + if (str === '\u001b') { + cancelPicker() + return + } + + // backspace + if (str === '\u007f') { + buffer = buffer.slice(0, -1) + return + } + + // enter + if (str === '\r' || str === '\n') { + processInput(buffer) + buffer = '' + return + } + + buffer += str + + // check for immediate single key selection + if (buffer.length === 1 && /^[1-9]$/.test(buffer)) { + processInput(buffer) + buffer = '' + } else if (buffer.length === 2 && /^r[1-9]$/i.test(buffer)) { + processInput(buffer) + buffer = '' + } else if (buffer.toLowerCase() === 'c') { + cancelPicker() + buffer = '' + } + } + + process.stdin.on('data', stdinDataListener) +} + +function processInput(input: string): void { + if (!activePickerContext) return + + const remember = input.toLowerCase().startsWith('r') + const numStr = remember ? input.slice(1) : input + const num = parseInt(numStr, 10) + + if (isNaN(num) || num < 1 || num > activePickerContext.servers.length) { + console.log(`Invalid selection: ${input}`) + return + } + + const server = activePickerContext.servers[num - 1] + const context = activePickerContext + + cleanupPicker() + context.onSelect(server, remember) +} + +function cancelPicker(): void { + const context = activePickerContext + cleanupPicker() + if (context) { + context.onCancel() + } +} + +function cleanupPicker(): void { + activePickerContext = null + if (stdinDataListener) { + process.stdin.removeListener('data', stdinDataListener) + stdinDataListener = null + } + if (rl) { + rl.close() + rl = null + } + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } +} + +// for non-interactive mode (CI, Detox), we need a way to resolve without user input +let pendingPickerResolvers: Map< + string, + { resolve: (server: ServerRegistration) => void; reject: (err: Error) => void } +> = new Map() + +export function resolvePendingPicker(bundleId: string, serverId: string): boolean { + const resolver = pendingPickerResolvers.get(bundleId) + if (!resolver || !activePickerContext) return false + + const server = activePickerContext.servers.find((s) => s.id === serverId) + if (!server) return false + + pendingPickerResolvers.delete(bundleId) + cleanupPicker() + resolver.resolve(server) + return true +} + +export function pickServer( + bundleId: string, + servers: ServerRegistration[] +): Promise<{ server: ServerRegistration; remember: boolean }> { + return new Promise((resolve, reject) => { + // check if we have a pending resolver for this bundleId (for programmatic resolution) + pendingPickerResolvers.set(bundleId, { + resolve: (server) => resolve({ server, remember: false }), + reject, + }) + + showPicker({ + bundleId, + servers, + onSelect: (server, remember) => { + pendingPickerResolvers.delete(bundleId) + resolve({ server, remember }) + }, + onCancel: () => { + pendingPickerResolvers.delete(bundleId) + reject(new Error('Selection cancelled')) + }, + }) + }) +} diff --git a/packages/one/src/daemon/proxy.ts b/packages/one/src/daemon/proxy.ts new file mode 100644 index 000000000..f07e0e200 --- /dev/null +++ b/packages/one/src/daemon/proxy.ts @@ -0,0 +1,86 @@ +// HTTP and WebSocket proxy for daemon + +import * as http from 'node:http' +import * as net from 'node:net' +import type { ServerRegistration } from './types' + +export function proxyHttpRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + target: ServerRegistration +): void { + const options: http.RequestOptions = { + hostname: 'localhost', + port: target.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + // preserve original host but add forwarded headers + 'x-forwarded-host': req.headers.host, + 'x-forwarded-for': req.socket.remoteAddress, + }, + } + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) + proxyRes.pipe(res) + }) + + proxyReq.on('error', (err) => { + console.error(`[daemon] Proxy error to port ${target.port}:`, err.message) + if (!res.headersSent) { + res.writeHead(502) + res.end(`Bad Gateway: ${err.message}`) + } + }) + + req.pipe(proxyReq) +} + +export function proxyWebSocket( + req: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + target: ServerRegistration +): void { + const proxySocket = net.connect(target.port, 'localhost', () => { + // reconstruct the HTTP upgrade request + const reqLines = [ + `${req.method} ${req.url} HTTP/1.1`, + ...Object.entries(req.headers).map(([k, v]) => { + if (Array.isArray(v)) return `${k}: ${v.join(', ')}` + return `${k}: ${v}` + }), + '', + '', + ] + + proxySocket.write(reqLines.join('\r\n')) + if (head.length) { + proxySocket.write(head) + } + + // bidirectional pipe + socket.pipe(proxySocket) + proxySocket.pipe(socket) + }) + + proxySocket.on('error', (err) => { + console.error(`[daemon] WebSocket proxy error to port ${target.port}:`, err.message) + socket.end() + }) + + socket.on('error', (err) => { + console.error(`[daemon] Client socket error:`, err.message) + proxySocket.end() + }) + + socket.on('close', () => { + proxySocket.end() + }) + + proxySocket.on('close', () => { + socket.end() + }) +} diff --git a/packages/one/src/daemon/registry.ts b/packages/one/src/daemon/registry.ts new file mode 100644 index 000000000..cb925b417 --- /dev/null +++ b/packages/one/src/daemon/registry.ts @@ -0,0 +1,89 @@ +// server registry for daemon + +import type { ServerRegistration, RouteBinding, DaemonState } from './types' + +let idCounter = 0 + +export function createRegistry(): DaemonState { + return { + servers: new Map(), + routes: new Map(), + } +} + +export function registerServer( + state: DaemonState, + opts: { port: number; bundleId: string; root: string } +): ServerRegistration { + const id = `server-${++idCounter}` + const registration: ServerRegistration = { + id, + port: opts.port, + bundleId: opts.bundleId, + root: opts.root, + registeredAt: Date.now(), + } + state.servers.set(id, registration) + return registration +} + +export function unregisterServer(state: DaemonState, id: string): boolean { + const deleted = state.servers.delete(id) + // also remove any routes pointing to this server + for (const [key, route] of state.routes) { + if (route.serverId === id) { + state.routes.delete(key) + } + } + return deleted +} + +export function findServersByBundleId( + state: DaemonState, + bundleId: string +): ServerRegistration[] { + const matches: ServerRegistration[] = [] + for (const server of state.servers.values()) { + if (server.bundleId === bundleId) { + matches.push(server) + } + } + return matches +} + +export function findServerById( + state: DaemonState, + id: string +): ServerRegistration | undefined { + return state.servers.get(id) +} + +export function setRoute( + state: DaemonState, + key: string, + serverId: string +): RouteBinding { + const binding: RouteBinding = { + key, + serverId, + createdAt: Date.now(), + } + state.routes.set(key, binding) + return binding +} + +export function getRoute(state: DaemonState, key: string): RouteBinding | undefined { + return state.routes.get(key) +} + +export function clearRoute(state: DaemonState, key: string): boolean { + return state.routes.delete(key) +} + +export function getAllServers(state: DaemonState): ServerRegistration[] { + return Array.from(state.servers.values()) +} + +export function getAllRoutes(state: DaemonState): RouteBinding[] { + return Array.from(state.routes.values()) +} diff --git a/packages/one/src/daemon/server.ts b/packages/one/src/daemon/server.ts new file mode 100644 index 000000000..44510034c --- /dev/null +++ b/packages/one/src/daemon/server.ts @@ -0,0 +1,284 @@ +// main daemon HTTP/WebSocket server + +import * as http from 'node:http' +import type { DaemonState, ServerRegistration } from './types' +import { + createRegistry, + findServersByBundleId, + findServerById, + getAllServers, + getRoute, + setRoute, +} from './registry' +import { createIPCServer, getSocketPath, cleanupSocket } from './ipc' +import { proxyHttpRequest, proxyWebSocket } from './proxy' +import { pickServer, getBootedSimulators, resolvePendingPicker } from './picker' +import colors from 'picocolors' + +const DEFAULT_PORT = 8081 + +interface DaemonOptions { + port?: number + host?: string +} + +export async function startDaemon(options: DaemonOptions = {}) { + const port = options.port || DEFAULT_PORT + const host = options.host || '0.0.0.0' + + const state = createRegistry() + + // pending requests waiting for picker selection (for future use) + // const pendingRequests: Map = new Map() + + // start IPC server for CLI communication + const ipcServer = createIPCServer( + state, + (id) => { + const server = findServerById(state, id) + if (server) { + const shortRoot = server.root.replace(process.env.HOME || '', '~') + console.log( + colors.green(`[daemon] Server registered: ${server.bundleId} → :${server.port} (${shortRoot})`) + ) + } + }, + (id) => { + console.log(colors.yellow(`[daemon] Server unregistered: ${id}`)) + } + ) + + // create HTTP server + const httpServer = http.createServer(async (req, res) => { + // daemon management endpoints + if (req.url?.startsWith('/__daemon')) { + await handleDaemonEndpoint(req, res, state) + return + } + + // parse app from query string + const url = new URL(req.url || '/', `http://${req.headers.host}`) + const bundleId = url.searchParams.get('app') + + // if no bundleId, check if only one server is registered + const servers = bundleId + ? findServersByBundleId(state, bundleId) + : getAllServers(state) + + if (servers.length === 0) { + res.writeHead(404) + res.end( + bundleId + ? `No server registered for app: ${bundleId}` + : 'No servers registered with daemon' + ) + return + } + + if (servers.length === 1) { + // single match, proxy directly + proxyHttpRequest(req, res, servers[0]) + return + } + + // multiple matches - check for pre-configured route + const routeKey = bundleId || 'default' + const existingRoute = getRoute(state, routeKey) + + if (existingRoute) { + const server = findServerById(state, existingRoute.serverId) + if (server) { + proxyHttpRequest(req, res, server) + return + } + // route exists but server is gone, fall through to picker + } + + // check if running in CI/non-interactive mode - use first match with warning + if (!process.stdin.isTTY || process.env.CI) { + console.log( + colors.yellow( + `[daemon] Non-interactive mode: routing ${bundleId} to first match (${servers[0].root})` + ) + ) + console.log( + colors.yellow( + `[daemon] Use 'one daemon route --app=${bundleId} --slot=N' to configure routing` + ) + ) + proxyHttpRequest(req, res, servers[0]) + return + } + + // show picker with timeout for interactive selection + const PICKER_TIMEOUT = 30000 // 30 seconds + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Picker timeout')), PICKER_TIMEOUT) + }) + + try { + const { server, remember } = await Promise.race([ + pickServer(routeKey, servers), + timeoutPromise, + ]) + + if (remember) { + setRoute(state, routeKey, server.id) + console.log(colors.blue(`[daemon] Route saved: ${routeKey} → ${server.id}`)) + } + + proxyHttpRequest(req, res, server) + } catch (err) { + // picker cancelled or timed out - use first match + console.log( + colors.yellow( + `[daemon] Picker timeout/cancelled: routing to first match (${servers[0].root})` + ) + ) + proxyHttpRequest(req, res, servers[0]) + } + }) + + // handle WebSocket upgrades + httpServer.on('upgrade', async (req, rawSocket, head) => { + const socket = rawSocket as import('node:net').Socket + const url = new URL(req.url || '/', `http://${req.headers.host}`) + const bundleId = url.searchParams.get('app') + + const servers = bundleId + ? findServersByBundleId(state, bundleId) + : getAllServers(state) + + if (servers.length === 0) { + socket.end('HTTP/1.1 404 Not Found\r\n\r\n') + return + } + + let target: ServerRegistration | undefined + + if (servers.length === 1) { + target = servers[0] + } else { + // check for pre-configured route + const routeKey = bundleId || 'default' + const existingRoute = getRoute(state, routeKey) + + if (existingRoute) { + target = findServerById(state, existingRoute.serverId) + } + + if (!target) { + // for WebSocket, we can't really show a picker, so use first match + // or wait for an HTTP request to establish the route + console.log( + colors.yellow( + `[daemon] WebSocket upgrade with ambiguous route (${servers.length} servers), using first match` + ) + ) + target = servers[0] + } + } + + if (target) { + proxyWebSocket(req, socket, head, target) + } else { + socket.end('HTTP/1.1 404 Not Found\r\n\r\n') + } + }) + + // start listening + httpServer.listen(port, host, () => { + console.log(colors.cyan('\n═══════════════════════════════════════════════════')) + console.log(colors.cyan(' one daemon')) + console.log(colors.cyan('═══════════════════════════════════════════════════')) + console.log(`\n Listening on ${colors.green(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`)}`) + console.log(` IPC socket: ${colors.dim(getSocketPath())}`) + console.log('') + console.log(colors.dim(' Waiting for dev servers to register...')) + console.log(colors.dim(" Run 'one dev' in your project directories")) + console.log('') + }) + + // graceful shutdown + const shutdown = () => { + console.log(colors.yellow('\n[daemon] Shutting down...')) + httpServer.close() + ipcServer.close() + cleanupSocket() + process.exit(0) + } + + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + + return { + httpServer, + ipcServer, + state, + shutdown, + } +} + +async function handleDaemonEndpoint( + req: http.IncomingMessage, + res: http.ServerResponse, + state: DaemonState +) { + const url = new URL(req.url || '/', `http://${req.headers.host}`) + + // GET /__daemon/status + if (url.pathname === '/__daemon/status') { + const servers = getAllServers(state) + const simulators = await getBootedSimulators() + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify( + { + servers: servers.map((s) => ({ + id: s.id, + port: s.port, + bundleId: s.bundleId, + root: s.root, + })), + simulators, + }, + null, + 2 + ) + ) + return + } + + // POST /__daemon/route?bundleId=...&serverId=... + if (url.pathname === '/__daemon/route' && req.method === 'POST') { + const bundleId = url.searchParams.get('bundleId') + const serverId = url.searchParams.get('serverId') + + if (!bundleId || !serverId) { + res.writeHead(400) + res.end('Missing bundleId or serverId') + return + } + + const server = findServerById(state, serverId) + if (!server) { + res.writeHead(404) + res.end('Server not found') + return + } + + setRoute(state, bundleId, serverId) + + // also resolve any pending picker + resolvePendingPicker(bundleId, serverId) + + res.writeHead(200) + res.end('Route set') + return + } + + res.writeHead(404) + res.end('Not found') +} diff --git a/packages/one/src/daemon/types.ts b/packages/one/src/daemon/types.ts new file mode 100644 index 000000000..679b60306 --- /dev/null +++ b/packages/one/src/daemon/types.ts @@ -0,0 +1,38 @@ +// daemon types + +export interface ServerRegistration { + id: string + port: number + bundleId: string + root: string + registeredAt: number +} + +export interface RouteBinding { + // key is usually simulatorUDID or a session identifier + key: string + serverId: string + createdAt: number +} + +export interface DaemonState { + servers: Map + routes: Map +} + +// IPC message types +export type IPCMessage = + | { type: 'register'; port: number; bundleId: string; root: string } + | { type: 'unregister'; id: string } + | { type: 'route'; bundleId: string; serverId: string } + | { type: 'route-clear'; bundleId: string } + | { type: 'status' } + | { type: 'ping' } + +export type IPCResponse = + | { type: 'registered'; id: string } + | { type: 'unregistered' } + | { type: 'routed' } + | { type: 'status'; servers: ServerRegistration[]; routes: RouteBinding[] } + | { type: 'pong' } + | { type: 'error'; message: string } diff --git a/packages/one/src/daemon/utils.ts b/packages/one/src/daemon/utils.ts new file mode 100644 index 000000000..0dbe3b375 --- /dev/null +++ b/packages/one/src/daemon/utils.ts @@ -0,0 +1,94 @@ +// daemon utility functions + +import * as fs from 'node:fs' +import * as path from 'node:path' + +export interface AppConfig { + expo?: { + name?: string + slug?: string + ios?: { + bundleIdentifier?: string + } + android?: { + package?: string + } + } + // bare RN config + name?: string +} + +export function getBundleIdFromConfig(root: string): string | undefined { + const appJsonPath = path.join(root, 'app.json') + + if (!fs.existsSync(appJsonPath)) { + return undefined + } + + try { + const appConfig: AppConfig = JSON.parse(fs.readFileSync(appJsonPath, 'utf-8')) + + // try expo config first + if (appConfig.expo?.ios?.bundleIdentifier) { + return appConfig.expo.ios.bundleIdentifier + } + + if (appConfig.expo?.android?.package) { + return appConfig.expo.android.package + } + + // fallback to slug or name + if (appConfig.expo?.slug) { + return appConfig.expo.slug + } + + if (appConfig.expo?.name) { + return appConfig.expo.name.toLowerCase().replace(/\s+/g, '-') + } + + if (appConfig.name) { + return appConfig.name.toLowerCase().replace(/\s+/g, '-') + } + + return undefined + } catch { + return undefined + } +} + +const MAX_PORT = 65535 + +export function getAvailablePort(preferredPort: number, excludePort?: number): Promise { + return new Promise((resolve, reject) => { + // dynamic import to avoid top-level require + import('node:net').then((netModule) => { + const server = netModule.createServer() + + const tryPort = (port: number) => { + if (port > MAX_PORT) { + reject(new Error(`No available port found between ${preferredPort} and ${MAX_PORT}`)) + return + } + + if (port === excludePort) { + tryPort(port + 1) + return + } + + server.once('error', () => { + tryPort(port + 1) + }) + + server.once('listening', () => { + server.close(() => { + resolve(port) + }) + }) + + server.listen(port, '127.0.0.1') + } + + tryPort(preferredPort) + }) + }) +} diff --git a/plans/one-daemon.md b/plans/one-daemon.md new file mode 100644 index 000000000..23ed8d755 --- /dev/null +++ b/plans/one-daemon.md @@ -0,0 +1,232 @@ +# one daemon - Multi-App Development Server Proxy + +## Overview + +A daemon process that runs on port 8081 and proxies requests to multiple `one dev` servers, enabling simultaneous development of multiple React Native apps without port conflicts. + +## Problem + +- React Native apps expect Metro/bundler on port 8081 +- Running multiple apps requires manual port juggling +- Detox tests hardcode port 8081 +- Same app in multiple checkouts (~/tamagui, ~/tamagui2) have identical bundleIdentifiers + +## Solution + +A smart proxy daemon that: +1. Runs on port 8081 (the standard RN port) +2. Dev servers register with daemon on random ports +3. Routes requests based on `?app=bundleId` query param (already sent by RN!) +4. Shows interactive picker when routing is ambiguous +5. Supports pre-registration for Detox/automation + +## Key Discovery + +React Native iOS already sends bundle ID in requests: +```objc +// RCTBundleURLProvider.mm +NSString *bundleID = [[NSBundle mainBundle] objectForInfoDictionaryKey:kCFBundleIdentifierKey]; +[queryItems addObject:[[NSURLQueryItem alloc] initWithName:@"app" value:bundleID]]; +``` + +So requests look like: `/index.bundle?platform=ios&dev=true&app=com.tamagui.kitchensink` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ one daemon (port 8081) │ +│ │ +│ IPC: ~/.one/daemon.sock (Unix socket for CLI communication) │ +│ │ +│ Registry: │ +│ slot 0: {port: 51234, bundleId: 'com.foo.app', root: '~/a'} │ +│ slot 1: {port: 51235, bundleId: 'com.foo.app', root: '~/b'} │ +│ slot 2: {port: 51236, bundleId: 'com.bar.app', root: '~/c'} │ +│ │ +│ Routing Table (for ambiguous cases): │ +│ simulatorUDID-ABC → slot 0 │ +│ simulatorUDID-XYZ → slot 1 │ +│ │ +│ Request Flow: │ +│ 1. Parse ?app= from request │ +│ 2. Find matching slots │ +│ 3. If 1 match → proxy directly │ +│ 4. If 0 matches → 404 or fallback │ +│ 5. If >1 matches → check routing table or show picker │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## CLI Commands + +### `one daemon` +Starts the daemon (foreground, for development): +```bash +one daemon +# Listening on :8081 +# IPC socket: ~/.one/daemon.sock +``` + +### `one daemon start` +Starts daemon in background: +```bash +one daemon start +# Daemon started (pid 12345) +``` + +### `one daemon stop` +Stops background daemon: +```bash +one daemon stop +# Daemon stopped +``` + +### `one daemon status` +Shows daemon status and registered servers: +```bash +one daemon status +# Daemon running on :8081 (pid 12345) +# +# Registered servers: +# [0] com.tamagui.kitchensink → :51234 (~/tamagui) +# [1] com.tamagui.kitchensink → :51235 (~/tamagui2) +# [2] com.example.app → :51236 (~/myapp) +# +# Active routes: +# iPhone 15 (5728FED1...) → slot 0 +``` + +### `one daemon route` +Pre-configure routing (for Detox/CI): +```bash +# Route by slot number +one daemon route --app=com.foo.app --slot=1 + +# Route by project path +one daemon route --app=com.foo.app --project=~/tamagui2 + +# Clear routing +one daemon route --app=com.foo.app --clear +``` + +## Changes to `one dev` + +When daemon is running: +1. Detect daemon via IPC socket +2. Pick random available port (not 8081) +3. Register with daemon: `{port, bundleId, root}` +4. Show modified terminal output: + ``` + one dev server running + Local: http://localhost:51234 (direct) + Daemon: http://localhost:8081 (via daemon) + + Press 'qr' to show Expo Go QR code + ``` + +When daemon is NOT running: +- Behave as normal (use 8081 directly) + +## Changes to `one run:ios` / `one run:android` + +When launching app: +1. Get simulator/emulator UDID +2. Tell daemon: "route this UDID to my dev server's slot" +3. Launch app as normal + +This enables automatic routing even for identical bundleIds. + +## Interactive Picker + +When ambiguous request arrives and no pre-configured route: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 🔀 Multiple servers for com.tamagui.kitchensink │ +│ │ +│ Running simulators: │ +│ • iPhone 15 (iOS 18.1) │ +│ │ +│ Select project: │ +│ [1] ~/tamagui (port 51234) │ +│ [2] ~/tamagui2 (port 51235) │ +│ │ +│ Press 1-2, or 'r' + number to remember │ +└─────────────────────────────────────────────────────────┘ +``` + +The picker: +- Shows in the daemon's terminal +- Blocks the HTTP request until selection +- Can "remember" choice for session (binds to connection characteristics) + +## WebSocket Handling + +Must proxy WebSocket connections for: +- `/hot` - Metro HMR +- `/__hmr` - Vite native HMR +- `/__client` - Client logging +- `/inspector/*` - React Native DevTools + +WebSocket upgrade requests also include query params, so routing works the same. + +## Implementation Files + +``` +packages/one/src/cli/daemon.ts # CLI command +packages/one/src/daemon/ + index.ts # Main daemon server + registry.ts # Server registration + router.ts # Request routing logic + picker.ts # Interactive picker UI + ipc.ts # Unix socket IPC + proxy.ts # HTTP/WS proxying +``` + +## Edge Cases + +### No `?app=` parameter +- Older RN versions might not send it +- Fall back to: single server → use it, multiple → picker + +### Server disconnects +- Remove from registry +- Clear any routes pointing to it + +### Daemon crashes +- Dev servers should detect and fall back to direct mode +- Or retry connecting to daemon + +### Multiple daemons +- Lock file at ~/.one/daemon.lock prevents multiple instances + +## Detox Integration + +For Detox tests, pre-configure routing before test run: + +```javascript +// .detoxrc.js or test setup +beforeAll(async () => { + // Tell daemon to route our bundleId to specific slot + await exec('one daemon route --app=com.tamagui.kitchensink --slot=0'); +}); +``` + +Or via environment variable: +```bash +ONE_DAEMON_SLOT=0 detox test +``` + +## Future Enhancements + +- Web UI at `localhost:8081/__daemon` for status/management +- Auto-discovery of running `one dev` servers +- Multiple daemon instances on different ports +- Remote daemon (for team development) + +## Testing Plan + +1. Unit tests for router logic +2. Integration tests with mock servers +3. Manual testing with real simulators +4. Detox test to verify automation works diff --git a/scripts/test-daemon.mjs b/scripts/test-daemon.mjs new file mode 100644 index 000000000..ffed18412 --- /dev/null +++ b/scripts/test-daemon.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +/** + * Test script for the one daemon + * + * Tests: + * 1. Daemon starts on specified port + * 2. IPC socket communication works + * 3. Server registration works + * 4. HTTP proxy routing works + * 5. Multiple servers with same bundleId handled correctly + */ + +import * as http from 'node:http' +import * as net from 'node:net' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { spawn } from 'node:child_process' + +const DAEMON_PORT = 8888 +const SOCKET_PATH = path.join(os.homedir(), '.one', 'daemon.sock') + +// colors +const green = (s) => `\x1b[32m${s}\x1b[0m` +const red = (s) => `\x1b[31m${s}\x1b[0m` +const yellow = (s) => `\x1b[33m${s}\x1b[0m` +const cyan = (s) => `\x1b[36m${s}\x1b[0m` + +let testsPassed = 0 +let testsFailed = 0 + +function log(msg) { + console.log(` ${msg}`) +} + +function pass(name) { + testsPassed++ + console.log(`${green('✓')} ${name}`) +} + +function fail(name, error) { + testsFailed++ + console.log(`${red('✗')} ${name}`) + if (error) console.log(` ${red(error)}`) +} + +// helper to send IPC message +async function sendIPC(message) { + return new Promise((resolve, reject) => { + const client = net.connect(SOCKET_PATH) + let buffer = '' + + const timeout = setTimeout(() => { + client.destroy() + reject(new Error('IPC timeout')) + }, 5000) + + client.on('connect', () => { + client.write(JSON.stringify(message) + '\n') + }) + + client.on('data', (data) => { + buffer += data.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + try { + clearTimeout(timeout) + const response = JSON.parse(line) + client.destroy() + resolve(response) + return + } catch { + // continue + } + } + }) + + client.on('error', (err) => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +// helper to make HTTP request +async function httpGet(port, path) { + return new Promise((resolve, reject) => { + const req = http.get(`http://localhost:${port}${path}`, (res) => { + let data = '' + res.on('data', (chunk) => data += chunk) + res.on('end', () => resolve({ status: res.statusCode, data })) + }) + req.on('error', reject) + req.setTimeout(5000, () => { + req.destroy() + reject(new Error('HTTP timeout')) + }) + }) +} + +// create a simple test server that responds with its port +function createTestServer(port) { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ port, url: req.url })) + }) + server.listen(port, '127.0.0.1', () => resolve(server)) + }) +} + +async function runTests() { + console.log(cyan('\n═══════════════════════════════════════════════════')) + console.log(cyan(' one daemon test suite')) + console.log(cyan('═══════════════════════════════════════════════════\n')) + + let daemonProcess = null + let testServer1 = null + let testServer2 = null + + try { + // clean up any existing socket + if (fs.existsSync(SOCKET_PATH)) { + fs.unlinkSync(SOCKET_PATH) + } + + // start daemon + console.log(yellow('Starting daemon on port ' + DAEMON_PORT + '...\n')) + + daemonProcess = spawn('npx', ['one', 'daemon', '--port', String(DAEMON_PORT)], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: path.join(process.cwd(), 'examples', 'one-basic'), + }) + + daemonProcess.stdout.on('data', (data) => { + // log(`[daemon] ${data.toString().trim()}`) + }) + + daemonProcess.stderr.on('data', (data) => { + // log(`[daemon err] ${data.toString().trim()}`) + }) + + // wait for daemon to start + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Test 1: IPC ping + try { + const response = await sendIPC({ type: 'ping' }) + if (response.type === 'pong') { + pass('IPC ping/pong works') + } else { + fail('IPC ping/pong works', `Expected pong, got ${response.type}`) + } + } catch (err) { + fail('IPC ping/pong works', err.message) + } + + // Test 2: Register a server + let server1Id = null + try { + const response = await sendIPC({ + type: 'register', + port: 9001, + bundleId: 'com.test.app', + root: '/tmp/test-app-1', + }) + if (response.type === 'registered' && response.id) { + server1Id = response.id + pass('Server registration works') + } else { + fail('Server registration works', `Expected registered, got ${response.type}`) + } + } catch (err) { + fail('Server registration works', err.message) + } + + // Test 3: Status shows registered server + try { + const response = await sendIPC({ type: 'status' }) + if (response.type === 'status' && response.servers.length === 1) { + pass('Status shows registered server') + } else { + fail('Status shows registered server', `Expected 1 server, got ${response.servers?.length}`) + } + } catch (err) { + fail('Status shows registered server', err.message) + } + + // Test 4: Register second server with same bundleId + let server2Id = null + try { + const response = await sendIPC({ + type: 'register', + port: 9002, + bundleId: 'com.test.app', + root: '/tmp/test-app-2', + }) + if (response.type === 'registered' && response.id) { + server2Id = response.id + pass('Second server registration works') + } else { + fail('Second server registration works', `Expected registered, got ${response.type}`) + } + } catch (err) { + fail('Second server registration works', err.message) + } + + // Test 5: Status shows both servers + try { + const response = await sendIPC({ type: 'status' }) + if (response.type === 'status' && response.servers.length === 2) { + pass('Status shows both servers') + } else { + fail('Status shows both servers', `Expected 2 servers, got ${response.servers?.length}`) + } + } catch (err) { + fail('Status shows both servers', err.message) + } + + // Test 6: Set route to specific server + try { + const response = await sendIPC({ + type: 'route', + bundleId: 'com.test.app', + serverId: server2Id, + }) + if (response.type === 'routed') { + pass('Route setting works') + } else { + fail('Route setting works', `Expected routed, got ${response.type}`) + } + } catch (err) { + fail('Route setting works', err.message) + } + + // Test 7: Start actual test servers and verify HTTP routing + testServer1 = await createTestServer(9001) + testServer2 = await createTestServer(9002) + log('') + log('Started test servers on 9001 and 9002') + + try { + // request with ?app=com.test.app should route to server2 (port 9002) due to route setting + const response = await httpGet(DAEMON_PORT, '/test?app=com.test.app') + const data = JSON.parse(response.data) + if (data.port === 9002) { + pass('HTTP routing respects configured route') + } else { + fail('HTTP routing respects configured route', `Expected port 9002, got ${data.port}`) + } + } catch (err) { + fail('HTTP routing respects configured route', err.message) + } + + // Test 8: Unregister server + try { + const response = await sendIPC({ type: 'unregister', id: server1Id }) + if (response.type === 'unregistered') { + pass('Server unregistration works') + } else { + fail('Server unregistration works', `Expected unregistered, got ${response.type}`) + } + } catch (err) { + fail('Server unregistration works', err.message) + } + + // Test 9: Status shows only one server after unregister + try { + const response = await sendIPC({ type: 'status' }) + if (response.type === 'status' && response.servers.length === 1) { + pass('Status correct after unregister') + } else { + fail('Status correct after unregister', `Expected 1 server, got ${response.servers?.length}`) + } + } catch (err) { + fail('Status correct after unregister', err.message) + } + + // Test 10: Daemon management endpoint + try { + const response = await httpGet(DAEMON_PORT, '/__daemon/status') + const data = JSON.parse(response.data) + if (data.servers && Array.isArray(data.servers)) { + pass('Daemon status endpoint works') + } else { + fail('Daemon status endpoint works', 'Invalid response format') + } + } catch (err) { + fail('Daemon status endpoint works', err.message) + } + + } finally { + // cleanup + log('') + log(yellow('Cleaning up...')) + + if (testServer1) testServer1.close() + if (testServer2) testServer2.close() + + if (daemonProcess) { + daemonProcess.kill('SIGTERM') + await new Promise(r => setTimeout(r, 500)) + } + + // clean up socket + if (fs.existsSync(SOCKET_PATH)) { + try { fs.unlinkSync(SOCKET_PATH) } catch {} + } + } + + // summary + console.log(cyan('\n═══════════════════════════════════════════════════')) + console.log(` Tests: ${green(testsPassed + ' passed')}, ${testsFailed > 0 ? red(testsFailed + ' failed') : testsFailed + ' failed'}`) + console.log(cyan('═══════════════════════════════════════════════════\n')) + + process.exit(testsFailed > 0 ? 1 : 0) +} + +runTests().catch((err) => { + console.error(red('Test suite error:'), err) + process.exit(1) +}) From 2690a94838e3340d0a7e51dcd62c92a6860d18a4 Mon Sep 17 00:00:00 2001 From: natew Date: Sun, 25 Jan 2026 19:28:36 -1000 Subject: [PATCH 2/5] daemon: add macOS dialog picker with simulator info - native AppleScript dialog for server selection - shows iOS version from simulator runtime - dedupes simulators by name+version - improved terminal picker messaging --- packages/one/src/daemon/picker.ts | 118 ++++++++++++++++++++++++++++-- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/packages/one/src/daemon/picker.ts b/packages/one/src/daemon/picker.ts index bdf54eef1..87fa57824 100644 --- a/packages/one/src/daemon/picker.ts +++ b/packages/one/src/daemon/picker.ts @@ -19,20 +19,25 @@ let rl: readline.Interface | null = null let stdinDataListener: ((key: Buffer) => void) | null = null export async function getBootedSimulators(): Promise< - { name: string; udid: string; state: string }[] + { name: string; udid: string; state: string; iosVersion?: string }[] > { try { const { stdout } = await execAsync('xcrun simctl list devices booted -j') const data = JSON.parse(stdout) - const simulators: { name: string; udid: string; state: string }[] = [] + const simulators: { name: string; udid: string; state: string; iosVersion?: string }[] = [] + + for (const [runtime, devices] of Object.entries(data.devices || {})) { + // extract iOS version from runtime like "com.apple.CoreSimulator.SimRuntime.iOS-18-1" + const versionMatch = runtime.match(/iOS-(\d+)-(\d+)/) + const iosVersion = versionMatch ? `${versionMatch[1]}.${versionMatch[2]}` : undefined - for (const [_runtime, devices] of Object.entries(data.devices || {})) { for (const device of devices as any[]) { if (device.state === 'Booted') { simulators.push({ name: device.name, udid: device.udid, state: device.state, + iosVersion, }) } } @@ -43,24 +48,123 @@ export async function getBootedSimulators(): Promise< } } +// show native macOS dialog using AppleScript +async function showMacOSDialog( + bundleId: string, + servers: ServerRegistration[] +): Promise<{ server: ServerRegistration; remember: boolean } | null> { + if (process.platform !== 'darwin') { + return null + } + + // get running simulators for context + const simulators = await getBootedSimulators() + let simInfo = '' + if (simulators.length > 0) { + // dedupe by name+version, show unique simulators + const seen = new Set() + const uniqueSims: string[] = [] + for (const sim of simulators) { + const key = `${sim.name}-${sim.iosVersion || ''}` + if (!seen.has(key)) { + seen.add(key) + uniqueSims.push(sim.iosVersion ? `${sim.name} (iOS ${sim.iosVersion})` : sim.name) + } + } + if (uniqueSims.length === 1) { + // single simulator - we know exactly which one is requesting + simInfo = `\\n\\nFrom: ${uniqueSims[0]}` + } else { + // multiple simulators - show which might be requesting + simInfo = `\\n\\nActive simulators: ${uniqueSims.slice(0, 3).join(', ')}${uniqueSims.length > 3 ? '...' : ''}` + } + } + + const choices = servers.map((s, i) => { + const shortRoot = s.root.replace(process.env.HOME || '', '~') + return `${i + 1}. ${shortRoot} (port ${s.port})` + }) + + // escape quotes for AppleScript + const choicesStr = choices.map((c) => `"${c.replace(/"/g, '\\"')}"`).join(', ') + const prompt = `${bundleId} bundle requested${simInfo}\\n\\nWhich project should serve it?` + + const script = `choose from list {${choicesStr}} with title "one daemon" with prompt "${prompt}" default items {"${choices[0].replace(/"/g, '\\"')}"}` + + try { + const { stdout } = await execAsync(`osascript -e '${script}'`) + const result = stdout.trim() + + if (result === 'false' || !result) { + return null // cancelled + } + + // parse selection - format is "1. ~/path (port XXXX)" + const match = result.match(/^(\d+)\./) + if (match) { + const index = parseInt(match[1], 10) - 1 + if (index >= 0 && index < servers.length) { + return { server: servers[index], remember: false } + } + } + + return null + } catch { + return null + } +} + export function showPicker(context: PickerContext): void { activePickerContext = context + // try native macOS dialog first + if (process.platform === 'darwin') { + showMacOSDialog(context.bundleId, context.servers).then((result) => { + if (result) { + cleanupPicker() + context.onSelect(result.server, result.remember) + } else if (activePickerContext === context) { + // dialog cancelled or failed, fall back to terminal picker + showTerminalPicker(context) + } + }) + return + } + + showTerminalPicker(context) +} + +function showTerminalPicker(context: PickerContext): void { console.log('\n' + '─'.repeat(60)) - console.log(`🔀 Multiple servers for ${context.bundleId}`) + console.log(`🔀 ${context.bundleId} bundle requested`) console.log('─'.repeat(60)) // show running simulators for context getBootedSimulators().then((sims) => { if (sims.length > 0) { - console.log('\nRunning simulators:') + // dedupe by name+version + const seen = new Set() + const uniqueSims: { name: string; iosVersion?: string }[] = [] for (const sim of sims) { - console.log(` • ${sim.name} (${sim.udid.slice(0, 8)}...)`) + const key = `${sim.name}-${sim.iosVersion || ''}` + if (!seen.has(key)) { + seen.add(key) + uniqueSims.push(sim) + } + } + if (uniqueSims.length === 1) { + const sim = uniqueSims[0] + console.log(`\nFrom: ${sim.name}${sim.iosVersion ? ` (iOS ${sim.iosVersion})` : ''}`) + } else { + console.log('\nActive simulators:') + for (const sim of uniqueSims.slice(0, 5)) { + console.log(` • ${sim.name}${sim.iosVersion ? ` (iOS ${sim.iosVersion})` : ''}`) + } } } }) - console.log('\nSelect project:') + console.log('\nWhich project should serve it?') context.servers.forEach((server, i) => { const shortRoot = server.root.replace(process.env.HOME || '', '~') console.log(` [${i + 1}] ${shortRoot} (port ${server.port})`) From 0fdd0c867e9cba583b9dd753a6ed850a80e27de7 Mon Sep 17 00:00:00 2001 From: natew Date: Sun, 25 Jan 2026 19:28:42 -1000 Subject: [PATCH 3/5] daemon: auto-route to most recent server in CI - use most recently registered server for ambiguous routes - remember routes after auto-selection - add route mode override for TUI control - support quiet mode for TUI operation --- packages/one/src/daemon/server.ts | 83 ++++++++++++++++++------------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/one/src/daemon/server.ts b/packages/one/src/daemon/server.ts index 44510034c..30ac42292 100644 --- a/packages/one/src/daemon/server.ts +++ b/packages/one/src/daemon/server.ts @@ -20,16 +20,24 @@ const DEFAULT_PORT = 8081 interface DaemonOptions { port?: number host?: string + quiet?: boolean +} + +// allow TUI to override route mode +let routeModeOverride: 'most-recent' | 'ask' | null = null + +export function setRouteMode(mode: 'most-recent' | 'ask' | null) { + routeModeOverride = mode } export async function startDaemon(options: DaemonOptions = {}) { const port = options.port || DEFAULT_PORT const host = options.host || '0.0.0.0' + const quiet = options.quiet || false - const state = createRegistry() + const log = quiet ? (..._args: any[]) => {} : console.log - // pending requests waiting for picker selection (for future use) - // const pendingRequests: Map = new Map() + const state = createRegistry() // start IPC server for CLI communication const ipcServer = createIPCServer( @@ -38,13 +46,13 @@ export async function startDaemon(options: DaemonOptions = {}) { const server = findServerById(state, id) if (server) { const shortRoot = server.root.replace(process.env.HOME || '', '~') - console.log( + log( colors.green(`[daemon] Server registered: ${server.bundleId} → :${server.port} (${shortRoot})`) ) } }, (id) => { - console.log(colors.yellow(`[daemon] Server unregistered: ${id}`)) + log(colors.yellow(`[daemon] Server unregistered: ${id}`)) } ) @@ -94,19 +102,21 @@ export async function startDaemon(options: DaemonOptions = {}) { // route exists but server is gone, fall through to picker } - // check if running in CI/non-interactive mode - use first match with warning - if (!process.stdin.isTTY || process.env.CI) { - console.log( - colors.yellow( - `[daemon] Non-interactive mode: routing ${bundleId} to first match (${servers[0].root})` - ) - ) - console.log( + // check route mode - TUI can override, otherwise check CI/TTY + const useAutoRoute = routeModeOverride === 'most-recent' || + (!routeModeOverride && (!process.stdin.isTTY || process.env.CI)) + + if (useAutoRoute) { + // sort by registeredAt descending, pick most recent + const mostRecent = [...servers].sort((a, b) => b.registeredAt - a.registeredAt)[0] + // remember this route for subsequent requests + setRoute(state, routeKey, mostRecent.id) + log( colors.yellow( - `[daemon] Use 'one daemon route --app=${bundleId} --slot=N' to configure routing` + `[daemon] Auto-routing ${bundleId} → ${mostRecent.root} (most recent, remembered)` ) ) - proxyHttpRequest(req, res, servers[0]) + proxyHttpRequest(req, res, mostRecent) return } @@ -125,18 +135,19 @@ export async function startDaemon(options: DaemonOptions = {}) { if (remember) { setRoute(state, routeKey, server.id) - console.log(colors.blue(`[daemon] Route saved: ${routeKey} → ${server.id}`)) + log(colors.blue(`[daemon] Route saved: ${routeKey} → ${server.id}`)) } proxyHttpRequest(req, res, server) } catch (err) { - // picker cancelled or timed out - use first match - console.log( + // picker cancelled or timed out - use most recent + const mostRecent = [...servers].sort((a, b) => b.registeredAt - a.registeredAt)[0] + log( colors.yellow( - `[daemon] Picker timeout/cancelled: routing to first match (${servers[0].root})` + `[daemon] Picker timeout/cancelled: routing to most recent (${mostRecent.root})` ) ) - proxyHttpRequest(req, res, servers[0]) + proxyHttpRequest(req, res, mostRecent) } }) @@ -169,14 +180,16 @@ export async function startDaemon(options: DaemonOptions = {}) { } if (!target) { - // for WebSocket, we can't really show a picker, so use first match - // or wait for an HTTP request to establish the route - console.log( + // for WebSocket, we can't show a picker - use most recently registered server + const mostRecent = [...servers].sort((a, b) => b.registeredAt - a.registeredAt)[0] + // remember this route + setRoute(state, routeKey, mostRecent.id) + log( colors.yellow( - `[daemon] WebSocket upgrade with ambiguous route (${servers.length} servers), using first match` + `[daemon] WebSocket: routing ${bundleId} → ${mostRecent.root} (most recent, remembered)` ) ) - target = servers[0] + target = mostRecent } } @@ -189,20 +202,20 @@ export async function startDaemon(options: DaemonOptions = {}) { // start listening httpServer.listen(port, host, () => { - console.log(colors.cyan('\n═══════════════════════════════════════════════════')) - console.log(colors.cyan(' one daemon')) - console.log(colors.cyan('═══════════════════════════════════════════════════')) - console.log(`\n Listening on ${colors.green(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`)}`) - console.log(` IPC socket: ${colors.dim(getSocketPath())}`) - console.log('') - console.log(colors.dim(' Waiting for dev servers to register...')) - console.log(colors.dim(" Run 'one dev' in your project directories")) - console.log('') + log(colors.cyan('\n═══════════════════════════════════════════════════')) + log(colors.cyan(' one daemon')) + log(colors.cyan('═══════════════════════════════════════════════════')) + log(`\n Listening on ${colors.green(`http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`)}`) + log(` IPC socket: ${colors.dim(getSocketPath())}`) + log('') + log(colors.dim(' Waiting for dev servers to register...')) + log(colors.dim(" Run 'one dev' in your project directories")) + log('') }) // graceful shutdown const shutdown = () => { - console.log(colors.yellow('\n[daemon] Shutting down...')) + log(colors.yellow('\n[daemon] Shutting down...')) httpServer.close() ipcServer.close() cleanupSocket() From 8a0d79e582b857553750f8f2a06600a91dbae4ca Mon Sep 17 00:00:00 2001 From: natew Date: Sun, 25 Jan 2026 19:28:48 -1000 Subject: [PATCH 4/5] daemon: add terminal UI for interactive management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patchbay-style interface: simulators ↔ servers - mode toggle: most-recent vs ask - keyboard shortcuts for connect/disconnect - background mode and login item installation --- packages/one/src/daemon/tui.ts | 388 ++++++++++++++++++++++++ packages/one/types/daemon/index.d.ts | 8 + packages/one/types/daemon/ipc.d.ts | 29 ++ packages/one/types/daemon/picker.d.ts | 21 ++ packages/one/types/daemon/proxy.d.ts | 6 + packages/one/types/daemon/registry.d.ts | 16 + packages/one/types/daemon/server.d.ts | 16 + packages/one/types/daemon/tui.d.ts | 7 + packages/one/types/daemon/types.d.ts | 54 ++++ packages/one/types/daemon/utils.d.ts | 16 + 10 files changed, 561 insertions(+) create mode 100644 packages/one/src/daemon/tui.ts create mode 100644 packages/one/types/daemon/index.d.ts create mode 100644 packages/one/types/daemon/ipc.d.ts create mode 100644 packages/one/types/daemon/picker.d.ts create mode 100644 packages/one/types/daemon/proxy.d.ts create mode 100644 packages/one/types/daemon/registry.d.ts create mode 100644 packages/one/types/daemon/server.d.ts create mode 100644 packages/one/types/daemon/tui.d.ts create mode 100644 packages/one/types/daemon/types.d.ts create mode 100644 packages/one/types/daemon/utils.d.ts diff --git a/packages/one/src/daemon/tui.ts b/packages/one/src/daemon/tui.ts new file mode 100644 index 000000000..c27f56b3a --- /dev/null +++ b/packages/one/src/daemon/tui.ts @@ -0,0 +1,388 @@ +// minimal terminal UI for daemon - no deps, just ANSI +import type { DaemonState, ServerRegistration } from './types' +import { getAllServers, getRoute, setRoute } from './registry' +import { getBootedSimulators } from './picker' +import { setRouteMode } from './server' +import colors from 'picocolors' + +interface Simulator { + name: string + udid: string + iosVersion?: string +} + +type RouteMode = 'most-recent' | 'ask' + +interface TUIState { + simulators: Simulator[] + servers: ServerRegistration[] + routes: Map // sim udid -> server id + selectedCol: 0 | 1 // 0 = sims, 1 = servers + selectedRow: number + connectingFrom: string | null // sim udid when in connect mode + routeMode: RouteMode +} + +const ESC = '\x1b' +const CSI = `${ESC}[` + +// ansi helpers +const cursor = { + hide: () => process.stdout.write(`${CSI}?25l`), + show: () => process.stdout.write(`${CSI}?25h`), + to: (x: number, y: number) => process.stdout.write(`${CSI}${y};${x}H`), + clear: () => process.stdout.write(`${CSI}2J`), +} + +const box = { + tl: '┌', tr: '┐', bl: '└', br: '┘', + h: '─', v: '│', + arrow: '►', dot: '●', circle: '○', + line: '────────', +} + +let tuiState: TUIState | null = null +let daemonState: DaemonState | null = null +let refreshInterval: NodeJS.Timeout | null = null +let stdinListener: ((key: Buffer) => void) | null = null + +// export current route mode so server.ts can check it +export function getRouteMode(): RouteMode { + return tuiState?.routeMode || 'ask' +} + +export function startTUI(state: DaemonState): void { + daemonState = state + tuiState = { + simulators: [], + servers: [], + routes: new Map(), + selectedCol: 0, + selectedRow: 0, + connectingFrom: null, + routeMode: 'most-recent', + } + + // setup terminal + cursor.hide() + cursor.clear() + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true) + } + process.stdin.resume() + + // keyboard handler + stdinListener = (key: Buffer) => { + const str = key.toString() + + // ctrl+c or q to quit + if (str === '\u0003' || str === 'q') { + stopTUI() + process.exit(0) + } + + if (!tuiState) return + + // navigation + if (str === '\u001b[A') { // up + tuiState.selectedRow = Math.max(0, tuiState.selectedRow - 1) + } else if (str === '\u001b[B') { // down + const max = tuiState.selectedCol === 0 + ? tuiState.simulators.length - 1 + : tuiState.servers.length - 1 + tuiState.selectedRow = Math.min(max, tuiState.selectedRow + 1) + } else if (str === '\u001b[C' || str === '\u001b[D') { // left/right + tuiState.selectedCol = tuiState.selectedCol === 0 ? 1 : 0 + tuiState.selectedRow = 0 + tuiState.connectingFrom = null + } else if (str === ' ' || str === '\r') { // space or enter to connect + handleConnect() + } else if (str === 'd') { // disconnect + handleDisconnect() + } else if (str === 'm') { // toggle mode + tuiState.routeMode = tuiState.routeMode === 'most-recent' ? 'ask' : 'most-recent' + setRouteMode(tuiState.routeMode) + } else if (str === 'b') { // background - close TUI but keep daemon running + stopTUI() + console.log(colors.dim('\nDaemon running in background. Use `one daemon status` to check.')) + return + } else if (str === 'i') { // install as login item + installLoginItem() + } else if (str === 'r') { // remember/persist route + // TODO: persist to disk + } + + render() + } + + process.stdin.on('data', stdinListener) + + // refresh data every 500ms + refreshInterval = setInterval(refreshData, 500) + refreshData() +} + +function handleConnect(): void { + if (!tuiState || !daemonState) return + + if (tuiState.selectedCol === 0) { + // on simulator side - start connecting + const sim = tuiState.simulators[tuiState.selectedRow] + if (sim) { + tuiState.connectingFrom = sim.udid + tuiState.selectedCol = 1 + tuiState.selectedRow = 0 + } + } else if (tuiState.connectingFrom) { + // on server side with active connection - complete it + const server = tuiState.servers[tuiState.selectedRow] + if (server) { + tuiState.routes.set(tuiState.connectingFrom, server.id) + // also set in daemon state by bundleId + setRoute(daemonState, server.bundleId, server.id) + tuiState.connectingFrom = null + } + } +} + +function handleDisconnect(): void { + if (!tuiState) return + + if (tuiState.selectedCol === 0) { + const sim = tuiState.simulators[tuiState.selectedRow] + if (sim) { + tuiState.routes.delete(sim.udid) + } + } +} + +async function refreshData(): Promise { + if (!tuiState || !daemonState) return + + tuiState.simulators = await getBootedSimulators() + tuiState.servers = getAllServers(daemonState) + + render() +} + +function render(): void { + if (!tuiState) return + + const width = process.stdout.columns || 80 + const height = process.stdout.rows || 24 + + cursor.to(1, 1) + + const lines: string[] = [] + + // header with mode switch + const title = ' one daemon ' + const port = ':8081' + const modeRecent = tuiState.routeMode === 'most-recent' + const modeSwitch = ` [m] ${modeRecent ? colors.green('●') : colors.dim('○')} recent ${!modeRecent ? colors.green('●') : colors.dim('○')} ask ` + const modeSwitchLen = 22 // approx length without ansi + const headerPad = width - title.length - port.length - modeSwitchLen - 4 + lines.push(colors.cyan(`${box.tl}${box.h}${title}${box.h.repeat(Math.max(0, headerPad))}`) + modeSwitch + colors.cyan(`${port}${box.h}${box.tr}`)) + + // empty line + lines.push(colors.cyan(box.v) + ' '.repeat(width - 4) + colors.cyan(box.v)) + + // column headers + const col1 = ' SIMULATORS' + const col2 = 'SERVERS' + const mid = Math.floor(width / 2) + lines.push( + colors.cyan(box.v) + + colors.bold(colors.dim(col1)) + + ' '.repeat(mid - col1.length - 2) + + colors.bold(colors.dim(col2)) + + ' '.repeat(width - mid - col2.length - 4) + + colors.cyan(box.v) + ) + + // separator + lines.push( + colors.cyan(box.v) + + colors.dim(' ' + '─'.repeat(mid - 4)) + + ' ' + + colors.dim('─'.repeat(width - mid - 4)) + + colors.cyan(box.v) + ) + + // content rows + const maxRows = Math.max(tuiState.simulators.length, tuiState.servers.length, 3) + + for (let i = 0; i < maxRows; i++) { + const sim = tuiState.simulators[i] + const server = tuiState.servers[i] + + // sim column + let simStr = '' + if (sim) { + const isSelected = tuiState.selectedCol === 0 && tuiState.selectedRow === i + const isConnecting = tuiState.connectingFrom === sim.udid + const hasRoute = tuiState.routes.has(sim.udid) + + const dot = hasRoute ? box.dot : box.circle + const name = sim.name.slice(0, 16) + const ver = sim.iosVersion ? ` (${sim.iosVersion})` : '' + + let text = ` ${dot} ${name}${ver}` + if (isSelected) text = colors.inverse(text) + if (isConnecting) text = colors.yellow(text) + if (hasRoute) text = colors.green(text) + + simStr = text + } + + // connection line + let connStr = ' ' + const simUdid = sim?.udid + const connectedServerId = simUdid ? tuiState.routes.get(simUdid) : null + if (connectedServerId && server?.id === connectedServerId) { + connStr = colors.green(` ${box.line}${box.arrow} `) + } else if (tuiState.connectingFrom === simUdid) { + connStr = colors.yellow(` ${box.line}${box.arrow} `) + } + + // server column + let serverStr = '' + if (server) { + const isSelected = tuiState.selectedCol === 1 && tuiState.selectedRow === i + const hasConnection = [...tuiState.routes.values()].includes(server.id) + + const dot = hasConnection ? box.dot : box.circle + const shortRoot = server.root.replace(process.env.HOME || '', '~').slice(0, 18) + const port = `:${server.port}` + + let text = `${dot} ${shortRoot.padEnd(18)} ${colors.dim(port)}` + if (isSelected) text = colors.inverse(text) + if (hasConnection) text = colors.green(text) + + serverStr = text + } + + // assemble row + const simWidth = mid - 6 + const padSim = Math.max(0, simWidth - stripAnsi(simStr).length) + const serverWidth = width - mid - 6 + const padServer = Math.max(0, serverWidth - stripAnsi(serverStr).length) + + lines.push( + colors.cyan(box.v) + + simStr + ' '.repeat(padSim) + + connStr + + serverStr + ' '.repeat(padServer) + + colors.cyan(box.v) + ) + } + + // empty padding + const contentHeight = 4 + maxRows + const padRows = Math.max(0, height - contentHeight - 4) + for (let i = 0; i < padRows; i++) { + lines.push(colors.cyan(box.v) + ' '.repeat(width - 4) + colors.cyan(box.v)) + } + + // help lines + lines.push(colors.cyan(box.v) + ' '.repeat(width - 4) + colors.cyan(box.v)) + const help1 = ' [↑↓] select [←→] switch [space] connect [d] disconnect' + const help2 = ' [m] mode [b] background [i] install [q] quit' + lines.push( + colors.cyan(box.v) + + colors.dim(help1) + + ' '.repeat(Math.max(0, width - help1.length - 4)) + + colors.cyan(box.v) + ) + lines.push( + colors.cyan(box.v) + + colors.dim(help2) + + ' '.repeat(Math.max(0, width - help2.length - 4)) + + colors.cyan(box.v) + ) + + // footer + lines.push(colors.cyan(`${box.bl}${box.h.repeat(width - 4)}${box.br}`)) + + // output + process.stdout.write(lines.join('\n')) +} + +function stripAnsi(str: string): string { + return str.replace(/\x1b\[[0-9;]*m/g, '') +} + +async function installLoginItem(): Promise { + if (process.platform !== 'darwin') { + return + } + + const { exec } = await import('node:child_process') + const { promisify } = await import('node:util') + const execAsync = promisify(exec) + + // find one binary path + const onePath = process.argv[1] || 'npx one' + + // use osascript to add login item + const script = ` + tell application "System Events" + make login item at end with properties {path:"${onePath}", name:"one daemon", hidden:true} + end tell + ` + + try { + await execAsync(`osascript -e '${script}'`) + console.log(colors.green('\n✓ Added to login items')) + } catch (err) { + // try launchd plist instead + const plist = ` + + + + Label + com.one.daemon + ProgramArguments + + ${onePath} + daemon + --tui=false + + RunAtLoad + + KeepAlive + + +` + + const fs = await import('node:fs') + const path = await import('node:path') + const os = await import('node:os') + + const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.one.daemon.plist') + fs.writeFileSync(plistPath, plist) + + await execAsync(`launchctl load ${plistPath}`) + console.log(colors.green('\n✓ Installed as launchd agent')) + } +} + +export function stopTUI(): void { + if (refreshInterval) { + clearInterval(refreshInterval) + refreshInterval = null + } + if (stdinListener) { + process.stdin.removeListener('data', stdinListener) + stdinListener = null + } + cursor.show() + cursor.clear() + cursor.to(1, 1) + if (process.stdin.isTTY) { + process.stdin.setRawMode(false) + } + tuiState = null + daemonState = null +} diff --git a/packages/one/types/daemon/index.d.ts b/packages/one/types/daemon/index.d.ts new file mode 100644 index 000000000..5b5a6bd21 --- /dev/null +++ b/packages/one/types/daemon/index.d.ts @@ -0,0 +1,8 @@ +export * from './types'; +export * from './registry'; +export * from './ipc'; +export * from './proxy'; +export * from './picker'; +export * from './server'; +export * from './utils'; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/ipc.d.ts b/packages/one/types/daemon/ipc.d.ts new file mode 100644 index 000000000..819425e64 --- /dev/null +++ b/packages/one/types/daemon/ipc.d.ts @@ -0,0 +1,29 @@ +import * as net from 'node:net'; +import type { IPCMessage, IPCResponse, DaemonState } from './types'; +export declare function getSocketPath(): string; +export declare function ensureSocketDir(): void; +export declare function cleanupSocket(): void; +export declare function createIPCServer(state: DaemonState, onServerRegistered?: (id: string) => void, onServerUnregistered?: (id: string) => void): net.Server; +export declare function isDaemonRunning(): Promise; +export declare function sendIPCMessage(message: IPCMessage): Promise; +export declare function registerWithDaemon(opts: { + port: number; + bundleId: string; + root: string; +}): Promise; +export declare function unregisterFromDaemon(id: string): Promise; +export declare function getDaemonStatus(): Promise<{ + servers: { + id: string; + port: number; + bundleId: string; + root: string; + }[]; + routes: { + key: string; + serverId: string; + }[]; +}>; +export declare function setDaemonRoute(bundleId: string, serverId: string): Promise; +export declare function clearDaemonRoute(bundleId: string): Promise; +//# sourceMappingURL=ipc.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/picker.d.ts b/packages/one/types/daemon/picker.d.ts new file mode 100644 index 000000000..acdf72192 --- /dev/null +++ b/packages/one/types/daemon/picker.d.ts @@ -0,0 +1,21 @@ +import type { ServerRegistration } from './types'; +interface PickerContext { + bundleId: string; + servers: ServerRegistration[]; + onSelect: (server: ServerRegistration, remember: boolean) => void; + onCancel: () => void; +} +export declare function getBootedSimulators(): Promise<{ + name: string; + udid: string; + state: string; + iosVersion?: string; +}[]>; +export declare function showPicker(context: PickerContext): void; +export declare function resolvePendingPicker(bundleId: string, serverId: string): boolean; +export declare function pickServer(bundleId: string, servers: ServerRegistration[]): Promise<{ + server: ServerRegistration; + remember: boolean; +}>; +export {}; +//# sourceMappingURL=picker.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/proxy.d.ts b/packages/one/types/daemon/proxy.d.ts new file mode 100644 index 000000000..351ade630 --- /dev/null +++ b/packages/one/types/daemon/proxy.d.ts @@ -0,0 +1,6 @@ +import * as http from 'node:http'; +import * as net from 'node:net'; +import type { ServerRegistration } from './types'; +export declare function proxyHttpRequest(req: http.IncomingMessage, res: http.ServerResponse, target: ServerRegistration): void; +export declare function proxyWebSocket(req: http.IncomingMessage, socket: net.Socket, head: Buffer, target: ServerRegistration): void; +//# sourceMappingURL=proxy.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/registry.d.ts b/packages/one/types/daemon/registry.d.ts new file mode 100644 index 000000000..a6d96c83d --- /dev/null +++ b/packages/one/types/daemon/registry.d.ts @@ -0,0 +1,16 @@ +import type { ServerRegistration, RouteBinding, DaemonState } from './types'; +export declare function createRegistry(): DaemonState; +export declare function registerServer(state: DaemonState, opts: { + port: number; + bundleId: string; + root: string; +}): ServerRegistration; +export declare function unregisterServer(state: DaemonState, id: string): boolean; +export declare function findServersByBundleId(state: DaemonState, bundleId: string): ServerRegistration[]; +export declare function findServerById(state: DaemonState, id: string): ServerRegistration | undefined; +export declare function setRoute(state: DaemonState, key: string, serverId: string): RouteBinding; +export declare function getRoute(state: DaemonState, key: string): RouteBinding | undefined; +export declare function clearRoute(state: DaemonState, key: string): boolean; +export declare function getAllServers(state: DaemonState): ServerRegistration[]; +export declare function getAllRoutes(state: DaemonState): RouteBinding[]; +//# sourceMappingURL=registry.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/server.d.ts b/packages/one/types/daemon/server.d.ts new file mode 100644 index 000000000..cb9ced2da --- /dev/null +++ b/packages/one/types/daemon/server.d.ts @@ -0,0 +1,16 @@ +import * as http from 'node:http'; +import type { DaemonState } from './types'; +interface DaemonOptions { + port?: number; + host?: string; + quiet?: boolean; +} +export declare function setRouteMode(mode: 'most-recent' | 'ask' | null): void; +export declare function startDaemon(options?: DaemonOptions): Promise<{ + httpServer: http.Server; + ipcServer: import("net").Server; + state: DaemonState; + shutdown: () => never; +}>; +export {}; +//# sourceMappingURL=server.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/tui.d.ts b/packages/one/types/daemon/tui.d.ts new file mode 100644 index 000000000..d936a2b84 --- /dev/null +++ b/packages/one/types/daemon/tui.d.ts @@ -0,0 +1,7 @@ +import type { DaemonState } from './types'; +type RouteMode = 'most-recent' | 'ask'; +export declare function getRouteMode(): RouteMode; +export declare function startTUI(state: DaemonState): void; +export declare function stopTUI(): void; +export {}; +//# sourceMappingURL=tui.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/types.d.ts b/packages/one/types/daemon/types.d.ts new file mode 100644 index 000000000..cc9a6eef2 --- /dev/null +++ b/packages/one/types/daemon/types.d.ts @@ -0,0 +1,54 @@ +export interface ServerRegistration { + id: string; + port: number; + bundleId: string; + root: string; + registeredAt: number; +} +export interface RouteBinding { + key: string; + serverId: string; + createdAt: number; +} +export interface DaemonState { + servers: Map; + routes: Map; +} +export type IPCMessage = { + type: 'register'; + port: number; + bundleId: string; + root: string; +} | { + type: 'unregister'; + id: string; +} | { + type: 'route'; + bundleId: string; + serverId: string; +} | { + type: 'route-clear'; + bundleId: string; +} | { + type: 'status'; +} | { + type: 'ping'; +}; +export type IPCResponse = { + type: 'registered'; + id: string; +} | { + type: 'unregistered'; +} | { + type: 'routed'; +} | { + type: 'status'; + servers: ServerRegistration[]; + routes: RouteBinding[]; +} | { + type: 'pong'; +} | { + type: 'error'; + message: string; +}; +//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/packages/one/types/daemon/utils.d.ts b/packages/one/types/daemon/utils.d.ts new file mode 100644 index 000000000..37d61df2e --- /dev/null +++ b/packages/one/types/daemon/utils.d.ts @@ -0,0 +1,16 @@ +export interface AppConfig { + expo?: { + name?: string; + slug?: string; + ios?: { + bundleIdentifier?: string; + }; + android?: { + package?: string; + }; + }; + name?: string; +} +export declare function getBundleIdFromConfig(root: string): string | undefined; +export declare function getAvailablePort(preferredPort: number, excludePort?: number): Promise; +//# sourceMappingURL=utils.d.ts.map \ No newline at end of file From 8fe70de122f3873189fd8673361d3427fc4b2703 Mon Sep 17 00:00:00 2001 From: natew Date: Sun, 25 Jan 2026 19:28:55 -1000 Subject: [PATCH 5/5] daemon: enable TUI by default in TTY - --tui flag (default: true if TTY) - auto-start TUI when running interactively --- packages/one/src/cli.ts | 4 ++++ packages/one/src/cli/daemon.ts | 14 ++++++++++++-- packages/one/types/cli/daemon.d.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 packages/one/types/cli/daemon.d.ts diff --git a/packages/one/src/cli.ts b/packages/one/src/cli.ts index 861637fdd..855cd0d18 100644 --- a/packages/one/src/cli.ts +++ b/packages/one/src/cli.ts @@ -315,6 +315,10 @@ const daemonCommand = defineCommand({ type: 'string', description: 'Project path for route command', }, + tui: { + type: 'boolean', + description: 'Show TUI (default: true if TTY)', + }, }, async run({ args }) { const { daemon } = await import('./cli/daemon') diff --git a/packages/one/src/cli/daemon.ts b/packages/one/src/cli/daemon.ts index ecf932906..fa8a92262 100644 --- a/packages/one/src/cli/daemon.ts +++ b/packages/one/src/cli/daemon.ts @@ -10,6 +10,7 @@ export async function daemon(args: { app?: string slot?: string project?: string + tui?: boolean }) { const subcommand = args.subcommand || 'run' @@ -34,7 +35,7 @@ export async function daemon(args: { } } -async function daemonStart(args: { port?: string; host?: string }) { +async function daemonStart(args: { port?: string; host?: string; tui?: boolean }) { labelProcess('daemon') const { isDaemonRunning } = await import('../daemon/ipc') @@ -47,10 +48,19 @@ async function daemonStart(args: { port?: string; host?: string }) { const { startDaemon } = await import('../daemon/server') - await startDaemon({ + // default to TUI if running in interactive terminal + const useTUI = args.tui ?? process.stdin.isTTY + + const { state } = await startDaemon({ port: args.port ? parseInt(args.port, 10) : undefined, host: args.host, + quiet: useTUI, // suppress normal logs when TUI is active }) + + if (useTUI) { + const { startTUI } = await import('../daemon/tui') + startTUI(state) + } } async function daemonStop() { diff --git a/packages/one/types/cli/daemon.d.ts b/packages/one/types/cli/daemon.d.ts new file mode 100644 index 000000000..b0efbb9b4 --- /dev/null +++ b/packages/one/types/cli/daemon.d.ts @@ -0,0 +1,10 @@ +export declare function daemon(args: { + subcommand?: string; + port?: string; + host?: string; + app?: string; + slot?: string; + project?: string; + tui?: boolean; +}): Promise; +//# sourceMappingURL=daemon.d.ts.map \ No newline at end of file