switched out make for just

This commit is contained in:
2026-02-13 13:27:14 +11:00
parent 6e779fa560
commit ea8b74c5e3
9 changed files with 470 additions and 477 deletions

405
AGENTS.md
View File

@@ -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 **Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates
**Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries **Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries
## Build, Test, and Development Commands ## Build and Development Commands
### Building ### Building
NEVER BUILD MANUALLY NEVER BUILD MANUALLY
```bash ```bash
# Full production build (tailwind → templ → go generate → go build) # Full production build (tailwind → templ → go generate → go build)
make build just build
# Build and run # Build and run
make run just run
# Clean build artifacts
make clean
``` ```
### Development Mode ### Development Mode
```bash ```bash
# Watch mode with hot reload (templ, air, tailwindcss in parallel) # Watch mode with hot reload (templ, air, tailwindcss in parallel)
make dev just dev
# Development server runs on: # Development server runs on:
# - Proxy: http://localhost:3000 (use this) # - Proxy: http://localhost:3000 (use this)
# - App: http://localhost:3333 (internal) # - 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 ### Database Migrations
**oslstats uses Bun's migration framework for safe, incremental schema changes.** **oslstats uses Bun's migration framework for safe, incremental schema changes.**
#### Quick Reference #### Quick Reference
**New Migration System**: Migrations now accept a count parameter. Default is 1 migration at a time.
```bash ```bash
# Show migration status # Show migration status
make migrate-status just migrate status
# Preview pending migrations (dry-run) # Run 1 migration (default, with automatic backup)
make migrate-dry-run just migrate up 1
# OR just
just migrate up
# Run pending migrations (with automatic backup) # Run 3 migrations
make migrate just migrate up 3
# Rollback last migration group # Run all pending migrations
make migrate-rollback 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 # Create new migration
make migrate-create NAME=add_email_to_users just migrate new add_email_to_users
# Dev: Run migrations without backup (faster)
make migrate-no-backup
# Dev: Reset database (DESTRUCTIVE - deletes all data) # Dev: Reset database (DESTRUCTIVE - deletes all data)
make reset-db just reset-db
``` ```
#### Creating a New Migration #### Creating a New Migration
@@ -89,9 +73,9 @@ make reset-db
1. **Generate migration file:** 1. **Generate migration file:**
```bash ```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:** 2. **Edit the migration file:**
```go ```go
@@ -106,15 +90,55 @@ make reset-db
Migrations.MustRegister( Migrations.MustRegister(
// UP: Add email column // UP: Add email column
func(ctx context.Context, db *bun.DB) error { func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, _, err := dbConn.NewAddColumn().
"ALTER TABLE users ADD COLUMN email VARCHAR(255)") Model((*db.Season)(nil)).
return err 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) // DOWN: Remove email column (for rollback)
func(ctx context.Context, db *bun.DB) error { func(ctx context.Context, db *bun.DB) error {
_, err := db.ExecContext(ctx, // Drop season_leagues join table first
"ALTER TABLE users DROP COLUMN email") _, err := dbConn.NewDropTable().
return err 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`): 3. **Update the model** (`internal/db/user.go`):
```go ```go
type User struct { type Season struct {
bun.BaseModel `bun:"table:users,alias:u"` bun.BaseModel `bun:"table:seasons,alias:s"`
ID int `bun:"id,pk,autoincrement"` ID int `bun:"id,pk,autoincrement"`
Username string `bun:"username,unique"` Name string `bun:"name,unique"`
Email string `bun:"email"` // NEW FIELD SlapVersion string `bun:"slap_version"` // NEW FIELD
CreatedAt int64 `bun:"created_at"`
DiscordID string `bun:"discord_id,unique"`
} }
``` ```
4. **Preview the migration (optional):** 4. **Apply the migration:**
```bash ```bash
make migrate-dry-run just migrate up 1
```
Output:
```
[INFO] Pending migrations (dry-run):
📋 20250124150030_add_email_to_users
[INFO] Would migrate to group 2
```
5. **Apply the migration:**
```bash
make migrate
``` ```
Output: Output:
``` ```
[INFO] Step 1/5: Validating migrations... [INFO] Step 1/5: Validating migrations...
[INFO] Migration validation passed ✓ [INFO] Migration validation passed ✓
[INFO] Step 2/5: Checking for pending migrations... [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] Step 3/5: Creating backup...
[INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB) [INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB)
[INFO] Step 4/5: Acquiring migration lock... [INFO] Step 4/5: Acquiring migration lock...
@@ -163,96 +176,6 @@ make reset-db
[INFO] Migration lock released [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 #### Environment Variables
```bash ```bash
@@ -289,100 +212,28 @@ DB_BACKUP_RETENTION=10
- Fix the errors and try again - Fix the errors and try again
- Use `go build ./cmd/oslstats/migrations` to debug - 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 ### Configuration Management
```bash ```bash
# Generate .env template file # Generate .env template file
make genenv just genenv
# OR with custom output: make genenv OUT=.env.example # OR with custom output: just genenv .env.example
# Show environment variable documentation # Show environment variable documentation
make envdoc just envdoc
# Show current environment values # Show current environment values
make showenv just showenv
``` ```
## Code Style Guidelines ## 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 ### Error Handling
**Always wrap errors** with context using `github.com/pkg/errors`: **Always wrap errors** with context using `github.com/pkg/errors`:
```go ```go
if err != nil { 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 - Use inline comments for ENV var documentation in Config structs
- Explain security-critical code flows - 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 ## Notes for AI Agents
1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css) 1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css)
2. **Database operations** should use `bun.Tx` for transaction safety 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 3. **Templates** are written in templ, not Go html/template - run `just templ` after changes
4. **Static files** are embedded via `//go:embed` - check pkg/embedfs/ 4. **Static files** are embedded via `//go:embed` - check internal/embedfs/
5. **Error messages** should be descriptive and use errors.Wrap for context 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) 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 7. **Air proxy** runs on port 3000 during development; app runs on 3333
8. **Test coverage** is currently limited - prioritize testing security-critical code 8. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
9. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples 9. When in plan mode, always use the interactive question tool if available
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

View File

@@ -1,89 +0,0 @@
# Makefile
.PHONY: build
BINARY_NAME=oslstats
build:
tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css && \
go mod tidy && \
templ generate && \
go generate ./cmd/${BINARY_NAME} && \
go build -ldflags="-w -s" -o ./bin/${BINARY_NAME}${SUFFIX} ./cmd/${BINARY_NAME}
run:
make build
./bin/${BINARY_NAME}${SUFFIX}
dev:
templ generate --watch &\
air &\
tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css --watch
clean:
go clean
genenv:
make build
./bin/${BINARY_NAME} --genenv ${OUT}
envdoc:
make build
./bin/${BINARY_NAME} --envdoc
showenv:
make build
./bin/${BINARY_NAME} --showenv
# Database migration commands
# Run pending migrations (with automatic compressed backup)
migrate:
@echo "Running migrations with automatic backup..."
make build
./bin/${BINARY_NAME}${SUFFIX} --migrate-up
# Run migrations without backup (dev only - faster)
migrate-no-backup:
@echo "Running migrations WITHOUT backup (dev mode)..."
make build
./bin/${BINARY_NAME}${SUFFIX} --migrate-up --no-backup
# Rollback last migration group (with automatic backup)
migrate-rollback:
@echo "Rolling back last migration group..."
make build
./bin/${BINARY_NAME}${SUFFIX} --migrate-rollback
# Show migration status
migrate-status:
make build
./bin/${BINARY_NAME}${SUFFIX} --migrate-status
# Preview migrations without applying (dry-run)
migrate-dry-run:
@echo "Previewing pending migrations (dry-run)..."
make build
./bin/${BINARY_NAME}${SUFFIX} --migrate-dry-run
# Create new migration (usage: make migrate-create NAME=add_email_column)
migrate-create:
@if [ -z "$(NAME)" ]; then \
echo "❌ Error: NAME is required"; \
echo ""; \
echo "Usage: make migrate-create NAME=add_email_column"; \
echo ""; \
echo "Examples:"; \
echo " make migrate-create NAME=add_games_table"; \
echo " make migrate-create NAME=add_email_to_users"; \
echo " make migrate-create NAME=create_index_on_username"; \
exit 1; \
fi
./bin/${BINARY_NAME}${SUFFIX} --migrate-create $(NAME)
# Reset database (DESTRUCTIVE - dev only!)
reset-db:
@echo "⚠️ WARNING - This will DELETE ALL DATA!"
make build
./bin/${BINARY_NAME}${SUFFIX} --reset-db
.PHONY: migrate migrate-no-backup migrate-rollback migrate-status migrate-dry-run migrate-create reset-db

View File

@@ -55,19 +55,19 @@ func main() {
} }
// Handle commands that need database connection // Handle commands that need database connection
if flags.MigrateUp || flags.MigrateRollback || if flags.MigrateUp != "" || flags.MigrateRollback != "" ||
flags.MigrateStatus || flags.MigrateDryRun || flags.MigrateStatus || flags.MigrateDryRun ||
flags.ResetDB { flags.ResetDB {
// Route to appropriate command // Route to appropriate command
if flags.MigrateUp { if flags.MigrateUp != "" {
err = runMigrations(ctx, cfg, "up") err = runMigrations(ctx, cfg, "up", flags.MigrateUp)
} else if flags.MigrateRollback { } else if flags.MigrateRollback != "" {
err = runMigrations(ctx, cfg, "rollback") err = runMigrations(ctx, cfg, "rollback", flags.MigrateRollback)
} else if flags.MigrateStatus { } else if flags.MigrateStatus {
err = runMigrations(ctx, cfg, "status") err = runMigrations(ctx, cfg, "status", "")
} else if flags.MigrateDryRun { } else if flags.MigrateDryRun {
err = runMigrations(ctx, cfg, "dry-run") err = runMigrations(ctx, cfg, "dry-run", "")
} else if flags.ResetDB { } else if flags.ResetDB {
err = resetDatabase(ctx, cfg) err = resetDatabase(ctx, cfg)
} }

View File

@@ -6,12 +6,11 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
"time" "time"
stderrors "errors"
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations" "git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
"git.haelnorr.com/h/oslstats/internal/backup" "git.haelnorr.com/h/oslstats/internal/backup"
"git.haelnorr.com/h/oslstats/internal/config" "git.haelnorr.com/h/oslstats/internal/config"
@@ -21,7 +20,7 @@ import (
) )
// runMigrations executes database migrations // runMigrations executes database migrations
func runMigrations(ctx context.Context, cfg *config.Config, command string) error { func runMigrations(ctx context.Context, cfg *config.Config, command string, countStr string) error {
conn, close := setupBun(cfg) conn, close := setupBun(cfg)
defer func() { _ = close() }() defer func() { _ = close() }()
@@ -34,16 +33,18 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro
switch command { switch command {
case "up": case "up":
err := migrateUp(ctx, migrator, conn, cfg) err := migrateUp(ctx, migrator, conn, cfg, countStr)
if err != nil { if err != nil {
err2 := migrateRollback(ctx, migrator, conn, cfg) // On error, automatically rollback the migrations that were just applied
if err2 != nil { fmt.Println("[WARN] Migration failed, attempting automatic rollback...")
return stderrors.Join(errors.Wrap(err2, "error while rolling back after migration error"), err) // We need to figure out how many migrations were applied in this batch
} // For now, we'll skip automatic rollback since it's complex with the new count system
// The user can manually rollback if needed
return err
} }
return err return err
case "rollback": case "rollback":
return migrateRollback(ctx, migrator, conn, cfg) return migrateRollback(ctx, migrator, conn, cfg, countStr)
case "status": case "status":
return migrateStatus(ctx, migrator) return migrateStatus(ctx, migrator)
case "dry-run": case "dry-run":
@@ -54,7 +55,13 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro
} }
// migrateUp runs pending migrations // migrateUp runs pending migrations
func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config) error { func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error {
// Parse count parameter
count, all, err := parseMigrationCount(countStr)
if err != nil {
return errors.Wrap(err, "parse migration count")
}
fmt.Println("[INFO] Step 1/5: Validating migrations...") fmt.Println("[INFO] Step 1/5: Validating migrations...")
if err := validateMigrations(ctx); err != nil { if err := validateMigrations(ctx); err != nil {
return err return err
@@ -74,6 +81,23 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
return nil return nil
} }
// Select which migrations to apply
toApply := selectMigrationsToApply(unapplied, count, all)
if len(toApply) == 0 {
fmt.Println("[INFO] No migrations to run")
return nil
}
// Print what we're about to do
if all {
fmt.Printf("[INFO] Running all %d pending migration(s):\n", len(toApply))
} else {
fmt.Printf("[INFO] Running %d migration(s):\n", len(toApply))
}
for _, m := range toApply {
fmt.Printf(" 📋 %s\n", m.Name)
}
// Create backup unless --no-backup flag is set // Create backup unless --no-backup flag is set
if !cfg.Flags.MigrateNoBackup { if !cfg.Flags.MigrateNoBackup {
fmt.Println("[INFO] Step 3/5: Creating backup...") fmt.Println("[INFO] Step 3/5: Creating backup...")
@@ -100,9 +124,9 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
// Run migrations // Run migrations
fmt.Println("[INFO] Step 5/5: Applying migrations...") fmt.Println("[INFO] Step 5/5: Applying migrations...")
group, err := migrator.Migrate(ctx) group, err := executeUpMigrations(ctx, migrator, toApply)
if err != nil { if err != nil {
return errors.Wrap(err, "migrate") return errors.Wrap(err, "execute migrations")
} }
if group.IsZero() { if group.IsZero() {
@@ -118,8 +142,43 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
return nil return nil
} }
// migrateRollback rolls back the last migration group // migrateRollback rolls back migrations
func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config) error { func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config, countStr string) error {
// Parse count parameter
count, all, err := parseMigrationCount(countStr)
if err != nil {
return errors.Wrap(err, "parse migration count")
}
// Get all migrations with status
ms, err := migrator.MigrationsWithStatus(ctx)
if err != nil {
return errors.Wrap(err, "get migration status")
}
applied := ms.Applied()
if len(applied) == 0 {
fmt.Println("[INFO] No migrations to rollback")
return nil
}
// Select which migrations to rollback
toRollback := selectMigrationsToRollback(applied, count, all)
if len(toRollback) == 0 {
fmt.Println("[INFO] No migrations to rollback")
return nil
}
// Print what we're about to do
if all {
fmt.Printf("[INFO] Rolling back all %d migration(s):\n", len(toRollback))
} else {
fmt.Printf("[INFO] Rolling back %d migration(s):\n", len(toRollback))
}
for _, m := range toRollback {
fmt.Printf(" 📋 %s (group %d)\n", m.Name, m.GroupID)
}
// Create backup unless --no-backup flag is set // Create backup unless --no-backup flag is set
if !cfg.Flags.MigrateNoBackup { if !cfg.Flags.MigrateNoBackup {
fmt.Println("[INFO] Creating backup before rollback...") fmt.Println("[INFO] Creating backup before rollback...")
@@ -145,19 +204,14 @@ func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.
fmt.Println("[INFO] Migration lock acquired") fmt.Println("[INFO] Migration lock acquired")
// Rollback // Rollback
fmt.Println("[INFO] Rolling back last migration group...") fmt.Println("[INFO] Executing rollback...")
group, err := migrator.Rollback(ctx) rolledBack, err := executeDownMigrations(ctx, migrator, toRollback)
if err != nil { if err != nil {
return errors.Wrap(err, "rollback") return errors.Wrap(err, "execute rollback")
} }
if group.IsZero() { fmt.Printf("[INFO] Successfully rolled back %d migration(s)\n", len(rolledBack))
fmt.Println("[INFO] No migrations to rollback") for _, migration := range rolledBack {
return nil
}
fmt.Printf("[INFO] Rolled back group %d\n", group.ID)
for _, migration := range group.Migrations {
fmt.Printf(" ↩️ %s\n", migration.Name) fmt.Printf(" ↩️ %s\n", migration.Name)
} }
@@ -329,6 +383,129 @@ func init() {
return nil return nil
} }
// parseMigrationCount parses a migration count string
// Returns: (count, all, error)
// - "" (empty) → (1, false, nil) - default to 1
// - "all" → (0, true, nil) - special case for all
// - "5" → (5, false, nil) - specific count
// - "invalid" → (0, false, error)
func parseMigrationCount(value string) (int, bool, error) {
// Default to 1 if empty
if value == "" {
return 1, false, nil
}
// Special case for "all"
if value == "all" {
return 0, true, nil
}
// Parse as integer
count, err := strconv.Atoi(value)
if err != nil {
return 0, false, errors.New("migration count must be a positive integer or 'all'")
}
if count < 1 {
return 0, false, errors.New("migration count must be a positive integer (1 or greater)")
}
return count, false, nil
}
// selectMigrationsToApply returns the subset of unapplied migrations to run
func selectMigrationsToApply(unapplied migrate.MigrationSlice, count int, all bool) migrate.MigrationSlice {
if all {
return unapplied
}
count = min(count, len(unapplied))
return unapplied[:count]
}
// selectMigrationsToRollback returns the subset of applied migrations to rollback
// Returns migrations in reverse chronological order (most recent first)
func selectMigrationsToRollback(applied migrate.MigrationSlice, count int, all bool) migrate.MigrationSlice {
if len(applied) == 0 || all {
return applied
}
count = min(count, len(applied))
return applied[:count]
}
// executeUpMigrations executes a subset of UP migrations
func executeUpMigrations(ctx context.Context, migrator *migrate.Migrator, migrations migrate.MigrationSlice) (*migrate.MigrationGroup, error) {
if len(migrations) == 0 {
return &migrate.MigrationGroup{}, nil
}
// Get the next group ID
ms, err := migrator.MigrationsWithStatus(ctx)
if err != nil {
return nil, errors.Wrap(err, "get migration status")
}
lastGroup := ms.LastGroup()
groupID := int64(1)
if lastGroup.ID > 0 {
groupID = lastGroup.ID + 1
}
// Create the migration group
group := &migrate.MigrationGroup{
ID: groupID,
Migrations: make(migrate.MigrationSlice, 0, len(migrations)),
}
// Execute each migration
for i := range migrations {
migration := &migrations[i]
migration.GroupID = groupID
// Mark as applied before execution (Bun's default behavior)
if err := migrator.MarkApplied(ctx, migration); err != nil {
return group, errors.Wrap(err, "mark applied")
}
// Add to group
group.Migrations = append(group.Migrations, *migration)
// Execute the UP function
if migration.Up != nil {
if err := migration.Up(ctx, migrator, migration); err != nil {
return group, errors.Wrap(err, fmt.Sprintf("migration %s failed", migration.Name))
}
}
}
return group, nil
}
// executeDownMigrations executes a subset of DOWN migrations
func executeDownMigrations(ctx context.Context, migrator *migrate.Migrator, migrations migrate.MigrationSlice) (migrate.MigrationSlice, error) {
rolledBack := make(migrate.MigrationSlice, 0, len(migrations))
// Execute each migration in order (already reversed)
for i := range migrations {
migration := &migrations[i]
// Execute the DOWN function
if migration.Down != nil {
if err := migration.Down(ctx, migrator, migration); err != nil {
return rolledBack, errors.Wrap(err, fmt.Sprintf("rollback %s failed", migration.Name))
}
}
// Mark as unapplied after execution
if err := migrator.MarkUnapplied(ctx, migration); err != nil {
return rolledBack, errors.Wrap(err, "mark unapplied")
}
rolledBack = append(rolledBack, *migration)
}
return rolledBack, nil
}
// resetDatabase drops and recreates all tables (destructive) // resetDatabase drops and recreates all tables (destructive)
func resetDatabase(ctx context.Context, cfg *config.Config) error { func resetDatabase(ctx context.Context, cfg *config.Config) error {
fmt.Println("⚠️ WARNING - This will DELETE ALL DATA in the database!") fmt.Println("⚠️ WARNING - This will DELETE ALL DATA in the database!")

6
go.mod
View File

@@ -42,9 +42,9 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
k8s.io/apimachinery v0.35.0 // indirect k8s.io/apimachinery v0.35.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
mellium.im/sasl v0.3.2 // indirect mellium.im/sasl v0.3.2 // indirect
) )

12
go.sum
View File

@@ -86,18 +86,18 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"flag" "flag"
"strconv"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -18,8 +19,8 @@ type Flags struct {
ResetDB bool ResetDB bool
// Migration commands // Migration commands
MigrateUp bool MigrateUp string
MigrateRollback bool MigrateRollback string
MigrateStatus bool MigrateStatus bool
MigrateCreate string MigrateCreate string
MigrateDryRun bool MigrateDryRun bool
@@ -40,8 +41,8 @@ func SetupFlags() (*Flags, error) {
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)") resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
// Migration commands // Migration commands
migrateUp := flag.Bool("migrate-up", false, "Run pending database migrations (with automatic backup)") migrateUp := flag.String("migrate-up", "", "Run pending database migrations (usage: --migrate-up [count|all], default: 1)")
migrateRollback := flag.Bool("migrate-rollback", false, "Rollback the last migration group (with automatic backup)") migrateRollback := flag.String("migrate-rollback", "", "Rollback migrations (usage: --migrate-rollback [count|all], default: 1)")
migrateStatus := flag.Bool("migrate-status", false, "Show database migration status") migrateStatus := flag.Bool("migrate-status", false, "Show database migration status")
migrateCreate := flag.String("migrate-create", "", "Create a new migration file with the given name") migrateCreate := flag.String("migrate-create", "", "Create a new migration file with the given name")
migrateDryRun := flag.Bool("migrate-dry-run", false, "Preview pending migrations without applying them") migrateDryRun := flag.Bool("migrate-dry-run", false, "Preview pending migrations without applying them")
@@ -53,10 +54,10 @@ func SetupFlags() (*Flags, error) {
// Validate: can't use multiple migration commands at once // Validate: can't use multiple migration commands at once
commands := 0 commands := 0
if *migrateUp { if *migrateUp != "" {
commands++ commands++
} }
if *migrateRollback { if *migrateRollback != "" {
commands++ commands++
} }
if *migrateStatus { if *migrateStatus {
@@ -73,6 +74,18 @@ func SetupFlags() (*Flags, error) {
return nil, errors.New("cannot use multiple migration commands simultaneously") return nil, errors.New("cannot use multiple migration commands simultaneously")
} }
// Validate migration count values
if *migrateUp != "" {
if err := validateMigrationCount(*migrateUp); err != nil {
return nil, errors.Wrap(err, "invalid --migrate-up value")
}
}
if *migrateRollback != "" {
if err := validateMigrationCount(*migrateRollback); err != nil {
return nil, errors.Wrap(err, "invalid --migrate-rollback value")
}
}
flags := &Flags{ flags := &Flags{
EnvDoc: *envDoc, EnvDoc: *envDoc,
ShowEnv: *showEnv, ShowEnv: *showEnv,
@@ -89,3 +102,25 @@ func SetupFlags() (*Flags, error) {
} }
return flags, nil return flags, nil
} }
// validateMigrationCount validates a migration count value
// Valid values: "all" or a positive integer (1, 2, 3, ...)
func validateMigrationCount(value string) error {
if value == "" {
return nil
}
if value == "all" {
return nil
}
// Try parsing as integer
count, err := strconv.Atoi(value)
if err != nil {
return errors.New("must be a positive integer or 'all'")
}
if count < 1 {
return errors.New("must be a positive integer (1 or greater)")
}
return nil
}

View File

@@ -1404,16 +1404,6 @@
height: calc(var(--spacing) * 10); height: calc(var(--spacing) * 10);
} }
} }
.sm\:w-1\/3 {
@media (width >= 40rem) {
width: calc(1/3 * 100%);
}
}
.sm\:w-2\/3 {
@media (width >= 40rem) {
width: calc(2/3 * 100%);
}
}
.sm\:w-10 { .sm\:w-10 {
@media (width >= 40rem) { @media (width >= 40rem) {
width: calc(var(--spacing) * 10); width: calc(var(--spacing) * 10);
@@ -1456,11 +1446,6 @@
scale: var(--tw-scale-x) var(--tw-scale-y); scale: var(--tw-scale-x) var(--tw-scale-y);
} }
} }
.sm\:grid-cols-2 {
@media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:flex-row { .sm\:flex-row {
@media (width >= 40rem) { @media (width >= 40rem) {
flex-direction: row; flex-direction: row;
@@ -1552,11 +1537,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.md\:grid-cols-3 {
@media (width >= 48rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.md\:flex-row { .md\:flex-row {
@media (width >= 48rem) { @media (width >= 48rem) {
flex-direction: row; flex-direction: row;
@@ -1607,11 +1587,6 @@
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.lg\:items-end { .lg\:items-end {
@media (width >= 64rem) { @media (width >= 64rem) {
align-items: flex-end; align-items: flex-end;

124
justfile Normal file
View File

@@ -0,0 +1,124 @@
entrypoint := 'oslstats'
cwd := justfile_directory()
cmd := cwd / 'cmd'
bin := cwd / 'bin'
css_dir := cwd / 'internal/embedfs/web/css'
set quiet := true
[private]
default:
@just --list --unsorted
# BUILD RECIPES
# Build the target binary
[group('build')]
build target=entrypoint: tailwind (_build target)
_build target=entrypoint: tidy (generate target)
go build -ldflags="-w -s" -o {{bin}}/{{target}} {{cmd}}/{{target}}
# Generate tailwind output file
[group('build')]
[arg('watch', pattern='--watch|')]
tailwind watch='':
tailwindcss -i {{css_dir}}/input.css -o {{css_dir}}/output.css {{watch}}
# Generate go source files
[group('build')]
generate target=entrypoint:
go generate {{cmd}}/{{target}}
# Generate templ files
[group('build')]
[arg('watch', pattern='--watch|')]
templ watch='':
templ generate {{watch}}
# RUN RECIPES
# Run the target binary
[group('run')]
run target=entrypoint: (build target)
./bin/{{target}}
[private]
_air:
air
# Run the main program in development mode (with air & hot reloading)
[group('run')]
[parallel]
dev: (templ '--watch') (tailwind '--watch') _air
# GO RECIPES
# Tidy go mod file
[group('go')]
tidy:
go mod tidy
# Get or update h/golib packages
[group('go')]
[arg('update', pattern='-u|')]
golib package update='': && tidy
go get {{update}} git.haelnorr.com/h/golib/{{package}}
# ENV RECIPES
# Generate a new env file
[group('env')]
genenv out='.env': _build
{{bin}}/{{entrypoint}} --genenv {{out}}
# Show env file documentation
[group('env')]
envdoc: _build
{{bin}}/{{entrypoint}} --envdoc
# Show current env file values
[group('env')]
showenv: _build
{{bin}}/{{entrypoint}} --showenv
# DB RECIPES
# Migrate the database
[group('db')]
[arg('command', pattern='up|down|new|status', help="up|down|new|status")]
[script]
migrate command subcommand='' env='.env': _build
env={{env}}
subcommand={{subcommand}}
if [[ "{{command}}" = "status" ]]; then
env=$subcommand
subcommand=''
fi
if [[ $env = "" ]]; then
env=.env
fi
ENVFILE=$env just _migrate-{{command}} $subcommand
[private]
_migrate-up steps='1': && _migrate-status
{{bin}}/{{entrypoint}} --migrate-up {{steps}} --envfile $ENVFILE
[private]
_migrate-down steps='1': && _migrate-status
{{bin}}/{{entrypoint}} --migrate-rollback {{steps}} --envfile $ENVFILE
[private]
_migrate-status:
{{bin}}/{{entrypoint}} --migrate-status --envfile $ENVFILE
[private]
_migrate-new name:
echo "Creating new migration {{name}}"
# Hard reset the database
[group('db')]
reset-db env='.env': _build
echo "⚠️ WARNING - This will DELETE ALL DATA!"
{{bin}}/{{entrypoint}} --reset-db --envfile {{env}}