Files
vibn-agent-runner/platform/backend/control-plane/src/routes/projects.ts
mawkone 2c3e7f9dfb feat: add Turborepo per-project monorepo scaffold and project API
- Add Turborepo scaffold templates (apps: product, website, admin, storybook; packages: ui, tokens, types, config)
- Add ProjectRecord and AppRecord types to control plane
- Add Gitea integration service (repo creation, scaffold push, webhooks)
- Add Coolify integration service (project + per-app service provisioning with turbo --filter)
- Add project routes: GET/POST /projects, GET /projects/:id/apps, POST /projects/:id/deploy
- Update chat route to inject project/monorepo context into AI requests
- Add deploy_app and scaffold_app tools to Gemini tool set
- Update deploy executor with monorepo-aware /execute/deploy endpoint
- Add TURBOREPO_MIGRATION_PLAN.md documenting rationale and scope

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:07:35 -08:00

196 lines
6.1 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { config } from "../config.js";
import * as gitea from "../gitea.js";
import * as coolify from "../coolify.js";
import {
saveProject,
getProject,
listProjects,
updateProjectApp,
} from "../storage/index.js";
import type { AppRecord, ProjectRecord } from "../types.js";
const DEFAULT_APPS: AppRecord[] = [
{ name: "product", path: "apps/product" },
{ name: "website", path: "apps/website" },
{ name: "admin", path: "apps/admin" },
{ name: "storybook", path: "apps/storybook" },
];
const TURBO_VERSION = "2.3.3";
interface CreateProjectBody {
name: string;
tenant_id: string;
gitea_owner: string;
apps?: string[];
}
interface DeployAppBody {
app_name: string;
}
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
function appDomain(projectSlug: string, appName: string, tenantSlug: string): string {
const base = config.platformUrl.replace(/^https?:\/\//, "").split(":")[0] ?? "vibnai.com";
return `${appName}-${projectSlug}.${tenantSlug}.${base}`;
}
export async function projectRoutes(app: FastifyInstance) {
/**
* List all projects for the authenticated tenant
*/
app.get<{ Querystring: { tenant_id: string } }>("/projects", async (req) => {
await requireAuth(req);
const tenantId = (req.query as any).tenant_id as string;
if (!tenantId) return app.httpErrors.badRequest("tenant_id is required");
return { projects: await listProjects(tenantId) };
});
/**
* Get a single project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return project;
});
/**
* Create a new project — scaffolds the Turborepo monorepo in Gitea
* and provisions Coolify services for each app.
*/
app.post<{ Body: CreateProjectBody }>("/projects", async (req) => {
await requireAuth(req);
const { name, tenant_id, gitea_owner, apps: selectedApps } = req.body;
if (!name || !tenant_id || !gitea_owner) {
return app.httpErrors.badRequest("name, tenant_id, and gitea_owner are required");
}
const slug = slugify(name);
const projectId = `proj_${nanoid(12)}`;
const now = new Date().toISOString();
const selectedAppNames = selectedApps ?? DEFAULT_APPS.map(a => a.name);
const apps = DEFAULT_APPS.filter(a => selectedAppNames.includes(a.name));
const project: ProjectRecord = {
project_id: projectId,
tenant_id,
name,
slug,
status: "provisioning",
repo: "",
apps,
turboVersion: TURBO_VERSION,
created_at: now,
updated_at: now,
};
await saveProject(project);
// Provision asynchronously — return immediately with "provisioning" status
provisionProject(project, gitea_owner).catch(async (err: Error) => {
project.status = "error";
project.error = err.message;
project.updated_at = new Date().toISOString();
await saveProject(project);
app.log.error({ projectId, err: err.message }, "Project provisioning failed");
});
return { project_id: projectId, status: "provisioning", slug };
});
/**
* List apps within a project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id/apps", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return { apps: project.apps };
});
/**
* Deploy a specific app within the project
*/
app.post<{ Params: { project_id: string }; Body: DeployAppBody }>(
"/projects/:project_id/deploy",
async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
const { app_name } = req.body;
const targetApp = project.apps.find(a => a.name === app_name);
if (!targetApp) return app.httpErrors.notFound(`App "${app_name}" not found in project`);
if (!targetApp.coolifyServiceUuid) {
return app.httpErrors.badRequest(`App "${app_name}" has no Coolify service yet`);
}
const deploymentUuid = await coolify.triggerDeploy(targetApp.coolifyServiceUuid);
return { deployment_uuid: deploymentUuid, app: app_name, status: "deploying" };
}
);
}
/**
* Full provisioning flow — runs after the route returns
*/
async function provisionProject(project: ProjectRecord, giteaOwner: string): Promise<void> {
const repoName = project.slug;
// 1. Create Gitea repo
const repoUrl = await gitea.createRepo(giteaOwner, repoName, `${project.name} monorepo`);
project.repo = repoUrl;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 2. Push Turborepo scaffold
await gitea.scaffoldRepo(giteaOwner, repoName, project.slug, project.name);
// 3. Register webhook
const webhookUrl = `${config.platformUrl}/webhooks/gitea`;
await gitea.registerWebhook(giteaOwner, repoName, webhookUrl);
// 4. Create Coolify project
const coolifyProjectUuid = await coolify.createProject(
project.name,
`Coolify project for ${project.name}`
);
project.coolifyProjectUuid = coolifyProjectUuid;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 5. Create a Coolify service per app
for (const projectApp of project.apps) {
const domain = appDomain(project.slug, projectApp.name, project.tenant_id);
const serviceUuid = await coolify.createAppService({
coolifyProjectUuid,
appName: projectApp.name,
repoUrl,
domain,
});
const updatedApp: AppRecord = {
...projectApp,
coolifyServiceUuid: serviceUuid,
domain,
};
await updateProjectApp(project.project_id, updatedApp);
}
project.status = "active";
project.updated_at = new Date().toISOString();
await saveProject(project);
}