# AGENTS.md - Developer Guide for oslstats This document provides guidelines for AI coding agents and developers working on the oslstats codebase. ## Project Overview **Module**: `git.haelnorr.com/h/oslstats` **Language**: Go 1.25.5 **Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates **Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries ## Build and Development Commands ### Building NEVER BUILD MANUALLY ```bash # Full production build (tailwind → templ → go generate → go build) just build # Build and run just run ``` ### Development Mode ```bash # Watch mode with hot reload (templ, air, tailwindcss in parallel) just dev # Development server runs on: # - Proxy: http://localhost:3000 (use this) # - App: http://localhost:3333 (internal) ``` ### Database Migrations **oslstats uses Bun's migration framework for safe, incremental schema changes.** #### Quick Reference **New Migration System**: Migrations now accept a count parameter. Default is 1 migration at a time. ```bash # Show migration status just migrate status # Run 1 migration (default, with automatic backup) just migrate up 1 # OR just just migrate up # Run 3 migrations just migrate up 3 # Run all pending migrations just migrate up all # Run with a specific environment file just migrate up 3 .test.env # Rollback works the same for all arguments just migrate down 2 .test.env # Create new migration just migrate new add_email_to_users # Dev: Reset database (DESTRUCTIVE - deletes all data) just reset-db ``` #### Creating a New Migration **Example: Adding an email field to users table** 1. **Generate migration file:** ```bash just migrate new add_leagues_and_slap_version ``` Creates: `cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go` 2. **Edit the migration file:** ```go package migrations import ( "context" "github.com/uptrace/bun" ) func init() { Migrations.MustRegister( // UP: Add email column func(ctx context.Context, db *bun.DB) error { _, err := dbConn.NewAddColumn(). Model((*db.Season)(nil)). ColumnExpr("slap_version VARCHAR NOT NULL"). IfNotExists(). Exec(ctx) if err != nil { return err } // Create leagues table _, err = dbConn.NewCreateTable(). Model((*db.League)(nil)). Exec(ctx) if err != nil { return err } // Create season_leagues join table _, err = dbConn.NewCreateTable(). Model((*db.SeasonLeague)(nil)). Exec(ctx) return err }, // DOWN: Remove email column (for rollback) func(ctx context.Context, db *bun.DB) error { // Drop season_leagues join table first _, err := dbConn.NewDropTable(). Model((*db.SeasonLeague)(nil)). IfExists(). Exec(ctx) if err != nil { return err } // Drop leagues table _, err = dbConn.NewDropTable(). Model((*db.League)(nil)). IfExists(). Exec(ctx) if err != nil { return err } // Remove slap_version column from seasons table _, err = dbConn.NewDropColumn(). Model((*db.Season)(nil)). ColumnExpr("slap_version"). Exec(ctx) return err }, ) } ``` 3. **Update the model** (`internal/db/user.go`): ```go type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` ID int `bun:"id,pk,autoincrement"` Name string `bun:"name,unique"` SlapVersion string `bun:"slap_version"` // NEW FIELD } ``` 4. **Apply the migration:** ```bash just migrate up 1 ``` Output: ``` [INFO] Step 1/5: Validating migrations... [INFO] Migration validation passed ✓ [INFO] Step 2/5: Checking for pending migrations... [INFO] Running 1 migration(s): 📋 20250124150030_add_email_to_users [INFO] Step 3/5: Creating backup... [INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB) [INFO] Step 4/5: Acquiring migration lock... [INFO] Migration lock acquired [INFO] Step 5/5: Applying migrations... [INFO] Migrated to group 2 ✅ 20250124150030_add_email_to_users [INFO] Migration lock released ``` #### Environment Variables ```bash # Backup directory (default: backups) DB_BACKUP_DIR=backups # Number of backups to keep (default: 10) DB_BACKUP_RETENTION=10 ``` #### Troubleshooting **"pg_dump not found"** - Migrations will still run, but backups will be skipped - Install PostgreSQL client tools for backups: ```bash # Ubuntu/Debian sudo apt-get install postgresql-client # macOS brew install postgresql # Arch sudo pacman -S postgresql-libs ``` **"migration already in progress"** - Another instance is running migrations - Wait for it to complete (max 5 minutes) - If stuck, check for hung database connections **"migration build failed"** - Migration file has syntax errors - Fix the errors and try again - Use `go build ./cmd/oslstats/migrations` to debug ### Configuration Management ```bash # Generate .env template file just genenv # OR with custom output: just genenv .env.example # Show environment variable documentation just envdoc # Show current environment values just showenv ``` ## Code Style Guidelines ### Error Handling **Always wrap errors** with context using `github.com/pkg/errors`: ```go if err != nil { return errors.Wrap(err, "package.FunctionName") } ``` **Validate inputs at function start**: ```go func DoSomething(cfg *Config, data string) error { if cfg == nil { return errors.New("cfg cannot be nil") } if data == "" { return errors.New("data cannot be empty") } // ... rest of function } ``` **HTTP error helpers** (in handlers package): *Note: `msg` should be a frontend presentable message* - `throwInternalServiceError(s, w, r, msg, err)` - 500 errors - `throwBadRequest(s, w, r, msg, err)` - 400 errors - `throwForbidden(s, w, r, msg, err)` - 403 errors (normal) - `throwForbiddenSecurity(s, w, r, msg, err)` - 403 security violations (WARN level) - `throwUnauthorized(s, w, r, msg, err)` - 401 errors (normal) - `throwUnauthorizedSecurity(s, w, r, msg, err)` - 401 security violations (WARN level) - `throwNotFound(s, w, r, path)` - 404 errors ### Common Patterns **HTTP Handler Pattern**: ```go func HandlerName(server *hws.Server, deps ...) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Handler logic here }, ) } ``` **Database Operation Pattern**: ```go func GetSomething(ctx context.Context, tx bun.Tx, id int) (*Result, error) { result := new(Result) err := tx.NewSelect(). Model(result). Where("id = ?", id). Scan(ctx) if err != nil { if err.Error() == "sql: no rows in result set" { return nil, nil // Return nil, nil for not found } return nil, errors.Wrap(err, "tx.Select") } return result, nil } ``` **Setup Function Pattern** (returns instance, cleanup func, error): ```go func setupSomething(ctx context.Context, cfg *Config) (*Type, func() error, error) { instance := newInstance() err := configure(instance) if err != nil { return nil, nil, errors.Wrap(err, "configure") } return instance, instance.Close, nil } ``` **Configuration Pattern** (using ezconf): ```go type Config struct { Field string // ENV FIELD_NAME: Description (required/default: value) } func ConfigFromEnv() (any, error) { cfg := &Config{ Field: env.String("FIELD_NAME", "default"), } // Validation here return cfg, nil } ``` ### Formatting & Types **Formatting**: - Use `gofmt` (standard Go formatting) **Types**: - Prefer explicit types over inference when it improves clarity - Use struct tags for ORM and JSON marshaling: ```go type User struct { bun.BaseModel `bun:"table:users,alias:u"` ID int `bun:"id,pk,autoincrement"` Username string `bun:"username,unique"` AccessToken string `json:"access_token"` } ``` **Comments**: - Document exported functions and types - Use inline comments for ENV var documentation in Config structs - Explain security-critical code flows ## Notes for AI Agents 1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css) 2. **Database operations** should use `bun.Tx` for transaction safety 3. **Templates** are written in templ, not Go html/template - run `just templ` after changes 4. **Static files** are embedded via `//go:embed` - check internal/embedfs/ 5. **Error messages** should be descriptive and use errors.Wrap for context 6. **Security is critical** - especially in OAuth flows (see pkg/oauth/state_test.go for examples) 7. **Air proxy** runs on port 3000 during development; app runs on 3333 8. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples 9. When in plan mode, always use the interactive question tool if available