#!/usr/bin/env node import fs from 'fs'; import net from 'net'; import dgram from 'dgram'; import os from 'os'; import path from 'path'; import crypto from 'crypto'; import { spawn, spawnSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { isModuleCliExecution } from './cli-entry.js'; import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js'; import { createRemoteClientAuthRuntime } from '../server/lib/client-auth/remote-clients.js'; import { intro as clackIntro, outro as clackOutro, log as clackLog, box as clackBox, confirm as clackConfirm, select as clackSelect, text as clackText, password as clackPassword, cancel as clackCancel, isCancel as clackIsCancel, isJsonMode, isQuietMode, shouldRenderHumanOutput, canPrompt, createSpinner, createProgress, printJson, logStatus, formatProviderWithIcon as clackFormatProviderWithIcon, } from './cli-output.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DEFAULT_PORT = 3000; const DEFAULT_TAIL_LINES = 200; const DAEMON_READY_TIMEOUT_MS = 30000; const LOG_ROTATE_MAX_BYTES = 10 * 1024 * 1024; const LOG_ROTATE_KEEP = 5; const STARTUP_SERVICE_ID = 'dev.openchamber.web'; const TUNNEL_PROFILES_VERSION = 1; const TUNNEL_PROFILES_FILE_NAME = 'tunnel-profiles.json'; const LEGACY_CLOUDFLARE_MANAGED_REMOTE_FILE_NAME = 'cloudflare-managed-remote-tunnels.json'; const TUNNEL_CLI_STATE_FILE_NAME = 'tunnel-cli-state.json'; const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json'; const TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS = 30 * 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MIN_MS = 60 * 1000; const TUNNEL_BOOTSTRAP_TTL_MAX_MS = 24 * 60 * 60 * 1000; const TUNNEL_SESSION_TTL_DEFAULT_MS = 8 * 60 * 60 * 1000; const TUNNEL_SESSION_TTL_MIN_MS = 5 * 60 * 1000; const TUNNEL_SESSION_TTL_MAX_MS = 30 * 24 * 60 * 60 * 1000; const CONNECT_TTL_PICKER_OPTIONS = [ { value: String(3 * 60 * 1000), label: '3m' }, { value: String(TUNNEL_BOOTSTRAP_TTL_DEFAULT_MS), label: '30m' }, { value: String(2 * 60 * 60 * 1000), label: '2h' }, { value: String(8 * 60 * 60 * 1000), label: '8h' }, { value: String(24 * 60 * 60 * 1000), label: '24h' }, { value: '__custom__', label: 'Custom' }, ]; const SESSION_TTL_PICKER_OPTIONS = [ { value: String(60 * 60 * 1000), label: '1h' }, { value: String(TUNNEL_SESSION_TTL_DEFAULT_MS), label: '8h' }, { value: String(12 * 60 * 60 * 1000), label: '12h' }, { value: String(24 * 60 * 60 * 1000), label: '24h' }, { value: String(7 * 24 * 60 * 60 * 1000), label: '1w' }, { value: String(30 * 24 * 60 * 60 * 1000), label: '30d' }, { value: '__custom__', label: 'Custom' }, ]; const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); const DEFAULT_TUNNEL_PROVIDER_CAPABILITIES = [cloudflareTunnelProviderCapabilities]; let onCancelCleanup = null; let activeCommandOptions = null; let foregroundServerActive = false; let foregroundShutdown = null; function setCancelCleanup(handler) { onCancelCleanup = typeof handler === 'function' ? handler : null; } const HAS_PLAIN_FLAG = process.argv.includes('--plain'); const STYLE_ENABLED = process.stdout.isTTY && process.env.NO_COLOR !== '1' && !HAS_PLAIN_FLAG; const ANSI = { bold: '\x1b[1m', unbold: '\x1b[22m', }; // Browser-unsafe ports (Fetch/Chromium restricted ports). const UNSAFE_BROWSER_PORTS = new Set([ 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080, ]); const EXIT_CODE = { SUCCESS: 0, GENERAL_ERROR: 1, USAGE_ERROR: 2, MISSING_DEPENDENCY: 3, AUTH_CONFIG_ERROR: 4, NETWORK_RUNTIME_ERROR: 5, }; class TunnelCliError extends Error { constructor(message, exitCode = EXIT_CODE.GENERAL_ERROR) { super(message); this.name = 'TunnelCliError'; this.exitCode = exitCode; } } function boldText(text) { if (!STYLE_ENABLED) return text; return `${ANSI.bold}${text}${ANSI.unbold}`; } function getDefaultCloudflaredConfigPath() { return path.join(os.homedir(), '.cloudflared', 'config.yml'); } function isReadableRegularFile(filePath) { if (typeof filePath !== 'string' || filePath.trim().length === 0) { return false; } try { const stat = fs.statSync(filePath); if (!stat.isFile()) return false; fs.accessSync(filePath, fs.constants.R_OK); return true; } catch { return false; } } function isUnsafeBrowserPort(port) { return Number.isFinite(port) && UNSAFE_BROWSER_PORTS.has(Math.trunc(port)); } function resolveConfiguredBindHost(hostOverride) { const configured = typeof hostOverride === 'string' && hostOverride.trim() ? hostOverride.trim() : typeof process.env.OPENCHAMBER_HOST === 'string' ? process.env.OPENCHAMBER_HOST.trim() : ''; return configured || '127.0.0.1'; } function isWildcardBindHost(host) { return host === '0.0.0.0' || host === '::' || host === '[::]'; } function resolveApiHost(hostOverride) { const configured = resolveConfiguredBindHost(hostOverride); if (!configured) { return '127.0.0.1'; } // Wildcard bind hosts are not valid destination hosts. if (configured === '0.0.0.0') { return '127.0.0.1'; } if (configured === '::' || configured === '[::]') { return '::1'; } // Strip brackets if user provided [::1] if (configured.startsWith('[') && configured.endsWith(']')) { return configured.slice(1, -1); } return configured; } function formatHostForUrl(host) { if (typeof host !== 'string') return '127.0.0.1'; // Bracket IPv6 for URL usage. return host.includes(':') ? `[${host}]` : host; } function buildLocalUrl(port, endpoint = '', hostOverride) { const host = formatHostForUrl(resolveApiHost(hostOverride)); const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; return `http://${host}:${port}${pathPart}`; } async function detectLanIPv4Address() { const ip = await new Promise((resolve) => { const socket = dgram.createSocket('udp4'); const finish = (value) => { try { socket.close(); } catch {} resolve(value); }; socket.once('error', () => finish(null)); try { socket.connect(80, '8.8.8.8', (error) => { if (error) return finish(null); try { const addr = socket.address(); finish(addr && typeof addr.address === 'string' ? addr.address : null); } catch { finish(null); } }); } catch { finish(null); } }); if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip; for (const entries of Object.values(os.networkInterfaces() || {})) { for (const entry of entries || []) { if (entry.family === 'IPv4' && !entry.internal && entry.address) { return entry.address; } } } return null; } async function resolveConnectUrlServerUrl(options) { let hostOverride = options.host; if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) { const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port)); if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) { hostOverride = storedOptions.host.trim(); } } const bindHost = resolveConfiguredBindHost(hostOverride); if (!isWildcardBindHost(bindHost)) { return { serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''), source: 'configured-host', }; } const lanAddress = await detectLanIPv4Address(); if (!lanAddress) { return { serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''), source: 'loopback-fallback', }; } return { serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`, source: 'lan-detected', }; } async function ensureConnectUrlServerRunning(options) { const running = await discoverRunningInstances(); if (running.some((entry) => entry.port === options.port)) { return { port: options.port, autoStarted: false }; } await commands.serve({ port: options.port, explicitPort: true, host: options.host, uiPassword: options.uiPassword, apiOnly: options.apiOnly, suppressUnsafePortWarning: true, suppressUiPasswordWarning: true, suppressStartupSummary: true, suppressQuietOutput: true, }); return { port: options.port, autoStarted: true }; } function normalizeServerUrlForConnection(value) { const trimmed = typeof value === 'string' ? value.trim() : ''; if (!trimmed) return null; try { const parsed = new URL(trimmed); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return null; } parsed.hash = ''; return parsed.toString().replace(/\/+$/, ''); } catch { return null; } } function getOpenChamberDataDir() { return process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'); } function buildClientConnectionPayload({ serverUrl, token, label }) { const params = new URLSearchParams(); params.set('v', '1'); params.set('server', serverUrl.trim().replace(/\/+$/, '')); params.set('token', token.trim()); if (label?.trim()) params.set('label', label.trim()); return `openchamber://connect?${params.toString()}`; } function formatUnsafePortWarning(port) { return `Port ${port} is browser-unsafe (ERR_UNSAFE_PORT) and is not supported for OpenChamber UI at ${buildLocalUrl(port, '/')}.`; } function assertSafeBrowserPort(port, { context = 'This action' } = {}) { if (!isUnsafeBrowserPort(port)) { return; } throw new TunnelCliError( `${context} cannot use port ${port}. ${formatUnsafePortWarning(port)} Use a safe port such as 3000, 5173, 8080, or a high ephemeral port.`, EXIT_CODE.USAGE_ERROR, ); } function parseHumanDurationToMs(value) { if (typeof value === 'number' && Number.isFinite(value)) { return Math.round(value); } if (typeof value !== 'string') { return null; } const trimmed = value.trim().toLowerCase(); if (!trimmed) { return null; } if (/^\d+$/.test(trimmed)) { return Number.parseInt(trimmed, 10); } const normalized = trimmed.replace(/\s+/g, ''); const pattern = /(\d+)(ms|s|m|h|d)/g; let cursor = 0; let total = 0; let match; while ((match = pattern.exec(normalized)) !== null) { if (match.index !== cursor) { return null; } cursor = pattern.lastIndex; const amount = Number.parseInt(match[1], 10); const unit = match[2]; const unitMs = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60 * 1000 : unit === 'h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; total += amount * unitMs; } if (cursor !== normalized.length) { return null; } return total; } function parseTtlMsOrThrow(rawValue, { flagName, minMs, maxMs, } = {}) { const parsed = parseHumanDurationToMs(rawValue); if (!Number.isFinite(parsed) || parsed <= 0) { throw new TunnelCliError( `Invalid value for ${flagName}. Use a positive duration like 30m, 24h, 1d, or milliseconds.`, EXIT_CODE.USAGE_ERROR, ); } if (parsed < minMs || parsed > maxMs) { throw new TunnelCliError( `${flagName} must be between ${minMs}ms and ${maxMs}ms.`, EXIT_CODE.USAGE_ERROR, ); } return parsed; } function formatDurationForCli(ms) { if (!Number.isFinite(ms) || ms <= 0) { return null; } const value = Math.round(ms); if (value % (24 * 60 * 60 * 1000) === 0) return `${value / (24 * 60 * 60 * 1000)}d`; if (value % (60 * 60 * 1000) === 0) return `${value / (60 * 60 * 1000)}h`; if (value % (60 * 1000) === 0) return `${value / (60 * 1000)}m`; if (value % 1000 === 0) return `${value / 1000}s`; return `${value}ms`; } function shellQuote(value) { const text = String(value); if (/^[A-Za-z0-9._\-/:=]+$/.test(text)) { return text; } return `'${text.replace(/'/g, `'"'"'`)}'`; } function buildTunnelStartReplayCommand({ port, provider, mode, profileName, configPath, hostname, connectTtlMs, sessionTtlMs, qr, noQr, includeTokenPlaceholder, tokenViaStdin, tokenFileProvided, }) { const parts = ['openchamber', 'tunnel', 'start']; if (Number.isFinite(port) && port > 0) { parts.push('--port', String(port)); } if (profileName) { parts.push('--profile', shellQuote(profileName)); } if (provider) { parts.push('--provider', shellQuote(provider)); } if (mode) { parts.push('--mode', shellQuote(mode)); } if (typeof configPath === 'string' && configPath.trim().length > 0) { parts.push('--config', shellQuote(configPath)); } if (typeof hostname === 'string' && hostname.trim().length > 0) { parts.push('--hostname', shellQuote(hostname)); } const connectTtl = formatDurationForCli(connectTtlMs); if (connectTtl) { parts.push('--connect-ttl', connectTtl); } const sessionTtl = formatDurationForCli(sessionTtlMs); if (sessionTtl) { parts.push('--session-ttl', sessionTtl); } if (qr) parts.push('--qr'); if (noQr) parts.push('--no-qr'); if (includeTokenPlaceholder) { if (tokenViaStdin) { parts.push('--token-stdin'); } else if (tokenFileProvided) { parts.push('--token-file', ''); } else { parts.push('--token', ''); } } return parts.join(' '); } function buildTunnelProfileAddCommand({ provider, hostname }) { const parts = [ 'openchamber', 'tunnel', 'profile', 'add', '--provider', shellQuote(provider || 'cloudflare'), '--mode', 'managed-remote', '--name', '', '--hostname', shellQuote(hostname || ''), '--token', '', ]; return parts.join(' '); } async function resolveTunnelTtlOverrides(options) { let connectTtlRaw = typeof options.connectTtl === 'string' ? options.connectTtl : undefined; let sessionTtlRaw = typeof options.sessionTtl === 'string' ? options.sessionTtl : undefined; const shouldPrompt = !connectTtlRaw && !sessionTtlRaw && canPrompt(options); if (shouldPrompt) { const connectChoice = await clackSelect({ message: 'Select connect-link TTL', options: CONNECT_TTL_PICKER_OPTIONS, }); if (clackIsCancel(connectChoice)) { clackCancel('Tunnel start cancelled.'); return null; } if (connectChoice === '__custom__') { const enteredConnect = await clackText({ message: 'Enter connect-link TTL (e.g. 30m, 2h, 1d)', placeholder: '30m', validate(value) { try { parseTtlMsOrThrow(value, { flagName: '--connect-ttl', minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, }); return undefined; } catch (error) { return error instanceof Error ? error.message : 'Invalid TTL value'; } }, }); if (clackIsCancel(enteredConnect)) { clackCancel('Tunnel start cancelled.'); return null; } connectTtlRaw = enteredConnect.trim(); } else { connectTtlRaw = connectChoice; } const sessionChoice = await clackSelect({ message: 'Select session TTL', options: SESSION_TTL_PICKER_OPTIONS, }); if (clackIsCancel(sessionChoice)) { clackCancel('Tunnel start cancelled.'); return null; } if (sessionChoice === '__custom__') { const enteredSession = await clackText({ message: 'Enter session TTL (e.g. 8h, 24h, 1d)', placeholder: '8h', validate(value) { try { parseTtlMsOrThrow(value, { flagName: '--session-ttl', minMs: TUNNEL_SESSION_TTL_MIN_MS, maxMs: TUNNEL_SESSION_TTL_MAX_MS, }); return undefined; } catch (error) { return error instanceof Error ? error.message : 'Invalid TTL value'; } }, }); if (clackIsCancel(enteredSession)) { clackCancel('Tunnel start cancelled.'); return null; } sessionTtlRaw = enteredSession.trim(); } else { sessionTtlRaw = sessionChoice; } } const connectTtlMs = connectTtlRaw !== undefined ? parseTtlMsOrThrow(connectTtlRaw, { flagName: '--connect-ttl', minMs: TUNNEL_BOOTSTRAP_TTL_MIN_MS, maxMs: TUNNEL_BOOTSTRAP_TTL_MAX_MS, }) : undefined; const sessionTtlMs = sessionTtlRaw !== undefined ? parseTtlMsOrThrow(sessionTtlRaw, { flagName: '--session-ttl', minMs: TUNNEL_SESSION_TTL_MIN_MS, maxMs: TUNNEL_SESSION_TTL_MAX_MS, }) : undefined; return { connectTtlMs, sessionTtlMs, }; } function levenshteinDistance(a, b) { const m = a.length; const n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } return dp[m][n]; } function findClosestMatch(input, candidates, maxDistance = 3) { if (typeof input !== 'string' || input.length === 0 || !Array.isArray(candidates)) { return null; } const normalized = input.toLowerCase(); let bestCandidate = null; let bestDistance = maxDistance + 1; for (const candidate of candidates) { const distance = levenshteinDistance(normalized, candidate.toLowerCase()); if (distance < bestDistance) { bestDistance = distance; bestCandidate = candidate; } } return bestDistance <= maxDistance ? bestCandidate : null; } function importFromFilePath(filePath) { return import(pathToFileURL(filePath).href); } 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'; } function hasUiPasswordConfigured(password) { return typeof password === 'string' && password.trim().length > 0; } const BUN_BIN = getBunBinary(); function isBunRuntime() { return typeof globalThis.Bun !== 'undefined'; } function isBunInstalled() { try { const result = spawnSync(BUN_BIN, ['--version'], { stdio: 'ignore', env: process.env, windowsHide: true, }); return result.status === 0; } catch { return false; } } function getPreferredServerRuntime() { return isBunInstalled() ? 'bun' : 'node'; } async function displayTunnelQrCode(url) { try { const qrcode = await import('qrcode-terminal'); console.log('\nšŸ“± Scan this QR code to access the tunnel:\n'); qrcode.default.generate(url, { small: true }); console.log(''); } catch (error) { console.warn(`Warning: Could not generate QR code: ${error.message}`); } } function isTruthyEnv(value) { if (typeof value !== 'string') return false; const normalized = value.trim().toLowerCase(); if (!normalized) return false; return normalized !== '0' && normalized !== 'false' && normalized !== 'no'; } function shouldDisplayTunnelQr(options) { if (options?.json) return false; if (options?.quiet) return false; if (options?.explicitQr === true) return options.qr === true; if (!process.stdout?.isTTY) return false; return !isTruthyEnv(process.env.CI); } function splitOptionToken(arg) { if (!arg.startsWith('-')) return null; if (arg.startsWith('--')) { const eqIndex = arg.indexOf('='); return { name: eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2), inlineValue: eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined, long: true, }; } return { name: arg.slice(1), inlineValue: undefined, long: false, }; } function parseArgs(argv = process.argv.slice(2)) { const args = Array.isArray(argv) ? [...argv] : []; const options = { port: DEFAULT_PORT, host: undefined, uiPassword: process.env.OPENCHAMBER_UI_PASSWORD || undefined, json: false, all: false, follow: true, lines: DEFAULT_TAIL_LINES, provider: undefined, mode: undefined, profile: undefined, name: undefined, configPath: undefined, token: undefined, tokenFile: undefined, tokenStdin: false, hostname: undefined, server: undefined, connectTtl: undefined, sessionTtl: undefined, qr: false, explicitQr: false, force: false, showSecrets: false, dryRun: false, plain: false, quiet: false, explicitPort: false, explicitUiPassword: false, envSnapshot: true, foreground: false, lan: false, apiOnly: false, }; const removedFlagErrors = []; const positional = []; let helpRequested = false; let versionRequested = false; const consumeValue = (index, inlineValue) => { if (typeof inlineValue === 'string' && inlineValue.length > 0) { return { value: inlineValue, nextIndex: index }; } const candidate = args[index + 1]; if (typeof candidate === 'string' && !candidate.startsWith('-')) { return { value: candidate, nextIndex: index + 1 }; } return { value: undefined, nextIndex: index }; }; for (let i = 0; i < args.length; i++) { const arg = args[i]; const parsedToken = splitOptionToken(arg); if (!parsedToken) { positional.push(arg); continue; } const { name, inlineValue, long } = parsedToken; switch (name) { case 'port': case 'p': { const { value: consumedValue, nextIndex: consumedIndex } = consumeValue(i, inlineValue); let value = consumedValue; let nextIndex = consumedIndex; // Support explicit negative numeric values like `-p -1` so we can report // a clear range validation error instead of "Unknown option". if (value === undefined && typeof inlineValue !== 'string') { const candidate = args[i + 1]; if (typeof candidate === 'string' && /^-\d+$/.test(candidate)) { value = candidate; nextIndex = i + 1; } } i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --port.', EXIT_CODE.USAGE_ERROR); } if (!/^-?\d+$/.test(value.trim())) { throw new TunnelCliError(`Invalid port value: ${value}`, EXIT_CODE.USAGE_ERROR); } const parsed = parseInt(value, 10); if (parsed < 1 || parsed > 65535) { throw new TunnelCliError(`Invalid port value: ${parsed}`, EXIT_CODE.USAGE_ERROR); } options.port = parsed; options.explicitPort = true; break; } case 'host': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --host.', EXIT_CODE.USAGE_ERROR); } options.host = value.trim(); break; } case 'lan': options.lan = true; break; case 'ui-password': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.uiPassword = typeof value === 'string' ? value : ''; options.explicitUiPassword = true; break; } case 'provider': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.provider = typeof value === 'string' ? value : options.provider; break; } case 'mode': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.mode = typeof value === 'string' ? value : options.mode; break; } case 'profile': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.profile = typeof value === 'string' ? value : options.profile; break; } case 'name': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.name = typeof value === 'string' ? value : options.name; break; } case 'config': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.configPath = typeof value === 'string' ? value : null; break; } case 'token': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.token = typeof value === 'string' ? value : options.token; break; } case 'token-file': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.tokenFile = typeof value === 'string' ? value : options.tokenFile; break; } case 'token-stdin': options.tokenStdin = true; break; case 'hostname': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.hostname = typeof value === 'string' ? value : options.hostname; break; } case 'server': case 'server-url': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; if (typeof value !== 'string' || value.trim().length === 0) { throw new TunnelCliError('Missing value for --server.', EXIT_CODE.USAGE_ERROR); } options.server = value.trim(); break; } case 'connect-ttl': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.connectTtl = typeof value === 'string' ? value : options.connectTtl; break; } case 'session-ttl': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; options.sessionTtl = typeof value === 'string' ? value : options.sessionTtl; break; } case 'json': options.json = true; break; case 'all': options.all = true; break; case 'no-follow': options.follow = false; break; case 'no-env-snapshot': options.envSnapshot = false; break; case 'lines': { const { value, nextIndex } = consumeValue(i, inlineValue); i = nextIndex; const parsed = parseInt(value ?? '', 10); if (Number.isFinite(parsed) && parsed > 0) { options.lines = parsed; } break; } case 'qr': options.qr = true; options.explicitQr = true; break; case 'no-qr': options.qr = false; options.explicitQr = true; break; case 'force': options.force = true; break; case 'show-secrets': options.showSecrets = true; break; case 'dry-run': options.dryRun = true; break; case 'plain': options.plain = true; break; case 'quiet': case 'q': options.quiet = true; break; case 'help': case 'h': helpRequested = true; break; case 'version': case 'v': versionRequested = true; break; case 'foreground': case 'no-daemon': options.foreground = true; break; case 'api-only': options.apiOnly = true; break; case 'daemon': case 'd': // Legacy no-op: daemon mode is already the default, but older clients // may still pass this when starting a remote server. break; case 'try-cf-tunnel': removedFlagErrors.push('`--try-cf-tunnel` was removed. Use: openchamber tunnel start --provider cloudflare --mode quick'); break; case 'tunnel-qr': removedFlagErrors.push('`--tunnel-qr` was removed. Use: openchamber tunnel start ... --qr'); break; case 'tunnel-password-url': removedFlagErrors.push('`--tunnel-password-url` was removed. Use UI password auth directly after tunnel start.'); break; case 'tunnel-provider': case 'tunnel-mode': case 'tunnel-config': case 'tunnel-token': case 'tunnel-hostname': case 'tunnel': removedFlagErrors.push(`\`--${name}\` was removed from top-level serve flow. Use: openchamber tunnel start ...`); break; default: if (!long && name.length === 1) { removedFlagErrors.push(`Unknown option: -${name}`); } else { removedFlagErrors.push(`Unknown option: --${name}`); } break; } } const command = positional[0] || 'serve'; const subcommand = command === 'tunnel' ? (positional[1] || 'help') : null; const tunnelAction = command === 'tunnel' ? (positional[2] || null) : null; const startupAction = command === 'startup' ? (positional[1] || 'status') : null; if (options.lan && typeof options.host !== 'string') { options.host = '0.0.0.0'; } if (command !== 'tunnel' && typeof options.hostname === 'string' && typeof options.host !== 'string') { options.host = options.hostname; } return { command, subcommand, tunnelAction, startupAction, options, removedFlagErrors, helpRequested, versionRequested, }; } function showHelp() { console.log(` OpenChamber - Web interface for the OpenCode AI coding agent USAGE: openchamber [COMMAND] [OPTIONS] COMMANDS: serve Start the web server (daemon default) stop Stop running instance(s) restart Stop and start the server status Show server status tunnel Tunnel lifecycle commands startup Manage launch at system startup logs Tail OpenChamber logs connect-url Generate URL/QR for connecting another client update Check for and install updates OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) --host Bind address (default: 127.0.0.1) --hostname Alias for --host outside tunnel commands --lan Bind to 0.0.0.0 for LAN access --server Public/server URL for connect-url links --ui-password Protect browser UI with single password --api-only Start API routes only, without serving browser UI assets --foreground Run server in foreground (use with systemd/process managers) --no-daemon Alias for --foreground -h, --help Show help -v, --version Show version ENVIRONMENT: OPENCHAMBER_HOST Bind address (e.g. 0.0.0.0 for all interfaces) OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag OPENCHAMBER_API_ONLY Set to true/1 to start API routes only OPENCHAMBER_DATA_DIR Override OpenChamber data directory OPENCODE_HOST External OpenCode server base URL, e.g. http://hostname:4096 OPENCODE_PORT Port of external OpenCode server to connect to OPENCODE_SKIP_START Skip starting OpenCode, use external server OPENCHAMBER_OPENCODE_HOSTNAME Bind hostname for managed OpenCode server (default: 127.0.0.1) EXAMPLES: openchamber # Start in daemon mode on default port 3000 (or free port) openchamber --port 8080 # Start on port 8080 (daemon) openchamber --lan --port 3002 # Start on LAN at 0.0.0.0:3002 openchamber serve --foreground # Start in foreground (for systemd Type=simple) openchamber connect-url --port 3000 --qr openchamber connect-url --server https://openchamber.example.com openchamber startup enable # Start OpenChamber at user login openchamber tunnel help # Show tunnel lifecycle help openchamber logs # Follow logs for latest running instance `); } function showStartupHelp() { console.log(` OpenChamber Startup Commands USAGE: openchamber startup [OPTIONS] SUBCOMMANDS: status Show startup integration status enable Install and start native user startup integration disable Stop and remove native user startup integration OPTIONS: -p, --port Web server port used by startup service --host Bind address used by startup service --ui-password Protect browser UI with single password --api-only Start API routes only, without serving browser UI assets --no-env-snapshot Do not save current environment for startup service --json Output machine-readable JSON -q, --quiet Suppress non-essential output EXAMPLES: openchamber startup enable openchamber startup enable --port 3000 openchamber startup enable --port 3000 --api-only --host 0.0.0.0 openchamber startup status --json `); } function showConnectUrlHelp() { console.log(` OpenChamber Connect URL USAGE: openchamber connect-url [OPTIONS] DESCRIPTION: Generate an openchamber:// connection link for adding this server to another OpenChamber app. If no server is running on the selected port, it starts one. OPTIONS: -p, --port Server port to use or start (default: ${DEFAULT_PORT}) --host
Bind address when starting the server --hostname
Alias for --host --lan Bind to 0.0.0.0 for LAN access when starting --server Public URL saved into the connection link --server-url Alias for --server --name