#!/usr/bin/env node import path from 'path'; import fs from 'fs'; import net from 'net'; import { spawn, spawnSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DEFAULT_PORT = 3000; const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); function getBunBinary() { if (typeof process.env.BUN_BINARY === 'string' && process.env.BUN_BINARY.trim().length > 0) { return process.env.BUN_BINARY.trim(); } if (typeof process.env.BUN_INSTALL === 'string' && process.env.BUN_INSTALL.trim().length > 0) { return path.join(process.env.BUN_INSTALL.trim(), 'bin', 'bun'); } return 'bun'; } const BUN_BIN = getBunBinary(); function importFromFilePath(filePath) { return import(pathToFileURL(filePath).href); } function isBunRuntime() { return typeof globalThis.Bun !== 'undefined'; } function isBunInstalled() { try { const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env }); return result.status === 0; } catch { return false; } } function getPreferredServerRuntime() { return isBunInstalled() ? 'bun' : 'node'; } function generateRandomPassword(length = 16) { const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; let password = ''; for (let i = 0; i < length; i++) { const randomIndex = Math.floor(Math.random() * charset.length); password += charset[randomIndex]; } return password; } function parseArgs() { const args = process.argv.slice(2); const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined; const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword }; let command = 'serve'; const consumeValue = (currentIndex, inlineValue) => { if (typeof inlineValue === 'string' && inlineValue.length > 0) { return { value: inlineValue, nextIndex: currentIndex }; } const candidate = args[currentIndex + 1]; if (typeof candidate === 'string' && !candidate.startsWith('-')) { return { value: candidate, nextIndex: currentIndex + 1 }; } return { value: undefined, nextIndex: currentIndex }; }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith('-')) { let optionName; let inlineValue; if (arg.startsWith('--')) { const eqIndex = arg.indexOf('='); optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2); inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined; } else { optionName = arg.slice(1); inlineValue = undefined; } switch (optionName) { case 'port': case 'p': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; const parsed = parseInt(value ?? '', 10); options.port = Number.isFinite(parsed) ? parsed : DEFAULT_PORT; break; } case 'daemon': case 'd': options.daemon = true; break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.uiPassword = typeof value === 'string' ? value : ''; break; } case 'help': case 'h': showHelp(); process.exit(0); break; case 'version': case 'v': console.log(PACKAGE_JSON.version); process.exit(0); break; } } else { command = arg; } } return { command, options }; } function showHelp() { console.log(` OpenChamber - Web interface for the OpenCode AI coding agent USAGE: openchamber [COMMAND] [OPTIONS] COMMANDS: serve Start the web server (default) stop Stop running instance(s) restart Stop and start the server status Show server status OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) --ui-password Protect browser UI with single password -d, --daemon Run in background (serve command) -h, --help Show help -v, --version Show version ENVIRONMENT: OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 (overrides OPENCODE_PORT) OPENCODE_PORT Port of external OpenCode server to connect to OPENCODE_SKIP_START Skip starting OpenCode, use external server EXAMPLES: openchamber # Start on default port 3000 (or a free port) openchamber --port 8080 # Start on port 8080 openchamber serve --daemon # Start in background openchamber stop # Stop all running instances openchamber stop --port 3000 # Stop specific instance openchamber status # Check status `); } const WINDOWS_EXTENSIONS = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM') .split(';') .map((ext) => ext.trim().toLowerCase()) .filter(Boolean) .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)) : ['']; function isExecutable(filePath) { try { const stats = fs.statSync(filePath); if (!stats.isFile()) { return false; } if (process.platform === 'win32') { return true; } fs.accessSync(filePath, fs.constants.X_OK); return true; } catch (error) { return false; } } function resolveExplicitBinary(candidate) { if (!candidate) { return null; } if (candidate.includes(path.sep) || path.isAbsolute(candidate)) { const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate); return isExecutable(resolved) ? resolved : null; } return null; } function searchPathFor(command) { const pathValue = process.env.PATH || ''; const segments = pathValue.split(path.delimiter).filter(Boolean); for (const dir of segments) { for (const ext of WINDOWS_EXTENSIONS) { const fileName = process.platform === 'win32' ? `${command}${ext}` : command; const candidate = path.join(dir, fileName); if (isExecutable(candidate)) { return candidate; } } } return null; } async function checkOpenCodeCLI() { if (process.env.OPENCODE_BINARY) { const override = resolveExplicitBinary(process.env.OPENCODE_BINARY); if (override) { process.env.OPENCODE_BINARY = override; return override; } console.warn(`Warning: OPENCODE_BINARY="${process.env.OPENCODE_BINARY}" is not an executable file. Falling back to PATH lookup.`); } const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { process.env.OPENCODE_BINARY = resolvedFromPath; return resolvedFromPath; } if (process.platform !== 'win32') { const shellCandidates = []; if (process.env.SHELL) { shellCandidates.push(process.env.SHELL); } shellCandidates.push('/bin/bash', '/bin/zsh', '/bin/sh'); for (const shellPath of shellCandidates) { if (!shellPath || !isExecutable(shellPath)) { continue; } try { const result = spawnSync(shellPath, ['-lic', 'command -v opencode'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); if (result.status === 0) { const candidate = result.stdout.trim().split(/\s+/).pop(); if (candidate && isExecutable(candidate)) { const dir = path.dirname(candidate); const currentPath = process.env.PATH || ''; const segments = currentPath.split(path.delimiter).filter(Boolean); if (!segments.includes(dir)) { segments.unshift(dir); process.env.PATH = segments.join(path.delimiter); } process.env.OPENCODE_BINARY = candidate; return candidate; } } } catch (error) { } } } else { try { const result = spawnSync('where', ['opencode'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); if (result.status === 0) { const candidate = result.stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line.length > 0); if (candidate && isExecutable(candidate)) { process.env.OPENCODE_BINARY = candidate; return candidate; } } } catch (error) { } } console.error('Error: Unable to locate the opencode CLI on PATH.'); console.error(`Current PATH: ${process.env.PATH || ''}`); console.error('Ensure the CLI is installed and reachable, or set OPENCODE_BINARY to its full path.'); process.exit(1); } 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 }, () => { server.close(() => resolve(true)); }); }); } async function resolveAvailablePort(desiredPort) { const startPort = Number.isFinite(desiredPort) ? Math.trunc(desiredPort) : DEFAULT_PORT; // If user explicitly chose a port (incl 0), respect it. if (process.argv.includes('--port') || process.argv.includes('-p')) { return startPort; } // 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; } console.warn(`Port ${startPort} in use; using a free port`); return 0; } async function getPidFilePath(port) { const os = await import('os'); const tmpDir = os.tmpdir(); return path.join(tmpDir, `openchamber-${port}.pid`); } async function getInstanceFilePath(port) { const os = await import('os'); const tmpDir = os.tmpdir(); return path.join(tmpDir, `openchamber-${port}.json`); } function readPidFile(pidFilePath) { try { const content = fs.readFileSync(pidFilePath, 'utf8').trim(); const pid = parseInt(content); if (isNaN(pid)) { return null; } return pid; } catch (error) { return null; } } function writePidFile(pidFilePath, pid) { try { fs.writeFileSync(pidFilePath, pid.toString()); } catch (error) { console.warn(`Warning: Could not write PID file: ${error.message}`); } } function removePidFile(pidFilePath) { try { if (fs.existsSync(pidFilePath)) { fs.unlinkSync(pidFilePath); } } catch (error) { console.warn(`Warning: Could not remove PID file: ${error.message}`); } } /** * Read stored instance options (port, daemon, uiPassword) */ function readInstanceOptions(instanceFilePath) { try { const content = fs.readFileSync(instanceFilePath, 'utf8'); return JSON.parse(content); } catch (error) { return null; } } /** * Write instance options for restart/update to reuse */ function writeInstanceOptions(instanceFilePath, options) { try { // Only store non-sensitive restart-relevant options const toStore = { port: options.port, daemon: options.daemon || false, // Store password existence but not value - will use env var hasUiPassword: typeof options.uiPassword === 'string', }; // For daemon mode, we need to store the password to restart properly if (options.daemon && typeof options.uiPassword === 'string') { toStore.uiPassword = options.uiPassword; } fs.writeFileSync(instanceFilePath, JSON.stringify(toStore, null, 2)); } catch (error) { console.warn(`Warning: Could not write instance file: ${error.message}`); } } function removeInstanceFile(instanceFilePath) { try { if (fs.existsSync(instanceFilePath)) { fs.unlinkSync(instanceFilePath); } } catch (error) { // Ignore } } function isProcessRunning(pid) { try { process.kill(pid, 0); return true; } catch (error) { return false; } } async function requestServerShutdown(port) { if (!Number.isFinite(port) || port <= 0) return false; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1500); try { const resp = await fetch(`http://127.0.0.1:${port}/api/system/shutdown`, { method: 'POST', signal: controller.signal, }); return resp.ok; } catch { return false; } finally { clearTimeout(timeout); } } const commands = { async serve(options) { options.port = await resolveAvailablePort(options.port); 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(); const serverPath = path.join(__dirname, '..', 'server', 'index.js'); const effectiveUiPassword = options.uiPassword; const serverArgs = [serverPath, '--port', options.port.toString()]; if (typeof effectiveUiPassword === 'string') { serverArgs.push('--ui-password', effectiveUiPassword); } const preferredRuntime = getPreferredServerRuntime(); const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath; if (options.daemon) { const child = spawn(runtimeBin, serverArgs, { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], env: { ...process.env, OPENCHAMBER_PORT: options.port.toString(), OPENCODE_BINARY: opencodeBinary, ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), } }); child.unref(); 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); } }); child.on('exit', () => { if (settled) return; settled = true; clearTimeout(timeout); resolve(options.port); }); }); // Important: in daemon mode we must close the IPC channel, otherwise the CLI // process can hang around as the parent of the detached server. try { child.removeAllListeners('message'); child.removeAllListeners('exit'); if (typeof child.disconnect === 'function' && child.connected) { child.disconnect(); } } catch { // ignore } 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'); } } else { console.error('Failed to start server in daemon mode'); process.exit(1); } return; } process.env.OPENCODE_BINARY = opencodeBinary; if (typeof effectiveUiPassword === 'string') { process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } if (process.env.OPENCODE_SKIP_START) { process.env.OPENCHAMBER_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START; } // 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 } : {}), ...(process.env.OPENCODE_SKIP_START ? { OPENCHAMBER_SKIP_OPENCODE_START: process.env.OPENCODE_SKIP_START } : {}), }, }); child.on('exit', (code) => { process.exit(typeof code === 'number' ? code : 1); }); return; } const { startWebUiServer } = await importFromFilePath(serverPath); await startWebUiServer({ port: options.port, attachSignals: true, exitOnShutdown: true, uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, }); }, async stop(options) { const os = await import('os'); const tmpDir = os.tmpdir(); let runningInstances = []; try { const files = fs.readdirSync(tmpDir); const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid')); for (const file of pidFiles) { const port = parseInt(file.replace('openchamber-', '').replace('.pid', '')); if (!isNaN(port)) { const pidFilePath = path.join(tmpDir, file); const pid = readPidFile(pidFilePath); if (pid && isProcessRunning(pid)) { const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); runningInstances.push({ port, pid, pidFilePath, instanceFilePath }); } else { removePidFile(pidFilePath); removeInstanceFile(path.join(tmpDir, `openchamber-${port}.json`)); } } } } catch (error) { } if (runningInstances.length === 0) { console.log('No running OpenChamber instances found'); return; } const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p'); if (portWasSpecified) { const targetInstance = runningInstances.find(inst => inst.port === options.port); if (!targetInstance) { console.log(`No OpenChamber instance found running on port ${options.port}`); return; } console.log(`Stopping OpenChamber (PID: ${targetInstance.pid}, Port: ${targetInstance.port})...`); try { await requestServerShutdown(targetInstance.port); process.kill(targetInstance.pid, 'SIGTERM'); let attempts = 0; const maxAttempts = 10; const checkShutdown = setInterval(() => { attempts++; if (!isProcessRunning(targetInstance.pid)) { clearInterval(checkShutdown); removePidFile(targetInstance.pidFilePath); removeInstanceFile(targetInstance.instanceFilePath); console.log('OpenChamber stopped successfully'); } else if (attempts >= maxAttempts) { clearInterval(checkShutdown); console.log('Force killing process...'); process.kill(targetInstance.pid, 'SIGKILL'); removePidFile(targetInstance.pidFilePath); removeInstanceFile(targetInstance.instanceFilePath); console.log('OpenChamber force stopped'); } }, 500); } catch (error) { console.error(`Error stopping process: ${error.message}`); process.exit(1); } } else { console.log(`Stopping all OpenChamber instances (${runningInstances.length} found)...`); for (const instance of runningInstances) { console.log(` Stopping instance on port ${instance.port} (PID: ${instance.pid})...`); try { await requestServerShutdown(instance.port); process.kill(instance.pid, 'SIGTERM'); let attempts = 0; const maxAttempts = 10; await new Promise((resolve) => { const checkShutdown = setInterval(() => { attempts++; if (!isProcessRunning(instance.pid)) { clearInterval(checkShutdown); removePidFile(instance.pidFilePath); removeInstanceFile(instance.instanceFilePath); console.log(` Port ${instance.port} stopped successfully`); resolve(true); } else if (attempts >= maxAttempts) { clearInterval(checkShutdown); console.log(` Force killing port ${instance.port}...`); try { process.kill(instance.pid, 'SIGKILL'); removePidFile(instance.pidFilePath); removeInstanceFile(instance.instanceFilePath); console.log(` Port ${instance.port} force stopped`); } catch (e) { } resolve(true); } }, 500); }); } catch (error) { console.error(` Error stopping port ${instance.port}: ${error.message}`); } } console.log('\nAll OpenChamber instances stopped'); } }, async restart(options) { const os = await import('os'); const tmpDir = os.tmpdir(); // Find running instances to get their stored options let instancesToRestart = []; try { const files = fs.readdirSync(tmpDir); const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid')); for (const file of pidFiles) { const port = parseInt(file.replace('openchamber-', '').replace('.pid', '')); if (!isNaN(port)) { const pidFilePath = path.join(tmpDir, file); const instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); const pid = readPidFile(pidFilePath); if (pid && isProcessRunning(pid)) { const storedOptions = readInstanceOptions(instanceFilePath); instancesToRestart.push({ port, pid, pidFilePath, instanceFilePath, storedOptions: storedOptions || { port, daemon: false }, }); } } } } catch (error) { // Ignore } const portWasSpecified = process.argv.includes('--port') || process.argv.includes('-p'); if (instancesToRestart.length === 0) { console.log('No running OpenChamber instances to restart'); console.log('Use "openchamber serve" to start a new instance'); return; } if (portWasSpecified) { // Restart specific instance const target = instancesToRestart.find(inst => inst.port === options.port); if (!target) { console.log(`No OpenChamber instance found running on port ${options.port}`); return; } instancesToRestart = [target]; } for (const instance of instancesToRestart) { console.log(`Restarting OpenChamber on port ${instance.port}...`); // Merge stored options with any explicitly provided options const restartOptions = { ...instance.storedOptions, // CLI-provided options override stored ones ...(portWasSpecified ? { port: options.port } : {}), ...(process.argv.includes('--daemon') || process.argv.includes('-d') ? { daemon: options.daemon } : {}), ...(process.argv.includes('--ui-password') ? { uiPassword: options.uiPassword } : {}), }; // Stop the instance try { await requestServerShutdown(instance.port); process.kill(instance.pid, 'SIGTERM'); // Wait for it to stop let attempts = 0; while (isProcessRunning(instance.pid) && attempts < 20) { await new Promise(resolve => setTimeout(resolve, 250)); attempts++; } if (isProcessRunning(instance.pid)) { process.kill(instance.pid, 'SIGKILL'); } removePidFile(instance.pidFilePath); } catch (error) { console.warn(`Warning: Could not stop instance: ${error.message}`); } // Small delay before restart await new Promise(resolve => setTimeout(resolve, 500)); // Start with merged options await commands.serve(restartOptions); } }, async status() { const os = await import('os'); const tmpDir = os.tmpdir(); let runningInstances = []; let stoppedInstances = []; try { const files = fs.readdirSync(tmpDir); const pidFiles = files.filter(file => file.startsWith('openchamber-') && file.endsWith('.pid')); for (const file of pidFiles) { const port = parseInt(file.replace('openchamber-', '').replace('.pid', '')); if (!isNaN(port)) { const pidFilePath = path.join(tmpDir, file); const pid = readPidFile(pidFilePath); if (pid && isProcessRunning(pid)) { runningInstances.push({ port, pid, pidFilePath }); } else { removePidFile(pidFilePath); stoppedInstances.push({ port }); } } } } catch (error) { } if (runningInstances.length === 0) { console.log('OpenChamber Status:'); console.log(' Status: Stopped'); if (stoppedInstances.length > 0) { console.log(` Previously used ports: ${stoppedInstances.map(s => s.port).join(', ')}`); } return; } console.log('OpenChamber Status:'); for (const [index, instance] of runningInstances.entries()) { if (runningInstances.length > 1) { console.log(`\nInstance ${index + 1}:`); } console.log(' Status: Running'); console.log(` PID: ${instance.pid}`); console.log(` Port: ${instance.port}`); console.log(` Visit: http://localhost:${instance.port}`); try { const { execSync } = await import('child_process'); const startTime = execSync(`ps -o lstart= -p ${instance.pid}`, { encoding: 'utf8' }).trim(); console.log(` Start Time: ${startTime}`); } catch (error) { } } }, }; async function main() { const { command, options } = parseArgs(); if (!commands[command]) { console.error(`Error: Unknown command '${command}'`); console.error('Use --help to see available commands'); process.exit(1); } try { await commands[command](options); } catch (error) { console.error(`Error executing command '${command}': ${error.message}`); process.exit(1); } } process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error); process.exit(1); }); main(); export { commands, parseArgs, getPidFilePath };