- 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>
196 lines
6.1 KiB
TypeScript
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);
|
|
}
|