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:
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
||||
|
||||
/** @type {import("typescript-eslint").Config} */
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
"react-hooks": reactHooksPlugin,
|
||||
},
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@{{project-slug}}/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./tsconfig.base.json": "./tsconfig.base.json",
|
||||
"./eslint": "./eslint.config.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@{{project-slug}}/tokens",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./css": "./src/tokens.css"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@{{project-slug}}/config": "workspace:*",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export const colors = {
|
||||
brand: {
|
||||
50: "#f0f9ff",
|
||||
100: "#e0f2fe",
|
||||
200: "#bae6fd",
|
||||
300: "#7dd3fc",
|
||||
400: "#38bdf8",
|
||||
500: "#0ea5e9",
|
||||
600: "#0284c7",
|
||||
700: "#0369a1",
|
||||
800: "#075985",
|
||||
900: "#0c4a6e",
|
||||
},
|
||||
neutral: {
|
||||
50: "#fafafa",
|
||||
100: "#f4f4f5",
|
||||
200: "#e4e4e7",
|
||||
300: "#d4d4d8",
|
||||
400: "#a1a1aa",
|
||||
500: "#71717a",
|
||||
600: "#52525b",
|
||||
700: "#3f3f46",
|
||||
800: "#27272a",
|
||||
900: "#18181b",
|
||||
},
|
||||
success: { DEFAULT: "#22c55e", light: "#dcfce7", dark: "#15803d" },
|
||||
warning: { DEFAULT: "#f59e0b", light: "#fef3c7", dark: "#b45309" },
|
||||
error: { DEFAULT: "#ef4444", light: "#fee2e2", dark: "#b91c1c" },
|
||||
} as const;
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: "var(--font-sans, ui-sans-serif, system-ui, sans-serif)",
|
||||
mono: "var(--font-mono, ui-monospace, monospace)",
|
||||
},
|
||||
fontSize: {
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl":["1.5rem", { lineHeight: "2rem" }],
|
||||
"3xl":["1.875rem", { lineHeight: "2.25rem" }],
|
||||
"4xl":["2.25rem", { lineHeight: "2.5rem" }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
px: "1px",
|
||||
0: "0",
|
||||
1: "0.25rem",
|
||||
2: "0.5rem",
|
||||
3: "0.75rem",
|
||||
4: "1rem",
|
||||
5: "1.25rem",
|
||||
6: "1.5rem",
|
||||
8: "2rem",
|
||||
10: "2.5rem",
|
||||
12: "3rem",
|
||||
16: "4rem",
|
||||
20: "5rem",
|
||||
24: "6rem",
|
||||
32: "8rem",
|
||||
} as const;
|
||||
|
||||
export const radius = {
|
||||
none: "0",
|
||||
sm: "0.125rem",
|
||||
DEFAULT: "0.25rem",
|
||||
md: "0.375rem",
|
||||
lg: "0.5rem",
|
||||
xl: "0.75rem",
|
||||
"2xl":"1rem",
|
||||
full: "9999px",
|
||||
} as const;
|
||||
|
||||
export const shadows = {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
DEFAULT: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
|
||||
none:"none",
|
||||
} as const;
|
||||
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
/* Brand */
|
||||
--color-brand-50: #f0f9ff;
|
||||
--color-brand-100: #e0f2fe;
|
||||
--color-brand-200: #bae6fd;
|
||||
--color-brand-300: #7dd3fc;
|
||||
--color-brand-400: #38bdf8;
|
||||
--color-brand-500: #0ea5e9;
|
||||
--color-brand-600: #0284c7;
|
||||
--color-brand-700: #0369a1;
|
||||
--color-brand-800: #075985;
|
||||
--color-brand-900: #0c4a6e;
|
||||
|
||||
/* Neutral */
|
||||
--color-neutral-50: #fafafa;
|
||||
--color-neutral-100: #f4f4f5;
|
||||
--color-neutral-200: #e4e4e7;
|
||||
--color-neutral-300: #d4d4d8;
|
||||
--color-neutral-400: #a1a1aa;
|
||||
--color-neutral-500: #71717a;
|
||||
--color-neutral-600: #52525b;
|
||||
--color-neutral-700: #3f3f46;
|
||||
--color-neutral-800: #27272a;
|
||||
--color-neutral-900: #18181b;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #22c55e;
|
||||
--color-success-light: #dcfce7;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fef3c7;
|
||||
--color-error: #ef4444;
|
||||
--color-error-light: #fee2e2;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@{{project-slug}}/types",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@{{project-slug}}/config": "workspace:*",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Shared types for {{project-name}}
|
||||
*
|
||||
* Add types here that are used across product, website, and admin.
|
||||
* Import in any app: import type { User } from "@{{project-slug}}/types"
|
||||
*/
|
||||
|
||||
export type ID = string;
|
||||
|
||||
export type User = {
|
||||
id: ID;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
data: T;
|
||||
error: null;
|
||||
} | {
|
||||
data: null;
|
||||
error: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PaginatedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@{{project-slug}}/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./styles": "./src/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@{{project-slug}}/tokens": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@{{project-slug}}/config": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
type BadgeVariant = "default" | "success" | "warning" | "error" | "brand";
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: "bg-[var(--color-neutral-100)] text-[var(--color-neutral-700)]",
|
||||
success: "bg-[var(--color-success-light)] text-[var(--color-success-dark,#15803d)]",
|
||||
warning: "bg-[var(--color-warning-light)] text-[var(--color-warning-dark,#b45309)]",
|
||||
error: "bg-[var(--color-error-light)] text-[var(--color-error-dark,#b91c1c)]",
|
||||
brand: "bg-[var(--color-brand-100)] text-[var(--color-brand-700)]",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "default", className = "", children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={[
|
||||
"inline-flex items-center rounded-[var(--radius-full)] px-2.5 py-0.5 text-xs font-medium",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "destructive";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary: "bg-[var(--color-brand-600)] text-white hover:bg-[var(--color-brand-700)]",
|
||||
secondary: "bg-[var(--color-neutral-100)] text-[var(--color-neutral-900)] hover:bg-[var(--color-neutral-200)]",
|
||||
ghost: "bg-transparent text-[var(--color-neutral-700)] hover:bg-[var(--color-neutral-100)]",
|
||||
destructive: "bg-[var(--color-error)] text-white hover:opacity-90",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-5 py-2.5 text-base",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
loading = false,
|
||||
disabled,
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={disabled ?? loading}
|
||||
className={[
|
||||
"inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] font-medium transition-colors",
|
||||
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const paddingClasses = {
|
||||
none: "",
|
||||
sm: "p-3",
|
||||
md: "p-5",
|
||||
lg: "p-8",
|
||||
};
|
||||
|
||||
export function Card({ padding = "md", className = "", children, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={[
|
||||
"rounded-[var(--radius-xl)] border border-[var(--color-neutral-200)] bg-white shadow-[var(--shadow-sm,0_1px_2px_0_rgb(0_0_0/0.05))]",
|
||||
paddingClasses[padding],
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, hint, className = "", id, ...props }: InputProps) {
|
||||
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-sm font-medium text-[var(--color-neutral-700)]">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
{...props}
|
||||
id={inputId}
|
||||
className={[
|
||||
"w-full rounded-[var(--radius-md)] border px-3 py-2 text-sm transition-colors",
|
||||
"placeholder:text-[var(--color-neutral-400)]",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)] focus:border-transparent",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
error
|
||||
? "border-[var(--color-error)] bg-[var(--color-error-light)]"
|
||||
: "border-[var(--color-neutral-300)] bg-white",
|
||||
className,
|
||||
].join(" ")}
|
||||
/>
|
||||
{error && <p className="text-xs text-[var(--color-error)]">{error}</p>}
|
||||
{hint && !error && <p className="text-xs text-[var(--color-neutral-500)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Button } from "./components/Button.js";
|
||||
export { Card } from "./components/Card.js";
|
||||
export { Input } from "./components/Input.js";
|
||||
export { Badge } from "./components/Badge.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
/* Import design tokens — include this once at your app root */
|
||||
@import "@{{project-slug}}/tokens/css";
|
||||
Reference in New Issue
Block a user