From 5f6a207419bb9a9da60c8e195a8cf53192d3c33f Mon Sep 17 00:00:00 2001 From: "stefan.kunz" Date: Sat, 13 Jun 2026 09:45:09 +0200 Subject: [PATCH] chore: sync template with current project state Run `pnpm sync-template` to propagate the latest project files into create-agentic-app/template/, including the Resend mail / S3 storage / Docker deploy / RHF additions and the recent changes: - compose.yml (renamed from docker-compose.yml) on PostgreSQL pg17 - e2e/auth.spec.ts register -> sign-in flow and playwright.config.ts - updated env.example, README, AGENTS and related source files Also exclude test-results/ and playwright-report/ from the sync so Playwright artifacts are no longer copied into the template. Co-Authored-By: Claude Opus 4.8 (1M context) --- create-agentic-app/scripts/sync-templates.js | 4 +- .../skills/nextjs/references/self-hosting.md | 4 +- .../skills/nextjs/references/self-hosting.md | 4 +- create-agentic-app/template/.dockerignore | 23 ++ create-agentic-app/template/AGENTS.md | 17 + create-agentic-app/template/DESIGN.md | 1 + create-agentic-app/template/Dockerfile | 47 +++ create-agentic-app/template/README.md | 138 ++++++--- create-agentic-app/template/_gitignore | 2 + .../{docker-compose.yml => compose.yml} | 3 +- create-agentic-app/template/e2e/auth.spec.ts | 38 +++ create-agentic-app/template/e2e/smoke.spec.ts | 13 + create-agentic-app/template/env.example | 36 ++- create-agentic-app/template/next.config.ts | 42 ++- create-agentic-app/template/package.json | 52 ++-- .../template/playwright.config.ts | 33 ++ .../template/pnpm-workspace.yaml | 13 + .../template/src/app/api/diagnostics/route.ts | 8 +- .../components/auth/forgot-password-form.tsx | 96 +++--- .../src/components/setup-checklist.tsx | 22 +- .../template/src/components/ui/form.tsx | 167 ++++++++++ .../src/emails/reset-password-email.tsx | 110 +++++++ .../src/emails/verification-email.tsx | 109 +++++++ .../template/src/hooks/use-diagnostics.ts | 20 +- create-agentic-app/template/src/lib/auth.ts | 22 +- create-agentic-app/template/src/lib/db.ts | 2 + create-agentic-app/template/src/lib/env.ts | 32 +- .../template/src/lib/mail/index.ts | 60 ++++ .../template/src/lib/storage.ts | 291 ++++++++++++++---- 29 files changed, 1200 insertions(+), 209 deletions(-) create mode 100644 create-agentic-app/template/.dockerignore create mode 100644 create-agentic-app/template/Dockerfile rename create-agentic-app/template/{docker-compose.yml => compose.yml} (58%) create mode 100644 create-agentic-app/template/e2e/auth.spec.ts create mode 100644 create-agentic-app/template/e2e/smoke.spec.ts create mode 100644 create-agentic-app/template/playwright.config.ts create mode 100644 create-agentic-app/template/pnpm-workspace.yaml create mode 100644 create-agentic-app/template/src/components/ui/form.tsx create mode 100644 create-agentic-app/template/src/emails/reset-password-email.tsx create mode 100644 create-agentic-app/template/src/emails/verification-email.tsx create mode 100644 create-agentic-app/template/src/lib/mail/index.ts diff --git a/create-agentic-app/scripts/sync-templates.js b/create-agentic-app/scripts/sync-templates.js index 9b2b1973..179e4b03 100644 --- a/create-agentic-app/scripts/sync-templates.js +++ b/create-agentic-app/scripts/sync-templates.js @@ -35,7 +35,9 @@ const excludePatterns = [ 'yarn.lock', 'tsconfig.tsbuildinfo', '.env', - 'create-agentic-app' + 'create-agentic-app', + 'test-results', + 'playwright-report' ]; // Check if a path should be excluded diff --git a/create-agentic-app/template/.agents/skills/nextjs/references/self-hosting.md b/create-agentic-app/template/.agents/skills/nextjs/references/self-hosting.md index 4b3966ad..3d5a3885 100644 --- a/create-agentic-app/template/.agents/skills/nextjs/references/self-hosting.md +++ b/create-agentic-app/template/.agents/skills/nextjs/references/self-hosting.md @@ -24,7 +24,9 @@ This creates a minimal `standalone` folder with only production dependencies: └── static/ # Must be copied separately ``` -## Docker Deployment +## Docker (or Podman) Deployment + +> Podman is a drop-in alternative to Docker here. The same `Dockerfile` and Compose file below build and run unchanged — substitute `podman build` for `docker build` and `podman compose up` for `docker compose up`. ### Dockerfile diff --git a/create-agentic-app/template/.claude/skills/nextjs/references/self-hosting.md b/create-agentic-app/template/.claude/skills/nextjs/references/self-hosting.md index 4b3966ad..3d5a3885 100644 --- a/create-agentic-app/template/.claude/skills/nextjs/references/self-hosting.md +++ b/create-agentic-app/template/.claude/skills/nextjs/references/self-hosting.md @@ -24,7 +24,9 @@ This creates a minimal `standalone` folder with only production dependencies: └── static/ # Must be copied separately ``` -## Docker Deployment +## Docker (or Podman) Deployment + +> Podman is a drop-in alternative to Docker here. The same `Dockerfile` and Compose file below build and run unchanged — substitute `podman build` for `docker build` and `podman compose up` for `docker compose up`. ### Dockerfile diff --git a/create-agentic-app/template/.dockerignore b/create-agentic-app/template/.dockerignore new file mode 100644 index 00000000..2c39ef62 --- /dev/null +++ b/create-agentic-app/template/.dockerignore @@ -0,0 +1,23 @@ +node_modules +.next +.git +.github +.vscode +.claude +.agents +npm-debug.log* +pnpm-debug.log* +.env +.env.* +!.env.example +public/uploads +.mermaid-cache +e2e +playwright-report +test-results +specs +docs +*.md +Dockerfile +.dockerignore +docker-compose.yml diff --git a/create-agentic-app/template/AGENTS.md b/create-agentic-app/template/AGENTS.md index fb400927..c19e457a 100644 --- a/create-agentic-app/template/AGENTS.md +++ b/create-agentic-app/template/AGENTS.md @@ -29,8 +29,25 @@ - Use any testing tools, libraries available to the project for testing your changes - Never assume your changes simply work, always test! +- This project uses **Playwright** for end-to-end tests in `e2e/`. They run against your local database (`POSTGRES_URL` from `.env`) — start it with `podman compose up -d` and run `pnpm db:migrate` first, then `pnpm test:e2e`. Add a test for new user-facing flows. - If the project does not have any testing tools, scripts, MCP tools, skills, etc. available for testing, ask the user whether testing should be skipped. +## STACK + +- **Framework:** Next.js (App Router) + React 19 + TypeScript +- **Auth:** Better Auth (email/password; Drizzle adapter) +- **DB/ORM:** PostgreSQL + Drizzle ORM (postgres-js driver) +- **Email:** Resend + React Email via `src/lib/mail` (console fallback without `RESEND_API_KEY`) +- **Storage:** S3-compatible via `src/lib/storage.ts` (local filesystem fallback) +- **Forms:** React Hook Form + Zod with the shadcn `form` component +- **AI (optional):** Vercel AI SDK + OpenRouter +- Keep these provider integrations generic — do not hard-code project-specific business logic into the storage, mail, or auth libraries. + +## DEPLOYMENT + +- The app builds to a standalone server (`output: "standalone"`) with a `Dockerfile`; it targets any Docker- or Podman-compatible host (Coolify, Hetzner, VPS). The same `Dockerfile` and `compose.yml` work unchanged with `podman build` / `podman compose`. +- Do **not** run database migrations during the build. `pnpm build` is `next build` only. Run `pnpm db:migrate` as a separate release/pre-deploy step. + ## UI DESIGN - Always follow the UI design system when creating or reviewing components or pages. diff --git a/create-agentic-app/template/DESIGN.md b/create-agentic-app/template/DESIGN.md index 20a9f49b..a178b07d 100644 --- a/create-agentic-app/template/DESIGN.md +++ b/create-agentic-app/template/DESIGN.md @@ -9,6 +9,7 @@ This document defines the visual design system for the project. All new componen - **Framework:** Next.js (App Router) + React + TypeScript - **Styling:** Tailwind CSS v4 (CSS-first config via `@theme inline` in `globals.css` — no `tailwind.config.ts`) - **Components:** shadcn/ui (new-york style, neutral base) +- **Forms:** React Hook Form + Zod via the shadcn `form` component (`@/components/ui/form`) - **Icons:** Lucide React - **Fonts:** Geist (sans) + Geist Mono (mono) via `next/font/google` - **Dark mode:** next-themes (class-based, system default) diff --git a/create-agentic-app/template/Dockerfile b/create-agentic-app/template/Dockerfile new file mode 100644 index 00000000..be929db9 --- /dev/null +++ b/create-agentic-app/template/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 + +# Multi-stage build producing a slim, standalone Next.js image. +# Works on any Docker host (Coolify, Hetzner, Fly, a plain VPS, …). +# +# Database migrations are NOT run during the build (the build has no DB access). +# Run them as a separate release/pre-deploy step, e.g. `pnpm db:migrate` +# (see README "Deployment"). + +FROM node:20-alpine AS base +RUN npm install -g pnpm@9 + +# --- Dependencies --- +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# --- Build --- +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Placeholder build-time env. Override NEXT_PUBLIC_* via build args for real +# deployments — they are inlined into the client bundle at build time. +ARG NEXT_PUBLIC_APP_URL="http://localhost:3000" +ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \ + POSTGRES_URL="postgresql://user:pass@localhost:5432/db" \ + BETTER_AUTH_SECRET="build-only-placeholder-secret-32chars" +RUN pnpm build:ci + +# --- Runtime --- +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/create-agentic-app/template/README.md b/create-agentic-app/template/README.md index 474f5560..1ca07d33 100644 --- a/create-agentic-app/template/README.md +++ b/create-agentic-app/template/README.md @@ -10,9 +10,13 @@ The goal is simple: install the starter, describe the product you want to build, - **TypeScript** and a strict project setup - **Better Auth** with email/password enabled by default - **PostgreSQL and Drizzle ORM** for schema and migrations -- **AI SDK and OpenRouter** for chat and AI features +- **Resend and React Email** for transactional email (with a console fallback) +- **S3-compatible object storage** (Hetzner, MinIO, AWS, R2) with a local fallback +- **React Hook Form and Zod** for forms and validation +- **Playwright** for end-to-end tests +- **Docker / Podman / Coolify-ready** deployment (standalone output + Dockerfile) +- **AI SDK and OpenRouter** for optional chat and AI features - **shadcn/ui, Tailwind CSS, and Lucide icons** for the UI foundation -- **Local or Vercel Blob file storage** through one storage abstraction - **Agent instructions** through `AGENTS.md` and `CLAUDE.md` - **Agent skills** for specs, implementation, reviews, security scans, UI work, and shipping @@ -35,7 +39,7 @@ Then configure and run the app: ```bash cp env.example .env -docker compose up -d +podman compose up -d # or: docker compose up -d pnpm db:migrate pnpm dev ``` @@ -46,7 +50,7 @@ The CLI copies the starter files, installs dependencies with your selected packa ## Guided Setup with Claude Code (Optional) -If you use Claude Code, you can install the `create-agentic-app` skill and have Claude walk you through the entire setup — folder strategy, package manager, PostgreSQL (Docker / Neon / Vercel / BYO), `.env` config, migrations, optional integrations (OpenRouter, Vercel Blob, Polar, email), build verification, and dev-server check — ending at a verified `http://localhost:3000`. +If you use Claude Code, you can install the `create-agentic-app` skill and have Claude walk you through the entire setup — folder strategy, package manager, PostgreSQL (Docker / hosted / BYO), `.env` config, migrations, optional integrations (Resend email, S3 storage, OpenRouter), build verification, and dev-server check — ending at a verified `http://localhost:3000`. Install the skill: @@ -66,12 +70,13 @@ Claude will run the skill end-to-end and ask you the few decisions it actually n ## Prerequisites -- Node.js 18 or newer +- Node.js 20 or newer - Git -- PostgreSQL, either through the included Docker Compose file or a hosted provider +- Docker **or** Podman (for the included PostgreSQL service and for building the deployment image). For `podman compose`, also install a compose provider (`docker-compose` or `podman-compose`). - A package manager: `pnpm`, `npm`, or `yarn` +- Optional: a Resend API key for sending real transactional emails +- Optional: S3-compatible object storage credentials for remote file uploads - Optional: an OpenRouter API key for AI chat features -- Optional: a Vercel account for deployment, hosted Postgres, and Blob storage ## Environment Variables @@ -84,25 +89,31 @@ POSTGRES_URL=postgresql://dev_user:dev_password@localhost:5432/postgres_dev # Authentication - Better Auth BETTER_AUTH_SECRET=your-random-secret -# AI Integration via OpenRouter +# App +NEXT_PUBLIC_APP_URL="http://localhost:3000" +NEXT_PUBLIC_APP_NAME="Your App" + +# Transactional email - Resend (optional; console fallback when unset) +RESEND_API_KEY= +EMAIL_FROM="onboarding@resend.dev" + +# File storage - S3-compatible (optional; local fallback when unset) +S3_ENDPOINT= +S3_REGION="auto" +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET= +S3_PUBLIC_URL= + +# AI Integration via OpenRouter (optional) OPENROUTER_API_KEY= OPENROUTER_MODEL="openai/gpt-5-mini" # Optional - for vector search only OPENAI_EMBEDDING_MODEL="text-embedding-3-large" - -# App URL -NEXT_PUBLIC_APP_URL="http://localhost:3000" - -# File storage -BLOB_READ_WRITE_TOKEN= - -# Polar payment processing -POLAR_WEBHOOK_SECRET=polar_ -POLAR_ACCESS_TOKEN=polar_ ``` -For local development, the default database URL works with the included `docker-compose.yml`. For production, use the database URL from your hosting provider. +For local development, the default database URL works with the included `compose.yml`. For production, use the database URL from your hosting provider. Generate a strong `BETTER_AUTH_SECRET` before deploying. The starter ships with a development value only so you can get moving quickly. @@ -118,7 +129,7 @@ The current auth setup includes: - password reset flow - email verification flow -In development, verification and password reset links are logged to the terminal instead of being sent through an email provider. When you are ready for production, ask your coding agent to connect an email service and update the Better Auth email callbacks. +Email verification and password reset are wired to **Resend** through `src/lib/mail` with templates in `src/emails`. When `RESEND_API_KEY` is not set, emails are logged to the terminal instead, so you can develop without an email account. Set `RESEND_API_KEY` and `EMAIL_FROM` to send real emails in production. ### Adding Google OAuth @@ -227,28 +238,32 @@ src/ │ └── page.tsx ├── components/ │ ├── auth/ -│ ├── ui/ +│ ├── ui/ # shadcn/ui primitives (incl. form.tsx) │ ├── site-footer.tsx │ └── site-header.tsx +├── emails/ # React Email templates ├── hooks/ └── lib/ ├── auth.ts ├── auth-client.ts ├── db.ts ├── env.ts + ├── mail/ # Resend mailer (console fallback) ├── schema.ts ├── session.ts - ├── storage.ts + ├── storage.ts # S3-compatible storage (local fallback) └── utils.ts ``` +The repository root also includes a `Dockerfile`, `.dockerignore`, `playwright.config.ts`, and an `e2e/` test folder. + Important root files: - `AGENTS.md`: coding-agent behavior rules - `CLAUDE.md`: Claude entrypoint for the same guidance - `DESIGN.md`: UI design system and component guidance - `drizzle.config.ts`: Drizzle migration configuration -- `docker-compose.yml`: local PostgreSQL service +- `compose.yml`: local PostgreSQL service - `env.example`: environment variable template - `components.json`: shadcn/ui configuration @@ -256,12 +271,13 @@ Important root files: ```bash pnpm dev # Start the development server with Turbopack -pnpm build # Run migrations, then build for production -pnpm build:ci # Build without running migrations +pnpm build # Build for production (no migrations) +pnpm build:ci # Build for production (alias used in CI) pnpm start # Start the production server pnpm lint # Run ESLint pnpm typecheck # Run TypeScript without emitting files pnpm check # Run lint and typecheck +pnpm test:e2e # Run Playwright end-to-end tests pnpm format # Format the repository pnpm format:check # Check formatting pnpm setup # Run the setup script @@ -284,7 +300,7 @@ Do not use schema push as a replacement for migrations in real project work. For local development: ```bash -docker compose up -d +podman compose up -d # or: docker compose up -d pnpm db:migrate ``` @@ -295,11 +311,11 @@ pnpm db:generate pnpm db:migrate ``` -If you deploy to Vercel or another hosted environment, set `POSTGRES_URL` in that environment before running migrations or building the app. +When deploying, set `POSTGRES_URL` in that environment and run `pnpm db:migrate` as a release step before the new version serves traffic. ## AI Features -The starter uses the Vercel AI SDK with OpenRouter. Set these variables to enable AI chat: +AI chat is an **optional module**. The starter uses the Vercel AI SDK with OpenRouter. If you don't need AI, you can remove the `chat` page, the `src/app/api/chat` route, and the `ai`, `@ai-sdk/react`, and `@openrouter/ai-sdk-provider` dependencies. To enable AI chat, set these variables: ```env OPENROUTER_API_KEY=sk-or-v1-your-key @@ -310,25 +326,33 @@ OpenRouter lets you switch models without changing the application code. Update ## File Storage -The starter includes a storage abstraction that can use local storage in development or Vercel Blob in production. +The starter includes a storage abstraction (`src/lib/storage.ts`) that uses an **S3-compatible** backend when configured and falls back to local filesystem storage otherwise. It works with Hetzner Object Storage, MinIO, AWS S3, Cloudflare R2, and any other S3-compatible provider. It also exposes `getUploadUrl` / `getDownloadUrl` helpers for pre-signed URLs. -For local development, leave `BLOB_READ_WRITE_TOKEN` empty. Files are stored under `public/uploads/`. +For local development, leave the `S3_*` variables empty. Files are stored under `public/uploads/`. -For Vercel Blob: +To use a remote bucket, set: -1. Create a Blob store in Vercel. -2. Copy the `BLOB_READ_WRITE_TOKEN`. -3. Add it to your production environment variables. +- `S3_BUCKET`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` +- `S3_REGION` (use `auto` for many providers) +- `S3_ENDPOINT` (leave empty for AWS S3; set it for Hetzner/MinIO/R2) +- `S3_PUBLIC_URL` (optional public/CDN base URL for object access) -The app chooses the storage backend based on whether `BLOB_READ_WRITE_TOKEN` is configured. +The app chooses the storage backend based on whether the S3 credentials are configured. ## Deployment -Vercel is the recommended deployment target. +The app builds to a standalone server (`output: "standalone"`) and ships a `Dockerfile`, so it runs on any Docker- or Podman-compatible host — **Coolify**, Hetzner, Fly, or a plain VPS. + +```bash +docker build -t my-app . +docker run -p 3000:3000 --env-file .env my-app +``` + +The same `Dockerfile` builds and runs unchanged with Podman: ```bash -npm install -g vercel -vercel --prod +podman build -t my-app . +podman run -p 3000:3000 --env-file .env my-app ``` Set the required production environment variables: @@ -336,21 +360,45 @@ Set the required production environment variables: - `POSTGRES_URL` - `BETTER_AUTH_SECRET` - `NEXT_PUBLIC_APP_URL` -- `OPENROUTER_API_KEY`, if using AI features -- `OPENROUTER_MODEL`, if using AI features -- `BLOB_READ_WRITE_TOKEN`, if using Vercel Blob -- `POLAR_WEBHOOK_SECRET` and `POLAR_ACCESS_TOKEN`, if using Polar payments +- `RESEND_API_KEY` and `EMAIL_FROM`, to send real emails +- `S3_*`, if using remote file storage +- `OPENROUTER_API_KEY` and `OPENROUTER_MODEL`, if using AI features + +Database migrations are **not** run during the build (a container build has no database access). Run them as a separate release/pre-deploy step: + +```bash +pnpm db:migrate +``` + +On Coolify, configure this as a pre-deploy command; in CI, run it behind a manual approval gate before deploying. The `.github/workflows/ci.yml` file includes a commented-out Coolify webhook deploy job you can enable. + +## Testing + +End-to-end tests live in `e2e/` and run with Playwright against your local +database (the `POSTGRES_URL` from `.env`). Make sure the database is running and +migrated first: + +```bash +pnpm exec playwright install # one-time: download browsers +podman compose up -d # or: docker compose up -d +pnpm db:migrate +pnpm test:e2e +``` -The default `pnpm build` script runs database migrations before `next build`. If your CI or host should not run migrations during build, use `pnpm build:ci` and run migrations as a separate deployment step. +Playwright starts the dev server automatically for local runs. The suite +includes a real register → sign-out → sign-in flow (using a unique email per +run) alongside the static smoke tests. In CI, a PostgreSQL service is +provisioned, migrations are applied, and the suite runs after the build (see +`.github/workflows/ci.yml`). ## Troubleshooting ### The app cannot connect to Postgres -Confirm Docker is running and start the database: +Confirm Docker (or Podman) is running and start the database: ```bash -docker compose up -d +podman compose up -d # or: docker compose up -d ``` Then check that `POSTGRES_URL` in `.env` matches the database connection string. diff --git a/create-agentic-app/template/_gitignore b/create-agentic-app/template/_gitignore index e89952f6..9679c8c6 100644 --- a/create-agentic-app/template/_gitignore +++ b/create-agentic-app/template/_gitignore @@ -14,6 +14,8 @@ # testing /coverage /.playwright-mcp +/test-results +/playwright-report # next.js /.next/ diff --git a/create-agentic-app/template/docker-compose.yml b/create-agentic-app/template/compose.yml similarity index 58% rename from create-agentic-app/template/docker-compose.yml rename to create-agentic-app/template/compose.yml index 99d87daf..eba85ced 100644 --- a/create-agentic-app/template/docker-compose.yml +++ b/create-agentic-app/template/compose.yml @@ -1,6 +1,7 @@ services: postgres: - image: pgvector/pgvector:pg18 + # Pin the major version to match your production database (e.g. Coolify/Hetzner). + image: pgvector/pgvector:pg17 environment: POSTGRES_DB: postgres_dev POSTGRES_USER: dev_user diff --git a/create-agentic-app/template/e2e/auth.spec.ts b/create-agentic-app/template/e2e/auth.spec.ts new file mode 100644 index 00000000..384102d4 --- /dev/null +++ b/create-agentic-app/template/e2e/auth.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +/** + * Database-backed flow: register a fresh account (which auto-signs-in and lands + * on the dashboard), sign out by clearing the session cookie, then sign back in + * with the same credentials. Exercises Better Auth + Postgres end to end. + */ +test("register, sign out, and sign back in", async ({ page }) => { + // Unique email per run so the test is repeatable against a persistent DB. + const email = `e2e-${Date.now()}@example.com`; + const password = "test-password-123"; + + // --- Register ----------------------------------------------------------- + await page.goto("/register"); + await page.getByLabel("Name").fill("E2E Test User"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByLabel("Confirm Password").fill(password); + await page.getByRole("button", { name: "Create account" }).click(); + + // Successful sign-up redirects to the protected dashboard. + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + + // --- Sign out (drop the session cookie) --------------------------------- + await page.context().clearCookies(); + + // --- Sign back in ------------------------------------------------------- + // Scope to the page form: the header also has a "Sign in" button when logged out. + const signInForm = page.locator("#main-content"); + await page.goto("/login"); + await signInForm.getByLabel("Email").fill(email); + await signInForm.getByLabel("Password", { exact: true }).fill(password); + await signInForm.getByRole("button", { name: "Sign in" }).click(); + + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); +}); diff --git a/create-agentic-app/template/e2e/smoke.spec.ts b/create-agentic-app/template/e2e/smoke.spec.ts new file mode 100644 index 00000000..a0071c4a --- /dev/null +++ b/create-agentic-app/template/e2e/smoke.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test("home page loads", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + // The app shell (header) should always render. + await expect(page.locator("body")).toBeVisible(); +}); + +test("login page renders the sign-in card", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText("Welcome back")).toBeVisible(); +}); diff --git a/create-agentic-app/template/env.example b/create-agentic-app/template/env.example index a0b471a1..142cc777 100644 --- a/create-agentic-app/template/env.example +++ b/create-agentic-app/template/env.example @@ -5,7 +5,30 @@ POSTGRES_URL=postgresql://dev_user:dev_password@localhost:5432/postgres_dev # Generate key using https://www.better-auth.com/docs/installation BETTER_AUTH_SECRET=qtD4Ssa0t5jY7ewALgai97sKhAtn7Ysc -# AI Integration via OpenRouter (Optional - for chat functionality) +# App +NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Product name used in transactional emails (optional) +NEXT_PUBLIC_APP_NAME="Your App" + +# Transactional email - Resend (optional) +# Without RESEND_API_KEY, auth emails are logged to the console (dev fallback). +# Get your API key from: https://resend.com/api-keys +RESEND_API_KEY= +EMAIL_FROM="onboarding@resend.dev" + +# File storage - S3-compatible (optional) +# Works with Hetzner Object Storage, MinIO, AWS S3, Cloudflare R2, etc. +# Without these, files are stored locally under public/uploads/. +# For AWS S3, leave S3_ENDPOINT empty and set S3_REGION. +S3_ENDPOINT= +S3_REGION="auto" +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET= +# Optional public base URL / CDN domain for object access +S3_PUBLIC_URL= + +# AI Integration via OpenRouter (optional module - for chat functionality) # Get your API key from: https://openrouter.ai/settings/keys # View available models at: https://openrouter.ai/models OPENROUTER_API_KEY= @@ -13,14 +36,3 @@ OPENROUTER_MODEL="openai/gpt-5-mini" # Optional - for vector search only OPENAI_EMBEDDING_MODEL="text-embedding-3-large" - -# App URL (for production deployments) -NEXT_PUBLIC_APP_URL="http://localhost:3000" - -# File storage (optional - if app required file uploads) -BLOB_READ_WRITE_TOKEN= - -# Polar payment processing -# Get these from: https://sandbox.polar.sh/dashboard (sandbox) or https://polar.sh/dashboard (production) -POLAR_WEBHOOK_SECRET=polar_ -POLAR_ACCESS_TOKEN=polar_ \ No newline at end of file diff --git a/create-agentic-app/template/next.config.ts b/create-agentic-app/template/next.config.ts index 4113266a..f7437a08 100644 --- a/create-agentic-app/template/next.config.ts +++ b/create-agentic-app/template/next.config.ts @@ -1,22 +1,36 @@ +import type { RemotePattern } from "next/dist/shared/lib/image-config"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { NextConfig } from "next"; +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + +// Allow images served from the configured S3-compatible bucket / CDN. +const remotePatterns: RemotePattern[] = [ + { protocol: "https", hostname: "lh3.googleusercontent.com" }, + { protocol: "https", hostname: "avatars.githubusercontent.com" }, +]; + +const s3PublicSource = process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT; +if (s3PublicSource) { + try { + remotePatterns.push({ protocol: "https", hostname: new URL(s3PublicSource).hostname }); + } catch { + // Ignore malformed S3 URLs — image optimization simply won't allow that host. + } +} + const nextConfig: NextConfig = { + // Produce a self-contained server bundle for Docker / Coolify / any host. + output: "standalone", + // Pin the file-tracing root to this project so the standalone output stays + // flat (server.js at .next/standalone/server.js) even when the project lives + // inside a larger repo. Keeps the Dockerfile COPY paths stable. + outputFileTracingRoot: projectRoot, + // Image optimization configuration images: { - remotePatterns: [ - { - protocol: "https", - hostname: "lh3.googleusercontent.com", - }, - { - protocol: "https", - hostname: "avatars.githubusercontent.com", - }, - { - protocol: "https", - hostname: "*.public.blob.vercel-storage.com", - }, - ], + remotePatterns, }, // Enable compression diff --git a/create-agentic-app/template/package.json b/create-agentic-app/template/package.json index 48a4f0c2..119aa901 100644 --- a/create-agentic-app/template/package.json +++ b/create-agentic-app/template/package.json @@ -3,12 +3,13 @@ "version": "1.1.2", "scripts": { "dev": "next dev --turbopack", - "build": "pnpm run db:migrate && next build", + "build": "next build", "build:ci": "next build", "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", "check": "pnpm lint && pnpm typecheck", + "test:e2e": "playwright test", "format": "prettier --write .", "format:check": "prettier --check .", "setup": "npx tsx scripts/setup.ts", @@ -22,52 +23,51 @@ }, "dependencies": { "@ai-sdk/react": "^2.0.190", - "@better-auth/api-key": "^1.6.11", + "@aws-sdk/client-s3": "^3.1067.0", + "@aws-sdk/s3-request-presigner": "^3.1067.0", + "@better-auth/api-key": "^1.6.17", + "@hookform/resolvers": "^5.4.0", "@openrouter/ai-sdk-provider": "^1.5.4", - "@radix-ui/react-avatar": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@vercel/blob": "^2.3.3", + "@radix-ui/react-avatar": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-slot": "^1.2.5", + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.0.8", "ai": "^5.0.188", - "better-auth": "^1.6.11", + "better-auth": "^1.6.17", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "^0.44.7", + "drizzle-orm": "^0.45.2", "lucide-react": "^0.539.0", - "next": "16.1.6", + "next": "16.2.9", "next-themes": "^0.4.6", - "pg": "^8.20.0", "postgres": "^3.4.9", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-hook-form": "^7.78.0", "react-markdown": "^10.1.0", + "resend": "^6.12.4", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "latest", "@types/node": "^20.19.41", - "@types/pg": "^8.20.0", - "@types/react": "19.2.5", + "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", - "eslint-config-next": "16.0.7", - "prettier": "^3.8.3", - "prettier-plugin-tailwindcss": "^0.6.14", + "eslint-config-next": "16.2.9", + "prettier": "^3.8.4", + "prettier-plugin-tailwindcss": "^0.8.0", "shadcn": "^3.8.5", "tailwindcss": "^4.3.0", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" - }, - "pnpm": { - "overrides": { - "@types/react": "19.2.5", - "@types/react-dom": "19.2.3" - } } } diff --git a/create-agentic-app/template/playwright.config.ts b/create-agentic-app/template/playwright.config.ts new file mode 100644 index 00000000..2b42129e --- /dev/null +++ b/create-agentic-app/template/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = process.env.PORT || "3000"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + // Starts the app for local runs against the database from your .env + // (POSTGRES_URL). Make sure the dev database is running and migrated first: + // podman compose up -d # or: docker compose up -d + // pnpm db:migrate + // In CI, start the server in a separate step and point PLAYWRIGHT_BASE_URL at it. + webServer: { + command: "pnpm dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/create-agentic-app/template/pnpm-workspace.yaml b/create-agentic-app/template/pnpm-workspace.yaml new file mode 100644 index 00000000..4fa391f1 --- /dev/null +++ b/create-agentic-app/template/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +allowBuilds: + esbuild: true + msw: true + sharp: true + unrs-resolver: true +overrides: + "@types/react": 19.2.17 + "@types/react-dom": 19.2.3 + # Force esbuild (pulled in only via the drizzle-kit dev toolchain) up to a + # patched release. Floor of 0.28.1 clears the dev-server, arbitrary-file-read + # and binary-integrity advisories (GHSA-67mh-4wv8-2f99, GHSA-g7r4-m6w7-qqqr, + # GHSA-36qx-fr4f-26g5). + esbuild: ">=0.28.1" diff --git a/create-agentic-app/template/src/app/api/diagnostics/route.ts b/create-agentic-app/template/src/app/api/diagnostics/route.ts index 33fe9e93..d9f59f14 100644 --- a/create-agentic-app/template/src/app/api/diagnostics/route.ts +++ b/create-agentic-app/template/src/app/api/diagnostics/route.ts @@ -122,8 +122,12 @@ export async function GET(req: Request) { env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET; const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here - // Storage configuration check - const storageConfigured = Boolean(process.env.BLOB_READ_WRITE_TOKEN); + // Storage configuration check (S3-compatible backend) + const storageConfigured = Boolean( + process.env.S3_BUCKET && + process.env.S3_ACCESS_KEY_ID && + process.env.S3_SECRET_ACCESS_KEY + ); const storageType: "local" | "remote" = storageConfigured ? "remote" : "local"; const overallStatus: StatusLevel = (() => { diff --git a/create-agentic-app/template/src/components/auth/forgot-password-form.tsx b/create-agentic-app/template/src/components/auth/forgot-password-form.tsx index b8f1dd8b..6ed29050 100644 --- a/create-agentic-app/template/src/components/auth/forgot-password-form.tsx +++ b/create-agentic-app/template/src/components/auth/forgot-password-form.tsx @@ -2,25 +2,43 @@ import { useState } from "react" import Link from "next/link" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { z } from "zod" import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { requestPasswordReset } from "@/lib/auth-client" +// Reference pattern: React Hook Form + Zod validation with shadcn/ui form fields. +const formSchema = z.object({ + email: z.string().email("Enter a valid email address"), +}) + +type FormValues = z.infer + export function ForgotPasswordForm() { - const [email, setEmail] = useState("") const [error, setError] = useState("") const [success, setSuccess] = useState(false) - const [isPending, setIsPending] = useState(false) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { email: "" }, + }) + + const onSubmit = async (values: FormValues) => { setError("") - setIsPending(true) try { const result = await requestPasswordReset({ - email, + email: values.email, redirectTo: "/reset-password", }) @@ -31,8 +49,6 @@ export function ForgotPasswordForm() { } } catch { setError("An unexpected error occurred") - } finally { - setIsPending(false) } } @@ -41,7 +57,6 @@ export function ForgotPasswordForm() {

If an account exists with that email, a password reset link has been sent. - Check your terminal for the reset URL.

-
- Remember your password?{" "} - - Sign in - -
- + {error &&

{error}

} + +
+ Remember your password?{" "} + + Sign in + +
+ + ) } diff --git a/create-agentic-app/template/src/components/setup-checklist.tsx b/create-agentic-app/template/src/components/setup-checklist.tsx index ca860525..e342ced1 100644 --- a/create-agentic-app/template/src/components/setup-checklist.tsx +++ b/create-agentic-app/template/src/components/setup-checklist.tsx @@ -47,7 +47,7 @@ function StatusIcon({ ok }: { ok: boolean }) { export function SetupChecklist() { const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); async function load() { @@ -66,7 +66,25 @@ export function SetupChecklist() { } useEffect(() => { - load(); + let cancelled = false; + (async () => { + try { + const res = await fetch("/api/diagnostics", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = (await res.json()) as DiagnosticsResponse; + if (!cancelled) setData(json); + } catch (e) { + if (!cancelled) + setError( + e instanceof Error ? e.message : "Failed to load diagnostics", + ); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; }, []); const steps = [ diff --git a/create-agentic-app/template/src/components/ui/form.tsx b/create-agentic-app/template/src/components/ui/form.tsx new file mode 100644 index 00000000..6541360d --- /dev/null +++ b/create-agentic-app/template/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId(); + + return ( + +
+ + ); +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField(); + + return ( +