import fs from 'fs'; import { pathToFileURL } from 'url'; import { spawn } from 'child_process'; import { EXIT_CODE, TunnelCliError } from './cli-errors.js'; import { buildLocalUrl, resolveServeHost, assertSafeBrowserPort, resolveServeUiPassword, assertAuthenticatedNetworkExposure } from './cli-network.js'; import { fetchSystemInfoFromPort } from './cli-http.js'; import { isPortAvailable, resolveAvailablePort } from './cli-ports.js'; import { ensureLogsDir, getLogFilePath } from './cli-paths.js'; import { rotateLogFile } from './cli-log-files.js'; import { discoverOpenChamberInstanceOnPort, isDesktopRuntimeForPort } from './cli-lifecycle.js'; import { getPidFilePath, getInstanceFilePath, writePidFile, writeInstanceOptions, removePidFile, removeInstanceFile, isProcessRunning, terminateProcessTree } from './cli-process.js'; import { isNetworkExposedBindHost } from '../../server/lib/security/bind-host.js'; import { intro as clackIntro, outro as clackOutro, isJsonMode, isQuietMode, shouldRenderHumanOutput, createSpinner, printJson, logStatus, } from '../cli-output.js'; const DAEMON_READY_TIMEOUT_MS = 30000; function createServeCommand({ serverPath, bunBin, checkOpenCodeCLI, getPreferredServerRuntime, setForegroundServerActive, setForegroundShutdown, }) { async function serveCommand(options) { const showOutput = shouldRenderHumanOutput(options); const jsonMessages = []; const emitNotice = (notice) => { if (!notice || typeof notice !== 'object' || typeof notice.message !== 'string') return; const level = notice.level === 'error' ? 'error' : (notice.level === 'warning' ? 'warning' : 'info'); if (isJsonMode(options)) { jsonMessages.push({ level, code: notice.code, message: notice.message, }); return; } if (showOutput) { logStatus(level, notice.message); return; } if (!isQuietMode(options)) { const prefix = level === 'warning' ? 'Warning' : level === 'error' ? 'Error' : 'Info'; const line = `${prefix}: ${notice.message}`; if (level === 'error') { console.error(line); } else { console.warn(line); } } }; const explicitPort = options.explicitPort === true; const effectiveHost = resolveServeHost(options.host); const targetPort = await resolveAvailablePort(options.port, explicitPort, emitNotice); if (targetPort !== 0 && !options.suppressUnsafePortWarning) { assertSafeBrowserPort(targetPort, { context: 'OpenChamber serve' }); } if (targetPort !== 0) { const existingInstance = await discoverOpenChamberInstanceOnPort(targetPort, { host: effectiveHost }); if (existingInstance?.runtime === 'desktop') { throw new Error( `Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.` ); } if (existingInstance) { const pidSuffix = Number.isFinite(existingInstance.pid) ? ` (PID: ${existingInstance.pid})` : ''; if (existingInstance.source === 'probe') { throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`); } throw new Error(`OpenChamber is already running on port ${targetPort}${pidSuffix}`); } if (explicitPort && !(await isPortAvailable(targetPort, effectiveHost))) { const systemInfo = await fetchSystemInfoFromPort(targetPort, globalThis.fetch, effectiveHost); if (isDesktopRuntimeForPort(systemInfo, targetPort)) { throw new Error( `Port ${targetPort} is used by OpenChamber Desktop app. Choose another port or stop the desktop app.` ); } const systemInfoRuntimeMatchesPort = systemInfo?.runtime !== 'desktop' || isDesktopRuntimeForPort(systemInfo, targetPort); if (systemInfo?.runtime && systemInfoRuntimeMatchesPort) { throw new Error(`OpenChamber is already running on port ${targetPort}. Use \`openchamber status\` or \`openchamber stop --port ${targetPort}\`.`); } throw new Error(`Port ${targetPort} is already in use by another process.`); } } const opencodeBinary = await checkOpenCodeCLI(emitNotice); const preferredRuntime = getPreferredServerRuntime(); const runtimeBin = preferredRuntime === 'bun' ? bunBin : process.execPath; ensureLogsDir(); const initialLogPort = targetPort === 0 ? 'auto' : String(targetPort); const initialLogPath = getLogFilePath(initialLogPort); rotateLogFile(initialLogPath); const logFd = fs.openSync(initialLogPath, 'a'); // Resolve the effective UI password before either launch path so a // password generated for `--ui-password` (no value) is set in the // daemon/foreground environment before spawning and persisted in the // instance state file the server and restart/status flows read. const resolvedUiPassword = resolveServeUiPassword(options); const effectiveUiPassword = resolvedUiPassword.password; const autoGeneratedUiPassword = resolvedUiPassword.generated === true; assertAuthenticatedNetworkExposure({ host: effectiveHost, uiPassword: effectiveUiPassword, }); if (!effectiveUiPassword && !options.suppressUiPasswordWarning) { const bindHost = effectiveHost; const networkExposed = isNetworkExposedBindHost(bindHost); const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set'; const warningDetail = networkExposed ? `server is bound to ${bindHost} and reachable on your network with no UI auth. ` + 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN.' : 'browser UI is unsecured. Use --ui-password or OPENCHAMBER_UI_PASSWORD.'; if (showOutput) { logStatus('warning', warningLine, warningDetail); } else if (isJsonMode(options)) { emitNotice({ level: 'warning', code: 'UI_PASSWORD_MISSING', message: `${warningLine}; ${warningDetail}`, }); } else if (!isQuietMode(options)) { console.warn(`Warning: ${warningLine}; ${warningDetail}`); } } // Foreground mode: run server inline so the CLI process is the server process. // Required for process managers like systemd (Type=simple) that track the // direct child rather than a detached grandchild. // IMPORTANT: foreground MUST remain inline (in-process). Do not convert to // child-process orchestration — that causes shell job-control suspension. if (options.foreground) { if (isJsonMode(options)) { throw new TunnelCliError( '--json is not supported with --foreground. Use --json with background (daemon) mode instead.', EXIT_CODE.USAGE_ERROR ); } // Propagate resolved values into env before importing the server module. if (opencodeBinary) { process.env.OPENCODE_BINARY = opencodeBinary; } if (effectiveUiPassword) { process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } process.env.OPENCHAMBER_HOST = effectiveHost; process.env.OPENCHAMBER_RUNTIME = 'web'; // In --quiet mode, redirect stdout/stderr to the log file so that // server runtime output (console.log calls) does not pollute the // deterministic CLI output contract. In plain human mode, close the // log fd and let output go to the inherited terminal as before. const suppressServerOutput = isQuietMode(options); // Keep a reference to the real stdout.write so CLI output (port, JSON) // can bypass the log-file redirect. const realStdoutWrite = process.stdout.write.bind(process.stdout); if (suppressServerOutput) { const logStream = fs.createWriteStream(null, { fd: logFd }); process.stdout.write = (chunk, encoding, callback) => { return logStream.write(chunk, encoding, callback); }; process.stderr.write = (chunk, encoding, callback) => { return logStream.write(chunk, encoding, callback); }; } else { // Close the log fd – in foreground human mode stdout/stderr are // inherited from the parent (e.g. journald/terminal). try { fs.closeSync(logFd); } catch { } } if (!isQuietMode(options)) { console.log(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort} (foreground)`); } const { startWebUiServer } = await import(pathToFileURL(serverPath).href); const controller = await startWebUiServer({ port: targetPort, host: effectiveHost, uiPassword: effectiveUiPassword, apiOnly: options.apiOnly === true, attachSignals: false, exitOnShutdown: false, }); const resolvedPort = controller.getPort(); // Write PID / instance files so status, stop, and restart can discover // this foreground instance the same way they discover daemon instances. const fgPidFilePath = await getPidFilePath(resolvedPort); const fgInstanceFilePath = await getInstanceFilePath(resolvedPort); writePidFile(fgPidFilePath, process.pid, emitNotice); writeInstanceOptions(fgInstanceFilePath, { port: resolvedPort, host: effectiveHost, launchMode: 'foreground', uiPassword: effectiveUiPassword, apiOnly: options.apiOnly === true, }, emitNotice); if (isQuietMode(options)) { if (!options.suppressQuietOutput) { realStdoutWrite( autoGeneratedUiPassword ? `${resolvedPort} pass:${effectiveUiPassword}\n` : `${resolvedPort}\n` ); } } else if (autoGeneratedUiPassword && showOutput && !options.suppressStartupSummary) { console.log(`Generated UI password: ${effectiveUiPassword}`); console.log('Save this password — it is not shown again.'); } // Clean up PID / instance files. const cleanupFiles = () => { removePidFile(fgPidFilePath); removeInstanceFile(fgInstanceFilePath); }; process.on('exit', cleanupFiles); // Idempotent graceful shutdown with deterministic exit codes. let shutdownInProgress = false; const shutdownForegroundServer = async (signal = 'SIGTERM') => { if (shutdownInProgress) return; shutdownInProgress = true; try { await controller.stop({ exitProcess: false }); } catch { } cleanupFiles(); setForegroundServerActive(false); setForegroundShutdown(null); const exitCode = signal === 'SIGINT' ? 130 : signal === 'SIGQUIT' ? 131 : 143; process.exit(exitCode); }; // Expose shutdown to the global SIGINT handler. setForegroundShutdown(shutdownForegroundServer); setForegroundServerActive(true); // Register signal handlers (additive, no removeAllListeners). process.on('SIGINT', () => { void shutdownForegroundServer('SIGINT'); }); process.on('SIGTERM', () => { void shutdownForegroundServer('SIGTERM'); }); process.on('SIGQUIT', () => { void shutdownForegroundServer('SIGQUIT'); }); // Block forever – the process stays alive until signalled. await new Promise(() => {}); } const serverArgs = [serverPath, '--port', String(targetPort)]; serverArgs.push('--host', effectiveHost); if (options.apiOnly === true) { serverArgs.push('--api-only'); } const serveSpin = showOutput ? createSpinner(options) : null; const child = spawn(runtimeBin, serverArgs, { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd, 'ipc'], env: { ...process.env, OPENCHAMBER_PORT: String(targetPort), OPENCHAMBER_RUNTIME: 'web', OPENCODE_BINARY: opencodeBinary, OPENCHAMBER_HOST: effectiveHost, ...(effectiveUiPassword ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), ...(options.apiOnly === true ? { OPENCHAMBER_API_ONLY: 'true' } : {}), ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), }, }); child.unref(); serveSpin?.start(`Starting OpenChamber on port ${targetPort === 0 ? 'auto' : targetPort}...`); let resolvedPort; try { resolvedPort = await new Promise((resolve, reject) => { let settled = false; const timeout = setTimeout(() => { if (settled) return; settled = true; reject(new Error(`OpenChamber daemon did not report ready within ${DAEMON_READY_TIMEOUT_MS / 1000}s`)); }, DAEMON_READY_TIMEOUT_MS); child.on('message', (msg) => { if (settled) return; if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') { settled = true; clearTimeout(timeout); resolve(msg.port); } }); child.on('error', (error) => { if (settled) return; settled = true; clearTimeout(timeout); reject(error); }); child.on('exit', (code, signal) => { if (settled) return; settled = true; clearTimeout(timeout); reject(new Error(`OpenChamber daemon exited before reporting ready${signal ? ` (${signal})` : ` (code ${code ?? 'unknown'})`}`)); }); }); } catch (error) { await terminateProcessTree(child.pid, { gracefulTimeoutMs: 1500, forceTimeoutMs: 1500 }); throw error; } try { if (typeof child.disconnect === 'function' && child.connected) { child.disconnect(); } } catch { } try { fs.closeSync(logFd); } catch { } const resolvedLogPath = getLogFilePath(resolvedPort); if (initialLogPath !== resolvedLogPath && !fs.existsSync(resolvedLogPath)) { try { fs.renameSync(initialLogPath, resolvedLogPath); } catch { } } if (!isProcessRunning(child.pid)) { serveSpin?.error('Failed to start OpenChamber'); throw new Error('Failed to start server in daemon mode'); } const pidFilePath = await getPidFilePath(resolvedPort); const instanceFilePath = await getInstanceFilePath(resolvedPort); writePidFile(pidFilePath, child.pid, emitNotice); writeInstanceOptions(instanceFilePath, { port: resolvedPort, host: effectiveHost, launchMode: 'daemon', uiPassword: effectiveUiPassword, apiOnly: options.apiOnly === true, }, emitNotice); const serveResult = { port: resolvedPort, pid: child.pid, url: buildLocalUrl(resolvedPort, '/'), logs: `openchamber logs -p ${resolvedPort}`, launchMode: 'daemon', }; if (isJsonMode(options)) { printJson({ ...serveResult, messages: jsonMessages, ...(autoGeneratedUiPassword ? { password: effectiveUiPassword } : {}), }); return resolvedPort; } if (isQuietMode(options)) { if (options.suppressQuietOutput) { return resolvedPort; } // A generated password is essential result data for scripts: include it // in the same compact `pass:` token form `openchamber status --quiet` // already emits. Configured passwords are never echoed. process.stdout.write( autoGeneratedUiPassword ? `${resolvedPort} pass:${effectiveUiPassword}\n` : `${resolvedPort}\n` ); return resolvedPort; } serveSpin?.clear(); if (!options.suppressStartupSummary && showOutput) { clackIntro('OpenChamber Started'); logStatus('success', `port ${serveResult.port} (PID: ${serveResult.pid})`); if (autoGeneratedUiPassword) { logStatus('success', 'UI password', effectiveUiPassword); logStatus('warning', 'save this password', 'it is not shown again'); } logStatus('info', `visit: ${serveResult.url}`); logStatus('info', `logs: ${serveResult.logs}`); clackOutro('daemon running'); } return resolvedPort; } return serveCommand; } export { createServeCommand };