From 2c3e7f9dfb3cdf110434861790cddd7a494b1e41 Mon Sep 17 00:00:00 2001 From: mawkone Date: Sat, 21 Feb 2026 15:07:35 -0800 Subject: [PATCH] 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 --- TURBOREPO_MIGRATION_PLAN.md | 229 ++++++++++++++++++ platform/backend/control-plane/src/config.ts | 13 +- platform/backend/control-plane/src/coolify.ts | 118 +++++++++ platform/backend/control-plane/src/gemini.ts | 65 ++++- platform/backend/control-plane/src/gitea.ts | 154 ++++++++++++ platform/backend/control-plane/src/index.ts | 2 + .../backend/control-plane/src/routes/chat.ts | 35 ++- .../control-plane/src/routes/projects.ts | 195 +++++++++++++++ .../control-plane/src/storage/index.ts | 12 +- .../control-plane/src/storage/memory.ts | 29 ++- platform/backend/control-plane/src/types.ts | 28 +++ .../backend/executors/deploy/src/index.ts | 57 +++-- .../scripts/templates/turborepo/.gitignore | 41 ++++ .../scripts/templates/turborepo/README.md | 57 +++++ .../turborepo/apps/admin/next.config.ts | 10 + .../turborepo/apps/admin/package.json | 27 +++ .../turborepo/apps/admin/tsconfig.json | 11 + .../turborepo/apps/product/next.config.ts | 10 + .../turborepo/apps/product/package.json | 27 +++ .../turborepo/apps/product/tsconfig.json | 11 + .../turborepo/apps/storybook/package.json | 32 +++ .../turborepo/apps/storybook/tsconfig.json | 10 + .../turborepo/apps/website/next.config.ts | 10 + .../turborepo/apps/website/package.json | 27 +++ .../turborepo/apps/website/tsconfig.json | 11 + .../scripts/templates/turborepo/package.json | 20 ++ .../packages/config/eslint.config.js | 23 ++ .../turborepo/packages/config/package.json | 9 + .../turborepo/packages/tokens/package.json | 18 ++ .../turborepo/packages/tokens/src/index.ts | 84 +++++++ .../turborepo/packages/tokens/src/tokens.css | 46 ++++ .../turborepo/packages/types/package.json | 17 ++ .../turborepo/packages/types/src/index.ts | 35 +++ .../turborepo/packages/ui/package.json | 29 +++ .../packages/ui/src/components/Badge.tsx | 30 +++ .../packages/ui/src/components/Button.tsx | 56 +++++ .../packages/ui/src/components/Card.tsx | 27 +++ .../packages/ui/src/components/Input.tsx | 37 +++ .../turborepo/packages/ui/src/index.ts | 4 + .../turborepo/packages/ui/src/styles.css | 2 + 40 files changed, 1625 insertions(+), 33 deletions(-) create mode 100644 TURBOREPO_MIGRATION_PLAN.md create mode 100644 platform/backend/control-plane/src/coolify.ts create mode 100644 platform/backend/control-plane/src/gitea.ts create mode 100644 platform/backend/control-plane/src/routes/projects.ts create mode 100644 platform/scripts/templates/turborepo/.gitignore create mode 100644 platform/scripts/templates/turborepo/README.md create mode 100644 platform/scripts/templates/turborepo/apps/admin/next.config.ts create mode 100644 platform/scripts/templates/turborepo/apps/admin/package.json create mode 100644 platform/scripts/templates/turborepo/apps/admin/tsconfig.json create mode 100644 platform/scripts/templates/turborepo/apps/product/next.config.ts create mode 100644 platform/scripts/templates/turborepo/apps/product/package.json create mode 100644 platform/scripts/templates/turborepo/apps/product/tsconfig.json create mode 100644 platform/scripts/templates/turborepo/apps/storybook/package.json create mode 100644 platform/scripts/templates/turborepo/apps/storybook/tsconfig.json create mode 100644 platform/scripts/templates/turborepo/apps/website/next.config.ts create mode 100644 platform/scripts/templates/turborepo/apps/website/package.json create mode 100644 platform/scripts/templates/turborepo/apps/website/tsconfig.json create mode 100644 platform/scripts/templates/turborepo/package.json create mode 100644 platform/scripts/templates/turborepo/packages/config/eslint.config.js create mode 100644 platform/scripts/templates/turborepo/packages/config/package.json create mode 100644 platform/scripts/templates/turborepo/packages/tokens/package.json create mode 100644 platform/scripts/templates/turborepo/packages/tokens/src/index.ts create mode 100644 platform/scripts/templates/turborepo/packages/tokens/src/tokens.css create mode 100644 platform/scripts/templates/turborepo/packages/types/package.json create mode 100644 platform/scripts/templates/turborepo/packages/types/src/index.ts create mode 100644 platform/scripts/templates/turborepo/packages/ui/package.json create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/components/Badge.tsx create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/components/Button.tsx create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/components/Card.tsx create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/components/Input.tsx create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/index.ts create mode 100644 platform/scripts/templates/turborepo/packages/ui/src/styles.css diff --git a/TURBOREPO_MIGRATION_PLAN.md b/TURBOREPO_MIGRATION_PLAN.md new file mode 100644 index 0000000..92bc80d --- /dev/null +++ b/TURBOREPO_MIGRATION_PLAN.md @@ -0,0 +1,229 @@ +# Turborepo Monorepo Per-Project Migration Plan + +## Why We Are Making This Change + +The core thesis of this platform is that **one AI controls everything in one project**. For that to work, the AI needs a complete mental model of the project — all apps, all shared code, all dependencies — in a single coherent context. + +The current architecture creates separate Gitea repos per app (frontend repo, API repo, etc.), which fragments that context. The AI has to context-switch across repos, cross-repo dependencies are manual and brittle, and shared code has no clean home. + +By adopting **Turborepo monorepo per project**, every project becomes a single repo containing all of its apps (`product`, `website`, `admin`) and shared packages (`ui`, `types`, `config`). The AI operates across the entire project simultaneously. Build orchestration, deployment, and shared code all become coherent automatically. + +**The structure every project will have:** + +``` +{project-slug}/ + apps/ + product/ ← the core user-facing app + website/ ← marketing / landing site + admin/ ← internal admin tool + packages/ + ui/ ← shared component library + types/ ← shared TypeScript types + config/ ← shared eslint, tsconfig + turbo.json + package.json ← workspace root (pnpm workspaces) + .gitignore + README.md +``` + +This is not a Vercel dependency. Turborepo is MIT-licensed, runs anywhere, and costs nothing. Remote caching is optional and can be self-hosted on Coolify. + +--- + +## Scope of Changes + +### 1. Project Scaffold Templates + +**What:** Create a set of template files that get written into a new Gitea repo when a project is created. + +**Files to create:** `platform/scripts/templates/turborepo/` + +- `turbo.json` — pipeline config defining `build`, `dev`, `lint`, `test` tasks and their dependencies +- `package.json` — workspace root with pnpm workspaces pointing to `apps/*` and `packages/*` +- `.gitignore` — covering node_modules, dist, .turbo cache +- `apps/product/package.json` — Next.js app skeleton +- `apps/website/package.json` — Astro or Next.js skeleton +- `apps/admin/package.json` — Next.js app skeleton +- `packages/ui/package.json` — shared component library stub +- `packages/types/package.json` — shared types stub +- `packages/config/` — shared `tsconfig.json` and `eslint` base configs + +**Notes:** +- Templates should be stack-agnostic at the shell level — the `turbo.json` pipeline is what matters, inner frameworks can vary +- Stack choices (Next.js vs Astro, etc.) can be parameterised later when we add a project creation wizard + +--- + +### 2. Control Plane — Project Data Model Update + +**What:** The current data model stores multiple Gitea repos per project. This changes to one repo per project. + +**File:** `platform/backend/control-plane/src/types.ts` + +**Changes:** +- Remove `repos: Array<{ gitea_repo, path }>` from `ProjectRecord` (or update it) +- Add `repo: string` — single Gitea repo URL for the project +- Add `apps: Array<{ name: string; path: string; coolify_service_uuid?: string }>` — tracks each app inside the monorepo and its Coolify service +- Add `turbo: { version: string }` — tracks which Turborepo version the project was scaffolded with + +--- + +### 3. Control Plane — New Project Routes + +**What:** Add project management endpoints to the control plane API. + +**File to create:** `platform/backend/control-plane/src/routes/projects.ts` + +**Endpoints:** + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/projects` | Create project — scaffold Turborepo repo in Gitea, register in DB | +| `GET` | `/projects/:project_id` | Get project record | +| `GET` | `/projects/:project_id/apps` | List apps within the monorepo | +| `POST` | `/projects/:project_id/apps` | Add a new app to the monorepo | +| `POST` | `/projects/:project_id/deploy` | Trigger Turbo build + Coolify deploy for one or all apps | + +**Project creation flow (`POST /projects`):** +1. Validate request (name, tenant_id, optional app selections) +2. Create Gitea repo via Gitea API +3. Scaffold Turborepo structure from templates, push initial commit +4. Register webhook: Gitea repo → control plane `/webhooks/gitea` +5. Create Coolify project +6. Create one Coolify service per app (with correct build filter) +7. Save project record to storage +8. Return project record with repo URL and app list + +--- + +### 4. Control Plane — Storage Layer Updates + +**What:** Add project storage operations alongside existing runs/tools storage. + +**File to update:** `platform/backend/control-plane/src/storage/memory.ts` +**File to update:** `platform/backend/control-plane/src/storage/firestore.ts` +**File to update:** `platform/backend/control-plane/src/storage/index.ts` + +**New operations to add:** +- `saveProject(project: ProjectRecord): Promise` +- `getProject(projectId: string): Promise` +- `listProjects(tenantId: string): Promise` +- `updateProjectApp(projectId: string, app: AppRecord): Promise` + +--- + +### 5. Gitea Integration Service + +**What:** New service to abstract all Gitea API calls. Currently there is no Gitea integration in the control plane. + +**File to create:** `platform/backend/control-plane/src/gitea.ts` + +**Responsibilities:** +- Create repo for a project +- Push initial scaffolded files (initial commit) +- Register webhooks +- Read file tree (so AI can understand the project structure) +- Read/write individual files (so AI can make edits) + +**Config needed in `config.ts`:** +- `giteaUrl` — from `GITEA_URL` env var (e.g. `https://git.vibnai.com`) +- `giteaToken` — from `GITEA_TOKEN` env var (admin token for repo creation) + +--- + +### 6. Coolify Integration Service + +**What:** New service to abstract all Coolify API calls. Currently the deploy executor calls Coolify but there is no central integration. + +**File to create:** `platform/backend/control-plane/src/coolify.ts` + +**Responsibilities:** +- Create a Coolify project +- Create a Coolify application service linked to a Gitea repo +- Set the build command to `turbo run build --filter={app-name}` +- Set the publish directory per app +- Trigger a deployment +- Get deployment status + +**Config needed in `config.ts`:** +- `coolifyUrl` — from `COOLIFY_URL` env var +- `coolifyToken` — from `COOLIFY_TOKEN` env var + +--- + +### 7. Deploy Executor — Monorepo Awareness + +**What:** The existing deploy executor (`platform/backend/executors/deploy`) currently deploys a single service. It needs to understand the monorepo structure and use `turbo run build --filter` to target the right app. + +**File to update:** `platform/backend/executors/deploy/src/index.ts` + +**Changes:** +- Accept `app_name` in the input payload (e.g. `"product"`, `"website"`, `"admin"`) +- Build command becomes `turbo run build --filter={app_name}` instead of `npm run build` +- Pass the root of the monorepo as the build context, not an app subdirectory + +--- + +### 8. AI Context — Project-Aware Prompting + +**What:** The Gemini chat integration currently has no awareness of which project the user is in. It needs project context so the AI can reason across the whole monorepo. + +**File to update:** `platform/backend/control-plane/src/gemini.ts` +**File to update:** `platform/backend/control-plane/src/routes/chat.ts` + +**Changes:** +- Add `project_id` to `ChatRequest` +- On chat requests with a `project_id`, fetch the project record and inject: + - Repo structure (app names, package names) + - Recent deployment status per app + - `turbo.json` pipeline config +- Add a new Gemini tool: `scaffold_app` — lets the AI add a new app to the user's monorepo +- Add a new Gemini tool: `deploy_app` — lets the AI trigger a Coolify deploy for a specific app by name + +--- + +### 9. Theia Workspace — Single Repo Mode + +**What:** The current Theia docker-compose opens a multi-root workspace across multiple repos. With one repo per project, this simplifies to a single workspace root. + +**File to update:** `theia-docker-compose.yml` (and the Coolify service config for Theia) + +**Changes:** +- Workspace path points to the cloned monorepo root +- Git remote is the project's single Gitea repo +- Theia extensions should be aware of the `turbo.json` to surface run targets in the UI (future) + +--- + +### 10. Local Dev — Replace start-all.sh with Turbo + +**What:** The current `platform/scripts/start-all.sh` manually starts each service with `&`. Once the platform itself is in a Turborepo, this can be replaced with `turbo run dev`. + +**Note:** This is a nice-to-have follow-on. The priority is getting user project scaffolding right first. The platform's own internal structure can be migrated to Turborepo in a separate pass. + +--- + +## Implementation Order + +| Step | Task | Depends On | +|------|------|-----------| +| 1 | Create scaffold templates | Nothing | +| 2 | Add `ProjectRecord` type + storage ops | Step 1 | +| 3 | Build Gitea integration service | Step 2 | +| 4 | Build Coolify integration service | Step 2 | +| 5 | Add project routes to control plane | Steps 2, 3, 4 | +| 6 | Update deploy executor for monorepo | Step 5 | +| 7 | Update AI chat with project context | Step 5 | +| 8 | Update Theia workspace config | Step 5 | +| 9 | Migrate platform itself to Turborepo | All of the above | + +--- + +## What Does Not Change + +- Gitea as the source control host — same, just one repo per project instead of many +- Coolify as the deployment host — same, just configured with Turbo build filters +- Theia as the IDE — same, just opens one repo instead of multi-root +- The control plane API architecture (Fastify, in-memory/Firestore storage) — same, just extended +- Auth model — unchanged +- No Vercel dependency anywhere in this plan diff --git a/platform/backend/control-plane/src/config.ts b/platform/backend/control-plane/src/config.ts index 35e1450..60f01ec 100644 --- a/platform/backend/control-plane/src/config.ts +++ b/platform/backend/control-plane/src/config.ts @@ -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", }; diff --git a/platform/backend/control-plane/src/coolify.ts b/platform/backend/control-plane/src/coolify.ts new file mode 100644 index 0000000..5a28783 --- /dev/null +++ b/platform/backend/control-plane/src/coolify.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 +): Promise { + for (const [key, value] of Object.entries(vars)) { + await coolifyFetch(`/applications/${serviceUuid}/envs`, { + method: "POST", + body: JSON.stringify({ key, value, is_preview: false }), + }); + } +} diff --git a/platform/backend/control-plane/src/gemini.ts b/platform/backend/control-plane/src/gemini.ts index ba60b5e..e836ba7 100644 --- a/platform/backend/control-plane/src/gemini.ts +++ b/platform/backend/control-plane/src/gemini.ts @@ -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= +- 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 diff --git a/platform/backend/control-plane/src/gitea.ts b/platform/backend/control-plane/src/gitea.ts new file mode 100644 index 0000000..d4680e1 --- /dev/null +++ b/platform/backend/control-plane/src/gitea.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); +} diff --git a/platform/backend/control-plane/src/index.ts b/platform/backend/control-plane/src/index.ts index fd980a4..6ccda0f 100644 --- a/platform/backend/control-plane/src/index.ts +++ b/platform/backend/control-plane/src/index.ts @@ -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}`); diff --git a/platform/backend/control-plane/src/routes/chat.ts b/platform/backend/control-plane/src/routes/chat.ts index 46b52a6..54436bc 100644 --- a/platform/backend/control-plane/src/routes/chat.ts +++ b/platform/backend/control-plane/src/routes/chat.ts @@ -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 => { 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=`, + ].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, ]; } diff --git a/platform/backend/control-plane/src/routes/projects.ts b/platform/backend/control-plane/src/routes/projects.ts new file mode 100644 index 0000000..8c436f7 --- /dev/null +++ b/platform/backend/control-plane/src/routes/projects.ts @@ -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 { + 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); +} diff --git a/platform/backend/control-plane/src/storage/index.ts b/platform/backend/control-plane/src/storage/index.ts index a52eb4c..18a6c6a 100644 --- a/platform/backend/control-plane/src/storage/index.ts +++ b/platform/backend/control-plane/src/storage/index.ts @@ -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; diff --git a/platform/backend/control-plane/src/storage/memory.ts b/platform/backend/control-plane/src/storage/memory.ts index e595a68..17add33 100644 --- a/platform/backend/control-plane/src/storage/memory.ts +++ b/platform/backend/control-plane/src/storage/memory.ts @@ -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(); const tools = new Map(); const artifacts = new Map(); +const projects = new Map(); // Run operations export async function saveRun(run: RunRecord): Promise { @@ -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 { + projects.set(project.project_id, { ...project }); +} + +export async function getProject(projectId: string): Promise { + return projects.get(projectId) ?? null; +} + +export async function listProjects(tenantId: string): Promise { + return Array.from(projects.values()).filter(p => p.tenant_id === tenantId); +} + +export async function updateProjectApp(projectId: string, app: AppRecord): Promise { + 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[] = [ diff --git a/platform/backend/control-plane/src/types.ts b/platform/backend/control-plane/src/types.ts index ab50def..3ff40dd 100644 --- a/platform/backend/control-plane/src/types.ts +++ b/platform/backend/control-plane/src/types.ts @@ -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 = { diff --git a/platform/backend/executors/deploy/src/index.ts b/platform/backend/executors/deploy/src/index.ts index d18a2c9..103a613 100644 --- a/platform/backend/executors/deploy/src/index.ts +++ b/platform/backend/executors/deploy/src/index.ts @@ -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, }; }); diff --git a/platform/scripts/templates/turborepo/.gitignore b/platform/scripts/templates/turborepo/.gitignore new file mode 100644 index 0000000..09507b1 --- /dev/null +++ b/platform/scripts/templates/turborepo/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Build outputs +dist +.next +out +build +storybook-static + +# Turbo +.turbo + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/settings.json +.idea + +# Testing +coverage +.nyc_output + +# TypeScript +*.tsbuildinfo diff --git a/platform/scripts/templates/turborepo/README.md b/platform/scripts/templates/turborepo/README.md new file mode 100644 index 0000000..837fa6d --- /dev/null +++ b/platform/scripts/templates/turborepo/README.md @@ -0,0 +1,57 @@ +# {{project-name}} + +A full-stack monorepo powered by [Turborepo](https://turbo.build). + +## Structure + +``` +apps/ + product/ — core user-facing application + website/ — marketing and landing pages + admin/ — internal admin tooling + storybook/ — component browser and design system +packages/ + ui/ — shared React component library + tokens/ — design tokens (colors, typography, spacing) + types/ — shared TypeScript types + config/ — shared eslint and tsconfig base configs +``` + +## Getting Started + +```bash +pnpm install +pnpm dev +``` + +## Running a specific app + +```bash +turbo run dev --filter=product +turbo run dev --filter=website +turbo run dev --filter=admin +``` + +## Building + +```bash +pnpm build +# or a single app +turbo run build --filter=product +``` + +## Adding a new app + +```bash +cd apps +npx create-next-app@latest my-new-app +# then add it to the workspace — pnpm will pick it up automatically +``` + +## Adding a new shared package + +```bash +mkdir packages/my-package +# add a package.json with name "@{{project-slug}}/my-package" +# reference it from any app: "@{{project-slug}}/my-package": "workspace:*" +``` diff --git a/platform/scripts/templates/turborepo/apps/admin/next.config.ts b/platform/scripts/templates/turborepo/apps/admin/next.config.ts new file mode 100644 index 0000000..8db7840 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/admin/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: [ + "@{{project-slug}}/ui", + "@{{project-slug}}/tokens", + ], +}; + +export default nextConfig; diff --git a/platform/scripts/templates/turborepo/apps/admin/package.json b/platform/scripts/templates/turborepo/apps/admin/package.json new file mode 100644 index 0000000..726b4a9 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/admin/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/platform/scripts/templates/turborepo/apps/admin/tsconfig.json b/platform/scripts/templates/turborepo/apps/admin/tsconfig.json new file mode 100644 index 0000000..3a8e4ad --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/admin/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/platform/scripts/templates/turborepo/apps/product/next.config.ts b/platform/scripts/templates/turborepo/apps/product/next.config.ts new file mode 100644 index 0000000..8db7840 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/product/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: [ + "@{{project-slug}}/ui", + "@{{project-slug}}/tokens", + ], +}; + +export default nextConfig; diff --git a/platform/scripts/templates/turborepo/apps/product/package.json b/platform/scripts/templates/turborepo/apps/product/package.json new file mode 100644 index 0000000..3c857d9 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/product/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/product", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/platform/scripts/templates/turborepo/apps/product/tsconfig.json b/platform/scripts/templates/turborepo/apps/product/tsconfig.json new file mode 100644 index 0000000..3a8e4ad --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/product/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/platform/scripts/templates/turborepo/apps/storybook/package.json b/platform/scripts/templates/turborepo/apps/storybook/package.json new file mode 100644 index 0000000..cb1eff6 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/storybook/package.json @@ -0,0 +1,32 @@ +{ + "name": "@{{project-slug}}/storybook", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "storybook dev --port 6006", + "build": "storybook build --output-dir storybook-static", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@chromatic-com/storybook": "^3.0.0", + "@storybook/addon-essentials": "^8.5.0", + "@storybook/addon-interactions": "^8.5.0", + "@storybook/addon-links": "^8.5.0", + "@storybook/blocks": "^8.5.0", + "@storybook/react": "^8.5.0", + "@storybook/react-vite": "^8.5.0", + "@storybook/test": "^8.5.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "storybook": "^8.5.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/platform/scripts/templates/turborepo/apps/storybook/tsconfig.json b/platform/scripts/templates/turborepo/apps/storybook/tsconfig.json new file mode 100644 index 0000000..aad30ba --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "storybook-static"] +} diff --git a/platform/scripts/templates/turborepo/apps/website/next.config.ts b/platform/scripts/templates/turborepo/apps/website/next.config.ts new file mode 100644 index 0000000..8db7840 --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/website/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: [ + "@{{project-slug}}/ui", + "@{{project-slug}}/tokens", + ], +}; + +export default nextConfig; diff --git a/platform/scripts/templates/turborepo/apps/website/package.json b/platform/scripts/templates/turborepo/apps/website/package.json new file mode 100644 index 0000000..c38506c --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/website/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/website", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/platform/scripts/templates/turborepo/apps/website/tsconfig.json b/platform/scripts/templates/turborepo/apps/website/tsconfig.json new file mode 100644 index 0000000..3a8e4ad --- /dev/null +++ b/platform/scripts/templates/turborepo/apps/website/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/platform/scripts/templates/turborepo/package.json b/platform/scripts/templates/turborepo/package.json new file mode 100644 index 0000000..2f56868 --- /dev/null +++ b/platform/scripts/templates/turborepo/package.json @@ -0,0 +1,20 @@ +{ + "name": "{{project-slug}}", + "private": true, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "type-check": "turbo run type-check", + "test": "turbo run test", + "clean": "turbo run clean && rm -rf node_modules" + }, + "devDependencies": { + "turbo": "^2.3.3" + }, + "packageManager": "pnpm@9.15.0", + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/platform/scripts/templates/turborepo/packages/config/eslint.config.js b/platform/scripts/templates/turborepo/packages/config/eslint.config.js new file mode 100644 index 0000000..bb4263c --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/config/eslint.config.js @@ -0,0 +1,23 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactPlugin from "eslint-plugin-react"; +import reactHooksPlugin from "eslint-plugin-react-hooks"; + +/** @type {import("typescript-eslint").Config} */ +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + plugins: { + react: reactPlugin, + "react-hooks": reactHooksPlugin, + }, + rules: { + "react/react-in-jsx-scope": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/consistent-type-imports": "error", + }, + } +); diff --git a/platform/scripts/templates/turborepo/packages/config/package.json b/platform/scripts/templates/turborepo/packages/config/package.json new file mode 100644 index 0000000..abaa001 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@{{project-slug}}/config", + "version": "0.1.0", + "private": true, + "exports": { + "./tsconfig.base.json": "./tsconfig.base.json", + "./eslint": "./eslint.config.js" + } +} diff --git a/platform/scripts/templates/turborepo/packages/tokens/package.json b/platform/scripts/templates/turborepo/packages/tokens/package.json new file mode 100644 index 0000000..36ebd17 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/tokens/package.json @@ -0,0 +1,18 @@ +{ + "name": "@{{project-slug}}/tokens", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./css": "./src/tokens.css" + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "typescript": "^5.7.0" + } +} diff --git a/platform/scripts/templates/turborepo/packages/tokens/src/index.ts b/platform/scripts/templates/turborepo/packages/tokens/src/index.ts new file mode 100644 index 0000000..b244d49 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/tokens/src/index.ts @@ -0,0 +1,84 @@ +export const colors = { + brand: { + 50: "#f0f9ff", + 100: "#e0f2fe", + 200: "#bae6fd", + 300: "#7dd3fc", + 400: "#38bdf8", + 500: "#0ea5e9", + 600: "#0284c7", + 700: "#0369a1", + 800: "#075985", + 900: "#0c4a6e", + }, + neutral: { + 50: "#fafafa", + 100: "#f4f4f5", + 200: "#e4e4e7", + 300: "#d4d4d8", + 400: "#a1a1aa", + 500: "#71717a", + 600: "#52525b", + 700: "#3f3f46", + 800: "#27272a", + 900: "#18181b", + }, + success: { DEFAULT: "#22c55e", light: "#dcfce7", dark: "#15803d" }, + warning: { DEFAULT: "#f59e0b", light: "#fef3c7", dark: "#b45309" }, + error: { DEFAULT: "#ef4444", light: "#fee2e2", dark: "#b91c1c" }, +} as const; + +export const typography = { + fontFamily: { + sans: "var(--font-sans, ui-sans-serif, system-ui, sans-serif)", + mono: "var(--font-mono, ui-monospace, monospace)", + }, + fontSize: { + xs: ["0.75rem", { lineHeight: "1rem" }], + sm: ["0.875rem", { lineHeight: "1.25rem" }], + base: ["1rem", { lineHeight: "1.5rem" }], + lg: ["1.125rem", { lineHeight: "1.75rem" }], + xl: ["1.25rem", { lineHeight: "1.75rem" }], + "2xl":["1.5rem", { lineHeight: "2rem" }], + "3xl":["1.875rem", { lineHeight: "2.25rem" }], + "4xl":["2.25rem", { lineHeight: "2.5rem" }], + }, +} as const; + +export const spacing = { + px: "1px", + 0: "0", + 1: "0.25rem", + 2: "0.5rem", + 3: "0.75rem", + 4: "1rem", + 5: "1.25rem", + 6: "1.5rem", + 8: "2rem", + 10: "2.5rem", + 12: "3rem", + 16: "4rem", + 20: "5rem", + 24: "6rem", + 32: "8rem", +} as const; + +export const radius = { + none: "0", + sm: "0.125rem", + DEFAULT: "0.25rem", + md: "0.375rem", + lg: "0.5rem", + xl: "0.75rem", + "2xl":"1rem", + full: "9999px", +} as const; + +export const shadows = { + sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)", + DEFAULT: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + xl: "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", + none:"none", +} as const; diff --git a/platform/scripts/templates/turborepo/packages/tokens/src/tokens.css b/platform/scripts/templates/turborepo/packages/tokens/src/tokens.css new file mode 100644 index 0000000..40354f5 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/tokens/src/tokens.css @@ -0,0 +1,46 @@ +:root { + /* Brand */ + --color-brand-50: #f0f9ff; + --color-brand-100: #e0f2fe; + --color-brand-200: #bae6fd; + --color-brand-300: #7dd3fc; + --color-brand-400: #38bdf8; + --color-brand-500: #0ea5e9; + --color-brand-600: #0284c7; + --color-brand-700: #0369a1; + --color-brand-800: #075985; + --color-brand-900: #0c4a6e; + + /* Neutral */ + --color-neutral-50: #fafafa; + --color-neutral-100: #f4f4f5; + --color-neutral-200: #e4e4e7; + --color-neutral-300: #d4d4d8; + --color-neutral-400: #a1a1aa; + --color-neutral-500: #71717a; + --color-neutral-600: #52525b; + --color-neutral-700: #3f3f46; + --color-neutral-800: #27272a; + --color-neutral-900: #18181b; + + /* Semantic */ + --color-success: #22c55e; + --color-success-light: #dcfce7; + --color-warning: #f59e0b; + --color-warning-light: #fef3c7; + --color-error: #ef4444; + --color-error-light: #fee2e2; + + /* Typography */ + --font-sans: ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, monospace; + + /* Radius */ + --radius-sm: 0.125rem; + --radius: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-full: 9999px; +} diff --git a/platform/scripts/templates/turborepo/packages/types/package.json b/platform/scripts/templates/turborepo/packages/types/package.json new file mode 100644 index 0000000..7bfae59 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/types/package.json @@ -0,0 +1,17 @@ +{ + "name": "@{{project-slug}}/types", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "typescript": "^5.7.0" + } +} diff --git a/platform/scripts/templates/turborepo/packages/types/src/index.ts b/platform/scripts/templates/turborepo/packages/types/src/index.ts new file mode 100644 index 0000000..6356f94 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/types/src/index.ts @@ -0,0 +1,35 @@ +/** + * Shared types for {{project-name}} + * + * Add types here that are used across product, website, and admin. + * Import in any app: import type { User } from "@{{project-slug}}/types" + */ + +export type ID = string; + +export type User = { + id: ID; + email: string; + name: string; + avatarUrl?: string; + createdAt: string; +}; + +export type ApiResponse = { + data: T; + error: null; +} | { + data: null; + error: { + message: string; + code?: string; + }; +}; + +export type PaginatedResponse = { + items: T[]; + total: number; + page: number; + pageSize: number; + hasMore: boolean; +}; diff --git a/platform/scripts/templates/turborepo/packages/ui/package.json b/platform/scripts/templates/turborepo/packages/ui/package.json new file mode 100644 index 0000000..010d130 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "@{{project-slug}}/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles": "./src/styles.css" + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "dependencies": { + "@{{project-slug}}/tokens": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/platform/scripts/templates/turborepo/packages/ui/src/components/Badge.tsx b/platform/scripts/templates/turborepo/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..df20e07 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/components/Badge.tsx @@ -0,0 +1,30 @@ +import type { HTMLAttributes } from "react"; + +type BadgeVariant = "default" | "success" | "warning" | "error" | "brand"; + +interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; +} + +const variantClasses: Record = { + default: "bg-[var(--color-neutral-100)] text-[var(--color-neutral-700)]", + success: "bg-[var(--color-success-light)] text-[var(--color-success-dark,#15803d)]", + warning: "bg-[var(--color-warning-light)] text-[var(--color-warning-dark,#b45309)]", + error: "bg-[var(--color-error-light)] text-[var(--color-error-dark,#b91c1c)]", + brand: "bg-[var(--color-brand-100)] text-[var(--color-brand-700)]", +}; + +export function Badge({ variant = "default", className = "", children, ...props }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/platform/scripts/templates/turborepo/packages/ui/src/components/Button.tsx b/platform/scripts/templates/turborepo/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..97a07cd --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/components/Button.tsx @@ -0,0 +1,56 @@ +import type { ButtonHTMLAttributes } from "react"; + +type Variant = "primary" | "secondary" | "ghost" | "destructive"; +type Size = "sm" | "md" | "lg"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + loading?: boolean; +} + +const variantClasses: Record = { + primary: "bg-[var(--color-brand-600)] text-white hover:bg-[var(--color-brand-700)]", + secondary: "bg-[var(--color-neutral-100)] text-[var(--color-neutral-900)] hover:bg-[var(--color-neutral-200)]", + ghost: "bg-transparent text-[var(--color-neutral-700)] hover:bg-[var(--color-neutral-100)]", + destructive: "bg-[var(--color-error)] text-white hover:opacity-90", +}; + +const sizeClasses: Record = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-sm", + lg: "px-5 py-2.5 text-base", +}; + +export function Button({ + variant = "primary", + size = "md", + loading = false, + disabled, + className = "", + children, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/platform/scripts/templates/turborepo/packages/ui/src/components/Card.tsx b/platform/scripts/templates/turborepo/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..2685766 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/components/Card.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes } from "react"; + +interface CardProps extends HTMLAttributes { + padding?: "none" | "sm" | "md" | "lg"; +} + +const paddingClasses = { + none: "", + sm: "p-3", + md: "p-5", + lg: "p-8", +}; + +export function Card({ padding = "md", className = "", children, ...props }: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/platform/scripts/templates/turborepo/packages/ui/src/components/Input.tsx b/platform/scripts/templates/turborepo/packages/ui/src/components/Input.tsx new file mode 100644 index 0000000..eb72989 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/components/Input.tsx @@ -0,0 +1,37 @@ +import type { InputHTMLAttributes } from "react"; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export function Input({ label, error, hint, className = "", id, ...props }: InputProps) { + const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-"); + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} + {hint && !error &&

{hint}

} +
+ ); +} diff --git a/platform/scripts/templates/turborepo/packages/ui/src/index.ts b/platform/scripts/templates/turborepo/packages/ui/src/index.ts new file mode 100644 index 0000000..58899a7 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/index.ts @@ -0,0 +1,4 @@ +export { Button } from "./components/Button.js"; +export { Card } from "./components/Card.js"; +export { Input } from "./components/Input.js"; +export { Badge } from "./components/Badge.js"; diff --git a/platform/scripts/templates/turborepo/packages/ui/src/styles.css b/platform/scripts/templates/turborepo/packages/ui/src/styles.css new file mode 100644 index 0000000..2448116 --- /dev/null +++ b/platform/scripts/templates/turborepo/packages/ui/src/styles.css @@ -0,0 +1,2 @@ +/* Import design tokens — include this once at your app root */ +@import "@{{project-slug}}/tokens/css";