feat: add --remote-url CLI option to connect to remote OpenCode instances (#161)

* feat: add --remote-url CLI option to connect to remote OpenCode instances

* fix: pass remoteUrl to main function when running as CLI
This commit is contained in:
刘奕聪
2026-01-16 15:58:55 +02:00
committed by GitHub
parent 976018dd32
commit 53f8e1324e
2 changed files with 97 additions and 36 deletions
+26 -5
View File
@@ -74,7 +74,7 @@ function buildTunnelUrl(baseUrl, password, includePassword) {
function parseArgs() { function parseArgs() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const envPassword = process.env.OPENCHAMBER_UI_PASSWORD || undefined; 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'; let command = 'serve';
const consumeValue = (currentIndex, inlineValue) => { const consumeValue = (currentIndex, inlineValue) => {
@@ -132,6 +132,12 @@ function parseArgs() {
options.uiPassword = typeof value === 'string' ? value : ''; options.uiPassword = typeof value === 'string' ? value : '';
break; 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 'help':
case 'h': case 'h':
showHelp(); showHelp();
@@ -167,6 +173,7 @@ COMMANDS:
OPTIONS: OPTIONS:
-p, --port Web server port (default: ${DEFAULT_PORT}) -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 --ui-password Protect browser UI with single password
--try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access --try-cf-tunnel Create a Cloudflare Quick Tunnel for remote access
--tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel) --tunnel-qr Display QR code for tunnel URL (use with --try-cf-tunnel)
@@ -177,12 +184,14 @@ OPTIONS:
ENVIRONMENT: ENVIRONMENT:
OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag OPENCHAMBER_UI_PASSWORD Alternative to --ui-password flag
OPENCODE_REMOTE_URL Alternative to --remote-url flag
EXAMPLES: EXAMPLES:
openchamber # Start on default port 3000 openchamber # Start on default port 3000
openchamber --port 8080 # Start on port 8080 openchamber --port 8080 # Start on port 8080
openchamber serve --daemon # Start in background openchamber serve --daemon # Start in background
openchamber --try-cf-tunnel # Start with Cloudflare Quick Tunnel 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 # Stop all running instances
openchamber stop --port 3000 # Stop specific instance openchamber stop --port 3000 # Stop specific instance
openchamber status # Check status openchamber status # Check status
@@ -456,7 +465,8 @@ const commands = {
process.exit(1); 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'); const serverPath = path.join(__dirname, '..', 'server', 'index.js');
@@ -475,6 +485,9 @@ const commands = {
if (options.tryCfTunnel) { if (options.tryCfTunnel) {
serverArgs.push('--try-cf-tunnel'); serverArgs.push('--try-cf-tunnel');
} }
if (options.remoteUrl) {
serverArgs.push('--remote-url', options.remoteUrl);
}
const preferredRuntime = getPreferredServerRuntime(); const preferredRuntime = getPreferredServerRuntime();
const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath; const runtimeBin = preferredRuntime === 'bun' ? BUN_BIN : process.execPath;
@@ -487,9 +500,10 @@ const commands = {
env: { env: {
...process.env, ...process.env,
OPENCHAMBER_PORT: options.port.toString(), OPENCHAMBER_PORT: options.port.toString(),
OPENCODE_BINARY: opencodeBinary, ...(opencodeBinary ? { OPENCODE_BINARY: opencodeBinary } : {}),
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
...(options.remoteUrl ? { OPENCODE_REMOTE_URL: options.remoteUrl } : {}),
} }
}); });
@@ -514,10 +528,15 @@ const commands = {
} else { } else {
process.env.OPENCODE_BINARY = opencodeBinary; if (opencodeBinary) {
process.env.OPENCODE_BINARY = opencodeBinary;
}
if (typeof effectiveUiPassword === 'string') { if (typeof effectiveUiPassword === 'string') {
process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword; process.env.OPENCHAMBER_UI_PASSWORD = effectiveUiPassword;
} }
if (options.remoteUrl) {
process.env.OPENCODE_REMOTE_URL = options.remoteUrl;
}
if (showAutoGeneratedPassword) { if (showAutoGeneratedPassword) {
console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`); console.log(`\n🔐 Auto-generated password: \x1b[92m${effectiveUiPassword}\x1b[0m`);
console.log('⚠️ Save this password - it won\'t be shown again!\n'); console.log('⚠️ Save this password - it won\'t be shown again!\n');
@@ -533,9 +552,10 @@ const commands = {
env: { env: {
...process.env, ...process.env,
OPENCHAMBER_PORT: options.port.toString(), OPENCHAMBER_PORT: options.port.toString(),
OPENCODE_BINARY: opencodeBinary, ...(opencodeBinary ? { OPENCODE_BINARY: opencodeBinary } : {}),
...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}), ...(typeof effectiveUiPassword === 'string' ? { OPENCHAMBER_UI_PASSWORD: effectiveUiPassword } : {}),
OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false', OPENCHAMBER_TRY_CF_TUNNEL: options.tryCfTunnel ? 'true' : 'false',
...(options.remoteUrl ? { OPENCODE_REMOTE_URL: options.remoteUrl } : {}),
}, },
}); });
@@ -553,6 +573,7 @@ const commands = {
exitOnShutdown: true, exitOnShutdown: true,
uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null, uiPassword: typeof effectiveUiPassword === 'string' ? effectiveUiPassword : null,
tryCfTunnel: options.tryCfTunnel, tryCfTunnel: options.tryCfTunnel,
remoteUrl: options.remoteUrl,
onTunnelReady: async (url) => { onTunnelReady: async (url) => {
const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl); const displayUrl = buildTunnelUrl(url, effectiveUiPassword, options.tunnelPasswordUrl);
console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`); console.log(`\n🌐 Tunnel URL: \x1b[36m${displayUrl}\x1b[0m\n`);
+71 -31
View File
@@ -1214,7 +1214,8 @@ function parseArgs(argv = process.argv.slice(2)) {
process.env.OPENCODE_UI_PASSWORD || process.env.OPENCODE_UI_PASSWORD ||
null; null;
const envCfTunnel = process.env.OPENCHAMBER_TRY_CF_TUNNEL === 'true'; 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) => { const consumeValue = (currentIndex, inlineValue) => {
if (typeof inlineValue === 'string') { if (typeof inlineValue === 'string') {
@@ -1256,6 +1257,13 @@ function parseArgs(argv = process.argv.slice(2)) {
options.tryCfTunnel = true; options.tryCfTunnel = true;
continue; 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; return options;
@@ -1656,14 +1664,20 @@ function setupProxy(app) {
next(); 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({ const proxyMiddleware = createProxyMiddleware({
target: openCodePort ? `http://localhost:${openCodePort}` : 'http://127.0.0.1:0', target: getProxyTarget(),
router: () => { router: getProxyTarget,
if (!openCodePort) {
return 'http://127.0.0.1:0';
}
return `http://localhost:${openCodePort}`;
},
changeOrigin: true, changeOrigin: true,
pathRewrite: (path) => { pathRewrite: (path) => {
if (!path.startsWith('/api')) { if (!path.startsWith('/api')) {
@@ -1796,6 +1810,7 @@ async function main(options = {}) {
const tryCfTunnel = options.tryCfTunnel === true; const tryCfTunnel = options.tryCfTunnel === true;
const attachSignals = options.attachSignals !== false; const attachSignals = options.attachSignals !== false;
const onTunnelReady = typeof options.onTunnelReady === 'function' ? options.onTunnelReady : null; 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') { if (typeof options.exitOnShutdown === 'boolean') {
exitOnShutdown = options.exitOnShutdown; exitOnShutdown = options.exitOnShutdown;
} }
@@ -4697,34 +4712,58 @@ async function main(options = {}) {
try { try {
// Check if we can reuse an existing OpenCode process from a previous HMR cycle if (remoteUrl) {
syncFromHmrState(); console.log(`Using remote OpenCode instance at: ${remoteUrl}`);
if (await isOpenCodeProcessHealthy()) { try {
console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`); 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 { } else {
// No healthy process, start fresh syncFromHmrState();
if (ENV_CONFIGURED_OPENCODE_PORT) { if (await isOpenCodeProcessHealthy()) {
console.log(`Using OpenCode port from environment: ${ENV_CONFIGURED_OPENCODE_PORT}`); console.log(`[HMR] Reusing existing OpenCode process on port ${openCodePort}`);
setOpenCodePort(ENV_CONFIGURED_OPENCODE_PORT);
} else { } 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(); syncToHmrState();
} }
await waitForOpenCodePort();
lastOpenCodeError = null; try {
openCodeProcess = await startOpenCode(); await waitForOpenCodeReady();
syncToHmrState(); } catch (error) {
} console.error(`OpenCode readiness check failed: ${error.message}`);
await waitForOpenCodePort(); scheduleOpenCodeApiDetection();
try { }
await waitForOpenCodeReady();
} catch (error) {
console.error(`OpenCode readiness check failed: ${error.message}`);
scheduleOpenCodeApiDetection();
} }
setupProxy(app); setupProxy(app);
scheduleOpenCodeApiDetection(); if (!remoteUrl) {
startHealthMonitoring(); scheduleOpenCodeApiDetection();
startHealthMonitoring();
}
} catch (error) { } catch (error) {
console.error(`Failed to start OpenCode: ${error.message}`); console.error(`Failed to start OpenCode: ${error.message}`);
console.log('Continuing without OpenCode integration...'); console.log('Continuing without OpenCode integration...');
@@ -4832,7 +4871,8 @@ if (isCliExecution) {
tryCfTunnel: cliOptions.tryCfTunnel, tryCfTunnel: cliOptions.tryCfTunnel,
attachSignals: true, attachSignals: true,
exitOnShutdown: true, exitOnShutdown: true,
uiPassword: cliOptions.uiPassword uiPassword: cliOptions.uiPassword,
remoteUrl: cliOptions.remoteUrl
}).catch(error => { }).catch(error => {
console.error('Failed to start server:', error); console.error('Failed to start server:', error);
process.exit(1); process.exit(1);