diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index b63fb47b..765a6a58 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -1,75 +1,75 @@ #!/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 { 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 { EXIT_CODE, TunnelCliError } from './lib/cli-errors.js'; import { - getUnauthenticatedLanErrorMessage, - isNetworkExposedBindHost, - isUnsafeUnauthenticatedLanAllowed, -} from '../server/lib/security/bind-host.js'; + resolveServeHost, + hasUiPasswordConfigured, + assertAuthenticatedNetworkExposure, +} from './lib/cli-network.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, + maskToken, + resolveToken, + redactProfileForOutput, + redactProfilesForOutput, + warnIfUnsafeFilePermissions, + ensureTunnelProfilesMigrated as ensureTunnelProfilesMigratedBase, +} from './lib/cli-tunnel-profiles.js'; +import { + parseArgs, + showHelp, + showStartupHelp, + showConnectUrlHelp, + showTunnelHelp, + generateCompletionScript, + findClosestMatch, +} from './lib/cli-args.js'; +import { readDesktopLocalPortFromSettings } from './lib/cli-paths.js'; +import { resolveExplicitBinary, searchPathFor } from './lib/cli-executables.js'; +import { startupCommand } from './lib/commands-startup.js'; +import { logsCommand } from './lib/commands-logs.js'; +import { statusCommand } from './lib/commands-status.js'; +import { createUpdateCommand } from './lib/commands-update.js'; +import { createConnectUrlCommand } from './lib/commands-connect-url.js'; +import { createLifecycleCommands } from './lib/commands-lifecycle.js'; +import { createServeCommand } from './lib/commands-serve.js'; +import { createTunnelCommand, isValidTunnelDoctorResponse, shouldDisplayTunnelQr } from './lib/commands-tunnel.js'; +import { + resolveDoctorPortStatuses, + discoverRunningInstances, + discoverOpenChamberInstanceOnPort, + discoverLifecycleInstances, + discoverUnconfirmedRegistryInstanceOnPort, + resolveTunnelProviders, +} from './lib/cli-lifecycle.js'; +import { + fetchTunnelProvidersFromPort, + fetchSystemInfoFromPort, +} from './lib/cli-http.js'; +import { + getPidFilePath, + getInstanceFilePath, + isProcessRunning, + isOpenchamberCmdline, + isOpenchamberProcessRunning, + getOpenchamberProcessState, +} from './lib/cli-process.js'; +import { + intro as clackIntro, outro as clackOutro, cancel as clackCancel, isJsonMode, isQuietMode, - shouldRenderHumanOutput, - canPrompt, - createSpinner, - createProgress, printJson, - logStatus, formatProviderWithIcon as clackFormatProviderWithIcon, + logStatus, } 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; @@ -80,6 +80,15 @@ function setCancelCleanup(handler) { onCancelCleanup = typeof handler === 'function' ? handler : null; } +function shouldWarnForTunnelProfileFile() { + if (!activeCommandOptions) return false; + return !isJsonMode(activeCommandOptions) && !isQuietMode(activeCommandOptions); +} + +function ensureTunnelProfilesMigrated() { + return ensureTunnelProfilesMigratedBase({ shouldWarn: shouldWarnForTunnelProfileFile() }); +} + const HAS_PLAIN_FLAG = process.argv.includes('--plain'); const STYLE_ENABLED = process.stdout.isTTY && process.env.NO_COLOR !== '1' && !HAS_PLAIN_FLAG; const ANSI = { @@ -87,543 +96,11 @@ const ANSI = { 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 resolveServeHost(hostOverride) { - return resolveConfiguredBindHost(hostOverride); -} - -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); } @@ -638,24 +115,6 @@ function getBunBinary() { return 'bun'; } -function hasUiPasswordConfigured(password) { - return typeof password === 'string' && password.trim().length > 0; -} - -function assertAuthenticatedNetworkExposure({ host, uiPassword }) { - const bindHost = resolveConfiguredBindHost(host); - if (hasUiPasswordConfigured(uiPassword)) { - return; - } - if (!isNetworkExposedBindHost(bindHost)) { - return; - } - if (isUnsafeUnauthenticatedLanAllowed(process.env)) { - return; - } - throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR); -} - const BUN_BIN = getBunBinary(); function isBunRuntime() { @@ -679,1212 +138,6 @@ 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