diff --git a/.env.example b/.env.example index e25292d..341aa97 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,43 @@ # Neon PostgreSQL Database DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require -# GitHub OAuth App -# Create at: https://github.com/settings/developers -GITHUB_CLIENT_ID=0v23li58m3t8TIbfIr8A -# Optional fallback for older deployments. Client-side/public only, not a secret. -NEXT_PUBLIC_GITHUB_CLIENT_ID=Ov231iS8m3t8TIbfIr8A -GITHUB_CLIENT_SECRET=your_github_oauth_client_secret +# Public URL of your app (used for OAuth callback redirects) +NEXT_PUBLIC_APP_URL=https://repofuse.com -# Public URL of your app (used for OAuth callback redirect) -NEXT_PUBLIC_APP_URL=https://repo-app-architect.vercel.app +# ── GitHub OAuth ────────────────────────────────────────────────────────────── +# Create at: https://github.com/settings/developers → "New OAuth App" +# Homepage URL: https://repofuse.com +# Callback URL: https://repofuse.com/api/auth/github/callback +GITHUB_CLIENT_ID=your_github_client_id +NEXT_PUBLIC_GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret -# OpenAI API Key (used by Vercel AI SDK for analysis) -OPENAI_API_KEY=sk-... +# ── GitLab OAuth ────────────────────────────────────────────────────────────── +# Create at: https://gitlab.com/-/profile/applications → "Add new application" +# Name: RepoFuse +# Redirect URI: https://repofuse.com/api/auth/gitlab/callback +# Scopes: read_user read_repository +GITLAB_CLIENT_ID=your_gitlab_application_id +GITLAB_CLIENT_SECRET=your_gitlab_secret -# Anthropic API Key (analysis + scaffold generation) +# ── Anthropic AI (analysis + scaffold generation) ───────────────────────────── +# Get your key at: https://console.anthropic.com/ ANTHROPIC_API_KEY=sk-ant-... -# Optional override for analysis + scaffold (default: Claude Sonnet 4.5 snapshot) -# ANTHROPIC_ANALYSIS_MODEL=claude-sonnet-4-5-20250929 +# Optional: override the model used for analysis (default: claude-sonnet-4-6) +# ANTHROPIC_ANALYSIS_MODEL=claude-sonnet-4-6 + +# ── Stripe (billing) ────────────────────────────────────────────────────────── +# Get keys at: https://dashboard.stripe.com/apikeys +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... +# Create a product + recurring price at: https://dashboard.stripe.com/products +# New product → add a Recurring price of $19/month → copy the price ID (starts with price_) +STRIPE_PRO_PRICE_ID=price_... +# Register your webhook at: https://dashboard.stripe.com/webhooks +# Endpoint URL: https://repofuse.com/api/stripe/webhook +# Events to listen for: checkout.session.completed, customer.subscription.updated, +# customer.subscription.deleted, invoice.payment_failed + +# ── Vercel Blob (export storage) ────────────────────────────────────────────── +BLOB_READ_WRITE_TOKEN=vercel_blob_... diff --git a/app/api/analyses/[id]/run/route.ts b/app/api/analyses/[id]/run/route.ts index fcf811c..0f5c9b5 100644 --- a/app/api/analyses/[id]/run/route.ts +++ b/app/api/analyses/[id]/run/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from 'next/server' import Anthropic from '@anthropic-ai/sdk' import { z } from 'zod' -import { getCurrentAccessToken } from '@/lib/auth' +import { getCurrentUser } from '@/lib/auth' import { getGitHubRepositoryTree, getGitHubRepositoryTreeFromBranch, @@ -14,9 +14,12 @@ import { createRepoFile, createBlueprint, deleteBlueprintsByAnalysis, - getBlueprintsByAnalysis + getBlueprintsByAnalysis, + getSubscriptionByGithubId, + incrementAnalysisUsage, } from '@/lib/queries' import { getAnthropicModel } from '@/lib/anthropic-model' +import { PLANS } from '@/lib/stripe' // Schema for AI-generated app blueprints const complexityEnum = z.preprocess((val) => { @@ -140,12 +143,31 @@ export async function POST( } try { - const accessToken = await getCurrentAccessToken() + const user = await getCurrentUser() + const accessToken = user?.access_token ?? null if (!accessToken) { send({ error: 'Sign in with GitHub before running an analysis.' }) controller.close() return } + + // Enforce plan limits + if (user) { + try { + const sub = await getSubscriptionByGithubId(user.github_id) + const planKey = sub?.plan === 'pro' ? 'pro' : 'free' + const limit = PLANS[planKey].analyses_per_month + const used = sub?.analyses_used_this_month ?? 0 + if (limit !== -1 && used >= limit) { + send({ error: `You've used all ${limit} analyses for this month. Upgrade to Pro for unlimited analyses.` }) + controller.close() + return + } + } catch { + // DB unavailable — allow the run + } + } + if (!process.env.ANTHROPIC_API_KEY) { send({ error: 'AI analysis is not configured. Missing ANTHROPIC_API_KEY.' }) controller.close() @@ -175,6 +197,9 @@ export async function POST( // Update status to scanning await updateAnalysisStatus(id, 'scanning') await deleteBlueprintsByAnalysis(id) + if (user) { + try { await incrementAnalysisUsage(user.github_id) } catch { /* DB unavailable */ } + } send({ status: 'scanning', progress: 10 }) // Fetch file trees from GitHub for each repository diff --git a/app/api/auth/bitbucket/callback/route.ts b/app/api/auth/bitbucket/callback/route.ts new file mode 100644 index 0000000..b92b17a --- /dev/null +++ b/app/api/auth/bitbucket/callback/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const cookieStore = await cookies() + const savedState = cookieStore.get('bitbucket_oauth_state')?.value + const from = cookieStore.get('bitbucket_oauth_from')?.value + const errorBase = from === 'dashboard' ? '/dashboard/repositories' : '/' + + if (error) { + return NextResponse.redirect(new URL(`${errorBase}?error=bitbucket_oauth_failed`, getBaseUrl(request))) + } + + if (!code) { + return NextResponse.redirect(new URL(`${errorBase}?error=missing_code`, getBaseUrl(request))) + } + + if (!state || !savedState || state !== savedState) { + return NextResponse.redirect(new URL(`${errorBase}?error=invalid_oauth_state`, getBaseUrl(request))) + } + + const clientId = process.env.BITBUCKET_CLIENT_ID + const clientSecret = process.env.BITBUCKET_CLIENT_SECRET + + if (!clientId || !clientSecret) { + return NextResponse.redirect(new URL(`${errorBase}?error=bitbucket_oauth_not_configured`, getBaseUrl(request))) + } + + const redirectUri = `${getBaseUrl(request)}/api/auth/bitbucket/callback` + + // Bitbucket uses HTTP Basic Auth for token exchange + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + const tokenResponse = await fetch('https://bitbucket.org/site/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${credentials}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }).toString(), + }) + + if (!tokenResponse.ok) { + return NextResponse.redirect(new URL(`${errorBase}?error=token_exchange_failed`, getBaseUrl(request))) + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string; error?: string } + const access_token = tokenJson.access_token + + if (!access_token) { + return NextResponse.redirect(new URL(`${errorBase}?error=token_exchange_failed`, getBaseUrl(request))) + } + + const response = NextResponse.redirect( + new URL('/dashboard/repositories?connected=bitbucket', getBaseUrl(request)) + ) + + response.cookies.set('bitbucket_access_token', access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + }) + response.cookies.set('bitbucket_oauth_state', '', { path: '/', maxAge: 0 }) + response.cookies.set('bitbucket_oauth_from', '', { path: '/', maxAge: 0 }) + + return response + } catch { + return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request))) + } +} diff --git a/app/api/auth/bitbucket/login/route.ts b/app/api/auth/bitbucket/login/route.ts new file mode 100644 index 0000000..173741d --- /dev/null +++ b/app/api/auth/bitbucket/login/route.ts @@ -0,0 +1,47 @@ +import crypto from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + const clientId = process.env.BITBUCKET_CLIENT_ID + const from = request.nextUrl.searchParams.get('from') + const errorBase = from === 'dashboard' ? '/dashboard/repositories' : '/' + + if (!clientId) { + return NextResponse.redirect(new URL(`${errorBase}?error=bitbucket_oauth_not_configured`, getBaseUrl(request))) + } + + const state = crypto.randomUUID() + const redirectUri = `${getBaseUrl(request)}/api/auth/bitbucket/callback` + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'repository account', + state, + }) + + const response = NextResponse.redirect( + `https://bitbucket.org/site/oauth2/authorize?${params.toString()}` + ) + response.cookies.set('bitbucket_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + response.cookies.set('bitbucket_oauth_from', from ?? '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + + return response +} diff --git a/app/api/auth/connect-platform/route.ts b/app/api/auth/connect-platform/route.ts deleted file mode 100644 index 7c3225a..0000000 --- a/app/api/auth/connect-platform/route.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getPlatformConfig } from '@/lib/platform-config' - -export async function POST(request: NextRequest) { - try { - const { platform, code } = await request.json() - - if (!platform || !code) { - return NextResponse.json({ error: 'Missing platform or code' }, { status: 400 }) - } - - const platformConfig = getPlatformConfig(platform) - if (!platformConfig) { - return NextResponse.json({ error: 'Invalid platform' }, { status: 400 }) - } - - const clientId = process.env[`${platform.toUpperCase()}_CLIENT_ID`] - const clientSecret = process.env[`${platform.toUpperCase()}_CLIENT_SECRET`] - const appUrl = process.env.NEXT_PUBLIC_APP_URL - - if (!clientId || !clientSecret || !appUrl) { - console.error(`[v0] Missing OAuth config for ${platform}`) - return NextResponse.json({ error: 'Platform not configured' }, { status: 500 }) - } - - // Exchange code for access token - const tokenResponse = await fetch(platformConfig.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ - client_id: clientId, - client_secret: clientSecret, - code, - redirect_uri: `${appUrl}/auth/callback`, - }), - }) - - if (!tokenResponse.ok) { - console.error(`[v0] Token exchange failed for ${platform}:`, tokenResponse.status) - return NextResponse.json({ error: 'Token exchange failed' }, { status: 400 }) - } - - const tokenData = await tokenResponse.json() - const accessToken = tokenData.access_token || tokenData.token - - if (!accessToken) { - console.error(`[v0] No access token for ${platform}:`, tokenData) - return NextResponse.json({ error: 'No access token received' }, { status: 400 }) - } - - // Get user info from platform - let userInfo: any = {} - - if (platform === 'github') { - const userRes = await fetch('https://api.github.com/user', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Accept': 'application/vnd.github+json', - }, - }) - userInfo = await userRes.json() - } else if (platform === 'vercel') { - const userRes = await fetch('https://api.vercel.com/www/user', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }) - userInfo = await userRes.json() - } else if (platform === 'gitlab') { - const userRes = await fetch('https://gitlab.com/api/v4/user', { - headers: { - 'PRIVATE-TOKEN': accessToken, - }, - }) - userInfo = await userRes.json() - } else if (platform === 'netlify') { - const userRes = await fetch('https://api.netlify.com/api/v1/user', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }) - userInfo = await userRes.json() - } - - // Store platform connection in cookies - const cookieStore = await cookies() - const connectedPlatforms = cookieStore.get('connected_platforms')?.value - ? JSON.parse(cookieStore.get('connected_platforms')!.value) - : {} - - connectedPlatforms[platform] = { - access_token: accessToken, - user: { - id: userInfo.id || userInfo.login || userInfo.email, - name: userInfo.name || userInfo.login || userInfo.email, - }, - connected_at: new Date().toISOString(), - } - - cookieStore.set('connected_platforms', JSON.stringify(connectedPlatforms), { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - maxAge: 60 * 60 * 24 * 30, // 30 days - path: '/', - }) - - return NextResponse.json({ success: true, platform }) - } catch (error) { - console.error('[v0] Platform connection error:', error) - return NextResponse.json({ error: 'Connection failed' }, { status: 500 }) - } -} diff --git a/app/api/auth/debug/route.ts b/app/api/auth/debug/route.ts deleted file mode 100644 index 2ecd995..0000000 --- a/app/api/auth/debug/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NextResponse } from 'next/server' -import { cookies } from 'next/headers' -import { getDb } from '@/lib/db' - -// Diagnostic endpoint — visit /api/auth/debug to see what the server sees. -// Remove or restrict this once the auth issue is resolved. -export async function GET() { - const cookieStore = await cookies() - const userIdCookie = cookieStore.get('github_user_id')?.value - const stateCookie = !!cookieStore.get('github_oauth_state')?.value - - const result: Record = { - cookies: { - github_user_id: userIdCookie ? `present (value: ${userIdCookie})` : 'missing', - github_oauth_state: stateCookie ? 'present' : 'missing', - }, - env: { - DATABASE_URL: !!process.env.DATABASE_URL, - GITHUB_CLIENT_ID: !!(process.env.GITHUB_CLIENT_ID || process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID), - GITHUB_CLIENT_SECRET: !!process.env.GITHUB_CLIENT_SECRET, - NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL ?? 'not set', - NODE_ENV: process.env.NODE_ENV, - }, - db: { status: 'not tested' }, - user: null, - } - - // Test DB connection and user lookup - try { - const sql = getDb() - - // Check if the user_auth table exists - const tableCheck = await sql` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'user_auth' - ) AS exists - ` - const tableExists = tableCheck[0]?.exists ?? false - result.db = { status: 'connected', user_auth_table_exists: tableExists } - - if (tableExists && userIdCookie) { - const githubId = Number.parseInt(userIdCookie, 10) - const users = await sql` - SELECT github_id, github_username FROM user_auth WHERE github_id = ${githubId} LIMIT 1 - ` - result.user = users[0] - ? { found: true, github_username: (users[0] as { github_username: string }).github_username } - : { found: false } - } - } catch (err) { - result.db = { status: 'error', message: String(err) } - } - - return NextResponse.json(result) -} diff --git a/app/api/auth/gitlab/callback/route.ts b/app/api/auth/gitlab/callback/route.ts new file mode 100644 index 0000000..ffbfdc5 --- /dev/null +++ b/app/api/auth/gitlab/callback/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams + const code = searchParams.get('code') + const state = searchParams.get('state') + const error = searchParams.get('error') + const cookieStore = await cookies() + const savedState = cookieStore.get('gitlab_oauth_state')?.value + const from = cookieStore.get('gitlab_oauth_from')?.value + const errorBase = from === 'dashboard' ? '/dashboard/repositories' : '/' + + if (error) { + return NextResponse.redirect(new URL(`${errorBase}?error=gitlab_oauth_failed`, getBaseUrl(request))) + } + + if (!code) { + return NextResponse.redirect(new URL(`${errorBase}?error=missing_code`, getBaseUrl(request))) + } + + if (!state || !savedState || state !== savedState) { + return NextResponse.redirect(new URL(`${errorBase}?error=invalid_oauth_state`, getBaseUrl(request))) + } + + const clientId = process.env.GITLAB_CLIENT_ID + const clientSecret = process.env.GITLAB_CLIENT_SECRET + + if (!clientId || !clientSecret) { + return NextResponse.redirect(new URL(`${errorBase}?error=gitlab_oauth_not_configured`, getBaseUrl(request))) + } + + const redirectUri = `${getBaseUrl(request)}/api/auth/gitlab/callback` + + const tokenResponse = await fetch('https://gitlab.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }), + }) + + if (!tokenResponse.ok) { + return NextResponse.redirect(new URL(`${errorBase}?error=token_exchange_failed`, getBaseUrl(request))) + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string; error?: string } + const access_token = tokenJson.access_token + + if (!access_token) { + return NextResponse.redirect(new URL(`${errorBase}?error=token_exchange_failed`, getBaseUrl(request))) + } + + const response = NextResponse.redirect( + new URL('/dashboard/repositories?connected=gitlab', getBaseUrl(request)) + ) + + response.cookies.set('gitlab_access_token', access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30, + }) + response.cookies.set('gitlab_oauth_state', '', { path: '/', maxAge: 0 }) + response.cookies.set('gitlab_oauth_from', '', { path: '/', maxAge: 0 }) + + return response + } catch { + return NextResponse.redirect(new URL('/?error=oauth_callback_failed', getBaseUrl(request))) + } +} diff --git a/app/api/auth/gitlab/login/route.ts b/app/api/auth/gitlab/login/route.ts new file mode 100644 index 0000000..69fad2f --- /dev/null +++ b/app/api/auth/gitlab/login/route.ts @@ -0,0 +1,46 @@ +import crypto from 'node:crypto' +import { NextRequest, NextResponse } from 'next/server' + +function getBaseUrl(request: NextRequest) { + return process.env.NEXT_PUBLIC_APP_URL || request.nextUrl.origin +} + +export async function GET(request: NextRequest) { + const clientId = process.env.GITLAB_CLIENT_ID + const from = request.nextUrl.searchParams.get('from') + const errorBase = from === 'dashboard' ? '/dashboard/repositories' : '/' + + if (!clientId) { + return NextResponse.redirect(new URL(`${errorBase}?error=gitlab_oauth_not_configured`, getBaseUrl(request))) + } + + const state = crypto.randomUUID() + const redirectUri = `${getBaseUrl(request)}/api/auth/gitlab/callback` + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'read_user read_repository', + state, + }) + + const response = NextResponse.redirect(`https://gitlab.com/oauth/authorize?${params.toString()}`) + response.cookies.set('gitlab_oauth_state', state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + // Persist the return destination across the OAuth round-trip + response.cookies.set('gitlab_oauth_from', from ?? '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, + }) + + return response +} diff --git a/app/api/bitbucket/repos/route.ts b/app/api/bitbucket/repos/route.ts new file mode 100644 index 0000000..8eaab16 --- /dev/null +++ b/app/api/bitbucket/repos/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +interface BitbucketRepo { + slug: string + name: string + full_name: string + description: string + links: { html: { href: string } } + mainbranch: { name: string } | null + is_private: boolean + language: string + size: number + updated_on: string +} + +interface BitbucketResponse { + values: BitbucketRepo[] + next?: string +} + +export async function GET() { + const cookieStore = await cookies() + const accessToken = cookieStore.get('bitbucket_access_token')?.value + + if (!accessToken) { + return NextResponse.json({ error: 'Not authenticated with Bitbucket' }, { status: 401 }) + } + + try { + const repos: BitbucketRepo[] = [] + let url: string | undefined = 'https://api.bitbucket.org/2.0/repositories?role=member&pagelen=100&sort=-updated_on' + + while (url && repos.length < 300) { + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'RepoFuse', + }, + cache: 'no-store', + }) + + if (!res.ok) { + if (res.status === 401) { + return NextResponse.json({ error: 'Bitbucket token expired — please reconnect' }, { status: 401 }) + } + break + } + + const data = (await res.json()) as BitbucketResponse + repos.push(...data.values) + url = data.next + } + + const normalized = repos.map((r) => ({ + id: r.full_name, + name: r.name, + full_name: r.full_name, + description: r.description || null, + url: r.links.html.href, + language: r.language || null, + stars: 0, + default_branch: r.mainbranch?.name ?? 'main', + private: r.is_private, + platform: 'bitbucket', + })) + + return NextResponse.json(normalized) + } catch { + return NextResponse.json({ error: 'Failed to fetch Bitbucket repositories' }, { status: 500 }) + } +} diff --git a/app/api/export/pdf/route.ts b/app/api/export/pdf/route.ts index 7a83466..615e184 100644 --- a/app/api/export/pdf/route.ts +++ b/app/api/export/pdf/route.ts @@ -122,7 +122,7 @@ function generateHTML(app: ExportApp): string {

- Generated by CodeVault on ${new Date().toLocaleDateString()} + Generated by RepoFuse on ${new Date().toLocaleDateString()}

diff --git a/app/api/gitlab/repos/route.ts b/app/api/gitlab/repos/route.ts new file mode 100644 index 0000000..6879f2a --- /dev/null +++ b/app/api/gitlab/repos/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +interface GitLabProject { + id: number + name: string + path_with_namespace: string + description: string | null + web_url: string + default_branch: string | null + visibility: string + star_count: number + forks_count: number + last_activity_at: string + topics: string[] + programming_language?: string +} + +export async function GET() { + const cookieStore = await cookies() + const accessToken = cookieStore.get('gitlab_access_token')?.value + + if (!accessToken) { + return NextResponse.json({ error: 'Not authenticated with GitLab' }, { status: 401 }) + } + + try { + const repos: GitLabProject[] = [] + let page = 1 + const perPage = 100 + + while (repos.length < 300) { + const res = await fetch( + `https://gitlab.com/api/v4/projects?membership=true&per_page=${perPage}&page=${page}&order_by=last_activity_at&sort=desc`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'RepoFuse', + }, + cache: 'no-store', + } + ) + + if (!res.ok) { + if (res.status === 401) { + return NextResponse.json({ error: 'GitLab token expired — please reconnect' }, { status: 401 }) + } + break + } + + const data = (await res.json()) as GitLabProject[] + if (data.length === 0) break + repos.push(...data) + + const totalPages = Number(res.headers.get('X-Total-Pages') ?? 1) + if (page >= totalPages) break + page++ + } + + const normalized = repos.map((p) => ({ + id: p.id, + name: p.name, + full_name: p.path_with_namespace, + description: p.description, + url: p.web_url, + language: null, + stars: p.star_count, + default_branch: p.default_branch ?? 'main', + private: p.visibility === 'private', + platform: 'gitlab', + })) + + return NextResponse.json(normalized) + } catch { + return NextResponse.json({ error: 'Failed to fetch GitLab repositories' }, { status: 500 }) + } +} diff --git a/app/api/repositories/route.ts b/app/api/repositories/route.ts index 0fecc0d..cecadc7 100644 --- a/app/api/repositories/route.ts +++ b/app/api/repositories/route.ts @@ -11,56 +11,130 @@ export async function GET() { } } +// Derive a stable negative integer ID for non-GitHub repos so the `github_id` +// UNIQUE column doesn't conflict with real GitHub IDs (which are always positive). +function stableNegativeId(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = Math.imul(31, hash) + char | 0 + } + return hash <= 0 ? hash : -hash +} + export async function POST(request: NextRequest) { try { const body = await request.json() - const { url } = body + const { url } = body as { url?: string } if (!url) { return NextResponse.json({ error: 'Repository URL is required' }, { status: 400 }) } - // Parse GitHub URL - const match = url.match(/github\.com\/([^\/]+)\/([^\/]+)/i) - if (!match) { - return NextResponse.json({ error: 'Invalid GitHub repository URL' }, { status: 400 }) + // ── GitHub ─────────────────────────────────────────────────────────────── + const githubMatch = url.match(/github\.com\/([^/]+)\/([^/]+)/i) + if (githubMatch) { + const [, owner, repo] = githubMatch + const repoName = repo.replace(/\.git$/, '') + + const githubRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}`, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'RepoFuse', + }, + }) + + if (!githubRes.ok) { + if (githubRes.status === 404) { + return NextResponse.json({ error: 'GitHub repository not found' }, { status: 404 }) + } + return NextResponse.json({ error: 'Failed to fetch repository from GitHub' }, { status: 500 }) + } + + const data = await githubRes.json() + const repository = await createRepository({ + github_id: data.id, + name: data.name, + full_name: data.full_name, + description: data.description, + url: data.html_url, + default_branch: data.default_branch, + language: data.language, + stars: data.stargazers_count, + }) + return NextResponse.json(repository) + } + + // ── GitLab ─────────────────────────────────────────────────────────────── + const gitlabMatch = url.match(/gitlab\.com\/([^?#]+)/i) + if (gitlabMatch) { + const fullPath = gitlabMatch[1].replace(/\.git$/, '').replace(/\/$/, '') + const encoded = encodeURIComponent(fullPath) + + const glRes = await fetch(`https://gitlab.com/api/v4/projects/${encoded}`, { + headers: { 'User-Agent': 'RepoFuse' }, + }) + + if (!glRes.ok) { + if (glRes.status === 404) { + return NextResponse.json({ error: 'GitLab project not found (private projects require GitLab sign-in)' }, { status: 404 }) + } + return NextResponse.json({ error: 'Failed to fetch project from GitLab' }, { status: 500 }) + } + + const data = await glRes.json() + const repository = await createRepository({ + github_id: -Math.abs(data.id), // negative to avoid colliding with GitHub IDs + name: data.name, + full_name: data.path_with_namespace, + description: data.description ?? null, + url: data.web_url, + default_branch: data.default_branch ?? 'main', + language: null, + stars: data.star_count ?? 0, + }) + return NextResponse.json(repository) } - const [, owner, repo] = match - const repoName = repo.replace(/\.git$/, '') + // ── Bitbucket ──────────────────────────────────────────────────────────── + const bbMatch = url.match(/bitbucket\.org\/([^/]+)\/([^/?#]+)/i) + if (bbMatch) { + const [, workspace, slug] = bbMatch + const repoSlug = slug.replace(/\.git$/, '') - // Fetch repository info from GitHub API - const githubRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}`, { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'CodeVault', - }, - }) + const bbRes = await fetch( + `https://api.bitbucket.org/2.0/repositories/${workspace}/${repoSlug}`, + { headers: { 'User-Agent': 'RepoFuse' } } + ) - if (!githubRes.ok) { - if (githubRes.status === 404) { - return NextResponse.json({ error: 'Repository not found' }, { status: 404 }) + if (!bbRes.ok) { + if (bbRes.status === 404) { + return NextResponse.json({ error: 'Bitbucket repository not found (private repos require Bitbucket sign-in)' }, { status: 404 }) + } + return NextResponse.json({ error: 'Failed to fetch repository from Bitbucket' }, { status: 500 }) } - return NextResponse.json({ error: 'Failed to fetch repository from GitHub' }, { status: 500 }) + + const data = await bbRes.json() + const full_name: string = data.full_name ?? `${workspace}/${repoSlug}` + const repository = await createRepository({ + github_id: stableNegativeId(`bitbucket:${full_name}`), + name: data.name ?? repoSlug, + full_name, + description: data.description ?? null, + url: data.links?.html?.href ?? url, + default_branch: data.mainbranch?.name ?? 'main', + language: data.language ?? null, + stars: 0, + }) + return NextResponse.json(repository) } - const githubData = await githubRes.json() - - // Save to database - const repository = await createRepository({ - github_id: githubData.id, - name: githubData.name, - full_name: githubData.full_name, - description: githubData.description, - url: githubData.html_url, - default_branch: githubData.default_branch, - language: githubData.language, - stars: githubData.stargazers_count, - }) - - return NextResponse.json(repository) + return NextResponse.json( + { error: 'Unsupported URL. Paste a GitHub (github.com), GitLab (gitlab.com), or Bitbucket (bitbucket.org) repository URL.' }, + { status: 400 } + ) } catch (error) { - console.error('Error creating repository:', error) + console.error('Error adding repository:', error) return NextResponse.json({ error: 'Failed to add repository' }, { status: 500 }) } } diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts index 9bfbe6c..e479013 100644 --- a/app/api/stripe/webhook/route.ts +++ b/app/api/stripe/webhook/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getStripe } from '@/lib/stripe' -import { upsertSubscription, getSubscriptionByStripeCustomerId } from '@/lib/queries' +import { upsertSubscription, getSubscriptionByStripeCustomerId, resetMonthlyUsage } from '@/lib/queries' import type Stripe from 'stripe' export async function POST(request: NextRequest) { @@ -48,6 +48,10 @@ export async function POST(request: NextRequest) { const existing = await getSubscriptionByStripeCustomerId(sub.customer as string) const periodEnd = (sub as unknown as { current_period_end?: number }).current_period_end if (existing) { + const newPeriodEnd = periodEnd ? new Date(periodEnd * 1000).toISOString() : null + const isRenewal = newPeriodEnd && existing.current_period_end && + new Date(newPeriodEnd) > new Date(existing.current_period_end) + const isPro = sub.status === 'active' || sub.status === 'trialing' await upsertSubscription({ github_id: existing.github_id, @@ -56,8 +60,12 @@ export async function POST(request: NextRequest) { : sub.status === 'past_due' ? 'past_due' : sub.status === 'trialing' ? 'trialing' : 'canceled', - current_period_end: periodEnd ? new Date(periodEnd * 1000).toISOString() : null, + current_period_end: newPeriodEnd, }) + + if (isRenewal) { + await resetMonthlyUsage(existing.github_id) + } } break } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 118601c..6b2534a 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,8 +1,10 @@ +import { Suspense } from 'react' import { getAllRepositories, getAllAnalyses, type Analysis, type Repository } from '@/lib/queries' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { FolderGit2, Sparkles, Code2, Plus, ArrowRight, Zap } from 'lucide-react' import Link from 'next/link' +import { UpgradeBanner } from '@/components/upgrade-banner' export const dynamic = 'force-dynamic' @@ -21,6 +23,10 @@ export default async function DashboardPage() { return (
+ + + + {/* Header */}

Dashboard

diff --git a/app/layout.tsx b/app/layout.tsx index c24a17f..298d2a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,9 +8,9 @@ const geist = Geist({ subsets: ['latin'], variable: '--font-geist' }) const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' }) export const metadata: Metadata = { - title: 'CodeVault - Discover Apps Hidden in Your Code', - description: 'AI-powered GitHub repository analyzer that discovers what apps you can build from your existing code', - generator: 'v0.app', + title: 'RepoFuse - Discover Apps Hidden in Your Code', + description: 'RepoFuse scans your GitHub, GitLab, and Bitbucket repositories and uses AI to discover what apps you can build from the code you already own.', + metadataBase: new URL('https://repofuse.com'), icons: { icon: [ { diff --git a/app/page.tsx b/app/page.tsx index a5ea4ea..aff027e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,40 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' -import { Github, Sparkles, Code2, Layers, ArrowRight, AlertCircle, Shield, Zap, GitBranch, Check } from 'lucide-react' +import { + Github, + Sparkles, + Code2, + Layers, + ArrowRight, + AlertCircle, + Shield, + Zap, + GitBranch, + Check, + GitMerge, + Globe, +} from 'lucide-react' const ERROR_MESSAGES: Record = { auth_required: 'You must sign in to access the dashboard.', github_oauth_not_configured: 'GitHub OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in your environment variables.', + gitlab_oauth_not_configured: 'GitLab OAuth is not configured. Add GITLAB_CLIENT_ID and GITLAB_CLIENT_SECRET to your environment variables, then register a GitLab OAuth application with the callback URL set to /api/auth/gitlab/callback.', + gitlab_oauth_failed: 'GitLab sign-in was cancelled or failed. Please try again.', invalid_oauth_state: 'Sign-in session expired or cookies were blocked. Close other tabs for this site and try signing in again.', - missing_code: 'GitHub did not return an authorization code. Try signing in again.', - token_exchange_failed: 'Could not exchange the GitHub code for a token. Check GITHUB_CLIENT_SECRET and that the OAuth callback URL matches your GitHub app.', + missing_code: 'The platform did not return an authorization code. Try signing in again.', + token_exchange_failed: 'Could not exchange the authorization code for a token. Check that your OAuth app credentials and callback URL are correct.', github_user_fetch_failed: 'Signed in with GitHub but could not load your profile. Try again.', oauth_callback_failed: 'Something went wrong finishing sign-in. Try again.', } +function GitLabIcon({ className }: { className?: string }) { + return ( + + + + ) +} + export default async function HomePage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { const { error } = await searchParams const errorMessage = error ? ERROR_MESSAGES[error] ?? 'An unexpected error occurred.' : null @@ -32,7 +55,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- CodeVault + RepoFuse
- {/* Hero Section */}
+ {/* Hero Section */}
- {/* Background gradient effects */}
+
@@ -70,30 +93,39 @@ export default async function HomePage({ searchParams }: { searchParams: Promise · Cross-repo blueprint engine powered by Claude AI
- +

Ship what you've already built

- +

- CodeVault scans your GitHub repositories and discovers products hiding across your codebase. - Get AI-generated blueprints showing exactly what you can ship — and what's missing. + RepoFuse scans your repositories across GitHub and GitLab — then discovers + products hiding in your codebase. Get AI blueprints showing exactly what you can ship.

-
- - + +
+
@@ -116,8 +148,37 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Metrics bar */} + {/* Platform support strip */}
+
+

+ Connect repositories from any platform +

+
+
+
+ +
+ GitHub +
+
+
+ +
+ GitLab +
+
+
+ +
+ Public URL +
+
+
+
+ + {/* Metrics bar */} +
@@ -140,10 +201,10 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
- {/* Feature Cards */} + {/* How it works */}
-

How CodeVault works

+

How RepoFuse works

Three steps from scattered code to a launch-ready blueprint

@@ -153,11 +214,12 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
1
- +
-

Connect repos

+

Connect your platforms

- Link your GitHub repositories with read-only OAuth. We scan file structures — never store your source code. + Link repos from GitHub or GitLab with read-only OAuth — or paste any public repo URL. + We scan file structures, never store your source code.

@@ -168,7 +230,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

AI analyzes patterns

- Claude AI examines every file across all your repos — identifying components, APIs, utilities, and how they fit together. + Claude AI examines every file across all your repos — identifying components, APIs, utilities, + and how they fit together.

@@ -179,7 +242,8 @@ export default async function HomePage({ searchParams }: { searchParams: Promise

Get blueprints

- See exactly what apps you can ship. Each blueprint shows files to reuse, what's missing, effort estimates, and a build plan. + See exactly what apps you can ship. Each blueprint shows files to reuse, what's missing, + effort estimates, and a full build plan.

@@ -194,12 +258,13 @@ export default async function HomePage({ searchParams }: { searchParams: Promise Stop rebuilding what you already have

- Most teams have 60-80% of their next product scattered across existing repos. CodeVault finds those hidden assets and shows you the shortest path to shipping. + Most teams have 60-80% of their next product scattered across existing repos. + RepoFuse finds those hidden assets and shows you the shortest path to shipping.

    {[ 'Identifies reusable components across all your repos', - 'Calculates exactly what percentage of code you can reuse', + 'Works across GitHub and GitLab simultaneously', 'Shows the specific files missing to complete each app', 'Generates scaffold code and build plans for missing pieces', ].map((item) => ( @@ -225,6 +290,7 @@ export default async function HomePage({ searchParams }: { searchParams: Promise "name": "Customer Dashboard", "reuse_percentage": 78, "complexity": "moderate", + "sources": ["github", "gitlab"], "existing_files": [ "components/Chart.tsx", "lib/api-client.ts", @@ -244,6 +310,52 @@ export default async function HomePage({ searchParams }: { searchParams: Promise
+ {/* Platform deep-dive */} +
+
+
+

Recover repos from anywhere

+

+ Wherever your code lives, RepoFuse can find it and bring it into a single unified analysis. +

+
+ +
+
+
+ +
+

GitHub

+

+ Sign in with GitHub OAuth to instantly list all your public and private repositories. + Supports organizations and personal accounts. +

+
+ {['Public repos', 'Private repos', 'Organizations'].map((tag) => ( + {tag} + ))} +
+
+ +
+
+ +
+

GitLab

+

+ Connect GitLab to import projects from gitlab.com. Works with personal projects, + groups, and subgroups. +

+
+ {['Personal projects', 'Groups', 'Subgroups'].map((tag) => ( + {tag} + ))} +
+
+
+
+
+ {/* CTA Section */}
@@ -252,15 +364,24 @@ export default async function HomePage({ searchParams }: { searchParams: Promise Ready to discover what you can ship?

- Connect your GitHub and get your first blueprint in under a minute. Free to use, no credit card required. + Connect your repositories and get your first blueprint in under a minute. + Free to start, no credit card required.

- +
+ + +
@@ -268,14 +389,37 @@ export default async function HomePage({ searchParams }: { searchParams: Promise {/* Footer */}