From 7e131438720ef73a94a6009941adb48e42f0948b Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Thu, 1 Oct 2026 21:21:31 +0530 Subject: [PATCH] fix(cli,web,mobile): repair tier display, opencode v2 startup and v2 health checks Fixes #129: the Dodo webhook stored the event name suffix ('updated', 'created', ...) as subscription_status, so effectiveTier() saw a non-'active' status and the CLI reported 'free' while the dashboard showed the raw paid tier. The webhook now reads the real status from the payload, ignores non-subscription events, keeps the paid tier through the cancel-at-period-end grace window, and effectiveTier() self-heals rows corrupted by the old webhook. Dashboard, API-key validation and the tunnel server all use the same grace-aware logic. Fixes #128: 'opencode serve --log-level DEBUG' is rejected by the v2 binary, killing the server at startup, and the CLI still printed a QR for the dead backend. The CLI now detects the server major via 'opencode --version', uses the matching flags, health paths and /api/* proxy routes, fails fast on spawn errors, and only hands back health-verified ports. The mobile app probes /api/info before /global/health so v2 servers no longer show 'remote server unreachable'. Custom binaries supported via opencodeBin config / CROSSCODE_OPENCODE_BIN. --- apps/mobile/app/(tabs)/index.tsx | 15 ++- apps/mobile/app/connect.tsx | 28 +++--- apps/mobile/lib/server-version.ts | 95 +++++++++++++++++++ apps/mobile/lib/utils.ts | 7 ++ apps/mobile/store/connection.store.ts | 3 + .../app/api/auth/api-key/validate/route.ts | 5 +- .../src/app/api/billing/subscription/route.ts | 6 +- apps/web/src/app/api/webhooks/dodo/route.ts | 44 ++++++--- docs/cli.md | 6 ++ packages/crosscode/src/cli.ts | 13 ++- packages/crosscode/src/config.ts | 8 ++ packages/crosscode/src/opencode-version.ts | 29 ++++++ packages/crosscode/src/opencode.ts | 58 +++++++---- packages/crosscode/src/port-detect.ts | 24 ++++- .../crosscode/src/providers/cloudflared.ts | 9 +- packages/crosscode/src/providers/ngrok.ts | 7 +- packages/crosscode/src/providers/tunnel.ts | 11 ++- packages/crosscode/src/proxy.ts | 7 +- packages/shared/src/plans.ts | 47 ++++++++- packages/tunnel-server/src/db.ts | 7 +- 20 files changed, 349 insertions(+), 80 deletions(-) create mode 100644 apps/mobile/lib/server-version.ts create mode 100644 packages/crosscode/src/opencode-version.ts diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 1c12e37..49d7d1d 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -12,7 +12,8 @@ import { useAllowsMultipleConnections } from "@/lib/entitlement" import { useAuth } from "@/store/auth.store" import { usePaywall } from "@/store/paywall.store" import { isAtTunnelLimit, requestPaywall } from "@/lib/paywall" -import { cn, formatDirectory, getAuthHeader } from "@/lib/utils" +import { cn, formatDirectory } from "@/lib/utils" +import { detectServerVersion } from "@/lib/server-version" import { getCurrentProject } from "@/lib/projects" import AlertTriangle from "lucide-react-native/dist/esm/icons/triangle-alert" import ArrowUpDown from "lucide-react-native/dist/esm/icons/arrow-up-down" @@ -287,13 +288,11 @@ export default function HomeScreen() { await Promise.all(connections.map(async (conn) => { if (!conn.url || !conn.token) return try { - const res = await fetch(`${conn.url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(conn.token) - } - }) - setConnectionHealth(conn.id, res.ok) + // Detecting the version doubles as the health check: a version + // means the server answered on a known API route. Always probe + // live here (already throttled above) so version and health stay fresh. + const version = await detectServerVersion(conn.url, conn.token, { force: true }) + setConnectionHealth(conn.id, version !== null) } catch { setConnectionHealth(conn.id, false) } diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index 7f221ee..18c67e1 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -9,7 +9,8 @@ import { Text } from "@/components/ui/text" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import XIcon from "lucide-react-native/dist/esm/icons/x" -import { cn, getAuthHeader } from "@/lib/utils" +import { cn } from "@/lib/utils" +import { cacheServerVersion, detectServerVersion, type ServerVersion } from "@/lib/server-version" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" import { consumePendingConnection, peekPendingConnection } from "@/lib/pending-connection" @@ -28,6 +29,7 @@ export default function Connect() { const [name, setName] = useState(`Connection ${new Date().toLocaleString()}`) const [testing, setTesting] = useState(false) const [tested, setTested] = useState<{ msg: string, error: boolean } | null>(null) + const [serverVersion, setServerVersion] = useState(null) const theme = colorScheme ?? "light" @@ -37,22 +39,24 @@ export default function Connect() { try { validateConnectionUrl(url) validateAuthToken(token) + // Detect the opencode API major: v2 answers /api/info, v1 answers + // /global/health. Reachable either way means the server is up. + // (On v2, /global/health serves the web UI with HTTP 200 but no + // JSON body, so probing only that path reported v2 servers as + // "unreachable" in #128.) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 10000) try { - const res = await fetch(`${url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) - }, - signal: controller.signal, - }) - if (res.ok) { + const version = await detectServerVersion(url, token, { force: true, signal: controller.signal }) + if (version) { + cacheServerVersion(url, version) + setServerVersion(version) setTested({ - msg: "Remote server reachable", + msg: version >= 2 ? "Remote server reachable (opencode v2)" : "Remote server reachable", error: false }) } else { + setServerVersion(null) setTested({ msg: "Remote server unreachable", error: true @@ -87,8 +91,10 @@ export default function Connect() { addConnection({ url, name, - token + token, + serverVersion: serverVersion ?? undefined, }) + cacheServerVersion(url, serverVersion ?? 1) consumePendingConnection() router.replace("/") } diff --git a/apps/mobile/lib/server-version.ts b/apps/mobile/lib/server-version.ts new file mode 100644 index 0000000..abf3b3e --- /dev/null +++ b/apps/mobile/lib/server-version.ts @@ -0,0 +1,95 @@ +import { getAuthHeader } from "@/lib/utils" +import { useConnections } from "@/store/connection.store" + +export type ServerVersion = 1 | 2 + +export type GatedFeature = "share" | "todo" | "question" + +const memoryCache = new Map() + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, "") +} + +export function cacheServerVersion(url: string, version: ServerVersion) { + memoryCache.set(normalizeUrl(url), version) +} + +// Sync lookup: memory cache, then the persisted connection, else v1. +// Unknown servers are treated as v1 so old CLIs keep working untouched. +export function getServerVersion(url: string | undefined | null): ServerVersion { + if (!url) return 1 + const cached = memoryCache.get(normalizeUrl(url)) + if (cached) return cached + const match = useConnections.getState().connections.find((c) => normalizeUrl(c.url) === normalizeUrl(url)) + if (match?.serverVersion === 2) { + memoryCache.set(normalizeUrl(url), 2) + return 2 + } + return 1 +} + +async function probeJson(url: string, token: string, path: string, signal?: AbortSignal): Promise { + try { + const res = await fetch(`${normalizeUrl(url)}${path}`, { + method: "GET", + headers: { "Authorization": getAuthHeader(token) }, + ...(signal ? { signal } : {}), + }) + if (!res.ok) return null + const contentType = res.headers.get("content-type") ?? "" + if (!contentType.includes("json")) return null + return (await res.json()) as unknown + } catch { + return null + } +} + +function persistVersion(url: string, version: ServerVersion) { + cacheServerVersion(url, version) + const state = useConnections.getState() + const match = state.connections.find((c) => normalizeUrl(c.url) === normalizeUrl(url)) + if (match && match.serverVersion !== version) { + state.updateConnection(match.id, { serverVersion: version }) + } +} + +// Probe the server to learn whether it speaks the v1 or v2 API. +// v2 answers GET /api/info with {"version": "2.x", ...}; v1 answers +// GET /global/health with {"healthy": true, ...}. Returns null when +// the server is unreachable. Order matters: on v2, /global/health +// serves the web UI (HTML 200), so /api/info must be checked first. +export async function detectServerVersion( + url: string, + token: string, + opts?: { force?: boolean; signal?: AbortSignal }, +): Promise { + const key = normalizeUrl(url) + if (!opts?.force) { + const cached = memoryCache.get(key) + if (cached) return cached + } + const info = await probeJson(url, token, "/api/info", opts?.signal) + if (info && typeof info === "object") { + const version = (info as Record).version + if (typeof version === "string" && parseInt(version.split(".")[0]!, 10) >= 2) { + persistVersion(url, 2) + return 2 + } + } + const health = await probeJson(url, token, "/global/health", opts?.signal) + if (health && typeof health === "object" && (health as Record).healthy === true) { + persistVersion(url, 1) + return 1 + } + return null +} + +// Features removed from the v2 API (expected to return later). +// The UI mutes these on v2 instead of removing them; v1 is unaffected. +export function supportsFeature(version: ServerVersion, feature: GatedFeature): boolean { + if (version >= 2) { + return feature !== "share" && feature !== "todo" && feature !== "question" + } + return true +} diff --git a/apps/mobile/lib/utils.ts b/apps/mobile/lib/utils.ts index 0a1e107..b46a182 100644 --- a/apps/mobile/lib/utils.ts +++ b/apps/mobile/lib/utils.ts @@ -44,6 +44,13 @@ export function clearAuthCache(token?: string): void { authCache.clear() } +// v2 moved the API under /api/*; v1 serves routes from the root. +export function apiUrl(url: string, version: 1 | 2, path: string): string { + const base = url.replace(/\/+$/, "") + const clean = path.startsWith("/") ? path : `/${path}` + return version >= 2 ? `${base}/api${clean}` : `${base}${clean}` +} + export function formatDirectory(path: string | undefined | null): string { if (!path) return "" const cleaned = path.replace(/\/+$/, '') diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index dc3fa6f..80e8eda 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -18,6 +18,9 @@ export type Connection = { name: string added: number healthy?: boolean | null + // Detected opencode API major (1 = classic paths, 2 = /api/* paths). + // Unknown means v1 so old CLIs keep working untouched. + serverVersion?: 1 | 2 | null } type ConnectionStore = { diff --git a/apps/web/src/app/api/auth/api-key/validate/route.ts b/apps/web/src/app/api/auth/api-key/validate/route.ts index 1987994..f35a89e 100644 --- a/apps/web/src/app/api/auth/api-key/validate/route.ts +++ b/apps/web/src/app/api/auth/api-key/validate/route.ts @@ -29,7 +29,10 @@ export async function POST(req: NextRequest) { } const u = users[0] - const tier = effectiveTier(u.tier, u.subscriptionStatus) + const tier = effectiveTier(u.tier, u.subscriptionStatus, { + cancelAtPeriodEnd: u.subscriptionCancelAtPeriodEnd, + renewsAt: u.subscriptionRenewsAt, + }) logger.info("API", `POST /api/auth/api-key/validate - Valid key for email=${u.email}, tier=${tier}`) return NextResponse.json({ diff --git a/apps/web/src/app/api/billing/subscription/route.ts b/apps/web/src/app/api/billing/subscription/route.ts index b580f4f..1293161 100644 --- a/apps/web/src/app/api/billing/subscription/route.ts +++ b/apps/web/src/app/api/billing/subscription/route.ts @@ -1,12 +1,16 @@ import { NextRequest, NextResponse } from "next/server" import { getBillingUser } from "@/lib/billing-auth" +import { effectiveTier } from "@crosscode/shared" export async function GET(req: NextRequest) { const currentUser = await getBillingUser(req) if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) return NextResponse.json({ - tier: currentUser.tier, + tier: effectiveTier(currentUser.tier, currentUser.subscriptionStatus, { + cancelAtPeriodEnd: currentUser.subscriptionCancelAtPeriodEnd, + renewsAt: currentUser.subscriptionRenewsAt, + }), status: currentUser.subscriptionStatus, renewsAt: currentUser.subscriptionRenewsAt, cancelAtPeriodEnd: currentUser.subscriptionCancelAtPeriodEnd, diff --git a/apps/web/src/app/api/webhooks/dodo/route.ts b/apps/web/src/app/api/webhooks/dodo/route.ts index a0e0a9e..86d51da 100644 --- a/apps/web/src/app/api/webhooks/dodo/route.ts +++ b/apps/web/src/app/api/webhooks/dodo/route.ts @@ -47,19 +47,39 @@ export async function POST(req: NextRequest) { : null if (!currentUser) return NextResponse.json({ received: true }) - const status = event.type.startsWith("subscription.") - ? event.type.replace("subscription.", "") - : currentUser.subscriptionStatus - const ended = ["cancelled", "expired"].includes(status || "") + // Only subscription lifecycle events carry subscription state. Other + // events (payment.*, customer.*, etc.) must not rewrite tier/status — + // previously any "subscription." event type (e.g. + // "subscription.updated") was stored verbatim as the status, so + // effectiveTier() saw "updated" instead of "active" and the CLI + // reported "free" while the dashboard (raw tier column) still showed + // the paid plan (issue #129). + if (!event.type.startsWith("subscription.")) return NextResponse.json({ received: true }) + + // The real subscription status lives in the payload, not the event + // name. Fall back to the previous value when the payload omits it. + const payloadStatus = + (typeof data.status === "string" && data.status) || + (typeof data.subscription_status === "string" && data.subscription_status) || + null + const eventSuffix = event.type.replace("subscription.", "") + const status = (payloadStatus || currentUser.subscriptionStatus || eventSuffix).toLowerCase() + const renewsAt = data.next_billing_date ? new Date(String(data.next_billing_date)) : currentUser.subscriptionRenewsAt + const cancelAtPeriodEnd = typeof data.cancel_at_period_end === "boolean" + ? data.cancel_at_period_end + : currentUser.subscriptionCancelAtPeriodEnd + // A cancelled subscription still grants access until the period ends. + const inGracePeriod = !!cancelAtPeriodEnd && !!renewsAt && renewsAt.getTime() > Date.now() + const ended = ["cancelled", "canceled", "expired", "terminated"].includes(status) && !inGracePeriod await db.update(user).set({ - dodoCustomerId: customerId || currentUser.dodoCustomerId, - dodoSubscriptionId: subscriptionId || currentUser.dodoSubscriptionId, - subscriptionProductId: productId || currentUser.subscriptionProductId, - subscriptionStatus: status, - subscriptionRenewsAt: data.next_billing_date ? new Date(String(data.next_billing_date)) : currentUser.subscriptionRenewsAt, - subscriptionCancelAtPeriodEnd: Boolean(data.cancel_at_period_end), - tier: ended ? "free" : tier || currentUser.tier, - updatedAt: new Date(), + dodoCustomerId: customerId || currentUser.dodoCustomerId, + dodoSubscriptionId: subscriptionId || currentUser.dodoSubscriptionId, + subscriptionProductId: productId || currentUser.subscriptionProductId, + subscriptionStatus: payloadStatus ? status : currentUser.subscriptionStatus, + subscriptionRenewsAt: renewsAt, + subscriptionCancelAtPeriodEnd: Boolean(cancelAtPeriodEnd), + tier: ended ? "free" : tier || currentUser.tier, + updatedAt: new Date(), }).where(eq(user.id, currentUser.id)) return NextResponse.json({ received: true }) diff --git a/docs/cli.md b/docs/cli.md index f8eb1c1..bc44e15 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -114,6 +114,7 @@ Config file: `~/.crosscode/config.json` { "ngrokToken": "string — ngrok auth token (saved on first use)", "port": "number — local opencode port (default: 4096)", + "opencodeBin": "string — opencode executable to run, e.g. \"opencode-v2\" (default: \"opencode\")", "auth": { "email": "string — logged-in email", "sessionToken": "string — API key", @@ -122,6 +123,10 @@ Config file: `~/.crosscode/config.json` } ``` +Set `"opencodeBin": "opencode-v2"` if v2 lives under a different command on +your machine. Crosscode detects the server version from `$bin --version` and +uses the matching flags and API paths automatically. + ## Log Files All logs are stored in `~/.crosscode/`: @@ -137,6 +142,7 @@ All logs are stored in `~/.crosscode/`: | Variable | Default | Description | |---|---|---| +| `CROSSCODE_OPENCODE_BIN` | `opencode` | Opencode executable to run (overrides `opencodeBin` in config) | | `CROSSCODE_WEB_URL` | `https://crosscode.site` | Web dashboard base URL | | `CROSSCODE_AUTH_URL` | `${CROSSCODE_WEB_URL}/api/auth` | Authentication API URL | | `CROSSCODE_TUNNEL_WS_URL` | `wss://tunnel.sish.work/ws` | Tunnel server WebSocket URL | diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index ba273e2..75a303a 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -6,7 +6,7 @@ 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 { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" @@ -141,9 +141,14 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} logCrosscode(`CrossCode starting up (tunnel: ${tunnelProvider}, debug: ${process.env.CROSSCODE_DEBUG === "1"})`) debug("startup config", { tunnelProvider, port, hasAuth: !!config.auth?.sessionToken }) - if (!checkDep("opencode")) { - console.error(chalk.red("[DEPENDENCY ERROR] opencode not found. Install opencode and try again.")) - logCrosscode("Dependency check failed: opencode") + const opencodeBin = resolveOpencodeBin(config) + logCrosscode(`Using opencode binary: ${opencodeBin}`) + debug("opencode binary", { opencodeBin }) + + if (!checkDep(opencodeBin)) { + console.error(chalk.red(`[DEPENDENCY ERROR] ${opencodeBin} not found. Install opencode and try again.`)) + console.log(chalk.dim(`Override the binary with "opencodeBin" in ~/.crosscode/config.json or CROSSCODE_OPENCODE_BIN.`)) + logCrosscode(`Dependency check failed: ${opencodeBin}`) missingDep = true } diff --git a/packages/crosscode/src/config.ts b/packages/crosscode/src/config.ts index 75ba9b6..5684986 100644 --- a/packages/crosscode/src/config.ts +++ b/packages/crosscode/src/config.ts @@ -24,6 +24,8 @@ export type Config = { sessionToken?: string projectId?: string telemetry?: boolean + // Name (or path) of the opencode executable to run, e.g. "opencode-v2". + opencodeBin?: string cloudflaredTunnel?: CloudflaredTunnel projects?: Record auth?: { @@ -33,6 +35,12 @@ export type Config = { } } +// Which opencode executable to spawn. Env wins over the config file so one +// machine can pin a different binary without editing shared config. +export function resolveOpencodeBin(config: Config): string { + return process.env.CROSSCODE_OPENCODE_BIN?.trim() || config.opencodeBin?.trim() || "opencode" +} + export function readConfig(): Config { if (!existsSync(configFile)) return {} try { diff --git a/packages/crosscode/src/opencode-version.ts b/packages/crosscode/src/opencode-version.ts new file mode 100644 index 0000000..2f2072c --- /dev/null +++ b/packages/crosscode/src/opencode-version.ts @@ -0,0 +1,29 @@ +import { execFile } from "child_process" + +export type OpencodeMajor = 1 | 2 + +let cached: { bin: string; major: OpencodeMajor } | null = null + +export function getCachedOpencodeMajor(): OpencodeMajor | null { + return cached?.major ?? null +} + +function parseMajor(output: string): OpencodeMajor { + const match = output.match(/(\d+)\.\d+\.\d+/) + if (match && parseInt(match[1], 10) >= 2) return 2 + return 1 +} + +// `opencode --version` prints e.g. "1.15.7" (v1) or "opencode v2.0.6" (v2). +// Defaults to 1 when the binary is missing or the output is unrecognized, +// so old setups keep working. +export function detectOpencodeMajor(bin = "opencode"): Promise { + if (cached && cached.bin === bin) return Promise.resolve(cached.major) + return new Promise((resolve) => { + execFile(bin, ["--version"], { encoding: "utf8", timeout: 8000 }, (err, stdout) => { + const major = parseMajor(err ? "" : stdout) + cached = { bin, major } + resolve(major) + }) + }) +} diff --git a/packages/crosscode/src/opencode.ts b/packages/crosscode/src/opencode.ts index 8a2a5c4..1a41034 100644 --- a/packages/crosscode/src/opencode.ts +++ b/packages/crosscode/src/opencode.ts @@ -4,10 +4,12 @@ import chalk from "chalk" import { debug, spawnCmd } from "./util" import { logCrosscode, opencodeLogStream } from "./log" import { waitForOpencodePort } from "./port-detect" +import { detectOpencodeMajor, type OpencodeMajor } from "./opencode-version" export type OpencodeInstance = { proc: ChildProcess detectedPort: number + serverVersion: OpencodeMajor } export async function startOpencode(opts: { @@ -15,11 +17,20 @@ export async function startOpencode(opts: { sessionToken: string spinner: Ora children: ChildProcess[] + opencodeBin?: string }): Promise { - const { port, sessionToken, spinner, children } = opts + const { port, sessionToken, spinner, children, opencodeBin = "opencode" } = opts - const proc = spawnCmd("opencode", [ - "serve", "--print-logs", "--log-level", "DEBUG", + // v2 renamed --log-level values to lowercase ("DEBUG" is rejected and + // the server exits immediately, which used to surface as a confusing + // "Failed to start opencode serve" followed by a dead QR in #128). + const serverVersion = await detectOpencodeMajor(opencodeBin) + const logLevel = serverVersion >= 2 ? "debug" : "DEBUG" + logCrosscode(`Detected ${opencodeBin} v${serverVersion}`) + debug("opencode version", { bin: opencodeBin, serverVersion }) + + const proc = spawnCmd(opencodeBin, [ + "serve", "--print-logs", "--log-level", logLevel, "--port", String(port), "--hostname", "127.0.0.1", ], { cwd: process.cwd(), @@ -29,27 +40,40 @@ export async function startOpencode(opts: { 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 }) + // Fail fast when the binary can't even spawn (missing executable): + // reject here so callers never print a QR for a dead backend. + let spawnError: Error | null = null + const spawnFailed = new Promise((_, reject) => { + proc.once("error", (err) => { + spawnError = err + spinner.fail(chalk.red.italic(`Failed to start ${opencodeBin} serve`)) + logCrosscode(`${opencodeBin} serve error: ${err.message}`) + debug("opencode spawn error", { bin: opencodeBin, error: err.message }) + reject(new Error(`Failed to start ${opencodeBin} serve: ${err.message}`)) + }) }) - const detectedPort = await waitForOpencodePort({ - proc, - requestedPort: port, - onData: d => opencodeLogStream.write(d), + proc.on("spawn", () => { + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Detecting port...") + logCrosscode(`${opencodeBin} serve started (PID: ` + proc.pid + ")") + debug("opencode spawned", { bin: opencodeBin, pid: proc.pid }) }) + const detectedPort = await Promise.race([ + spawnFailed, + waitForOpencodePort({ + proc, + requestedPort: port, + healthPath: serverVersion >= 2 ? "/api/info" : "/global/health", + onData: d => opencodeLogStream.write(d), + }), + ]) + void spawnError + if (detectedPort !== port) { logCrosscode(`Using detected port ${detectedPort} instead of requested port ${port}`) debug("using detected port", { detected: detectedPort, requested: port }) } - return { proc, detectedPort } + return { proc, detectedPort, serverVersion } } diff --git a/packages/crosscode/src/port-detect.ts b/packages/crosscode/src/port-detect.ts index 76e9d5b..14f1ca0 100644 --- a/packages/crosscode/src/port-detect.ts +++ b/packages/crosscode/src/port-detect.ts @@ -3,12 +3,14 @@ import http from "http" const PORT_PATTERNS = [ /opencode server listening on \S*?:(\d+)/i, + // v2 prints "server listening on http://127.0.0.1:PORT" (no "opencode" prefix) + /server listening on \S*?:(\d+)/i, /listening on (?:https?:\/\/)?[^\s"']*?:(\d+)/i, ] -function probePort(port: number): Promise { +function probePort(port: number, healthPath: string): Promise { return new Promise((resolve) => { - const req = http.get({ host: "127.0.0.1", port, path: "/global/health", timeout: 700 }, (res) => { + const req = http.get({ host: "127.0.0.1", port, path: healthPath, timeout: 700 }, (res) => { res.resume() resolve(true) }) @@ -68,10 +70,11 @@ async function getListeningPorts(pid?: number): Promise { export function waitForOpencodePort(opts: { proc: ChildProcess requestedPort: number + healthPath?: string onData?: (data: Buffer) => void timeoutMs?: number }): Promise { - const { proc, requestedPort, onData, timeoutMs = 15_000 } = opts + const { proc, requestedPort, healthPath = "/global/health", onData, timeoutMs = 15_000 } = opts let buffer = "" let resolved = false @@ -108,15 +111,26 @@ export function waitForOpencodePort(opts: { if (exited || probing) return probing = true - const alive = await probePort(requestedPort) + const alive = await probePort(requestedPort, healthPath) probing = false if (alive) return finish(requestedPort) }, 250) const timeout = setTimeout(async () => { + // Never hand back a dead port: verify each candidate with the + // version-correct health endpoint first. Handing back an + // unverified port is what produced a scannable-but-dead QR + // in #128. + const logged = fromLogs() + if (logged && await probePort(logged, healthPath)) return finish(logged) const ports = (await getListeningPorts(proc.pid)).filter((p) => p > 0 && p <= 65535) + for (const p of ports) { + if (await probePort(p, healthPath)) return finish(p) + } + if (await probePort(requestedPort, healthPath)) return finish(requestedPort) + if (logged) return finish(logged) if (ports.length > 0) return finish(ports[0]) - finish(fromLogs() ?? requestedPort) + finish(requestedPort) }, timeoutMs) proc.on("exit", (code) => { diff --git a/packages/crosscode/src/providers/cloudflared.ts b/packages/crosscode/src/providers/cloudflared.ts index e5e086b..d04f074 100644 --- a/packages/crosscode/src/providers/cloudflared.ts +++ b/packages/crosscode/src/providers/cloudflared.ts @@ -10,7 +10,7 @@ import { logCrosscode, cloudflaredLogStream, cloudflaredTunnelDir, cfCertPath } import { createOpencodeProxy } from "../proxy" import { startOpencode } from "../opencode" import type { Config, ProjectConfig, CloudflaredTunnel } from "../config" -import { ensureSessionToken, ensureProjectId, saveProjectConfig } from "../config" +import { ensureSessionToken, ensureProjectId, saveProjectConfig, resolveOpencodeBin } from "../config" // Use a persistent named cloudflared tunnel so the public URL is stable // (.cfargotunnel.com) across restarts and reconnects, instead of @@ -65,18 +65,19 @@ export async function startCloudflaredProvider( ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() - const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy") + const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy", serverVersion) 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...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") const namedTunnel = await ensureCloudflaredNamedTunnel(config, project) if (!namedTunnel) { diff --git a/packages/crosscode/src/providers/ngrok.ts b/packages/crosscode/src/providers/ngrok.ts index eb1bfb3..103124f 100644 --- a/packages/crosscode/src/providers/ngrok.ts +++ b/packages/crosscode/src/providers/ngrok.ts @@ -7,7 +7,7 @@ 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 { ensureSessionToken, saveConfig, resolveOpencodeBin } from "../config" import { setupNgrokToken } from "../auth" export async function startNgrokProvider( @@ -30,8 +30,9 @@ export async function startNgrokProvider( const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) const ngrok = spawnCmd("ngrok", ["http", `--authtoken=${ngrokToken}`, `${detectedPort}`], { stdio: ["ignore", "pipe", "pipe"], @@ -41,7 +42,7 @@ export async function startNgrokProvider( ngrok.on("spawn", () => { logCrosscode("ngrok started (PID: " + ngrok.pid + ")") - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Starting ngrok tunnel...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Starting ngrok tunnel...") debug("ngrok spawned", { pid: ngrok.pid }) }) ngrok.on("error", (err) => { diff --git a/packages/crosscode/src/providers/tunnel.ts b/packages/crosscode/src/providers/tunnel.ts index 812da53..7f140ec 100644 --- a/packages/crosscode/src/providers/tunnel.ts +++ b/packages/crosscode/src/providers/tunnel.ts @@ -8,7 +8,7 @@ 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" +import { ensureSessionToken, ensureProjectId, resolveOpencodeBin } from "../config" export type TunnelCallbacks = { onTunnelUrl: (url: string) => void @@ -25,19 +25,20 @@ export async function startTunnelProvider( ) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() const sessionToken = ensureSessionToken(config, project) + const opencodeBin = resolveOpencodeBin(config) - const { detectedPort } = await startOpencode({ port, sessionToken, spinner, children }) + const { detectedPort, serverVersion } = await startOpencode({ port, sessionToken, spinner, children, opencodeBin }) let proxyPort = await getFreePort() while (proxyPort === port) proxyPort = await getFreePort() - const proxy = createOpencodeProxy(detectedPort, sessionToken, "tunnel") + const proxy = createOpencodeProxy(detectedPort, sessionToken, "tunnel", serverVersion) 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`, { + const testReq = http.request(`http://127.0.0.1:${detectedPort}${serverVersion >= 2 ? "/api/info" : "/global/health"}`, { method: "GET", headers: { "Authorization": `Basic ${Buffer.from(`opencode:${sessionToken}`).toString("base64")}`, @@ -53,7 +54,7 @@ export async function startTunnelProvider( }) testReq.end() - spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") + spinner.text = chalk.green.italic(`${opencodeBin} serve running`) + chalk.yellow.italic(" • Connecting to tunnel server...") const projectId = ensureProjectId(config, project) logCrosscode(`Project ID: ${projectId}`) diff --git a/packages/crosscode/src/proxy.ts b/packages/crosscode/src/proxy.ts index 0fa2ed1..8d11402 100644 --- a/packages/crosscode/src/proxy.ts +++ b/packages/crosscode/src/proxy.ts @@ -1,6 +1,7 @@ import http from "http" import { debug, censorAuth } from "./util" import { handleGitRequest } from "./git-handler" +import type { OpencodeMajor } from "./opencode-version" 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"]) @@ -26,7 +27,9 @@ export function sanitizeUrlPath(url: string | undefined): string { return `${cleaned || "/"}${rawQuery ? `?${rawQuery}` : ""}` } -export function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string): http.Server { +export function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string, serverVersion: OpencodeMajor = 1): http.Server { + // v2 moved the API under /api/* + const eventPath = serverVersion >= 2 ? "/api/event" : "/event" return http.createServer(async (req, res) => { const safePath = sanitizeUrlPath(req.url) const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` @@ -67,7 +70,7 @@ export function createOpencodeProxy(targetPort: number, sessionToken: string, lo debug("converted SSE auth to Basic format") } - const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { + const sseReq = http.get(`http://127.0.0.1:${targetPort}${eventPath}`, { headers: { "Accept": "text/event-stream", "Authorization": sseAuth, diff --git a/packages/shared/src/plans.ts b/packages/shared/src/plans.ts index 8e40403..ce943d4 100644 --- a/packages/shared/src/plans.ts +++ b/packages/shared/src/plans.ts @@ -28,13 +28,50 @@ export const tierTunnelLimits: Record = { const PAID_TIERS = new Set(["starter", "builder", "enterprise"]) +// Dodo subscription statuses that still grant paid access. "active" is the +// steady state; "trialing" covers trials. A subscription cancelled with +// cancel_at_period_end=true stays "active" until the period ends, but treat +// an explicit grace window as paid too so webhook timing can't downgrade +// a user who still has access (issue #129). +const ACTIVE_STATUSES = new Set(["active", "trialing", "trial", "past_due"]) + +// Every status Dodo actually reports. Anything else in this column is a +// leftover from the old webhook, which stored the event name suffix +// ("updated", "created", ...) as the status. +const KNOWN_STATUSES = new Set([ + "active", "trialing", "trial", "past_due", "cancelled", "canceled", + "expired", "terminated", "failed", "on_hold", "paused", + "incomplete", "incomplete_expired", "unpaid", +]) + +export type EffectiveTierOpts = { + cancelAtPeriodEnd?: boolean | null + renewsAt?: Date | string | null +} + /** - * A paid tier only counts as paid while its subscription is active. + * A paid tier only counts as paid while its subscription grants access. * Otherwise it falls back to "free". Mirrors the tunnel-server SQL logic * so the tier shown in the CLI matches what the tunnel server enforces. */ -export function effectiveTier(tier: string | null | undefined, subscriptionStatus: string | null | undefined): string { - if (!tier) return "free" - if (PAID_TIERS.has(tier) && (subscriptionStatus ?? "") !== "active") return "free" - return tier +export function effectiveTier( + tier: string | null | undefined, + subscriptionStatus: string | null | undefined, + opts?: EffectiveTierOpts, +): string { + if (!tier) return "free" + if (!PAID_TIERS.has(tier)) return tier + const status = (subscriptionStatus ?? "").toLowerCase() + if (ACTIVE_STATUSES.has(status)) return tier + // Grace period: cancelled at period end but the current period hasn't + // elapsed yet (dashboard still shows the paid plan until renewal date). + const renews = opts?.renewsAt ? new Date(opts.renewsAt).getTime() : NaN + const renewsInFuture = !Number.isNaN(renews) && renews > Date.now() + if (opts?.cancelAtPeriodEnd && (Number.isNaN(renews) || renewsInFuture)) return tier + // Self-heal rows corrupted by the old webhook (event suffix stored as + // status): an unrecognized status with a future renewal date belongs to + // a subscription Dodo still considers billable, so keep the paid tier + // until a real status arrives. + if (status && !KNOWN_STATUSES.has(status) && renewsInFuture) return tier + return "free" } diff --git a/packages/tunnel-server/src/db.ts b/packages/tunnel-server/src/db.ts index 2b02470..0b14b6c 100644 --- a/packages/tunnel-server/src/db.ts +++ b/packages/tunnel-server/src/db.ts @@ -42,14 +42,17 @@ export async function validateApiKey(apiKey: string): Promise<{ userId: string; for (let attempt = 1; attempt <= 3; attempt++) { try { const rows = await sql` - SELECT id, email, tier, subscription_status + SELECT id, email, tier, subscription_status, subscription_cancel_at_period_end, subscription_renews_at FROM "user" WHERE api_key = ${apiKey} LIMIT 1 ` if (rows.length === 0) { logger.warn("API key not found in database", { apiKey: apiKey.substring(0, 8) + "..." }) return null } - const tier = effectiveTier(rows[0].tier, rows[0].subscription_status) + const tier = effectiveTier(rows[0].tier, rows[0].subscription_status, { + cancelAtPeriodEnd: rows[0].subscription_cancel_at_period_end, + renewsAt: rows[0].subscription_renews_at, + }) logger.info("API key validated", { userId: rows[0].id, email: rows[0].email, tier }) return { userId: rows[0].id, email: rows[0].email, tier } } catch (err) {