feat: turborepo monorepo scaffold and provisioning

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-21 16:44:30 -08:00
parent e22f5e379f
commit 8587644a62
35 changed files with 841 additions and 5 deletions

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

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