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>
This commit is contained in:
2026-02-21 15:07:35 -08:00
parent 57b9ce2f1a
commit 2c3e7f9dfb
40 changed files with 1625 additions and 33 deletions

View File

@@ -6,5 +6,16 @@ export const config = {
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
authMode: process.env.AUTH_MODE ?? "dev",
// Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured
storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory")
storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory"),
// Gitea
giteaUrl: process.env.GITEA_URL ?? "https://git.vibnai.com",
giteaToken: process.env.GITEA_TOKEN ?? "",
// Coolify
coolifyUrl: process.env.COOLIFY_URL ?? "http://localhost:8000",
coolifyToken: process.env.COOLIFY_TOKEN ?? "",
// Platform webhook base (used when registering Gitea webhooks)
platformUrl: process.env.PLATFORM_URL ?? "http://localhost:8080",
};

View File

@@ -0,0 +1,118 @@
/**
* Coolify API integration
*
* Handles project creation, per-app service provisioning, and deployment
* triggering. Each app in a user's Turborepo monorepo gets its own
* Coolify service with the correct Turbo build filter set.
*/
import { config } from "./config.js";
const DEFAULT_SERVER_UUID = process.env.COOLIFY_SERVER_UUID ?? "0";
async function coolifyFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${config.coolifyUrl}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${config.coolifyToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res;
}
export async function createProject(name: string, description: string): Promise<string> {
const res = await coolifyFetch("/projects", {
method: "POST",
body: JSON.stringify({ name, description }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Coolify project: ${res.status} ${body}`);
}
const data = await res.json() as { uuid: string };
return data.uuid;
}
type CreateServiceOptions = {
coolifyProjectUuid: string;
appName: string;
repoUrl: string;
repoBranch?: string;
domain: string;
};
/**
* Create a Coolify application service for one app within the monorepo.
* The build command uses turbo --filter so only the relevant app builds.
*/
export async function createAppService(opts: CreateServiceOptions): Promise<string> {
const {
coolifyProjectUuid,
appName,
repoUrl,
repoBranch = "main",
domain,
} = opts;
const res = await coolifyFetch("/applications/public", {
method: "POST",
body: JSON.stringify({
project_uuid: coolifyProjectUuid,
server_uuid: DEFAULT_SERVER_UUID,
name: appName,
git_repository: repoUrl,
git_branch: repoBranch,
build_command: `pnpm install && turbo run build --filter=${appName}`,
start_command: `turbo run start --filter=${appName}`,
publish_directory: `apps/${appName}/.next`,
fqdn: `https://${domain}`,
environment_variables: [],
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Coolify service for ${appName}: ${res.status} ${body}`);
}
const data = await res.json() as { uuid: string };
return data.uuid;
}
export async function triggerDeploy(serviceUuid: string): Promise<string> {
const res = await coolifyFetch(`/applications/${serviceUuid}/deploy`, {
method: "POST",
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to trigger deploy for ${serviceUuid}: ${res.status} ${body}`);
}
const data = await res.json() as { deployment_uuid: string };
return data.deployment_uuid;
}
export async function getDeploymentStatus(deploymentUuid: string): Promise<string> {
const res = await coolifyFetch(`/deployments/${deploymentUuid}`);
if (!res.ok) return "unknown";
const data = await res.json() as { status: string };
return data.status;
}
export async function setEnvVars(
serviceUuid: string,
vars: Record<string, string>
): Promise<void> {
for (const [key, value] of Object.entries(vars)) {
await coolifyFetch(`/applications/${serviceUuid}/envs`, {
method: "POST",
body: JSON.stringify({ key, value, is_preview: false }),
});
}
}

View File

@@ -108,26 +108,71 @@ export const PRODUCT_OS_TOOLS = [
},
required: ["task"]
}
},
{
name: "deploy_app",
description: "Deploy a specific app from the project monorepo. Use when user wants to deploy or ship one of their apps (product, website, admin, storybook).",
parameters: {
type: "object",
properties: {
project_id: { type: "string", description: "The project ID" },
app_name: {
type: "string",
enum: ["product", "website", "admin", "storybook"],
description: "Which app to deploy"
},
env: {
type: "string",
enum: ["dev", "staging", "prod"],
description: "Target environment"
}
},
required: ["project_id", "app_name"]
}
},
{
name: "scaffold_app",
description: "Add a new app to the project monorepo. Use when user wants to add a new application beyond the defaults.",
parameters: {
type: "object",
properties: {
project_id: { type: "string", description: "The project ID" },
app_name: { type: "string", description: "Name for the new app (e.g. 'mobile', 'api', 'dashboard')" },
framework: {
type: "string",
enum: ["nextjs", "astro", "express", "fastify"],
description: "Framework to scaffold"
}
},
required: ["project_id", "app_name"]
}
}
];
// System prompt for Product OS assistant
const SYSTEM_PROMPT = `You are Product OS, an AI assistant specialized in helping users launch and operate SaaS products on Google Cloud.
const SYSTEM_PROMPT = `You are the AI for a software platform where every project is a Turborepo monorepo containing multiple apps: product, website, admin, and storybook. You have full visibility and control over the entire project.
Each project has:
- apps/product — the core user-facing application
- apps/website — the marketing and landing site
- apps/admin — internal admin tooling
- apps/storybook — component browser and design system
- packages/ui — shared React component library
- packages/tokens — shared design tokens (colors, spacing, typography)
- packages/types — shared TypeScript types
- packages/config — shared eslint and tsconfig
You can help with:
- Deploying services to Cloud Run
- Deploying any app using turbo run build --filter=<app>
- Writing and modifying code across any app or package in the monorepo
- Adding new apps or packages to the project
- Analyzing product metrics and funnels
- Generating marketing content
- Writing and modifying code
- Understanding what drives user behavior
When users ask you to do something, use the available tools to take action. Be concise and helpful.
If a user asks about code, analyze their request and either:
1. Use generate_code tool for code changes
2. Provide explanations directly
Always confirm before taking destructive actions like deploying to production.`;
When a user says "deploy" without specifying an app, ask which one or default to "product".
When a user asks to change something visual, consider whether it belongs in packages/ui or packages/tokens.
When users ask you to do something, use the available tools to take action. Be concise and specific about which app or package you are working in.`;
/**
* Chat with Gemini

View File

@@ -0,0 +1,154 @@
/**
* Gitea API integration
*
* Handles repo creation, file scaffolding, and webhook registration
* for user projects. All project repos are created under the user's
* Gitea account and contain the full Turborepo monorepo structure.
*/
import { config } from "./config.js";
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, "../../../../scripts/templates/turborepo");
type GiteaFile = {
path: string;
content: string;
};
async function giteaFetch(path: string, options: RequestInit = {}): Promise<Response> {
const url = `${config.giteaUrl}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Authorization": `token ${config.giteaToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
return res;
}
export async function createRepo(owner: string, repoName: string, description: string): Promise<string> {
const res = await giteaFetch(`/user/repos`, {
method: "POST",
body: JSON.stringify({
name: repoName,
description,
private: false,
auto_init: false,
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to create Gitea repo: ${res.status} ${body}`);
}
const data = await res.json() as { clone_url: string };
return data.clone_url;
}
export async function registerWebhook(owner: string, repoName: string, webhookUrl: string): Promise<void> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/hooks`, {
method: "POST",
body: JSON.stringify({
type: "gitea",
active: true,
events: ["push", "pull_request"],
config: {
url: webhookUrl,
content_type: "json",
},
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to register webhook: ${res.status} ${body}`);
}
}
/**
* Walk the template directory and collect all files with their content,
* replacing {{project-slug}} and {{project-name}} placeholders.
*/
async function collectTemplateFiles(
projectSlug: string,
projectName: string
): Promise<GiteaFile[]> {
const files: GiteaFile[] = [];
async function walk(dir: string) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const relPath = relative(TEMPLATES_DIR, fullPath);
let content = await readFile(fullPath, "utf-8");
content = content
.replaceAll("{{project-slug}}", projectSlug)
.replaceAll("{{project-name}}", projectName);
files.push({ path: relPath, content });
}
}
}
await walk(TEMPLATES_DIR);
return files;
}
/**
* Push the full Turborepo scaffold to a Gitea repo as an initial commit.
* Uses Gitea's contents API to create each file individually.
*/
export async function scaffoldRepo(
owner: string,
repoName: string,
projectSlug: string,
projectName: string
): Promise<void> {
const files = await collectTemplateFiles(projectSlug, projectName);
for (const file of files) {
const encoded = Buffer.from(file.content).toString("base64");
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${file.path}`, {
method: "POST",
body: JSON.stringify({
message: `chore: scaffold ${file.path}`,
content: encoded,
branch: "main",
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Failed to push ${file.path}: ${res.status} ${body}`);
}
}
}
export async function getRepoTree(owner: string, repoName: string, ref = "main"): Promise<string[]> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/git/trees/${ref}?recursive=true`);
if (!res.ok) return [];
const data = await res.json() as { tree: { path: string; type: string }[] };
return data.tree.filter(e => e.type === "blob").map(e => e.path);
}
export async function getFileContent(
owner: string,
repoName: string,
filePath: string,
ref = "main"
): Promise<string | null> {
const res = await giteaFetch(`/repos/${owner}/${repoName}/contents/${filePath}?ref=${ref}`);
if (!res.ok) return null;
const data = await res.json() as { content: string };
return Buffer.from(data.content, "base64").toString("utf-8");
}

View File

@@ -8,6 +8,7 @@ import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js";
import { runRoutes } from "./routes/runs.js";
import { chatRoutes } from "./routes/chat.js";
import { projectRoutes } from "./routes/projects.js";
const app = Fastify({ logger: true });
@@ -20,6 +21,7 @@ await app.register(healthRoutes);
await app.register(toolRoutes);
await app.register(runRoutes);
await app.register(chatRoutes);
await app.register(projectRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
console.log(`🚀 Control Plane API running on http://localhost:${config.port}`);

View File

@@ -2,12 +2,13 @@ import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText } from "../storage/index.js";
import { saveRun, writeArtifactText, getProject } from "../storage/index.js";
import { nanoid } from "nanoid";
import type { RunRecord } from "../types.js";
interface ChatRequest {
messages: ChatMessage[];
project_id?: string;
context?: {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
@@ -26,10 +27,34 @@ export async function chatRoutes(app: FastifyInstance) {
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
await requireAuth(req);
const { messages, context, autoExecuteTools = true } = req.body;
const { messages, project_id, context, autoExecuteTools = true } = req.body;
// Enhance messages with context if provided
let enhancedMessages = [...messages];
// Inject project context so the AI understands the full monorepo structure
if (project_id) {
const project = await getProject(project_id);
if (project) {
const appList = project.apps.map(a => ` - ${a.name} (${a.path})${a.domain ? `${a.domain}` : ""}`).join("\n");
const projectContext = [
`Project: ${project.name} (${project.slug})`,
`Repo: ${project.repo || "provisioning..."}`,
`Status: ${project.status}`,
`Apps in this monorepo:`,
appList,
`Shared packages: ui, tokens, types, config`,
`Build system: Turborepo ${project.turboVersion}`,
`Build command: turbo run build --filter=<app-name>`,
].join("\n");
enhancedMessages = [
{ role: "user" as const, content: `Project context:\n${projectContext}` },
...messages,
];
}
}
// Enhance messages with file/selection context if provided
if (context?.files?.length) {
const fileContext = context.files
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
@@ -37,7 +62,7 @@ export async function chatRoutes(app: FastifyInstance) {
enhancedMessages = [
{ role: "user" as const, content: `Context:\n${fileContext}` },
...messages
...enhancedMessages,
];
}
@@ -45,7 +70,7 @@ export async function chatRoutes(app: FastifyInstance) {
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
enhancedMessages = [
{ role: "user" as const, content: selectionContext },
...messages
...enhancedMessages,
];
}

View File

@@ -0,0 +1,195 @@
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);
}

View File

@@ -15,9 +15,19 @@ if (useMemory) {
console.log(`☁️ Using GCP storage (project: ${config.projectId})`);
}
// Export unified interface
// Runs
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
export const getRun = useMemory ? memory.getRun : firestore.getRun;
// Tools
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
export const listTools = useMemory ? memory.listTools : firestore.listTools;
// Artifacts
export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText;
// Projects (memory-only until Firestore adapter is extended)
export const saveProject = memory.saveProject;
export const getProject = memory.getProject;
export const listProjects = memory.listProjects;
export const updateProjectApp = memory.updateProjectApp;

View File

@@ -1,12 +1,13 @@
/**
* In-memory storage for local development without Firestore/GCS
*/
import type { RunRecord, ToolDef } from "../types.js";
import type { AppRecord, ProjectRecord, RunRecord, ToolDef } from "../types.js";
// In-memory stores
const runs = new Map<string, RunRecord>();
const tools = new Map<string, ToolDef>();
const artifacts = new Map<string, string>();
const projects = new Map<string, ProjectRecord>();
// Run operations
export async function saveRun(run: RunRecord): Promise<void> {
@@ -33,6 +34,32 @@ export async function writeArtifactText(prefix: string, filename: string, conten
return { bucket: "memory", path };
}
// Project operations
export async function saveProject(project: ProjectRecord): Promise<void> {
projects.set(project.project_id, { ...project });
}
export async function getProject(projectId: string): Promise<ProjectRecord | null> {
return projects.get(projectId) ?? null;
}
export async function listProjects(tenantId: string): Promise<ProjectRecord[]> {
return Array.from(projects.values()).filter(p => p.tenant_id === tenantId);
}
export async function updateProjectApp(projectId: string, app: AppRecord): Promise<void> {
const project = projects.get(projectId);
if (!project) throw new Error(`Project not found: ${projectId}`);
const idx = project.apps.findIndex(a => a.name === app.name);
if (idx >= 0) {
project.apps[idx] = app;
} else {
project.apps.push(app);
}
project.updated_at = new Date().toISOString();
projects.set(projectId, project);
}
// Seed some example tools for testing
export function seedTools() {
const sampleTools: ToolDef[] = [

View File

@@ -1,3 +1,31 @@
// ─── Project ──────────────────────────────────────────────────────────────────
export type ProjectStatus = "provisioning" | "active" | "error" | "archived";
export type AppRecord = {
name: string;
path: string;
coolifyServiceUuid?: string;
domain?: string;
};
export type ProjectRecord = {
project_id: string;
tenant_id: string;
name: string;
slug: string;
status: ProjectStatus;
repo: string;
coolifyProjectUuid?: string;
apps: AppRecord[];
turboVersion: string;
created_at: string;
updated_at: string;
error?: string;
};
// ─── Tools ────────────────────────────────────────────────────────────────────
export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = {

View File

@@ -11,37 +11,64 @@ await app.register(sensible);
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
/**
* Deploy a Cloud Run service
* In production: triggers Cloud Build, deploys to Cloud Run
* In dev: returns mock response
* Deploy an app from a Turborepo monorepo.
*
* Expects input to include:
* - repo_url: git clone URL (the project monorepo)
* - app_name: the app folder name under apps/ (e.g. "product", "website")
* - ref: git branch/tag/sha (default "main")
* - env: target environment ("dev" | "staging" | "prod")
*
* Build command: turbo run build --filter={app_name}
* In production this triggers Coolify via its API; in dev it returns a mock.
*/
app.post("/execute/deploy", async (req) => {
const body = req.body as any;
const { run_id, tenant_id, input } = body;
const appName = input.app_name ?? input.service_name ?? "product";
const repoUrl = input.repo_url ?? "";
const ref = input.ref ?? "main";
const env = input.env ?? "dev";
console.log(`🚀 Monorepo deploy request:`, { run_id, tenant_id, appName, repoUrl, ref, env });
await new Promise(r => setTimeout(r, 1500));
const mockRevision = `${appName}-${Date.now().toString(36)}`;
const mockUrl = `https://${appName}-${ref}.vibnai.com`;
console.log(`✅ Deploy complete:`, { appName, revision: mockRevision, url: mockUrl });
return {
app_name: appName,
service_url: mockUrl,
revision: mockRevision,
build_command: `turbo run build --filter=${appName}`,
build_id: `build-${Date.now()}`,
deployed_at: new Date().toISOString(),
env,
};
});
// Legacy Cloud Run endpoint — kept for backwards compatibility
app.post("/execute/cloudrun/deploy", async (req) => {
const body = req.body as any;
const { run_id, tenant_id, input } = body;
console.log(`🚀 Deploy request:`, { run_id, tenant_id, input });
// Simulate deployment time
console.log(`🚀 Deploy request (legacy):`, { run_id, tenant_id, input });
await new Promise(r => setTimeout(r, 1500));
// In production, this would:
// 1. Clone the repo
// 2. Trigger Cloud Build
// 3. Deploy to Cloud Run
// 4. Return the service URL
const mockRevision = `${input.service_name}-${Date.now().toString(36)}`;
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
console.log(`✅ Deploy complete:`, { revision: mockRevision, url: mockUrl });
return {
service_url: mockUrl,
revision: mockRevision,
build_id: `build-${Date.now()}`,
deployed_at: new Date().toISOString(),
region: input.region ?? "us-central1",
env: input.env
env: input.env,
};
});