#!/usr/bin/env node import path from 'path'; import fs from 'fs'; import { spawn, spawnSync } from 'child_process'; import { fileURLToPath } 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 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 update Check for and install updates 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 EXAMPLES: openchamber # Start on default port 3000 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 openchamber update # Update to latest version `); } 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 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; } } const commands = { async serve(options) { 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 opencodeBinary = await checkOpenCodeCLI(); const serverPath = path.join(__dirname, '..', 'server', 'index.js'); const serverArgs = [serverPath, '--port', options.port.toString()]; if (typeof options.uiPassword === 'string') { serverArgs.push('--ui-password', options.uiPassword); } if (options.daemon) { const child = spawn(process.execPath, serverArgs, { detached: true, stdio: 'ignore', env: { ...process.env, OPENCHAMBER_PORT: options.port.toString(), OPENCODE_BINARY: opencodeBinary, ...(typeof options.uiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: options.uiPassword } : {}) } }); child.unref(); setTimeout(() => { if (isProcessRunning(child.pid)) { writePidFile(pidFilePath, child.pid); writeInstanceOptions(instanceFilePath, options); console.log(`OpenChamber started in daemon mode on port ${options.port}`); console.log(`PID: ${child.pid}`); console.log(`Visit: http://localhost:${options.port}`); } else { console.error('Failed to start server in daemon mode'); process.exit(1); } }, 1000); } else { process.env.OPENCODE_BINARY = opencodeBinary; if (typeof options.uiPassword === 'string') { process.env.OPENCHAMBER_UI_PASSWORD = options.uiPassword; } writeInstanceOptions(instanceFilePath, options); const { startWebUiServer } = await import(serverPath); await startWebUiServer({ port: options.port, attachSignals: true, exitOnShutdown: true, uiPassword: typeof options.uiPassword === 'string' ? options.uiPassword : 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 { 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 { 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 { 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 update() { const os = await import('os'); const tmpDir = os.tmpdir(); const packageManagerPath = path.join(__dirname, '..', 'server', 'lib', 'package-manager.js'); const { checkForUpdates, executeUpdate, detectPackageManager, getCurrentVersion, } = await import(packageManagerPath); // Check for running instances before update 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 instanceFilePath = path.join(tmpDir, `openchamber-${port}.json`); const pid = readPidFile(pidFilePath); if (pid && isProcessRunning(pid)) { const storedOptions = readInstanceOptions(instanceFilePath); runningInstances.push({ port, pid, pidFilePath, instanceFilePath, storedOptions: storedOptions || { port, daemon: true }, }); } } } } catch (error) { // Ignore } console.log('Checking for updates...'); console.log(`Current version: ${getCurrentVersion()}`); const updateInfo = await checkForUpdates(); if (updateInfo.error) { console.error(`Error: ${updateInfo.error}`); process.exit(1); } if (!updateInfo.available) { console.log('\nYou are running the latest version.'); return; } console.log(`\nNew version available: ${updateInfo.version}`); if (updateInfo.body) { console.log('\nChangelog:'); console.log('─'.repeat(40)); // Simple formatting for CLI const formatted = updateInfo.body .replace(/^## \[(\d+\.\d+\.\d+)\] - \d{4}-\d{2}-\d{2}/gm, '\nv$1') .replace(/^### /gm, '\n') .replace(/^- /gm, ' • '); console.log(formatted); console.log('─'.repeat(40)); } // Stop running instances before update if (runningInstances.length > 0) { console.log(`\nStopping ${runningInstances.length} running instance(s) before update...`); for (const instance of runningInstances) { try { process.kill(instance.pid, 'SIGTERM'); 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); console.log(` Stopped instance on port ${instance.port}`); } catch (error) { console.warn(` Warning: Could not stop instance on port ${instance.port}`); } } } const pm = detectPackageManager(); console.log(`\nDetected package manager: ${pm}`); console.log('Installing update...\n'); const result = executeUpdate(pm); if (result.success) { console.log('\nUpdate successful!'); // Restart previously running instances if (runningInstances.length > 0) { console.log(`\nRestarting ${runningInstances.length} instance(s)...`); for (const instance of runningInstances) { try { // Force daemon mode for restart after update const restartOptions = { ...instance.storedOptions, daemon: true, }; await commands.serve(restartOptions); console.log(` Restarted instance on port ${instance.port}`); } catch (error) { console.error(` Failed to restart instance on port ${instance.port}: ${error.message}`); console.log(` Run manually: openchamber serve --port ${instance.port} --daemon`); } } } } else { console.error('\nUpdate failed.'); console.error(`Exit code: ${result.exitCode}`); process.exit(1); } }, }; 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 };