diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index cba35160..d0259976 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -74,7 +74,7 @@ function buildTunnelUrl(baseUrl, password, includePassword) { 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, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false }; + const options = { port: DEFAULT_PORT, daemon: false, uiPassword: envPassword, tryCfTunnel: false, tunnelQr: false, tunnelPasswordUrl: false, remoteUrl: null }; let command = 'serve'; const consumeValue = (currentIndex, inlineValue) => { @@ -132,6 +132,12 @@ function parseArgs() { options.uiPassword = typeof value === 'string' ? value : ''; break; } + case 'remote-url': { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.remoteUrl = typeof value === 'string' && value.length > 0 ? value : null; + break; + } case 'help': case 'h': showHelp(); @@ -167,6 +173,7 @@ COMMANDS: OPTIONS: -p, --port Web server port (default: ${DEFAULT_PORT}) + --remote-url Connect to remote OpenCode instance instead of starting local --ui-password Protect browser UI with single password --try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access --tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel) @@ -177,12 +184,14 @@ OPTIONS: ENVIRONMENT: OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag + OPENCODE_REMOTE_URL Alternative to --remote-url flag EXAMPLES: openchamber # Start on default port 3000 openchamber --port 8080 # Start on port 8080 openchamber serve --daemon # Start in background openchamber --try-cf-tunnel # Start with Cloudflare Quick Tunnel + openchamber --remote-url http://remote-server:3000 # Connect to remote OpenCode openchamber stop # Stop all running instances openchamber stop --port 3000 # Stop specific instance openchamber status # Check status @@ -456,7 +465,8 @@ const commands = { process.exit(1); } - const opencodeBinary = await checkOpenCodeCLI(); + // When remoteUrl is provided, skip local OpenCode CLI check + const opencodeBinary = options.remoteUrl ? null : await checkOpenCodeCLI(); const serverPath = path.join(__dirname, '..', 'server', 'index.js'); @@ -475,6 +485,9 @@ const commands = { if (options.tryCfTunnel) { serverArgs.push('--try-cf-tunnel'); } + if (options.remoteUrl) { + serverArgs.push('--remote-url', options.remoteUrl); + } const preferredRuntime = getPreferredServerRuntime(); const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath; @@ -487,9 +500,10 @@ const commands = { env: { ...process.env, OPENCHAMBER_PORT: options.port.toString(), - OPENCODE_BINARY: opencodeBinary, + ...(opencodeBinary ? { OPENCODE_BINARY: opencodeBinary } : {}), ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', + ...(options.remoteUrl ? { OPENCODE_REMOTE_URL: options.remoteUrl } : {}), } }); @@ -514,10 +528,15 @@ const commands = { } else { - process.env.OPENCODE_BINARY = opencodeBinary; + if (opencodeBinary) { + process.env.OPENCODE_BINARY = opencodeBinary; + } if (typeof effectiveUiPassword === 'string') { process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; } + if (options.remoteUrl) { + process.env.OPENCODE_REMOTE_URL = options.remoteUrl; + } 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'); @@ -533,9 +552,10 @@ const commands = { env: { ...process.env, OPENCHAMBER_PORT: options.port.toString(), - OPENCODE_BINARY: opencodeBinary, + ...(opencodeBinary ? { OPENCODE_BINARY: opencodeBinary } : {}), ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', + ...(options.remoteUrl ? { OPENCODE_REMOTE_URL: options.remoteUrl } : {}), }, }); @@ -553,6 +573,7 @@ const commands = { exitOnShutdown: true, uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, tryCfTunnel: options.tryCfTunnel, + remoteUrl: options.remoteUrl, onTunnelReady: async (url) => { const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl); console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index e88be96d..202c353f 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1214,7 +1214,8 @@ function parseArgs(argv = process.argv.slice(2)) { process.env.OPENCODE_UI_PASSWORD || null; const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; - const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel }; + const envRemoteUrl = process.env.OPENCODE_REMOTE_URL || null; + const options = { port: DEFAULT_PORT, uiPassword: envPassword, tryCfTunnel: envCfTunnel, remoteUrl: envRemoteUrl }; const consumeValue = (currentIndex, inlineValue) => { if (typeof inlineValue === 'string') { @@ -1256,6 +1257,13 @@ function parseArgs(argv = process.argv.slice(2)) { options.tryCfTunnel = true; continue; } + + if (optionName === 'remote-url') { + const { value, nextIndex } = consumeValue(i, inlineValue); + i = nextIndex; + options.remoteUrl = typeof value === 'string' && value.length > 0 ? value : null; + continue; + } } return options; @@ -1656,14 +1664,20 @@ function setupProxy(app) { next(); }); + const remoteOrigin = process.env.__OPENCODE_REMOTE_ORIGIN || null; + const getProxyTarget = () => { + if (remoteOrigin) { + return remoteOrigin; + } + if (!openCodePort) { + return 'http://127.0.0.1:0'; + } + return `http://localhost:${openCodePort}`; + }; + const proxyMiddleware = createProxyMiddleware({ - target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0', - router: () => { - if (!openCodePort) { - return 'http://127.0.0.1:0'; - } - return `http://localhost:${openCodePort}`; - }, + target: getProxyTarget(), + router: getProxyTarget, changeOrigin: true, pathRewrite: (path) => { if (!path.startsWith('/api')) { @@ -1796,6 +1810,7 @@ async function main(options = {}) { const tryCfTunnel = options.tryCfTunnel === true; const attachSignals = options.attachSignals !== false; const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null; + const remoteUrl = typeof options.remoteUrl === 'string' && options.remoteUrl.length > 0 ? options.remoteUrl : null; if (typeof options.exitOnShutdown === 'boolean') { exitOnShutdown = options.exitOnShutdown; } @@ -4697,34 +4712,58 @@ async function main(options = {}) { try { - // Check if we can reuse an existing OpenCode process from a previous HMR cycle - syncFromHmrState(); - if (await isOpenCodeProcessHealthy()) { - console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); + if (remoteUrl) { + console.log(`Using remote OpenCode instance at: ${remoteUrl}`); + try { + const parsedUrl = new URL(remoteUrl); + const remotePort = parseInt(parsedUrl.port, 10) || (parsedUrl.protocol === 'https:' ? 443 : 80); + const remotePrefix = normalizeApiPrefix(parsedUrl.pathname); + + openCodePort = remotePort; + syncToHmrState(); + + // Remote origin is used by setupProxy to route requests to remote OpenCode + process.env.__OPENCODE_REMOTE_ORIGIN = `${parsedUrl.protocol}//${parsedUrl.host}`; + + setDetectedOpenCodeApiPrefix(remotePrefix); + isOpenCodeReady = true; + lastOpenCodeError = null; + openCodeNotReadySince = 0; + + console.log(`Remote OpenCode configured - port: ${remotePort}, prefix: ${remotePrefix || '(root)'}`); + } catch (urlError) { + throw new Error(`Invalid remote URL "${remoteUrl}": ${urlError.message}`); + } } else { - // No healthy process, start fresh - if (ENV_CONFIGURED_OPENCODE_PORT) { - console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); - setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); + syncFromHmrState(); + if (await isOpenCodeProcessHealthy()) { + console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); } else { - openCodePort = null; + if (ENV_CONFIGURED_OPENCODE_PORT) { + console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); + setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT); + } else { + openCodePort = null; + syncToHmrState(); + } + + lastOpenCodeError = null; + openCodeProcess = await startOpenCode(); syncToHmrState(); } - - lastOpenCodeError = null; - openCodeProcess = await startOpenCode(); - syncToHmrState(); - } - await waitForOpenCodePort(); - try { - await waitForOpenCodeReady(); - } catch (error) { - console.error(`OpenCode readiness check failed: ${error.message}`); - scheduleOpenCodeApiDetection(); + await waitForOpenCodePort(); + try { + await waitForOpenCodeReady(); + } catch (error) { + console.error(`OpenCode readiness check failed: ${error.message}`); + scheduleOpenCodeApiDetection(); + } } setupProxy(app); - scheduleOpenCodeApiDetection(); - startHealthMonitoring(); + if (!remoteUrl) { + scheduleOpenCodeApiDetection(); + startHealthMonitoring(); + } } catch (error) { console.error(`Failed to start OpenCode: ${error.message}`); console.log('Continuing without OpenCode integration...'); @@ -4832,7 +4871,8 @@ if (isCliExecution) { tryCfTunnel: cliOptions.tryCfTunnel, attachSignals: true, exitOnShutdown: true, - uiPassword: cliOptions.uiPassword + uiPassword: cliOptions.uiPassword, + remoteUrl: cliOptions.remoteUrl }).catch(error => { console.error('Failed to start server:', error); process.exit(1);