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:
@@ -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",
|
||||
};
|
||||
|
||||
118
platform/backend/control-plane/src/coolify.ts
Normal file
118
platform/backend/control-plane/src/coolify.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
154
platform/backend/control-plane/src/gitea.ts
Normal file
154
platform/backend/control-plane/src/gitea.ts
Normal 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");
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
195
platform/backend/control-plane/src/routes/projects.ts
Normal file
195
platform/backend/control-plane/src/routes/projects.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user