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
|
**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
|
|
||||||
|
|||||||
89
Makefile
89
Makefile
@@ -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
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
6
go.mod
@@ -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
12
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
124
justfile
Normal 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}}
|
||||||
Reference in New Issue
Block a user