From 6b963539568d187e3af2004123b17f506c57d0b6 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:33:26 +0530 Subject: [PATCH 1/9] feat(cli): add maskEmail util and tunnelPreference config --- packages/crosscode/src/config.ts | 3 +++ packages/crosscode/src/util.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/crosscode/src/config.ts b/packages/crosscode/src/config.ts index 5684986..efb10de 100644 --- a/packages/crosscode/src/config.ts +++ b/packages/crosscode/src/config.ts @@ -17,6 +17,8 @@ export type ProjectConfig = { port?: number } +export type TunnelPreference = "crosscode" | "cloudflared-named" | "cloudflared-quick" | "ngrok" + export type Config = { ngrokToken?: string port?: number @@ -24,6 +26,7 @@ export type Config = { sessionToken?: string projectId?: string telemetry?: boolean + tunnelPreference?: TunnelPreference // Name (or path) of the opencode executable to run, e.g. "opencode-v2". opencodeBin?: string cloudflaredTunnel?: CloudflaredTunnel diff --git a/packages/crosscode/src/util.ts b/packages/crosscode/src/util.ts index c3675d1..a1ab86e 100644 --- a/packages/crosscode/src/util.ts +++ b/packages/crosscode/src/util.ts @@ -34,6 +34,17 @@ export function censorToken(val: string): string { return `${val.substring(0, 8)}...${val.substring(val.length - 4)}` } +export function maskEmail(email: string | undefined): string { + if (!email || !email.includes("@")) return "***" + const [local, domain] = email.split("@") + if (!local || !domain) return "***" + const maskedLocal = `${local[0]}***` + const domainParts = domain.split(".") + if (domainParts.length === 1 || !domainParts[0]) return `${maskedLocal}@***` + const maskedFirst = `${domainParts[0][0]}***` + return `${maskedLocal}@${[maskedFirst, ...domainParts.slice(1)].join(".")}` +} + export function checkDep(name: string): boolean { const finder = process.platform === "win32" ? "where" : "which" try { From 01821353a2f0edc38482c2e12ab3287ecded499e Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:33:46 +0530 Subject: [PATCH 2/9] feat(cli): add tunnel selector with 1-4 choices and remember prompt --- packages/crosscode/src/tunnel-setup.ts | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/crosscode/src/tunnel-setup.ts diff --git a/packages/crosscode/src/tunnel-setup.ts b/packages/crosscode/src/tunnel-setup.ts new file mode 100644 index 0000000..d8bcbab --- /dev/null +++ b/packages/crosscode/src/tunnel-setup.ts @@ -0,0 +1,51 @@ +import chalk from "chalk" +import { checkDep } from "./util" +import { promptInput } from "./auth" +import type { TunnelPreference } from "./config" + +export type TunnelChoice = TunnelPreference + +function depStatus(bin: string): string { + return checkDep(bin) ? "installed" : "not found" +} + +export async function promptTunnelChoice(): Promise { + const cfStatus = depStatus("cloudflared") + const ngrokStatus = depStatus("ngrok") + + console.log() + console.log(chalk.cyan("How do you want to connect?")) + console.log() + console.log("1. CrossCode Tunnel (recommended): Zero installations or setup, login required, stable URL") + console.log(`2. Cloudflared Named Tunnel (${cfStatus}): Stable URL`) + console.log(`3. Cloudflared Quick Tunnel (${cfStatus}): Zero setup, URL changes`) + console.log(`4. Ngrok (${ngrokStatus}): requires auth token`) + console.log() + + for (;;) { + const raw = (await promptInput("Select [1-4] (default 1): ")).trim() + if (raw === "" || raw === "1") return "crosscode" + if (raw === "2") return "cloudflared-named" + if (raw === "3") return "cloudflared-quick" + if (raw === "4") return "ngrok" + console.log(chalk.yellow("Please enter a number between 1 and 4.")) + } +} + +export async function promptRememberChoice(): Promise { + for (;;) { + const raw = (await promptInput("Remember this tunnel choice so 'crosscode' starts instantly next time? [Y/n]: ")).trim().toLowerCase() + if (raw === "" || raw === "y" || raw === "yes") return true + if (raw === "n" || raw === "no") return false + console.log(chalk.yellow("Please answer Y or n.")) + } +} + +export function isPaidTier(tier: string | undefined): boolean { + if (!tier) return false + return tier.toLowerCase() !== "free" +} + +export function shouldShowProUpsell(tier: string | undefined, provider: string): boolean { + return !isPaidTier(tier) || provider !== "tunnel" +} From 386df9679afdc50ca2dc51b95273bacdb8ba166d Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:34:32 +0530 Subject: [PATCH 3/9] feat(cli): prompt tunnel choice when logged out with remember option --- packages/crosscode/src/cli.ts | 49 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index bd33298..2740f8d 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -2,10 +2,12 @@ import { readFile } from "fs/promises" import chalk from "chalk" -import { debug, checkDep, setLogWriter, getFreePort, terminateChild, openBrowser } from "./util" +import { debug, checkDep, setLogWriter, getFreePort, terminateChild, openBrowser, maskEmail } from "./util" import { logCrosscode, closeAllLogs, crosscodeLogFile, cloudflaredLogFile, opencodeLogFile } from "./log" import { startConnectionServer, closeConnectionServer, connectionPageUrl, setConnectionInfo } from "./connection-page" import { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" +import type { TunnelPreference } from "./config" +import { promptTunnelChoice, promptRememberChoice } from "./tunnel-setup" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" @@ -91,7 +93,7 @@ async function main() { if (command === "logout") { if (config.auth) { - console.log(chalk.yellow(`\n Logged out ${config.auth.email}\n`)) + console.log(chalk.yellow(`\n Logged out ${maskEmail(config.auth.email)}\n`)) delete config.auth saveConfig(config) } else { @@ -102,7 +104,7 @@ async function main() { if (command === "status") { if (config.auth?.sessionToken) { - console.log(chalk.green(`\n Logged in as ${config.auth.email}`)) + console.log(chalk.green(`\n Logged in as ${maskEmail(config.auth.email)}`)) console.log(chalk.dim(` Tier: ${config.auth.tier || "free"}\n`)) } else { console.log(chalk.dim("\n Not logged in (using cloudflared tunnel)\n")) @@ -152,8 +154,47 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} const useNgrok = args.includes("--ngrok") const useCloudflared = args.includes("--cloudflared") + const useCrosscodeTunnel = args.includes("--tunnel") || args.includes("--crosscode") const canUseTunnel = !!(config.auth?.sessionToken) - const tunnelProvider = useNgrok ? "ngrok" : (useCloudflared ? "cloudflared" : (canUseTunnel ? "tunnel" : "cloudflared")) + + let tunnelChoice: TunnelPreference | null = null + let choiceFromPrompt = false + if (useNgrok) tunnelChoice = "ngrok" + else if (useCloudflared) tunnelChoice = "cloudflared-quick" + else if (useCrosscodeTunnel) tunnelChoice = "crosscode" + else if (config.tunnelPreference) tunnelChoice = config.tunnelPreference + else if (canUseTunnel) tunnelChoice = "crosscode" + else if (process.stdout.isTTY && process.stdin.isTTY) { + tunnelChoice = await promptTunnelChoice() + choiceFromPrompt = true + } else { + tunnelChoice = "cloudflared-quick" + } + + // CrossCode dedicated tunnel requires login. Offer it inline so the + // user never has to leave the flow to figure out the `login` command. + if (tunnelChoice === "crosscode" && !config.auth?.sessionToken) { + const success = await loginFlow(config) + if (!success) { + console.log(chalk.red("\n Login required for CrossCode tunnel.\n")) + process.exit(1) + } + await refreshTier(config) + } + + if (choiceFromPrompt && tunnelChoice) { + try { + if (await promptRememberChoice()) { + config.tunnelPreference = tunnelChoice + saveConfig(config) + logCrosscode(`Tunnel preference saved: ${tunnelChoice}`) + } + } catch { + // Non-fatal: continue with the one-time choice. + } + } + + const tunnelProvider = tunnelChoice === "ngrok" ? "ngrok" : (tunnelChoice === "crosscode" ? "tunnel" : "cloudflared") initSentry(config, { tunnelProvider }) Sentry.setTag("tunnelProvider", tunnelProvider) const project = getProjectConfig(config) From 2468d139322ff784fe7d3c9cf98ba006c818884b Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:35:44 +0530 Subject: [PATCH 4/9] feat(cli): in-CLI cloudflared named setup and Windows quick-tunnel fix --- packages/crosscode/src/cli.ts | 13 +++++-- .../crosscode/src/providers/cloudflared.ts | 34 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index 2740f8d..0891134 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -159,16 +159,24 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} let tunnelChoice: TunnelPreference | null = null let choiceFromPrompt = false + let cloudflaredMode: "auto" | "named" | "quick" = "auto" if (useNgrok) tunnelChoice = "ngrok" - else if (useCloudflared) tunnelChoice = "cloudflared-quick" + else if (useCloudflared) { tunnelChoice = "cloudflared-quick"; cloudflaredMode = "auto" } else if (useCrosscodeTunnel) tunnelChoice = "crosscode" - else if (config.tunnelPreference) tunnelChoice = config.tunnelPreference + else if (config.tunnelPreference) { + tunnelChoice = config.tunnelPreference + if (tunnelChoice === "cloudflared-named") cloudflaredMode = "named" + else if (tunnelChoice === "cloudflared-quick") cloudflaredMode = "quick" + } else if (canUseTunnel) tunnelChoice = "crosscode" else if (process.stdout.isTTY && process.stdin.isTTY) { tunnelChoice = await promptTunnelChoice() choiceFromPrompt = true + if (tunnelChoice === "cloudflared-named") cloudflaredMode = "named" + else if (tunnelChoice === "cloudflared-quick") cloudflaredMode = "quick" } else { tunnelChoice = "cloudflared-quick" + cloudflaredMode = "quick" } // CrossCode dedicated tunnel requires login. Offer it inline so the @@ -288,6 +296,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} () => isShuttingDown, (url) => { tunnelUrl = url }, printTunnelQr, + { mode: cloudflaredMode }, ) } diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index d04f074..fa8e63a 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -11,17 +11,36 @@ import { createOpencodeProxy } from "../proxy" import { startOpencode } from "../opencode" import type { Config, ProjectConfig, CloudflaredTunnel } from "../config" import { ensureSessionToken, ensureProjectId, saveProjectConfig, resolveOpencodeBin } from "../config" +import { promptInput } from "../auth" + +export type CloudflaredMode = "auto" | "named" | "quick" // 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 ensureCloudflaredLogin(): Promise { + if (existsSync(cfCertPath)) return true + console.log(chalk.cyan("\n Cloudflared Named Tunnel needs a one-time login.\n")) + console.log(" 1. Run this in another terminal: " + chalk.cyan("cloudflared tunnel login")) + console.log(chalk.dim(" 2. Complete the browser login, then return here.\n")) + const answer = (await promptInput(" Press Enter once done, or type 'cancel' to use Quick Tunnel instead: ")).trim().toLowerCase() + if (answer === "cancel" || answer === "c" || answer === "q") return false + if (existsSync(cfCertPath)) return true + console.log(chalk.yellow(" Still no cert.pem found. Falling back to Quick Tunnel.\n")) + debug("cloudflared login not completed; falling back to quick tunnel") + return false +} + 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 loggedIn = await ensureCloudflaredLogin() + if (!loggedIn) { + 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)) { @@ -62,6 +81,7 @@ export async function startCloudflaredProvider( isShuttingDown: () => boolean, onTunnelUrl: (url: string) => void, printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void, + opts: { mode?: CloudflaredMode } = {}, ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) @@ -79,8 +99,12 @@ export async function startCloudflaredProvider( debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") - const namedTunnel = await ensureCloudflaredNamedTunnel(config, project) + const mode: CloudflaredMode = opts.mode ?? "auto" + const namedTunnel = mode === "quick" ? null : await ensureCloudflaredNamedTunnel(config, project) if (!namedTunnel) { + if (mode === "named") { + console.log(chalk.yellow("\n Could not set up Named Tunnel, using Quick Tunnel instead.\n")) + } logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") } @@ -103,7 +127,9 @@ export async function startCloudflaredProvider( 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}`] + // Quick tunnel: no config file. (Older code passed + // --config /dev/null which breaks on Windows.) + cfArgs = ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${proxyPort}`] } const cf = spawnCmd("cloudflared", cfArgs, { stdio: ["ignore", "pipe", "pipe"] }) From d7b48d6d05f86d958116429a3b7a5c97bd08d9d3 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:37:40 +0530 Subject: [PATCH 5/9] feat(cli): remove keypress help, fix em-dashes, mask email, add Pro upsell --- packages/crosscode/src/auth.ts | 4 +- packages/crosscode/src/cli.ts | 97 +++++-------------- .../crosscode/src/providers/cloudflared.ts | 4 +- packages/crosscode/src/qr.ts | 4 +- 4 files changed, 30 insertions(+), 79 deletions(-) diff --git a/packages/crosscode/src/auth.ts b/packages/crosscode/src/auth.ts index 5c8fd8f..b21792e 100644 --- a/packages/crosscode/src/auth.ts +++ b/packages/crosscode/src/auth.ts @@ -2,7 +2,7 @@ import chalk from "chalk" import ora from "ora" import type { Config } from "./config" import { saveConfig } from "./config" -import { debug, openBrowser } from "./util" +import { debug, openBrowser, maskEmail } 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` @@ -118,7 +118,7 @@ export async function loginFlow(config: Config): Promise { } saveConfig(config) - console.log(chalk.green(`\n Logged in as ${result.email}`)) + console.log(chalk.green(`\n Logged in as ${maskEmail(result.email)}`)) console.log(chalk.dim(` Tier: ${result.tier}\n`)) return true } diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index 0891134..b2e961a 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -1,19 +1,17 @@ #!/usr/bin/env node -import { readFile } from "fs/promises" import chalk from "chalk" import { debug, checkDep, setLogWriter, getFreePort, terminateChild, openBrowser, maskEmail } from "./util" -import { logCrosscode, closeAllLogs, crosscodeLogFile, cloudflaredLogFile, opencodeLogFile } from "./log" +import { logCrosscode, closeAllLogs } from "./log" import { startConnectionServer, closeConnectionServer, connectionPageUrl, setConnectionInfo } from "./connection-page" import { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" import type { TunnelPreference } from "./config" -import { promptTunnelChoice, promptRememberChoice } from "./tunnel-setup" +import { promptTunnelChoice, promptRememberChoice, shouldShowProUpsell } from "./tunnel-setup" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" import { printTunnelQr as renderTunnelQr } from "./qr" import { proxyAgent } from "./proxy" -import { onKeypress, cleanupKeypress } from "./keypress" import { startTunnelProvider } from "./providers/tunnel" import { startCloudflaredProvider } from "./providers/cloudflared" import { startNgrokProvider } from "./providers/ngrok" @@ -43,8 +41,8 @@ function printTunnelQr(url: string, token: string, opencodePort: number, request console.log(chalk.green(` Browser opened: ${pageUrl}`)) console.log(chalk.dim(" Scan the QR, or copy the URL/token from that page.")) console.log(chalk.grey(` URL: ${url}`)) - console.log(chalk.yellow.bold(" Don't close this terminal — closing it disconnects the app.")) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + console.log(chalk.yellow.bold(" Don't close this terminal. Closing it disconnects the app.")) + console.log(chalk.dim(" Press Ctrl+C to exit.")) return } @@ -53,15 +51,12 @@ function printTunnelQr(url: string, token: string, opencodePort: number, request disabled: argv.includes("--no-qr"), small: argv.includes("--small-qr") || process.env.CROSSCODE_QR_SMALL === "1", }) - console.log(chalk.yellow.bold(" Don't close this terminal — closing it disconnects the app.")) + console.log(chalk.yellow.bold(" Don't close this terminal. Closing it disconnects the app.")) + console.log(chalk.dim(" Press Ctrl+C to exit.")) } -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() +function printProUpsell() { + console.log(chalk.dim(" Upgrade to CrossCode Pro for a stable URL and unlimited connections: https://crosscode.site/pricing")) } async function main() { @@ -127,23 +122,24 @@ ${chalk.yellow.bold("COMMANDS:")} ${chalk.green("help")} Show this help message ${chalk.yellow.bold("OPTIONS:")} - ${chalk.green("--cloudflared")} Use Cloudflare tunnel (default for free tier) + ${chalk.green("--cloudflared")} Use Cloudflare tunnel ${chalk.green("--ngrok")} Use ngrok tunnel (requires auth token) + ${chalk.green("--tunnel")} Use CrossCode dedicated tunnel (login required) ${chalk.green("--no-open")} Don't auto-open the connection page in a browser ${chalk.green("--no-qr")} Skip QR graphic, print URL only ${chalk.green("--small-qr")} Compact QR graphic (smaller margin) ${chalk.green("--help, -h")} Show this help message ${chalk.yellow.bold("EXAMPLES:")} - ${chalk.dim("$")} crosscode Start with auto-selected tunnel + ${chalk.dim("$")} crosscode Start (prompts for tunnel when logged out) ${chalk.dim("$")} crosscode --ngrok Start with ngrok tunnel ${chalk.dim("$")} crosscode --cloudflared Start with Cloudflare tunnel ${chalk.dim("$")} crosscode login Authenticate for paid tier features ${chalk.dim("$")} crosscode status Check authentication status ${chalk.yellow.bold("TUNNEL PROVIDERS:")} - ${chalk.blue("Free tier")} Cloudflare tunnel (default when not logged in) - ${chalk.blue("Paid tier")} CrossCode tunnel (default when logged in) + ${chalk.blue("CrossCode")} Dedicated tunnel (login required, stable URL) + ${chalk.blue("Cloudflare")} Named tunnel (stable URL) or Quick tunnel (URL changes) ${chalk.blue("ngrok")} Alternative tunnel (requires ngrok auth token) ${chalk.dim("Default behavior: uses connect.crosscode.site when logged in with a paid tier.")} @@ -209,7 +205,6 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} const port = project.port || await getFreePort() let missingDep = false - let logsVisible = false let tunnelUrl = "" logCrosscode(`CrossCode starting up (tunnel: ${tunnelProvider}, debug: ${process.env.CROSSCODE_DEBUG === "1"})`) @@ -257,7 +252,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } if (config.auth?.sessionToken) { - console.log(chalk.green(`\n Logged in as ${config.auth.email}`)) + console.log(chalk.green(`\n Logged in as ${maskEmail(config.auth.email)}`)) console.log(chalk.dim(` Tier: ${config.auth.tier || "free"}\n`)) } else { console.log(chalk.cyan(" Tip:") + chalk.dim(" run ") + @@ -265,6 +260,11 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} chalk.dim(" to use the free built-in CrossCode tunnel.\n")) } + const printQrWithUpsell = (url: string, token: string, opencodePort: number, requestedPort: number) => { + printTunnelQr(url, token, opencodePort, requestedPort) + if (shouldShowProUpsell(config.auth?.tier, tunnelProvider)) printProUpsell() + } + // 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 = async () => { @@ -274,63 +274,33 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} config, project, port, children, () => isShuttingDown, (url) => { tunnelUrl = url }, - printTunnelQr, + printQrWithUpsell, + { mode: cloudflaredMode === "named" ? "named" : "quick" }, ) } if (tunnelProvider === "tunnel") { await startTunnelProvider(config, project, port, children, { onTunnelUrl: (url) => { tunnelUrl = url }, - printQr: printTunnelQr, + printQr: printQrWithUpsell, onFallbackNeeded: () => { void launchCloudflaredFallback().catch(handleFatalError) }, }) } else if (tunnelProvider === "ngrok") { await startNgrokProvider( config, project, port, children, (url) => { tunnelUrl = url }, - printTunnelQr, + printQrWithUpsell, ) } else { await startCloudflaredProvider( config, project, port, children, () => isShuttingDown, (url) => { tunnelUrl = url }, - printTunnelQr, + printQrWithUpsell, { mode: cloudflaredMode }, ) } - const toggleLogs = async () => { - logsVisible = !logsVisible - if (logsVisible) { - try { - const [crosscodeContent, cloudflaredContent, opencodeContent] = await Promise.all([ - readFile(crosscodeLogFile, "utf-8").catch(() => ""), - readFile(cloudflaredLogFile, "utf-8").catch(() => ""), - readFile(opencodeLogFile, "utf-8").catch(() => ""), - ]) - - const crosscodeLines = crosscodeContent.split("\n").filter(Boolean).slice(-20) - const cloudflaredLines = cloudflaredContent.split("\n").filter(Boolean).slice(-20) - const opencodeLines = opencodeContent.split("\n").filter(Boolean).slice(-20) - - console.log("\n" + chalk.cyan.bold("═══ CROSSCODE LOGS ═══")) - console.log(chalk.dim(crosscodeLines.join("\n"))) - console.log("\n" + chalk.yellow.bold("═══ TUNNEL LOGS ═══")) - console.log(chalk.dim(cloudflaredLines.join("\n"))) - console.log("\n" + chalk.magenta.bold("═══ OPENCODE LOGS ═══")) - console.log(chalk.dim(opencodeLines.join("\n"))) - console.log(chalk.dim("──────────────────────────────────────")) - } - catch { - console.log(chalk.red("Could not read log files")) - } - } - else { - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) - } - } - // Fix I1: await log stream flush before exiting const shutdown = async (source?: string) => { if (isShuttingDown) return @@ -339,7 +309,6 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} logCrosscode(`Shutting down... (source: ${source || "unknown"})`) debug("shutdown initiated", { source: source || "unknown" }) await Promise.all(children.map(terminateChild)) - cleanupKeypress() closeConnectionServer() proxyAgent.destroy() await closeAllLogs() @@ -351,24 +320,6 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} process.on("exit", (code) => { logCrosscode(`Process exit event (code: ${code})`) }) - process.stdin.on("end", () => { - logCrosscode("stdin end event") - debug("stdin end event") - }) - process.stdin.on("close", () => { - logCrosscode("stdin close event") - 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") - }) } async function handleFatalError(err: unknown) { diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index fa8e63a..9cd8564 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -158,10 +158,10 @@ export async function startCloudflaredProvider( 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")) + 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")) + console.log(chalk.red("\n Cloudflare tunnel exited. The session URL may have changed. Restart crosscode to reconnect.\n")) } }) diff --git a/packages/crosscode/src/qr.ts b/packages/crosscode/src/qr.ts index 6752af2..4112975 100644 --- a/packages/crosscode/src/qr.ts +++ b/packages/crosscode/src/qr.ts @@ -53,7 +53,7 @@ export function printTunnelQr( console.log(chalk.cyanBright("\n Scan with CrossCode App:")) console.log(chalk.grey(`URL: ${url}`)) console.log(chalk.dim("QR display disabled (--no-qr). Enter the URL manually in the app.")) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + console.log(chalk.dim("Press Ctrl+C to exit.")) return } console.log(chalk.cyanBright("\n Scan with CrossCode App:")) @@ -71,5 +71,5 @@ export function printTunnelQr( if (process.env.TERM_PROGRAM === "Apple_Terminal") { console.log(chalk.dim("Terminal.app tip: if the code won't scan, zoom out (Cmd+-) and keep the whole code visible.")) } - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + console.log(chalk.dim("Press Ctrl+C to exit.")) } From 8d5f9cf40c3292b500a451740d2c0e502a8184bc Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 11:38:46 +0530 Subject: [PATCH 6/9] chore(cli): remove unused keypress module --- packages/crosscode/src/keypress.ts | 32 ------------------------------ 1 file changed, 32 deletions(-) delete mode 100644 packages/crosscode/src/keypress.ts diff --git a/packages/crosscode/src/keypress.ts b/packages/crosscode/src/keypress.ts deleted file mode 100644 index c1c1926..0000000 --- a/packages/crosscode/src/keypress.ts +++ /dev/null @@ -1,32 +0,0 @@ -let dataListener: ((data: Buffer) => void) | null = null - -export function onKeypress(callback: (key: string) => void) { - if (dataListener) { - process.stdin.removeListener("data", dataListener) - } - - if (process.stdin.isTTY) { - process.stdin.setRawMode(true) - process.stdin.resume() - } - - 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() - } -} \ No newline at end of file From 2cf2d78a302c8079b768e5b7e363872de5585848 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 12:01:07 +0530 Subject: [PATCH 7/9] fix(cli): ask to remember tunnel only after auth setup succeeds --- packages/crosscode/src/cli.ts | 31 ++++++++++--------- .../crosscode/src/providers/cloudflared.ts | 5 ++- packages/crosscode/src/providers/ngrok.ts | 8 +++++ packages/crosscode/src/tunnel-setup.ts | 17 ++++++++++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index b2e961a..1828f81 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -6,7 +6,7 @@ import { logCrosscode, closeAllLogs } from "./log" import { startConnectionServer, closeConnectionServer, connectionPageUrl, setConnectionInfo } from "./connection-page" import { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" import type { TunnelPreference } from "./config" -import { promptTunnelChoice, promptRememberChoice, shouldShowProUpsell } from "./tunnel-setup" +import { promptTunnelChoice, maybeRememberTunnelChoice, shouldShowProUpsell } from "./tunnel-setup" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" @@ -175,6 +175,19 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} cloudflaredMode = "quick" } + // Remember only after the tunnel's own setup (login / token / cert) + // succeeds. Providers call handleAuthComplete once their config is done. + let rememberHandled = false + const handleAuthComplete = async () => { + if (choiceFromPrompt && !rememberHandled && tunnelChoice) { + rememberHandled = true + await maybeRememberTunnelChoice(config, () => { + saveConfig(config) + logCrosscode(`Tunnel preference saved: ${tunnelChoice}`) + }, tunnelChoice, true) + } + } + // CrossCode dedicated tunnel requires login. Offer it inline so the // user never has to leave the flow to figure out the `login` command. if (tunnelChoice === "crosscode" && !config.auth?.sessionToken) { @@ -184,18 +197,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} process.exit(1) } await refreshTier(config) - } - - if (choiceFromPrompt && tunnelChoice) { - try { - if (await promptRememberChoice()) { - config.tunnelPreference = tunnelChoice - saveConfig(config) - logCrosscode(`Tunnel preference saved: ${tunnelChoice}`) - } - } catch { - // Non-fatal: continue with the one-time choice. - } + await handleAuthComplete() } const tunnelProvider = tunnelChoice === "ngrok" ? "ngrok" : (tunnelChoice === "crosscode" ? "tunnel" : "cloudflared") @@ -290,6 +292,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} config, project, port, children, (url) => { tunnelUrl = url }, printQrWithUpsell, + { onAuthComplete: handleAuthComplete }, ) } else { await startCloudflaredProvider( @@ -297,7 +300,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} () => isShuttingDown, (url) => { tunnelUrl = url }, printQrWithUpsell, - { mode: cloudflaredMode }, + { mode: cloudflaredMode, onAuthComplete: handleAuthComplete }, ) } diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index 9cd8564..9b13622 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -81,7 +81,7 @@ export async function startCloudflaredProvider( isShuttingDown: () => boolean, onTunnelUrl: (url: string) => void, printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void, - opts: { mode?: CloudflaredMode } = {}, + opts: { mode?: CloudflaredMode; onAuthComplete?: () => Promise } = {}, ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) @@ -107,6 +107,9 @@ export async function startCloudflaredProvider( } logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") } + // Config is complete here: quick needs nothing, named just + // finished its login + tunnel create. Ask to remember now. + await opts.onAuthComplete?.() let tunnelUrl = "" diff --git a/packages/crosscode/src/providers/ngrok.ts b/packages/crosscode/src/providers/ngrok.ts index 103124f..7ad3a22 100644 --- a/packages/crosscode/src/providers/ngrok.ts +++ b/packages/crosscode/src/providers/ngrok.ts @@ -17,15 +17,23 @@ export async function startNgrokProvider( children: ChildProcess[], onTunnelUrl: (url: string) => void, printQr: (url: string, token: string, opencodePort: number, requestedPort: number) => void, + opts: { onAuthComplete?: () => Promise } = {}, ) { let ngrokToken = config.ngrokToken if (!ngrokToken) { ngrokToken = await setupNgrokToken() + if (!ngrokToken.trim()) { + console.log(chalk.red("\n ngrok auth token is required.\n")) + process.exit(1) + } config.ngrokToken = ngrokToken saveConfig(config) logCrosscode("ngrok auth token saved") debug("ngrok token saved") + await opts.onAuthComplete?.() + } else { + await opts.onAuthComplete?.() } const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() diff --git a/packages/crosscode/src/tunnel-setup.ts b/packages/crosscode/src/tunnel-setup.ts index d8bcbab..ad87c6b 100644 --- a/packages/crosscode/src/tunnel-setup.ts +++ b/packages/crosscode/src/tunnel-setup.ts @@ -41,6 +41,23 @@ export async function promptRememberChoice(): Promise { } } +export async function maybeRememberTunnelChoice( + config: { tunnelPreference?: TunnelPreference }, + save: () => void, + choice: TunnelPreference | null, + shouldAsk: boolean, +): Promise { + if (!shouldAsk || !choice) return + try { + if (await promptRememberChoice()) { + config.tunnelPreference = choice + save() + } + } catch { + // Non-fatal: continue with the one-time choice. + } +} + export function isPaidTier(tier: string | undefined): boolean { if (!tier) return false return tier.toLowerCase() !== "free" From 83ad6255ecce6dd40d417b10ea06b497fca6f66f Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 12:50:17 +0530 Subject: [PATCH 8/9] fix(tunnel-server): own-project reconnects bypass limit, fast-fail dead sockets, quiet late-chunk spam --- packages/tunnel-server/package.json | 2 +- packages/tunnel-server/src/index.ts | 2 +- packages/tunnel-server/src/proxy.ts | 27 ++++++++++++++---- packages/tunnel-server/src/registry.ts | 30 ++++++++++++++++++++ packages/tunnel-server/src/ws-handler.ts | 35 ++++++++++++++++++------ 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/packages/tunnel-server/package.json b/packages/tunnel-server/package.json index cffa726..f2c3a68 100644 --- a/packages/tunnel-server/package.json +++ b/packages/tunnel-server/package.json @@ -1,6 +1,6 @@ { "name": "@crosscode/tunnel-server", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "scripts": { diff --git a/packages/tunnel-server/src/index.ts b/packages/tunnel-server/src/index.ts index ed79a49..ef17b53 100644 --- a/packages/tunnel-server/src/index.ts +++ b/packages/tunnel-server/src/index.ts @@ -6,7 +6,7 @@ import { logger } from "./logger.js" import { getRegistryStats } from "./registry.js" const PORT = parseInt(process.env.PORT || "3100", 10) -const VERSION = "0.2.0" +const VERSION = "0.2.4" logger.info("Starting tunnel-server", { version: VERSION, port: PORT, nodeEnv: process.env.NODE_ENV, tunnelDomain: process.env.TUNNEL_DOMAIN || "connect.crosscode.site", tunnelUrlMode: process.env.TUNNEL_URL_MODE || "path" }) diff --git a/packages/tunnel-server/src/proxy.ts b/packages/tunnel-server/src/proxy.ts index 86ba636..ef37eae 100644 --- a/packages/tunnel-server/src/proxy.ts +++ b/packages/tunnel-server/src/proxy.ts @@ -2,6 +2,8 @@ import type { IncomingMessage, ServerResponse } from "http" import { get, addPendingRequest, + noteClosedRequest, + removePendingRequest, recordRequestBytes, recordRequestEnd, recordRequestStart, @@ -68,7 +70,7 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { if (!entry) { logger.warn("Tunnel not active for project", { projectId, path, method }) res.writeHead(503, { "Content-Type": "application/json" }) - res.end(JSON.stringify({ error: "Tunnel not active" })) + res.end(JSON.stringify({ error: "Tunnel not active", code: "TUNNEL_NOT_ACTIVE", projectId })) return } @@ -175,7 +177,18 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { body, } - sendToClient(entry.ws, msg, projectId, reqId) + if (!sendToClient(entry.ws, msg, projectId, reqId)) { + // The registry entry exists but its socket is already dead (e.g. a + // transient 'error' before 'close' fires). Fail fast instead of + // leaving the app hanging until it times out. + removePendingRequest(projectId, reqId) + recordRequestEnd(projectId, Date.now() - startTime, true) + if (!res.destroyed) { + res.writeHead(502, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ error: "Tunnel endpoint unavailable", code: "TUNNEL_ENDPOINT_UNAVAILABLE", projectId })) + } + return + } }) req.on("error", (err) => { @@ -192,17 +205,21 @@ export function handleProxy(req: IncomingMessage, res: ServerResponse): void { if (entry) { const wasPending = entry.pendingRequests.delete(reqId) if (wasPending) { + // The CLI may still deliver late chunks for this reqId; remember it + // so they are logged at debug instead of warning. + noteClosedRequest(reqId) logger.warn("Response closed before completion", { projectId, reqId, method, path, duration }) } } }) } -function sendToClient(ws: WebSocket, msg: TunnelS2C, projectId: string, reqId: string) { +function sendToClient(ws: WebSocket, msg: TunnelS2C, projectId: string, reqId: string): boolean { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(msg)) logger.debug("Request forwarded to tunnel client", { projectId, reqId }) - } else { - logger.error("Cannot forward request: WebSocket not open", { projectId, reqId, readyState: ws.readyState }) + return true } + logger.error("Cannot forward request: WebSocket not open", { projectId, reqId, readyState: ws.readyState }) + return false } diff --git a/packages/tunnel-server/src/registry.ts b/packages/tunnel-server/src/registry.ts index bb1b0a5..826ddc2 100644 --- a/packages/tunnel-server/src/registry.ts +++ b/packages/tunnel-server/src/registry.ts @@ -143,6 +143,36 @@ export function recordRequestEnd(projectId: string, durationMs: number, failed: entry.totalLatencyMs += durationMs } +// reqIds whose HTTP response already closed (usually the mobile app's SSE +// reconnect timing out a /mobile-event stream). The CLI keeps forwarding +// late chunks for them; treat those as expected noise instead of warnings. +const recentlyClosed = new Map() +const RECENTLY_CLOSED_TTL_MS = 120_000 +const RECENTLY_CLOSED_MAX = 5_000 + +function sweepRecentlyClosed(now: number) { + if (recentlyClosed.size < RECENTLY_CLOSED_MAX) return + for (const [reqId, at] of recentlyClosed) { + if (now - at > RECENTLY_CLOSED_TTL_MS) recentlyClosed.delete(reqId) + if (recentlyClosed.size < RECENTLY_CLOSED_MAX / 2) break + } +} + +export function noteClosedRequest(reqId: string): void { + sweepRecentlyClosed(Date.now()) + recentlyClosed.set(reqId, Date.now()) +} + +export function wasRecentlyClosed(reqId: string): boolean { + const at = recentlyClosed.get(reqId) + if (at === undefined) return false + if (Date.now() - at > RECENTLY_CLOSED_TTL_MS) { + recentlyClosed.delete(reqId) + return false + } + return true +} + export function getRegistryStats(): { totalClients: number totalPendingRequests: number diff --git a/packages/tunnel-server/src/ws-handler.ts b/packages/tunnel-server/src/ws-handler.ts index 2490996..8470d66 100644 --- a/packages/tunnel-server/src/ws-handler.ts +++ b/packages/tunnel-server/src/ws-handler.ts @@ -1,6 +1,6 @@ import { WebSocket } from "ws" import { validateApiKey } from "./db.js" -import { register, deregister, get, removePendingRequest, countByUserId } from "./registry.js" +import { register, deregister, get, removePendingRequest, countByUserId, wasRecentlyClosed } from "./registry.js" import type { TunnelC2S, TunnelS2C } from "@crosscode/shared" import { tierTunnelLimits } from "@crosscode/shared" import crypto from "crypto" @@ -71,6 +71,8 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa const pending = entry?.pendingRequests.get(msg.reqId) if (pending) { pending.onChunk(Buffer.from(msg.data, "base64")) + } else if (wasRecentlyClosed(msg.reqId)) { + logger.debug("Response chunk arrived after HTTP response closed", { projectId, reqId: msg.reqId }) } else { logger.warn("Response chunk for unknown reqId", { projectId, reqId: msg.reqId }) } @@ -82,6 +84,8 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa if (pending) { logger.debug("Response end received", { projectId, reqId: msg.reqId }) pending.onEnd() + } else if (wasRecentlyClosed(msg.reqId)) { + logger.debug("Response end arrived after HTTP response closed", { projectId, reqId: msg.reqId }) } else { logger.warn("Response end for unknown reqId", { projectId, reqId: msg.reqId }) } @@ -93,6 +97,8 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa if (pending) { logger.warn("Response error from tunnel client", { projectId, reqId: msg.reqId, error: msg.message }) pending.onError(msg.message) + } else if (wasRecentlyClosed(msg.reqId)) { + logger.debug("Response error arrived after HTTP response closed", { projectId, reqId: msg.reqId }) } else { logger.warn("Response error for unknown reqId", { projectId, reqId: msg.reqId }) } @@ -104,20 +110,26 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa } }) + // 'error' is usually followed by 'close' for the same socket. Deregister + // only once so the logs don't claim an unknown project on the second event. + let deregistered = false + const deregisterOnce = () => { + if (authenticated && !deregistered) { + deregistered = true + deregister(projectId) + } + } + ws.on("close", (code, reason) => { cleanup() logger.info("WebSocket connection closed", { projectId, remoteAddr, code, reason: reason.toString() }) - if (authenticated) { - deregister(projectId) - } + deregisterOnce() }) ws.on("error", (err) => { logger.error("WebSocket error", { projectId, remoteAddr, error: err.message }) cleanup() - if (authenticated) { - deregister(projectId) - } + deregisterOnce() }) function handleAuth(apiKey: string, projId: string) { @@ -139,7 +151,14 @@ export function handleWebSocket(ws: WebSocket, req: import("http").IncomingMessa return } - const activeTunnels = countByUserId(result.userId) + // A reconnect for the same project replaces its own registry entry + // (see register()), so it must not consume a tunnel-limit slot. + // Without this, a flaky network that reconnects while the stale + // socket hasn't fired 'close' yet gets a false "tunnel limit + // reached" and the CLI falls back to cloudflared while the app + // keeps polling the dead CrossCode URL. + const selfCount = get(projId)?.userId === result.userId ? 1 : 0 + const activeTunnels = countByUserId(result.userId) - selfCount const tunnelLimit = tierTunnelLimits[result.tier] ?? tierTunnelLimits.free if (activeTunnels >= tunnelLimit) { logger.warn("Auth failed: tunnel limit reached", { projectId: projId, userId: result.userId, tier: result.tier, activeTunnels, tunnelLimit }) From 46c6207d1939298124f04130175a63b30a2a1247 Mon Sep 17 00:00:00 2001 From: snehasishcodes <–snehasish.ray2025@iem.edu.in> Date: Mon, 19 Oct 2026 12:50:27 +0530 Subject: [PATCH 9/9] fix(cli): keep polling ngrok api until a public url appears --- packages/crosscode/src/providers/ngrok.ts | 35 +++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/crosscode/src/providers/ngrok.ts b/packages/crosscode/src/providers/ngrok.ts index 7ad3a22..f12fc08 100644 --- a/packages/crosscode/src/providers/ngrok.ts +++ b/packages/crosscode/src/providers/ngrok.ts @@ -68,22 +68,27 @@ export async function startNgrokProvider( 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) + 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) + return + } + debug("ngrok API has no tunnels yet, retrying", { tunnels: json.tunnels?.length ?? 0 }) + } catch (e) { + debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) } - } catch (e) { - debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) - setTimeout(pollNgrokApi, 500) - } - }) + // ngrok takes a few seconds to establish the tunnel: a + // 200 with an empty list is not fatal, keep polling until + // a public URL appears. + if (!tunnelUrl) setTimeout(pollNgrokApi, 500) + }) }) req.on("error", (e) => { debug("ngrok API request error", { error: e.message })