feat: add Turborepo per-project monorepo scaffold and project API

- Add Turborepo scaffold templates (apps: product, website, admin, storybook; packages: ui, tokens, types, config)
- Add ProjectRecord and AppRecord types to control plane
- Add Gitea integration service (repo creation, scaffold push, webhooks)
- Add Coolify integration service (project + per-app service provisioning with turbo --filter)
- Add project routes: GET/POST /projects, GET /projects/:id/apps, POST /projects/:id/deploy
- Update chat route to inject project/monorepo context into AI requests
- Add deploy_app and scaffold_app tools to Gemini tool set
- Update deploy executor with monorepo-aware /execute/deploy endpoint
- Add TURBOREPO_MIGRATION_PLAN.md documenting rationale and scope

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 15:07:35 -08:00
parent 57b9ce2f1a
commit 2c3e7f9dfb
40 changed files with 1625 additions and 33 deletions

229
TURBOREPO_MIGRATION_PLAN.md Normal file
View 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

View File

@@ -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",
}; };

View File

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

View File

@@ -108,26 +108,71 @@ export const PRODUCT_OS_TOOLS = [
}, },
required: ["task"] 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

View File

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

View File

@@ -8,6 +8,7 @@ import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js"; import { 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}`);

View File

@@ -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,
]; ];
} }

View File

@@ -0,0 +1,195 @@
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { config } from "../config.js";
import * as gitea from "../gitea.js";
import * as coolify from "../coolify.js";
import {
saveProject,
getProject,
listProjects,
updateProjectApp,
} from "../storage/index.js";
import type { AppRecord, ProjectRecord } from "../types.js";
const DEFAULT_APPS: AppRecord[] = [
{ name: "product", path: "apps/product" },
{ name: "website", path: "apps/website" },
{ name: "admin", path: "apps/admin" },
{ name: "storybook", path: "apps/storybook" },
];
const TURBO_VERSION = "2.3.3";
interface CreateProjectBody {
name: string;
tenant_id: string;
gitea_owner: string;
apps?: string[];
}
interface DeployAppBody {
app_name: string;
}
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
function appDomain(projectSlug: string, appName: string, tenantSlug: string): string {
const base = config.platformUrl.replace(/^https?:\/\//, "").split(":")[0] ?? "vibnai.com";
return `${appName}-${projectSlug}.${tenantSlug}.${base}`;
}
export async function projectRoutes(app: FastifyInstance) {
/**
* List all projects for the authenticated tenant
*/
app.get<{ Querystring: { tenant_id: string } }>("/projects", async (req) => {
await requireAuth(req);
const tenantId = (req.query as any).tenant_id as string;
if (!tenantId) return app.httpErrors.badRequest("tenant_id is required");
return { projects: await listProjects(tenantId) };
});
/**
* Get a single project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return project;
});
/**
* Create a new project — scaffolds the Turborepo monorepo in Gitea
* and provisions Coolify services for each app.
*/
app.post<{ Body: CreateProjectBody }>("/projects", async (req) => {
await requireAuth(req);
const { name, tenant_id, gitea_owner, apps: selectedApps } = req.body;
if (!name || !tenant_id || !gitea_owner) {
return app.httpErrors.badRequest("name, tenant_id, and gitea_owner are required");
}
const slug = slugify(name);
const projectId = `proj_${nanoid(12)}`;
const now = new Date().toISOString();
const selectedAppNames = selectedApps ?? DEFAULT_APPS.map(a => a.name);
const apps = DEFAULT_APPS.filter(a => selectedAppNames.includes(a.name));
const project: ProjectRecord = {
project_id: projectId,
tenant_id,
name,
slug,
status: "provisioning",
repo: "",
apps,
turboVersion: TURBO_VERSION,
created_at: now,
updated_at: now,
};
await saveProject(project);
// Provision asynchronously — return immediately with "provisioning" status
provisionProject(project, gitea_owner).catch(async (err: Error) => {
project.status = "error";
project.error = err.message;
project.updated_at = new Date().toISOString();
await saveProject(project);
app.log.error({ projectId, err: err.message }, "Project provisioning failed");
});
return { project_id: projectId, status: "provisioning", slug };
});
/**
* List apps within a project
*/
app.get<{ Params: { project_id: string } }>("/projects/:project_id/apps", async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
return { apps: project.apps };
});
/**
* Deploy a specific app within the project
*/
app.post<{ Params: { project_id: string }; Body: DeployAppBody }>(
"/projects/:project_id/deploy",
async (req) => {
await requireAuth(req);
const project = await getProject((req.params as any).project_id);
if (!project) return app.httpErrors.notFound("Project not found");
const { app_name } = req.body;
const targetApp = project.apps.find(a => a.name === app_name);
if (!targetApp) return app.httpErrors.notFound(`App "${app_name}" not found in project`);
if (!targetApp.coolifyServiceUuid) {
return app.httpErrors.badRequest(`App "${app_name}" has no Coolify service yet`);
}
const deploymentUuid = await coolify.triggerDeploy(targetApp.coolifyServiceUuid);
return { deployment_uuid: deploymentUuid, app: app_name, status: "deploying" };
}
);
}
/**
* Full provisioning flow — runs after the route returns
*/
async function provisionProject(project: ProjectRecord, giteaOwner: string): Promise<void> {
const repoName = project.slug;
// 1. Create Gitea repo
const repoUrl = await gitea.createRepo(giteaOwner, repoName, `${project.name} monorepo`);
project.repo = repoUrl;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 2. Push Turborepo scaffold
await gitea.scaffoldRepo(giteaOwner, repoName, project.slug, project.name);
// 3. Register webhook
const webhookUrl = `${config.platformUrl}/webhooks/gitea`;
await gitea.registerWebhook(giteaOwner, repoName, webhookUrl);
// 4. Create Coolify project
const coolifyProjectUuid = await coolify.createProject(
project.name,
`Coolify project for ${project.name}`
);
project.coolifyProjectUuid = coolifyProjectUuid;
project.updated_at = new Date().toISOString();
await saveProject(project);
// 5. Create a Coolify service per app
for (const projectApp of project.apps) {
const domain = appDomain(project.slug, projectApp.name, project.tenant_id);
const serviceUuid = await coolify.createAppService({
coolifyProjectUuid,
appName: projectApp.name,
repoUrl,
domain,
});
const updatedApp: AppRecord = {
...projectApp,
coolifyServiceUuid: serviceUuid,
domain,
};
await updateProjectApp(project.project_id, updatedApp);
}
project.status = "active";
project.updated_at = new Date().toISOString();
await saveProject(project);
}

View File

@@ -15,9 +15,19 @@ if (useMemory) {
console.log(`☁️ Using GCP storage (project: ${config.projectId})`); 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;

View File

@@ -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[] = [

View File

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

View File

@@ -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,
}; };
}); });

View 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

View 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:*"
```

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@{{project-slug}}/ui",
"@{{project-slug}}/tokens",
],
};
export default nextConfig;

View 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"
}
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@{{project-slug}}/ui",
"@{{project-slug}}/tokens",
],
};
export default nextConfig;

View File

@@ -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"
}
}

View File

@@ -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"]
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@{{project-slug}}/config/tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "storybook-static"]
}

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@{{project-slug}}/ui",
"@{{project-slug}}/tokens",
],
};
export default nextConfig;

View File

@@ -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"
}
}

View File

@@ -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"]
}

View 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/*"
]
}

View File

@@ -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",
},
}
);

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"
}
}

View File

@@ -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;
};

View File

@@ -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"
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
/* Import design tokens — include this once at your app root */
@import "@{{project-slug}}/tokens/css";