From 9c318ad03580c4f21bf9b24f0a61dc5aa4318b00 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:57:58 +0530 Subject: [PATCH 01/10] fix(keypress): store listener ref and clean up properly The data listener was never removed on cleanup, leaking handlers if onKeypress was called more than once. Now stores the reference and removes it in cleanupKeypress. Also adds 'h' key support and drops the unused emitKeypressEvents import. --- packages/crosscode/src/keypress.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/crosscode/src/keypress.ts b/packages/crosscode/src/keypress.ts index bcc7a76..c1c1926 100644 --- a/packages/crosscode/src/keypress.ts +++ b/packages/crosscode/src/keypress.ts @@ -1,20 +1,30 @@ -import { emitKeypressEvents } from "node:readline"; +let dataListener: ((data: Buffer) => void) | null = null export function onKeypress(callback: (key: string) => void) { - emitKeypressEvents(process.stdin) + if (dataListener) { + process.stdin.removeListener("data", dataListener) + } if (process.stdin.isTTY) { process.stdin.setRawMode(true) process.stdin.resume() } - process.stdin.on("data", (data: Buffer) => { + dataListener = (data: Buffer) => { if (data.length === 1 && data[0] === 0x6c) callback("l") + else if (data.length === 1 && data[0] === 0x68) callback("h") else if (data.length === 1 && data[0] === 0x03) callback("ctrl-c") - }) + } + + process.stdin.on("data", dataListener) } export function cleanupKeypress() { + if (dataListener) { + process.stdin.removeListener("data", dataListener) + dataListener = null + } + if (process.stdin.isTTY) { process.stdin.setRawMode(false) process.stdin.pause() From d5e4b3a30fa69fb68dd3674ea56ba7482417da17 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:58:18 +0530 Subject: [PATCH 02/10] fix(port-detect): reject on early process exit instead of hanging If opencode crashed before a port was detected, the promise would sit for 15s then silently resolve with a dead port. Now listens for the exit event, clears the polling interval, and rejects immediately so the caller can surface the error. --- packages/crosscode/src/port-detect.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/crosscode/src/port-detect.ts b/packages/crosscode/src/port-detect.ts index cbd8330..76e9d5b 100644 --- a/packages/crosscode/src/port-detect.ts +++ b/packages/crosscode/src/port-detect.ts @@ -84,9 +84,6 @@ export function waitForOpencodePort(opts: { } proc.stdout?.on("data", collect) proc.stderr?.on("data", collect) - proc.on("exit", () => { - exited = true - }) const fromLogs = (): number | null => { for (const pattern of PORT_PATTERNS) { @@ -96,7 +93,7 @@ export function waitForOpencodePort(opts: { return null } - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const finish = (port: number) => { if (resolved) return resolved = true @@ -121,5 +118,16 @@ export function waitForOpencodePort(opts: { if (ports.length > 0) return finish(ports[0]) finish(fromLogs() ?? requestedPort) }, timeoutMs) + + proc.on("exit", (code) => { + exited = true + clearInterval(interval) + clearTimeout(timeout) + if (resolved) return + const logged = fromLogs() + if (logged) return finish(logged) + resolved = true + reject(new Error(`opencode exited with code ${code} before port was detected`)) + }) }) } From 7ebdea3964ab51b084a05c36e38f6ce153233355 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:58:28 +0530 Subject: [PATCH 03/10] fix(tunnel-client): prevent double reconnect and add heartbeat Both close and error events would fire on failure, scheduling two reconnect timers and opening duplicate connections. Added a guard so only close triggers reconnect. Also adds a 60s heartbeat timeout to detect silently-dead connections, and moves backoff reset to after auth.ok so a rejected handshake doesn't hammer the server at 1s intervals. --- packages/crosscode/src/tunnel-client.ts | 34 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/crosscode/src/tunnel-client.ts b/packages/crosscode/src/tunnel-client.ts index a2ef910..dc63753 100644 --- a/packages/crosscode/src/tunnel-client.ts +++ b/packages/crosscode/src/tunnel-client.ts @@ -5,6 +5,7 @@ import type { TunnelC2S, TunnelS2C } from "@crosscode/shared" const TUNNEL_WS_URL = process.env.CROSSCODE_TUNNEL_WS_URL || "wss://connect.crosscode.site/ws" const INITIAL_BACKOFF_MS = 1_000 const MAX_BACKOFF_MS = 30_000 +const HEARTBEAT_TIMEOUT_MS = 60_000 const DEBUG = process.env.CROSSCODE_DEBUG === "1" interface InFlightRequest { @@ -38,24 +39,43 @@ export function connectTunnel( let ws: WebSocket | null = null let backoff = INITIAL_BACKOFF_MS let reconnectTimer: ReturnType | null = null + let heartbeatTimer: ReturnType | null = null + let reconnecting = false let shuttingDown = false const inFlight = new Map() const wsUrl = tunnelWsUrl || TUNNEL_WS_URL + function resetHeartbeat() { + if (heartbeatTimer) clearTimeout(heartbeatTimer) + heartbeatTimer = setTimeout(() => { + debug("heartbeat timeout, closing WS") + ws?.close() + }, HEARTBEAT_TIMEOUT_MS) + } + + function clearHeartbeat() { + if (heartbeatTimer) { + clearTimeout(heartbeatTimer) + heartbeatTimer = null + } + } + function connect() { if (shuttingDown) return + reconnecting = false debug("connecting", { url: wsUrl }) ws = new WebSocket(wsUrl) ws.on("open", () => { - backoff = INITIAL_BACKOFF_MS + resetHeartbeat() debug("connected, sending auth", { projectId }) const authMsg: TunnelC2S = { type: "auth", apiKey, projectId } ws!.send(JSON.stringify(authMsg)) }) ws.on("message", (raw) => { + resetHeartbeat() let msg: TunnelS2C try { msg = JSON.parse(raw.toString()) @@ -66,6 +86,7 @@ export function connectTunnel( switch (msg.type) { case "auth.ok": + backoff = INITIAL_BACKOFF_MS debug("auth succeeded", { tunnelUrl: msg.tunnelUrl }) onTunnelUrl(msg.tunnelUrl) break @@ -74,6 +95,7 @@ export function connectTunnel( debug("auth failed", { reason: msg.reason }) onError(new Error(msg.reason)) shuttingDown = true + clearHeartbeat() ws?.close() break @@ -94,14 +116,17 @@ export function connectTunnel( ws.on("close", (code, reason) => { debug("connection closed", { code, reason: reason.toString() }) - abortAllInFlight() - if (!shuttingDown) scheduleReconnect() + clearHeartbeat() + if (!reconnecting) { + reconnecting = true + abortAllInFlight() + if (!shuttingDown) scheduleReconnect() + } }) ws.on("error", (err) => { debug("connection error", { error: err.message }) abortAllInFlight() - if (!shuttingDown) scheduleReconnect() }) } @@ -204,6 +229,7 @@ export function connectTunnel( return () => { shuttingDown = true + clearHeartbeat() if (reconnectTimer) clearTimeout(reconnectTimer) abortAllInFlight() ws?.close() From ea171deeabb55743f9327bbd75251cb7d7881059 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:58:36 +0530 Subject: [PATCH 04/10] fix(git-handler): timing-safe username check and case-insensitive hash regex The username was compared with === while the password used timingSafeEqual, leaking timing info. Now both use timingSafeEqual. Also removed the encodeURI re-encoding on the pathname and made the commit hash regex accept uppercase hex digits. --- packages/crosscode/src/git-handler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/crosscode/src/git-handler.ts b/packages/crosscode/src/git-handler.ts index cfb7ee8..6eaebb1 100644 --- a/packages/crosscode/src/git-handler.ts +++ b/packages/crosscode/src/git-handler.ts @@ -82,7 +82,9 @@ function checkAuth(req: http.IncomingMessage, sessionToken: string): boolean { if (sep === -1) return false const user = decoded.slice(0, sep) const pass = decoded.slice(sep + 1) - return user === "opencode" && timingSafeEqualStr(pass, sessionToken) + const userOk = timingSafeEqualStr(user, "opencode") + const passOk = timingSafeEqualStr(pass, sessionToken) + return userOk && passOk } catch { return false } @@ -231,12 +233,12 @@ async function handleGitCommit( export async function handleGitRequest(req: http.IncomingMessage, res: http.ServerResponse, opts: GitHandlerOpts): Promise { let pathname: string try { - pathname = encodeURI(new URL(req.url || "/", "http://localhost").pathname) + pathname = new URL(req.url || "/", "http://localhost").pathname } catch { return false } - const isGitRoute = pathname === "/git-log" || /^\/git-commit\/[0-9a-f]{7,40}$/.test(pathname) + const isGitRoute = pathname === "/git-log" || /^\/git-commit\/[0-9a-fA-F]{7,40}$/.test(pathname) if (!isGitRoute) return false debug("git route matched", { pathname, method: req.method }) From 6a8d51a4653036d55c14a079160eb2a65d8766e1 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:58:44 +0530 Subject: [PATCH 05/10] fix(crosscode): move shared to dependencies, drop dead main field @crosscode/shared is imported at runtime in cli.ts, so it belongs in dependencies not devDependencies. Removed the main field pointing to dist/index.js which was never generated. --- packages/crosscode/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/crosscode/package.json b/packages/crosscode/package.json index 8687fa3..d13372a 100644 --- a/packages/crosscode/package.json +++ b/packages/crosscode/package.json @@ -10,7 +10,6 @@ }, "version": "0.6.9", "description": "An OpenCode Remote Client CLI", - "main": "./dist/index.js", "scripts": { "build": "tsup src/cli.ts --out-dir dist --format esm --clean", "dev": "tsup src/cli.ts --out-dir dist --format esm --watch", @@ -24,7 +23,6 @@ "author": "snhsish", "license": "MIT", "devDependencies": { - "@crosscode/shared": "workspace:^", "@types/node": "^26.0.0", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.5.13", @@ -32,6 +30,7 @@ "typescript": "^5.4.0" }, "dependencies": { + "@crosscode/shared": "workspace:^", "chalk": "^5.6.2", "ora": "^9.4.1", "qrcode-terminal": "^0.12.0", From 00373b521dd8536e9fd9af2e1d31855edd949efe Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 22:59:00 +0530 Subject: [PATCH 06/10] refactor(crosscode): split cli.ts into focused modules The 1120-line cli.ts was doing everything: logging, config, auth, proxying, and three tunnel providers with near-identical spawn logic copy-pasted across each. Now split into: util.ts - debug, censor, dep check, port alloc log.ts - rotation, streams, async flush on shutdown config.ts - types, read/save, project/session identity auth.ts - login flow, API key validation, prompt input proxy.ts - HTTP proxy server, URL sanitization opencode.ts - shared opencode spawn+port-detect helper providers/ - one file per tunnel backend cli.ts is now ~270 lines of arg parsing and orchestration. This also fixes the tunnel-to-cloudflared fallback that never actually started cloudflared (the flag was set async after the if/else chain had already evaluated), and makes shutdown await log buffer flush before calling process.exit. --- packages/crosscode/src/auth.ts | 135 +++ packages/crosscode/src/cli.ts | 959 +----------------- packages/crosscode/src/config.ts | 114 +++ packages/crosscode/src/log.ts | 64 ++ packages/crosscode/src/opencode.ts | 55 + .../crosscode/src/providers/cloudflared.ts | 153 +++ packages/crosscode/src/providers/ngrok.ts | 86 ++ packages/crosscode/src/providers/tunnel.ts | 106 ++ packages/crosscode/src/proxy.ts | 167 +++ packages/crosscode/src/util.ts | 74 ++ 10 files changed, 1010 insertions(+), 903 deletions(-) create mode 100644 packages/crosscode/src/auth.ts create mode 100644 packages/crosscode/src/config.ts create mode 100644 packages/crosscode/src/log.ts create mode 100644 packages/crosscode/src/opencode.ts create mode 100644 packages/crosscode/src/providers/cloudflared.ts create mode 100644 packages/crosscode/src/providers/ngrok.ts create mode 100644 packages/crosscode/src/providers/tunnel.ts create mode 100644 packages/crosscode/src/proxy.ts create mode 100644 packages/crosscode/src/util.ts diff --git a/packages/crosscode/src/auth.ts b/packages/crosscode/src/auth.ts new file mode 100644 index 0000000..5c8fd8f --- /dev/null +++ b/packages/crosscode/src/auth.ts @@ -0,0 +1,135 @@ +import chalk from "chalk" +import ora from "ora" +import type { Config } from "./config" +import { saveConfig } from "./config" +import { debug, openBrowser } from "./util" + +const WEB_URL = process.env.CROSSCODE_WEB_URL || "https://crosscode.site" +const AUTH_API_URL = process.env.CROSSCODE_AUTH_URL || `${WEB_URL}/api/auth` + +export function promptInput(prompt: string): Promise { + return new Promise((resolve) => { + process.stdout.write(prompt) + process.stdin.resume() + process.stdin.setEncoding("utf8") + process.stdin.setRawMode(true) + + let input = "" + const onData = (char: string) => { + if (char === "\r" || char === "\n") { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", onData) + process.stdin.pause() + console.log() + resolve(input) + } else if (char === "\u0003") { + process.stdin.setRawMode(false) + process.stdin.removeListener("data", onData) + process.stdin.pause() + console.log() + process.exit(1) + } else if (char === "\u007F" || char === "\b") { + if (input.length > 0) { + input = input.slice(0, -1) + process.stdout.write("\b \b") + } + } else { + input += char + process.stdout.write(char) + } + } + + process.stdin.on("data", onData) + }) +} + +export async function validateApiKey(apiKey: string): Promise<{ email: string; name: string; tier: string } | null> { + try { + debug("validating API key", { keyPrefix: apiKey.substring(0, 8) + "..." }) + const response = await fetch(`${AUTH_API_URL}/api-key/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey }), + }) + if (!response.ok) { + console.log(chalk.dim(`\n Server returned ${response.status}`)) + debug("API key validation failed", { status: response.status }) + return null + } + const data = await response.json() as { email: string; name: string; tier: string } + debug("API key validated", { email: data.email, tier: data.tier }) + return { email: data.email, name: data.name, tier: data.tier } + } catch (err) { + debug("API key validation error", { error: err instanceof Error ? err.message : String(err) }) + console.log(chalk.dim(`\n Connection failed: ${err instanceof Error ? err.message : err}`)) + return null + } +} + +export async function refreshTier(config: Config): Promise { + if (!config.auth?.sessionToken) return + try { + const result = await validateApiKey(config.auth.sessionToken) + if (!result) return + if (result.tier !== config.auth.tier || result.email !== config.auth.email) { + config.auth.tier = result.tier + config.auth.email = result.email + saveConfig(config) + debug("tier refreshed", { tier: result.tier, email: result.email }) + } + } catch (err) { + debug("tier refresh failed", { error: err instanceof Error ? err.message : String(err) }) + } +} + +export async function loginFlow(config: Config): Promise { + console.log(chalk.cyan("\n CrossCode Authentication\n")) + console.log(chalk.white(" Sign in to unlock dedicated tunnels and unlimited connections.\n")) + + const loginUrl = `${WEB_URL}/login` + console.log(chalk.blue(" Opening browser...")) + console.log(chalk.dim(` If browser doesn't open, visit: ${loginUrl}\n`)) + + openBrowser(loginUrl) + + console.log(chalk.white(" After logging in, you'll see an API key on the dashboard.")) + console.log(chalk.dim(" Copy the API key and paste it below.\n")) + + const apiKey = (await promptInput(chalk.yellow(" API Key: "))).trim() + + if (!apiKey) { + console.log(chalk.red("\n API key is required.\n")) + return false + } + + const spinner = ora(chalk.blue("Validating API key...")).start() + const result = await validateApiKey(apiKey) + spinner.stop() + + if (!result) { + console.log(chalk.red("\n Invalid API key. Please try again.\n")) + return false + } + + config.auth = { + email: result.email, + sessionToken: apiKey, + tier: result.tier, + } + saveConfig(config) + + console.log(chalk.green(`\n Logged in as ${result.email}`)) + console.log(chalk.dim(` Tier: ${result.tier}\n`)) + return true +} + +// Fix I2: use promptInput instead of raw stdin listener for consistency +export async function setupNgrokToken(): Promise { + console.log(chalk.cyan("\n ngrok requires a free auth token.\n")) + console.log(chalk.white(" 1. Sign up at: ") + chalk.underline.blue("https://dashboard.ngrok.com/signup")) + console.log(chalk.white(" 2. Get your token at: ") + chalk.underline.blue("https://dashboard.ngrok.com/get-started/your-authtoken")) + console.log() + + const token = (await promptInput(chalk.yellow(" Paste your ngrok auth token: "))).trim() + return token +} diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index 03867f5..399c23e 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -1,275 +1,23 @@ #!/usr/bin/env node -import { spawn, execFile, execFileSync } from "child_process" -import { createWriteStream, mkdirSync, existsSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync } from "fs" import { readFile } from "fs/promises" -import { join } from "path" -import { homedir } from "os" -import qrcode from "qrcode-terminal" import chalk from "chalk" -import ora from "ora" -import crypto from "crypto" -import http from "http" -import net from "net" +import qrcode from "qrcode-terminal" import { encodeQrPayload } from "@crosscode/shared" +import { debug, checkDep, setLogWriter, getFreePort } from "./util" +import { logCrosscode, closeAllLogs, crosscodeLogFile, cloudflaredLogFile, opencodeLogFile } from "./log" +import { readConfig, saveConfig, getProjectConfig, ensureSessionToken } from "./config" +import { loginFlow, refreshTier } from "./auth" +import { proxyAgent } from "./proxy" import { onKeypress, cleanupKeypress } from "./keypress" -import { connectTunnel } from "./tunnel-client" -import { handleGitRequest } from "./git-handler" -import { waitForOpencodePort } from "./port-detect" +import { startTunnelProvider } from "./providers/tunnel" +import { startCloudflaredProvider } from "./providers/cloudflared" +import { startNgrokProvider } from "./providers/ngrok" const children: import("child_process").ChildProcess[] = [] let isShuttingDown = false -const logDir = join(homedir(), ".crosscode") -const configFile = join(logDir, "config.json") -const MAX_LOG_SIZE = 1024 * 1024 -const DEBUG = process.env.CROSSCODE_DEBUG === "1" -const MAX_BODY_SIZE = 10 * 1024 * 1024 -const HOP_BY_HOP = new Set(["host", "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer"]) - -const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 50 }) - -function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer() - srv.on("error", reject) - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address() as net.AddressInfo - const port = addr.port - srv.close(() => resolve(port)) - }) - }) -} - -if (!existsSync(logDir)) - mkdirSync(logDir, { - recursive: true, - mode: 0o700, - }) - -const crosscodeLogFile = join(logDir, "crosscode.log") -const cloudflaredLogFile = join(logDir, "cloudflared.log") -const opencodeLogFile = join(logDir, "opencode.log") -const ngrokLogFile = join(logDir, "ngrok.log") - -function rotateLogIfNeeded(logFile: string) { - try { - if (existsSync(logFile)) { - const stats = statSync(logFile) - if (stats.size > MAX_LOG_SIZE) { - const backup = `${logFile}.1` - if (existsSync(backup)) unlinkSync(backup) - renameSync(logFile, backup) - } - } - } catch {} -} - -rotateLogIfNeeded(crosscodeLogFile) -rotateLogIfNeeded(cloudflaredLogFile) -rotateLogIfNeeded(opencodeLogFile) -rotateLogIfNeeded(ngrokLogFile) - -const crosscodeLogStream = createWriteStream(crosscodeLogFile, { flags: "a", mode: 0o600 }) -const cloudflaredLogStream = createWriteStream(cloudflaredLogFile, { flags: "a", mode: 0o600 }) -const opencodeLogStream = createWriteStream(opencodeLogFile, { flags: "a", mode: 0o600 }) -const ngrokLogStream = createWriteStream(ngrokLogFile, { flags: "a", mode: 0o600 }) - -function logCrosscode(msg: string) { - crosscodeLogStream.write(`${new Date().toISOString()} ${msg}\n`) -} - -function debug(msg: string, meta?: Record) { - if (DEBUG) { - const ts = new Date().toISOString() - const extra = meta ? ` ${JSON.stringify(meta)}` : "" - const line = `[${ts}] [DEBUG] ${msg}${extra}` - console.log(chalk.dim(line)) - logCrosscode(line) - } -} - -function censorAuth(val: string | undefined): string { - if (!val) return "" - if (val.startsWith("Basic ")) { - return `Basic ${val.substring(6, 14)}...` - } - return `${val.substring(0, 8)}...` -} - -function censorToken(val: string): string { - if (val.length <= 16) return "***" - return `${val.substring(0, 8)}...${val.substring(val.length - 4)}` -} - -function checkDep(name: string): boolean { - const finder = process.platform === "win32" ? "where" : "which" - try { - execFileSync(finder, [name], { stdio: "ignore" }) - return true - } catch { - return false - } -} - -function spawnCmd(cmd: string, args: string[], opts: Parameters[2] = {}) { - return spawn(cmd, args, { ...opts, shell: false }) -} - -type CloudflaredTunnel = { - name: string - credentialsPath: string - url: string -} - -type ProjectConfig = { - path?: string - sessionToken?: string - projectId?: string - cloudflaredTunnel?: CloudflaredTunnel - port?: number -} - -type Config = { - ngrokToken?: string - port?: number - tunnelWsUrl?: string - sessionToken?: string - projectId?: string - cloudflaredTunnel?: CloudflaredTunnel - projects?: Record - auth?: { - email?: string - sessionToken?: string - tier?: string - } -} - -function readConfig(): Config { - if (!existsSync(configFile)) return {} - try { - return JSON.parse(readFileSync(configFile, "utf-8")) - } catch { - return {} - } -} - -function saveConfig(config: Config) { - writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) -} - -function getProjectKey(): string { - const cwd = process.cwd() - const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 16) - return process.env.CROSSCODE_PROJECT_KEY || hash -} - -function getProjectConfig(config: Config): ProjectConfig { - const key = getProjectKey() - if (!config.projects) config.projects = {} - if (!config.projects[key]) { - const legacy: ProjectConfig = {} - let migrated = false - if (config.projectId && !Object.values(config.projects).some((p) => p.projectId === config.projectId)) { - legacy.projectId = config.projectId - migrated = true - } - if (config.sessionToken && !Object.values(config.projects).some((p) => p.sessionToken === config.sessionToken)) { - legacy.sessionToken = config.sessionToken - migrated = true - } - if (config.cloudflaredTunnel && !Object.values(config.projects).some((p) => p.cloudflaredTunnel?.name === config.cloudflaredTunnel?.name)) { - legacy.cloudflaredTunnel = config.cloudflaredTunnel - migrated = true - } - config.projects[key] = { path: process.cwd(), ...legacy } - if (migrated) { - saveConfig(config) - logCrosscode(`Migrated legacy global identity to project ${process.cwd()} (key: ${key})`) - } - } else if (!config.projects[key].path) { - config.projects[key].path = process.cwd() - } - return config.projects[key] -} - -function saveProjectConfig(config: Config) { - const key = getProjectKey() - if (config.projects?.[key]) config.projects[key].path = process.cwd() - saveConfig(config) -} - -// Stable, persistent per-project session identity so the QR/URL stays the -// same across CLI restarts and network blips, without leaking the same -// identity across different project directories. Without this the opencode -// password + QR token are randomized every run, making it impossible for a -// returning mobile client to reconnect. -function ensureSessionToken(config: Config, project?: ProjectConfig): string { - const target = project ?? getProjectConfig(config) - if (!target.sessionToken) { - target.sessionToken = crypto.randomBytes(32).toString("hex") - saveProjectConfig(config) - logCrosscode(`Session token generated for ${process.cwd()} (censored: ${censorToken(target.sessionToken)})`) - } else { - debug("session token reused from project config", { cwd: process.cwd() }) - } - return target.sessionToken -} - -function ensureProjectId(config: Config, project?: ProjectConfig): string { - const target = project ?? getProjectConfig(config) - if (!target.projectId) { - target.projectId = crypto.randomBytes(4).toString("hex") - saveProjectConfig(config) - logCrosscode(`Project ID generated for ${process.cwd()}: ${target.projectId}`) - } else { - debug("project ID reused from project config", { projectId: target.projectId, cwd: process.cwd() }) - } - return target.projectId -} -const cloudflaredTunnelDir = join(logDir, "cloudflared") -const cfCertPath = join(homedir(), ".cloudflared", "cert.pem") - -// Use a persistent named cloudflared tunnel so the public URL is stable -// (.cfargotunnel.com) across restarts and reconnects, instead of -// the random ephemeral quick tunnel that rotates its URL on every (re)start. -// Returns null when cloudflared isn't logged in yet, falling back to the -// quick tunnel (which still works but changes URL on restart). -async function ensureCloudflaredNamedTunnel(config: Config, project?: ProjectConfig): Promise<{ name: string; credentialsPath: string; url: string } | null> { - if (!existsSync(cfCertPath)) { - debug("cloudflared not logged in (no cert.pem); falling back to quick tunnel") - return null - } - const target = project ?? getProjectConfig(config) - if (target.cloudflaredTunnel && existsSync(target.cloudflaredTunnel.credentialsPath)) { - return target.cloudflaredTunnel - } - const projectId = ensureProjectId(config, target) - const name = `crosscode-${projectId}` - const credentialsPath = join(cloudflaredTunnelDir, `${name}.json`) - try { - if (!existsSync(cloudflaredTunnelDir)) mkdirSync(cloudflaredTunnelDir, { recursive: true, mode: 0o700 }) - await new Promise((resolve, reject) => { - execFile("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], { stdio: "ignore" }, (err) => { - if (err) reject(err) - else resolve() - }) - }) - } catch (e) { - debug("cloudflared tunnel create failed", { error: (e as Error).message }) - return null - } - let url = "" - try { - const creds = JSON.parse(readFileSync(credentialsPath, "utf-8")) - const id = creds.TunnelID || creds.id - if (id) url = `https://${id}.cfargotunnel.com` - } catch {} - target.cloudflaredTunnel = { name, credentialsPath, url } - saveProjectConfig(config) - logCrosscode(`Cloudflared named tunnel created: ${name} (${url})`) - return target.cloudflaredTunnel -} +setLogWriter(logCrosscode) function printTunnelQr(url: string, token: string, opencodePort: number, requestedPort: number) { const payload = encodeQrPayload({ url, token, v: 1 }) @@ -282,316 +30,12 @@ function printTunnelQr(url: string, token: string, opencodePort: number, request console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) } -const WEB_URL = process.env.CROSSCODE_WEB_URL || "https://crosscode.site" -const AUTH_API_URL = process.env.CROSSCODE_AUTH_URL || `${WEB_URL}/api/auth` - -function promptInput(prompt: string): Promise { - return new Promise((resolve) => { - process.stdout.write(prompt) - process.stdin.resume() - process.stdin.setEncoding("utf8") - process.stdin.setRawMode(true) - - let input = "" - const onData = (char: string) => { - if (char === "\r" || char === "\n") { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", onData) - process.stdin.pause() - console.log() - resolve(input) - } else if (char === "\u0003") { - process.stdin.setRawMode(false) - process.stdin.removeListener("data", onData) - process.stdin.pause() - console.log() - process.exit(1) - } else if (char === "\u007F" || char === "\b") { - if (input.length > 0) { - input = input.slice(0, -1) - process.stdout.write("\b \b") - } - } else { - input += char - process.stdout.write(char) - } - } - - process.stdin.on("data", onData) - }) -} - -function openBrowser(url: string): void { - if (!url.startsWith("https://") && !url.startsWith("http://")) return - const platform = process.platform - try { - if (platform === "darwin") { - execFileSync("open", [url]) - } else if (platform === "win32") { - execFileSync("cmd", ["/c", "start", "", url]) - } else { - execFileSync("xdg-open", [url]) - } - } catch {} -} - -async function validateApiKey(apiKey: string): Promise<{ email: string; name: string; tier: string } | null> { try { - debug("validating API key", { keyPrefix: apiKey.substring(0, 8) + "..." }) - const response = await fetch(`${AUTH_API_URL}/api-key/validate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey }), - }) - if (!response.ok) { - console.log(chalk.dim(`\n Server returned ${response.status}`)) - debug("API key validation failed", { status: response.status }) - return null - } - const data = await response.json() - debug("API key validated", { email: data.email, tier: data.tier }) - return { email: data.email, name: data.name, tier: data.tier } - } catch (err) { - debug("API key validation error", { error: err instanceof Error ? err.message : String(err) }) - console.log(chalk.dim(`\n Connection failed: ${err instanceof Error ? err.message : err}`)) - return null - } -} - -async function refreshTier(config: Config): Promise { - if (!config.auth?.sessionToken) return - try { - const result = await validateApiKey(config.auth.sessionToken) - if (!result) return - if (result.tier !== config.auth.tier || result.email !== config.auth.email) { - config.auth.tier = result.tier - config.auth.email = result.email - saveConfig(config) - debug("tier refreshed", { tier: result.tier, email: result.email }) - } - } catch (err) { - debug("tier refresh failed", { error: err instanceof Error ? err.message : String(err) }) - } -} - -async function loginFlow(config: Config): Promise { - console.log(chalk.cyan("\n CrossCode Authentication\n")) - console.log(chalk.white(" Sign in to unlock dedicated tunnels and unlimited connections.\n")) - - const loginUrl = `${WEB_URL}/login` - console.log(chalk.blue(" Opening browser...")) - console.log(chalk.dim(` If browser doesn't open, visit: ${loginUrl}\n`)) - - openBrowser(loginUrl) - - console.log(chalk.white(" After logging in, you'll see an API key on the dashboard.")) - console.log(chalk.dim(" Copy the API key and paste it below.\n")) - - const apiKey = (await promptInput(chalk.yellow(" API Key: "))).trim() - - if (!apiKey) { - console.log(chalk.red("\n API key is required.\n")) - return false - } - - const spinner = ora(chalk.blue("Validating API key...")).start() - const result = await validateApiKey(apiKey) - spinner.stop() - - if (!result) { - console.log(chalk.red("\n Invalid API key. Please try again.\n")) - return false - } - - config.auth = { - email: result.email, - sessionToken: apiKey, - tier: result.tier, - } - saveConfig(config) - - console.log(chalk.green(`\n Logged in as ${result.email}`)) - console.log(chalk.dim(` Tier: ${result.tier}\n`)) - return true -} - -async function setupNgrokToken(): Promise { - console.log(chalk.cyan("\n ngrok requires a free auth token.\n")) - console.log(chalk.white(" 1. Sign up at: ") + chalk.underline.blue("https://dashboard.ngrok.com/signup")) - console.log(chalk.white(" 2. Get your token at: ") + chalk.underline.blue("https://dashboard.ngrok.com/get-started/your-authtoken")) +function printHelp() { + console.log(chalk.cyan("\n CrossCode Keybindings:\n")) + console.log(` ${chalk.green("l")} Toggle log viewer`) + console.log(` ${chalk.green("h")} Show this help`) + console.log(` ${chalk.green("Ctrl+C")} Shut down`) console.log() - - const token = await new Promise((resolve) => { - process.stdout.write(chalk.yellow(" Paste your ngrok auth token: ")) - process.stdin.resume() - process.stdin.setEncoding("utf8") - - const onData = (data: Buffer) => { - const input = data.toString().trim() - if (input.length > 0) { - process.stdin.removeListener("data", onData) - process.stdin.pause() - resolve(input) - } - } - - process.stdin.on("data", onData) - }) - - return token -} - -function sanitizeUrlPath(url: string | undefined): string { - if (!url || url.length === 0) return "/" - let decoded: string - try { - decoded = decodeURIComponent(url.split("#")[0]) - } catch { - return "/" - } - const queryIndex = decoded.indexOf("?") - const rawPath = queryIndex === -1 ? decoded : decoded.slice(0, queryIndex) - const rawQuery = queryIndex === -1 ? "" : decoded.slice(queryIndex + 1) - if (!rawPath.startsWith("/")) return "/" - const cleaned = rawPath.replace(/\/+/g, "/") - if (cleaned.includes("..") || cleaned.includes("@") || cleaned.includes("\\")) return "/" - return `${cleaned || "/"}${rawQuery ? `?${rawQuery}` : ""}` -} - -function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string): http.Server { - return http.createServer(async (req, res) => { - const safePath = sanitizeUrlPath(req.url) - const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` - const authHeader = req.headers["authorization"] - - debug(`${logPrefix} request received`, { - method: req.method, - url: req.url, - safePath, - hasAuth: !!authHeader, - auth: censorAuth(authHeader), - }) - - if (req.method === "OPTIONS") { - debug("handling CORS preflight") - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }) - res.end() - return - } - - if (req.url === "/mobile-event" && req.method === "POST") { - debug("handling SSE request") - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - }) - - let sseAuth = authHeader || "" - if (sseAuth && !sseAuth.startsWith("Basic ")) { - sseAuth = `Basic ${Buffer.from(`:${sseAuth}`).toString("base64")}` - debug("converted SSE auth to Basic format") - } - - const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { - headers: { - "Accept": "text/event-stream", - "Authorization": sseAuth, - }, - }, (sseRes) => { - debug("SSE upstream connected", { status: sseRes.statusCode }) - sseRes.on("data", (chunk) => { res.write(chunk) }) - sseRes.on("end", () => { debug("SSE upstream ended"); res.end() }) - }) - - sseReq.on("error", (err) => { - debug("SSE upstream error", { error: err.message }) - res.end() - }) - - req.on("close", () => { - debug("SSE client disconnected") - sseReq.destroy() - }) - - return - } - - if (await handleGitRequest(req, res, { worktree: process.cwd(), sessionToken })) { - return - } - - const forwardHeaders: Record = {} - for (const [key, value] of Object.entries(req.headers)) { - if (!HOP_BY_HOP.has(key.toLowerCase())) forwardHeaders[key] = value - } - forwardHeaders["host"] = `127.0.0.1:${targetPort}` - - if (authHeader && !authHeader.startsWith("Basic ")) { - forwardHeaders["authorization"] = `Basic ${Buffer.from(`:${authHeader}`).toString("base64")}` - debug("converted auth to Basic format") - } - - debug("forwarding to opencode", { - targetUrl, - method: req.method, - hasAuth: !!forwardHeaders["authorization"], - auth: censorAuth(forwardHeaders["authorization"] as string), - }) - - const proxyReq = http.request(targetUrl, { - method: req.method, - headers: forwardHeaders, - agent: proxyAgent, - }, (proxyRes) => { - debug("opencode responded", { status: proxyRes.statusCode, method: req.method, path: safePath }) - res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) - proxyRes.pipe(res) - }) - - proxyReq.on("error", (err) => { - debug("proxy request error", { error: err.message }) - res.writeHead(502) - res.end("Bad Gateway") - }) - - let bodySize = 0 - let bodyTooLarge = false - - req.on("data", (chunk) => { - bodySize += chunk.length - if (bodySize > MAX_BODY_SIZE) { - bodyTooLarge = true - debug("request body too large", { size: bodySize, max: MAX_BODY_SIZE }) - req.destroy() - proxyReq.destroy() - if (!res.headersSent) { - res.writeHead(413) - res.end("Request body too large") - } - return - } - proxyReq.write(chunk) - }) - - req.on("end", () => { - if (!bodyTooLarge) proxyReq.end() - }) - - req.on("error", (err) => { - debug("request stream error", { error: err.message }) - proxyReq.destroy() - if (!res.headersSent) { - res.writeHead(500) - res.end("Internal Server Error") - } - }) - }) } async function main() { @@ -673,7 +117,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} let logsVisible = false let tunnelUrl = "" - logCrosscode(`CrossCode starting up (tunnel: ${tunnelProvider}, debug: ${DEBUG})`) + logCrosscode(`CrossCode starting up (tunnel: ${tunnelProvider}, debug: ${process.env.CROSSCODE_DEBUG === "1"})`) debug("startup config", { tunnelProvider, port, hasAuth: !!config.auth?.sessionToken }) if (!checkDep("opencode")) { @@ -714,328 +158,38 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} chalk.dim(" to use the free built-in CrossCode tunnel.\n")) } - let tunnelFailed = false - - if (tunnelProvider === "tunnel") { - const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - - const sessionToken = ensureSessionToken(config, project) - - const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { - cwd: process.cwd(), - env: { ...process.env, OPENCODE_SERVER_PASSWORD: sessionToken }, - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(opencode) - - let opencodePort = port - - opencode.on("spawn", () => { - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Detecting port...") - logCrosscode("opencode serve started (PID: " + opencode.pid + ")") - debug("opencode spawned", { pid: opencode.pid }) - }) - opencode.on("error", (err) => { - spinner.fail(chalk.red.italic("Failed to start opencode serve")) - logCrosscode("opencode serve error: " + err.message) - debug("opencode spawn error", { error: err.message }) - }) - - let proxyPort = await getFreePort() - while (proxyPort === port) proxyPort = await getFreePort() - - waitForOpencodePort({ proc: opencode, requestedPort: port, onData: d => opencodeLogStream.write(d) }).then((detectedPort) => { - opencodePort = detectedPort - startProxy(detectedPort, proxyPort, sessionToken, port, spinner) - }) - - function startProxy(targetPort: number, proxyPort: number, sessionToken: string, requestedPort: number, spinner: any) { - if (targetPort !== requestedPort) { - logCrosscode(`Using detected port ${targetPort} instead of requested port ${requestedPort}`) - debug("using detected port", { detected: targetPort, requested: requestedPort }) - } - - const proxy = createOpencodeProxy(targetPort, sessionToken, "tunnel") - - proxy.listen(proxyPort, "127.0.0.1", () => { - logCrosscode(`SSE proxy started on port ${proxyPort}`) - debug("proxy listening", { port: proxyPort, targetPort }) - - const testReq = http.request(`http://127.0.0.1:${targetPort}/global/health`, { - method: "GET", - headers: { - "Authorization": `Basic ${Buffer.from(`opencode:${sessionToken}`).toString("base64")}` - }, - agent: proxyAgent, - }, (testRes) => { - debug("health check result", { status: testRes.statusCode }) - logCrosscode(`Direct test to opencode: ${testRes.statusCode}`) - }) - testReq.on("error", (e) => { - debug("health check failed", { error: e.message }) - logCrosscode(`Direct test to opencode failed: ${e.message}`) - }) - testReq.end() - - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") - - const projectId = ensureProjectId(config, project) - logCrosscode(`Project ID: ${projectId}`) - debug("connecting to tunnel", { projectId, proxyPort }) - - const tunnelTimeout = setTimeout(() => { - spinner.fail(chalk.red.italic("Tunnel connection timed out")) - logCrosscode("Tunnel connection timed out, falling back to cloudflared") - debug("tunnel connection timeout") - console.log(chalk.yellow("\n Falling back to Cloudflare tunnel...\n")) - children.forEach(c => c.kill()) - children.length = 0 - tunnelFailed = true - }, 15_000) - - const disconnectTunnel = connectTunnel( - config.auth!.sessionToken!, - projectId, - proxyPort, - config.tunnelWsUrl, - (url) => { - clearTimeout(tunnelTimeout) - if (tunnelUrl) { - if (tunnelUrl !== url) { - tunnelUrl = url - logCrosscode("Tunnel URL changed: " + tunnelUrl) - console.log(chalk.yellow(`\n Tunnel URL changed: ${tunnelUrl}\n`)) - } - return - } - tunnelUrl = url - spinner.succeed(chalk.green("Tunnel ready")) - logCrosscode("Tunnel ready: " + tunnelUrl) - debug("tunnel connected", { tunnelUrl }) - printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) - }, - (err) => { - clearTimeout(tunnelTimeout) - spinner.fail(chalk.red.italic(`Tunnel error: ${err.message}`)) - logCrosscode(`Tunnel error: ${err.message}, falling back to cloudflared`) - debug("tunnel error", { error: err.message }) - console.log(chalk.yellow("\n Falling back to Cloudflare tunnel...\n")) - children.forEach(c => c.kill()) - children.length = 0 - disconnectTunnel() - tunnelFailed = true - } - ) - }) - } + // Fix C1: cloudflared fallback is invoked as a function, not gated behind an + // else-if that already evaluated before the async tunnel error occurs. + const launchCloudflaredFallback = () => { + children.forEach(c => c.kill()) + children.length = 0 + startCloudflaredProvider( + config, project, port, children, + () => isShuttingDown, + (url) => { tunnelUrl = url }, + printTunnelQr, + ) } - if (tunnelProvider === "ngrok") { - let ngrokToken = config.ngrokToken - - if (!ngrokToken) { - ngrokToken = await setupNgrokToken() - config.ngrokToken = ngrokToken - saveConfig(config) - logCrosscode("ngrok auth token saved") - debug("ngrok token saved") - } - - const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - - const sessionToken = ensureSessionToken(config, project) - - const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { - cwd: process.cwd(), - env: { ...process.env, OPENCODE_SERVER_PASSWORD: sessionToken }, - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(opencode) - - let opencodePort = port - - opencode.on("spawn", () => { - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Detecting port...") - logCrosscode("opencode serve started (PID: " + opencode.pid + ")") - debug("opencode spawned", { pid: opencode.pid }) - }) - opencode.on("error", (err) => { - spinner.fail(chalk.red.italic("Failed to start opencode serve")) - logCrosscode("opencode serve error: " + err.message) - debug("opencode spawn error", { error: err.message }) - }) - - waitForOpencodePort({ proc: opencode, requestedPort: port, onData: d => opencodeLogStream.write(d) }).then((detectedPort) => { - opencodePort = detectedPort - if (detectedPort !== port) { - logCrosscode(`Using detected port ${detectedPort} instead of requested port ${port}`) - debug("using detected port", { detected: detectedPort, requested: port }) - } - - const ngrok = spawnCmd("ngrok", ["http", `--authtoken=${ngrokToken}`, `${detectedPort}`], { - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(ngrok) - - ngrok.on("spawn", () => { - logCrosscode("ngrok started (PID: " + ngrok.pid + ")") - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Starting ngrok tunnel...") - debug("ngrok spawned", { pid: ngrok.pid }) - }) - ngrok.on("error", (err) => { - logCrosscode("ngrok error: " + err.message) - debug("ngrok error", { error: err.message }) - }) - ngrok.stdout?.on("data", d => { ngrokLogStream.write(d); cloudflaredLogStream.write(d) }) - ngrok.stderr?.on("data", d => { ngrokLogStream.write(d); cloudflaredLogStream.write(d) }) - - const pollNgrokApi = () => { - debug("polling ngrok API") - const req = http.get("http://127.0.0.1:4040/api/tunnels", { agent: proxyAgent }, (res) => { - let data = "" - res.on("data", chunk => data += chunk) - res.on("end", () => { - debug("ngrok API response", { size: data.length }) - try { - const json = JSON.parse(data) - if (json.tunnels && json.tunnels.length > 0 && !tunnelUrl) { - tunnelUrl = json.tunnels[0].public_url - spinner.succeed(chalk.green("Tunnel ready")) - logCrosscode("ngrok tunnel ready: " + tunnelUrl) - debug("ngrok tunnel ready", { tunnelUrl }) - printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) - } - } catch (e) { - debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) - setTimeout(pollNgrokApi, 500) - } - }) - }) - req.on("error", (e) => { - debug("ngrok API request error", { error: e.message }) - setTimeout(pollNgrokApi, 500) - }) - } - - setTimeout(pollNgrokApi, 1000) - }) - } else if (tunnelProvider === "cloudflared" || tunnelFailed) { - const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - - const sessionToken = ensureSessionToken(config, project) - - const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { - cwd: process.cwd(), - env: { ...process.env, OPENCODE_SERVER_PASSWORD: sessionToken }, - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(opencode) - - let opencodePort = port - - opencode.on("spawn", () => { - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Detecting port...") - logCrosscode("opencode serve started (PID: " + opencode.pid + ")") - debug("opencode spawned", { pid: opencode.pid }) - }) - opencode.on("error", (err) => { - spinner.fail(chalk.red.italic("Failed to start opencode serve")) - logCrosscode("opencode serve error: " + err.message) - debug("opencode spawn error", { error: err.message }) - }) - - let proxyPort = await getFreePort() - while (proxyPort === port) proxyPort = await getFreePort() - - waitForOpencodePort({ proc: opencode, requestedPort: port, onData: d => opencodeLogStream.write(d) }).then((detectedPort) => { - opencodePort = detectedPort - if (detectedPort !== port) { - logCrosscode(`Using detected port ${detectedPort} instead of requested port ${port}`) - debug("using detected port", { detected: detectedPort, requested: port }) - } - - const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy") - - proxy.listen(proxyPort, "127.0.0.1", async () => { - logCrosscode(`SSE proxy started on port ${proxyPort}`) - debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") - - const namedTunnel = await ensureCloudflaredNamedTunnel(config, project) - if (!namedTunnel) { - logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") - } - - function setTunnelUrl(url: string) { - if (tunnelUrl) return - tunnelUrl = url - spinner.succeed(chalk.green("Tunnel ready")) - logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) - debug("cloudflare tunnel ready", { tunnelUrl }) - printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) - } - - function startCloudflared(): import("child_process").ChildProcess { - let cfArgs: string[] - if (namedTunnel) { - const cfgPath = join(cloudflaredTunnelDir, `${namedTunnel.name}.yml`) - writeFileSync(cfgPath, `url: http://127.0.0.1:${proxyPort}\ntunnel: ${namedTunnel.name}\ncredentials-file: ${namedTunnel.credentialsPath}\n`, { mode: 0o600 }) - cfArgs = ["tunnel", "--no-autoupdate", "--config", cfgPath, "run"] - } else { - cfArgs = ["tunnel", "--no-autoupdate", "--config", "/dev/null", "--url", `http://127.0.0.1:${proxyPort}`] - } - - const cf = spawnCmd("cloudflared", cfArgs, { stdio: ["ignore", "pipe", "pipe"] }) - children.push(cf) - - cf.on("spawn", () => { - logCrosscode("cloudflared started (PID: " + cf.pid + ")") - debug("cloudflared spawned", { pid: cf.pid }) - }) - cf.on("error", (err) => { - logCrosscode("cloudflared error: " + err.message) - debug("cloudflared error", { error: err.message }) - }) - - cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) - - cf.stderr?.on("data", (data: Buffer) => { - const text = data.toString() - const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(trycloudflare|cfargotunnel)\.com/) - if (m && !tunnelUrl) setTunnelUrl(m[0]) - cloudflaredLogStream.write(data) - }) - - cf.on("exit", (code) => { - logCrosscode("cloudflared exited (code: " + code + ")") - if (isShuttingDown) return - if (namedTunnel) { - logCrosscode("Restarting cloudflared (named tunnel keeps stable URL)") - console.log(chalk.yellow("\n Cloudflare tunnel dropped — reconnecting (URL unchanged)...\n")) - startCloudflared() - } else { - console.log(chalk.red("\n Cloudflare tunnel exited. The session URL may have changed — restart crosscode to reconnect.\n")) - } - }) - - return cf - } - - const cf = startCloudflared() - - if (namedTunnel?.url && !tunnelUrl) { - const urlFallbackTimer = setTimeout(() => { - if (!tunnelUrl) setTunnelUrl(namedTunnel.url) - }, 8000) - cf.on("exit", () => clearTimeout(urlFallbackTimer)) - } - }) - + if (tunnelProvider === "tunnel") { + await startTunnelProvider(config, project, port, children, { + onTunnelUrl: (url) => { tunnelUrl = url }, + printQr: printTunnelQr, + onFallbackNeeded: launchCloudflaredFallback, }) + } else if (tunnelProvider === "ngrok") { + await startNgrokProvider( + config, project, port, children, + (url) => { tunnelUrl = url }, + printTunnelQr, + ) + } else { + await startCloudflaredProvider( + config, project, port, children, + () => isShuttingDown, + (url) => { tunnelUrl = url }, + printTunnelQr, + ) } const toggleLogs = async () => { @@ -1069,18 +223,17 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } } - const shutdown = (source?: string) => { + // Fix I1: await log stream flush before exiting + const shutdown = async (source?: string) => { + if (isShuttingDown) return isShuttingDown = true console.log(chalk.yellow("\nShutting down...")) logCrosscode(`Shutting down... (source: ${source || "unknown"})`) debug("shutdown initiated", { source: source || "unknown" }) - crosscodeLogStream.end() - cloudflaredLogStream.end() - ngrokLogStream.end() - opencodeLogStream.end() - proxyAgent.destroy() children.forEach(c => c.kill()) cleanupKeypress() + proxyAgent.destroy() + await closeAllLogs() process.exit(0) } @@ -1098,22 +251,22 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} debug("stdin close event") }) + // Fix M2: 'h' keypress now handled onKeypress((key: string) => { if (key === "l") toggleLogs() + else if (key === "h") + printHelp() else if (key === "ctrl-c") shutdown("ctrl-c keypress") }) } main() - .catch(err => { + .catch(async err => { console.error(chalk.red(err)) - logCrosscode("Fatal error: " + err.message) - crosscodeLogStream.end() - cloudflaredLogStream.end() - ngrokLogStream.end() - opencodeLogStream.end() + logCrosscode("Fatal error: " + (err instanceof Error ? err.message : String(err))) proxyAgent.destroy() + await closeAllLogs() process.exit(1) }) diff --git a/packages/crosscode/src/config.ts b/packages/crosscode/src/config.ts new file mode 100644 index 0000000..59e4746 --- /dev/null +++ b/packages/crosscode/src/config.ts @@ -0,0 +1,114 @@ +import { existsSync, readFileSync, writeFileSync } from "fs" +import crypto from "crypto" +import { configFile, logCrosscode } from "./log" +import { debug, censorToken } from "./util" + +export type CloudflaredTunnel = { + name: string + credentialsPath: string + url: string +} + +export type ProjectConfig = { + path?: string + sessionToken?: string + projectId?: string + cloudflaredTunnel?: CloudflaredTunnel + port?: number +} + +export type Config = { + ngrokToken?: string + port?: number + tunnelWsUrl?: string + sessionToken?: string + projectId?: string + cloudflaredTunnel?: CloudflaredTunnel + projects?: Record + auth?: { + email?: string + sessionToken?: string + tier?: string + } +} + +export function readConfig(): Config { + if (!existsSync(configFile)) return {} + try { + return JSON.parse(readFileSync(configFile, "utf-8")) + } catch { + return {} + } +} + +export function saveConfig(config: Config) { + writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) +} + +export function getProjectKey(): string { + const cwd = process.cwd() + const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 16) + return process.env.CROSSCODE_PROJECT_KEY || hash +} + +export function getProjectConfig(config: Config): ProjectConfig { + const key = getProjectKey() + if (!config.projects) config.projects = {} + if (!config.projects[key]) { + const legacy: ProjectConfig = {} + let migrated = false + if (config.projectId && !Object.values(config.projects).some((p) => p.projectId === config.projectId)) { + legacy.projectId = config.projectId + migrated = true + } + if (config.sessionToken && !Object.values(config.projects).some((p) => p.sessionToken === config.sessionToken)) { + legacy.sessionToken = config.sessionToken + migrated = true + } + if (config.cloudflaredTunnel && !Object.values(config.projects).some((p) => p.cloudflaredTunnel?.name === config.cloudflaredTunnel?.name)) { + legacy.cloudflaredTunnel = config.cloudflaredTunnel + migrated = true + } + config.projects[key] = { path: process.cwd(), ...legacy } + if (migrated) { + saveConfig(config) + logCrosscode(`Migrated legacy global identity to project ${process.cwd()} (key: ${key})`) + } + } else if (!config.projects[key].path) { + config.projects[key].path = process.cwd() + } + return config.projects[key] +} + +export function saveProjectConfig(config: Config) { + const key = getProjectKey() + if (config.projects?.[key]) config.projects[key].path = process.cwd() + saveConfig(config) +} + +// Stable, persistent per-project session identity so the QR/URL stays the +// same across CLI restarts and network blips, without leaking the same +// identity across different project directories. +export function ensureSessionToken(config: Config, project?: ProjectConfig): string { + const target = project ?? getProjectConfig(config) + if (!target.sessionToken) { + target.sessionToken = crypto.randomBytes(32).toString("hex") + saveProjectConfig(config) + logCrosscode(`Session token generated for ${process.cwd()} (censored: ${censorToken(target.sessionToken)})`) + } else { + debug("session token reused from project config", { cwd: process.cwd() }) + } + return target.sessionToken +} + +export function ensureProjectId(config: Config, project?: ProjectConfig): string { + const target = project ?? getProjectConfig(config) + if (!target.projectId) { + target.projectId = crypto.randomBytes(4).toString("hex") + saveProjectConfig(config) + logCrosscode(`Project ID generated for ${process.cwd()}: ${target.projectId}`) + } else { + debug("project ID reused from project config", { projectId: target.projectId, cwd: process.cwd() }) + } + return target.projectId +} diff --git a/packages/crosscode/src/log.ts b/packages/crosscode/src/log.ts new file mode 100644 index 0000000..48ea8e9 --- /dev/null +++ b/packages/crosscode/src/log.ts @@ -0,0 +1,64 @@ +import { createWriteStream, mkdirSync, existsSync, statSync, renameSync, unlinkSync } from "fs" +import { join } from "path" +import { homedir } from "os" +import type { WriteStream } from "fs" + +const MAX_LOG_SIZE = 1024 * 1024 + +export const logDir = join(homedir(), ".crosscode") +export const configFile = join(logDir, "config.json") +export const cloudflaredTunnelDir = join(logDir, "cloudflared") +export const cfCertPath = join(homedir(), ".cloudflared", "cert.pem") + +if (!existsSync(logDir)) + mkdirSync(logDir, { recursive: true, mode: 0o700 }) + +export const crosscodeLogFile = join(logDir, "crosscode.log") +export const cloudflaredLogFile = join(logDir, "cloudflared.log") +export const opencodeLogFile = join(logDir, "opencode.log") +export const ngrokLogFile = join(logDir, "ngrok.log") + +function rotateLogIfNeeded(logFile: string) { + try { + if (existsSync(logFile)) { + const stats = statSync(logFile) + if (stats.size > MAX_LOG_SIZE) { + const backup = `${logFile}.1` + if (existsSync(backup)) unlinkSync(backup) + renameSync(logFile, backup) + } + } + } catch (err) { + // Log rotation is best-effort; permission errors on the log dir are non-fatal + if (process.env.CROSSCODE_DEBUG === "1") { + console.error(`[log] rotation failed for ${logFile}: ${err instanceof Error ? err.message : err}`) + } + } +} + +rotateLogIfNeeded(crosscodeLogFile) +rotateLogIfNeeded(cloudflaredLogFile) +rotateLogIfNeeded(opencodeLogFile) +rotateLogIfNeeded(ngrokLogFile) + +export const crosscodeLogStream = createWriteStream(crosscodeLogFile, { flags: "a", mode: 0o600 }) +export const cloudflaredLogStream = createWriteStream(cloudflaredLogFile, { flags: "a", mode: 0o600 }) +export const opencodeLogStream = createWriteStream(opencodeLogFile, { flags: "a", mode: 0o600 }) +export const ngrokLogStream = createWriteStream(ngrokLogFile, { flags: "a", mode: 0o600 }) + +export function logCrosscode(msg: string) { + crosscodeLogStream.write(`${new Date().toISOString()} ${msg}\n`) +} + +export const allLogStreams: WriteStream[] = [ + crosscodeLogStream, + cloudflaredLogStream, + opencodeLogStream, + ngrokLogStream, +] + +export function closeAllLogs(): Promise { + return Promise.all( + allLogStreams.map(s => new Promise(r => s.end(r))) + ).then(() => {}) +} diff --git a/packages/crosscode/src/opencode.ts b/packages/crosscode/src/opencode.ts new file mode 100644 index 0000000..8a2a5c4 --- /dev/null +++ b/packages/crosscode/src/opencode.ts @@ -0,0 +1,55 @@ +import type { ChildProcess } from "child_process" +import type { Ora } from "ora" +import chalk from "chalk" +import { debug, spawnCmd } from "./util" +import { logCrosscode, opencodeLogStream } from "./log" +import { waitForOpencodePort } from "./port-detect" + +export type OpencodeInstance = { + proc: ChildProcess + detectedPort: number +} + +export async function startOpencode(opts: { + port: number + sessionToken: string + spinner: Ora + children: ChildProcess[] +}): Promise { + const { port, sessionToken, spinner, children } = opts + + const proc = spawnCmd("opencode", [ + "serve", "--print-logs", "--log-level", "DEBUG", + "--port", String(port), "--hostname", "127.0.0.1", + ], { + cwd: process.cwd(), + env: { ...process.env, OPENCODE_SERVER_PASSWORD: sessionToken }, + stdio: ["ignore", "pipe", "pipe"], + }) + + children.push(proc) + + proc.on("spawn", () => { + spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Detecting port...") + logCrosscode("opencode serve started (PID: " + proc.pid + ")") + debug("opencode spawned", { pid: proc.pid }) + }) + proc.on("error", (err) => { + spinner.fail(chalk.red.italic("Failed to start opencode serve")) + logCrosscode("opencode serve error: " + err.message) + debug("opencode spawn error", { error: err.message }) + }) + + const detectedPort = await waitForOpencodePort({ + proc, + requestedPort: port, + onData: d => opencodeLogStream.write(d), + }) + + if (detectedPort !== port) { + logCrosscode(`Using detected port ${detectedPort} instead of requested port ${port}`) + debug("using detected port", { detected: detectedPort, requested: port }) + } + + return { proc, detectedPort } +} diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts new file mode 100644 index 0000000..e5e086b --- /dev/null +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -0,0 +1,153 @@ +import type { ChildProcess } from "child_process" +import { execFile } from "child_process" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { join } from "path" +import { homedir } from "os" +import chalk from "chalk" +import ora from "ora" +import { debug, getFreePort, spawnCmd } from "../util" +import { logCrosscode, cloudflaredLogStream, cloudflaredTunnelDir, cfCertPath } from "../log" +import { createOpencodeProxy } from "../proxy" +import { startOpencode } from "../opencode" +import type { Config, ProjectConfig, CloudflaredTunnel } from "../config" +import { ensureSessionToken, ensureProjectId, saveProjectConfig } from "../config" + +// Use a persistent named cloudflared tunnel so the public URL is stable +// (.cfargotunnel.com) across restarts and reconnects, instead of +// the random ephemeral quick tunnel that rotates its URL on every (re)start. +async function ensureCloudflaredNamedTunnel( + config: Config, + project?: ProjectConfig, +): Promise { + if (!existsSync(cfCertPath)) { + debug("cloudflared not logged in (no cert.pem); falling back to quick tunnel") + return null + } + const target = project ?? (() => { throw new Error("project required") })() + if (target.cloudflaredTunnel && existsSync(target.cloudflaredTunnel.credentialsPath)) { + return target.cloudflaredTunnel + } + const projectId = ensureProjectId(config, target) + const name = `crosscode-${projectId}` + const credentialsPath = join(cloudflaredTunnelDir, `${name}.json`) + try { + if (!existsSync(cloudflaredTunnelDir)) mkdirSync(cloudflaredTunnelDir, { recursive: true, mode: 0o700 }) + await new Promise((resolve, reject) => { + execFile("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], (err: Error | null) => { + if (err) reject(err) + else resolve() + }) + }) + } catch (e) { + debug("cloudflared tunnel create failed", { error: (e as Error).message }) + return null + } + let url = "" + try { + const creds = JSON.parse(readFileSync(credentialsPath, "utf-8")) + const id = creds.TunnelID || creds.id + if (id) url = `https://${id}.cfargotunnel.com` + } catch {} + target.cloudflaredTunnel = { name, credentialsPath, url } + saveProjectConfig(config) + logCrosscode(`Cloudflared named tunnel created: ${name} (${url})`) + return target.cloudflaredTunnel +} + +export async function startCloudflaredProvider( + config: Config, + project: ProjectConfig, + port: number, + children: ChildProcess[], + isShuttingDown: () => boolean, + onTunnelUrl: (url: string) => void, + printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void, +) { + const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() + const sessionToken = ensureSessionToken(config, project) + + const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + + let proxyPort = await getFreePort() + while (proxyPort === port) proxyPort = await getFreePort() + + const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy") + + proxy.listen(proxyPort, "127.0.0.1", async () => { + logCrosscode(`SSE proxy started on port ${proxyPort}`) + debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) + spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") + + const namedTunnel = await ensureCloudflaredNamedTunnel(config, project) + if (!namedTunnel) { + logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") + } + + let tunnelUrl = "" + + function setTunnelUrl(url: string) { + if (tunnelUrl) return + tunnelUrl = url + spinner.succeed(chalk.green("Tunnel ready")) + logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) + debug("cloudflare tunnel ready", { tunnelUrl }) + onTunnelUrl(tunnelUrl) + printQr(tunnelUrl, sessionToken, detectedPort, port) + } + + function startCloudflared(): ChildProcess { + let cfArgs: string[] + if (namedTunnel) { + const cfgPath = join(cloudflaredTunnelDir, `${namedTunnel.name}.yml`) + writeFileSync(cfgPath, `url: http://127.0.0.1:${proxyPort}\ntunnel: ${namedTunnel.name}\ncredentials-file: ${namedTunnel.credentialsPath}\n`, { mode: 0o600 }) + cfArgs = ["tunnel", "--no-autoupdate", "--config", cfgPath, "run"] + } else { + cfArgs = ["tunnel", "--no-autoupdate", "--config", "/dev/null", "--url", `http://127.0.0.1:${proxyPort}`] + } + + const cf = spawnCmd("cloudflared", cfArgs, { stdio: ["ignore", "pipe", "pipe"] }) + children.push(cf) + + cf.on("spawn", () => { + logCrosscode("cloudflared started (PID: " + cf.pid + ")") + debug("cloudflared spawned", { pid: cf.pid }) + }) + cf.on("error", (err) => { + logCrosscode("cloudflared error: " + err.message) + debug("cloudflared error", { error: err.message }) + }) + + cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) + + cf.stderr?.on("data", (data: Buffer) => { + const text = data.toString() + const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(trycloudflare|cfargotunnel)\.com/) + if (m && !tunnelUrl) setTunnelUrl(m[0]) + cloudflaredLogStream.write(data) + }) + + cf.on("exit", (code) => { + logCrosscode("cloudflared exited (code: " + code + ")") + if (isShuttingDown()) return + if (namedTunnel) { + logCrosscode("Restarting cloudflared (named tunnel keeps stable URL)") + console.log(chalk.yellow("\n Cloudflare tunnel dropped — reconnecting (URL unchanged)...\n")) + startCloudflared() + } else { + console.log(chalk.red("\n Cloudflare tunnel exited. The session URL may have changed — restart crosscode to reconnect.\n")) + } + }) + + return cf + } + + const cf = startCloudflared() + + if (namedTunnel?.url && !tunnelUrl) { + const urlFallbackTimer = setTimeout(() => { + if (!tunnelUrl) setTunnelUrl(namedTunnel.url) + }, 8000) + cf.on("exit", () => clearTimeout(urlFallbackTimer)) + } + }) +} diff --git a/packages/crosscode/src/providers/ngrok.ts b/packages/crosscode/src/providers/ngrok.ts new file mode 100644 index 0000000..eb1bfb3 --- /dev/null +++ b/packages/crosscode/src/providers/ngrok.ts @@ -0,0 +1,86 @@ +import type { ChildProcess } from "child_process" +import http from "http" +import chalk from "chalk" +import ora from "ora" +import { debug, spawnCmd } from "../util" +import { logCrosscode, ngrokLogStream, cloudflaredLogStream } from "../log" +import { proxyAgent } from "../proxy" +import { startOpencode } from "../opencode" +import type { Config, ProjectConfig } from "../config" +import { ensureSessionToken, saveConfig } from "../config" +import { setupNgrokToken } from "../auth" + +export async function startNgrokProvider( + config: Config, + project: ProjectConfig, + port: number, + children: ChildProcess[], + onTunnelUrl: (url: string) => void, + printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void, +) { + let ngrokToken = config.ngrokToken + + if (!ngrokToken) { + ngrokToken = await setupNgrokToken() + config.ngrokToken = ngrokToken + saveConfig(config) + logCrosscode("ngrok auth token saved") + debug("ngrok token saved") + } + + const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() + const sessionToken = ensureSessionToken(config, project) + + const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + + const ngrok = spawnCmd("ngrok", ["http", `--authtoken=${ngrokToken}`, `${detectedPort}`], { + stdio: ["ignore", "pipe", "pipe"], + }) + + children.push(ngrok) + + ngrok.on("spawn", () => { + logCrosscode("ngrok started (PID: " + ngrok.pid + ")") + spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Starting ngrok tunnel...") + debug("ngrok spawned", { pid: ngrok.pid }) + }) + ngrok.on("error", (err) => { + logCrosscode("ngrok error: " + err.message) + debug("ngrok error", { error: err.message }) + }) + ngrok.stdout?.on("data", d => { ngrokLogStream.write(d); cloudflaredLogStream.write(d) }) + ngrok.stderr?.on("data", d => { ngrokLogStream.write(d); cloudflaredLogStream.write(d) }) + + let tunnelUrl = "" + + const pollNgrokApi = () => { + debug("polling ngrok API") + const req = http.get("http://127.0.0.1:4040/api/tunnels", { agent: proxyAgent }, (res) => { + let data = "" + res.on("data", chunk => data += chunk) + res.on("end", () => { + debug("ngrok API response", { size: data.length }) + try { + const json = JSON.parse(data) + if (json.tunnels && json.tunnels.length > 0 && !tunnelUrl) { + tunnelUrl = json.tunnels[0].public_url + spinner.succeed(chalk.green("Tunnel ready")) + logCrosscode("ngrok tunnel ready: " + tunnelUrl) + debug("ngrok tunnel ready", { tunnelUrl }) + onTunnelUrl(tunnelUrl) + printQr(tunnelUrl, sessionToken, detectedPort, port) + } + } catch (e) { + debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) + setTimeout(pollNgrokApi, 500) + } + }) + }) + req.on("error", (e) => { + debug("ngrok API request error", { error: e.message }) + setTimeout(pollNgrokApi, 500) + }) + } + + setTimeout(pollNgrokApi, 1000) +} diff --git a/packages/crosscode/src/providers/tunnel.ts b/packages/crosscode/src/providers/tunnel.ts new file mode 100644 index 0000000..812da53 --- /dev/null +++ b/packages/crosscode/src/providers/tunnel.ts @@ -0,0 +1,106 @@ +import type { ChildProcess } from "child_process" +import http from "http" +import chalk from "chalk" +import ora from "ora" +import { debug, getFreePort, spawnCmd } from "../util" +import { logCrosscode, cloudflaredLogStream } from "../log" +import { createOpencodeProxy, proxyAgent } from "../proxy" +import { startOpencode } from "../opencode" +import { connectTunnel } from "../tunnel-client" +import type { Config, ProjectConfig } from "../config" +import { ensureSessionToken, ensureProjectId } from "../config" + +export type TunnelCallbacks = { + onTunnelUrl: (url: string) => void + printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void + onFallbackNeeded: () => void +} + +export async function startTunnelProvider( + config: Config, + project: ProjectConfig, + port: number, + children: ChildProcess[], + callbacks: TunnelCallbacks, +) { + const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() + const sessionToken = ensureSessionToken(config, project) + + const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + + let proxyPort = await getFreePort() + while (proxyPort === port) proxyPort = await getFreePort() + + const proxy = createOpencodeProxy(detectedPort, sessionToken, "tunnel") + + proxy.listen(proxyPort, "127.0.0.1", () => { + logCrosscode(`SSE proxy started on port ${proxyPort}`) + debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) + + const testReq = http.request(`http://127.0.0.1:${detectedPort}/global/health`, { + method: "GET", + headers: { + "Authorization": `Basic ${Buffer.from(`opencode:${sessionToken}`).toString("base64")}`, + }, + agent: proxyAgent, + }, (testRes) => { + debug("health check result", { status: testRes.statusCode }) + logCrosscode(`Direct test to opencode: ${testRes.statusCode}`) + }) + testReq.on("error", (e) => { + debug("health check failed", { error: e.message }) + logCrosscode(`Direct test to opencode failed: ${e.message}`) + }) + testReq.end() + + spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") + + const projectId = ensureProjectId(config, project) + logCrosscode(`Project ID: ${projectId}`) + debug("connecting to tunnel", { projectId, proxyPort }) + + let tunnelUrl = "" + + const tunnelTimeout = setTimeout(() => { + spinner.fail(chalk.red.italic("Tunnel connection timed out")) + logCrosscode("Tunnel connection timed out, falling back to cloudflared") + debug("tunnel connection timeout") + console.log(chalk.yellow("\n Falling back to Cloudflare tunnel...\n")) + disconnectTunnel() + callbacks.onFallbackNeeded() + }, 15_000) + + const disconnectTunnel = connectTunnel( + config.auth!.sessionToken!, + projectId, + proxyPort, + config.tunnelWsUrl, + (url) => { + clearTimeout(tunnelTimeout) + if (tunnelUrl) { + if (tunnelUrl !== url) { + tunnelUrl = url + logCrosscode("Tunnel URL changed: " + tunnelUrl) + console.log(chalk.yellow(`\n Tunnel URL changed: ${tunnelUrl}\n`)) + } + return + } + tunnelUrl = url + spinner.succeed(chalk.green("Tunnel ready")) + logCrosscode("Tunnel ready: " + tunnelUrl) + debug("tunnel connected", { tunnelUrl }) + callbacks.onTunnelUrl(tunnelUrl) + callbacks.printQr(tunnelUrl, sessionToken, detectedPort, port) + }, + (err) => { + clearTimeout(tunnelTimeout) + spinner.fail(chalk.red.italic(`Tunnel error: ${err.message}`)) + logCrosscode(`Tunnel error: ${err.message}, falling back to cloudflared`) + debug("tunnel error", { error: err.message }) + console.log(chalk.yellow("\n Falling back to Cloudflare tunnel...\n")) + disconnectTunnel() + callbacks.onFallbackNeeded() + }, + ) + }) +} diff --git a/packages/crosscode/src/proxy.ts b/packages/crosscode/src/proxy.ts new file mode 100644 index 0000000..0fa2ed1 --- /dev/null +++ b/packages/crosscode/src/proxy.ts @@ -0,0 +1,167 @@ +import http from "http" +import { debug, censorAuth } from "./util" +import { handleGitRequest } from "./git-handler" + +const MAX_BODY_SIZE = 10 * 1024 * 1024 +const HOP_BY_HOP = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer"]) + +export const proxyAgent = new http.Agent({ keepAlive: true, maxSockets: 50 }) + +export function sanitizeUrlPath(url: string | undefined): string { + if (!url || url.length === 0) return "/" + const fragmentIndex = url.indexOf("#") + const withoutFragment = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex) + const queryIndex = withoutFragment.indexOf("?") + const rawPath = queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex) + const rawQuery = queryIndex === -1 ? "" : withoutFragment.slice(queryIndex + 1) + if (!rawPath.startsWith("/")) return "/" + const cleaned = rawPath.replace(/\/+/g, "/") + let decoded: string + try { + decoded = decodeURIComponent(cleaned) + } catch { + return "/" + } + if (decoded.includes("..") || decoded.includes("@") || decoded.includes("\\")) return "/" + return `${cleaned || "/"}${rawQuery ? `?${rawQuery}` : ""}` +} + +export function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string): http.Server { + return http.createServer(async (req, res) => { + const safePath = sanitizeUrlPath(req.url) + const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` + const authHeader = req.headers["authorization"] + + debug(`${logPrefix} request received`, { + method: req.method, + url: req.url, + safePath, + hasAuth: !!authHeader, + auth: censorAuth(authHeader), + }) + + if (req.method === "OPTIONS") { + debug("handling CORS preflight") + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }) + res.end() + return + } + + if (req.url === "/mobile-event" && req.method === "POST") { + debug("handling SSE request") + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }) + + let sseAuth = authHeader || "" + if (sseAuth && !sseAuth.startsWith("Basic ")) { + sseAuth = `Basic ${Buffer.from(`:${sseAuth}`).toString("base64")}` + debug("converted SSE auth to Basic format") + } + + const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { + headers: { + "Accept": "text/event-stream", + "Authorization": sseAuth, + }, + }, (sseRes) => { + debug("SSE upstream connected", { status: sseRes.statusCode }) + sseRes.on("data", (chunk) => { res.write(chunk) }) + sseRes.on("end", () => { debug("SSE upstream ended"); res.end() }) + }) + + sseReq.on("error", (err) => { + debug("SSE upstream error", { error: err.message }) + res.end() + }) + + req.on("close", () => { + debug("SSE client disconnected") + sseReq.destroy() + }) + + return + } + + if (await handleGitRequest(req, res, { worktree: process.cwd(), sessionToken })) { + return + } + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) forwardHeaders[key] = value + } + forwardHeaders["host"] = `127.0.0.1:${targetPort}` + + if (authHeader && !authHeader.startsWith("Basic ")) { + forwardHeaders["authorization"] = `Basic ${Buffer.from(`:${authHeader}`).toString("base64")}` + debug("converted auth to Basic format") + } + + debug("forwarding to opencode", { + targetUrl, + method: req.method, + hasAuth: !!forwardHeaders["authorization"], + auth: censorAuth(forwardHeaders["authorization"] as string), + }) + + const proxyReq = http.request(targetUrl, { + method: req.method, + headers: forwardHeaders, + agent: proxyAgent, + }, (proxyRes) => { + debug("opencode responded", { status: proxyRes.statusCode, method: req.method, path: safePath }) + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) + proxyRes.pipe(res) + }) + + proxyReq.on("error", (err) => { + debug("proxy request error", { error: err.message }) + if (!res.headersSent) { + res.writeHead(502) + res.end("Bad Gateway") + } + }) + + let bodySize = 0 + let bodyTooLarge = false + + req.on("data", (chunk) => { + bodySize += chunk.length + if (bodySize > MAX_BODY_SIZE) { + bodyTooLarge = true + debug("request body too large", { size: bodySize, max: MAX_BODY_SIZE }) + req.destroy() + proxyReq.destroy() + if (!res.headersSent) { + res.writeHead(413) + res.end("Request body too large") + } + return + } + proxyReq.write(chunk) + }) + + // Fix I7: guard against calling end() on a destroyed proxyReq + req.on("end", () => { + if (!bodyTooLarge && !proxyReq.destroyed) proxyReq.end() + }) + + req.on("error", (err) => { + debug("request stream error", { error: err.message }) + proxyReq.destroy() + if (!res.headersSent) { + res.writeHead(500) + res.end("Internal Server Error") + } + }) + }) +} diff --git a/packages/crosscode/src/util.ts b/packages/crosscode/src/util.ts new file mode 100644 index 0000000..d1794d8 --- /dev/null +++ b/packages/crosscode/src/util.ts @@ -0,0 +1,74 @@ +import { spawn, execFileSync } from "child_process" +import net from "net" +import chalk from "chalk" + +export const DEBUG = process.env.CROSSCODE_DEBUG === "1" + +let logWriter: ((msg: string) => void) | null = null + +export function setLogWriter(fn: (msg: string) => void) { + logWriter = fn +} + +export function debug(msg: string, meta?: Record) { + if (DEBUG) { + const ts = new Date().toISOString() + const extra = meta ? ` ${JSON.stringify(meta)}` : "" + const line = `[${ts}] [DEBUG] ${msg}${extra}` + console.log(chalk.dim(line)) + logWriter?.(line) + } +} + +export function censorAuth(val: string | undefined): string { + if (!val) return "" + if (val.startsWith("Basic ")) { + return `Basic ${val.substring(6, 14)}...` + } + return `${val.substring(0, 8)}...` +} + +export function censorToken(val: string): string { + if (val.length <= 16) return "***" + return `${val.substring(0, 8)}...${val.substring(val.length - 4)}` +} + +export function checkDep(name: string): boolean { + const finder = process.platform === "win32" ? "where" : "which" + try { + execFileSync(finder, [name], { stdio: "ignore" }) + return true + } catch { + return false + } +} + +export function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer() + srv.on("error", reject) + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as net.AddressInfo + const port = addr.port + srv.close(() => resolve(port)) + }) + }) +} + +export function spawnCmd(cmd: string, args: string[], opts: Parameters[2] = {}) { + return spawn(cmd, args, { ...opts, shell: false }) +} + +export function openBrowser(url: string): void { + if (!url.startsWith("https://") && !url.startsWith("http://")) return + const platform = process.platform + try { + if (platform === "darwin") { + execFileSync("open", [url]) + } else if (platform === "win32") { + execFileSync("cmd", ["/c", "start", "", url]) + } else { + execFileSync("xdg-open", [url]) + } + } catch {} +} From ed24db545493d6ce2594c4094f2ad990914d943a Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 23:06:08 +0530 Subject: [PATCH 07/10] refactor(tunnel-client): import shared debug and censorAuth from util --- packages/crosscode/src/tunnel-client.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/crosscode/src/tunnel-client.ts b/packages/crosscode/src/tunnel-client.ts index dc63753..674205b 100644 --- a/packages/crosscode/src/tunnel-client.ts +++ b/packages/crosscode/src/tunnel-client.ts @@ -1,33 +1,17 @@ import WebSocket from "ws" import http from "http" import type { TunnelC2S, TunnelS2C } from "@crosscode/shared" +import { debug, censorAuth } from "./util" const TUNNEL_WS_URL = process.env.CROSSCODE_TUNNEL_WS_URL || "wss://connect.crosscode.site/ws" const INITIAL_BACKOFF_MS = 1_000 const MAX_BACKOFF_MS = 30_000 const HEARTBEAT_TIMEOUT_MS = 60_000 -const DEBUG = process.env.CROSSCODE_DEBUG === "1" interface InFlightRequest { req: http.ClientRequest } -function debug(msg: string, meta?: Record) { - if (DEBUG) { - const ts = new Date().toISOString() - const extra = meta ? ` ${JSON.stringify(meta)}` : "" - console.log(`[${ts}] [tunnel-client] ${msg}${extra}`) - } -} - -function censorAuth(val: string | undefined): string { - if (!val) return "" - if (val.startsWith("Basic ")) { - return `Basic ${val.substring(6, 14)}...` - } - return `${val.substring(0, 8)}...` -} - export function connectTunnel( apiKey: string, projectId: string, From 059a49e5e84ec14bd72c51845ea080ec81ae801a Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 23:06:12 +0530 Subject: [PATCH 08/10] refactor(git-handler): import shared debug from util --- packages/crosscode/src/git-handler.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/crosscode/src/git-handler.ts b/packages/crosscode/src/git-handler.ts index 6eaebb1..a924431 100644 --- a/packages/crosscode/src/git-handler.ts +++ b/packages/crosscode/src/git-handler.ts @@ -1,8 +1,8 @@ import { execFile } from "child_process" import crypto from "crypto" import http from "http" +import { debug } from "./util" -const DEBUG = process.env.CROSSCODE_DEBUG === "1" const GIT_TIMEOUT_MS = 10_000 const GIT_MAX_BUFFER = 10 * 1024 * 1024 const FIELD_SEP = "\x1f" @@ -43,14 +43,6 @@ export type GitCommitDetail = { deletions: number } -function debug(msg: string, meta?: Record) { - if (DEBUG) { - const ts = new Date().toISOString() - const extra = meta ? ` ${JSON.stringify(meta)}` : "" - console.log(`[${ts}] [git-handler] ${msg}${extra}`) - } -} - function sendJson(res: http.ServerResponse, status: number, body: unknown) { if (res.headersSent) return res.writeHead(status, { From 61efe16a5b8d15620dc6ca1794ec156675e562e5 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 23:19:34 +0530 Subject: [PATCH 09/10] fix(shared): declare type module in package.json --- packages/shared/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/package.json b/packages/shared/package.json index 363e861..213da81 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,7 @@ "name": "@crosscode/shared", "version": "0.1.0", "private": true, + "type": "module", "description": "", "main": "./src/index.ts", "types": "./src/index.ts", From 107a2cd0d0d5fb34e53bda68fd2d18c773f9cc63 Mon Sep 17 00:00:00 2001 From: r2hu1 Date: Mon, 7 Sep 2026 23:19:43 +0530 Subject: [PATCH 10/10] build(crosscode): bundle workspace dependencies via tsup config --- packages/crosscode/package.json | 4 ++-- packages/crosscode/tsup.config.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 packages/crosscode/tsup.config.ts diff --git a/packages/crosscode/package.json b/packages/crosscode/package.json index d13372a..5e08bcb 100644 --- a/packages/crosscode/package.json +++ b/packages/crosscode/package.json @@ -11,8 +11,8 @@ "version": "0.6.9", "description": "An OpenCode Remote Client CLI", "scripts": { - "build": "tsup src/cli.ts --out-dir dist --format esm --clean", - "dev": "tsup src/cli.ts --out-dir dist --format esm --watch", + "build": "tsup", + "dev": "tsup --watch", "prepublishOnly": "pnpm build" }, "keywords": [ diff --git a/packages/crosscode/tsup.config.ts b/packages/crosscode/tsup.config.ts new file mode 100644 index 0000000..9ca5e24 --- /dev/null +++ b/packages/crosscode/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/cli.ts"], + format: ["esm"], + outDir: "dist", + clean: true, + bundle: true, + splitting: false, + sourcemap: false, + dts: false, + minify: false, + noExternal: [/@crosscode\/shared/], +})