From 3df362b2203e7a7adb0ffa9f391f47a70c1a9253 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 18 Jan 2026 18:18:42 +0200 Subject: [PATCH] feat(web): enable automatic port assignment if default is in use (#175) --- packages/web/bin/cli.js | 205 +++++++++++++++++++++-------------- packages/web/server/index.js | 9 +- 2 files changed, 131 insertions(+), 83 deletions(-) diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index cba35160..5c2c404e 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -179,7 +179,7 @@ ENVIRONMENT: OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag EXAMPLES: - openchamber # Start on default port 3000 + openchamber # Start on default port 3000 (or a free port) openchamber --port 8080 # Start on port 8080 openchamber serve --daemon # Start in background openchamber --try-cf-tunnel # Start with Cloudflare Quick Tunnel @@ -315,36 +315,40 @@ async function checkOpenCodeCLI() { } async function isPortAvailable(port) { + if (!Number.isFinite(port) || port <= 0) { + return false; + } + + // Don't specify host here; `server.listen(port)` in the web server also doesn't, + // and on some platforms (notably macOS) IPv4/IPv6 binding differences can make + // a 127.0.0.1-only probe report "free" while the real server bind fails. return await new Promise((resolve) => { const server = net.createServer(); server.unref(); server.on('error', () => resolve(false)); - server.listen({ port, host: '127.0.0.1' }, () => { + server.listen({ port }, () => { server.close(() => resolve(true)); }); }); } + async function resolveAvailablePort(desiredPort) { const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT; - // Only auto-pick when user didn't explicitly choose a port. + + // If user explicitly chose a port (incl 0), respect it. if (process.argv.includes('--port') || process.argv.includes('-p')) { return startPort; } - // If default is busy, probe upward a bit. + // Prefer the default port for predictable URLs, but fall back to an OS-assigned + // free port when it is already in use. if (await isPortAvailable(startPort)) { return startPort; } - for (let port = startPort + 1; port <= startPort + 50; port++) { - if (await isPortAvailable(port)) { - console.warn(`Port ${startPort} in use; using ${port}`); - return port; - } - } - - return startPort; + console.warn(`Port ${startPort} in use; using a free port`); + return 0; } async function getPidFilePath(port) { @@ -359,6 +363,7 @@ async function getInstanceFilePath(port) { return path.join(tmpDir, `openchamber-${port}.json`); } + function readPidFile(pidFilePath) { try { const content = fs.readFileSync(pidFilePath, 'utf8').trim(); @@ -446,14 +451,26 @@ function isProcessRunning(pid) { const commands = { async serve(options) { options.port = await resolveAvailablePort(options.port); - const pidFilePath = await getPidFilePath(options.port); - const instanceFilePath = await getInstanceFilePath(options.port); - const existingPid = readPidFile(pidFilePath); - if (existingPid && isProcessRunning(existingPid)) { - console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`); - console.error('Use "openchamber stop" to stop the existing instance'); - process.exit(1); + const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p'); + + // When using dynamic port (port=0), don't use pid/instance files - the port is + // unknown until the server binds. + if (options.port !== 0) { + const pidFilePath = await getPidFilePath(options.port); + const instanceFilePath = await getInstanceFilePath(options.port); + + const existingPid = readPidFile(pidFilePath); + if (existingPid && isProcessRunning(existingPid)) { + console.error(`Error: OpenChamber is already running on port ${options.port} (PID: ${existingPid})`); + console.error('Use "openchamber stop" to stop the existing instance'); + process.exit(1); + } + + // Persist for restart/update to reuse the chosen port. + writeInstanceOptions(instanceFilePath, { ...options }); + } else if (portWasSpecified) { + // Explicitly requested port=0; nothing to persist. } const opencodeBinary = await checkOpenCodeCLI(); @@ -480,10 +497,9 @@ const commands = { const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath; if (options.daemon) { - const child = spawn(runtimeBin, serverArgs, { detached: true, - stdio: 'ignore', + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: { ...process.env, OPENCHAMBER_PORT: options.port.toString(), @@ -495,76 +511,101 @@ const commands = { child.unref(); - setTimeout(() => { - if (isProcessRunning(child.pid)) { - writePidFile(pidFilePath, child.pid); - writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword }); - console.log(`OpenChamber started in daemon mode on port ${options.port}`); - console.log(`PID: ${child.pid}`); - console.log(`Visit: http://localhost:${options.port}`); - if (showAutoGeneratedPassword) { - console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); - console.log('āš ļø Save this password - it won\'t be shown again!\n'); + const resolvedPort = await new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + resolve(options.port); + }, 5000); + + child.on('message', (msg) => { + if (settled) return; + if (msg && msg.type === 'openchamber:ready' && typeof msg.port === 'number') { + settled = true; + clearTimeout(timeout); + resolve(msg.port); } - } else { - console.error('Failed to start server in daemon mode'); - process.exit(1); + }); + + child.on('exit', () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(options.port); + }); + }); + + if (isProcessRunning(child.pid)) { + const pidFilePathResolved = await getPidFilePath(resolvedPort); + const instanceFilePathResolved = await getInstanceFilePath(resolvedPort); + + writePidFile(pidFilePathResolved, child.pid); + writeInstanceOptions(instanceFilePathResolved, { ...options, port: resolvedPort, uiPassword: effectiveUiPassword }); + + console.log(`OpenChamber started in daemon mode on port ${resolvedPort}`); + console.log(`PID: ${child.pid}`); + console.log(`Visit: http://localhost:${resolvedPort}`); + if (showAutoGeneratedPassword) { + console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); + console.log('āš ļø Save this password - it won\'t be shown again!\n'); } - }, 1000); - - } else { - - process.env.OPENCODE_BINARY = opencodeBinary; - if (typeof effectiveUiPassword === 'string') { - process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; - } - if (showAutoGeneratedPassword) { - console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); - console.log('āš ļø Save this password - it won\'t be shown again!\n'); + } else { + console.error('Failed to start server in daemon mode'); + process.exit(1); } - writeInstanceOptions(instanceFilePath, { ...options, uiPassword: effectiveUiPassword }); + return; + } - // Prefer bun when installed (much faster PTY). If CLI is running under Node, - // run the server in a child process so Node doesn't have to load bun-pty. - if (preferredRuntime === 'bun' && !isBunRuntime()) { - const child = spawn(runtimeBin, serverArgs, { - stdio: 'inherit', - env: { - ...process.env, - OPENCHAMBER_PORT: options.port.toString(), - OPENCODE_BINARY: opencodeBinary, - ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), - OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', - }, - }); + process.env.OPENCODE_BINARY = opencodeBinary; + if (typeof effectiveUiPassword === 'string') { + process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; + } + if (showAutoGeneratedPassword) { + console.log(`\nšŸ” Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); + console.log('āš ļø Save this password - it won\'t be shown again!\n'); + } - child.on('exit', (code) => { - process.exit(typeof code === 'number' ? code : 1); - }); - - return; - } - - const { startWebUiServer } = await import(serverPath); - await startWebUiServer({ - port: options.port, - attachSignals: true, - exitOnShutdown: true, - uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, - tryCfTunnel: options.tryCfTunnel, - onTunnelReady: async (url) => { - const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl); - console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`); - if (options.tunnelPasswordUrl && effectiveUiPassword) { - console.log('šŸ”‘ Password is embedded in URL for auto-login\n'); - } - if (options.tunnelQr) { - await displayTunnelQrCode(displayUrl); - } + // Prefer bun when installed (much faster PTY). If CLI is running under Node, + // run the server in a child process so Node doesn't have to load bun-pty. + if (preferredRuntime === 'bun' && !isBunRuntime()) { + const child = spawn(runtimeBin, serverArgs, { + stdio: 'inherit', + env: { + ...process.env, + OPENCHAMBER_PORT: options.port.toString(), + OPENCODE_BINARY: opencodeBinary, + ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), + OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', }, }); + + child.on('exit', (code) => { + process.exit(typeof code === 'number' ? code : 1); + }); + + return; } + + const { startWebUiServer } = await import(serverPath); + await startWebUiServer({ + port: options.port, + attachSignals: true, + exitOnShutdown: true, + uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, + tryCfTunnel: options.tryCfTunnel, + onTunnelReady: async (url) => { + const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl); + console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`); + if (options.tunnelPasswordUrl && effectiveUiPassword) { + console.log('šŸ”‘ Password is embedded in URL for auto-login\n'); + } + if (options.tunnelQr) { + await displayTunnelQrCode(displayUrl); + } + }, + }); }, async stop(options) { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index cd90bfcf..afcf71e8 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1812,7 +1812,7 @@ async function main(options = {}) { exitOnShutdown = options.exitOnShutdown; } - console.log(`Starting OpenChamber on port ${port}`); + console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); const app = express(); expressApp = app; @@ -4779,6 +4779,13 @@ async function main(options = {}) { server.off('error', onError); const addressInfo = server.address(); activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port; + + try { + process.send?.({ type: 'openchamber:ready', port: activePort }); + } catch { + // ignore + } + console.log(`OpenChamber server running on port ${activePort}`); console.log(`Health check: http://localhost:${activePort}/health`); console.log(`Web interface: http://localhost:${activePort}`);