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
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ bun run db:migrate

# 6. Start the dashboard
bun run dev:web
# → http://localhost:3000
# → http://localhost:3003
```

## Prerequisites
Expand Down Expand Up @@ -74,7 +74,7 @@ Run from the **repo root** unless noted.
| Command | What it starts |
|---------|---------------|
| `bun run dev` | All dev servers (Turborepo) |
| `bun run dev:web` | Dashboard only (port 3000) |
| `bun run dev:web` | Dashboard and Nova Web (port 3003) |
| `bun run dev:docs` | Docs site (port 3001) |
| `bun run dev:terminal` | Terminal web client |
| `bun run dev:terminal-server` | CLI agent dev loop |
Expand Down Expand Up @@ -124,7 +124,7 @@ Example for local development:
```env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
BETTER_AUTH_SECRET="your-secret" # openssl rand -hex 32
BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_URL="http://localhost:3003"
GITHUB_CLIENT_ID="your-github-client-id" # from github.com/settings/developers
GITHUB_CLIENT_SECRET="your-github-secret"
```
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ bun run db:migrate # create database tables
bun run dev:web # start the dashboard
```

> **Dashboard** → http://localhost:3000
> **Dashboard / Nova Web** → http://localhost:3003 / http://nova.localhost:3003

No database? Set one up later — `bun install` skips Prisma client generation gracefully when `DATABASE_URL` isn't set.

Expand All @@ -81,9 +81,9 @@ This is a **monorepo** managed with [Turborepo](https://turbo.build) and [Bun](h

| App | Description | Port |
|-----|-------------|------|
| `apps/web` | Next.js 16 dashboard (supercli.com) | `3000` |
| `apps/web` | Next.js 16 dashboard and Nova Web | `3003` |
| `apps/docs` | Next.js MDX documentation site | `3001` |
| `apps/supercode-cli/client` | Terminal web client UI (Next.js) | `3002` |
| `apps/supercode-cli/client` | Terminal web client UI and Nova login | `3000` |
| `apps/supercode-cli/server` | AI coding agent — also published as the `supercode` npm CLI | — |
| `apps/api` | Shared API server (scaffolded) | TBD |

Expand Down Expand Up @@ -271,7 +271,7 @@ We use high-quality coding datasets:
```env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
BETTER_AUTH_SECRET="your-secret-key" # openssl rand -hex 32
BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_URL="http://localhost:3003"
GITHUB_CLIENT_ID="your-github-oauth-id"
GITHUB_CLIENT_SECRET="your-github-oauth-secret"
```
Expand All @@ -290,7 +290,7 @@ We use high-quality coding datasets:
```bash
bun run dev:web
```
Open [http://localhost:3000](http://localhost:3000).
Open [http://localhost:3003](http://localhost:3003), or Nova at [http://nova.localhost:3003](http://nova.localhost:3003).

7. **Start other apps** (in separate terminals)
```bash
Expand Down Expand Up @@ -403,7 +403,7 @@ Run from the **repo root** unless otherwise noted.
| Script | What it does |
|--------|--------------|
| `bun run dev` | Start all dev servers (Turborepo) |
| `bun run dev:web` | Dashboard only (port 3000) |
| `bun run dev:web` | Dashboard and Nova Web (port 3003) |
| `bun run dev:docs` | Docs only (port 3001) |
| `bun run dev:terminal` | Terminal web client only |
| `bun run dev:terminal-server` | CLI agent dev loop |
Expand Down
54 changes: 46 additions & 8 deletions apps/supercode-cli/client/components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ import { Github, Code2, Sparkles, ArrowRight } from 'lucide-react'
import { ParticleBackground } from './particle-background'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · MEDIUM · Duplicated Nova origin/host allowlists across apps can drift

Nova redirect/origin logic is implemented separately in CLI (ALLOWED_REDIRECT_ORIGINS) and web (NOVA_HOSTS). If ports/hosts/staging domains change, one side can drift and break redirects or weaken validation. Suggested fix: centralize these values via shared config/env and reuse the same source of truth in both places.

import { PixelLogo } from '@/components/ui/pixel-logo'

const ALLOWED_REDIRECT_ORIGINS = new Set([
"https://nova.supercodeai.tech",
"http://nova.localhost:3003",
])

function getSafeRedirect(): URL | null {
const redirect = new URLSearchParams(window.location.search).get("redirect")
if (!redirect) return null

try {
const url = new URL(redirect)
return ALLOWED_REDIRECT_ORIGINS.has(url.origin) ? url : null
} catch {
return null
}
}

const LoginForm = () => {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
Expand All @@ -18,10 +35,32 @@ const LoginForm = () => {
const { data, isPending } = authClient.useSession()

useEffect(() => {
if (!isPending && data?.session) {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect")
router.replace(redirect || "/")
if (isPending || !data?.session) return

const novaUrl = getSafeRedirect()
if (!novaUrl) {
router.replace("/")
return
}

let cancelled = false
const transferSession = async () => {
const { data: tokenData, error: tokenError } = await authClient.oneTimeToken.generate()
if (cancelled) return
if (tokenError || !tokenData?.token) {
setError("Failed to create the Nova login session. Please try again.")
return
}

const callbackUrl = new URL("/api/auth/cli/callback", novaUrl.origin)
callbackUrl.searchParams.set("token", tokenData.token)
callbackUrl.searchParams.set("redirect", novaUrl.pathname)
window.location.replace(callbackUrl.toString())
}

void transferSession()
return () => {
cancelled = true
}
}, [data, isPending, router])

Expand All @@ -46,10 +85,9 @@ const LoginForm = () => {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams(window.location.search)
const redirect = params.get("redirect") || ""
const callbackURL = redirect
? new URL(redirect, window.location.origin).toString()
const novaUrl = getSafeRedirect()
const callbackURL = novaUrl
? `${window.location.origin}/sign-in?redirect=${encodeURIComponent(novaUrl.toString())}`
: window.location.origin
await authClient.signIn.social({
provider: 'github',
Expand Down
19 changes: 12 additions & 7 deletions apps/supercode-cli/client/lib/auth-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { createAuthClient } from "better-auth/react";
import {deviceAuthorizationClient} from 'better-auth/client/plugins'
import { createAuthClient } from "better-auth/react"
import {
deviceAuthorizationClient,
oneTimeTokenClient,
} from "better-auth/client/plugins"

export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3004",
plugins:[
deviceAuthorizationClient()
]
})
baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3004",
plugins: [
deviceAuthorizationClient(),
oneTimeTokenClient(),
],
})
4 changes: 4 additions & 0 deletions apps/supercode-cli/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ GITHUB_REDIRECT_URI=""
PORT=10000
# 🔴 CLI auth URL (used by `supercode login`)
SUPERCODE_SERVER_URL="http://localhost:10000"
NOVA_WEB_URL="http://nova.localhost:3003"

# Composio connections for CLI, Nova desktop, and Nova web (not Review integrations).
COMPOSIO_API_KEY=""
# ⚪ Default workspace root for file operations
SUPERCODE_WORKSPACE_ROOT=""

Expand Down
2 changes: 2 additions & 0 deletions apps/supercode-cli/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ app.get("/api/nova/sessions", proxyDesktopNova)
app.post("/api/nova/sessions", proxyDesktopNova)
app.get("/api/nova/sessions/:sessionId", proxyDesktopNova)
app.get("/api/nova/sessions/:sessionId/sync", proxyDesktopNova)
app.post("/api/nova/sessions/:sessionId/messages", proxyDesktopNova)
app.post("/api/nova/sessions/:sessionId/turn", proxyDesktopNova)

app.get("/api/user/me", async (req, res) => {
try {
Expand Down
12 changes: 10 additions & 2 deletions apps/supercode-cli/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@

import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { deviceAuthorization } from "better-auth/plugins"
import { deviceAuthorization, oneTimeToken } from "better-auth/plugins"
import prisma from "./prisma"

const serverUrl = process.env.BETTER_AUTH_URL || "http://localhost:3004"
const clientUrl = process.env.CLIENT_URL || "http://localhost:3000"
const isProduction = serverUrl.startsWith("https://")
const novaOrigins = [
"https://nova.supercodeai.tech",
"http://nova.localhost:3003",
]

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
baseURL: serverUrl,
basePath: "/api/auth",
trustedOrigins: [clientUrl, serverUrl],
trustedOrigins: [clientUrl, serverUrl, ...novaOrigins],
account: {
skipStateCookieCheck: true,
},
Expand All @@ -33,6 +37,10 @@ export const auth = betterAuth({
},
},
plugins: [
oneTimeToken({
expiresIn: 3,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Login token expires too quickly The CLI generates this token before sending the browser to Nova, where the callback makes another request to verify it. If navigation or a cold start takes more than three seconds, an otherwise successful GitHub sign-in ends in a 401, and the callback cannot recover the login.

storeToken: "hashed",
}),
deviceAuthorization({
schema: {},
expiresIn: "10m",
Expand Down
154 changes: 154 additions & 0 deletions apps/supercode-cli/server/src/lib/composio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Composio } from "@composio/core"

const READ_ONLY_TOOL_PATTERN = /(^|_)(CHECK|DESCRIBE|DOWNLOAD|FETCH|FIND|GET|LIST|LOOKUP|QUERY|READ|RETRIEVE|SEARCH|VIEW)(_|$)/i

export class ServerComposioService {
constructor(private client?: Composio) {}

private get composio(): Composio {
if (this.client) return this.client
const apiKey = process.env.COMPOSIO_API_KEY?.trim()
if (!apiKey) {
throw Object.assign(new Error("COMPOSIO_API_KEY is not configured on the CLI server"), {
statusCode: 503,
})
}
this.client = new Composio({ apiKey })
return this.client
}

private async activeAccounts(userId: string) {
const accounts = [] as Awaited<ReturnType<Composio["connectedAccounts"]["list"]>>["items"]
let cursor: string | undefined
do {
const page = await this.composio.connectedAccounts.list({
userIds: [userId],
statuses: ["ACTIVE"],
accountType: "PRIVATE",
cursor,
limit: 100,
})
accounts.push(...page.items.filter((account) => account.status === "ACTIVE" && !account.isDisabled))
cursor = page.nextCursor ?? undefined
} while (cursor)
return accounts
}

private async authConfigs(toolkit?: string) {
const configs = [] as Awaited<ReturnType<Composio["authConfigs"]["list"]>>["items"]
let cursor: string | undefined
do {
const page = await this.composio.authConfigs.list({ toolkit, showDisabled: false, cursor, limit: 100 })
configs.push(...page.items.filter((config) => config.status === "ENABLED"))
cursor = page.nextCursor ?? undefined
} while (cursor)
return configs
}

async listApps(userId: string) {
const [toolkits, configs, accounts] = await Promise.all([
this.composio.toolkits.get(),
this.authConfigs(),
this.activeAccounts(userId),
])
const configured = new Set(configs.map((config) => config.toolkit.slug))
const connected = new Map(accounts.map((account) => [account.toolkit.slug, account.id]))
return toolkits
.filter((toolkit) => configured.has(toolkit.slug) || connected.has(toolkit.slug))
.map((toolkit) => ({
slug: toolkit.slug,
name: toolkit.name,
description: toolkit.meta?.description ?? "",
logo: toolkit.meta?.logo ?? null,
connected: connected.has(toolkit.slug),
connectedAccountId: connected.get(toolkit.slug) ?? null,
}))
.sort((a, b) => a.connected === b.connected
? a.name.localeCompare(b.name)
: a.connected ? -1 : 1)
}

async createSession(userId: string) {
const accounts = await this.activeAccounts(userId)
const connectedAccounts = Object.fromEntries(accounts.map((account) => [account.toolkit.slug, account.id]))
const session = await this.composio.sessions.create(userId, {
mcp: true,
connectedAccounts,
toolkits: Object.keys(connectedAccounts),
manageConnections: false,
})
return {
url: session.mcp.url,
headers: session.mcp.headers,
sessionId: session.sessionId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Composio session ID mismatch Both existing callers of this SDK response read session_id, but this code reads sessionId. If the SDK returns session_id, the CLI response omits the session ID and Nova passes an undefined ID to the Connections UI. The new tests mock sessionId, so they do not catch that mismatch.

}
}

async connect(userId: string, slug: string, callbackUrl: string) {
const accounts = await this.activeAccounts(userId)
const existing = accounts.find((account) => account.toolkit.slug === slug)
if (existing) return { connectedAccountId: existing.id, redirectUrl: null }

const configs = await this.authConfigs(slug)
const envId = process.env[`COMPOSIO_${slug.toUpperCase()}_AUTH_CONFIG_ID`]?.trim()
const config = configs.find((candidate) => candidate.id === envId)
?? configs.find((candidate) => candidate.toolkit.slug === slug)
const configId = config?.id ?? (await this.composio.authConfigs.create(slug, {
type: "use_composio_managed_auth",
name: `Supercode ${slug}`,
})).id
const connection = await this.composio.connectedAccounts.link(userId, configId, { callbackUrl })
if (!connection.redirectUrl) throw new Error("Composio did not return an authorization URL")
return { connectedAccountId: connection.id, redirectUrl: connection.redirectUrl }
}

async verifyConnection(userId: string, slug: string, connectedAccountId: string) {
const accounts = await this.activeAccounts(userId)
return accounts.some((account) => account.id === connectedAccountId && account.toolkit.slug === slug)
}

async disconnect(userId: string, connectedAccountId: string) {
const accounts = await this.activeAccounts(userId)
if (!accounts.some((account) => account.id === connectedAccountId)) {
throw Object.assign(new Error("Connected account not found"), { statusCode: 404 })
}
await this.composio.connectedAccounts.delete(connectedAccountId)
}

async listTools(userId: string) {
const accounts = await this.activeAccounts(userId)
const toolkits = [...new Set(accounts.map((account) => account.toolkit.slug))]
if (toolkits.length === 0) return []
const raw = await this.composio.tools.getRawComposioTools({ toolkits })
return raw.filter((tool) => !tool.isDeprecated && tool.toolkit && toolkits.includes(tool.toolkit.slug)).map((tool) => ({
name: tool.slug,
displayName: tool.name,
description: tool.description || tool.name,
parameters: tool.inputParameters ?? { type: "object", properties: {} },
toolkit: tool.toolkit?.slug,
toolkitName: tool.toolkit?.name,
requiresApproval: !READ_ONLY_TOOL_PATTERN.test(tool.slug),
}))
}

async executeTool(userId: string, name: string, args: Record<string, unknown>) {
const tools = await this.composio.tools.getRawComposioTools({ tools: [name] })
const tool = tools.find((candidate) => candidate.slug === name && !candidate.isDeprecated)
if (!tool?.toolkit) throw Object.assign(new Error("Composio tool not found"), { statusCode: 404 })
const toolkit = tool.toolkit.slug
const accounts = await this.activeAccounts(userId)
const account = accounts.find((candidate) => candidate.toolkit.slug === toolkit)
if (!account) {
throw Object.assign(new Error(`Connect ${toolkit} before using this tool`), { statusCode: 403 })
}
const version = process.env[`COMPOSIO_TOOLKIT_VERSION_${toolkit.toUpperCase()}`]?.trim()
|| process.env.COMPOSIO_TOOLKIT_VERSION?.trim()
|| "latest"
return this.composio.tools.execute(name, {
userId,
connectedAccountId: account.id,
arguments: args,
version,
})
}
}
Loading
Loading