switched out make for just
This commit is contained in:
405
AGENTS.md
405
AGENTS.md
@@ -9,78 +9,62 @@ This document provides guidelines for AI coding agents and developers working on
|
||||
**Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates
|
||||
**Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
## Build and Development Commands
|
||||
|
||||
### Building
|
||||
NEVER BUILD MANUALLY
|
||||
```bash
|
||||
# Full production build (tailwind → templ → go generate → go build)
|
||||
make build
|
||||
just build
|
||||
|
||||
# Build and run
|
||||
make run
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
just run
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
# Watch mode with hot reload (templ, air, tailwindcss in parallel)
|
||||
make dev
|
||||
just dev
|
||||
|
||||
# Development server runs on:
|
||||
# - Proxy: http://localhost:3000 (use this)
|
||||
# - App: http://localhost:3333 (internal)
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./pkg/oauth
|
||||
|
||||
# Run a single test function
|
||||
go test ./pkg/oauth -run TestGenerateState_Success
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./pkg/oauth
|
||||
|
||||
# Run tests with coverage
|
||||
go test -cover ./...
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out
|
||||
```
|
||||
|
||||
### 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
|
||||
make migrate-status
|
||||
just migrate status
|
||||
|
||||
# Preview pending migrations (dry-run)
|
||||
make migrate-dry-run
|
||||
# Run 1 migration (default, with automatic backup)
|
||||
just migrate up 1
|
||||
# OR just
|
||||
just migrate up
|
||||
|
||||
# Run pending migrations (with automatic backup)
|
||||
make migrate
|
||||
# Run 3 migrations
|
||||
just migrate up 3
|
||||
|
||||
# Rollback last migration group
|
||||
make migrate-rollback
|
||||
# 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
|
||||
make migrate-create NAME=add_email_to_users
|
||||
|
||||
# Dev: Run migrations without backup (faster)
|
||||
make migrate-no-backup
|
||||
just migrate new add_email_to_users
|
||||
|
||||
# Dev: Reset database (DESTRUCTIVE - deletes all data)
|
||||
make reset-db
|
||||
just reset-db
|
||||
```
|
||||
|
||||
#### Creating a New Migration
|
||||
@@ -89,9 +73,9 @@ make reset-db
|
||||
|
||||
1. **Generate migration file:**
|
||||
```bash
|
||||
make migrate-create NAME=add_email_to_users
|
||||
just migrate new add_leagues_and_slap_version
|
||||
```
|
||||
Creates: `cmd/oslstats/migrations/20250124150030_add_email_to_users.go`
|
||||
Creates: `cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go`
|
||||
|
||||
2. **Edit the migration file:**
|
||||
```go
|
||||
@@ -106,15 +90,55 @@ make reset-db
|
||||
Migrations.MustRegister(
|
||||
// UP: Add email column
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users ADD COLUMN email VARCHAR(255)")
|
||||
return err
|
||||
_, 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 {
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users DROP COLUMN email")
|
||||
return err
|
||||
// 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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -122,37 +146,26 @@ make reset-db
|
||||
|
||||
3. **Update the model** (`internal/db/user.go`):
|
||||
```go
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
type Season struct {
|
||||
bun.BaseModel `bun:"table:seasons,alias:s"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
Username string `bun:"username,unique"`
|
||||
Email string `bun:"email"` // NEW FIELD
|
||||
CreatedAt int64 `bun:"created_at"`
|
||||
DiscordID string `bun:"discord_id,unique"`
|
||||
Name string `bun:"name,unique"`
|
||||
SlapVersion string `bun:"slap_version"` // NEW FIELD
|
||||
}
|
||||
```
|
||||
|
||||
4. **Preview the migration (optional):**
|
||||
4. **Apply the migration:**
|
||||
```bash
|
||||
make migrate-dry-run
|
||||
```
|
||||
Output:
|
||||
```
|
||||
[INFO] Pending migrations (dry-run):
|
||||
📋 20250124150030_add_email_to_users
|
||||
[INFO] Would migrate to group 2
|
||||
```
|
||||
|
||||
5. **Apply the migration:**
|
||||
```bash
|
||||
make migrate
|
||||
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...
|
||||
@@ -163,96 +176,6 @@ make reset-db
|
||||
[INFO] Migration lock released
|
||||
```
|
||||
|
||||
#### Migration Patterns
|
||||
|
||||
**Create Table:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*db.Game)(nil)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.NewDropTable().
|
||||
Model((*db.Game)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
return err
|
||||
```
|
||||
|
||||
**Add Column:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users ADD COLUMN email VARCHAR(255)")
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users DROP COLUMN email")
|
||||
return err
|
||||
```
|
||||
|
||||
**Create Index:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewCreateIndex().
|
||||
Model((*db.User)(nil)).
|
||||
Index("idx_username").
|
||||
Column("username").
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.ExecContext(ctx,
|
||||
"DROP INDEX IF EXISTS idx_username")
|
||||
return err
|
||||
```
|
||||
|
||||
**Data Migration:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewUpdate().
|
||||
Model((*db.User)(nil)).
|
||||
Set("status = ?", "active").
|
||||
Where("status IS NULL").
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN (if reversible)
|
||||
_, err := db.NewUpdate().
|
||||
Model((*db.User)(nil)).
|
||||
Set("status = NULL").
|
||||
Where("status = ?", "active").
|
||||
Exec(ctx)
|
||||
return err
|
||||
```
|
||||
|
||||
#### Safety Features
|
||||
|
||||
✅ **Automatic Backups**
|
||||
- Compressed with gzip (saves ~80% space)
|
||||
- Created before every migration and rollback
|
||||
- Stored in `backups/` directory
|
||||
- Retention policy keeps last 10 backups
|
||||
- Graceful fallback if `pg_dump` not installed
|
||||
|
||||
✅ **Migration Locking**
|
||||
- PostgreSQL advisory locks prevent concurrent migrations
|
||||
- 5-minute timeout prevents hung locks
|
||||
- Safe for multi-instance deployments
|
||||
|
||||
✅ **Validation**
|
||||
- Pre-migration `go build` check ensures migrations compile
|
||||
- Dry-run mode previews changes without applying
|
||||
- Status command shows exactly what's applied
|
||||
|
||||
✅ **Rollback Support**
|
||||
- Every migration must have a DOWN function
|
||||
- Rollbacks are grouped (undo last batch together)
|
||||
- Automatic backup before rollback
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
```bash
|
||||
@@ -289,100 +212,28 @@ DB_BACKUP_RETENTION=10
|
||||
- Fix the errors and try again
|
||||
- Use `go build ./cmd/oslstats/migrations` to debug
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Always test in development first**
|
||||
- Use `make migrate-dry-run` to preview
|
||||
- Test rollback: `make migrate-rollback`
|
||||
- Verify with `make migrate-status`
|
||||
|
||||
2. **Write reversible migrations**
|
||||
- DOWN function should undo UP changes
|
||||
- Test rollbacks work correctly
|
||||
- Some operations (data deletion) may not be reversible
|
||||
|
||||
3. **Keep migrations focused**
|
||||
- One migration = one logical change
|
||||
- Don't mix schema changes with data migrations
|
||||
- Use descriptive names
|
||||
|
||||
4. **Production checklist**
|
||||
- ✅ Tested in development
|
||||
- ✅ Tested rollback
|
||||
- ✅ Verified backup works
|
||||
- ✅ Communicated downtime (if any)
|
||||
- ✅ Have rollback plan ready
|
||||
|
||||
5. **Development workflow**
|
||||
- Use `--no-backup` for speed: `make migrate-no-backup`
|
||||
- Use `--reset-db` to start fresh (loses data!)
|
||||
- Commit migrations to version control
|
||||
|
||||
### Configuration Management
|
||||
```bash
|
||||
# Generate .env template file
|
||||
make genenv
|
||||
# OR with custom output: make genenv OUT=.env.example
|
||||
just genenv
|
||||
# OR with custom output: just genenv .env.example
|
||||
|
||||
# Show environment variable documentation
|
||||
make envdoc
|
||||
just envdoc
|
||||
|
||||
# Show current environment values
|
||||
make showenv
|
||||
just showenv
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Import Organization
|
||||
Organize imports in **3 groups** separated by blank lines:
|
||||
|
||||
```go
|
||||
import (
|
||||
// 1. Standard library
|
||||
"context"
|
||||
"net/http"
|
||||
"fmt"
|
||||
|
||||
// 2. External dependencies
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
// 3. Internal packages
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Variables**:
|
||||
- Local: `camelCase` (userAgentKey, httpServer, dbConn)
|
||||
- Exported: `PascalCase` (Config, User, Token)
|
||||
- Common abbreviations: `cfg`, `ctx`, `tx`, `db`, `err`, `w`, `r`
|
||||
|
||||
**Functions**:
|
||||
- Exported: `PascalCase` (GetConfig, NewStore, GenerateState)
|
||||
- Private: `camelCase` (throwError, shouldShowDetails, loadModels)
|
||||
- HTTP handlers: Return `http.Handler`, use dependency injection pattern
|
||||
- Database functions: Use `bun.Tx` as parameter for transactions
|
||||
|
||||
**Types**:
|
||||
- Structs/Interfaces: `PascalCase` (Config, User, OAuthSession)
|
||||
- Use `-er` suffix for interfaces (implied from usage)
|
||||
|
||||
**Files**:
|
||||
- Prefer single word: `config.go`, `oauth.go`, `errors.go`
|
||||
- Don't use snake_case except for tests: `state_test.go`
|
||||
- Test files: `*_test.go` alongside source files
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Always wrap errors** with context using `github.com/pkg/errors`:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "operation_name")
|
||||
return errors.Wrap(err, "package.FunctionName")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -491,94 +342,14 @@ func ConfigFromEnv() (any, error) {
|
||||
- Use inline comments for ENV var documentation in Config structs
|
||||
- Explain security-critical code flows
|
||||
|
||||
### Testing
|
||||
|
||||
**Test File Location**: Place `*_test.go` files alongside source files
|
||||
|
||||
**Test Naming**:
|
||||
```go
|
||||
func TestFunctionName_Scenario(t *testing.T)
|
||||
func TestGenerateState_Success(t *testing.T)
|
||||
func TestVerifyState_WrongUserAgentKey(t *testing.T)
|
||||
```
|
||||
|
||||
**Test Structure**:
|
||||
- Use subtests with `t.Run()` for related scenarios
|
||||
- Use table-driven tests for multiple similar cases
|
||||
- Create helper functions for common setup (e.g., `testConfig()`)
|
||||
- Test happy paths, error cases, edge cases, and security properties
|
||||
|
||||
**Test Categories** (from pkg/oauth/state_test.go example):
|
||||
1. Happy path tests
|
||||
2. Error handling (nil params, empty fields, malformed input)
|
||||
3. Security tests (MITM, CSRF, replay attacks, tampering)
|
||||
4. Edge cases (concurrency, constant-time comparison)
|
||||
5. Integration tests (round-trip verification)
|
||||
|
||||
### Security
|
||||
|
||||
**Critical Practices**:
|
||||
- Use `crypto/subtle.ConstantTimeCompare` for cryptographic comparisons
|
||||
- Implement CSRF protection via state tokens
|
||||
- Store sensitive cookies as HttpOnly
|
||||
- Use separate logging levels for security violations (WARN)
|
||||
- Validate all inputs at function boundaries
|
||||
- Use parameterized queries (Bun ORM handles this)
|
||||
- Never commit secrets (.env, keys/ are gitignored)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
oslstats/
|
||||
├── cmd/oslstats/ # Application entry point
|
||||
│ ├── main.go # Entry point with flag parsing
|
||||
│ ├── run.go # Server initialization & graceful shutdown
|
||||
│ ├── httpserver.go # HTTP server setup
|
||||
│ ├── routes.go # Route registration
|
||||
│ ├── middleware.go # Middleware registration
|
||||
│ ├── auth.go # Authentication setup
|
||||
│ └── db.go # Database connection & migrations
|
||||
├── internal/ # Private application code
|
||||
│ ├── config/ # Configuration aggregation
|
||||
│ ├── db/ # Database models & queries (Bun ORM)
|
||||
│ ├── discord/ # Discord OAuth integration
|
||||
│ ├── handlers/ # HTTP request handlers
|
||||
│ ├── session/ # Session store (in-memory)
|
||||
│ └── view/ # Templ templates
|
||||
│ ├── component/ # Reusable UI components
|
||||
│ ├── layout/ # Page layouts
|
||||
│ └── page/ # Full pages
|
||||
├── pkg/ # Reusable packages
|
||||
│ ├── contexts/ # Context key definitions
|
||||
│ ├── embedfs/ # Embedded static files
|
||||
│ └── oauth/ # OAuth state management
|
||||
├── bin/ # Compiled binaries (gitignored)
|
||||
├── keys/ # Private keys (gitignored)
|
||||
├── tmp/ # Air hot reload temp files (gitignored)
|
||||
├── Makefile # Build automation
|
||||
├── .air.toml # Hot reload configuration
|
||||
└── go.mod # Go module definition
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **git.haelnorr.com/h/golib/*** - Custom libraries (env, ezconf, hlog, hws, hwsauth, cookies, jwt)
|
||||
- **github.com/a-h/templ** - Type-safe HTML templating
|
||||
- **github.com/uptrace/bun** - PostgreSQL ORM
|
||||
- **github.com/bwmarrin/discordgo** - Discord API client
|
||||
- **github.com/pkg/errors** - Error wrapping (use this, not fmt.Errorf)
|
||||
- **github.com/joho/godotenv** - .env file loading
|
||||
|
||||
## 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 `templ generate` after changes
|
||||
4. **Static files** are embedded via `//go:embed` - check pkg/embedfs/
|
||||
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. **Test coverage** is currently limited - prioritize testing security-critical code
|
||||
9. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
|
||||
10. **Graceful shutdown** is implemented in cmd/oslstats/run.go - follow this pattern
|
||||
11. When in plan mode, always use the interactive question tool if available
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user