Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
28 changes: 17 additions & 11 deletions apps/mobile/app/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,6 +29,7 @@ export default function Connect() {
const [name, setName] = useState(`Connection ${new Date().toLocaleString()}`)
const [testing, setTesting] = useState<boolean>(false)
const [tested, setTested] = useState<{ msg: string, error: boolean } | null>(null)
const [serverVersion, setServerVersion] = useState<ServerVersion | null>(null)

const theme = colorScheme ?? "light"

Expand All @@ -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
Expand Down Expand Up @@ -87,8 +91,10 @@ export default function Connect() {
addConnection({
url,
name,
token
token,
serverVersion: serverVersion ?? undefined,
})
cacheServerVersion(url, serverVersion ?? 1)
consumePendingConnection()
router.replace("/")
}
Expand Down
95 changes: 95 additions & 0 deletions apps/mobile/lib/server-version.ts
Original file line number Diff line number Diff line change
@@ -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<string, ServerVersion>()

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<unknown | null> {
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<ServerVersion | null> {
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<string, unknown>).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<string, unknown>).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
}
7 changes: 7 additions & 0 deletions apps/mobile/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/+$/, '')
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/store/connection.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/api/auth/api-key/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/api/billing/subscription/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
44 changes: 32 additions & 12 deletions apps/web/src/app/api/webhooks/dodo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<anything>" 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 })
Expand Down
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/`:
Expand All @@ -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 |
Expand Down
13 changes: 9 additions & 4 deletions packages/crosscode/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions packages/crosscode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ProjectConfig>
auth?: {
Expand All @@ -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 {
Expand Down
Loading
Loading