Compare commits

...

10 Commits

Author SHA1 Message Date
8e6406232d fix: pass GOOGLE_API_KEY to Cloud Run workspace services
Without this, Theia's startup script could not configure Gemini AI
features or write the correct settings.json (dark theme, API key).
New workspaces now receive GOOGLE_API_KEY from the vibn-frontend env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 11:50:55 -08:00
6eaa6d64ac feat: add Code OS project-info prompt template to scaffold
Adds .prompts/project-info.prompttemplate to the Turborepo scaffold so
every new user project gets a customized context file loaded by the Code
OS agent — including build commands, workspace structure, and shared
package import paths with the project's actual slug injected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 17:27:34 -08:00
91f579dbc5 fix: add pnpm-workspace.yaml, .node-version, remove npm workspaces from scaffold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 16:55:49 -08:00
8587644a62 feat: turborepo monorepo scaffold and provisioning
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 16:44:37 -08:00
e22f5e379f chore: remove test file 2026-02-22 00:36:35 +00:00
1154592ab8 test: add test file 2026-02-22 00:35:43 +00:00
b42edbe681 fix: handle Cloud Run 409 by linking to existing service; show provisioning spinner not failure
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 17:31:01 -08:00
f4ab70822c fix: handle Gitea 409 on project create by linking to existing repo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 17:16:24 -08:00
aeedc76a18 fix: correct authOptions import path in prewarm route
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 16:26:43 -08:00
1ff58049c0 feat: pass GITEA_TOKEN to IDE containers + prewarm on project list load
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 16:13:09 -08:00
42 changed files with 1005 additions and 18 deletions

View File

@@ -126,8 +126,22 @@ export default function ProjectsPage() {
throw new Error(err.error || "Failed to fetch projects");
}
const data = await res.json();
setProjects(data.projects || []);
const loaded: ProjectWithStats[] = data.projects || [];
setProjects(loaded);
setError(null);
// Fire-and-forget: prewarm all provisioned IDE workspaces so containers
// are already running by the time the user clicks "Open IDE"
const warmUrls = loaded
.map((p) => p.theiaWorkspaceUrl)
.filter((u): u is string => Boolean(u));
if (warmUrls.length > 0) {
fetch("/api/projects/prewarm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls: warmUrls }),
}).catch(() => {}); // ignore errors — this is best-effort
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {

View File

@@ -3,7 +3,9 @@ import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { query } from '@/lib/db-postgres';
import { randomUUID } from 'crypto';
import { createRepo, createWebhook, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
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';
@@ -67,29 +69,92 @@ export async function POST(request: Request) {
try {
const repoName = slug; // e.g. "taskmaster"
const repo = await createRepo(repoName, {
description: `${projectName} — managed by Vibn`,
private: true,
auto_init: true,
});
let repo;
try {
repo = await createRepo(repoName, {
description: `${projectName} — managed by Vibn`,
private: true,
auto_init: false,
});
console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`);
} catch (createErr) {
const msg = createErr instanceof Error ? createErr.message : String(createErr);
// 409 = repo already exists — link to it instead of failing
if (msg.includes('409')) {
console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`);
repo = await getRepo(GITEA_ADMIN_USER, repoName);
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
} else {
throw createErr;
}
}
giteaRepo = repo.full_name; // e.g. "mark/taskmaster"
giteaRepoUrl = repo.html_url; // e.g. "https://git.vibnai.com/mark/taskmaster"
giteaCloneUrl = repo.clone_url;
giteaSshUrl = repo.ssh_url;
// 2. Register webhook on the repo pointing back to Vibn
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
giteaWebhookId = hook.id;
// Push Turborepo monorepo scaffold as initial commit
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
console.log(`[API] Gitea repo created: ${giteaRepo}, webhook: ${giteaWebhookId}`);
// 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(() => []);
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
if (!alreadyHooked) {
const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
giteaWebhookId = hook.id;
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
} else {
giteaWebhookId = existingHooks.find(h => h.config.url.includes(projectId))?.id ?? null;
console.log(`[API] Webhook already exists for ${giteaRepo}`);
}
} catch (err) {
// Non-fatal — log and continue. Project is still created.
giteaError = err instanceof Error ? err.message : String(err);
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
// ──────────────────────────────────────────────
@@ -151,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,
};

View File

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

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth/authOptions';
import { prewarmWorkspace } from '@/lib/cloud-run-workspace';
/**
* POST /api/projects/prewarm
* Body: { urls: string[] }
*
* Fires warm-up requests to Cloud Run workspace URLs so containers
* are running by the time the user clicks "Open IDE". Server-side
* to avoid CORS issues with run.app domains.
*/
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { urls } = await req.json() as { urls: string[] };
if (!Array.isArray(urls) || urls.length === 0) {
return NextResponse.json({ warmed: 0 });
}
// Fire all prewarm pings in parallel — intentionally not awaited
Promise.allSettled(urls.map(url => prewarmWorkspace(url))).catch(() => {});
return NextResponse.json({ warmed: urls.length });
}

View File

@@ -329,9 +329,11 @@ export function ProjectCreationModal({
{createdTheiaUrl ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<XCircle className="h-3.5 w-3.5 text-yellow-500 shrink-0" />
<Loader2 className="h-3.5 w-3.5 text-blue-400 shrink-0 animate-spin" />
)}
Dedicated IDE workspace{createdTheiaUrl ? ` at ${createdTheiaUrl.replace('https://', '')}` : ' — provisioning failed'}
Dedicated IDE workspace{createdTheiaUrl
? ` at ${createdTheiaUrl.replace('https://', '')}`
: ' — provisioning in background (ready in ~30s)'}
</li>
</ul>
</div>

View File

@@ -85,6 +85,10 @@ export async function provisionTheiaWorkspace(
{ name: 'VIBN_API_URL', value: VIBN_URL },
{ name: 'GITEA_REPO', value: giteaRepo ?? '' },
{ name: 'GITEA_API_URL', value: process.env.GITEA_API_URL ?? 'https://git.vibnai.com' },
// Token lets the startup script clone and push to the project's repo
{ name: 'GITEA_TOKEN', value: process.env.GITEA_API_TOKEN ?? '' },
// Gemini API key — needed by startup.sh to configure AI features in Theia
{ name: 'GOOGLE_API_KEY', value: process.env.GOOGLE_API_KEY ?? '' },
],
// 5 minute startup timeout — Theia needs time to initialise
startupProbe: {
@@ -110,6 +114,16 @@ export async function provisionTheiaWorkspace(
if (!createRes.ok) {
const body = await createRes.text();
// 409 = service already exists — fetch its URL instead of failing
if (createRes.status === 409) {
console.log(`[workspace] Cloud Run service already exists: ${serviceName} — fetching existing URL`);
const serviceUrl = await waitForServiceUrl(serviceName, token);
await allowUnauthenticated(serviceName, token);
console.log(`[workspace] Linked to existing service: ${serviceName}${serviceUrl}`);
return { serviceUrl, serviceName };
}
throw new Error(`Cloud Run create service failed (${createRes.status}): ${body}`);
}

View File

@@ -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`, {

View File

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

View File

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

55
lib/scaffold/index.ts Normal file
View File

@@ -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<string[]> {
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<void> {
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}`);
}
}

16
lib/scaffold/turborepo/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1 @@
22

View File

@@ -0,0 +1,67 @@
## Project: {{project-name}}
This is a **Turborepo monorepo** project named `{{project-slug}}`, built with Next.js and pnpm workspaces.
### Workspace Structure
```
apps/
product/ ← main user-facing Next.js application
website/ ← marketing / landing site (Next.js)
admin/ ← internal admin panel (Next.js)
storybook/ ← component browser for packages/ui
packages/
ui/ ← shared React components (@{{project-slug}}/ui)
tokens/ ← design tokens — CSS variables + TypeScript (@{{project-slug}}/tokens)
types/ ← shared TypeScript interfaces and types (@{{project-slug}}/types)
config/ ← shared tsconfig and eslint config (@{{project-slug}}/config)
```
### Build Commands (always run from repo root)
| Task | Command |
|------|---------|
| Install dependencies | `pnpm install` |
| Build one app | `pnpm turbo run build --filter=product` |
| Build all apps | `pnpm turbo run build` |
| Dev one app | `pnpm turbo run dev --filter=product` |
| Dev all | `pnpm turbo run dev` |
| Lint | `pnpm turbo run lint` |
| Type-check | `pnpm turbo run type-check` |
| Add dep to app | `pnpm add <pkg> --filter=product` |
| Add dep to package | `pnpm add <pkg> --filter=@{{project-slug}}/ui` |
### Shared Package Imports
Always import workspace packages by their scoped name:
```typescript
import { Button } from '@{{project-slug}}/ui';
import type { User } from '@{{project-slug}}/types';
import '@{{project-slug}}/tokens/css'; // CSS variables
```
Reference in `package.json` dependencies:
```json
{ "@{{project-slug}}/ui": "workspace:*" }
```
### Key Rules
- **Never** use `npm install` — always `pnpm install` from the repo root
- **Never** run `next build` or `next dev` directly inside an app directory — use `turbo run` from root
- **Always** add dependencies with `--filter` to target the correct workspace
- Turbo caches build outputs in `.turbo/` — run `pnpm turbo run clean` to clear cache
- The `packages/config` package exports shared `tsconfig.base.json` and eslint config
### Apps Overview
- **product**: The primary user-facing Next.js app. Port `3000` in dev.
- **website**: Marketing site. Port `3001` in dev.
- **admin**: Admin panel. Port `3002` in dev.
- **storybook**: Visual component browser for `packages/ui`. Port `6006` in dev.
### Next.js App Router
All apps use the Next.js App Router (`app/` directory). Page files are `page.tsx`, layouts are `layout.tsx`.

View File

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

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

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

View File

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

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

View File

@@ -0,0 +1,16 @@
{
"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"
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
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,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"
}
}

View File

@@ -0,0 +1,29 @@
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,49 @@
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,21 @@
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-sm',
paddingClasses[padding], className,
].join(' ')}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,35 @@
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 @@
@import '@{{project-slug}}/tokens/css';

View File

@@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'packages/*'

View File

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

View File

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