feat: implement composable monorepo template system with component architecture

Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-31 19:11:42 -07:00
parent c59d348040
commit 8282d60c69
153 changed files with 8764 additions and 1731 deletions

View File

@ -44,6 +44,13 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
- **500-line limit:** Files exceeding 500 lines must be split
- **Tests:** All handlers and services require tests
- **Multi-step ops:** NEVER log-and-continue after partial failure. Rollback or document partial state.
- **Logging:** Use injected `*slog.Logger` only. NEVER `fmt.Println`, `log.Fatal`, `log.Printf`, or bare `slog.Info()`. Error key is ALWAYS `"error"` (not `"err"`). Log once at boundary (handlers/workers log, services return errors).
- **HTTP clients:** NEVER create `&http.Client{}` without a `Timeout` field. All HTTP clients must have explicit timeouts (30s standard, 5s for health checks). A bare client can hang indefinitely.
- **Config:** Use `envutil.GetEnv()` / `GetEnvInt()` / `GetEnvBool()` from `internal/envutil` for all env var reads with defaults. NEVER define local `getEnv` helpers — they duplicate and drift. Raw `os.Getenv()` is fine for required values with no default (secrets, passwords).
- **Handler timeouts:** NEVER use inline `time.Duration` in `context.WithTimeout` inside handlers. Use constants from `internal/handlers/timeouts.go`: `TimeoutFastLookup` (5s), `TimeoutLookup` (10s), `TimeoutStandard` (30s), `TimeoutHeavyWrite` (60s), `TimeoutOrchestration` (90s), `TimeoutLongRunning` (10m).
- **Response helpers:** Use `api.WriteUnauthorized`, `api.WriteForbidden`, `api.WriteBadRequest`, `api.WriteNotFound`, `api.WriteInternalError` instead of bare `api.WriteError` with status codes. Only use `api.WriteError` directly for custom error codes (e.g., KEY_REVOKED, IP_NOT_ALLOWED).
- **JSON decoding:** ALWAYS use `api.DecodeJSON(r, &req)` to decode request bodies. NEVER use raw `json.NewDecoder(r.Body).Decode()`. The helper handles nil body, EOF, and returns typed errors. Decode error message is always `"invalid request body"`.
- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`.
## Quick Reference

View File

@ -14,6 +14,11 @@ Composable Monorepo Templates evolve rdev's project scaffolding from single temp
- Optional `component.yaml` per component for ports, dependencies, build order
- Shared `pkg/` from Aeries chassis + Colix patterns (8 packages)
- Deployment supports whole-monorepo or individual-component targets
- **CI is template-provided** - skeleton has `.woodpecker.yml.tmpl`, components have `.woodpecker.step.yml.tmpl`
**Critical Design Decision:**
CI/CD configuration MUST come from templates, never AI-generated. Claude Code produces invalid
Woodpecker YAML when generating from scratch (broken YAML anchor syntax).
**File Pointers:**
- Plan: `tmp/template-monorepo-plan.md`
@ -30,6 +35,7 @@ POST /projects {"name": "acme"}
Creates monorepo skeleton:
- CLAUDE.md, README.md, Procfile
- docker-compose.yml, go.work, .golangci.yml
- .woodpecker.yml (template-provided CI)
- scripts/ (discover, install, quality, dev)
- pkg/ (8 shared packages from Aeries + Colix)
- .claude/ (guides, skills, commands)
@ -49,6 +55,7 @@ Auto-updates:
- Procfile (add service entry)
- go.work (add module)
- CLAUDE.md (add routing)
- .woodpecker.yml (add build step for component)
```
### Monorepo Structure

View File

@ -4,9 +4,9 @@ import (
"context"
"log/slog"
"os"
"strconv"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/envutil"
"github.com/orchard9/rdev/internal/port"
)
@ -75,28 +75,14 @@ type InfraConfig struct {
}
func loadConfig() Config {
port := 8080
if v := os.Getenv("PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
port = p
}
}
dbPort := 5432
if v := os.Getenv("DB_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
dbPort = p
}
}
return Config{
Port: port,
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
DBPort: dbPort,
DBUser: getEnv("DB_USER", "appuser"),
Port: envutil.GetEnvInt("PORT", 8080),
DBHost: envutil.GetEnv("DB_HOST", "postgres.databases.svc"),
DBPort: envutil.GetEnvInt("DB_PORT", 5432),
DBUser: envutil.GetEnv("DB_USER", "appuser"),
DBPassword: os.Getenv("DB_PASSWORD"),
DBName: getEnv("DB_NAME", "rdev"),
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
DBName: envutil.GetEnv("DB_NAME", "rdev"),
DBSSLMode: envutil.GetEnv("DB_SSL_MODE", "disable"),
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
// Encryption key for credential store (generate with: openssl rand -base64 32)
@ -105,33 +91,26 @@ func loadConfig() Config {
// OpenCode (optional alternative code agent)
OpenCodeURL: os.Getenv("OPENCODE_URL"), // e.g., "http://opencode:4096"
OpenCodeUsername: getEnv("OPENCODE_USERNAME", "opencode"),
OpenCodeUsername: envutil.GetEnv("OPENCODE_USERNAME", "opencode"),
OpenCodePassword: os.Getenv("OPENCODE_PASSWORD"),
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaURL: envutil.GetEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
GiteaDefaultOrg: envutil.GetEnv("GITEA_DEFAULT_ORG", "jordan"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
DefaultDomain: envutil.GetEnv("DEFAULT_DOMAIN", "threesix.ai"),
DeployNamespace: envutil.GetEnv("DEPLOY_NAMESPACE", "projects"),
DeployTLSIssuer: envutil.GetEnv("DEPLOY_TLS_ISSUER", "letsencrypt-prod"),
ClusterIP: envutil.GetEnv("CLUSTER_IP", "208.122.204.172"),
RegistryURL: envutil.GetEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
WoodpeckerURL: envutil.GetEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
}
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// loadInfraConfig loads infrastructure configuration from credential store,
// falling back to environment variables if not found in the store.
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
@ -159,20 +138,6 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
return envFallback
}
// Parse CRDB and Redis ports
crdbPort := 26257
if v := os.Getenv("CRDB_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
crdbPort = p
}
}
redisPort := 6379
if v := os.Getenv("REDIS_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
redisPort = p
}
}
infraCfg := InfraConfig{
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
@ -190,11 +155,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
// CockroachDB and Redis provisioners (env-only for now)
CRDBHost: os.Getenv("CRDB_HOST"), // e.g., "cockroachdb-public.databases.svc"
CRDBPort: crdbPort,
CRDBUser: getEnv("CRDB_USER", "root"),
CRDBSSLMode: getEnv("CRDB_SSL_MODE", "disable"),
CRDBPort: envutil.GetEnvInt("CRDB_PORT", 26257),
CRDBUser: envutil.GetEnv("CRDB_USER", "root"),
CRDBSSLMode: envutil.GetEnv("CRDB_SSL_MODE", "disable"),
RedisHost: os.Getenv("REDIS_HOST"), // e.g., "redis.threesix.svc"
RedisPort: redisPort,
RedisPort: envutil.GetEnvInt("REDIS_PORT", 6379),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
}
@ -211,3 +176,15 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
return infraCfg
}
// closeProvisioner attempts to close a provisioner that implements io.Closer.
func closeProvisioner(p any, name string, logger *slog.Logger) {
if p == nil {
return
}
if closer, ok := p.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
logger.Warn("failed to close "+name+" provisioner", "error", err)
}
}
}

View File

@ -1,36 +1,4 @@
// Package main provides the entry point for the rdev API server.
//
// rdev (Remote Developer) provides a REST API for controlling Claude Code
// instances running in Kubernetes pods. External clients (Discord bots,
// CLI tools, etc.) connect via this API.
//
// Authentication:
// - All endpoints (except /health, /ready, /docs) require X-API-Key header
// - Admin key from RDEV_ADMIN_KEY env var for key management
// - Create additional keys via POST /keys
//
// Endpoints:
// - GET /health - Health check (no auth)
// - GET /ready - Readiness check (no auth)
// - GET /docs - Scalar API documentation (no auth)
// - GET /openapi.json - OpenAPI 3.0 specification (no auth)
// - GET /keys - List API keys
// - POST /keys - Create API key
// - GET /keys/{id} - Get API key details
// - DELETE /keys/{id} - Revoke API key
// - GET /projects - List available projects
// - GET /projects/{id} - Get project details
// - POST /projects/{id}/claude - Run Claude command
// - POST /projects/{id}/shell - Run shell command
// - POST /projects/{id}/git - Run git command
// - GET /projects/{id}/events - SSE stream for output
// - GET /projects/{id}/claude-config - List commands/skills/agents
// - GET /projects/{id}/claude-config/commands - List commands
// - POST /projects/{id}/claude-config/commands - Create command
// - GET /projects/{id}/claude-config/commands/{name} - Get command
// - PUT /projects/{id}/claude-config/commands/{name} - Update command
// - DELETE /projects/{id}/claude-config/commands/{name} - Delete command
// (same pattern for /skills and /agents)
package main
import (
@ -54,6 +22,7 @@ import (
"github.com/orchard9/rdev/internal/adapter/woodpecker"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/db"
"github.com/orchard9/rdev/internal/envutil"
"github.com/orchard9/rdev/internal/handlers"
"github.com/orchard9/rdev/internal/metrics"
"github.com/orchard9/rdev/internal/middleware"
@ -120,7 +89,7 @@ func main() {
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
// Create adapters (dependency injection)
namespace := getEnv("K8S_NAMESPACE", "rdev")
namespace := envutil.GetEnv("K8S_NAMESPACE", "rdev")
// Initialize K8s client for dynamic project discovery
// Falls back gracefully if K8s is unavailable (e.g., local development)
@ -138,27 +107,18 @@ func main() {
k8sExecutor := kubernetes.NewExecutor(namespace)
streamPub := memory.NewStreamPublisher()
// Start watching for project pod changes if K8s client is available
if k8sClient != nil {
if err := projectRepo.StartWatching(context.Background()); err != nil {
logger.Warn("failed to start project watcher", "error", err)
}
}
// Initialize audit logger
auditLogger := postgres.NewAuditLogger(database.DB)
// Initialize rate limiter
rateLimiter := postgres.NewRateLimiter(database.DB)
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
// Initialize command queue
commandQueue := postgres.NewCommandQueueRepository(database.DB)
// Initialize work queue (for worker pool tasks)
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
// Initialize webhook repository and dispatcher
webhookRepo := postgres.NewWebhookRepository(database.DB)
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
WorkerCount: 10,
@ -172,8 +132,7 @@ func main() {
os.Exit(1)
}
// Initialize infrastructure adapters (optional - only if configured)
// Uses infraCfg which loads from credential store with env var fallback
// Infrastructure adapters (optional - only if configured)
var giteaClient *gitea.Client
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
var err error
@ -384,6 +343,23 @@ func main() {
// Initialize project management handler
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger)
// Initialize component service and handler (for monorepo component management)
var componentsHandler *handlers.ComponentsHandler
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" && templateProvider != nil {
bulkFileClient := gitea.NewBulkFileClient(infraCfg.GiteaURL, infraCfg.GiteaToken)
componentService := service.NewComponentService(
database.DB,
templateProvider,
bulkFileClient,
service.ComponentServiceConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
Logger: logger,
},
)
componentsHandler = handlers.NewComponentsHandler(componentService, logger)
logger.Info("component service initialized")
}
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
deployerAdapter,
@ -425,6 +401,9 @@ func main() {
workHandler.Mount(app.Router())
infraHandler.Mount(app.Router())
projectMgmtHandler.Mount(app.Router())
if componentsHandler != nil {
componentsHandler.Mount(app.Router())
}
woodpeckerHandler.Mount(app.Router())
credentialsHandler.Mount(app.Router())
agentsHandler.Mount(app.Router())
@ -493,47 +472,18 @@ func main() {
// Enable API documentation
app.EnableDocs(buildOpenAPISpec())
// Cleanup on shutdown
app.OnShutdown(func(ctx context.Context) error {
// Stop work executor (deregisters worker)
workExecutor.Stop()
// Stop queue maintenance worker
queueMaintenance.Stop()
// Stop queue processor
queueProcessor.Stop()
// Stop webhook dispatcher
webhookDispatcher.Stop()
// Stop project watcher
projectRepo.StopWatching()
// Stop rate limit cleanup worker
stopRateLimitCleanup()
// Close database and cache provisioners
if dbProvisioner != nil {
if closer, ok := dbProvisioner.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
logger.Warn("failed to close database provisioner", "error", err)
}
}
}
if cacheProvisioner != nil {
if closer, ok := cacheProvisioner.(interface{ Close() error }); ok {
if err := closer.Close(); err != nil {
logger.Warn("failed to close cache provisioner", "error", err)
}
}
}
// Shutdown telemetry (flush pending traces)
closeProvisioner(dbProvisioner, "database", logger)
closeProvisioner(cacheProvisioner, "cache", logger)
if err := tel.Shutdown(ctx); err != nil {
logger.Error("telemetry shutdown error", "error", err)
}
return database.Close()
})
@ -547,5 +497,4 @@ func main() {
app.Run()
}
// Config, InfraConfig, loadConfig, loadInfraConfig, and getEnv
// are defined in config.go.
// Config, InfraConfig, loadConfig, loadInfraConfig are defined in config.go.

423
cookbooks/composable-app.md Normal file
View File

@ -0,0 +1,423 @@
# Composable App Cookbook
> Deploy a full-stack application with multiple components using composable monorepo templates.
## Overview
This cookbook creates a full-stack application by composing components:
```
POST /projects
Creates: Monorepo skeleton with shared packages
POST /projects/{id}/components (service)
Adds: Go API backend with CI step
POST /projects/{id}/components (app)
Adds: React/Astro frontend with CI step
POST /projects/{id}/deploy
Deploys all components to K8s
```
**Composable. Template-driven. CI auto-configured.**
---
## Prerequisites
### API Access
```bash
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<your-api-key>"
```
### Infrastructure Required
- rdev-api running with embedded worker
- Gitea at https://git.threesix.ai
- Woodpecker CI at https://ci.threesix.ai
---
## Step 1: Create Project (Monorepo Skeleton)
```bash
curl -X POST "$RDEV_API_URL/projects" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "taskapp",
"description": "Task management application"
}'
```
**Response:**
```json
{
"data": {
"project_id": "taskapp",
"name": "taskapp",
"domain": "xyz789ab.threesix.ai",
"url": "https://xyz789ab.threesix.ai",
"git": {
"owner": "jordan",
"name": "taskapp",
"html_url": "https://git.threesix.ai/jordan/taskapp"
}
}
}
```
This creates:
```
taskapp/
├── CLAUDE.md # AI routing
├── README.md # Project docs
├── Procfile # Local dev (empty)
├── docker-compose.yml # Postgres, Redis
├── go.work # Go workspace
├── .woodpecker.yml # CI pipeline (template-provided)
├── .golangci.yml # Go linting
├── scripts/ # Discovery scripts
│ ├── discover.sh
│ ├── install.sh
│ ├── quality.sh
│ └── dev.sh
├── pkg/ # Shared Go packages
│ ├── app/ # Service bootstrapper
│ ├── middleware/ # HTTP middleware
│ ├── httpresponse/ # JSON responses
│ └── ...
├── services/ # (empty, ready for components)
├── apps/ # (empty, ready for components)
├── workers/ # (empty, ready for components)
└── cli/ # (empty, ready for components)
```
---
## Step 2: Add Backend Service
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "service",
"name": "api",
"template": "service"
}'
```
**Response:**
```json
{
"data": {
"type": "service",
"name": "api",
"path": "services/api",
"port": 8001,
"template": "service"
}
}
```
This adds:
```
services/api/
├── cmd/server/main.go # Entry point using pkg/app
├── internal/
│ ├── api/routes.go # Chi router setup
│ ├── api/handlers/health.go # Health endpoints
│ └── config/config.go # Configuration
├── migrations/ # Database migrations
├── Makefile # Build targets
├── Dockerfile # Multi-stage Go build
├── component.yaml # Port, dependencies
└── .env.example # Environment template
```
And updates:
- `.woodpecker.yml` - adds `build-api` step
- `Procfile` - adds `api: make -C services/api run`
- `go.work` - adds `use ./services/api`
---
## Step 3: Add Frontend App
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "app",
"name": "dashboard",
"template": "app-react"
}'
```
**Response:**
```json
{
"data": {
"type": "app",
"name": "dashboard",
"path": "apps/dashboard",
"port": 3001,
"template": "app-react"
}
}
```
This adds:
```
apps/dashboard/
├── src/
│ ├── App.tsx
│ ├── main.tsx
│ └── components/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── Dockerfile # Multi-stage Node build
└── component.yaml # Port, dependencies
```
---
## Step 4: Customize with Claude (Optional)
Submit a build task to customize the components:
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Implement a task management system:\n\nBACKEND (services/api):\n- Add Task model with id, title, description, status, created_at\n- Endpoints: GET /api/tasks, POST /api/tasks, PATCH /api/tasks/{id}, DELETE /api/tasks/{id}\n- In-memory storage for now\n\nFRONTEND (apps/dashboard):\n- Task list with status badges\n- Add task form\n- Mark complete/delete buttons\n- Dark theme with Tailwind\n- Fetch from /api/tasks",
"auto_commit": true,
"auto_push": true
}'
```
Monitor the build:
```bash
curl -s "$RDEV_API_URL/builds/{task_id}" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data.status'
```
---
## Step 5: Deploy All Components
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
```
This deploys all components to K8s, or deploy a single component:
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/deploy" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"component": "services/api"}'
```
---
## Step 6: Monitor CI Pipeline
```bash
curl -s "$RDEV_API_URL/projects/taskapp/pipelines" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data[0]'
```
The pipeline builds both components in parallel then deploys.
---
## Step 7: Verify Deployment
```bash
# Check site is live
curl -I https://xyz789ab.threesix.ai
# Test API
curl https://xyz789ab.threesix.ai/api/tasks | jq .
# View the app
open https://xyz789ab.threesix.ai
```
---
## Adding More Components
### Add a Background Worker
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "worker",
"name": "notifications",
"template": "worker"
}'
```
### Add a CLI Tool
```bash
curl -X POST "$RDEV_API_URL/projects/taskapp/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "cli",
"name": "taskctl",
"template": "cli"
}'
```
### List All Components
```bash
curl -s "$RDEV_API_URL/projects/taskapp/components" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
```
---
## Component Types
| Type | Directory | Templates | Default Port |
|------|-----------|-----------|--------------|
| service | `services/` | service | 8001+ |
| worker | `workers/` | worker | N/A |
| app | `apps/` | app-astro, app-react | 3001+ |
| cli | `cli/` | cli | N/A |
Ports auto-increment as you add components of the same type.
---
## Teardown
```bash
curl -X DELETE "$RDEV_API_URL/projects/taskapp" \
-H "X-API-Key: $RDEV_API_KEY"
```
Removes: DNS records, K8s deployments, project metadata. Gitea repo preserved.
---
## E2E Test Script
Run the full flow:
```bash
./cookbooks/scripts/composable-test.sh run my-test-app
```
Check status:
```bash
./cookbooks/scripts/composable-test.sh status my-test-app
```
Cleanup:
```bash
./cookbooks/scripts/composable-test.sh teardown my-test-app
```
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Composable Full-Stack Deployment │
│ │
│ POST /projects │
│ │ │
│ └──► Creates monorepo skeleton with: │
│ - Shared pkg/ (8 packages) │
│ - .woodpecker.yml (base CI) │
│ - Discovery scripts │
│ │
│ POST /projects/{id}/components (× N) │
│ │ │
│ └──► For each component: │
│ - Renders template to services/|apps/|workers/|cli/ │
│ - Inserts CI step into .woodpecker.yml │
│ - Updates Procfile, go.work │
│ │
│ POST /projects/{id}/builds (optional) │
│ │ │
│ └──► Claude customizes existing components │
│ │
│ Git push → Woodpecker CI: │
│ ├──► build-api (Kaniko → registry) │
│ ├──► build-dashboard (Kaniko → registry) │
│ └──► deploy (kubectl set image) │
│ │
│ Components live at https://{slug}.threesix.ai │
│ ├──► /api/* → services/api │
│ └──► /* → apps/dashboard │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Troubleshooting
### Component addition fails
```bash
# Check project exists
curl -s "$RDEV_API_URL/projects/taskapp" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
# Check available templates
curl -s "$RDEV_API_URL/templates/components" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
```
### Build stuck in pending
```bash
# Check worker status
curl -s "$RDEV_API_URL/workers" -H "X-API-Key: $RDEV_API_KEY" | jq '.data.summary'
```
### Pipeline fails
```bash
# Get pipeline details
curl -s "$RDEV_API_URL/projects/taskapp/pipelines/1" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
# Check Woodpecker UI
open https://ci.threesix.ai/jordan/taskapp
```
### Deployment not updating
```bash
# Check K8s pods
kubectl get pods -n projects -l app=taskapp
# Check deployment events
kubectl describe deployment taskapp-api -n projects
```
---
## Related
- [Landing Page Cookbook](./landing-page.md) - Simple single-component site
- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md)
- [Component Templates](../.claude/guides/services/templates.md)

View File

@ -1,383 +0,0 @@
# Full-Stack App Cookbook
> Deploy a full-stack application (Next.js + Go backend) built entirely by Claude through the threesix.ai infrastructure.
## Overview
This cookbook creates and deploys a complete full-stack application using **agent-driven development**:
```
POST /project/create-and-build
Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment
Enqueues build task with comprehensive prompt
Worker picks up task → Claude builds the entire stack
Agent commits + pushes
CI builds and deploys
Live full-stack app
```
**Claude builds everything from scratch: Next.js frontend with shadcn/ui, Go backend API, Docker configs, and CI pipeline.**
---
## Prerequisites
### API Access
```bash
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<your-api-key>"
```
### Infrastructure Required
- rdev-api running with embedded worker
- Gitea at https://git.threesix.ai
- Woodpecker CI at https://ci.threesix.ai
- claudebox-0 pod running in rdev namespace
---
## Step 1: Create Project and Build Full-Stack App
Single API call that creates infrastructure AND enqueues the full-stack build:
```bash
curl -X POST "$RDEV_API_URL/project/create-and-build" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-fullstack-app",
"description": "Full-stack app with Next.js frontend and Go backend",
"build": {
"prompt": "Build a full-stack task management application with the following structure:\n\nFRONTEND (Next.js 14 + shadcn/ui):\n- Create a Next.js 14 app with App Router in /frontend\n- Use shadcn/ui for all components (install with npx shadcn-ui@latest init)\n- Dark theme with modern aesthetic\n- Pages: Dashboard showing tasks, Add Task form, Task detail view\n- Use Tailwind CSS for styling\n- Connect to backend API at /api proxy\n\nBACKEND (Go):\n- Create a Go HTTP server in /backend using chi router\n- Endpoints: GET /api/tasks, POST /api/tasks, GET /api/tasks/{id}, DELETE /api/tasks/{id}\n- In-memory task storage (no database needed)\n- Structured JSON responses\n- CORS middleware for frontend\n\nDOCKER:\n- /frontend/Dockerfile: Multi-stage build for Next.js (node:20-alpine)\n- /backend/Dockerfile: Multi-stage build for Go (golang:1.22-alpine)\n- /docker-compose.yml: Run both services, frontend proxies to backend\n\nCI/CD:\n- /.woodpecker.yml: Build both images, push to registry, deploy to k8s\n\nCreate all necessary files including package.json, go.mod, and configuration files.",
"auto_commit": true,
"auto_push": true
}
}'
```
**Response:**
```json
{
"project": {
"project_id": "my-fullstack-app",
"domain": "xyz789ab.threesix.ai",
"git": {
"html_url": "https://git.threesix.ai/jordan/my-fullstack-app"
}
},
"build": {
"task_id": "task-uuid",
"status": "pending",
"status_url": "/builds/task-uuid"
}
}
```
---
## Step 2: Monitor Build Progress
Poll the build status:
```bash
curl -s "$RDEV_API_URL/builds/{task_id}" \
-H "X-API-Key: $RDEV_API_KEY" | jq .
```
**Status progression:** `pending``running``completed` (or `failed`)
Full-stack builds take longer than simple landing pages. Expect 2-5 minutes.
When completed:
```json
{
"task_id": "task-uuid",
"status": "completed",
"result": {
"success": true,
"commit_sha": "def456",
"files_changed": [
"frontend/package.json",
"frontend/app/page.tsx",
"frontend/app/layout.tsx",
"frontend/components/task-list.tsx",
"frontend/components/add-task-form.tsx",
"frontend/Dockerfile",
"backend/main.go",
"backend/go.mod",
"backend/Dockerfile",
"docker-compose.yml",
".woodpecker.yml"
],
"duration_ms": 180000
}
}
```
---
## Step 3: Monitor CI Pipeline
The agent's push triggers Woodpecker CI to build both services:
```bash
curl -s "$RDEV_API_URL/projects/my-fullstack-app/pipelines" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data[0]'
```
Pipeline stages:
1. Build frontend Docker image
2. Build backend Docker image
3. Push both to registry
4. Deploy to Kubernetes
Wait for `status: "success"`.
---
## Step 4: Verify Deployment
```bash
# Check site is live
curl -I https://xyz789ab.threesix.ai
# Test frontend loads
curl -s https://xyz789ab.threesix.ai | head -20
# Test backend API
curl -s https://xyz789ab.threesix.ai/api/tasks | jq .
# Open in browser
open https://xyz789ab.threesix.ai
```
---
## Iterating on the App
### Add a Feature
```bash
curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Add task priority levels (low, medium, high) with color-coded badges in the UI. Update the backend Task struct and frontend components to support priorities. Add a priority filter dropdown on the dashboard.",
"auto_commit": true,
"auto_push": true
}'
```
### Fix a Bug
```bash
curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Fix the task deletion - ensure the DELETE endpoint returns 204 No Content and the frontend removes the task from the list immediately without requiring a page refresh.",
"auto_commit": true,
"auto_push": true
}'
```
### Add Authentication
```bash
curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Add simple JWT authentication:\n- Backend: Add /api/auth/login endpoint that accepts username/password and returns JWT\n- Backend: Add auth middleware to protect /api/tasks endpoints\n- Frontend: Add login page with shadcn form components\n- Frontend: Store JWT in localStorage, include in API requests\n- Create a demo user (admin/admin123) for testing",
"auto_commit": true,
"auto_push": true
}'
```
Each build:
1. Claude clones the existing repo
2. Makes the requested changes
3. Commits and pushes
4. CI deploys automatically
---
## Alternative Prompts
### E-commerce Storefront
```bash
curl -X POST "$RDEV_API_URL/project/create-and-build" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-store",
"description": "E-commerce storefront",
"build": {
"prompt": "Build an e-commerce storefront:\n\nFRONTEND: Next.js 14 with shadcn/ui, dark theme\n- Product grid with images, prices, descriptions\n- Product detail page\n- Shopping cart (localStorage)\n- Checkout form (no payment processing)\n\nBACKEND: Go with chi router\n- GET /api/products - list products\n- GET /api/products/{id} - product detail\n- POST /api/orders - create order (log to console)\n- Seed with 6 sample products\n\nInclude Dockerfiles and .woodpecker.yml for CI/CD.",
"auto_commit": true,
"auto_push": true
}
}'
```
### Dashboard App
```bash
curl -X POST "$RDEV_API_URL/project/create-and-build" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-dashboard",
"description": "Analytics dashboard",
"build": {
"prompt": "Build an analytics dashboard:\n\nFRONTEND: Next.js 14 with shadcn/ui + recharts\n- Dashboard with 4 stat cards (users, revenue, orders, growth)\n- Line chart showing weekly trends\n- Bar chart showing top products\n- Recent activity table\n- Dark theme, responsive grid layout\n\nBACKEND: Go with chi router\n- GET /api/stats - return dashboard statistics\n- GET /api/trends - return weekly trend data\n- GET /api/activity - return recent activity\n- Generate realistic sample data\n\nInclude Dockerfiles and .woodpecker.yml for CI/CD.",
"auto_commit": true,
"auto_push": true
}
}'
```
---
## Adding Custom Domains
```bash
# Add custom domain
curl -X POST "$RDEV_API_URL/projects/my-fullstack-app/domains" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "app.mycompany.com"}'
# List all domains
curl -s "$RDEV_API_URL/projects/my-fullstack-app/domains" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data.domains'
```
---
## Teardown
```bash
curl -X DELETE "$RDEV_API_URL/project/my-fullstack-app" \
-H "X-API-Key: $RDEV_API_KEY"
```
Removes: DNS records, K8s deployment, project metadata. Gitea repo preserved for safety.
---
## E2E Test Script
Run the full flow:
```bash
./cookbooks/scripts/fullstack-test.sh run my-test-fullstack
```
Check status:
```bash
./cookbooks/scripts/fullstack-test.sh status my-test-fullstack
```
Cleanup:
```bash
./cookbooks/scripts/fullstack-test.sh teardown my-test-fullstack
```
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Agent-Driven Full-Stack App │
│ │
│ POST /project/create-and-build │
│ │ │
│ ├──► Gitea: creates repo │
│ ├──► Cloudflare: creates DNS │
│ ├──► Woodpecker: activates CI │
│ ├──► K8s: creates Deployment/Service/Ingress │
│ └──► Work Queue: enqueues build task │
│ │ │
│ ▼ │
│ Worker polls queue, claims task │
│ │ │
│ ▼ │
│ Claude Code executes in claudebox-0: │
│ - Clones repo │
│ - Creates Next.js frontend with shadcn/ui │
│ - Creates Go backend with chi router │
│ - Writes Dockerfiles and CI config │
│ - Commits and pushes │
│ │ │
│ ▼ │
│ Woodpecker CI triggered by push: │
│ - Builds frontend Docker image │
│ - Builds backend Docker image │
│ - Pushes to registry │
│ - Deploys to K8s │
│ │ │
│ ▼ │
│ Full-stack app live at https://{slug}.threesix.ai │
│ - Frontend: Next.js + shadcn/ui │
│ - Backend: Go API │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Troubleshooting
### Build stuck in pending
```bash
# Check worker status
curl -s "$RDEV_API_URL/workers" -H "X-API-Key: $RDEV_API_KEY" | jq '.data.summary'
# Should show at least 1 idle worker
```
### Build failed
```bash
# Get build details with full output
curl -s "$RDEV_API_URL/builds/{task_id}" -H "X-API-Key: $RDEV_API_KEY" | jq '.result'
# Check rdev-api logs for worker errors
./scripts/logs.sh -e
```
### Pipeline not triggering
```bash
# Check if commit was pushed
curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-fullstack-app/commits" | jq '.[0]'
# Check Woodpecker
open https://ci.threesix.ai/jordan/my-fullstack-app
```
### Frontend/Backend connection issues
```bash
# Check both containers are running
kubectl get pods -n projects -l app=my-fullstack-app
# Check frontend logs
kubectl logs -n projects -l app=my-fullstack-app -c frontend
# Check backend logs
kubectl logs -n projects -l app=my-fullstack-app -c backend
```
---
## Related
- [Landing Page Cookbook](./landing-page.md) - Simpler single-page deployment
- [Worker Pool Guide](../.claude/guides/services/worker-pool.md)
- [Build Orchestration](../.claude/guides/services/build-orchestration.md)

View File

@ -1,28 +1,26 @@
# Landing Page Cookbook
> Deploy a landing page built by a Claude agent through the threesix.ai infrastructure.
> Deploy a landing page using composable templates through the threesix.ai infrastructure.
## Overview
This cookbook creates and deploys a landing page using **agent-driven development**:
This cookbook creates and deploys a landing page using **composable monorepo templates**:
```
POST /project/create-and-build
POST /projects
Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment
Creates: Monorepo skeleton + Gitea repo + DNS + CI
Enqueues build task with prompt
POST /projects/{id}/components (type: app, template: app-astro)
Worker picks up task → Claude builds the site
Adds: Astro landing page component with valid CI step
Agent commits + pushes
Git push triggers Woodpecker CI
CI builds and deploys
Live site
Live site at https://{slug}.threesix.ai
```
**No templates. Claude builds it from scratch based on your prompt.**
**Template-driven. CI is pre-configured. No AI-generated YAML.**
---
@ -38,24 +36,18 @@ export RDEV_API_KEY="<your-api-key>"
- rdev-api running with embedded worker
- Gitea at https://git.threesix.ai
- Woodpecker CI at https://ci.threesix.ai
- claudebox-0 pod running in rdev namespace
---
## Step 1: Create Project and Build in One Call
Single API call that creates infrastructure AND enqueues agent work:
## Step 1: Create Project (Monorepo Skeleton)
```bash
curl -X POST "$RDEV_API_URL/project/create-and-build" \
curl -X POST "$RDEV_API_URL/projects" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-landing",
"description": "Company landing page",
"prompt": "Build a modern landing page with: dark gradient background, centered hero section with company name and tagline, email signup form, responsive design. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a simple Dockerfile that serves with nginx.",
"auto_commit": true,
"auto_push": true
"description": "Company landing page"
}'
```
@ -71,53 +63,79 @@ curl -X POST "$RDEV_API_URL/project/create-and-build" \
"owner": "jordan",
"name": "my-landing",
"html_url": "https://git.threesix.ai/jordan/my-landing"
},
"task_id": "task-uuid",
"status": "pending",
"status_url": "/builds/task-uuid"
},
"meta": { "timestamp": "..." }
}
}
}
```
This creates a monorepo skeleton with:
- `README.md`, `CLAUDE.md`, `Procfile`
- `.woodpecker.yml` (template-provided, valid CI)
- `scripts/` (discover, install, quality, dev)
- `pkg/` (shared Go packages)
- Empty component directories (`services/`, `apps/`, `workers/`, `cli/`)
---
## Step 2: Monitor Build Progress
## Step 2: Add Landing Page Component
Poll the build status:
```bash
curl -X POST "$RDEV_API_URL/projects/my-landing/components" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "app",
"name": "landing",
"template": "app-astro"
}'
```
**Response:**
```json
{
"data": {
"type": "app",
"name": "landing",
"path": "apps/landing",
"port": 3001,
"template": "app-astro"
}
}
```
This adds:
- `apps/landing/` - Astro project with Tailwind CSS
- Updates `.woodpecker.yml` with build step for this component
- Updates `Procfile` with dev server entry
---
## Step 3: Customize with Claude (Optional)
Submit a build task to customize the landing page:
```bash
curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Update apps/landing to have: dark gradient background, centered hero section with company name \"Acme Corp\" and tagline \"Building the future\", email signup form with shadcn styling. Keep the existing Astro structure.",
"auto_commit": true,
"auto_push": true
}'
```
Monitor the build:
```bash
curl -s "$RDEV_API_URL/builds/{task_id}" \
-H "X-API-Key: $RDEV_API_KEY" | jq .data
```
**Status progression:** `pending``running``completed` (or `failed`)
When completed:
```json
{
"task_id": "task-uuid",
"project_id": "my-landing",
"status": "completed",
"prompt": "Build a modern landing page...",
"auto_commit": true,
"auto_push": true,
"started_at": "2025-01-29T10:00:00Z",
"completed_at": "2025-01-29T10:00:45Z",
"result": {
"success": true,
"commit_sha": "abc123",
"files_changed": ["index.html", "styles.css", "Dockerfile", "nginx.conf"],
"duration_ms": 45000
}
}
```
---
## Step 3: Monitor CI Pipeline
## Step 4: Monitor CI Pipeline
The agent's push triggers Woodpecker CI:
The push triggers Woodpecker CI automatically:
```bash
curl -s "$RDEV_API_URL/projects/my-landing/pipelines" \
@ -128,7 +146,7 @@ Wait for `status: "success"`.
---
## Step 4: Verify Deployment
## Step 5: Verify Deployment
```bash
# Check site is live
@ -140,35 +158,6 @@ open https://abc123xy.threesix.ai
---
## Alternative: Two-Step Flow
If you prefer to create the project first, then submit builds separately:
### Create Project (empty repo)
```bash
curl -X POST "$RDEV_API_URL/project" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-landing",
"description": "Company landing page"
}'
```
### Submit Build Task
```bash
curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Build a modern landing page with dark theme, hero section, and email signup form. Use HTML/CSS/JS with nginx Dockerfile.",
"auto_commit": true,
"auto_push": true
}'
```
---
## Iterating on the Site
Submit additional builds to modify the site:
@ -178,7 +167,7 @@ curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Add a pricing section with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.",
"prompt": "Add a pricing section to apps/landing with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.",
"auto_commit": true,
"auto_push": true
}'
@ -211,7 +200,7 @@ curl -s "$RDEV_API_URL/projects/my-landing/domains" \
## Teardown
```bash
curl -X DELETE "$RDEV_API_URL/project/my-landing" \
curl -X DELETE "$RDEV_API_URL/projects/my-landing" \
-H "X-API-Key: $RDEV_API_KEY"
```
@ -242,34 +231,31 @@ Cleanup:
```
┌─────────────────────────────────────────────────────────────────────┐
Agent-Driven Landing Page
Composable Landing Page Deployment
│ │
│ POST /project/create-and-build
│ POST /projects
│ │ │
│ ├──► Gitea: creates repo
│ ├──► Gitea: creates repo with skeleton
│ ├──► Cloudflare: creates DNS │
│ ├──► Woodpecker: activates CI │
│ ├──► K8s: creates Deployment/Service/Ingress │
│ └──► Work Queue: enqueues build task │
│ │ │
│ ▼ │
│ Worker polls queue, claims task │
│ │ │
│ ▼ │
│ Claude Code executes in claudebox-0: │
│ - Clones repo │
│ - Builds site from prompt │
│ - Commits and pushes │
│ │ │
│ ▼ │
│ Woodpecker CI triggered by push: │
│ - Builds Docker image │
│ - Pushes to registry │
│ - Updates K8s deployment │
│ │ │
│ ▼ │
│ Site live at https://{slug}.threesix.ai │
│ └──► Skeleton includes .woodpecker.yml (template-provided) │
│ │
│ POST /projects/{id}/components │
│ │ │
│ ├──► Adds apps/landing/ from app-astro template │
│ ├──► Updates .woodpecker.yml with build step │
│ └──► Updates Procfile │
│ │
│ POST /projects/{id}/builds (optional customization) │
│ │ │
│ └──► Claude modifies existing files, commits, pushes │
│ │
│ Git push triggers Woodpecker CI: │
│ ├──► Builds Docker image via Kaniko │
│ ├──► Pushes to registry.threesix.ai │
│ └──► Deploys to K8s │
│ │
│ Site live at https://{slug}.threesix.ai │
└─────────────────────────────────────────────────────────────────────┘
```
@ -303,10 +289,17 @@ curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-landing/commits" | jq '.
open https://ci.threesix.ai/jordan/my-landing
```
### Component not appearing
```bash
# List components
curl -s "$RDEV_API_URL/projects/my-landing/components" \
-H "X-API-Key: $RDEV_API_KEY" | jq '.data'
```
---
## Related
- [Full-Stack App Cookbook](./fullstack-app.md) - Next.js + Go backend
- [Composable App Cookbook](./composable-app.md) - Full-stack apps with multiple components
- [Worker Pool Guide](../.claude/guides/services/worker-pool.md)
- [Build Orchestration](../.claude/guides/services/build-orchestration.md)
- [Composable Monorepo Guide](../.claude/guides/services/composable-monorepo.md)

View File

@ -0,0 +1,194 @@
#!/bin/bash
set -euo pipefail
# Composable App E2E Test Script
# Tests the composable monorepo template flow:
# 1. Create project (skeleton)
# 2. Add service component
# 3. Add app component
# 4. Optionally customize with Claude
# 5. Deploy and verify
#
# Usage: ./cookbooks/scripts/composable-test.sh <command> <project-name>
# Commands: run, status, teardown
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}"
PROJECT_NAME="${2:-}"
if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then
echo "Usage: $0 <command> <project-name>"
echo "Commands:"
echo " run - Create project with components and deploy"
echo " status - Check project and component status"
echo " teardown - Delete the project"
exit 1
fi
# Add a component and verify
add_component() {
local comp_type="$1"
local comp_name="$2"
local template="${3:-$comp_type}"
echo "Adding $comp_type component: $comp_name (template: $template)"
local payload
payload=$(jq -n \
--arg type "$comp_type" \
--arg name "$comp_name" \
--arg template "$template" \
'{type: $type, name: $name, template: $template}')
local result
result=$(api_call POST "/projects/$PROJECT_NAME/components" "$payload")
local path
path=$(echo "$result" | jq -r '.data.path // .path // ""')
if [[ -z "$path" ]]; then
print_error "Failed to add component"
echo "$result" | jq '.'
return 1
fi
local port
port=$(echo "$result" | jq -r '.data.port // .port // "N/A"')
print_success "Added $comp_type/$comp_name at $path (port: $port)"
return 0
}
run_flow() {
print_header "Composable App E2E Test"
echo "Project: $PROJECT_NAME"
# Step 1: Create project (skeleton)
print_header "Step 1: Creating project skeleton"
local create_payload
create_payload=$(jq -n \
--arg name "$PROJECT_NAME" \
--arg desc "Composable app E2E test" \
'{name: $name, description: $desc}')
local create_result
create_result=$(api_call POST "/projects" "$create_payload")
echo "$create_result" | jq '.'
local domain
domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""')
if [[ -z "$domain" ]]; then
print_error "Failed to create project"
exit 1
fi
print_success "Project created with domain: $domain"
# Step 2: Add backend service
print_header "Step 2: Adding backend service"
if ! add_component "service" "api" "service"; then
exit 1
fi
# Step 3: Add frontend app
print_header "Step 3: Adding frontend app"
if ! add_component "app" "web" "app-react"; then
exit 1
fi
# Step 4: List components
print_header "Step 4: Verifying components"
local components
components=$(api_call GET "/projects/$PROJECT_NAME/components")
echo "$components" | jq '.data // .'
local comp_count
comp_count=$(echo "$components" | jq '.data | length // 0')
if [[ "$comp_count" -lt 2 ]]; then
print_warning "Expected 2 components, got $comp_count"
else
print_success "All components added successfully"
fi
# Step 5: Wait for CI pipeline
print_header "Step 5: Waiting for CI pipeline"
if ! wait_for_pipeline "$PROJECT_NAME"; then
print_warning "Pipeline may have issues, continuing to check site..."
fi
# Step 6: Wait for site
print_header "Step 6: Verifying site is accessible"
if ! wait_for_site "$domain"; then
print_error "Site not accessible"
exit 1
fi
# Step 7: Test API endpoint
print_header "Step 7: Testing API endpoint"
local api_response
api_response=$(curl -s "https://$domain/api/health" 2>/dev/null || echo '{"error":"failed"}')
if echo "$api_response" | jq -e '.' > /dev/null 2>&1; then
print_success "API responded with valid JSON"
echo "$api_response" | jq '.'
else
print_warning "API health check returned non-JSON: $api_response"
fi
# Summary
print_header "E2E Test Results"
print_success "Project created: $PROJECT_NAME"
print_success "Components added: $comp_count"
echo ""
echo "Site URL: https://$domain"
echo "Git repo: https://git.threesix.ai/jordan/$PROJECT_NAME"
echo "CI: https://ci.threesix.ai/jordan/$PROJECT_NAME"
}
check_status() {
print_header "Project Status: $PROJECT_NAME"
# Get project info
echo "Project:"
api_call GET "/projects/$PROJECT_NAME" | jq '.data // .'
echo ""
# Get components
echo "Components:"
api_call GET "/projects/$PROJECT_NAME/components" | jq '.data // .'
echo ""
# Get latest pipelines
echo "Latest Pipelines:"
api_call GET "/projects/$PROJECT_NAME/pipelines" | jq '.data[:3] // .'
}
teardown() {
print_header "Tearing down: $PROJECT_NAME"
local result
result=$(api_call DELETE "/projects/$PROJECT_NAME")
echo "$result" | jq '.'
echo ""
print_success "Project deleted. Gitea repo preserved."
}
case "$COMMAND" in
run)
run_flow
;;
status)
check_status
;;
teardown)
teardown
;;
*)
echo "Unknown command: $COMMAND"
echo "Valid commands: run, status, teardown"
exit 1
;;
esac

View File

@ -1,202 +0,0 @@
#!/bin/bash
set -euo pipefail
# Full-Stack App E2E Test Script
# Usage: ./cookbooks/scripts/fullstack-test.sh <command> <project-name>
# Commands: run, status, teardown
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
COMMAND="${1:-}"
PROJECT_NAME="${2:-}"
if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then
echo "Usage: $0 <command> <project-name>"
echo "Commands:"
echo " run - Create project and run full-stack build"
echo " status - Check build and deployment status"
echo " teardown - Delete the project"
exit 1
fi
# Full-stack app build prompt
FULLSTACK_PROMPT='Build a full-stack task management application with the following structure:
FRONTEND (Next.js 14 + shadcn/ui):
- Create a Next.js 14 app with App Router in /frontend
- Use shadcn/ui for all components (install with npx shadcn-ui@latest init)
- Dark theme with modern aesthetic
- Pages: Dashboard showing tasks, Add Task form, Task detail view
- Use Tailwind CSS for styling
- Connect to backend API at /api proxy
BACKEND (Go):
- Create a Go HTTP server in /backend using chi router
- Endpoints: GET /api/tasks, POST /api/tasks, GET /api/tasks/{id}, DELETE /api/tasks/{id}
- In-memory task storage (no database needed)
- Structured JSON responses
- CORS middleware for frontend
DOCKER:
- /frontend/Dockerfile: Multi-stage build for Next.js (node:20-alpine)
- /backend/Dockerfile: Multi-stage build for Go (golang:1.22-alpine)
- /docker-compose.yml: Run both services, frontend proxies to backend
CI/CD:
- /.woodpecker.yml: Build both images, push to registry, deploy to k8s
Create all necessary files including package.json, go.mod, and configuration files.'
# Test backend API
test_backend_api() {
local domain="$1"
echo "Testing backend API..."
# Test GET /api/tasks
local response
response=$(curl -s "https://$domain/api/tasks" 2>/dev/null || echo '{"error":"failed"}')
if echo "$response" | jq -e '.' > /dev/null 2>&1; then
echo " GET /api/tasks: OK"
echo " Response: $response"
return 0
else
echo " GET /api/tasks: FAILED"
echo " Response: $response"
return 1
fi
}
run_flow() {
echo "=== Full-Stack App E2E Test ==="
echo "Project: $PROJECT_NAME"
echo ""
# Step 1: Create project with build
echo "Step 1: Creating project and submitting full-stack build..."
local create_result
# Build the JSON payload (prompt, auto_commit, auto_push are top-level fields)
local payload
payload=$(jq -n \
--arg name "$PROJECT_NAME" \
--arg desc "Full-stack app E2E test" \
--arg prompt "$FULLSTACK_PROMPT" \
'{
name: $name,
description: $desc,
prompt: $prompt,
auto_commit: true,
auto_push: true
}')
create_result=$(api_call POST "/project/create-and-build" "$payload")
echo "$create_result" | jq '.'
local domain
domain=$(echo "$create_result" | jq -r '.data.domain // .domain // ""')
local task_id
task_id=$(echo "$create_result" | jq -r '.data.task_id // .task_id // ""')
if [[ -z "$domain" || -z "$task_id" ]]; then
echo "ERROR: Failed to create project"
exit 1
fi
echo ""
echo "Domain: $domain"
echo "Build Task: $task_id"
echo ""
# Step 2: Wait for build
echo "Step 2: Waiting for Claude to build the full-stack app..."
if ! wait_for_build "$task_id"; then
echo "ERROR: Build failed"
exit 1
fi
echo ""
# Step 3: Wait for CI pipeline
echo "Step 3: Waiting for CI pipeline to build and deploy..."
if ! wait_for_pipeline "$PROJECT_NAME"; then
echo "WARNING: Pipeline may have failed, continuing to check site..."
fi
echo ""
# Step 4: Wait for site
echo "Step 4: Verifying site is accessible..."
if ! wait_for_site "$domain"; then
echo "ERROR: Site not accessible"
exit 1
fi
echo ""
# Step 5: Test backend API
echo "Step 5: Testing backend API..."
if ! test_backend_api "$domain"; then
echo "WARNING: Backend API test failed"
fi
echo ""
# Summary
echo "=== E2E Test Results ==="
echo "Project created: PASS"
echo "Build completed: PASS"
echo "CI Pipeline: $(wait_for_pipeline "$PROJECT_NAME" > /dev/null 2>&1 && echo "PASS" || echo "CHECK")"
echo "Site accessible: PASS"
echo "Backend API: $(test_backend_api "$domain" > /dev/null 2>&1 && echo "PASS" || echo "CHECK")"
echo ""
echo "Site URL: https://$domain"
echo "Git repo: https://git.threesix.ai/jordan/$PROJECT_NAME"
echo "CI: https://ci.threesix.ai/jordan/$PROJECT_NAME"
}
check_status() {
echo "=== Project Status: $PROJECT_NAME ==="
echo ""
# Get project info
local project_result
project_result=$(api_call GET "/projects/$PROJECT_NAME")
echo "Project:"
echo "$project_result" | jq '.'
echo ""
# Get latest build
echo "Latest Builds:"
api_call GET "/projects/$PROJECT_NAME/builds" | jq '.data[:3]'
echo ""
# Get latest pipeline
echo "Latest Pipelines:"
api_call GET "/projects/$PROJECT_NAME/pipelines" | jq '.data[:3]'
}
teardown() {
echo "=== Tearing down: $PROJECT_NAME ==="
local result
result=$(api_call DELETE "/project/$PROJECT_NAME")
echo "$result" | jq '.'
echo ""
echo "Project deleted. Gitea repo preserved."
}
case "$COMMAND" in
run)
run_flow
;;
status)
check_status
;;
teardown)
teardown
;;
*)
echo "Unknown command: $COMMAND"
echo "Valid commands: run, status, teardown"
exit 1
;;
esac

View File

@ -1,6 +1,12 @@
#!/bin/bash
# Landing Page Cookbook Test Script
# Tests the full agent-driven landing page flow from cookbooks/landing-page.md
# Tests the composable landing page flow from cookbooks/landing-page.md
#
# Flow:
# 1. Create project (monorepo skeleton)
# 2. Add app-astro component
# 3. Wait for CI pipeline
# 4. Verify site is live
#
# Usage:
# ./cookbooks/scripts/landing-test.sh run [name] # Run the full flow
@ -26,14 +32,9 @@ log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Timeouts
BUILD_TIMEOUT=600 # 10 minutes for Claude to build the site
BUILD_POLL_INTERVAL=5 # Check every 5 seconds
PIPELINE_TIMEOUT=300 # 5 minutes max wait for CI pipeline
PIPELINE_POLL_INTERVAL=10
SITE_TIMEOUT=60 # 1 minute max wait for site to be live
# Streaming mode (set to true to stream live build output via SSE)
STREAM_MODE="${STREAM_MODE:-false}"
SITE_TIMEOUT=120 # 2 minutes max wait for site to be live
api_call() {
local method="$1"
@ -65,136 +66,7 @@ check_health() {
fi
}
# Stream build events via SSE (real-time output)
# Arguments: project_id, task_id
stream_build_events() {
local project_id="$1"
local task_id="$2"
local stream_url="${API_URL}/projects/${project_id}/events?stream_id=${task_id}"
log_info "Streaming build events from: $stream_url"
echo ""
# Use curl to stream SSE events
curl -s -N \
-H "X-API-Key: ${API_KEY}" \
-H "Accept: text/event-stream" \
"$stream_url" 2>/dev/null | while IFS= read -r line; do
# Skip empty lines and event headers
if [[ -z "$line" || "$line" == "event:"* || "$line" == "id:"* ]]; then
continue
fi
# Parse data lines
if [[ "$line" == "data:"* ]]; then
local data="${line#data: }"
# Parse event type and content
local event_type content
event_type=$(echo "$data" | jq -r '.type // "unknown"' 2>/dev/null)
case "$event_type" in
build.started)
echo -e "${GREEN}[BUILD STARTED]${NC}"
;;
build.output)
content=$(echo "$data" | jq -r '.content // ""' 2>/dev/null)
[[ -n "$content" ]] && echo "$content"
;;
build.tool_use)
local tool_name
tool_name=$(echo "$data" | jq -r '.tool_name // "unknown"' 2>/dev/null)
echo -e "${YELLOW}[TOOL: $tool_name]${NC}"
;;
build.completed)
echo -e "${GREEN}[BUILD COMPLETED]${NC}"
return 0
;;
build.failed)
local error
error=$(echo "$data" | jq -r '.error // "unknown error"' 2>/dev/null)
echo -e "${RED}[BUILD FAILED]${NC} $error"
return 1
;;
esac
fi
done
}
# Wait for build to complete (Claude building the site)
# Returns: 0 on success, 1 on failure/timeout
wait_for_build() {
local task_id="$1"
local project_id="${2:-}"
local start_time=$(date +%s)
log_info "Waiting for Claude to build the site (timeout: ${BUILD_TIMEOUT}s)..."
# If streaming mode is enabled and we have a project_id, use SSE
if [[ "$STREAM_MODE" == "true" && -n "$project_id" ]]; then
log_info "Streaming mode enabled - showing live build output"
stream_build_events "$project_id" "$task_id" &
local stream_pid=$!
fi
while true; do
local elapsed=$(($(date +%s) - start_time))
if [[ $elapsed -ge $BUILD_TIMEOUT ]]; then
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
log_error "Build timeout after ${BUILD_TIMEOUT}s"
return 1
fi
local response
response=$(api_call GET "/builds/$task_id" 2>/dev/null || echo "{}")
local status
status=$(echo "$response" | jq -r '.data.status // "unknown"' 2>/dev/null)
case "$status" in
completed)
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
local success
success=$(echo "$response" | jq -r '.data.result.success // false')
if [[ "$success" == "true" ]]; then
log_success "Build completed successfully (${elapsed}s)"
echo "$response" | jq '.data.result | {success, commit_sha, files_changed, duration_ms}'
return 0
else
log_error "Build completed but failed"
echo "$response" | jq '.data.result'
return 1
fi
;;
failed)
[[ -n "${stream_pid:-}" ]] && kill "$stream_pid" 2>/dev/null || true
log_error "Build failed"
echo "$response" | jq '.data.result // .data'
return 1
;;
running)
if [[ "$STREAM_MODE" != "true" ]]; then
echo -ne "\r${BLUE}[INFO]${NC} Build status: running (${elapsed}s)... "
fi
;;
pending)
if [[ "$STREAM_MODE" != "true" ]]; then
echo -ne "\r${BLUE}[INFO]${NC} Build status: pending (${elapsed}s)... "
fi
;;
*)
if [[ "$STREAM_MODE" != "true" ]]; then
echo -ne "\r${BLUE}[INFO]${NC} Build status: $status (${elapsed}s)... "
fi
;;
esac
sleep $BUILD_POLL_INTERVAL
done
}
# Wait for pipeline to appear and complete
# Returns: 0 on success, 1 on failure/timeout
wait_for_pipeline() {
local project_name="$1"
local start_time=$(date +%s)
@ -214,7 +86,7 @@ wait_for_pipeline() {
local response
response=$(api_call GET "/projects/$project_name/pipelines" 2>/dev/null || echo "{}")
# Check if we have pipelines (API returns array at .data)
# Check if we have pipelines
local pipeline_count
pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0")
@ -230,10 +102,12 @@ wait_for_pipeline() {
case "$pipeline_status" in
success)
echo ""
log_success "Pipeline #$pipeline_number completed successfully (${elapsed}s)"
return 0
;;
failure|error|killed|declined)
echo ""
log_error "Pipeline #$pipeline_number failed with status: $pipeline_status"
echo "$response" | jq '.data[0]'
return 1
@ -254,7 +128,6 @@ wait_for_pipeline() {
}
# Wait for site to be accessible
# Returns: 0 on success, 1 on failure/timeout
wait_for_site() {
local domain="$1"
local start_time=$(date +%s)
@ -285,393 +158,167 @@ wait_for_site() {
done
}
# Test adding a DNS alias
test_dns_alias() {
local project_name="$1"
local alias_domain="$2"
log_info "Testing DNS alias: $alias_domain"
local response
response=$(api_call POST "/projects/$project_name/domains" "{\"domain\": \"$alias_domain\"}")
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
local error_code
error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"')
if [[ "$error_code" == "DOMAIN_EXISTS" ]]; then
log_warn "Domain alias already exists: $alias_domain"
return 0
fi
log_error "Failed to add DNS alias"
echo "$response" | jq .
return 1
fi
log_success "DNS alias added: $alias_domain"
echo "$response" | jq '.data | {domain, type, record_type}'
return 0
}
# Remove a DNS alias
remove_dns_alias() {
local project_name="$1"
local alias_domain="$2"
log_info "Removing DNS alias: $alias_domain"
local response
response=$(api_call DELETE "/projects/$project_name/domains/$alias_domain")
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
local error_code
error_code=$(echo "$response" | jq -r '.error.code // "UNKNOWN"')
if [[ "$error_code" == "NOT_FOUND" ]]; then
log_warn "Domain alias not found (already deleted?): $alias_domain"
return 0
fi
log_warn "Failed to remove DNS alias: $alias_domain"
return 1
fi
log_success "DNS alias removed: $alias_domain"
return 0
}
# Main run flow
run_flow() {
local project_name="${1:-landing-test}"
# Default prompt for building a landing page
local build_prompt="Build a modern landing page with: dark gradient background (#1a1a2e to #16213e), centered hero section with company name 'Acme Corp' and tagline 'Building the future', email signup form with a submit button, responsive design for mobile. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a Dockerfile that serves with nginx on port 80."
local project_name="$1"
echo ""
echo "=========================================="
echo " Landing Page Cookbook Test"
echo " Project: $project_name"
echo " Flow: Agent-driven (create-and-build)"
echo " Landing Page E2E Test (Composable)"
echo "=========================================="
echo ""
echo "Project: $project_name"
echo ""
# Step 0: Health check
check_health || exit 1
echo ""
# Step 1: Create project AND enqueue build in one call
log_info "Step 1: Creating project and enqueuing build task..."
log_info "Prompt: ${build_prompt:0:80}..."
local create_payload
create_payload=$(jq -n \
--arg name "$project_name" \
--arg desc "Cookbook test: agent-driven landing page" \
--arg prompt "$build_prompt" \
'{
name: $name,
description: $desc,
prompt: $prompt,
auto_commit: true,
auto_push: true
}')
# Step 1: Create project (monorepo skeleton)
log_info "Step 1: Creating project skeleton..."
local create_response
create_response=$(api_call POST "/project/create-and-build" "$create_payload")
create_response=$(api_call POST "/projects" "{\"name\": \"$project_name\", \"description\": \"Landing page E2E test\"}")
if echo "$create_response" | jq -e '.error' > /dev/null 2>&1; then
local domain
domain=$(echo "$create_response" | jq -r '.data.domain // ""')
if [[ -z "$domain" || "$domain" == "null" ]]; then
log_error "Failed to create project"
echo "$create_response" | jq .
exit 1
fi
log_success "Project created and build enqueued"
echo "$create_response" | jq '.data | {
project_id,
domain,
url,
git: .git.html_url,
task_id,
status,
status_url
}'
log_success "Project created: $project_name"
echo " Domain: $domain"
echo " Git: https://git.threesix.ai/jordan/$project_name"
echo ""
# Extract key info
local primary_domain
local task_id
primary_domain=$(echo "$create_response" | jq -r '.data.domain')
task_id=$(echo "$create_response" | jq -r '.data.task_id')
# Step 2: Add app-astro component
log_info "Step 2: Adding landing page component (app-astro)..."
if [[ -z "$task_id" || "$task_id" == "null" ]]; then
log_error "No task_id returned - build was not enqueued"
local component_response
component_response=$(api_call POST "/projects/$project_name/components" '{"type": "app", "name": "landing", "template": "app-astro"}')
local component_path
component_path=$(echo "$component_response" | jq -r '.data.path // ""')
if [[ -z "$component_path" || "$component_path" == "null" ]]; then
log_error "Failed to add component"
echo "$component_response" | jq .
exit 1
fi
log_success "Build task ID: $task_id"
local component_port
component_port=$(echo "$component_response" | jq -r '.data.port // "N/A"')
log_success "Component added: $component_path (port: $component_port)"
echo ""
# Step 2: Monitor build progress (Claude building the site)
log_info "Step 2: Monitoring build progress..."
# Step 3: Wait for pipeline
log_info "Step 3: Waiting for CI pipeline..."
echo ""
local build_success=false
if wait_for_build "$task_id" "$project_name"; then
build_success=true
else
log_error "Build did not complete successfully"
log_info "Check build details: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq ."
if ! wait_for_pipeline "$project_name"; then
log_warn "Pipeline failed, but continuing to check if site is accessible..."
fi
echo ""
# Step 3: Monitor CI pipeline (only if build succeeded)
local pipeline_success=false
if [[ "$build_success" == "true" ]]; then
log_info "Step 3: Monitoring CI pipeline..."
if wait_for_pipeline "$project_name"; then
pipeline_success=true
else
log_warn "Pipeline did not complete successfully"
log_info "Check Woodpecker: https://ci.threesix.ai/threesix/$project_name"
fi
else
log_info "Step 3: Skipping pipeline monitoring (build failed)"
fi
echo ""
# Step 4: Wait for site
log_info "Step 4: Verifying site is accessible..."
# Step 4: Verify site is live
local site_live=false
if [[ "$pipeline_success" == "true" ]]; then
log_info "Step 4: Verifying site is accessible..."
if wait_for_site "$primary_domain"; then
site_live=true
# Show a snippet of the response
log_info "Fetching site content preview..."
curl -s "https://$primary_domain" | head -20 | grep -E '<title>|<h1' || true
fi
if wait_for_site "$domain"; then
log_success "Site verified!"
else
log_info "Step 4: Skipping site verification (pipeline not successful)"
fi
echo ""
# Step 5: Test adding custom domains
local test_alias="${project_name}-alias.threesix.ai"
log_info "Step 5: Testing custom domain functionality..."
if test_dns_alias "$project_name" "$test_alias"; then
log_success "Domain alias test passed"
# Clean up test alias
sleep 2
remove_dns_alias "$project_name" "$test_alias"
else
log_warn "Domain alias test failed - check Cloudflare permissions"
fi
echo ""
# List all domains
log_info "Listing all project domains..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then
echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}'
log_warn "Site not accessible yet, may need more time"
fi
echo ""
# Summary
echo "=========================================="
echo " Test Results Summary"
echo " Test Complete"
echo "=========================================="
echo ""
echo " Project: $project_name"
echo " Task ID: $task_id"
echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url // "N/A"')"
echo " Primary: https://$primary_domain"
echo " Site URL: https://$domain"
echo " Git: https://git.threesix.ai/jordan/$project_name"
echo " CI: https://ci.threesix.ai/jordan/$project_name"
echo ""
echo " Test Results:"
echo -e " Project created: ${GREEN}PASS${NC}"
if [[ "$build_success" == "true" ]]; then
echo -e " Agent build: ${GREEN}PASS${NC}"
else
echo -e " Agent build: ${RED}FAIL${NC}"
fi
if [[ "$pipeline_success" == "true" ]]; then
echo -e " CI Pipeline: ${GREEN}PASS${NC}"
elif [[ "$build_success" == "true" ]]; then
echo -e " CI Pipeline: ${RED}FAIL${NC}"
else
echo -e " CI Pipeline: ${YELLOW}SKIPPED${NC}"
fi
if [[ "$site_live" == "true" ]]; then
echo -e " Site accessible: ${GREEN}PASS${NC}"
elif [[ "$pipeline_success" == "true" ]]; then
echo -e " Site accessible: ${YELLOW}PENDING${NC}"
else
echo -e " Site accessible: ${YELLOW}SKIPPED${NC}"
fi
echo -e " Custom domains: ${GREEN}TESTED${NC}"
echo " To customize: POST /projects/$project_name/builds with a prompt"
echo " To teardown: $0 teardown $project_name"
echo ""
echo " Useful commands:"
echo " Build status: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq .data"
echo " Check status: ./cookbooks/scripts/landing-test.sh status $project_name"
echo " View logs: ./scripts/logs.sh -e"
echo " Woodpecker: https://ci.threesix.ai/threesix/$project_name"
echo " Teardown: ./cookbooks/scripts/landing-test.sh teardown $project_name"
echo ""
# Return appropriate exit code
if [[ "$build_success" == "true" && "$pipeline_success" == "true" && "$site_live" == "true" ]]; then
log_success "Full E2E test PASSED"
return 0
elif [[ "$build_success" == "true" && "$pipeline_success" == "true" ]]; then
log_warn "Partial success - build and pipeline passed but site not yet live"
return 0
elif [[ "$build_success" == "true" ]]; then
log_warn "Partial success - build passed but pipeline failed"
return 1
else
log_error "E2E test FAILED - build did not complete"
return 1
fi
}
# Check status
check_status() {
local project_name="$1"
echo ""
echo "=== Project Status: $project_name ==="
echo ""
log_info "Project info:"
api_call GET "/projects/$project_name" | jq '.data // .'
echo ""
log_info "Components:"
api_call GET "/projects/$project_name/components" | jq '.data // .'
echo ""
log_info "Latest pipelines:"
api_call GET "/projects/$project_name/pipelines" | jq '.data[:3] // .'
echo ""
}
# Teardown
teardown() {
local project_name="${1:-landing-test}"
local project_name="$1"
echo ""
echo "=========================================="
echo " Teardown: $project_name"
echo "=========================================="
echo ""
log_info "Tearing down project: $project_name"
# First, list domains that will be deleted
log_info "Checking domains to be deleted..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.data.total' > /dev/null 2>&1; then
local domain_count
domain_count=$(echo "$domains_response" | jq -r '.data.total')
log_info "Will delete $domain_count domain(s):"
echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain)"'
echo ""
fi
log_info "Deleting project $project_name..."
local response
response=$(api_call DELETE "/project/$project_name")
if echo "$response" | jq -e '.data.status == "deleted"' > /dev/null 2>&1; then
log_success "Project deleted"
echo "$response" | jq .data
elif echo "$response" | jq -e '.error.code == "NOT_FOUND"' > /dev/null 2>&1; then
log_warn "Project not found (already deleted?)"
else
log_error "Failed to delete project"
echo "$response" | jq .
exit 1
fi
echo ""
log_info "Note: Gitea repo is preserved for safety. Delete manually if needed:"
echo " https://git.threesix.ai/threesix/$project_name/settings"
echo ""
}
status() {
local project_name="${1:-landing-test}"
echo ""
log_info "Fetching status for: $project_name"
echo ""
# Get project info
local response
response=$(api_call GET "/project/$project_name")
response=$(api_call DELETE "/projects/$project_name")
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
log_error "Project not found or error"
log_error "Teardown failed"
echo "$response" | jq .
exit 1
fi
echo "$response" | jq '.data | {
name,
description,
domain,
url,
git: .git.html_url,
deployment
}'
log_success "Project deleted (Gitea repo preserved)"
echo "$response" | jq '.data // .'
echo ""
log_info "Listing all domains..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then
echo "$domains_response" | jq '.data.domains'
fi
echo ""
log_info "Checking recent builds..."
local builds_response
builds_response=$(api_call GET "/projects/$project_name/builds?limit=3")
if echo "$builds_response" | jq -e '.data.builds' > /dev/null 2>&1; then
echo "$builds_response" | jq '.data.builds[] | {task_id, status, started_at, result: .result.success}'
else
log_info "No builds found"
fi
echo ""
log_info "Checking recent pipelines..."
local pipelines_response
pipelines_response=$(api_call GET "/projects/$project_name/pipelines")
if echo "$pipelines_response" | jq -e '.data | length > 0' > /dev/null 2>&1; then
echo "$pipelines_response" | jq '.data[0:3][] | {number, status, branch, commit: .commit[0:8]}'
else
log_info "No pipelines found"
fi
}
# Main
case "${1:-}" in
# Parse command
COMMAND="${1:-}"
PROJECT_NAME="${2:-landing-test-$(date +%s)}"
case "$COMMAND" in
run)
shift
run_flow "${1:-landing-test}"
;;
teardown)
shift
teardown "${1:-landing-test}"
run_flow "$PROJECT_NAME"
;;
status)
shift
status "${1:-landing-test}"
check_status "$PROJECT_NAME"
;;
teardown)
teardown "$PROJECT_NAME"
;;
*)
echo "Usage: $0 {run|teardown|status} [project-name]"
echo "Landing Page E2E Test Script"
echo ""
echo "Usage: $0 <command> [project-name]"
echo ""
echo "Commands:"
echo " run [name] Create project with agent-driven build and run full E2E flow"
echo " teardown [name] Delete project and clean up"
echo " status [name] Check current project status, builds, and pipelines"
echo ""
echo "E2E Flow (matches cookbooks/landing-page.md):"
echo " 1. POST /project/create-and-build - Create project + enqueue agent build"
echo " 2. GET /builds/{task_id} - Monitor Claude building the site"
echo " 3. GET /projects/{id}/pipelines - Monitor CI pipeline"
echo " 4. Verify site is live (HTTP 200)"
echo " 5. Test custom domains (POST/DELETE /projects/{id}/domains)"
echo " 6. DELETE /project/{name} - Teardown"
echo ""
echo "Timeouts:"
echo " Build: ${BUILD_TIMEOUT}s, Pipeline: ${PIPELINE_TIMEOUT}s, Site: ${SITE_TIMEOUT}s"
echo ""
echo "Environment:"
echo " RDEV_API_URL API endpoint (default: https://rdev.masq-ops.orchard9.ai)"
echo " RDEV_API_KEY API key (required)"
echo " STREAM_MODE Set to 'true' for live SSE streaming of build output"
echo " run Run the full composable landing page flow"
echo " status Check project and component status"
echo " teardown Delete project (preserves git repo)"
echo ""
echo "Examples:"
echo " $0 run # Run with default project name 'landing-test'"
echo " $0 run my-landing # Run with custom project name"
echo " STREAM_MODE=true $0 run # Run with live build output streaming"
echo " $0 status my-landing # Check status, builds, and pipelines"
echo " $0 teardown my-landing # Clean up project"
echo " $0 run my-landing"
echo " $0 status my-landing"
echo " $0 teardown my-landing"
echo ""
exit 1
;;
esac

View File

@ -0,0 +1,303 @@
package deployer
import (
"bytes"
"context"
"fmt"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/orchard9/rdev/internal/domain"
)
// UndeployComponent removes deployment resources for a specific component.
func (d *Deployer) UndeployComponent(ctx context.Context, projectName, componentPath string) error {
// Build deployment name from project and component
spec := domain.DeploySpec{
ProjectName: projectName,
ComponentPath: componentPath,
}
deploymentName := spec.DeploymentName()
ns := d.config.Namespace
// Delete Ingress
err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete ingress: %w", err)
}
// Delete Service
err = d.client.CoreV1().Services(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete service: %w", err)
}
// Delete Deployment
err = d.client.AppsV1().Deployments(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete deployment: %w", err)
}
// Delete Secret
err = d.client.CoreV1().Secrets(ns).Delete(ctx, deploymentName+"-env", metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("failed to delete secret: %w", err)
}
return nil
}
// GetComponentStatus returns deployment status for a specific component.
func (d *Deployer) GetComponentStatus(ctx context.Context, projectName, componentPath string) (*domain.DeployStatus, error) {
// Build deployment name from project and component
spec := domain.DeploySpec{
ProjectName: projectName,
ComponentPath: componentPath,
}
deploymentName := spec.DeploymentName()
ns := d.config.Namespace
deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to get deployment: %w", err)
}
// Determine status
var status domain.DeploymentStatus
switch {
case deployment.Status.ReadyReplicas == *deployment.Spec.Replicas:
status = domain.DeploymentStatusRunning
case deployment.Status.UnavailableReplicas > 0:
status = domain.DeploymentStatusFailed
case deployment.Status.ReadyReplicas < *deployment.Spec.Replicas:
status = domain.DeploymentStatusPending
default:
status = domain.DeploymentStatusUnknown
}
// Get URL from ingress
var url string
ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if err == nil && len(ingress.Spec.Rules) > 0 {
host := ingress.Spec.Rules[0].Host
url = "https://" + host
}
return &domain.DeployStatus{
ProjectName: projectName,
ComponentPath: componentPath,
Image: deployment.Spec.Template.Spec.Containers[0].Image,
Replicas: int(*deployment.Spec.Replicas),
ReadyReplicas: int(deployment.Status.ReadyReplicas),
URL: url,
Status: status,
CreatedAt: deployment.CreationTimestamp.Time,
UpdatedAt: time.Now(),
}, nil
}
// ListComponentStatuses returns deployment status for all components in a project.
func (d *Deployer) ListComponentStatuses(ctx context.Context, projectName string) (*domain.ProjectDeployStatus, error) {
ns := d.config.Namespace
// List all deployments for this project
deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("project=%s", projectName),
})
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
result := &domain.ProjectDeployStatus{
ProjectName: projectName,
Components: make([]domain.ComponentDeployStatus, 0, len(deployments.Items)),
}
for _, dep := range deployments.Items {
componentPath := dep.Labels["component"]
componentName := dep.Name
if componentPath == "" {
// This is the main project deployment, not a component
componentName = projectName
}
// Determine status
var status domain.DeploymentStatus
switch {
case dep.Status.ReadyReplicas == *dep.Spec.Replicas:
status = domain.DeploymentStatusRunning
case dep.Status.UnavailableReplicas > 0:
status = domain.DeploymentStatusFailed
case dep.Status.ReadyReplicas < *dep.Spec.Replicas:
status = domain.DeploymentStatusPending
default:
status = domain.DeploymentStatusUnknown
}
// Get URL from ingress
var url string
ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, dep.Name, metav1.GetOptions{})
if err == nil && len(ingress.Spec.Rules) > 0 {
url = "https://" + ingress.Spec.Rules[0].Host
// Set overall URL to first component URL
if result.OverallURL == "" {
result.OverallURL = url
}
}
// Determine component type from path
componentType := "unknown"
if componentPath != "" {
parts := splitComponentPath(componentPath)
if len(parts) > 0 {
switch parts[0] {
case "services":
componentType = "service"
case "workers":
componentType = "worker"
case "apps":
componentType = "app"
case "cli":
componentType = "cli"
}
}
}
result.Components = append(result.Components, domain.ComponentDeployStatus{
ComponentPath: componentPath,
ComponentName: componentName,
ComponentType: componentType,
Image: dep.Spec.Template.Spec.Containers[0].Image,
Replicas: int(*dep.Spec.Replicas),
ReadyReplicas: int(dep.Status.ReadyReplicas),
URL: url,
Status: status,
})
}
return result, nil
}
// splitComponentPath splits a component path like "services/auth-api" into ["services", "auth-api"].
func splitComponentPath(path string) []string {
var parts []string
current := ""
for _, c := range path {
if c == '/' {
if current != "" {
parts = append(parts, current)
current = ""
}
} else {
current += string(c)
}
}
if current != "" {
parts = append(parts, current)
}
return parts
}
// RestartComponent triggers a rolling restart of a specific component.
func (d *Deployer) RestartComponent(ctx context.Context, projectName, componentPath string) error {
// Build deployment name
spec := domain.DeploySpec{
ProjectName: projectName,
ComponentPath: componentPath,
}
deploymentName := spec.DeploymentName()
ns := d.config.Namespace
deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get deployment: %w", err)
}
// Add annotation to trigger rollout
if deployment.Spec.Template.Annotations == nil {
deployment.Spec.Template.Annotations = make(map[string]string)
}
deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339)
_, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update deployment: %w", err)
}
return nil
}
// ScaleComponent adjusts the replica count for a component.
func (d *Deployer) ScaleComponent(ctx context.Context, projectName, componentPath string, replicas int) error {
// Build deployment name
spec := domain.DeploySpec{
ProjectName: projectName,
ComponentPath: componentPath,
}
deploymentName := spec.DeploymentName()
ns := d.config.Namespace
scale, err := d.client.AppsV1().Deployments(ns).GetScale(ctx, deploymentName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get scale: %w", err)
}
scale.Spec.Replicas = int32(replicas)
_, err = d.client.AppsV1().Deployments(ns).UpdateScale(ctx, deploymentName, scale, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update scale: %w", err)
}
return nil
}
// GetComponentLogs returns recent logs from a specific component's pods.
func (d *Deployer) GetComponentLogs(ctx context.Context, projectName, componentPath string, tailLines int) (string, error) {
// Build deployment name
spec := domain.DeploySpec{
ProjectName: projectName,
ComponentPath: componentPath,
}
deploymentName := spec.DeploymentName()
ns := d.config.Namespace
// List pods for the component deployment
pods, err := d.client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("app=%s", deploymentName),
})
if err != nil {
return "", fmt.Errorf("failed to list pods: %w", err)
}
if len(pods.Items) == 0 {
return "", fmt.Errorf("no pods found for component %s in project %s", componentPath, projectName)
}
// Get logs from the first pod
tail := int64(tailLines)
opts := &corev1.PodLogOptions{
TailLines: &tail,
}
req := d.client.CoreV1().Pods(ns).GetLogs(pods.Items[0].Name, opts)
logs, err := req.Stream(ctx)
if err != nil {
return "", fmt.Errorf("failed to get logs: %w", err)
}
defer func() { _ = logs.Close() }()
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(logs)
if err != nil {
return "", fmt.Errorf("failed to read logs: %w", err)
}
return buf.String(), nil
}

View File

@ -41,7 +41,8 @@ func (d *Deployer) ensureNamespace(ctx context.Context) error {
// createOrUpdateSecret manages the secret for environment variables.
func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error {
secretName := spec.ProjectName + "-env"
deploymentName := spec.DeploymentName()
secretName := deploymentName + "-env"
ns := d.config.Namespace
secret := &corev1.Secret{
@ -49,7 +50,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS
Name: secretName,
Namespace: ns,
Labels: map[string]string{
"app": spec.ProjectName,
"app": deploymentName,
"project": spec.ProjectName,
},
},
@ -69,6 +70,7 @@ func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeployS
func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error {
ns := d.config.Namespace
replicas := int32(spec.Replicas)
deploymentName := spec.DeploymentName()
// Build env vars
var envVars []corev1.EnvVar
@ -82,7 +84,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
envFrom = append(envFrom, corev1.EnvFromSource{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: spec.ProjectName + "-env",
Name: deploymentName + "-env",
},
},
})
@ -90,7 +92,7 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom)
_, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
_, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if errors.IsNotFound(err) {
_, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{})
} else if err == nil {
@ -100,33 +102,38 @@ func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.Dep
}
func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment {
deploymentName := spec.DeploymentName()
// Build labels - always include project, component if present
labels := map[string]string{
"app": deploymentName,
"project": spec.ProjectName,
}
if spec.ComponentPath != "" {
labels["component"] = spec.ComponentPath
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: spec.ProjectName,
Name: deploymentName,
Namespace: ns,
Labels: map[string]string{
"app": spec.ProjectName,
"project": spec.ProjectName,
},
Labels: labels,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": spec.ProjectName,
"app": deploymentName,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": spec.ProjectName,
"project": spec.ProjectName,
},
Labels: labels,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: spec.ProjectName,
Name: deploymentName,
Image: spec.Image,
Env: envVars,
EnvFrom: envFrom,
@ -157,19 +164,26 @@ func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas i
// createOrUpdateService manages the Kubernetes Service resource.
func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error {
ns := d.config.Namespace
deploymentName := spec.DeploymentName()
// Build labels
labels := map[string]string{
"app": deploymentName,
"project": spec.ProjectName,
}
if spec.ComponentPath != "" {
labels["component"] = spec.ComponentPath
}
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: spec.ProjectName,
Name: deploymentName,
Namespace: ns,
Labels: map[string]string{
"app": spec.ProjectName,
"project": spec.ProjectName,
},
Labels: labels,
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{
"app": spec.ProjectName,
"app": deploymentName,
},
Ports: []corev1.ServicePort{
{
@ -181,7 +195,7 @@ func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.Deploy
},
}
_, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
_, err := d.client.CoreV1().Services(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if errors.IsNotFound(err) {
_, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{})
} else if err == nil {
@ -195,6 +209,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
ns := d.config.Namespace
pathType := networkingv1.PathTypePrefix
ingressClass := d.config.IngressClass
deploymentName := spec.DeploymentName()
// Build TLS secret name from domain
tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls"
@ -206,7 +221,7 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations)
_, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{})
_, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{})
if errors.IsNotFound(err) {
_, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
} else if err == nil {
@ -216,14 +231,22 @@ func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.Deploy
}
func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress {
deploymentName := spec.DeploymentName()
// Build labels
labels := map[string]string{
"app": deploymentName,
"project": spec.ProjectName,
}
if spec.ComponentPath != "" {
labels["component"] = spec.ComponentPath
}
return &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: spec.ProjectName,
Namespace: ns,
Labels: map[string]string{
"app": spec.ProjectName,
"project": spec.ProjectName,
},
Name: deploymentName,
Namespace: ns,
Labels: labels,
Annotations: annotations,
},
Spec: networkingv1.IngressSpec{
@ -245,7 +268,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: spec.ProjectName,
Name: deploymentName,
Port: networkingv1.ServiceBackendPort{
Number: int32(spec.Port),
},

View File

@ -4,6 +4,7 @@ package gitea
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -88,7 +89,9 @@ func NewBulkFileClient(baseURL, token string) *BulkFileClient {
return &BulkFileClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
token: token,
client: &http.Client{},
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
@ -183,3 +186,53 @@ func isRetryableError(err error) bool {
strings.Contains(err.Error(), "timeout") ||
strings.Contains(err.Error(), "EOF")
}
// GetFileContent retrieves the content of a file from a repository.
// Returns the decoded content and the file's SHA (needed for updates).
// Returns nil, nil if the file doesn't exist (404).
func (c *BulkFileClient) GetFileContent(ctx context.Context, owner, repo, filepath string) ([]byte, string, error) {
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, filepath)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode == 404 {
return nil, "", nil // File doesn't exist
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", &apiError{StatusCode: resp.StatusCode, Body: string(respBody)}
}
var result struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
SHA string `json:"sha"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, "", fmt.Errorf("failed to parse response: %w", err)
}
// Decode base64 content
content, err := base64.StdEncoding.DecodeString(result.Content)
if err != nil {
return nil, "", fmt.Errorf("failed to decode content: %w", err)
}
return content, result.SHA, nil
}

View File

@ -36,6 +36,52 @@ var availableTemplates = []port.TemplateInfo{
{Name: "go-api", Description: "Go REST API with chi router", Stack: "go"},
}
// skeletonTemplate is the monorepo skeleton template used for composable projects.
var skeletonTemplate = port.TemplateInfo{
Name: "skeleton",
Description: "Composable monorepo skeleton with services, workers, apps, and CLI directories",
Stack: "monorepo",
}
// availableComponentTemplates lists all supported component templates.
var availableComponentTemplates = []port.ComponentTemplateInfo{
{
Type: "service",
Description: "Go API service using pkg/ shared packages",
Stack: "go",
DefaultPort: 8080,
DestDir: "services",
},
{
Type: "worker",
Description: "Go background worker for async job processing",
Stack: "go",
DefaultPort: 0, // Workers don't expose ports
DestDir: "workers",
},
{
Type: "app-astro",
Description: "Astro landing page with Tailwind CSS",
Stack: "astro",
DefaultPort: 4321,
DestDir: "apps",
},
{
Type: "app-react",
Description: "React SPA with Vite, TypeScript, and Tailwind",
Stack: "react",
DefaultPort: 5173,
DestDir: "apps",
},
{
Type: "cli",
Description: "Go CLI tool using Cobra",
Stack: "go",
DefaultPort: 0, // CLIs don't expose ports
DestDir: "cli",
},
}
// templateNameRegex validates template names (alphanumeric, dash only).
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
@ -247,3 +293,195 @@ func listTemplateFiles(templateName string) ([]string, error) {
return files, err
}
// SeedSkeleton populates a repository with the monorepo skeleton template.
// This creates the base monorepo structure without any components.
func (p *Provider) SeedSkeleton(ctx context.Context, owner, repo string, vars map[string]string) error {
return p.SeedRepo(ctx, owner, repo, "skeleton", vars)
}
// GetSkeleton returns info about the monorepo skeleton template.
func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
result := skeletonTemplate
files, err := listTemplateFiles("skeleton")
if err == nil {
result.Files = files
}
return &result, nil
}
// GetComponentTemplate returns info about a specific component template.
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
for _, t := range availableComponentTemplates {
if t.Type == componentType {
result := t
files, err := listComponentTemplateFiles(componentType)
if err == nil {
result.Files = files
}
return &result, nil
}
}
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
// ListComponentTemplates returns available component templates.
// If componentType is empty, returns all templates; otherwise filters by type.
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
var result []port.ComponentTemplateInfo
for _, t := range availableComponentTemplates {
if componentType != "" && t.Type != componentType {
continue
}
info := t
files, err := listComponentTemplateFiles(t.Type)
if err == nil {
info.Files = files
}
result = append(result, info)
}
return result, nil
}
// GetComponentFiles returns the files for a component template with variables interpolated.
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Validate component type exists
found := false
for _, t := range availableComponentTemplates {
if t.Type == componentType {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
}
templateDir := "templates/components/" + componentType
var files []port.ComponentFile
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read component template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from component template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Prepend destination path
fullPath := filepath.Join(destPath, relPath)
files = append(files, port.ComponentFile{
Path: fullPath,
Content: interpolated,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to collect component template files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("component template %s contains no files", componentType)
}
p.logger.Debug("prepared component files",
"component_type", componentType,
"dest_path", destPath,
"file_count", len(files),
)
return files, nil
}
// listComponentTemplateFiles returns the list of files in a component template.
func listComponentTemplateFiles(componentType string) ([]string, error) {
templateDir := "templates/components/" + componentType
var files []string
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return err
}
// Strip .tmpl extension for display
relPath = strings.TrimSuffix(relPath, ".tmpl")
files = append(files, relPath)
return nil
})
return files, err
}
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
// This is the CI step that should be inserted into the main .woodpecker.yml file.
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
content, err := templatesFS.ReadFile(stepPath)
if err != nil {
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
}
return interpolateVars(string(content), vars), nil
}

View File

@ -141,3 +141,48 @@ func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) {
assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f)
}
}
func TestSkeletonTemplate(t *testing.T) {
// Test that skeleton template exists and has expected files
files, err := listTemplateFiles("skeleton")
require.NoError(t, err)
// Should have the core monorepo files
assert.Contains(t, files, "CLAUDE.md", "skeleton template should have CLAUDE.md")
assert.Contains(t, files, "README.md", "skeleton template should have README.md")
assert.Contains(t, files, ".woodpecker.yml", "skeleton template should have .woodpecker.yml")
assert.Contains(t, files, "docker-compose.yml", "skeleton template should have docker-compose.yml")
assert.Contains(t, files, "go.work", "skeleton template should have go.work")
assert.Contains(t, files, "Procfile", "skeleton template should have Procfile")
assert.Contains(t, files, ".gitignore", "skeleton template should have .gitignore")
assert.Contains(t, files, ".golangci.yml", "skeleton template should have .golangci.yml")
// Should have scripts
assert.Contains(t, files, "scripts/dev.sh", "skeleton template should have scripts/dev.sh")
assert.Contains(t, files, "scripts/install.sh", "skeleton template should have scripts/install.sh")
assert.Contains(t, files, "scripts/quality.sh", "skeleton template should have scripts/quality.sh")
assert.Contains(t, files, "scripts/discover.sh", "skeleton template should have scripts/discover.sh")
// Should have .claude structure
assert.Contains(t, files, ".claude/settings.local.json", "skeleton template should have .claude/settings.local.json")
assert.Contains(t, files, ".claude/guides/local/setup.md", "skeleton template should have .claude/guides/local/setup.md")
assert.Contains(t, files, ".claude/guides/ops/deploying.md", "skeleton template should have .claude/guides/ops/deploying.md")
assert.Contains(t, files, ".claude/skills/code-review/SKILL.md", "skeleton template should have .claude/skills/code-review/SKILL.md")
// Should have component directory placeholders
assert.Contains(t, files, "services/.gitkeep", "skeleton template should have services/.gitkeep")
assert.Contains(t, files, "workers/.gitkeep", "skeleton template should have workers/.gitkeep")
assert.Contains(t, files, "apps/.gitkeep", "skeleton template should have apps/.gitkeep")
assert.Contains(t, files, "cli/.gitkeep", "skeleton template should have cli/.gitkeep")
// Should have pkg directory files
assert.Contains(t, files, "pkg/go.mod", "skeleton template should have pkg/go.mod")
assert.Contains(t, files, "pkg/README.md", "skeleton template should have pkg/README.md")
}
func TestSkeletonTemplateInfo(t *testing.T) {
// Verify skeleton template metadata
assert.Equal(t, "skeleton", skeletonTemplate.Name)
assert.Equal(t, "monorepo", skeletonTemplate.Stack)
assert.NotEmpty(t, skeletonTemplate.Description)
}

View File

@ -0,0 +1,18 @@
# Woodpecker CI step for {{COMPONENT_NAME}} Astro app
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
context: .
dockerfile: apps/{{COMPONENT_NAME}}/Dockerfile
cache: true
skip-tls-verify: true
when:
branch: main
event: push

View File

@ -0,0 +1,27 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY apps/{{COMPONENT_NAME}}/package*.json ./
# Install dependencies
RUN npm install
# Copy source
COPY apps/{{COMPONENT_NAME}}/ ./
# Build
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
COPY apps/{{COMPONENT_NAME}}/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
output: 'static',
server: {
port: {{PORT}},
},
});

View File

@ -0,0 +1,6 @@
name: {{COMPONENT_NAME}}
type: app
port: {{PORT}}
path: apps/{{COMPONENT_NAME}}
stack: astro
dependencies: []

View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@ -0,0 +1,23 @@
{
"name": "{{COMPONENT_NAME}}",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev --port {{PORT}}",
"start": "astro dev --port {{PORT}}",
"build": "astro build",
"preview": "astro preview --port {{PORT}}",
"lint": "eslint . --ext .js,.mjs,.ts,.astro",
"format": "prettier --write ."
},
"dependencies": {
"@{{PROJECT_NAME}}/logger": "workspace:*",
"astro": "^4.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^5.0.0",
"tailwindcss": "^3.4.0",
"prettier": "^3.2.0",
"prettier-plugin-astro": "^0.13.0"
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.8L50.4 78.5z" fill="#fff"/>
<path d="M90.9 110.8c-7.6 3-21.3 6-37 6S30.5 113.8 23 110.8L32.8 97a150.3 150.3 0 0 1 31.2-3c11.2 0 22.2.7 31.2 3l9.7 13.8z" fill="#ff5d01"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

View File

@ -0,0 +1,25 @@
---
interface Props {
title: string;
description?: string;
}
const { title, description = '{{PROJECT_NAME}} - {{COMPONENT_NAME}}' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="min-h-screen bg-slate-900 text-slate-100 antialiased">
<slot />
<script>
import '../lib/logger';
</script>
</body>
</html>

View File

@ -0,0 +1,13 @@
import { createLogger, installGlobalHandlers } from '@{{PROJECT_NAME}}/logger';
export const logger = createLogger({
level: import.meta.env.DEV ? 'debug' : 'info',
service: '{{COMPONENT_NAME}}',
// Set endpoint to send logs to your backend:
// endpoint: '/api/logs',
});
// Install global error handlers (client-side only)
if (typeof window !== 'undefined') {
installGlobalHandlers(logger);
}

View File

@ -0,0 +1,37 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="{{COMPONENT_NAME}} | {{PROJECT_NAME}}">
<main class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
<div class="container mx-auto px-4 py-16">
<div class="text-center">
<h1 class="text-5xl font-bold text-white mb-6">
{{COMPONENT_NAME}}
</h1>
<p class="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
Welcome to your Astro app. This is part of the
<code class="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code> monorepo.
</p>
<p class="text-slate-400 mb-8">
Edit this file at
<code class="bg-slate-700 px-2 py-1 rounded">apps/{{COMPONENT_NAME}}/src/pages/index.astro</code>
</p>
<div class="flex gap-4 justify-center">
<a
href="https://docs.astro.build"
class="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Astro Docs
</a>
<a
href="{{GIT_URL}}"
class="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
>
View Source
</a>
</div>
</div>
</div>
</main>
</Layout>

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,18 @@
# Woodpecker CI step for {{COMPONENT_NAME}} React app
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
context: .
dockerfile: apps/{{COMPONENT_NAME}}/Dockerfile
cache: true
skip-tls-verify: true
when:
branch: main
event: push

View File

@ -0,0 +1,27 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY apps/{{COMPONENT_NAME}}/package*.json ./
# Install dependencies
RUN npm install
# Copy source
COPY apps/{{COMPONENT_NAME}}/ ./
# Build
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
COPY apps/{{COMPONENT_NAME}}/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,6 @@
name: {{COMPONENT_NAME}}
type: app
port: {{PORT}}
path: apps/{{COMPONENT_NAME}}
stack: react
dependencies: []

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{COMPONENT_NAME}} | {{PROJECT_NAME}}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@ -0,0 +1,34 @@
{
"name": "{{COMPONENT_NAME}}",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --port {{PORT}}",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview --port {{PORT}}",
"format": "prettier --write src/"
},
"dependencies": {
"@{{PROJECT_NAME}}/logger": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.38",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,46 @@
function App() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
<div className="container mx-auto px-4 py-16">
<div className="text-center">
<h1 className="text-5xl font-bold text-white mb-6">
{{COMPONENT_NAME}}
</h1>
<p className="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
Welcome to your React app. This is part of the{' '}
<code className="bg-slate-700 px-2 py-1 rounded">{{PROJECT_NAME}}</code>{' '}
monorepo.
</p>
<p className="text-slate-400 mb-8">
Edit this file at{' '}
<code className="bg-slate-700 px-2 py-1 rounded">
apps/{{COMPONENT_NAME}}/src/App.tsx
</code>
</p>
<div className="flex gap-4 justify-center">
<a
href="https://react.dev"
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
React Docs
</a>
<a
href="https://vitejs.dev"
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Vite Docs
</a>
<a
href="{{GIT_URL}}"
className="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
>
View Source
</a>
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,11 @@
import { createLogger, installGlobalHandlers } from '@{{PROJECT_NAME}}/logger';
export const logger = createLogger({
level: import.meta.env.DEV ? 'debug' : 'info',
service: '{{COMPONENT_NAME}}',
// Set endpoint to send logs to your backend:
// endpoint: '/api/logs',
});
// Install global error handlers
installGlobalHandlers(logger);

View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import './lib/logger';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: {{PORT}},
},
preview: {
port: {{PORT}},
},
});

View File

@ -0,0 +1,16 @@
# Woodpecker CI step for {{COMPONENT_NAME}} CLI
# Add this step to your .woodpecker.yml
# CLI binaries typically don't need Docker images for deployment.
# This step builds and tests the CLI.
build-{{COMPONENT_NAME}}:
image: golang:1.23-alpine
commands:
- cd cli/{{COMPONENT_NAME}}
- go mod download
- go build -o bin/{{COMPONENT_NAME}} ./cmd
- go test -v ./...
when:
branch: main
event: push

View File

@ -0,0 +1,46 @@
.PHONY: build install test lint fmt clean
CLI := {{COMPONENT_NAME}}
BINARY := bin/$(CLI)
GO_MODULE := {{GO_MODULE}}
# Build variables (for version injection)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS := -ldflags "-X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.Version=$(VERSION)' \
-X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.GitCommit=$(GIT_COMMIT)' \
-X '{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd.BuildDate=$(BUILD_DATE)'"
# Build the CLI binary
build:
go build $(LDFLAGS) -o $(BINARY) ./cmd
# Install to $GOPATH/bin
install:
go install $(LDFLAGS) ./cmd
# Run tests
test:
go test -v ./...
# Run linter
lint:
golangci-lint run ./...
# Format code
fmt:
gofmt -w .
goimports -w -local $(GO_MODULE) .
# Clean build artifacts
clean:
rm -rf bin/
# Build for multiple platforms
build-all: clean
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-linux-amd64 ./cmd
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(CLI)-linux-arm64 ./cmd
GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-darwin-amd64 ./cmd
GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o bin/$(CLI)-darwin-arm64 ./cmd
GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o bin/$(CLI)-windows-amd64.exe ./cmd

View File

@ -0,0 +1,14 @@
// Package main is the entry point for the {{COMPONENT_NAME}} CLI.
package main
import (
"os"
"{{GO_MODULE}}/cli/{{COMPONENT_NAME}}/internal/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@ -0,0 +1,4 @@
name: {{COMPONENT_NAME}}
type: cli
path: cli/{{COMPONENT_NAME}}
dependencies: []

View File

@ -0,0 +1,31 @@
module {{GO_MODULE}}/cli/{{COMPONENT_NAME}}
go 1.23
require (
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,64 @@
// Package cmd provides CLI commands for {{COMPONENT_NAME}}.
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "{{COMPONENT_NAME}}",
Short: "{{COMPONENT_NAME}} CLI tool",
Long: `{{COMPONENT_NAME}} is a CLI tool for the {{PROJECT_NAME}} project.
This CLI provides commands for managing and interacting with
the {{PROJECT_NAME}} system.`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.{{COMPONENT_NAME}}.yaml)")
rootCmd.PersistentFlags().Bool("verbose", false, "enable verbose output")
// Bind flags to viper
_ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag
viper.SetConfigFile(cfgFile)
} else {
// Find home directory
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".{{COMPONENT_NAME}}" (without extension)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".{{COMPONENT_NAME}}")
}
viper.AutomaticEnv()
// If a config file is found, read it in
if err := viper.ReadInConfig(); err == nil {
if viper.GetBool("verbose") {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
}

View File

@ -0,0 +1,28 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Version information (set at build time via ldflags).
var (
Version = "dev"
GitCommit = "unknown"
BuildDate = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("{{COMPONENT_NAME}} %s\n", Version)
fmt.Printf(" Git commit: %s\n", GitCommit)
fmt.Printf(" Build date: %s\n", BuildDate)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}

View File

@ -0,0 +1,17 @@
# {{COMPONENT_NAME}} Service Configuration
# Server
SERVER_PORT={{PORT}}
SERVER_HOST=0.0.0.0
# App
APP_NAME={{COMPONENT_NAME}}
APP_ENVIRONMENT=development
APP_DEBUG=true
# Logging
LOG_LEVEL=debug
LOG_FORMAT=text
# Database (if needed)
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable

View File

@ -0,0 +1,18 @@
# Woodpecker CI step for {{COMPONENT_NAME}} service
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
context: .
dockerfile: services/{{COMPONENT_NAME}}/Dockerfile
cache: true
skip-tls-verify: true
when:
branch: main
event: push

View File

@ -0,0 +1,32 @@
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Copy go workspace files
COPY go.work go.work.sum* ./
COPY pkg/go.mod pkg/go.sum* ./pkg/
COPY services/{{COMPONENT_NAME}}/go.mod services/{{COMPONENT_NAME}}/go.sum* ./services/{{COMPONENT_NAME}}/
# Download dependencies
RUN cd services/{{COMPONENT_NAME}} && go mod download
# Copy source
COPY pkg/ ./pkg/
COPY services/{{COMPONENT_NAME}}/ ./services/{{COMPONENT_NAME}}/
# Build
RUN cd services/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/server
# Production stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /
COPY --from=builder /{{COMPONENT_NAME}} /{{COMPONENT_NAME}}
EXPOSE {{PORT}}
ENTRYPOINT ["/{{COMPONENT_NAME}}"]

View File

@ -0,0 +1,34 @@
.PHONY: build run test lint fmt docker-build clean
SERVICE := {{COMPONENT_NAME}}
BINARY := bin/$(SERVICE)
GO_MODULE := {{GO_MODULE}}
# Build the service binary
build:
go build -o $(BINARY) ./cmd/server
# Run the service locally
run:
go run ./cmd/server
# Run tests
test:
go test -v ./...
# Run linter
lint:
golangci-lint run ./...
# Format code
fmt:
gofmt -w .
goimports -w -local $(GO_MODULE) .
# Build Docker image (run from monorepo root)
docker-build:
docker build -t $(SERVICE):latest -f Dockerfile ../..
# Clean build artifacts
clean:
rm -rf bin/

View File

@ -0,0 +1,18 @@
// Package main is the entry point for the {{COMPONENT_NAME}} service.
package main
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
)
func main() {
// Create application
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}}))
// Register routes
api.RegisterRoutes(application)
// Start server
application.Run()
}

View File

@ -0,0 +1,9 @@
name: {{COMPONENT_NAME}}
type: service
port: {{PORT}}
path: services/{{COMPONENT_NAME}}
dependencies: []
# Add dependencies as needed:
# - postgres
# - redis
# - other-service

View File

@ -0,0 +1,5 @@
module {{GO_MODULE}}/services/{{COMPONENT_NAME}}
go 1.23
require {{GO_MODULE}}/pkg v0.0.0

View File

@ -0,0 +1,26 @@
package handlers
import (
"net/http"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
)
// Health handles health check endpoints.
type Health struct {
logger *logging.Logger
}
// NewHealth creates a new Health handler.
func NewHealth(logger *logging.Logger) *Health {
return &Health{logger: logger}
}
// Check returns the service health status.
func (h *Health) Check(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"service": "{{COMPONENT_NAME}}",
"status": "healthy",
})
}

View File

@ -0,0 +1,21 @@
// Package api provides HTTP routing and handlers for the {{COMPONENT_NAME}} service.
package api
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
)
// RegisterRoutes registers all HTTP routes for the service.
func RegisterRoutes(application *app.App) {
logger := application.Logger()
// Initialize handlers
healthHandler := handlers.NewHealth(logger)
// Register API routes
application.Route("/api/v1", func(r app.Router) {
r.Get("/health", healthHandler.Check)
// Add more routes here
})
}

View File

@ -0,0 +1,32 @@
// Package config provides service-specific configuration.
package config
import (
"{{GO_MODULE}}/pkg/config"
)
// Config extends the base config with {{COMPONENT_NAME}}-specific settings.
type Config struct {
config.AppConfig
Server config.ServerConfig
Database config.DatabaseConfig
Logging config.LoggingConfig
// Add service-specific config fields here
}
// Load reads configuration from environment variables.
func Load() (*Config, error) {
if err := config.Init(config.Options{
AppName: "{{COMPONENT_NAME}}",
DefaultPort: {{PORT}},
}); err != nil {
return nil, err
}
return &Config{
AppConfig: config.ReadAppConfig(),
Server: config.ReadServerConfig(),
Database: config.ReadDatabaseConfig(),
Logging: config.ReadLoggingConfig(),
}, nil
}

View File

@ -0,0 +1,21 @@
# {{COMPONENT_NAME}} Worker Configuration
# App
APP_NAME={{COMPONENT_NAME}}
APP_ENVIRONMENT=development
APP_DEBUG=true
# Logging
LOG_LEVEL=debug
LOG_FORMAT=text
# Worker
WORKER_POLL_INTERVAL=10s
WORKER_BATCH_SIZE=10
WORKER_MAX_RETRIES=3
# Database (if needed)
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable
# Redis (if needed)
# REDIS_URL=redis://localhost:6379/0

View File

@ -0,0 +1,18 @@
# Woodpecker CI step for {{COMPONENT_NAME}} worker
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
tags:
- latest
- ${CI_COMMIT_SHA:0:8}
context: .
dockerfile: workers/{{COMPONENT_NAME}}/Dockerfile
cache: true
skip-tls-verify: true
when:
branch: main
event: push

View File

@ -0,0 +1,30 @@
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Copy go workspace files
COPY go.work go.work.sum* ./
COPY pkg/go.mod pkg/go.sum* ./pkg/
COPY workers/{{COMPONENT_NAME}}/go.mod workers/{{COMPONENT_NAME}}/go.sum* ./workers/{{COMPONENT_NAME}}/
# Download dependencies
RUN cd workers/{{COMPONENT_NAME}} && go mod download
# Copy source
COPY pkg/ ./pkg/
COPY workers/{{COMPONENT_NAME}}/ ./workers/{{COMPONENT_NAME}}/
# Build
RUN cd workers/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/worker
# Production stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /
COPY --from=builder /{{COMPONENT_NAME}} /{{COMPONENT_NAME}}
ENTRYPOINT ["/{{COMPONENT_NAME}}"]

View File

@ -0,0 +1,34 @@
.PHONY: build run test lint fmt docker-build clean
WORKER := {{COMPONENT_NAME}}
BINARY := bin/$(WORKER)
GO_MODULE := {{GO_MODULE}}
# Build the worker binary
build:
go build -o $(BINARY) ./cmd/worker
# Run the worker locally
run:
go run ./cmd/worker
# Run tests
test:
go test -v ./...
# Run linter
lint:
golangci-lint run ./...
# Format code
fmt:
gofmt -w .
goimports -w -local $(GO_MODULE) .
# Build Docker image (run from monorepo root)
docker-build:
docker build -t $(WORKER):latest -f Dockerfile ../..
# Clean build artifacts
clean:
rm -rf bin/

View File

@ -0,0 +1,54 @@
// Package main is the entry point for the {{COMPONENT_NAME}} worker.
package main
import (
"context"
"os"
"os/signal"
"syscall"
"{{GO_MODULE}}/pkg/config"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
)
func main() {
// Initialize configuration
config.MustInit(config.Options{
AppName: "{{COMPONENT_NAME}}",
})
// Initialize logger
logCfg := config.ReadLoggingConfig()
appCfg := config.ReadAppConfig()
logger := logging.New(logging.Config{
Level: logging.ParseLevel(logCfg.Level),
Format: logging.ParseFormat(logCfg.Format),
Environment: appCfg.Environment,
AddSource: appCfg.IsDevelopment(),
}).WithService("{{COMPONENT_NAME}}")
logger.Info("starting {{COMPONENT_NAME}} worker")
// Setup graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Initialize and start handler
handler := handlers.New(logger)
// Start worker in goroutine
go handler.Run(ctx)
// Wait for shutdown signal
sig := <-sigCh
logger.Info("received shutdown signal", "signal", sig.String())
// Trigger graceful shutdown
cancel()
logger.Info("{{COMPONENT_NAME}} worker stopped")
}

View File

@ -0,0 +1,8 @@
name: {{COMPONENT_NAME}}
type: worker
path: workers/{{COMPONENT_NAME}}
dependencies: []
# Add dependencies as needed:
# - postgres
# - redis
# - rabbitmq

View File

@ -0,0 +1,5 @@
module {{GO_MODULE}}/workers/{{COMPONENT_NAME}}
go 1.23
require {{GO_MODULE}}/pkg v0.0.0

View File

@ -0,0 +1,55 @@
// Package config provides worker-specific configuration.
package config
import (
"time"
"github.com/spf13/viper"
"{{GO_MODULE}}/pkg/config"
)
// Config holds {{COMPONENT_NAME}} worker configuration.
type Config struct {
config.AppConfig
Database config.DatabaseConfig
Logging config.LoggingConfig
Worker WorkerConfig
}
// WorkerConfig holds worker-specific settings.
type WorkerConfig struct {
// PollInterval is how often to check for new jobs.
PollInterval time.Duration
// BatchSize is the max number of jobs to process per poll.
BatchSize int
// MaxRetries is the maximum number of retry attempts for failed jobs.
MaxRetries int
}
// Load reads configuration from environment variables.
func Load() (*Config, error) {
if err := config.Init(config.Options{
AppName: "{{COMPONENT_NAME}}",
SetDefaults: func() {
viper.SetDefault("WORKER_POLL_INTERVAL", "10s")
viper.SetDefault("WORKER_BATCH_SIZE", 10)
viper.SetDefault("WORKER_MAX_RETRIES", 3)
},
}); err != nil {
return nil, err
}
return &Config{
AppConfig: config.ReadAppConfig(),
Database: config.ReadDatabaseConfig(),
Logging: config.ReadLoggingConfig(),
Worker: WorkerConfig{
PollInterval: viper.GetDuration("WORKER_POLL_INTERVAL"),
BatchSize: viper.GetInt("WORKER_BATCH_SIZE"),
MaxRetries: viper.GetInt("WORKER_MAX_RETRIES"),
},
}, nil
}

View File

@ -0,0 +1,53 @@
// Package handlers provides the worker's job processing logic.
package handlers
import (
"context"
"time"
"{{GO_MODULE}}/pkg/logging"
)
// Handler processes background jobs.
type Handler struct {
logger *logging.Logger
}
// New creates a new Handler.
func New(logger *logging.Logger) *Handler {
return &Handler{
logger: logger.WithComponent("handler"),
}
}
// Run starts the worker loop and processes jobs until context is cancelled.
func (h *Handler) Run(ctx context.Context) {
h.logger.Info("worker loop started")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
h.logger.Info("worker loop stopping")
return
case <-ticker.C:
h.processJobs(ctx)
}
}
}
// processJobs processes pending jobs.
func (h *Handler) processJobs(ctx context.Context) {
h.logger.Debug("checking for jobs")
// TODO: Implement job processing logic
// Example:
// jobs, err := h.queue.Dequeue(ctx, 10)
// for _, job := range jobs {
// if err := h.process(ctx, job); err != nil {
// h.logger.Error("job failed", "job_id", job.ID, "error", err)
// }
// }
}

View File

@ -0,0 +1,56 @@
#!/bin/bash
# Commit message validation hook
# Validates conventional commit format
# Install: ./scripts/setup-hooks.sh
set -e
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
# Conventional commit pattern:
# type(scope): description
# Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert
PATTERN='^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\([a-z0-9-]+\))?: .{1,100}$'
# Also allow merge commits and WIP commits
if echo "$COMMIT_MSG" | grep -qE "^(Merge|WIP|fixup!|squash!)"; then
exit 0
fi
# Check first line of commit message
FIRST_LINE=$(echo "$COMMIT_MSG" | head -n1)
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo -e "${RED}ERROR: Invalid commit message format${NC}"
echo ""
echo "Expected format: type(scope): description"
echo ""
echo "Valid types:"
echo " feat - A new feature"
echo " fix - A bug fix"
echo " docs - Documentation changes"
echo " style - Code style changes (formatting, etc.)"
echo " refactor - Code refactoring"
echo " test - Adding or updating tests"
echo " chore - Maintenance tasks"
echo " perf - Performance improvements"
echo " ci - CI/CD changes"
echo " build - Build system changes"
echo " revert - Reverting changes"
echo ""
echo "Examples:"
echo " feat(auth): add JWT authentication"
echo " fix(api): handle null response"
echo " docs: update README"
echo ""
echo "Your message: $FIRST_LINE"
exit 1
fi
echo -e "${GREEN}Commit message format is valid${NC}"
exit 0

View File

@ -0,0 +1,135 @@
#!/bin/bash
# Pre-commit hook for monorepo quality checks
# Install: ./scripts/setup-hooks.sh
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "Running pre-commit checks..."
# Get staged files
STAGED_GO_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.go$' || true)
STAGED_TS_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' || true)
ERRORS=0
# ============================================
# 1. File Length Check (500 lines max)
# ============================================
echo "Checking file lengths..."
for file in $STAGED_GO_FILES $STAGED_TS_FILES; do
if [ -f "$file" ]; then
LINE_COUNT=$(wc -l < "$file" | tr -d ' ')
if [ "$LINE_COUNT" -gt 500 ]; then
echo -e "${RED}ERROR: $file has $LINE_COUNT lines (max 500)${NC}"
ERRORS=$((ERRORS + 1))
fi
fi
done
# ============================================
# 2. Go Checks (if Go files are staged)
# ============================================
if [ -n "$STAGED_GO_FILES" ]; then
echo "Running Go checks..."
# gofmt
echo " - gofmt..."
GOFMT_OUTPUT=$(gofmt -l $STAGED_GO_FILES 2>&1 || true)
if [ -n "$GOFMT_OUTPUT" ]; then
echo -e "${YELLOW}Auto-fixing gofmt issues...${NC}"
gofmt -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
fi
# goimports (if available)
if command -v goimports &> /dev/null; then
echo " - goimports..."
GOIMPORTS_OUTPUT=$(goimports -l $STAGED_GO_FILES 2>&1 || true)
if [ -n "$GOIMPORTS_OUTPUT" ]; then
echo -e "${YELLOW}Auto-fixing goimports issues...${NC}"
goimports -w $STAGED_GO_FILES
git add $STAGED_GO_FILES
fi
fi
# golangci-lint (if available)
if command -v golangci-lint &> /dev/null; then
echo " - golangci-lint..."
# Get unique directories with Go files
DIRS=$(echo "$STAGED_GO_FILES" | xargs -n1 dirname | sort -u)
for dir in $DIRS; do
if ! golangci-lint run "$dir/..." --fast 2>/dev/null; then
echo -e "${RED}golangci-lint found issues in $dir${NC}"
ERRORS=$((ERRORS + 1))
fi
done
fi
# go vet
echo " - go vet..."
if ! go vet ./... 2>/dev/null; then
echo -e "${RED}go vet found issues${NC}"
ERRORS=$((ERRORS + 1))
fi
fi
# ============================================
# 3. TypeScript/JavaScript Checks
# ============================================
if [ -n "$STAGED_TS_FILES" ]; then
echo "Running TypeScript checks..."
# Get component directories with TS files
TS_DIRS=$(echo "$STAGED_TS_FILES" | xargs -n1 dirname | sort -u | grep -E '^apps/' | cut -d'/' -f1-2 | sort -u || true)
for dir in $TS_DIRS; do
if [ -f "$dir/package.json" ]; then
# Use subshell to automatically restore directory on exit
(
cd "$dir"
# Get files relative to this component directory
COMPONENT_FILES=$(echo "$STAGED_TS_FILES" | grep "^$dir/" | xargs)
# prettier (if available)
if [ -f "node_modules/.bin/prettier" ] || command -v prettier &> /dev/null; then
echo " - prettier in $dir..."
npx prettier --write $COMPONENT_FILES 2>/dev/null || true
fi
# eslint (if available)
if [ -f "node_modules/.bin/eslint" ] || command -v eslint &> /dev/null; then
echo " - eslint in $dir..."
if ! npx eslint --fix $COMPONENT_FILES 2>/dev/null; then
echo -e "${RED}eslint found issues in $dir${NC}"
# Note: ERRORS can't propagate from subshell, so we exit with error
exit 1
fi
fi
) || ERRORS=$((ERRORS + 1))
fi
done
# Re-add auto-fixed files
for file in $STAGED_TS_FILES; do
if [ -f "$file" ]; then
git add "$file"
fi
done
fi
# ============================================
# 4. Final Result
# ============================================
if [ $ERRORS -gt 0 ]; then
echo -e "${RED}Pre-commit checks failed with $ERRORS error(s)${NC}"
exit 1
fi
echo -e "${GREEN}Pre-commit checks passed!${NC}"
exit 0

View File

@ -0,0 +1,48 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
bin/
dist/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.html
# Dependency directories
vendor/
# Go workspace file (local only)
go.work.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment files
.env
.env.local
*.env
# Node
node_modules/
.npm/
# Shared packages
packages/*/node_modules/
packages/*/dist/
# Build artifacts
build/
.next/
# OS
.DS_Store
Thumbs.db

View File

@ -0,0 +1,25 @@
run:
timeout: 5m
modules-download-mode: readonly
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- goimports
linters-settings:
gofmt:
simplify: true
goimports:
local-prefixes: {{GO_MODULE}}
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

View File

@ -0,0 +1,19 @@
# CI/CD Pipeline for {{PROJECT_NAME}}
# Components will add their build steps below the marker
clone:
git:
image: woodpeckerci/plugin-git
settings:
depth: 1
steps:
# COMPONENT_STEPS_BELOW - Do not remove this marker
deploy:
image: bitnami/kubectl:latest
commands:
- echo "Deploying {{PROJECT_NAME}}"
when:
branch: main
event: push

View File

@ -0,0 +1,49 @@
# {{PROJECT_NAME}}
{{DESCRIPTION}}
## Find Your Guide
| If you need to... | Read this |
|-------------------|-----------|
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference
```bash
# Start local dev
./scripts/dev.sh
# Run quality checks
./scripts/quality.sh
# List all components
./scripts/discover.sh
```
## Architecture
```
{{PROJECT_NAME}}/
├── services/ # Go API services (port 8001+)
├── workers/ # Background workers (no port)
├── apps/ # Frontend applications (port 3001+)
├── cli/ # CLI tools (no port)
├── packages/ # Shared TypeScript packages (@{{PROJECT_NAME}}/*)
├── pkg/ # Shared Go packages ({{GO_MODULE}}/pkg/*)
└── scripts/ # Development & CI scripts
```
| Slot | Language | Port Range | Purpose |
|------|----------|------------|---------|
| services/ | Go | 8001+ | REST APIs, backend services |
| workers/ | Go | none | Background jobs, queue consumers |
| apps/ | TypeScript | 3001+ | React, Astro frontends |
| cli/ | Go | none | CLI tools, scripts |
| packages/ | TypeScript | none | Shared frontend packages |
| pkg/ | Go | none | Shared backend packages |
## Components
<!-- Components will be listed here as they're added -->

View File

@ -0,0 +1,2 @@
# Local development processes
# Components will be added below as they're created

View File

@ -0,0 +1,55 @@
# {{PROJECT_NAME}}
{{DESCRIPTION}}
## Quickstart
```bash
# Clone the repo
git clone {{GIT_URL}}
cd {{PROJECT_NAME}}
# Install dependencies
./scripts/install.sh
# Start local development
./scripts/dev.sh
```
## Project Structure
```
{{PROJECT_NAME}}/
├── services/ # Go API services
├── workers/ # Background workers
├── apps/ # Frontend applications
├── cli/ # CLI tools
├── packages/ # Shared TypeScript packages
├── pkg/ # Shared Go packages
└── scripts/ # Development scripts
```
## Scripts
| Script | Description |
|--------|-------------|
| `./scripts/dev.sh` | Start local development environment |
| `./scripts/install.sh` | Install all dependencies |
| `./scripts/quality.sh` | Run quality checks on all components |
| `./scripts/discover.sh` | List all components in the monorepo |
## Adding Components
Components are added via the rdev API:
```bash
# Add a Go service
curl -X POST $RDEV_API_URL/projects/{{PROJECT_NAME}}/components \
-H "X-API-Key: $RDEV_API_KEY" \
-d '{"type": "service", "name": "auth-api"}'
# Add a React app
curl -X POST $RDEV_API_URL/projects/{{PROJECT_NAME}}/components \
-H "X-API-Key: $RDEV_API_KEY" \
-d '{"type": "app", "name": "dashboard", "template": "app-react"}'
```

View File

@ -0,0 +1 @@
# Frontend applications go here

View File

@ -0,0 +1 @@
# CLI tools go here

View File

@ -0,0 +1,24 @@
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: {{PROJECT_NAME}}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

View File

@ -0,0 +1,4 @@
go 1.23
use ./pkg
// Component modules will be added below

View File

@ -0,0 +1,15 @@
{
"name": "@{{PROJECT_NAME}}/logger",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.5.3"
}
}

View File

@ -0,0 +1,35 @@
import type { Logger } from './logger';
/**
* Install global error handlers that route uncaught errors to the logger.
*
* Captures:
* - window.onerror (uncaught exceptions)
* - window.onunhandledrejection (unhandled promise rejections)
*
* Call once at app init. Returns a cleanup function.
*/
export function installGlobalHandlers(logger: Logger): () => void {
const onError = (event: ErrorEvent) => {
logger.error('Uncaught exception', event.error ?? new Error(event.message), {
source: event.filename,
line: event.lineno,
col: event.colno,
});
};
const onRejection = (event: PromiseRejectionEvent) => {
const err = event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
logger.error('Unhandled promise rejection', err);
};
window.addEventListener('error', onError);
window.addEventListener('unhandledrejection', onRejection);
return () => {
window.removeEventListener('error', onError);
window.removeEventListener('unhandledrejection', onRejection);
};
}

View File

@ -0,0 +1,3 @@
export { createLogger, Logger } from './logger';
export { installGlobalHandlers } from './handlers';
export type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';

View File

@ -0,0 +1,170 @@
import type { LogLevel, LogContext, LogEntry, LoggerConfig, LogTransport } from './types';
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
/** Default transport: sends batched logs via sendBeacon or fetch. */
class HttpTransport implements LogTransport {
constructor(private endpoint: string) {}
send(entries: LogEntry[]): void {
const payload = JSON.stringify(entries);
// sendBeacon is fire-and-forget, works during page unload
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
const sent = navigator.sendBeacon(this.endpoint, blob);
if (sent) return;
}
// Fallback to fetch (non-blocking)
fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => {
// Silently drop - we don't want logging failures to break the app
});
}
}
/** Console transport for development. */
class ConsoleTransport implements LogTransport {
send(entries: LogEntry[]): void {
for (const entry of entries) {
const method = entry.level === 'debug' ? 'log' : entry.level;
const ctx = Object.keys(entry.context).length > 0 ? entry.context : undefined;
if (entry.error) {
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, entry.error, ctx);
} else if (ctx) {
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`, ctx);
} else {
console[method](`[${entry.level.toUpperCase()}] ${entry.message}`);
}
}
}
}
export class Logger {
private buffer: LogEntry[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
private transport: LogTransport;
private minLevel: number;
private baseContext: LogContext;
private batchSize: number;
private flushInterval: number;
constructor(private config: LoggerConfig) {
this.minLevel = LEVEL_PRIORITY[config.level];
this.batchSize = config.batchSize ?? 20;
this.flushInterval = config.flushInterval ?? 5000;
this.baseContext = { service: config.service };
if (config.endpoint) {
this.transport = new HttpTransport(config.endpoint);
} else {
this.transport = new ConsoleTransport();
}
this.startFlushTimer();
// Flush on page unload
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
window.addEventListener('pagehide', () => this.flush());
}
}
/** Create a child logger with additional context fields. */
withContext(ctx: LogContext): Logger {
const child = Object.create(this) as Logger;
child.baseContext = { ...this.baseContext, ...ctx };
return child;
}
debug(message: string, ctx?: LogContext): void {
this.log('debug', message, ctx);
}
info(message: string, ctx?: LogContext): void {
this.log('info', message, ctx);
}
warn(message: string, ctx?: LogContext): void {
this.log('warn', message, ctx);
}
error(message: string, error?: Error | unknown, ctx?: LogContext): void {
const entry = this.createEntry('error', message, ctx);
if (error instanceof Error) {
entry.error = {
name: error.name,
message: error.message,
stack: error.stack,
};
} else if (error !== undefined) {
entry.error = {
name: 'UnknownError',
message: String(error),
};
}
this.enqueue(entry);
}
/** Force-flush the buffer immediately. */
flush(): void {
if (this.buffer.length === 0) return;
const entries = this.buffer.splice(0);
this.transport.send(entries);
}
/** Stop the flush timer (call on teardown). */
destroy(): void {
this.flush();
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private log(level: LogLevel, message: string, ctx?: LogContext): void {
if (LEVEL_PRIORITY[level] < this.minLevel) return;
this.enqueue(this.createEntry(level, message, ctx));
}
private createEntry(level: LogLevel, message: string, ctx?: LogContext): LogEntry {
return {
level,
message,
timestamp: new Date().toISOString(),
context: { ...this.baseContext, ...ctx },
};
}
private enqueue(entry: LogEntry): void {
this.buffer.push(entry);
if (this.buffer.length >= this.batchSize) {
this.flush();
}
}
private startFlushTimer(): void {
if (this.flushInterval > 0 && typeof setInterval !== 'undefined') {
this.timer = setInterval(() => this.flush(), this.flushInterval);
}
}
}
/** Create a logger instance. */
export function createLogger(config: LoggerConfig): Logger {
return new Logger(config);
}

View File

@ -0,0 +1,40 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LogContext {
trace_id?: string;
request_id?: string;
user_id?: string;
component?: string;
[key: string]: unknown;
}
export interface LogEntry {
level: LogLevel;
message: string;
timestamp: string;
context: LogContext;
error?: {
name: string;
message: string;
stack?: string;
};
}
export interface LoggerConfig {
/** Minimum log level to emit */
level: LogLevel;
/** Service/app name for log context */
service: string;
/** Endpoint to send logs to (POST). Omit for console-only. */
endpoint?: string;
/** Max entries to buffer before flushing (default: 20) */
batchSize?: number;
/** Max ms to wait before flushing (default: 5000) */
flushInterval?: number;
/** Install global error/rejection handlers (default: true) */
captureGlobalErrors?: boolean;
}
export interface LogTransport {
send(entries: LogEntry[]): void;
}

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -0,0 +1,286 @@
# Shared Packages
This directory contains shared Go packages used across all components in the monorepo.
## Package Overview
| Package | Description |
|---------|-------------|
| `app` | Service bootstrapper with chi router, middleware, and graceful shutdown |
| `config` | Viper-based configuration loading from environment variables |
| `httpcontext` | Type-safe context key helpers for request-scoped data |
| `httpclient` | Resilient HTTP client with automatic retries and exponential backoff |
| `httpresponse` | Standard response envelope pattern for API responses |
| `httpvalidation` | Struct validation wrapper around go-playground/validator |
| `logging` | slog-based structured logging with context integration |
| `middleware` | HTTP middleware: CORS, recovery, request ID, request logging |
## Quick Start
### Creating a New Service
```go
package main
import (
"net/http"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/httpresponse"
)
func main() {
// Create application with default middleware and health endpoints
svc := app.New("my-service", app.WithDefaultPort(8080))
// Register routes
svc.GET("/hello", func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{"message": "Hello, World!"})
})
// Start server (blocks until shutdown signal)
svc.Run()
}
```
## Package Documentation
### pkg/app
Service bootstrapper that provides:
- Chi router with standard middleware
- Graceful shutdown handling
- Health check endpoints (`/health`, `/ready`)
```go
app := app.New("my-service",
app.WithDefaultPort(8080),
app.WithLogger(customLogger),
)
// Register routes
app.GET("/users/{id}", getUser)
app.POST("/users", createUser)
// Group routes
app.Route("/api/v1", func(r chi.Router) {
r.Get("/users", listUsers)
})
// Register shutdown hooks
app.OnShutdown(func(ctx context.Context) error {
return db.Close()
})
app.Run()
```
### pkg/config
Configuration loading from environment variables with Viper.
```go
// Initialize configuration (once at startup)
config.MustInit(config.Options{
AppName: "my-service",
DefaultPort: 8080,
})
// Read typed configuration
appCfg := config.ReadAppConfig() // APP_NAME, APP_ENVIRONMENT, APP_DEBUG
serverCfg := config.ReadServerConfig() // SERVER_HOST, SERVER_PORT, timeouts
dbCfg := config.ReadDatabaseConfig() // DATABASE_URL, pool settings
// Direct access
dbURL := config.GetString("DATABASE_URL")
debug := config.GetBool("APP_DEBUG")
```
**Environment Variables:**
- `APP_NAME` - Application name (default: service name)
- `APP_ENVIRONMENT` - development, staging, production
- `APP_DEBUG` - Enable debug mode
- `SERVER_HOST` - Server bind host (default: 0.0.0.0)
- `SERVER_PORT` - Server port (default: 8080)
- `DATABASE_URL` - Database connection string
- `LOG_LEVEL` - debug, info, warn, error
- `LOG_FORMAT` - json, text, auto
### pkg/httpcontext
Type-safe context key helpers.
```go
// Set values in middleware
ctx := httpcontext.SetRequestID(r.Context(), requestID)
ctx = httpcontext.SetUser(ctx, user)
ctx = httpcontext.SetOrgID(ctx, orgID)
// Get values in handlers
requestID, ok := httpcontext.GetRequestID(ctx)
user, ok := httpcontext.GetUser(ctx)
orgID, ok := httpcontext.GetOrgID(ctx)
// Panic if not found (use when middleware guarantees presence)
user := httpcontext.MustGetUser(ctx)
```
### pkg/httpclient
HTTP client with automatic retries.
```go
// Create client
client := httpclient.New(httpclient.Config{
Timeout: 10 * time.Second,
MaxRetries: 3,
})
// Make requests
resp, err := client.Do(req)
// Convenience methods
resp, err := httpclient.Get(ctx, "https://api.example.com/users")
resp, err := httpclient.JSONPost(ctx, url, bytes.NewReader(jsonData))
```
Retries on:
- HTTP 5xx server errors
- HTTP 429 Too Many Requests
- Connection errors (timeout, refused)
Does NOT retry on:
- HTTP 4xx client errors (except 429)
- Context cancellation
### pkg/httpresponse
Standard response envelope for API responses.
```go
// Success responses
httpresponse.OK(w, r, data) // 200 OK
httpresponse.Created(w, r, data) // 201 Created
httpresponse.NoContent(w) // 204 No Content
// Error responses
httpresponse.BadRequest(w, r, "invalid input")
httpresponse.Unauthorized(w, r, "authentication required")
httpresponse.Forbidden(w, r, "insufficient permissions")
httpresponse.NotFound(w, r, "user not found")
httpresponse.InternalError(w, r, "something went wrong")
// Validation errors with details
httpresponse.ValidationError(w, r, "validation failed", details)
// Decode request body
var req CreateUserRequest
if err := httpresponse.DecodeJSON(r, &req); err != nil {
httpresponse.BadRequest(w, r, "invalid JSON")
return
}
```
**Response Format:**
```json
{
"data": { ... },
"error": {
"code": "VALIDATION_ERROR",
"message": "validation failed",
"details": [ ... ]
},
"meta": {
"request_id": "abc-123",
"timestamp": "2024-01-15T10:30:00Z"
}
}
```
### pkg/httpvalidation
Struct validation using go-playground/validator.
```go
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Name string `json:"name" validate:"required,min=2,max=100"`
Phone string `json:"phone" validate:"omitempty,phone"`
}
// Validate struct
if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
httpresponse.ValidationError(w, r, "validation failed", details)
return
}
// Custom validators available:
// - uuid: Valid UUID
// - uuid_or_empty: Valid UUID or empty string
// - phone: E.164 phone number format
// - slug: URL-safe slug (lowercase, numbers, hyphens)
// - hex_color: Hex color code (#RGB, #RRGGBB, #RRGGBBAA)
```
### pkg/logging
Structured logging with slog.
```go
// Create logger
logger := logging.New(logging.Config{
Level: logging.LevelInfo,
Format: logging.FormatJSON,
Environment: "production",
})
// Or use convenience constructors
logger := logging.NewDevelopment() // text format, debug level
logger := logging.NewProduction() // JSON format, info level
// Log messages
logger.Info("user created", "user_id", userID)
logger.Error("failed to connect", "error", err)
// Create derived loggers
reqLogger := logger.With("request_id", requestID)
svcLogger := logger.WithService("user-service")
// Get logger from context (set by middleware)
logger := logging.FromContext(r.Context())
```
### pkg/middleware
HTTP middleware for chi router.
```go
r := chi.NewRouter()
// Request ID generation/propagation
r.Use(middleware.RequestID())
// Request logging
r.Use(middleware.RequestLogger(logger))
// Panic recovery
r.Use(middleware.Recoverer(logger))
// CORS
r.Use(middleware.CORS(middleware.DefaultCORSConfig()))
// Production CORS
r.Use(middleware.CORS(middleware.CORSConfig{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowCredentials: true,
}))
```
## Guidelines
- **Import Path**: Use `{{GO_MODULE}}/pkg/<package>` for imports
- **Keep packages focused**: Each package should do one thing well
- **No circular dependencies**: pkg packages should not import from services/workers
- **Document public APIs**: All exported functions should have doc comments
- **Write tests**: Cover exported functions with unit tests

View File

@ -0,0 +1,297 @@
// Package app provides a service bootstrapper for HTTP services.
//
// App is the main application struct that provides infrastructure for HTTP services.
// It manages configuration, logging, routing, and graceful shutdown.
//
// Example usage:
//
// func main() {
// app := app.New("my-service", app.WithDefaultPort(8080))
// app.GET("/users/{id}", handlers.GetUser)
// app.POST("/users", handlers.CreateUser)
// app.Run()
// }
package app
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"{{GO_MODULE}}/pkg/config"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/middleware"
)
// Router is an alias for chi.Router, exposing it for handler mounting.
type Router = chi.Router
// App is the main application struct that provides infrastructure for HTTP services.
type App struct {
name string
defaultPort int
logger *logging.Logger
router chi.Router
server *http.Server
// Configuration
appConfig config.AppConfig
serverConfig config.ServerConfig
// Lifecycle hooks
onShutdown []func(context.Context) error
}
// Option configures the App.
type Option func(*App)
// WithLogger sets a custom logger for the application.
func WithLogger(logger *logging.Logger) Option {
return func(a *App) {
a.logger = logger
}
}
// WithDefaultPort sets the default port if not configured via environment.
func WithDefaultPort(port int) Option {
return func(a *App) {
a.defaultPort = port
}
}
// New creates a new App instance with the given service name.
// It initializes configuration, logging, and routing infrastructure.
//
// The service name is used for:
// - Configuration defaults (APP_NAME)
// - Logging context (service attribute)
// - Health check identification
//
// Configuration is loaded from environment variables with support for:
// - .env file (in development)
// - Environment variables (highest priority)
func New(serviceName string, opts ...Option) *App {
app := &App{
name: serviceName,
defaultPort: 8080,
onShutdown: make([]func(context.Context) error, 0),
}
// Apply options before initialization (to capture defaultPort)
for _, opt := range opts {
opt(app)
}
// Initialize configuration
config.MustInit(config.Options{
AppName: serviceName,
DefaultPort: app.defaultPort,
})
// Load configuration
app.appConfig = config.ReadAppConfig()
app.serverConfig = config.ReadServerConfig()
// Initialize logger if not provided
if app.logger == nil {
logCfg := config.ReadLoggingConfig()
app.logger = logging.New(logging.Config{
Level: logging.ParseLevel(logCfg.Level),
Format: logging.ParseFormat(logCfg.Format),
Environment: app.appConfig.Environment,
AddSource: app.appConfig.IsDevelopment(),
}).WithService(serviceName)
}
// Initialize router with standard middleware
app.router = chi.NewRouter()
app.setupMiddleware()
app.setupHealthEndpoints()
return app
}
// setupMiddleware configures the standard middleware stack.
func (a *App) setupMiddleware() {
// Core middleware (order matters)
a.router.Use(middleware.RequestID())
a.router.Use(middleware.Tracing())
a.router.Use(middleware.RequestLogger(a.logger))
a.router.Use(middleware.Recoverer(a.logger))
// CORS (configurable via environment)
a.router.Use(middleware.CORS(middleware.DefaultCORSConfig()))
}
// setupHealthEndpoints registers /health and /ready endpoints.
func (a *App) setupHealthEndpoints() {
// Liveness probe - returns 200 if the process is running
a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"status": "ok",
"service": a.name,
})
})
// Readiness probe - returns 200 if the service is ready to accept traffic
a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"status": "ready",
"service": a.name,
})
})
}
// Logger returns the application logger.
func (a *App) Logger() *logging.Logger {
return a.logger
}
// Config returns the application configuration.
func (a *App) Config() config.AppConfig {
return a.appConfig
}
// ServerConfig returns the server configuration.
func (a *App) ServerConfig() config.ServerConfig {
return a.serverConfig
}
// Router returns the underlying chi router for advanced configuration.
func (a *App) Router() chi.Router {
return a.router
}
// Use appends middleware to the router middleware stack.
func (a *App) Use(middlewares ...func(http.Handler) http.Handler) {
a.router.Use(middlewares...)
}
// GET registers a handler for GET requests to the given pattern.
func (a *App) GET(pattern string, handler http.HandlerFunc) {
a.router.Get(pattern, handler)
}
// POST registers a handler for POST requests to the given pattern.
func (a *App) POST(pattern string, handler http.HandlerFunc) {
a.router.Post(pattern, handler)
}
// PUT registers a handler for PUT requests to the given pattern.
func (a *App) PUT(pattern string, handler http.HandlerFunc) {
a.router.Put(pattern, handler)
}
// PATCH registers a handler for PATCH requests to the given pattern.
func (a *App) PATCH(pattern string, handler http.HandlerFunc) {
a.router.Patch(pattern, handler)
}
// DELETE registers a handler for DELETE requests to the given pattern.
func (a *App) DELETE(pattern string, handler http.HandlerFunc) {
a.router.Delete(pattern, handler)
}
// Route creates a new sub-router with the given pattern prefix.
//
// Example:
//
// app.Route("/api/v1", func(r chi.Router) {
// r.Get("/users", listUsers)
// r.Post("/users", createUser)
// })
func (a *App) Route(pattern string, fn func(r chi.Router)) {
a.router.Route(pattern, fn)
}
// Mount attaches a sub-router or http.Handler at the given pattern.
func (a *App) Mount(pattern string, handler http.Handler) {
a.router.Mount(pattern, handler)
}
// OnShutdown registers a function to be called during graceful shutdown.
// Functions are called in the order they were registered.
func (a *App) OnShutdown(fn func(context.Context) error) {
a.onShutdown = append(a.onShutdown, fn)
}
// Run starts the HTTP server and blocks until shutdown.
// It handles graceful shutdown on SIGINT and SIGTERM signals.
func (a *App) Run() {
addr := a.serverConfig.Addr()
a.server = &http.Server{
Addr: addr,
Handler: a.router,
ReadTimeout: a.serverConfig.ReadTimeout,
WriteTimeout: a.serverConfig.WriteTimeout,
IdleTimeout: a.serverConfig.IdleTimeout,
}
// Start server in a goroutine
errChan := make(chan error, 1)
go func() {
a.logger.Info("starting server",
"service", a.name,
"address", addr,
"environment", a.appConfig.Environment,
)
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait for shutdown signal or server error
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errChan:
a.logger.Error("server error", "error", err)
os.Exit(1)
case sig := <-quit:
a.logger.Info("received shutdown signal", "signal", sig.String())
}
// Graceful shutdown
a.shutdown()
}
// shutdown performs graceful shutdown of the application.
func (a *App) shutdown() {
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
a.logger.Info("shutting down server")
// Shutdown HTTP server
if err := a.server.Shutdown(ctx); err != nil {
a.logger.Error("server shutdown error", "error", err)
}
// Run shutdown hooks
for _, fn := range a.onShutdown {
if err := fn(ctx); err != nil {
a.logger.Error("shutdown hook error", "error", err)
}
}
a.logger.Info("server stopped", "service", a.name)
}
// ListenAddr returns the address the server is configured to listen on.
func (a *App) ListenAddr() string {
return a.serverConfig.Addr()
}
// ServeHTTP implements http.Handler, allowing App to be used in tests.
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.router.ServeHTTP(w, r)
}

View File

@ -0,0 +1,235 @@
// Package config provides configuration loading using Viper.
//
// This package standardizes configuration loading across all services with:
// - Environment variable support
// - .env file support for development
// - Sensible defaults
// - Type-safe configuration structs
//
// Usage:
//
// // Initialize configuration (call once at startup)
// config.MustInit(config.Options{
// AppName: "my-service",
// DefaultPort: 8080,
// })
//
// // Read configuration
// appCfg := config.ReadAppConfig()
// serverCfg := config.ReadServerConfig()
//
// // Or read specific values
// dbURL := viper.GetString("DATABASE_URL")
package config
import (
"fmt"
"time"
"github.com/spf13/viper"
)
// ServerConfig holds HTTP server configuration.
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
IdleTimeout time.Duration `json:"idle_timeout"`
}
// Addr returns the server address in host:port format.
func (c ServerConfig) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
// DatabaseConfig holds database connection configuration.
type DatabaseConfig struct {
URL string `json:"url"`
MaxOpenConns int `json:"max_open_conns"`
MaxIdleConns int `json:"max_idle_conns"`
ConnMaxLifetime time.Duration `json:"conn_max_lifetime"`
}
// AppConfig holds application-level configuration common to all services.
type AppConfig struct {
Name string `json:"name"`
Environment string `json:"environment"`
Debug bool `json:"debug"`
}
// IsDevelopment returns true if the environment is development.
func (c AppConfig) IsDevelopment() bool {
return c.Environment == "development"
}
// IsProduction returns true if the environment is production.
func (c AppConfig) IsProduction() bool {
return c.Environment == "production"
}
// LoggingConfig holds logging configuration.
type LoggingConfig struct {
Level string `json:"level"`
Format string `json:"format"`
}
// Options configures the behavior of Init.
type Options struct {
// AppName is the default name for the application.
AppName string
// DefaultPort is the default server port if not specified.
DefaultPort int
// EnvFile is the path to the .env file for development.
// Defaults to ".env" if not specified.
EnvFile string
// SetDefaults is an optional function to set additional viper defaults
// before loading configuration.
SetDefaults func()
// SkipEnvFile skips loading from .env file.
// Useful for production where all config comes from environment.
SkipEnvFile bool
}
// Init initializes viper with common defaults and loads configuration.
// This should be called once at service startup before using viper.Get* functions.
//
// Load order (later sources override earlier):
// 1. Default values
// 2. .env file (development only)
// 3. Environment variables
func Init(opts Options) error {
viper.SetConfigType("env")
viper.SetEnvPrefix("")
viper.AutomaticEnv()
// Set common defaults
setCommonDefaults(opts)
// Set service-specific defaults
if opts.SetDefaults != nil {
opts.SetDefaults()
}
// In development, optionally load from .env file
if !opts.SkipEnvFile {
env := viper.GetString("APP_ENVIRONMENT")
if env == "development" || env == "" {
envFile := opts.EnvFile
if envFile == "" {
envFile = ".env"
}
viper.SetConfigFile(envFile)
_ = viper.ReadInConfig() // Ignore error - fallback to env vars
}
}
return nil
}
// MustInit is like Init but panics if initialization fails.
// This is useful in main() where you want to fail fast on configuration errors.
func MustInit(opts Options) {
if err := Init(opts); err != nil {
panic(fmt.Sprintf("failed to initialize config: %v", err))
}
}
// setCommonDefaults sets default values for common configuration fields.
func setCommonDefaults(opts Options) {
// App defaults
appName := opts.AppName
if appName == "" {
appName = "service"
}
viper.SetDefault("APP_NAME", appName)
viper.SetDefault("APP_ENVIRONMENT", "development")
viper.SetDefault("APP_DEBUG", false)
// Server defaults
viper.SetDefault("SERVER_HOST", "0.0.0.0")
port := opts.DefaultPort
if port == 0 {
port = 8080
}
viper.SetDefault("SERVER_PORT", port)
viper.SetDefault("SERVER_READ_TIMEOUT", "30s")
viper.SetDefault("SERVER_WRITE_TIMEOUT", "0s") // Disabled for SSE support
viper.SetDefault("SERVER_IDLE_TIMEOUT", "120s")
// Database defaults
viper.SetDefault("DATABASE_MAX_OPEN_CONNS", 25)
viper.SetDefault("DATABASE_MAX_IDLE_CONNS", 5)
viper.SetDefault("DATABASE_CONN_MAX_LIFETIME", "5m")
// Logging defaults
viper.SetDefault("LOG_LEVEL", "info")
viper.SetDefault("LOG_FORMAT", "auto") // auto = JSON in prod, text in dev
}
// ReadAppConfig reads AppConfig from viper.
func ReadAppConfig() AppConfig {
return AppConfig{
Name: viper.GetString("APP_NAME"),
Environment: viper.GetString("APP_ENVIRONMENT"),
Debug: viper.GetBool("APP_DEBUG"),
}
}
// ReadServerConfig reads ServerConfig from viper.
func ReadServerConfig() ServerConfig {
return ServerConfig{
Host: viper.GetString("SERVER_HOST"),
Port: viper.GetInt("SERVER_PORT"),
ReadTimeout: viper.GetDuration("SERVER_READ_TIMEOUT"),
WriteTimeout: viper.GetDuration("SERVER_WRITE_TIMEOUT"),
IdleTimeout: viper.GetDuration("SERVER_IDLE_TIMEOUT"),
}
}
// ReadDatabaseConfig reads DatabaseConfig from viper.
func ReadDatabaseConfig() DatabaseConfig {
return DatabaseConfig{
URL: viper.GetString("DATABASE_URL"),
MaxOpenConns: viper.GetInt("DATABASE_MAX_OPEN_CONNS"),
MaxIdleConns: viper.GetInt("DATABASE_MAX_IDLE_CONNS"),
ConnMaxLifetime: viper.GetDuration("DATABASE_CONN_MAX_LIFETIME"),
}
}
// ReadLoggingConfig reads LoggingConfig from viper.
func ReadLoggingConfig() LoggingConfig {
return LoggingConfig{
Level: viper.GetString("LOG_LEVEL"),
Format: viper.GetString("LOG_FORMAT"),
}
}
// GetString returns a string configuration value.
func GetString(key string) string {
return viper.GetString(key)
}
// GetInt returns an integer configuration value.
func GetInt(key string) int {
return viper.GetInt(key)
}
// GetBool returns a boolean configuration value.
func GetBool(key string) bool {
return viper.GetBool(key)
}
// GetDuration returns a duration configuration value.
func GetDuration(key string) time.Duration {
return viper.GetDuration(key)
}
// IsSet returns true if the key is set in configuration.
func IsSet(key string) bool {
return viper.IsSet(key)
}

View File

@ -0,0 +1,39 @@
module {{GO_MODULE}}/pkg
go 1.23
require (
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.23.0
github.com/google/uuid v1.6.0
github.com/spf13/viper v1.19.0
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,253 @@
// Package httpclient provides a robust HTTP client with automatic retries and exponential backoff.
//
// This package wraps the standard http.Client to provide:
// - Automatic retries with exponential backoff
// - Request ID and trace ID propagation
// - Configurable timeouts
//
// Usage:
//
// // Create a client with default settings
// client := httpclient.New(httpclient.Config{
// Timeout: 10 * time.Second,
// MaxRetries: 3,
// })
//
// // Make requests
// resp, err := client.Do(req)
//
// // Or use convenience methods
// resp, err := httpclient.Get(ctx, "https://api.example.com/users")
package httpclient
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"{{GO_MODULE}}/pkg/httpcontext"
)
// Config holds configuration for the HTTP client.
type Config struct {
// Timeout for individual HTTP requests (default: 10s)
Timeout time.Duration
// MaxRetries for failed requests (default: 3)
MaxRetries int
// Logger for structured logging (optional, defaults to slog.Default())
Logger *slog.Logger
}
// Client wraps http.Client to provide retry logic and request ID propagation.
type Client struct {
httpClient *http.Client
logger *slog.Logger
config Config
}
// New creates a new robust HTTP client.
func New(config Config) *Client {
if config.Timeout == 0 {
config.Timeout = 10 * time.Second
}
if config.MaxRetries == 0 {
config.MaxRetries = 3
}
if config.Logger == nil {
config.Logger = slog.Default()
}
return &Client{
httpClient: &http.Client{
Timeout: config.Timeout,
},
logger: config.Logger,
config: config,
}
}
// Do executes an HTTP request with exponential backoff retry logic.
//
// Retries on transient errors:
// - HTTP 5xx server errors
// - HTTP 429 Too Many Requests
// - Connection errors (timeout, connection refused)
//
// Does NOT retry on:
// - HTTP 4xx client errors (except 429)
// - Context cancellation or deadline exceeded
func (c *Client) Do(req *http.Request) (*http.Response, error) {
const (
initialDelay = 100 * time.Millisecond
maxDelay = 2 * time.Second
)
// Propagate request ID if present in context
if requestID, ok := httpcontext.GetRequestID(req.Context()); ok && requestID != "" {
if req.Header.Get("X-Request-ID") == "" {
req.Header.Set("X-Request-ID", requestID)
}
}
// Propagate trace ID if present in context
if traceID, ok := httpcontext.GetTraceID(req.Context()); ok && traceID != "" {
if req.Header.Get("X-Trace-ID") == "" {
req.Header.Set("X-Trace-ID", traceID)
}
}
// Clone request body for retries (critical: POST/PUT bodies get exhausted)
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
_ = req.Body.Close()
}
var lastErr error
maxRetries := c.config.MaxRetries
ctx := req.Context()
for attempt := 0; attempt <= maxRetries; attempt++ {
// Check if context is already cancelled
if err := ctx.Err(); err != nil {
return nil, err
}
// Reset body for each attempt
if bodyBytes != nil {
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
// Execute the request
resp, err := c.httpClient.Do(req)
// Network error
if err != nil {
lastErr = err
if !isRetryableError(err, nil) {
return nil, lastErr
}
// Continue to retry
} else {
// HTTP 429 - retry
if resp.StatusCode == http.StatusTooManyRequests {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
// Continue to retry
} else if resp.StatusCode >= 400 && resp.StatusCode < 500 {
// Other HTTP 4xx - return immediately (not transient)
return resp, nil
} else if resp.StatusCode >= 500 {
// HTTP 5xx - retry
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
// Continue to retry
} else {
// HTTP 2xx/3xx - success
return resp, nil
}
}
// Don't retry if we've exhausted attempts
if attempt >= maxRetries {
break
}
// Calculate exponential backoff delay using bit-shift
delay := initialDelay << attempt
if delay > maxDelay {
delay = maxDelay
}
c.logger.Debug("retrying http request",
"attempt", attempt+1,
"max_retries", maxRetries,
"delay_ms", delay.Milliseconds(),
"url", req.URL.String(),
"error", lastErr)
// Wait with context awareness
select {
case <-time.After(delay):
// Continue to next retry
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("request failed after %d retries: %w", maxRetries, lastErr)
}
// isRetryableError determines if an error or response should trigger a retry.
func isRetryableError(err error, resp *http.Response) bool {
// Network/connection errors are retryable
if err != nil {
// Don't retry on context cancellation
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
// Retry on all other errors (connection refused, timeout, etc.)
return true
}
// HTTP 5xx errors and 429 are retryable
if resp != nil {
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return true
}
}
return false
}
// -----------------------------------------------------------------------------
// Convenience methods using a default client
// -----------------------------------------------------------------------------
// Default is a pre-configured client with 30s timeout and 3 retries.
var Default = New(Config{
Timeout: 30 * time.Second,
MaxRetries: 3,
})
// Do performs an HTTP request with retry logic using the default client.
func Do(req *http.Request) (*http.Response, error) {
return Default.Do(req)
}
// Get performs a GET request with the default client.
func Get(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return Default.Do(req)
}
// Post performs a POST request with the default client.
func Post(ctx context.Context, url, contentType string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return Default.Do(req)
}
// JSONPost performs a POST request with JSON content type.
func JSONPost(ctx context.Context, url string, body io.Reader) (*http.Response, error) {
return Post(ctx, url, "application/json", body)
}

View File

@ -0,0 +1,186 @@
// Package httpcontext provides type-safe context keys and helpers for HTTP request contexts.
//
// This package standardizes how context values are stored and retrieved across all services.
// Using unexported types for context keys prevents collisions with other packages.
//
// Usage in middleware:
//
// func AuthMiddleware() func(http.Handler) http.Handler {
// return func(next http.Handler) http.Handler {
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user := extractUserFromAuth(r)
// ctx := httpcontext.SetUser(r.Context(), user)
// ctx = httpcontext.SetOrgID(ctx, user.OrganizationID)
// next.ServeHTTP(w, r.WithContext(ctx))
// })
// }
// }
//
// Usage in handlers:
//
// func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
// user, ok := httpcontext.GetUser(r.Context())
// if !ok {
// http.Error(w, "unauthorized", http.StatusUnauthorized)
// return
// }
// // ... use user
// }
package httpcontext
import "context"
// contextKey is an unexported type used for context keys to prevent collisions.
// Other packages cannot create values of this type, ensuring our keys are unique.
type contextKey string
// Standard context keys used across services.
const (
keyUser contextKey = "user"
keyOrgID contextKey = "organization_id"
keyRequestID contextKey = "request_id"
keyTraceID contextKey = "trace_id"
keyJWTClaims contextKey = "jwt_claims"
)
// SetUser adds a user to the context.
// The user can be any type - typically a domain user struct.
// Returns a new context with the user attached.
func SetUser(ctx context.Context, user any) context.Context {
if user == nil {
return ctx
}
return context.WithValue(ctx, keyUser, user)
}
// GetUser retrieves the user from context.
// Returns (user, true) if present, (nil, false) if not found.
// Caller should type-assert the returned value to their user type.
//
// Example:
//
// if val, ok := httpcontext.GetUser(ctx); ok {
// user := val.(*domain.User)
// // ... use user
// }
func GetUser(ctx context.Context) (any, bool) {
val := ctx.Value(keyUser)
if val == nil {
return nil, false
}
return val, true
}
// SetOrgID adds an organization ID to the context.
// Returns a new context with the organization ID attached.
func SetOrgID(ctx context.Context, orgID string) context.Context {
if orgID == "" {
return ctx
}
return context.WithValue(ctx, keyOrgID, orgID)
}
// GetOrgID retrieves the organization ID from context.
// Returns (orgID, true) if present, ("", false) if not found.
func GetOrgID(ctx context.Context) (string, bool) {
val := ctx.Value(keyOrgID)
if val == nil {
return "", false
}
if orgID, ok := val.(string); ok {
return orgID, true
}
return "", false
}
// SetRequestID adds a request ID to the context.
// Returns a new context with the request ID attached.
func SetRequestID(ctx context.Context, requestID string) context.Context {
if requestID == "" {
return ctx
}
return context.WithValue(ctx, keyRequestID, requestID)
}
// GetRequestID retrieves the request ID from context.
// Returns (requestID, true) if present, ("", false) if not found.
func GetRequestID(ctx context.Context) (string, bool) {
val := ctx.Value(keyRequestID)
if val == nil {
return "", false
}
if requestID, ok := val.(string); ok {
return requestID, true
}
return "", false
}
// SetTraceID adds a trace ID to the context.
// Returns a new context with the trace ID attached.
func SetTraceID(ctx context.Context, traceID string) context.Context {
if traceID == "" {
return ctx
}
return context.WithValue(ctx, keyTraceID, traceID)
}
// GetTraceID retrieves the trace ID from context.
// Returns (traceID, true) if present, ("", false) if not found.
func GetTraceID(ctx context.Context) (string, bool) {
val := ctx.Value(keyTraceID)
if val == nil {
return "", false
}
if traceID, ok := val.(string); ok {
return traceID, true
}
return "", false
}
// SetJWTClaims adds JWT claims to the context.
// The claims can be any type - typically a custom claims struct.
// Returns a new context with the claims attached.
func SetJWTClaims(ctx context.Context, claims any) context.Context {
if claims == nil {
return ctx
}
return context.WithValue(ctx, keyJWTClaims, claims)
}
// GetJWTClaims retrieves JWT claims from context.
// Returns (claims, true) if present, (nil, false) if not found.
// Caller should type-assert the returned value to their claims type.
//
// Example:
//
// if val, ok := httpcontext.GetJWTClaims(ctx); ok {
// claims := val.(*auth.CustomClaims)
// // ... use claims
// }
func GetJWTClaims(ctx context.Context) (any, bool) {
val := ctx.Value(keyJWTClaims)
if val == nil {
return nil, false
}
return val, true
}
// MustGetUser retrieves the user from context and panics if not found.
// Use only when authentication middleware guarantees user presence.
func MustGetUser(ctx context.Context) any {
user, ok := GetUser(ctx)
if !ok {
panic("httpcontext: user not found in context")
}
return user
}
// MustGetRequestID retrieves the request ID from context and panics if not found.
// Use only when middleware guarantees request ID presence.
func MustGetRequestID(ctx context.Context) string {
requestID, ok := GetRequestID(ctx)
if !ok {
panic("httpcontext: request_id not found in context")
}
return requestID
}

View File

@ -0,0 +1,75 @@
// Package httpresponse provides standard HTTP response types and helpers.
//
// This package implements an envelope pattern for consistent API responses:
//
// {
// "data": {...}, // Present on success
// "error": {...}, // Present on error
// "meta": {
// "request_id": "...",
// "trace_id": "...",
// "timestamp": "..."
// }
// }
//
// Usage:
//
// func GetUser(w http.ResponseWriter, r *http.Request) {
// user, err := svc.Get(ctx, id)
// if err != nil {
// httpresponse.NotFound(w, r, "user not found")
// return
// }
// httpresponse.OK(w, r, user)
// }
package httpresponse
import (
"net/http"
"time"
"{{GO_MODULE}}/pkg/httpcontext"
)
// Response is the standard envelope for all API responses.
type Response struct {
Data any `json:"data,omitempty"`
Error *Error `json:"error,omitempty"`
Meta Meta `json:"meta"`
}
// Error represents an API error in the response envelope.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details,omitempty"`
}
// Meta contains response metadata.
type Meta struct {
RequestID string `json:"request_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
Timestamp string `json:"timestamp"`
}
// newMeta creates a Meta with current timestamp, request ID, and trace ID from context.
func newMeta(r *http.Request) Meta {
requestID, _ := httpcontext.GetRequestID(r.Context())
traceID, _ := httpcontext.GetTraceID(r.Context())
return Meta{
RequestID: requestID,
TraceID: traceID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
// Error codes for machine-readable error classification.
const (
CodeBadRequest = "BAD_REQUEST"
CodeUnauthorized = "UNAUTHORIZED"
CodeForbidden = "FORBIDDEN"
CodeNotFound = "NOT_FOUND"
CodeConflict = "CONFLICT"
CodeInternal = "INTERNAL_ERROR"
CodeValidation = "VALIDATION_ERROR"
)

View File

@ -0,0 +1,192 @@
package httpresponse
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
)
var (
// ErrEmptyBody is returned when the request body is empty.
ErrEmptyBody = errors.New("request body is empty")
// ErrInvalidJSON is returned when the request body contains invalid JSON.
ErrInvalidJSON = errors.New("invalid JSON")
// ErrUnknownFields is returned when strict decoding encounters unknown fields.
ErrUnknownFields = errors.New("unknown fields in JSON")
)
// -----------------------------------------------------------------------------
// Success Responses
// -----------------------------------------------------------------------------
// JSON writes a JSON response with the given status code.
// The data is wrapped in the standard response envelope.
func JSON(w http.ResponseWriter, r *http.Request, status int, data any) {
resp := Response{
Data: data,
Meta: newMeta(r),
}
writeJSON(w, status, resp)
}
// OK writes a successful JSON response with status 200 OK.
func OK(w http.ResponseWriter, r *http.Request, data any) {
JSON(w, r, http.StatusOK, data)
}
// Created writes a successful JSON response with status 201 Created.
func Created(w http.ResponseWriter, r *http.Request, data any) {
JSON(w, r, http.StatusCreated, data)
}
// Accepted writes a successful JSON response with status 202 Accepted.
func Accepted(w http.ResponseWriter, r *http.Request, data any) {
JSON(w, r, http.StatusAccepted, data)
}
// NoContent writes a successful response with status 204 No Content.
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
// -----------------------------------------------------------------------------
// Error Responses
// -----------------------------------------------------------------------------
// WriteError writes an error response with the given status code.
func WriteError(w http.ResponseWriter, r *http.Request, status int, code, message string, details ...any) {
var detailsVal any
if len(details) > 0 {
detailsVal = details[0]
}
resp := Response{
Error: &Error{
Code: code,
Message: message,
Details: detailsVal,
},
Meta: newMeta(r),
}
writeJSON(w, status, resp)
}
// BadRequest writes a 400 Bad Request error response.
func BadRequest(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusBadRequest, CodeBadRequest, message)
}
// ValidationError writes a 400 Bad Request error response for validation failures.
func ValidationError(w http.ResponseWriter, r *http.Request, message string, details any) {
WriteError(w, r, http.StatusBadRequest, CodeValidation, message, details)
}
// Unauthorized writes a 401 Unauthorized error response.
func Unauthorized(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusUnauthorized, CodeUnauthorized, message)
}
// Forbidden writes a 403 Forbidden error response.
func Forbidden(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusForbidden, CodeForbidden, message)
}
// NotFound writes a 404 Not Found error response.
func NotFound(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusNotFound, CodeNotFound, message)
}
// Conflict writes a 409 Conflict error response.
func Conflict(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusConflict, CodeConflict, message)
}
// InternalError writes a 500 Internal Server Error response.
// The message should be safe to expose to clients; internal details should be logged.
func InternalError(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusInternalServerError, CodeInternal, message)
}
// ServiceUnavailable writes a 503 Service Unavailable error response.
func ServiceUnavailable(w http.ResponseWriter, r *http.Request, message string) {
WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", message)
}
// -----------------------------------------------------------------------------
// Request Body Decoding
// -----------------------------------------------------------------------------
// DecodeJSON decodes JSON from request body into v.
// Returns descriptive errors for common failure cases.
// Does not enforce strict field matching.
func DecodeJSON(r *http.Request, v any) error {
if r.Body == nil {
return ErrEmptyBody
}
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return ErrEmptyBody
}
return fmt.Errorf("%w: %w", ErrInvalidJSON, err)
}
return nil
}
// DecodeJSONStrict decodes JSON from request body into v.
// Rejects JSON that contains fields not present in the target struct.
// Useful for strict API validation to catch client errors early.
func DecodeJSONStrict(r *http.Request, v any) error {
if r.Body == nil {
return ErrEmptyBody
}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return ErrEmptyBody
}
// Check if it's an unknown field error
var syntaxErr *json.SyntaxError
var unmarshalErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalErr) {
return fmt.Errorf("%w: %w", ErrInvalidJSON, err)
}
// Unknown field errors contain "unknown field" in the message
return fmt.Errorf("%w: %w", ErrUnknownFields, err)
}
return nil
}
// IsEmptyBodyError checks if an error is ErrEmptyBody.
func IsEmptyBodyError(err error) bool {
return errors.Is(err, ErrEmptyBody)
}
// IsInvalidJSONError checks if an error is ErrInvalidJSON.
func IsInvalidJSONError(err error) bool {
return errors.Is(err, ErrInvalidJSON)
}
// IsUnknownFieldsError checks if an error is ErrUnknownFields.
func IsUnknownFieldsError(err error) bool {
return errors.Is(err, ErrUnknownFields)
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
// writeJSON marshals and writes the response.
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}

View File

@ -0,0 +1,308 @@
// Package httpvalidation provides consistent request validation across services.
//
// This package wraps go-playground/validator/v10 with a simpler API
// and human-readable error messages suitable for API responses.
//
// Usage:
//
// type CreateUserRequest struct {
// Email string `json:"email" validate:"required,email"`
// Phone string `json:"phone" validate:"omitempty,e164"`
// }
//
// func CreateUser(w http.ResponseWriter, r *http.Request) {
// var req CreateUserRequest
// if err := httpresponse.DecodeJSON(r, &req); err != nil {
// httpresponse.BadRequest(w, r, "invalid JSON")
// return
// }
// if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
// httpresponse.ValidationError(w, r, "validation failed", details)
// return
// }
// // ... proceed with valid request
// }
package httpvalidation
import (
"errors"
"fmt"
"reflect"
"regexp"
"strings"
"sync"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
)
var (
// Singleton validator instance
once sync.Once
validate *validator.Validate
// Regex patterns for custom validations
// E.164 allows 1-15 digits total, with country code starting with 1-9
phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{4,14}$`)
)
// ValidationDetail represents a single field validation error.
// This structure is designed for API responses, providing clear
// field-level error information to clients.
type ValidationDetail struct {
// Field is the JSON field name that failed validation.
Field string `json:"field"`
// Message is a human-readable description of the validation failure.
Message string `json:"message"`
}
// Validator returns the singleton validator instance with all custom validators registered.
// Thread-safe and initialized only once.
func Validator() *validator.Validate {
once.Do(func() {
validate = validator.New()
// Use JSON tag names in error messages
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" || name == "" {
return fld.Name
}
return name
})
// Register custom validators
_ = validate.RegisterValidation("uuid", validateUUID)
_ = validate.RegisterValidation("uuid_or_empty", validateUUIDOrEmpty)
_ = validate.RegisterValidation("phone", validatePhone)
_ = validate.RegisterValidation("slug", validateSlug)
_ = validate.RegisterValidation("hex_color", validateHexColor)
})
return validate
}
// ValidateStruct validates a struct and returns a slice of ValidationDetail for any validation errors.
// Returns nil if validation passes.
//
// Example:
//
// type CreateFanRequest struct {
// Email string `json:"email" validate:"required,email"`
// Phone string `json:"phone" validate:"omitempty,phone"`
// }
//
// if details := httpvalidation.ValidateStruct(req); len(details) > 0 {
// httpresponse.ValidationError(w, r, "validation failed", details)
// return
// }
func ValidateStruct(s any) []ValidationDetail {
v := Validator()
err := v.Struct(s)
if err == nil {
return nil
}
var details []ValidationDetail
// Use errors.As to handle wrapped errors
var validationErrs validator.ValidationErrors
if !errors.As(err, &validationErrs) {
// If not validation errors, return generic error
details = append(details, ValidationDetail{
Field: "unknown",
Message: err.Error(),
})
return details
}
// Convert validator errors to ValidationDetails
for _, e := range validationErrs {
details = append(details, ValidationDetail{
Field: fieldName(e),
Message: fieldError(e),
})
}
return details
}
// ValidateVar validates a single variable against validation tags.
// Returns nil if validation passes, or a ValidationDetail slice with the error.
//
// Example:
//
// if err := httpvalidation.ValidateVar(email, "required,email"); err != nil {
// // handle error
// }
func ValidateVar(field any, tag string) []ValidationDetail {
v := Validator()
err := v.Var(field, tag)
if err == nil {
return nil
}
var validationErrs validator.ValidationErrors
if !errors.As(err, &validationErrs) {
return []ValidationDetail{{Field: "value", Message: err.Error()}}
}
if len(validationErrs) > 0 {
return []ValidationDetail{{Field: "value", Message: fieldError(validationErrs[0])}}
}
return nil
}
// fieldName extracts the JSON field name from a validation error.
// Falls back to the struct field name if JSON tag is not present.
func fieldName(e validator.FieldError) string {
field := e.Field()
// Remove any struct prefix
parts := strings.Split(field, ".")
if len(parts) > 0 {
field = parts[len(parts)-1]
}
// Convert to camelCase for API consistency
if len(field) > 0 {
return strings.ToLower(field[:1]) + field[1:]
}
return field
}
// fieldError generates a human-readable error message for a validation error.
func fieldError(e validator.FieldError) string {
field := e.Field()
tag := e.Tag()
param := e.Param()
switch tag {
case "required":
return fmt.Sprintf("%s is required", field)
case "email":
return fmt.Sprintf("%s must be a valid email address", field)
case "min":
if e.Kind() == reflect.String {
return fmt.Sprintf("%s must be at least %s characters", field, param)
}
return fmt.Sprintf("%s must be at least %s", field, param)
case "max":
if e.Kind() == reflect.String {
return fmt.Sprintf("%s must be at most %s characters", field, param)
}
return fmt.Sprintf("%s must be at most %s", field, param)
case "len":
if e.Kind() == reflect.String {
return fmt.Sprintf("%s must be exactly %s characters", field, param)
}
return fmt.Sprintf("%s must have exactly %s items", field, param)
case "uuid":
return fmt.Sprintf("%s must be a valid UUID", field)
case "uuid_or_empty":
return fmt.Sprintf("%s must be a valid UUID or empty", field)
case "phone", "e164":
return fmt.Sprintf("%s must be a valid phone number in E.164 format", field)
case "url":
return fmt.Sprintf("%s must be a valid URL", field)
case "oneof":
return fmt.Sprintf("%s must be one of: %s", field, param)
case "gt":
return fmt.Sprintf("%s must be greater than %s", field, param)
case "gte":
return fmt.Sprintf("%s must be greater than or equal to %s", field, param)
case "lt":
return fmt.Sprintf("%s must be less than %s", field, param)
case "lte":
return fmt.Sprintf("%s must be less than or equal to %s", field, param)
case "slug":
return fmt.Sprintf("%s must be a valid slug (lowercase letters, numbers, hyphens)", field)
case "hex_color":
return fmt.Sprintf("%s must be a valid hex color code", field)
case "alphanum":
return fmt.Sprintf("%s must contain only alphanumeric characters", field)
case "alpha":
return fmt.Sprintf("%s must contain only alphabetic characters", field)
case "numeric":
return fmt.Sprintf("%s must be numeric", field)
case "datetime":
return fmt.Sprintf("%s must be a valid datetime in format %s", field, param)
case "eqfield":
return fmt.Sprintf("%s must equal %s", field, param)
case "nefield":
return fmt.Sprintf("%s must not equal %s", field, param)
default:
return fmt.Sprintf("%s failed validation (%s)", field, tag)
}
}
// -----------------------------------------------------------------------------
// Custom Validators
// -----------------------------------------------------------------------------
// validateUUID checks if a field is a valid UUID.
func validateUUID(fl validator.FieldLevel) bool {
field := fl.Field().String()
if field == "" {
return false
}
_, err := uuid.Parse(field)
return err == nil
}
// validateUUIDOrEmpty checks if a field is either empty or a valid UUID.
func validateUUIDOrEmpty(fl validator.FieldLevel) bool {
field := fl.Field().String()
if field == "" {
return true
}
_, err := uuid.Parse(field)
return err == nil
}
// validatePhone checks if a field is a valid phone number in E.164 format.
// E.164 format: +[country code][number] (e.g., +14155552671)
func validatePhone(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if phone == "" {
return false
}
return phoneRegex.MatchString(phone)
}
// validateSlug checks if a field is a valid URL slug.
// Valid slugs contain only lowercase letters, numbers, and hyphens.
func validateSlug(fl validator.FieldLevel) bool {
slug := fl.Field().String()
if slug == "" {
return false
}
// Must start with letter or number, can contain hyphens, must end with letter or number
match, _ := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, slug)
return match
}
// validateHexColor checks if a field is a valid hex color code.
// Accepts #RGB, #RRGGBB, #RRGGBBAA formats.
func validateHexColor(fl validator.FieldLevel) bool {
color := fl.Field().String()
if color == "" {
return false
}
match, _ := regexp.MatchString(`^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$`, color)
return match
}
// RegisterValidation registers a custom validation function.
// Returns an error if registration fails.
func RegisterValidation(tag string, fn validator.Func) error {
return Validator().RegisterValidation(tag, fn)
}
// MustRegisterValidation registers a custom validation function and panics on error.
// Use this during initialization when registration failure should be fatal.
func MustRegisterValidation(tag string, fn validator.Func) {
if err := RegisterValidation(tag, fn); err != nil {
panic(fmt.Sprintf("failed to register validation %q: %v", tag, err))
}
}

View File

@ -0,0 +1,99 @@
package logging
import (
"context"
"log/slog"
)
type contextKey int
const (
loggerKey contextKey = iota
requestIDKey
userIDKey
traceIDKey
)
// NewContext returns a new context with the logger attached.
func NewContext(ctx context.Context, logger *Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
// FromContext extracts the logger from the context.
// Returns a no-op logger if none is found.
func FromContext(ctx context.Context) *Logger {
if logger, ok := ctx.Value(loggerKey).(*Logger); ok {
return logger
}
return Nop()
}
// WithRequestID adds a request ID to the context.
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey, requestID)
}
// RequestIDFromContext extracts the request ID from the context.
func RequestIDFromContext(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey).(string); ok {
return id
}
return ""
}
// WithUserID adds a user ID to the context.
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// UserIDFromContext extracts the user ID from the context.
func UserIDFromContext(ctx context.Context) string {
if id, ok := ctx.Value(userIDKey).(string); ok {
return id
}
return ""
}
// WithTraceID adds a trace ID to the context.
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, traceIDKey, traceID)
}
// TraceIDFromContext extracts the trace ID from the context.
func TraceIDFromContext(ctx context.Context) string {
if id, ok := ctx.Value(traceIDKey).(string); ok {
return id
}
return ""
}
// ContextAttrs returns slog attributes from context values.
func ContextAttrs(ctx context.Context) []slog.Attr {
var attrs []slog.Attr
if id := RequestIDFromContext(ctx); id != "" {
attrs = append(attrs, slog.String("request_id", id))
}
if id := UserIDFromContext(ctx); id != "" {
attrs = append(attrs, slog.String("user_id", id))
}
if id := TraceIDFromContext(ctx); id != "" {
attrs = append(attrs, slog.String("trace_id", id))
}
return attrs
}
// LoggerWithContext returns a logger enriched with context attributes.
func LoggerWithContext(ctx context.Context, logger *Logger) *Logger {
attrs := ContextAttrs(ctx)
if len(attrs) == 0 {
return logger
}
args := make([]any, 0, len(attrs)*2)
for _, attr := range attrs {
args = append(args, attr.Key, attr.Value.Any())
}
return logger.With(args...)
}

View File

@ -0,0 +1,245 @@
// Package logging provides slog-based structured logging with context integration.
//
// This package standardizes logging across all services with:
// - Environment-aware formatting (JSON for production, text for development)
// - Request-scoped loggers with context propagation
// - Convenience methods for common logging patterns
//
// Usage:
//
// // Create a logger based on environment
// logger := logging.New(logging.Config{
// Level: logging.LevelInfo,
// Format: logging.FormatJSON,
// Environment: "production",
// })
//
// // Or use convenience constructors
// logger := logging.NewDevelopment() // text format, debug level
// logger := logging.NewProduction() // JSON format, info level
package logging
import (
"io"
"log/slog"
"os"
"strings"
)
// Level represents the logging level.
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
// String returns the string representation of the level.
func (l Level) String() string {
switch l {
case LevelDebug:
return "debug"
case LevelInfo:
return "info"
case LevelWarn:
return "warn"
case LevelError:
return "error"
default:
return "info"
}
}
// ParseLevel parses a string into a Level.
// Returns LevelInfo if the string is not recognized.
func ParseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return LevelDebug
case "info":
return LevelInfo
case "warn", "warning":
return LevelWarn
case "error":
return LevelError
default:
return LevelInfo
}
}
func (l Level) toSlog() slog.Level {
switch l {
case LevelDebug:
return slog.LevelDebug
case LevelInfo:
return slog.LevelInfo
case LevelWarn:
return slog.LevelWarn
case LevelError:
return slog.LevelError
default:
return slog.LevelInfo
}
}
// Format represents the output format.
type Format int
const (
FormatJSON Format = iota
FormatText
)
// String returns the string representation of the format.
func (f Format) String() string {
switch f {
case FormatJSON:
return "json"
case FormatText:
return "text"
default:
return "json"
}
}
// ParseFormat parses a string into a Format.
// Returns FormatJSON if the string is not recognized.
func ParseFormat(s string) Format {
switch strings.ToLower(strings.TrimSpace(s)) {
case "text", "console":
return FormatText
case "json":
return FormatJSON
default:
return FormatJSON
}
}
// Config holds the logger configuration.
type Config struct {
// Level sets the minimum log level.
// Default: LevelInfo
Level Level
// Format sets the output format.
// Default: FormatJSON
Format Format
// Output sets the output writer.
// Default: os.Stdout
Output io.Writer
// AddSource adds source file and line number to log entries.
// Default: false
AddSource bool
// Environment determines default format if not specified.
// "development" uses text format, others use JSON.
Environment string
}
// Logger wraps slog.Logger with additional convenience methods.
type Logger struct {
*slog.Logger
}
// New creates a new Logger with the given configuration.
func New(cfg Config) *Logger {
if cfg.Output == nil {
cfg.Output = os.Stdout
}
// Auto-detect format based on environment if not explicitly set
format := cfg.Format
if cfg.Environment == "development" && format == FormatJSON {
format = FormatText
}
opts := &slog.HandlerOptions{
Level: cfg.Level.toSlog(),
AddSource: cfg.AddSource,
}
var handler slog.Handler
switch format {
case FormatText:
handler = slog.NewTextHandler(cfg.Output, opts)
default:
handler = slog.NewJSONHandler(cfg.Output, opts)
}
return &Logger{
Logger: slog.New(handler),
}
}
// NewDevelopment creates a logger configured for development.
// Uses text format, debug level, and includes source location.
func NewDevelopment() *Logger {
return New(Config{
Level: LevelDebug,
Format: FormatText,
AddSource: true,
})
}
// NewProduction creates a logger configured for production.
// Uses JSON format and info level.
func NewProduction() *Logger {
return New(Config{
Level: LevelInfo,
Format: FormatJSON,
})
}
// With returns a new Logger with the given attributes.
func (l *Logger) With(args ...any) *Logger {
return &Logger{
Logger: l.Logger.With(args...),
}
}
// WithGroup returns a new Logger with the given group name.
func (l *Logger) WithGroup(name string) *Logger {
return &Logger{
Logger: l.Logger.WithGroup(name),
}
}
// WithError returns a new Logger with the error attribute.
func (l *Logger) WithError(err error) *Logger {
if err == nil {
return l
}
return l.With("error", err.Error())
}
// WithComponent returns a new Logger with the component attribute.
func (l *Logger) WithComponent(name string) *Logger {
return l.With("component", name)
}
// WithService returns a new Logger with the service attribute.
func (l *Logger) WithService(name string) *Logger {
return l.With("service", name)
}
// Nop returns a logger that discards all output.
func Nop() *Logger {
return New(Config{
Output: io.Discard,
Level: LevelError,
})
}
// Default returns the default logger configured for the current environment.
// Uses APP_ENVIRONMENT env var to determine format.
func Default() *Logger {
env := os.Getenv("APP_ENVIRONMENT")
if env == "development" || env == "" {
return NewDevelopment()
}
return NewProduction()
}

Some files were not shown because too many files have changed in this diff Show More