User feedback: the previous flow was a single-screen "name + audience"
dialog that gave AI no context about what the user actually wanted to
make. That worked for the demo but produced messy projects in practice
because everything was decided after the fact in chat.
The new flow asks the user one human question first ("How would you
like to begin?") and then captures the minimum context needed to seed
the AI's first conversation in the project.
Three paths, each is a 2-step setup screen with internal step dots:
- Build your own idea — Step 1: name + audience. Step 2: free-text
"what do you want to build". Becomes the project's vision and the
AI's first-message context.
- Run an open source tool — Step 1: name + audience. Step 2:
segmented tabs to either (a) paste a GitHub link or (b) describe
the kind of tool you want and have Vibn find one. Vision is set
to either "Install and host this open-source project: <url>" or
"Find and install an open-source tool that fits this need: <desc>"
so the AI knows which mode to operate in on first chat.
- Import existing code — Step 1: name + audience + repo URL.
Step 2: optional "what do you want to do with it" textarea.
Public repos only for v1; private-repo OAuth lands later.
Backend:
- /api/projects/create now accepts and persists `creationMode` and
`sourceData` on the project record under a `kickoff` blob:
{ mode, sourceData, vision, createdAt }
The chat endpoint will read this on first turn to seed the AI
with the user's stated intent rather than asking them to re-type
it in chat.
Cleanup:
- Removed FreshIdeaSetup, CodeImportSetup, ChatImportSetup,
MigrateSetup — replaced by BuildSetup, OssSetup, ImportSetup.
- Removed the unused initialWorkspacePath prop from
project-association-prompt (the new flow doesn't take it).
- TypeSelector defaults are restored — the modal opens on the
type-picker step now, not directly on a setup form.
UI building blocks added to setup-shared:
- TextArea (multi-line input)
- StepDots (page indicator)
- SegmentedTabs (generic-typed tab selector, used in OSS Step 2)
- SecondaryButton (used as ← Back inside Step 2)
Made-with: Cursor
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { authSession } from "@/lib/auth/session-server";
|
|
import { query } from '@/lib/db-postgres';
|
|
import { randomUUID } from 'crypto';
|
|
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
|
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
|
import { ensureProjectCoolifyProject } from '@/lib/projects';
|
|
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
|
|
|
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
|
const APP_URL = process.env.NEXTAUTH_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? 'https://vibnai.com';
|
|
const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-secret';
|
|
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const session = await authSession();
|
|
if (!session?.user?.email) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const email = session.user.email;
|
|
const workspace = email.split('@')[0].toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-account';
|
|
|
|
// Upsert user into fs_users — guarantees the FK target exists
|
|
const userData = JSON.stringify({
|
|
email,
|
|
name: session.user.name,
|
|
image: session.user.image,
|
|
workspace,
|
|
});
|
|
const existingUser = await query<{ id: string; data: any }>(
|
|
`SELECT id, data FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
|
[email]
|
|
);
|
|
if (existingUser.length === 0) {
|
|
await query(
|
|
`INSERT INTO fs_users (id, user_id, data) VALUES (gen_random_uuid()::text, $1, $2::jsonb)`,
|
|
[session.user.id, userData]
|
|
);
|
|
} else {
|
|
await query(
|
|
`UPDATE fs_users SET user_id = $1, data = data || $2::jsonb, updated_at = NOW() WHERE id = $3`,
|
|
[session.user.id, userData, existingUser[0].id]
|
|
);
|
|
}
|
|
|
|
// Fetch the canonical fs_users row (now guaranteed to exist)
|
|
const users = await query<{ id: string; data: any }>(`
|
|
SELECT id, data FROM fs_users WHERE data->>'email' = $1 LIMIT 1
|
|
`, [email]);
|
|
|
|
const firebaseUserId = users[0]!.id;
|
|
|
|
// Resolve (and lazily provision) the user's workspace. Provides:
|
|
// - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs
|
|
// - vibnWorkspace.gitea_org → owner for Gitea repos
|
|
// If provisioning failed for either, we fall back to legacy admin
|
|
// identifiers so the project create still succeeds (with degraded isolation).
|
|
let vibnWorkspace = await getOrCreateProvisionedWorkspace({
|
|
userId: firebaseUserId,
|
|
email,
|
|
displayName: session.user.name ?? null,
|
|
});
|
|
|
|
const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER;
|
|
|
|
const body = await request.json();
|
|
const {
|
|
projectName,
|
|
projectType,
|
|
slug,
|
|
vision,
|
|
product,
|
|
workspacePath,
|
|
chatgptUrl,
|
|
githubRepo,
|
|
githubRepoId,
|
|
githubRepoUrl,
|
|
githubDefaultBranch,
|
|
githubToken,
|
|
creationMode,
|
|
sourceData,
|
|
} = body;
|
|
|
|
// Check slug uniqueness
|
|
const existing = await query(`SELECT id FROM fs_projects WHERE slug = $1 LIMIT 1`, [slug]);
|
|
if (existing.length > 0) {
|
|
return NextResponse.json({ error: 'Project slug already exists' }, { status: 400 });
|
|
}
|
|
|
|
const projectId = randomUUID();
|
|
const now = new Date().toISOString();
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 1. Provision Gitea repo
|
|
// ──────────────────────────────────────────────
|
|
let giteaRepo: string | null = null;
|
|
let giteaRepoUrl: string | null = null;
|
|
let giteaCloneUrl: string | null = null;
|
|
let giteaSshUrl: string | null = null;
|
|
let giteaWebhookId: number | null = null;
|
|
let giteaError: string | null = null;
|
|
|
|
try {
|
|
const repoName = slug; // e.g. "taskmaster"
|
|
let repo;
|
|
|
|
try {
|
|
repo = await createRepo(repoName, {
|
|
description: `${projectName} — managed by Vibn`,
|
|
private: true,
|
|
auto_init: false,
|
|
owner: repoOwner,
|
|
});
|
|
console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`);
|
|
} catch (createErr) {
|
|
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
|
// 409 = repo already exists — link to it instead of failing
|
|
if (msg.includes('409')) {
|
|
console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`);
|
|
repo = await getRepo(repoOwner, repoName);
|
|
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
|
|
} else {
|
|
throw createErr;
|
|
}
|
|
}
|
|
|
|
giteaRepo = repo.full_name; // e.g. "mark/taskmaster"
|
|
giteaRepoUrl = repo.html_url; // e.g. "https://git.vibnai.com/mark/taskmaster"
|
|
giteaCloneUrl = repo.clone_url;
|
|
giteaSshUrl = repo.ssh_url;
|
|
|
|
// If a GitHub repo was provided, mirror it as-is.
|
|
// Otherwise leave the repo empty — the user (or AI) decides what to
|
|
// put in it. The old turborepo auto-scaffold is no longer pushed
|
|
// because most projects don't need a 4-app monorepo and the AI
|
|
// can scaffold whatever the user actually wants on demand.
|
|
if (githubRepoUrl) {
|
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
|
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
github_url: githubRepoUrl,
|
|
gitea_repo: `${repoOwner}/${repoName}`,
|
|
project_name: projectName,
|
|
github_token: githubToken || undefined,
|
|
}),
|
|
});
|
|
if (!mirrorRes.ok) {
|
|
const detail = await mirrorRes.text();
|
|
throw new Error(`GitHub mirror failed: ${detail}`);
|
|
}
|
|
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
|
|
}
|
|
|
|
// Register webhook — skip if one already points to this project
|
|
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
|
const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []);
|
|
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
|
|
|
|
if (!alreadyHooked) {
|
|
const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
|
|
giteaWebhookId = hook.id;
|
|
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
|
|
} else {
|
|
giteaWebhookId = existingHooks.find(h => h.config.url.includes(projectId))?.id ?? null;
|
|
console.log(`[API] Webhook already exists for ${giteaRepo}`);
|
|
}
|
|
} catch (err) {
|
|
// Non-fatal — log and continue. Project is still created.
|
|
giteaError = err instanceof Error ? err.message : String(err);
|
|
console.error('[API] Gitea provisioning failed (non-fatal):', giteaError);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 2. Provision a dedicated Coolify project for this Vibn project
|
|
// ──────────────────────────────────────────────
|
|
// Each Vibn project gets its OWN Coolify project named
|
|
// `vibn-{workspace-slug}-{project-slug}`. All apps/databases/services
|
|
// the user (or AI) deploys for this project will land inside it.
|
|
//
|
|
// We don't pre-create any services anymore — the project starts empty
|
|
// and the user/AI decides what to deploy via apps_create on demand.
|
|
//
|
|
// Note: ensureProjectCoolifyProject reads the fs_projects row, so we
|
|
// insert a minimal row first, then provision Coolify, then update the
|
|
// row with the full project data further below.
|
|
await query(
|
|
`INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id)
|
|
VALUES ($1, '{}'::jsonb, $2, $3, $4, $5)
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
[projectId, firebaseUserId, workspace, slug, vibnWorkspace.id],
|
|
);
|
|
const coolifyProjectUuid: string | null = await ensureProjectCoolifyProject(
|
|
projectId,
|
|
vibnWorkspace,
|
|
{ projectSlug: slug, projectName },
|
|
);
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 3. Save project record
|
|
// ──────────────────────────────────────────────
|
|
const projectData = {
|
|
id: projectId,
|
|
name: projectName,
|
|
slug,
|
|
userId: firebaseUserId,
|
|
workspace,
|
|
projectType,
|
|
productName: product?.name || projectName,
|
|
productVision: vision || '',
|
|
isForClient: product?.isForClient || false,
|
|
hasLogo: product?.hasLogo || false,
|
|
hasDomain: product?.hasDomain || false,
|
|
hasWebsite: product?.hasWebsite || false,
|
|
hasGithub: !!githubRepo,
|
|
hasChatGPT: !!chatgptUrl,
|
|
workspacePath: workspacePath || null,
|
|
workspaceName: workspacePath ? workspacePath.split('/').pop() : null,
|
|
githubRepo: githubRepo || null,
|
|
githubRepoId: githubRepoId || null,
|
|
githubRepoUrl: githubRepoUrl || null,
|
|
githubDefaultBranch: githubDefaultBranch || null,
|
|
chatgptUrl: chatgptUrl || null,
|
|
extensionLinked: false,
|
|
status: 'active',
|
|
currentPhase: 'collector',
|
|
phaseStatus: 'not_started',
|
|
phaseData: {} as ProjectPhaseData,
|
|
phaseScores: {} as ProjectPhaseScores,
|
|
// Gitea provisioning
|
|
giteaRepo,
|
|
giteaRepoUrl,
|
|
giteaCloneUrl,
|
|
giteaSshUrl,
|
|
giteaWebhookId,
|
|
giteaError,
|
|
// Context snapshot (kept fresh by webhooks)
|
|
contextSnapshot: null,
|
|
// Coolify project — one per VIBN project, scopes all app services + DBs.
|
|
// Apps are deployed on-demand via apps_create (no auto-scaffold).
|
|
coolifyProjectUuid,
|
|
// How this project was created — drives the AI's first-chat seed.
|
|
// Shape: { mode: "build"|"oss"|"import", sourceData: {...} } where
|
|
// sourceData is the path-specific payload from the wizard.
|
|
creationMode: creationMode ?? null,
|
|
kickoff: creationMode ? {
|
|
mode: creationMode,
|
|
sourceData: sourceData ?? null,
|
|
vision: vision || null,
|
|
createdAt: now,
|
|
} : null,
|
|
// Import metadata
|
|
isImport: !!githubRepoUrl,
|
|
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
|
importAnalysisJobId: null as string | null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
// Update the row we pre-inserted above with the full project data.
|
|
// We merge with existing data so the coolifyProjectUuid set by
|
|
// ensureProjectCoolifyProject() above is preserved.
|
|
await query(`
|
|
UPDATE fs_projects
|
|
SET data = data || $2::jsonb,
|
|
user_id = $3,
|
|
workspace = $4,
|
|
slug = $5,
|
|
vibn_workspace_id = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]);
|
|
|
|
// Associate any unlinked sessions for this workspace path
|
|
if (workspacePath) {
|
|
await query(`
|
|
UPDATE fs_sessions
|
|
SET data = jsonb_set(
|
|
jsonb_set(data, '{projectId}', $1::jsonb),
|
|
'{needsProjectAssociation}', 'false'
|
|
)
|
|
WHERE user_id = $2
|
|
AND data->>'workspacePath' = $3
|
|
AND (data->>'needsProjectAssociation')::boolean = true
|
|
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 5. If this is an import, trigger the analysis agent
|
|
// ──────────────────────────────────────────────
|
|
let analysisJobId: string | null = null;
|
|
if (githubRepoUrl && giteaRepo) {
|
|
try {
|
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
|
const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agent: 'ImportAnalyzer',
|
|
task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
|
repo: giteaRepo,
|
|
}),
|
|
});
|
|
if (jobRes.ok) {
|
|
const jobData = await jobRes.json() as { jobId?: string };
|
|
analysisJobId = jobData.jobId ?? null;
|
|
// Store the job ID on the project record
|
|
if (analysisJobId) {
|
|
await query(
|
|
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
|
[JSON.stringify(analysisJobId), projectId]
|
|
);
|
|
}
|
|
console.log(`[API] Import analysis job started: ${analysisJobId}`);
|
|
}
|
|
} catch (analysisErr) {
|
|
console.error('[API] Failed to start import analysis (non-fatal):', analysisErr);
|
|
}
|
|
}
|
|
|
|
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
projectId,
|
|
slug,
|
|
workspace,
|
|
gitea: giteaRepo
|
|
? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl }
|
|
: null,
|
|
giteaError: giteaError ?? undefined,
|
|
isImport: !!githubRepoUrl,
|
|
analysisJobId: analysisJobId ?? undefined,
|
|
});
|
|
} catch (error) {
|
|
console.error('[POST /api/projects/create] Error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to create project', details: error instanceof Error ? error.message : String(error) },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|