switched out make for just
This commit is contained in:
401
AGENTS.md
401
AGENTS.md
@@ -9,78 +9,62 @@ This document provides guidelines for AI coding agents and developers working on
|
||||
**Architecture**: Web application with Discord OAuth, PostgreSQL database, templ templates
|
||||
**Key Technologies**: Bun ORM, templ, TailwindCSS, custom golib libraries
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
## Build and Development Commands
|
||||
|
||||
### Building
|
||||
NEVER BUILD MANUALLY
|
||||
```bash
|
||||
# Full production build (tailwind → templ → go generate → go build)
|
||||
make build
|
||||
just build
|
||||
|
||||
# Build and run
|
||||
make run
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
just run
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
# Watch mode with hot reload (templ, air, tailwindcss in parallel)
|
||||
make dev
|
||||
just dev
|
||||
|
||||
# Development server runs on:
|
||||
# - Proxy: http://localhost:3000 (use this)
|
||||
# - App: http://localhost:3333 (internal)
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests for a specific package
|
||||
go test ./pkg/oauth
|
||||
|
||||
# Run a single test function
|
||||
go test ./pkg/oauth -run TestGenerateState_Success
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./pkg/oauth
|
||||
|
||||
# Run tests with coverage
|
||||
go test -cover ./...
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
**oslstats uses Bun's migration framework for safe, incremental schema changes.**
|
||||
|
||||
#### Quick Reference
|
||||
|
||||
**New Migration System**: Migrations now accept a count parameter. Default is 1 migration at a time.
|
||||
|
||||
```bash
|
||||
# Show migration status
|
||||
make migrate-status
|
||||
just migrate status
|
||||
|
||||
# Preview pending migrations (dry-run)
|
||||
make migrate-dry-run
|
||||
# Run 1 migration (default, with automatic backup)
|
||||
just migrate up 1
|
||||
# OR just
|
||||
just migrate up
|
||||
|
||||
# Run pending migrations (with automatic backup)
|
||||
make migrate
|
||||
# Run 3 migrations
|
||||
just migrate up 3
|
||||
|
||||
# Rollback last migration group
|
||||
make migrate-rollback
|
||||
# Run all pending migrations
|
||||
just migrate up all
|
||||
|
||||
# Run with a specific environment file
|
||||
just migrate up 3 .test.env
|
||||
|
||||
# Rollback works the same for all arguments
|
||||
just migrate down 2 .test.env
|
||||
|
||||
# Create new migration
|
||||
make migrate-create NAME=add_email_to_users
|
||||
|
||||
# Dev: Run migrations without backup (faster)
|
||||
make migrate-no-backup
|
||||
just migrate new add_email_to_users
|
||||
|
||||
# Dev: Reset database (DESTRUCTIVE - deletes all data)
|
||||
make reset-db
|
||||
just reset-db
|
||||
```
|
||||
|
||||
#### Creating a New Migration
|
||||
@@ -89,9 +73,9 @@ make reset-db
|
||||
|
||||
1. **Generate migration file:**
|
||||
```bash
|
||||
make migrate-create NAME=add_email_to_users
|
||||
just migrate new add_leagues_and_slap_version
|
||||
```
|
||||
Creates: `cmd/oslstats/migrations/20250124150030_add_email_to_users.go`
|
||||
Creates: `cmd/oslstats/migrations/20250124150030_add_leagues_and_slap_version.go`
|
||||
|
||||
2. **Edit the migration file:**
|
||||
```go
|
||||
@@ -106,14 +90,54 @@ make reset-db
|
||||
Migrations.MustRegister(
|
||||
// UP: Add email column
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users ADD COLUMN email VARCHAR(255)")
|
||||
_, err := dbConn.NewAddColumn().
|
||||
Model((*db.Season)(nil)).
|
||||
ColumnExpr("slap_version VARCHAR NOT NULL").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create leagues table
|
||||
_, err = dbConn.NewCreateTable().
|
||||
Model((*db.League)(nil)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create season_leagues join table
|
||||
_, err = dbConn.NewCreateTable().
|
||||
Model((*db.SeasonLeague)(nil)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
// DOWN: Remove email column (for rollback)
|
||||
func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users DROP COLUMN email")
|
||||
// Drop season_leagues join table first
|
||||
_, err := dbConn.NewDropTable().
|
||||
Model((*db.SeasonLeague)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop leagues table
|
||||
_, err = dbConn.NewDropTable().
|
||||
Model((*db.League)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove slap_version column from seasons table
|
||||
_, err = dbConn.NewDropColumn().
|
||||
Model((*db.Season)(nil)).
|
||||
ColumnExpr("slap_version").
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
@@ -122,37 +146,26 @@ make reset-db
|
||||
|
||||
3. **Update the model** (`internal/db/user.go`):
|
||||
```go
|
||||
type User struct {
|
||||
bun.BaseModel `bun:"table:users,alias:u"`
|
||||
type Season struct {
|
||||
bun.BaseModel `bun:"table:seasons,alias:s"`
|
||||
|
||||
ID int `bun:"id,pk,autoincrement"`
|
||||
Username string `bun:"username,unique"`
|
||||
Email string `bun:"email"` // NEW FIELD
|
||||
CreatedAt int64 `bun:"created_at"`
|
||||
DiscordID string `bun:"discord_id,unique"`
|
||||
Name string `bun:"name,unique"`
|
||||
SlapVersion string `bun:"slap_version"` // NEW FIELD
|
||||
}
|
||||
```
|
||||
|
||||
4. **Preview the migration (optional):**
|
||||
4. **Apply the migration:**
|
||||
```bash
|
||||
make migrate-dry-run
|
||||
```
|
||||
Output:
|
||||
```
|
||||
[INFO] Pending migrations (dry-run):
|
||||
📋 20250124150030_add_email_to_users
|
||||
[INFO] Would migrate to group 2
|
||||
```
|
||||
|
||||
5. **Apply the migration:**
|
||||
```bash
|
||||
make migrate
|
||||
just migrate up 1
|
||||
```
|
||||
Output:
|
||||
```
|
||||
[INFO] Step 1/5: Validating migrations...
|
||||
[INFO] Migration validation passed ✓
|
||||
[INFO] Step 2/5: Checking for pending migrations...
|
||||
[INFO] Running 1 migration(s):
|
||||
📋 20250124150030_add_email_to_users
|
||||
[INFO] Step 3/5: Creating backup...
|
||||
[INFO] Backup created: backups/20250124_150145_pre_migration.sql.gz (2.3 MB)
|
||||
[INFO] Step 4/5: Acquiring migration lock...
|
||||
@@ -163,96 +176,6 @@ make reset-db
|
||||
[INFO] Migration lock released
|
||||
```
|
||||
|
||||
#### Migration Patterns
|
||||
|
||||
**Create Table:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewCreateTable().
|
||||
Model((*db.Game)(nil)).
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.NewDropTable().
|
||||
Model((*db.Game)(nil)).
|
||||
IfExists().
|
||||
Exec(ctx)
|
||||
return err
|
||||
```
|
||||
|
||||
**Add Column:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users ADD COLUMN email VARCHAR(255)")
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.ExecContext(ctx,
|
||||
"ALTER TABLE users DROP COLUMN email")
|
||||
return err
|
||||
```
|
||||
|
||||
**Create Index:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewCreateIndex().
|
||||
Model((*db.User)(nil)).
|
||||
Index("idx_username").
|
||||
Column("username").
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN
|
||||
_, err := db.ExecContext(ctx,
|
||||
"DROP INDEX IF EXISTS idx_username")
|
||||
return err
|
||||
```
|
||||
|
||||
**Data Migration:**
|
||||
```go
|
||||
// UP
|
||||
_, err := db.NewUpdate().
|
||||
Model((*db.User)(nil)).
|
||||
Set("status = ?", "active").
|
||||
Where("status IS NULL").
|
||||
Exec(ctx)
|
||||
return err
|
||||
|
||||
// DOWN (if reversible)
|
||||
_, err := db.NewUpdate().
|
||||
Model((*db.User)(nil)).
|
||||
Set("status = NULL").
|
||||
Where("status = ?", "active").
|
||||
Exec(ctx)
|
||||
return err
|
||||
```
|
||||
|
||||
#### Safety Features
|
||||
|
||||
✅ **Automatic Backups**
|
||||
- Compressed with gzip (saves ~80% space)
|
||||
- Created before every migration and rollback
|
||||
- Stored in `backups/` directory
|
||||
- Retention policy keeps last 10 backups
|
||||
- Graceful fallback if `pg_dump` not installed
|
||||
|
||||
✅ **Migration Locking**
|
||||
- PostgreSQL advisory locks prevent concurrent migrations
|
||||
- 5-minute timeout prevents hung locks
|
||||
- Safe for multi-instance deployments
|
||||
|
||||
✅ **Validation**
|
||||
- Pre-migration `go build` check ensures migrations compile
|
||||
- Dry-run mode previews changes without applying
|
||||
- Status command shows exactly what's applied
|
||||
|
||||
✅ **Rollback Support**
|
||||
- Every migration must have a DOWN function
|
||||
- Rollbacks are grouped (undo last batch together)
|
||||
- Automatic backup before rollback
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
```bash
|
||||
@@ -289,100 +212,28 @@ DB_BACKUP_RETENTION=10
|
||||
- Fix the errors and try again
|
||||
- Use `go build ./cmd/oslstats/migrations` to debug
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Always test in development first**
|
||||
- Use `make migrate-dry-run` to preview
|
||||
- Test rollback: `make migrate-rollback`
|
||||
- Verify with `make migrate-status`
|
||||
|
||||
2. **Write reversible migrations**
|
||||
- DOWN function should undo UP changes
|
||||
- Test rollbacks work correctly
|
||||
- Some operations (data deletion) may not be reversible
|
||||
|
||||
3. **Keep migrations focused**
|
||||
- One migration = one logical change
|
||||
- Don't mix schema changes with data migrations
|
||||
- Use descriptive names
|
||||
|
||||
4. **Production checklist**
|
||||
- ✅ Tested in development
|
||||
- ✅ Tested rollback
|
||||
- ✅ Verified backup works
|
||||
- ✅ Communicated downtime (if any)
|
||||
- ✅ Have rollback plan ready
|
||||
|
||||
5. **Development workflow**
|
||||
- Use `--no-backup` for speed: `make migrate-no-backup`
|
||||
- Use `--reset-db` to start fresh (loses data!)
|
||||
- Commit migrations to version control
|
||||
|
||||
### Configuration Management
|
||||
```bash
|
||||
# Generate .env template file
|
||||
make genenv
|
||||
# OR with custom output: make genenv OUT=.env.example
|
||||
just genenv
|
||||
# OR with custom output: just genenv .env.example
|
||||
|
||||
# Show environment variable documentation
|
||||
make envdoc
|
||||
just envdoc
|
||||
|
||||
# Show current environment values
|
||||
make showenv
|
||||
just showenv
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Import Organization
|
||||
Organize imports in **3 groups** separated by blank lines:
|
||||
|
||||
```go
|
||||
import (
|
||||
// 1. Standard library
|
||||
"context"
|
||||
"net/http"
|
||||
"fmt"
|
||||
|
||||
// 2. External dependencies
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
// 3. Internal packages
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
||||
)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Variables**:
|
||||
- Local: `camelCase` (userAgentKey, httpServer, dbConn)
|
||||
- Exported: `PascalCase` (Config, User, Token)
|
||||
- Common abbreviations: `cfg`, `ctx`, `tx`, `db`, `err`, `w`, `r`
|
||||
|
||||
**Functions**:
|
||||
- Exported: `PascalCase` (GetConfig, NewStore, GenerateState)
|
||||
- Private: `camelCase` (throwError, shouldShowDetails, loadModels)
|
||||
- HTTP handlers: Return `http.Handler`, use dependency injection pattern
|
||||
- Database functions: Use `bun.Tx` as parameter for transactions
|
||||
|
||||
**Types**:
|
||||
- Structs/Interfaces: `PascalCase` (Config, User, OAuthSession)
|
||||
- Use `-er` suffix for interfaces (implied from usage)
|
||||
|
||||
**Files**:
|
||||
- Prefer single word: `config.go`, `oauth.go`, `errors.go`
|
||||
- Don't use snake_case except for tests: `state_test.go`
|
||||
- Test files: `*_test.go` alongside source files
|
||||
|
||||
### Error Handling
|
||||
|
||||
**Always wrap errors** with context using `github.com/pkg/errors`:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "operation_name")
|
||||
return errors.Wrap(err, "package.FunctionName")
|
||||
}
|
||||
```
|
||||
|
||||
@@ -491,94 +342,14 @@ func ConfigFromEnv() (any, error) {
|
||||
- Use inline comments for ENV var documentation in Config structs
|
||||
- Explain security-critical code flows
|
||||
|
||||
### Testing
|
||||
|
||||
**Test File Location**: Place `*_test.go` files alongside source files
|
||||
|
||||
**Test Naming**:
|
||||
```go
|
||||
func TestFunctionName_Scenario(t *testing.T)
|
||||
func TestGenerateState_Success(t *testing.T)
|
||||
func TestVerifyState_WrongUserAgentKey(t *testing.T)
|
||||
```
|
||||
|
||||
**Test Structure**:
|
||||
- Use subtests with `t.Run()` for related scenarios
|
||||
- Use table-driven tests for multiple similar cases
|
||||
- Create helper functions for common setup (e.g., `testConfig()`)
|
||||
- Test happy paths, error cases, edge cases, and security properties
|
||||
|
||||
**Test Categories** (from pkg/oauth/state_test.go example):
|
||||
1. Happy path tests
|
||||
2. Error handling (nil params, empty fields, malformed input)
|
||||
3. Security tests (MITM, CSRF, replay attacks, tampering)
|
||||
4. Edge cases (concurrency, constant-time comparison)
|
||||
5. Integration tests (round-trip verification)
|
||||
|
||||
### Security
|
||||
|
||||
**Critical Practices**:
|
||||
- Use `crypto/subtle.ConstantTimeCompare` for cryptographic comparisons
|
||||
- Implement CSRF protection via state tokens
|
||||
- Store sensitive cookies as HttpOnly
|
||||
- Use separate logging levels for security violations (WARN)
|
||||
- Validate all inputs at function boundaries
|
||||
- Use parameterized queries (Bun ORM handles this)
|
||||
- Never commit secrets (.env, keys/ are gitignored)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
oslstats/
|
||||
├── cmd/oslstats/ # Application entry point
|
||||
│ ├── main.go # Entry point with flag parsing
|
||||
│ ├── run.go # Server initialization & graceful shutdown
|
||||
│ ├── httpserver.go # HTTP server setup
|
||||
│ ├── routes.go # Route registration
|
||||
│ ├── middleware.go # Middleware registration
|
||||
│ ├── auth.go # Authentication setup
|
||||
│ └── db.go # Database connection & migrations
|
||||
├── internal/ # Private application code
|
||||
│ ├── config/ # Configuration aggregation
|
||||
│ ├── db/ # Database models & queries (Bun ORM)
|
||||
│ ├── discord/ # Discord OAuth integration
|
||||
│ ├── handlers/ # HTTP request handlers
|
||||
│ ├── session/ # Session store (in-memory)
|
||||
│ └── view/ # Templ templates
|
||||
│ ├── component/ # Reusable UI components
|
||||
│ ├── layout/ # Page layouts
|
||||
│ └── page/ # Full pages
|
||||
├── pkg/ # Reusable packages
|
||||
│ ├── contexts/ # Context key definitions
|
||||
│ ├── embedfs/ # Embedded static files
|
||||
│ └── oauth/ # OAuth state management
|
||||
├── bin/ # Compiled binaries (gitignored)
|
||||
├── keys/ # Private keys (gitignored)
|
||||
├── tmp/ # Air hot reload temp files (gitignored)
|
||||
├── Makefile # Build automation
|
||||
├── .air.toml # Hot reload configuration
|
||||
└── go.mod # Go module definition
|
||||
```
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **git.haelnorr.com/h/golib/*** - Custom libraries (env, ezconf, hlog, hws, hwsauth, cookies, jwt)
|
||||
- **github.com/a-h/templ** - Type-safe HTML templating
|
||||
- **github.com/uptrace/bun** - PostgreSQL ORM
|
||||
- **github.com/bwmarrin/discordgo** - Discord API client
|
||||
- **github.com/pkg/errors** - Error wrapping (use this, not fmt.Errorf)
|
||||
- **github.com/joho/godotenv** - .env file loading
|
||||
|
||||
## Notes for AI Agents
|
||||
|
||||
1. **Never commit** .env files, keys/, or generated files (*_templ.go, output.css)
|
||||
2. **Database operations** should use `bun.Tx` for transaction safety
|
||||
3. **Templates** are written in templ, not Go html/template - run `templ generate` after changes
|
||||
4. **Static files** are embedded via `//go:embed` - check pkg/embedfs/
|
||||
3. **Templates** are written in templ, not Go html/template - run `just templ` after changes
|
||||
4. **Static files** are embedded via `//go:embed` - check internal/embedfs/
|
||||
5. **Error messages** should be descriptive and use errors.Wrap for context
|
||||
6. **Security is critical** - especially in OAuth flows (see pkg/oauth/state_test.go for examples)
|
||||
7. **Air proxy** runs on port 3000 during development; app runs on 3333
|
||||
8. **Test coverage** is currently limited - prioritize testing security-critical code
|
||||
9. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
|
||||
10. **Graceful shutdown** is implemented in cmd/oslstats/run.go - follow this pattern
|
||||
11. When in plan mode, always use the interactive question tool if available
|
||||
8. **Configuration** uses ezconf pattern - see internal/*/ezconf.go files for examples
|
||||
9. When in plan mode, always use the interactive question tool if available
|
||||
|
||||
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
|
||||
if flags.MigrateUp || flags.MigrateRollback ||
|
||||
if flags.MigrateUp != "" || flags.MigrateRollback != "" ||
|
||||
flags.MigrateStatus || flags.MigrateDryRun ||
|
||||
flags.ResetDB {
|
||||
|
||||
// Route to appropriate command
|
||||
if flags.MigrateUp {
|
||||
err = runMigrations(ctx, cfg, "up")
|
||||
} else if flags.MigrateRollback {
|
||||
err = runMigrations(ctx, cfg, "rollback")
|
||||
if flags.MigrateUp != "" {
|
||||
err = runMigrations(ctx, cfg, "up", flags.MigrateUp)
|
||||
} else if flags.MigrateRollback != "" {
|
||||
err = runMigrations(ctx, cfg, "rollback", flags.MigrateRollback)
|
||||
} else if flags.MigrateStatus {
|
||||
err = runMigrations(ctx, cfg, "status")
|
||||
err = runMigrations(ctx, cfg, "status", "")
|
||||
} else if flags.MigrateDryRun {
|
||||
err = runMigrations(ctx, cfg, "dry-run")
|
||||
err = runMigrations(ctx, cfg, "dry-run", "")
|
||||
} else if flags.ResetDB {
|
||||
err = resetDatabase(ctx, cfg)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
stderrors "errors"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
||||
"git.haelnorr.com/h/oslstats/internal/backup"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
@@ -21,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
// 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)
|
||||
defer func() { _ = close() }()
|
||||
|
||||
@@ -34,16 +33,18 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro
|
||||
|
||||
switch command {
|
||||
case "up":
|
||||
err := migrateUp(ctx, migrator, conn, cfg)
|
||||
err := migrateUp(ctx, migrator, conn, cfg, countStr)
|
||||
if err != nil {
|
||||
err2 := migrateRollback(ctx, migrator, conn, cfg)
|
||||
if err2 != nil {
|
||||
return stderrors.Join(errors.Wrap(err2, "error while rolling back after migration error"), err)
|
||||
}
|
||||
// On error, automatically rollback the migrations that were just applied
|
||||
fmt.Println("[WARN] Migration failed, attempting automatic rollback...")
|
||||
// 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
|
||||
case "rollback":
|
||||
return migrateRollback(ctx, migrator, conn, cfg)
|
||||
return migrateRollback(ctx, migrator, conn, cfg, countStr)
|
||||
case "status":
|
||||
return migrateStatus(ctx, migrator)
|
||||
case "dry-run":
|
||||
@@ -54,7 +55,13 @@ func runMigrations(ctx context.Context, cfg *config.Config, command string) erro
|
||||
}
|
||||
|
||||
// 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...")
|
||||
if err := validateMigrations(ctx); err != nil {
|
||||
return err
|
||||
@@ -74,6 +81,23 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
|
||||
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
|
||||
if !cfg.Flags.MigrateNoBackup {
|
||||
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
|
||||
fmt.Println("[INFO] Step 5/5: Applying migrations...")
|
||||
group, err := migrator.Migrate(ctx)
|
||||
group, err := executeUpMigrations(ctx, migrator, toApply)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "migrate")
|
||||
return errors.Wrap(err, "execute migrations")
|
||||
}
|
||||
|
||||
if group.IsZero() {
|
||||
@@ -118,8 +142,43 @@ func migrateUp(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cf
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateRollback rolls back the last migration group
|
||||
func migrateRollback(ctx context.Context, migrator *migrate.Migrator, conn *bun.DB, cfg *config.Config) error {
|
||||
// migrateRollback rolls back migrations
|
||||
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
|
||||
if !cfg.Flags.MigrateNoBackup {
|
||||
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")
|
||||
|
||||
// Rollback
|
||||
fmt.Println("[INFO] Rolling back last migration group...")
|
||||
group, err := migrator.Rollback(ctx)
|
||||
fmt.Println("[INFO] Executing rollback...")
|
||||
rolledBack, err := executeDownMigrations(ctx, migrator, toRollback)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "rollback")
|
||||
return errors.Wrap(err, "execute rollback")
|
||||
}
|
||||
|
||||
if group.IsZero() {
|
||||
fmt.Println("[INFO] No migrations to rollback")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("[INFO] Rolled back group %d\n", group.ID)
|
||||
for _, migration := range group.Migrations {
|
||||
fmt.Printf("[INFO] Successfully rolled back %d migration(s)\n", len(rolledBack))
|
||||
for _, migration := range rolledBack {
|
||||
fmt.Printf(" ↩️ %s\n", migration.Name)
|
||||
}
|
||||
|
||||
@@ -329,6 +383,129 @@ func init() {
|
||||
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)
|
||||
func resetDatabase(ctx context.Context, cfg *config.Config) error {
|
||||
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
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
k8s.io/apimachinery v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
k8s.io/apimachinery v0.35.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
|
||||
)
|
||||
|
||||
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.6.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.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU=
|
||||
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/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
|
||||
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||
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/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -18,8 +19,8 @@ type Flags struct {
|
||||
ResetDB bool
|
||||
|
||||
// Migration commands
|
||||
MigrateUp bool
|
||||
MigrateRollback bool
|
||||
MigrateUp string
|
||||
MigrateRollback string
|
||||
MigrateStatus bool
|
||||
MigrateCreate string
|
||||
MigrateDryRun bool
|
||||
@@ -40,8 +41,8 @@ func SetupFlags() (*Flags, error) {
|
||||
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
||||
|
||||
// Migration commands
|
||||
migrateUp := flag.Bool("migrate-up", false, "Run pending database migrations (with automatic backup)")
|
||||
migrateRollback := flag.Bool("migrate-rollback", false, "Rollback the last migration group (with automatic backup)")
|
||||
migrateUp := flag.String("migrate-up", "", "Run pending database migrations (usage: --migrate-up [count|all], default: 1)")
|
||||
migrateRollback := flag.String("migrate-rollback", "", "Rollback migrations (usage: --migrate-rollback [count|all], default: 1)")
|
||||
migrateStatus := flag.Bool("migrate-status", false, "Show database migration status")
|
||||
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")
|
||||
@@ -53,10 +54,10 @@ func SetupFlags() (*Flags, error) {
|
||||
|
||||
// Validate: can't use multiple migration commands at once
|
||||
commands := 0
|
||||
if *migrateUp {
|
||||
if *migrateUp != "" {
|
||||
commands++
|
||||
}
|
||||
if *migrateRollback {
|
||||
if *migrateRollback != "" {
|
||||
commands++
|
||||
}
|
||||
if *migrateStatus {
|
||||
@@ -73,6 +74,18 @@ func SetupFlags() (*Flags, error) {
|
||||
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{
|
||||
EnvDoc: *envDoc,
|
||||
ShowEnv: *showEnv,
|
||||
@@ -89,3 +102,25 @@ func SetupFlags() (*Flags, error) {
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
@media (width >= 40rem) {
|
||||
width: calc(var(--spacing) * 10);
|
||||
@@ -1456,11 +1446,6 @@
|
||||
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 {
|
||||
@media (width >= 40rem) {
|
||||
flex-direction: row;
|
||||
@@ -1552,11 +1537,6 @@
|
||||
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 {
|
||||
@media (width >= 48rem) {
|
||||
flex-direction: row;
|
||||
@@ -1607,11 +1587,6 @@
|
||||
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 {
|
||||
@media (width >= 64rem) {
|
||||
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