diff --git a/packages/crosscode/package.json b/packages/crosscode/package.json index 8687fa3..5e08bcb 100644 --- a/packages/crosscode/package.json +++ b/packages/crosscode/package.json @@ -10,10 +10,9 @@ }, "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", + "build": "tsup", + "dev": "tsup --watch", "prepublishOnly": "pnpm build" }, "keywords": [ @@ -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", 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/git-handler.ts b/packages/crosscode/src/git-handler.ts index cfb7ee8..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, { @@ -82,7 +74,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 +225,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 }) 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() 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/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`)) + }) }) } 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/tunnel-client.ts b/packages/crosscode/src/tunnel-client.ts index a2ef910..674205b 100644 --- a/packages/crosscode/src/tunnel-client.ts +++ b/packages/crosscode/src/tunnel-client.ts @@ -1,32 +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 DEBUG = process.env.CROSSCODE_DEBUG === "1" +const HEARTBEAT_TIMEOUT_MS = 60_000 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, @@ -38,24 +23,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 +70,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 +79,7 @@ export function connectTunnel( debug("auth failed", { reason: msg.reason }) onError(new Error(msg.reason)) shuttingDown = true + clearHeartbeat() ws?.close() break @@ -94,14 +100,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 +213,7 @@ export function connectTunnel( return () => { shuttingDown = true + clearHeartbeat() if (reconnectTimer) clearTimeout(reconnectTimer) abortAllInFlight() ws?.close() 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 {} +} 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/], +}) 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",