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:
229
TURBOREPO_MIGRATION_PLAN.md
Normal file
229
TURBOREPO_MIGRATION_PLAN.md
Normal file
@@ -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<void>`
|
||||||
|
- `getProject(projectId: string): Promise<ProjectRecord | null>`
|
||||||
|
- `listProjects(tenantId: string): Promise<ProjectRecord[]>`
|
||||||
|
- `updateProjectApp(projectId: string, app: AppRecord): Promise<void>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -6,5 +6,16 @@ export const config = {
|
|||||||
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
|
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
|
||||||
authMode: process.env.AUTH_MODE ?? "dev",
|
authMode: process.env.AUTH_MODE ?? "dev",
|
||||||
// Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured
|
// 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"]
|
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
|
// 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:
|
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
|
- Analyzing product metrics and funnels
|
||||||
- Generating marketing content
|
- Generating marketing content
|
||||||
- Writing and modifying code
|
|
||||||
- Understanding what drives user behavior
|
- Understanding what drives user behavior
|
||||||
|
|
||||||
When users ask you to do something, use the available tools to take action. Be concise and helpful.
|
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.
|
||||||
If a user asks about code, analyze their request and either:
|
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.`;
|
||||||
1. Use generate_code tool for code changes
|
|
||||||
2. Provide explanations directly
|
|
||||||
|
|
||||||
Always confirm before taking destructive actions like deploying to production.`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat with Gemini
|
* 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 { toolRoutes } from "./routes/tools.js";
|
||||||
import { runRoutes } from "./routes/runs.js";
|
import { runRoutes } from "./routes/runs.js";
|
||||||
import { chatRoutes } from "./routes/chat.js";
|
import { chatRoutes } from "./routes/chat.js";
|
||||||
|
import { projectRoutes } from "./routes/projects.js";
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ await app.register(healthRoutes);
|
|||||||
await app.register(toolRoutes);
|
await app.register(toolRoutes);
|
||||||
await app.register(runRoutes);
|
await app.register(runRoutes);
|
||||||
await app.register(chatRoutes);
|
await app.register(chatRoutes);
|
||||||
|
await app.register(projectRoutes);
|
||||||
|
|
||||||
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
|
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
|
||||||
console.log(`🚀 Control Plane API running on http://localhost:${config.port}`);
|
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 { requireAuth } from "../auth.js";
|
||||||
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
|
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
|
||||||
import { getRegistry } from "../registry.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 { nanoid } from "nanoid";
|
||||||
import type { RunRecord } from "../types.js";
|
import type { RunRecord } from "../types.js";
|
||||||
|
|
||||||
interface ChatRequest {
|
interface ChatRequest {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
project_id?: string;
|
||||||
context?: {
|
context?: {
|
||||||
files?: { path: string; content: string }[];
|
files?: { path: string; content: string }[];
|
||||||
selection?: { path: string; text: string; startLine: number };
|
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> => {
|
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
|
||||||
await requireAuth(req);
|
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];
|
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) {
|
if (context?.files?.length) {
|
||||||
const fileContext = context.files
|
const fileContext = context.files
|
||||||
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
||||||
@@ -37,7 +62,7 @@ export async function chatRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
enhancedMessages = [
|
enhancedMessages = [
|
||||||
{ role: "user" as const, content: `Context:\n${fileContext}` },
|
{ 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\`\`\``;
|
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
|
||||||
enhancedMessages = [
|
enhancedMessages = [
|
||||||
{ role: "user" as const, content: selectionContext },
|
{ 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})`);
|
console.log(`☁️ Using GCP storage (project: ${config.projectId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export unified interface
|
// Runs
|
||||||
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
|
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
|
||||||
export const getRun = useMemory ? memory.getRun : firestore.getRun;
|
export const getRun = useMemory ? memory.getRun : firestore.getRun;
|
||||||
|
|
||||||
|
// Tools
|
||||||
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
|
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
|
||||||
export const listTools = useMemory ? memory.listTools : firestore.listTools;
|
export const listTools = useMemory ? memory.listTools : firestore.listTools;
|
||||||
|
|
||||||
|
// Artifacts
|
||||||
export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText;
|
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
|
* 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
|
// In-memory stores
|
||||||
const runs = new Map<string, RunRecord>();
|
const runs = new Map<string, RunRecord>();
|
||||||
const tools = new Map<string, ToolDef>();
|
const tools = new Map<string, ToolDef>();
|
||||||
const artifacts = new Map<string, string>();
|
const artifacts = new Map<string, string>();
|
||||||
|
const projects = new Map<string, ProjectRecord>();
|
||||||
|
|
||||||
// Run operations
|
// Run operations
|
||||||
export async function saveRun(run: RunRecord): Promise<void> {
|
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 };
|
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
|
// Seed some example tools for testing
|
||||||
export function seedTools() {
|
export function seedTools() {
|
||||||
const sampleTools: ToolDef[] = [
|
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 ToolRisk = "low" | "medium" | "high";
|
||||||
|
|
||||||
export type ToolDef = {
|
export type ToolDef = {
|
||||||
|
|||||||
@@ -11,37 +11,64 @@ await app.register(sensible);
|
|||||||
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
|
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy a Cloud Run service
|
* Deploy an app from a Turborepo monorepo.
|
||||||
* In production: triggers Cloud Build, deploys to Cloud Run
|
*
|
||||||
* In dev: returns mock response
|
* 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) => {
|
app.post("/execute/cloudrun/deploy", async (req) => {
|
||||||
const body = req.body as any;
|
const body = req.body as any;
|
||||||
const { run_id, tenant_id, input } = body;
|
const { run_id, tenant_id, input } = body;
|
||||||
|
|
||||||
console.log(`🚀 Deploy request:`, { run_id, tenant_id, input });
|
console.log(`🚀 Deploy request (legacy):`, { run_id, tenant_id, input });
|
||||||
|
|
||||||
// Simulate deployment time
|
|
||||||
await new Promise(r => setTimeout(r, 1500));
|
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 mockRevision = `${input.service_name}-${Date.now().toString(36)}`;
|
||||||
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
|
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
|
||||||
|
|
||||||
console.log(`✅ Deploy complete:`, { revision: mockRevision, url: mockUrl });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
service_url: mockUrl,
|
service_url: mockUrl,
|
||||||
revision: mockRevision,
|
revision: mockRevision,
|
||||||
build_id: `build-${Date.now()}`,
|
build_id: `build-${Date.now()}`,
|
||||||
deployed_at: new Date().toISOString(),
|
deployed_at: new Date().toISOString(),
|
||||||
region: input.region ?? "us-central1",
|
region: input.region ?? "us-central1",
|
||||||
env: input.env
|
env: input.env,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
41
platform/scripts/templates/turborepo/.gitignore
vendored
Normal file
41
platform/scripts/templates/turborepo/.gitignore
vendored
Normal file
@@ -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
|
||||||
57
platform/scripts/templates/turborepo/README.md
Normal file
57
platform/scripts/templates/turborepo/README.md
Normal file
@@ -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:*"
|
||||||
|
```
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: [
|
||||||
|
"@{{project-slug}}/ui",
|
||||||
|
"@{{project-slug}}/tokens",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
27
platform/scripts/templates/turborepo/apps/admin/package.json
Normal file
27
platform/scripts/templates/turborepo/apps/admin/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: [
|
||||||
|
"@{{project-slug}}/ui",
|
||||||
|
"@{{project-slug}}/tokens",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@{{project-slug}}/config/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules", "storybook-static"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
transpilePackages: [
|
||||||
|
"@{{project-slug}}/ui",
|
||||||
|
"@{{project-slug}}/tokens",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
20
platform/scripts/templates/turborepo/package.json
Normal file
20
platform/scripts/templates/turborepo/package.json
Normal file
@@ -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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> = {
|
||||||
|
data: T;
|
||||||
|
error: null;
|
||||||
|
} | {
|
||||||
|
data: null;
|
||||||
|
error: {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginatedResponse<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type BadgeVariant = "default" | "success" | "warning" | "error" | "brand";
|
||||||
|
|
||||||
|
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center rounded-[var(--radius-full)] px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
variantClasses[variant],
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "ghost" | "destructive";
|
||||||
|
type Size = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<Variant, string> = {
|
||||||
|
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<Size, string> = {
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={disabled ?? loading}
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] font-medium transition-colors",
|
||||||
|
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={[
|
||||||
|
"rounded-[var(--radius-xl)] border border-[var(--color-neutral-200)] bg-white shadow-[var(--shadow-sm,0_1px_2px_0_rgb(0_0_0/0.05))]",
|
||||||
|
paddingClasses[padding],
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={inputId} className="text-sm font-medium text-[var(--color-neutral-700)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
id={inputId}
|
||||||
|
className={[
|
||||||
|
"w-full rounded-[var(--radius-md)] border px-3 py-2 text-sm transition-colors",
|
||||||
|
"placeholder:text-[var(--color-neutral-400)]",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:border-transparent",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
error
|
||||||
|
? "border-[var(--color-error)] bg-[var(--color-error-light)]"
|
||||||
|
: "border-[var(--color-neutral-300)] bg-white",
|
||||||
|
className,
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-[var(--color-error)]">{error}</p>}
|
||||||
|
{hint && !error && <p className="text-xs text-[var(--color-neutral-500)]">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* Import design tokens — include this once at your app root */
|
||||||
|
@import "@{{project-slug}}/tokens/css";
|
||||||
Reference in New Issue
Block a user