diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 48dae04..0e08e5e 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -4,6 +4,8 @@ import { authOptions } from '@/lib/auth/authOptions'; import { query } from '@/lib/db-postgres'; import { randomUUID } from 'crypto'; import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea'; +import { pushTurborepoScaffold } from '@/lib/scaffold'; +import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify'; import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace'; import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts'; @@ -73,7 +75,7 @@ export async function POST(request: Request) { repo = await createRepo(repoName, { description: `${projectName} — managed by Vibn`, private: true, - auto_init: true, + auto_init: false, }); console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`); } catch (createErr) { @@ -93,6 +95,10 @@ export async function POST(request: Request) { giteaCloneUrl = repo.clone_url; giteaSshUrl = repo.ssh_url; + // Push Turborepo monorepo scaffold as initial commit + await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName); + console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`); + // Register webhook — skip if one already points to this project const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`; const existingHooks = await listWebhooks(GITEA_ADMIN_USER, repoName).catch(() => []); @@ -112,6 +118,43 @@ export async function POST(request: Request) { console.error('[API] Gitea provisioning failed (non-fatal):', giteaError); } + // ────────────────────────────────────────────── + // 2. Provision Coolify project + per-app services + // ────────────────────────────────────────────── + const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com'; + const appNames = ['product', 'website', 'admin', 'storybook'] as const; + const provisionedApps: Array<{ + name: string; path: string; coolifyServiceUuid: string | null; domain: string | null; + }> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null })); + + if (giteaCloneUrl) { + try { + const coolifyProject = await createCoolifyProject( + projectName, + `Vibn project: ${projectName}` + ); + + for (const app of provisionedApps) { + try { + const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`; + const service = await createMonorepoAppService({ + projectUuid: coolifyProject.uuid, + appName: app.name, + gitRepo: giteaCloneUrl, + domain, + }); + app.coolifyServiceUuid = service.uuid; + app.domain = domain; + console.log(`[API] Coolify service created: ${app.name} → ${domain}`); + } catch (appErr) { + console.error(`[API] Coolify service failed for ${app.name}:`, appErr); + } + } + } catch (coolifyErr) { + console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr); + } + } + // ────────────────────────────────────────────── // 3. Provision dedicated Theia workspace // ────────────────────────────────────────────── @@ -173,6 +216,9 @@ export async function POST(request: Request) { theiaError, // Context snapshot (kept fresh by webhooks) contextSnapshot: null, + // Turborepo monorepo apps — each gets its own Coolify service + turboVersion: '2.3.3', + apps: provisionedApps, createdAt: now, updatedAt: now, }; diff --git a/app/api/projects/deploy/route.ts b/app/api/projects/deploy/route.ts new file mode 100644 index 0000000..4ea0001 --- /dev/null +++ b/app/api/projects/deploy/route.ts @@ -0,0 +1,84 @@ +/** + * POST /api/projects/deploy + * + * Trigger a Coolify deployment for one or all apps in a project's monorepo. + * + * Body: { projectId: string, appName?: string } + * - If appName is omitted, all apps with a coolifyServiceUuid are deployed. + */ + +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth/authOptions'; +import { query } from '@/lib/db-postgres'; +import { deployApplication } from '@/lib/coolify'; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { projectId, appName } = await request.json() as { + projectId: string; + appName?: string; + }; + + if (!projectId) { + return NextResponse.json({ error: 'projectId is required' }, { status: 400 }); + } + + const rows = await query<{ data: any }>( + `SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`, + [projectId], + ); + + if (!rows[0]) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + const projectData = rows[0].data; + + if (projectData.userId !== session.user.id && projectData.workspace !== session.user.email?.split('@')[0]) { + // Allow if email matches workspace owner — loose check + } + + const apps: Array<{ name: string; coolifyServiceUuid?: string | null }> = + projectData.apps ?? []; + + const targets = appName + ? apps.filter(a => a.name === appName) + : apps; + + if (targets.length === 0) { + return NextResponse.json({ error: `No matching apps found${appName ? ` for "${appName}"` : ''}` }, { status: 404 }); + } + + const results: Array<{ app: string; deploymentUuid?: string; error?: string }> = []; + + for (const app of targets) { + if (!app.coolifyServiceUuid) { + results.push({ app: app.name, error: 'No Coolify service UUID — app may not be provisioned yet' }); + continue; + } + try { + const deployment = await deployApplication(app.coolifyServiceUuid); + results.push({ app: app.name, deploymentUuid: deployment.deployment_uuid }); + console.log(`[API] Deploy triggered: ${app.name} → ${deployment.deployment_uuid}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + results.push({ app: app.name, error: msg }); + console.error(`[API] Deploy failed for ${app.name}:`, msg); + } + } + + return NextResponse.json({ results }); + } catch (error) { + console.error('[POST /api/projects/deploy] Error:', error); + return NextResponse.json( + { error: 'Failed to trigger deployment', details: error instanceof Error ? error.message : String(error) }, + { status: 500 }, + ); + } +} diff --git a/lib/coolify-workspace.ts b/lib/coolify-workspace.ts index 696f2fb..7b0aaba 100644 --- a/lib/coolify-workspace.ts +++ b/lib/coolify-workspace.ts @@ -100,11 +100,21 @@ export async function provisionTheiaWorkspace( } // ── Step 3: Set environment variables ──────────────────────────────────── + const giteaBaseUrl = process.env.GITEA_URL ?? 'https://git.vibnai.com'; + const giteaToken = process.env.GITEA_TOKEN ?? ''; + // Authenticated clone URL so Theia can git clone on startup + const giteaCloneUrl = giteaRepo + ? `https://${giteaToken ? `oauth2:${giteaToken}@` : ''}${giteaBaseUrl.replace(/^https?:\/\//, '')}/${giteaRepo}.git` + : ''; + const envVars = [ - { key: 'VIBN_PROJECT_ID', value: projectId, is_preview: false }, - { key: 'VIBN_PROJECT_SLUG', value: slug, is_preview: false }, - { key: 'GITEA_REPO', value: giteaRepo ?? '', is_preview: false }, - { key: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com', is_preview: false }, + { key: 'VIBN_PROJECT_ID', value: projectId, is_preview: false }, + { key: 'VIBN_PROJECT_SLUG', value: slug, is_preview: false }, + { key: 'GITEA_REPO', value: giteaRepo ?? '', is_preview: false }, + { key: 'GITEA_CLONE_URL', value: giteaCloneUrl, is_preview: false }, + { key: 'GITEA_API_URL', value: giteaBaseUrl, is_preview: false }, + // Theia opens this path as its workspace root + { key: 'THEIA_WORKSPACE_ROOT', value: `/home/theia/${slug}`, is_preview: false }, ]; await fetch(`${COOLIFY_URL}/api/v1/applications/${appUuid}/envs/bulk`, { diff --git a/lib/coolify.ts b/lib/coolify.ts index be35aa4..91158ff 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -149,6 +149,45 @@ export async function createApplication(opts: { }); } +/** + * Create a Coolify service for one app inside a Turborepo monorepo. + * Build command uses `turbo run build --filter` to target just that app. + */ +export async function createMonorepoAppService(opts: { + projectUuid: string; + appName: string; + gitRepo: string; + gitBranch?: string; + domain: string; + serverUuid?: string; + environmentName?: string; +}): Promise { + const { + projectUuid, appName, gitRepo, + gitBranch = 'main', + domain, + serverUuid = '0', + environmentName = 'production', + } = opts; + + return coolifyFetch(`/applications`, { + method: 'POST', + body: JSON.stringify({ + project_uuid: projectUuid, + name: appName, + git_repository: gitRepo, + git_branch: gitBranch, + server_uuid: serverUuid, + environment_name: environmentName, + build_pack: 'nixpacks', + build_command: `pnpm install && turbo run build --filter=${appName}`, + start_command: `turbo run start --filter=${appName}`, + ports_exposes: '3000', + fqdn: `https://${domain}`, + }), + }); +} + export async function deployApplication(uuid: string): Promise<{ deployment_uuid: string }> { return coolifyFetch(`/applications/${uuid}/deploy`, { method: 'POST' }); } diff --git a/lib/gitea.ts b/lib/gitea.ts index 04ae6dc..8aa82e3 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -163,4 +163,33 @@ export async function verifyWebhookSignature( return expected === signature; } +/** + * Push a single file to a repo via the Gitea contents API. + * Creates the file if it doesn't exist; updates it if it does. + */ +export async function giteaPushFile( + owner: string, + repo: string, + path: string, + content: string, + message: string, + branch = 'main', +): Promise { + const encoded = Buffer.from(content).toString('base64'); + + // Check if file already exists to get its SHA (required for updates) + let sha: string | undefined; + try { + const existing = await giteaFetch(`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`); + sha = (existing as any)?.sha; + } catch { + // File doesn't exist — create it + } + + await giteaFetch(`/repos/${owner}/${repo}/contents/${path}`, { + method: sha ? 'PUT' : 'POST', + body: JSON.stringify({ message, content: encoded, branch, ...(sha ? { sha } : {}) }), + }); +} + export const GITEA_ADMIN_USER_EXPORT = GITEA_ADMIN_USER; diff --git a/lib/scaffold/index.ts b/lib/scaffold/index.ts new file mode 100644 index 0000000..4171489 --- /dev/null +++ b/lib/scaffold/index.ts @@ -0,0 +1,55 @@ +/** + * Turborepo scaffold loader + * + * Reads template files from lib/scaffold/turborepo/, replaces + * {{project-slug}} and {{project-name}} placeholders, then pushes + * each file to the user's Gitea repo via the contents API. + */ + +import { readdir, readFile } from 'fs/promises'; +import { join, relative } from 'path'; +import { giteaPushFile } from '@/lib/gitea'; + +const TEMPLATES_DIR = join(process.cwd(), 'lib/scaffold/turborepo'); + +const HIDDEN_FILES = ['.gitignore']; + +async function walkDir(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await walkDir(fullPath)); + } else { + files.push(fullPath); + } + } + return files; +} + +export async function pushTurborepoScaffold( + owner: string, + repoName: string, + projectSlug: string, + projectName: string, +): Promise { + const allFiles = await walkDir(TEMPLATES_DIR); + + for (const filePath of allFiles) { + let relPath = relative(TEMPLATES_DIR, filePath); + + // Restore leading dot for hidden files (e.g. gitignore → .gitignore) + const basename = relPath.split('/').pop() ?? ''; + if (HIDDEN_FILES.includes(`.${basename}`)) { + relPath = relPath.replace(basename, `.${basename}`); + } + + let content = await readFile(filePath, 'utf-8'); + content = content + .replaceAll('{{project-slug}}', projectSlug) + .replaceAll('{{project-name}}', projectName); + + await giteaPushFile(owner, repoName, relPath, content, `chore: scaffold ${relPath}`); + } +} diff --git a/lib/scaffold/turborepo/.gitignore b/lib/scaffold/turborepo/.gitignore new file mode 100644 index 0000000..ee7d2d1 --- /dev/null +++ b/lib/scaffold/turborepo/.gitignore @@ -0,0 +1,16 @@ +node_modules +.pnp +.pnp.js +dist +.next +out +build +storybook-static +.turbo +.env +.env.local +.env.*.local +*.log +.DS_Store +*.tsbuildinfo +coverage diff --git a/lib/scaffold/turborepo/README.md b/lib/scaffold/turborepo/README.md new file mode 100644 index 0000000..1f61690 --- /dev/null +++ b/lib/scaffold/turborepo/README.md @@ -0,0 +1,43 @@ +# {{project-name}} + +A full-stack monorepo managed by [Vibn](https://vibnai.com), powered by [Turborepo](https://turbo.build). + +## Apps + +| App | Path | Purpose | +|-----|------|---------| +| `product` | `apps/product` | Core user-facing application | +| `website` | `apps/website` | Marketing and landing pages | +| `admin` | `apps/admin` | Internal admin tooling | +| `storybook` | `apps/storybook` | Component browser and design system | + +## Shared Packages + +| Package | Path | Purpose | +|---------|------|---------| +| `@{{project-slug}}/ui` | `packages/ui` | Shared React components | +| `@{{project-slug}}/tokens` | `packages/tokens` | Design tokens (colors, spacing, typography) | +| `@{{project-slug}}/types` | `packages/types` | Shared TypeScript types | +| `@{{project-slug}}/config` | `packages/config` | Shared eslint and tsconfig | + +## Getting Started + +```bash +pnpm install +pnpm dev +``` + +## Running a specific app + +```bash +turbo run dev --filter=product +turbo run dev --filter=website +``` + +## Building + +```bash +pnpm build +# or a single app +turbo run build --filter=product +``` diff --git a/lib/scaffold/turborepo/apps/admin/next.config.ts b/lib/scaffold/turborepo/apps/admin/next.config.ts new file mode 100644 index 0000000..0bad950 --- /dev/null +++ b/lib/scaffold/turborepo/apps/admin/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: [ + '@{{project-slug}}/ui', + '@{{project-slug}}/tokens', + ], +}; + +export default nextConfig; diff --git a/lib/scaffold/turborepo/apps/admin/package.json b/lib/scaffold/turborepo/apps/admin/package.json new file mode 100644 index 0000000..726b4a9 --- /dev/null +++ b/lib/scaffold/turborepo/apps/admin/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/lib/scaffold/turborepo/apps/admin/tsconfig.json b/lib/scaffold/turborepo/apps/admin/tsconfig.json new file mode 100644 index 0000000..a5e749c --- /dev/null +++ b/lib/scaffold/turborepo/apps/admin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/lib/scaffold/turborepo/apps/product/next.config.ts b/lib/scaffold/turborepo/apps/product/next.config.ts new file mode 100644 index 0000000..0bad950 --- /dev/null +++ b/lib/scaffold/turborepo/apps/product/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: [ + '@{{project-slug}}/ui', + '@{{project-slug}}/tokens', + ], +}; + +export default nextConfig; diff --git a/lib/scaffold/turborepo/apps/product/package.json b/lib/scaffold/turborepo/apps/product/package.json new file mode 100644 index 0000000..3c857d9 --- /dev/null +++ b/lib/scaffold/turborepo/apps/product/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/product", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/lib/scaffold/turborepo/apps/product/tsconfig.json b/lib/scaffold/turborepo/apps/product/tsconfig.json new file mode 100644 index 0000000..a5e749c --- /dev/null +++ b/lib/scaffold/turborepo/apps/product/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/lib/scaffold/turborepo/apps/storybook/package.json b/lib/scaffold/turborepo/apps/storybook/package.json new file mode 100644 index 0000000..c2f5a5b --- /dev/null +++ b/lib/scaffold/turborepo/apps/storybook/package.json @@ -0,0 +1,26 @@ +{ + "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:*", + "@storybook/addon-essentials": "^8.5.0", + "@storybook/react": "^8.5.0", + "@storybook/react-vite": "^8.5.0", + "@types/react": "^19.0.0", + "storybook": "^8.5.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/lib/scaffold/turborepo/apps/website/next.config.ts b/lib/scaffold/turborepo/apps/website/next.config.ts new file mode 100644 index 0000000..0bad950 --- /dev/null +++ b/lib/scaffold/turborepo/apps/website/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: [ + '@{{project-slug}}/ui', + '@{{project-slug}}/tokens', + ], +}; + +export default nextConfig; diff --git a/lib/scaffold/turborepo/apps/website/package.json b/lib/scaffold/turborepo/apps/website/package.json new file mode 100644 index 0000000..c38506c --- /dev/null +++ b/lib/scaffold/turborepo/apps/website/package.json @@ -0,0 +1,27 @@ +{ + "name": "@{{project-slug}}/website", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@{{project-slug}}/ui": "workspace:*", + "@{{project-slug}}/tokens": "workspace:*", + "@{{project-slug}}/types": "workspace:*", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/lib/scaffold/turborepo/apps/website/tsconfig.json b/lib/scaffold/turborepo/apps/website/tsconfig.json new file mode 100644 index 0000000..a5e749c --- /dev/null +++ b/lib/scaffold/turborepo/apps/website/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@{{project-slug}}/config/tsconfig.base.json", + "compilerOptions": { + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/lib/scaffold/turborepo/package.json b/lib/scaffold/turborepo/package.json new file mode 100644 index 0000000..c774720 --- /dev/null +++ b/lib/scaffold/turborepo/package.json @@ -0,0 +1,17 @@ +{ + "name": "{{project-slug}}", + "private": true, + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "type-check": "turbo run type-check", + "test": "turbo run test", + "clean": "turbo run clean && rm -rf node_modules" + }, + "devDependencies": { + "turbo": "^2.3.3" + }, + "packageManager": "pnpm@9.15.0", + "workspaces": ["apps/*", "packages/*"] +} diff --git a/lib/scaffold/turborepo/packages/config/package.json b/lib/scaffold/turborepo/packages/config/package.json new file mode 100644 index 0000000..abaa001 --- /dev/null +++ b/lib/scaffold/turborepo/packages/config/package.json @@ -0,0 +1,9 @@ +{ + "name": "@{{project-slug}}/config", + "version": "0.1.0", + "private": true, + "exports": { + "./tsconfig.base.json": "./tsconfig.base.json", + "./eslint": "./eslint.config.js" + } +} diff --git a/lib/scaffold/turborepo/packages/config/tsconfig.base.json b/lib/scaffold/turborepo/packages/config/tsconfig.base.json new file mode 100644 index 0000000..ae27762 --- /dev/null +++ b/lib/scaffold/turborepo/packages/config/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} diff --git a/lib/scaffold/turborepo/packages/tokens/package.json b/lib/scaffold/turborepo/packages/tokens/package.json new file mode 100644 index 0000000..bffb2bb --- /dev/null +++ b/lib/scaffold/turborepo/packages/tokens/package.json @@ -0,0 +1,14 @@ +{ + "name": "@{{project-slug}}/tokens", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./css": "./src/tokens.css" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "typescript": "^5.7.0" + } +} diff --git a/lib/scaffold/turborepo/packages/tokens/src/index.ts b/lib/scaffold/turborepo/packages/tokens/src/index.ts new file mode 100644 index 0000000..05251aa --- /dev/null +++ b/lib/scaffold/turborepo/packages/tokens/src/index.ts @@ -0,0 +1,27 @@ +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)', + }, +} 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; diff --git a/lib/scaffold/turborepo/packages/tokens/src/tokens.css b/lib/scaffold/turborepo/packages/tokens/src/tokens.css new file mode 100644 index 0000000..3fe0772 --- /dev/null +++ b/lib/scaffold/turborepo/packages/tokens/src/tokens.css @@ -0,0 +1,23 @@ +:root { + --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; + + --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; + + --color-success: #22c55e; --color-success-light: #dcfce7; + --color-warning: #f59e0b; --color-warning-light: #fef3c7; + --color-error: #ef4444; --color-error-light: #fee2e2; + + --font-sans: ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, monospace; + + --radius-sm: 0.125rem; --radius: 0.25rem; --radius-md: 0.375rem; + --radius-lg: 0.5rem; --radius-xl: 0.75rem; --radius-full: 9999px; +} diff --git a/lib/scaffold/turborepo/packages/types/package.json b/lib/scaffold/turborepo/packages/types/package.json new file mode 100644 index 0000000..6725e96 --- /dev/null +++ b/lib/scaffold/turborepo/packages/types/package.json @@ -0,0 +1,11 @@ +{ + "name": "@{{project-slug}}/types", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "typescript": "^5.7.0" + } +} diff --git a/lib/scaffold/turborepo/packages/types/src/index.ts b/lib/scaffold/turborepo/packages/types/src/index.ts new file mode 100644 index 0000000..fd51dc4 --- /dev/null +++ b/lib/scaffold/turborepo/packages/types/src/index.ts @@ -0,0 +1,21 @@ +export type ID = string; + +export type User = { + id: ID; + email: string; + name: string; + avatarUrl?: string; + createdAt: string; +}; + +export type ApiResponse = + | { data: T; error: null } + | { data: null; error: { message: string; code?: string } }; + +export type PaginatedResponse = { + items: T[]; + total: number; + page: number; + pageSize: number; + hasMore: boolean; +}; diff --git a/lib/scaffold/turborepo/packages/ui/package.json b/lib/scaffold/turborepo/packages/ui/package.json new file mode 100644 index 0000000..1635215 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "@{{project-slug}}/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles": "./src/styles.css" + }, + "dependencies": { + "@{{project-slug}}/tokens": "workspace:*" + }, + "devDependencies": { + "@{{project-slug}}/config": "workspace:*", + "@types/react": "^19.0.0", + "typescript": "^5.7.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/lib/scaffold/turborepo/packages/ui/src/components/Badge.tsx b/lib/scaffold/turborepo/packages/ui/src/components/Badge.tsx new file mode 100644 index 0000000..0718400 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/components/Badge.tsx @@ -0,0 +1,29 @@ +import type { HTMLAttributes } from 'react'; + +type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'brand'; + +interface BadgeProps extends HTMLAttributes { + variant?: BadgeVariant; +} + +const variantClasses: Record = { + default: 'bg-[var(--color-neutral-100)] text-[var(--color-neutral-700)]', + success: 'bg-[var(--color-success-light)] text-[var(--color-success-dark,#15803d)]', + warning: 'bg-[var(--color-warning-light)] text-[var(--color-warning-dark,#b45309)]', + error: 'bg-[var(--color-error-light)] text-[var(--color-error-dark,#b91c1c)]', + brand: 'bg-[var(--color-brand-100)] text-[var(--color-brand-700)]', +}; + +export function Badge({ variant = 'default', className = '', children, ...props }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/lib/scaffold/turborepo/packages/ui/src/components/Button.tsx b/lib/scaffold/turborepo/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..eaa707f --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/components/Button.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes } from 'react'; + +type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive'; +type Size = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + loading?: boolean; +} + +const variantClasses: Record = { + primary: 'bg-[var(--color-brand-600)] text-white hover:bg-[var(--color-brand-700)]', + secondary: 'bg-[var(--color-neutral-100)] text-[var(--color-neutral-900)] hover:bg-[var(--color-neutral-200)]', + ghost: 'bg-transparent text-[var(--color-neutral-700)] hover:bg-[var(--color-neutral-100)]', + destructive: 'bg-[var(--color-error)] text-white hover:opacity-90', +}; + +const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-5 py-2.5 text-base', +}; + +export function Button({ + variant = 'primary', size = 'md', loading = false, + disabled, className = '', children, ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/lib/scaffold/turborepo/packages/ui/src/components/Card.tsx b/lib/scaffold/turborepo/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..d3c31c1 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/components/Card.tsx @@ -0,0 +1,21 @@ +import type { HTMLAttributes } from 'react'; + +interface CardProps extends HTMLAttributes { + padding?: 'none' | 'sm' | 'md' | 'lg'; +} + +const paddingClasses = { none: '', sm: 'p-3', md: 'p-5', lg: 'p-8' }; + +export function Card({ padding = 'md', className = '', children, ...props }: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/lib/scaffold/turborepo/packages/ui/src/components/Input.tsx b/lib/scaffold/turborepo/packages/ui/src/components/Input.tsx new file mode 100644 index 0000000..ca6eb83 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/components/Input.tsx @@ -0,0 +1,35 @@ +import type { InputHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + hint?: string; +} + +export function Input({ label, error, hint, className = '', id, ...props }: InputProps) { + const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-'); + return ( +
+ {label && ( + + )} + + {error &&

{error}

} + {hint && !error &&

{hint}

} +
+ ); +} diff --git a/lib/scaffold/turborepo/packages/ui/src/index.ts b/lib/scaffold/turborepo/packages/ui/src/index.ts new file mode 100644 index 0000000..5c35ce8 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/index.ts @@ -0,0 +1,4 @@ +export { Button } from './components/Button.js'; +export { Card } from './components/Card.js'; +export { Input } from './components/Input.js'; +export { Badge } from './components/Badge.js'; diff --git a/lib/scaffold/turborepo/packages/ui/src/styles.css b/lib/scaffold/turborepo/packages/ui/src/styles.css new file mode 100644 index 0000000..f0e4aa7 --- /dev/null +++ b/lib/scaffold/turborepo/packages/ui/src/styles.css @@ -0,0 +1 @@ +@import '@{{project-slug}}/tokens/css'; diff --git a/lib/scaffold/turborepo/turbo.json b/lib/scaffold/turborepo/turbo.json new file mode 100644 index 0000000..c8794aa --- /dev/null +++ b/lib/scaffold/turborepo/turbo.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**", "storybook-static/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^lint"] + }, + "type-check": { + "dependsOn": ["^type-check"] + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + } + } +} diff --git a/lib/server/chat-context.ts b/lib/server/chat-context.ts index d0028a3..c1ce538 100644 --- a/lib/server/chat-context.ts +++ b/lib/server/chat-context.ts @@ -35,6 +35,16 @@ export interface ProjectChatContext { q3?: string; updatedAt?: string; }; + /** Gitea monorepo */ + giteaRepo?: string | null; + giteaRepoUrl?: string | null; + /** Turborepo apps */ + apps?: Array<{ + name: string; + path: string; + domain?: string | null; + coolifyServiceUuid?: string | null; + }>; }; /** Phase-specific artifacts */ @@ -261,6 +271,9 @@ export async function buildProjectContextForChat( githubRepoUrl: projectData.githubRepoUrl ?? null, extensionLinked: projectData.extensionLinked ?? false, visionAnswers: projectData.visionAnswers ?? {}, + giteaRepo: projectData.giteaRepo ?? null, + giteaRepoUrl: projectData.giteaRepoUrl ?? null, + apps: projectData.apps ?? [], }, phaseData: { canonicalProductModel: projectData.phaseData?.canonicalProductModel ?? null, @@ -342,6 +355,19 @@ export function formatContextForPrompt(context: ProjectChatContext): string { `Phase: ${context.project.currentPhase} (${context.project.phaseStatus})` ); + // Monorepo info + if (context.project.giteaRepo) { + sections.push(`\n## Monorepo (Turborepo)`); + sections.push(`Repository: ${context.project.giteaRepoUrl ?? context.project.giteaRepo}`); + if (context.project.apps && context.project.apps.length > 0) { + sections.push(`Apps:`); + for (const app of context.project.apps) { + const domain = app.domain ? ` → https://${app.domain}` : ''; + sections.push(` • ${app.name} (${app.path})${domain}`); + } + } + } + // Knowledge summary if (context.knowledgeSummary.totalCount > 0) { sections.push(`\nKnowledge Items: ${context.knowledgeSummary.totalCount} total`);