diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..71102ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Git +.git +.gitignore + +# Documentation +README.md +docs/ + +# Environment files +.env +.env.local +.env.example + +# Build artifacts +bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test artifacts +*.test +*.out +coverage.* +*.coverprofile +profile.cov + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Development +scripts/dev.sh \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff904ba --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Server Configuration +SERVER_HOST=localhost +SERVER_PORT=8080 +SERVER_ENV=development + +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=your_password_here +DB_NAME=voidrunner +DB_SSL_MODE=disable + +# Logger Configuration +LOG_LEVEL=info +LOG_FORMAT=json + +# CORS Configuration +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Request-ID \ No newline at end of file diff --git a/.gitignore b/.gitignore index aaadf73..7de0c12 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ *.so *.dylib +# Build output +bin/ + # Test binary, built with `go test -c` *.test diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b4fde9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +# Install dependencies +RUN apk --no-cache add ca-certificates git + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o api cmd/api/main.go + +# Final stage +FROM alpine:latest + +# Install ca-certificates and curl for health checks +RUN apk --no-cache add ca-certificates curl + +# Create non-root user +RUN addgroup -g 1001 -S voidrunner && \ + adduser -u 1001 -S voidrunner -G voidrunner + +# Set working directory +WORKDIR /app + +# Copy binary from builder stage +COPY --from=builder /app/api . + +# Change ownership to non-root user +RUN chown -R voidrunner:voidrunner /app + +# Switch to non-root user +USER voidrunner + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +# Run the application +CMD ["./api"] \ No newline at end of file diff --git a/README.md b/README.md index 638b58c..e229546 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,132 @@ -# voidrunner -The LLM-powered distributed task execution platform +# VoidRunner + +The LLM-powered distributed task execution platform built with Go and Kubernetes. + +## Overview + +VoidRunner is a Kubernetes-based distributed task execution platform that provides secure, scalable code execution in containerized environments. The platform is designed with security-first principles and follows microservices architecture. + +## Features + +- **Secure Execution**: Container-based task execution with gVisor security +- **RESTful API**: Clean HTTP API with structured logging and monitoring +- **Kubernetes Native**: Designed for cloud-native deployments +- **Authentication**: JWT-based authentication system +- **Monitoring**: Built-in health checks and observability + +## Quick Start + +### Prerequisites + +- Go 1.24+ installed +- PostgreSQL (for future database operations) +- Docker (for containerization) + +### Setup + +1. **Clone the repository** + ```bash + git clone https://github.com/voidrunnerhq/voidrunner.git + cd voidrunner + ``` + +2. **Install dependencies** + ```bash + go mod download + ``` + +3. **Configure environment** + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +4. **Run the development server** + ```bash + go run cmd/api/main.go + ``` + +The server will start on `http://localhost:8080` by default. + +### API Endpoints + +- `GET /health` - Health check endpoint +- `GET /ready` - Readiness check endpoint +- `GET /api/v1/ping` - Simple ping endpoint + +### Testing + +Run all tests: +```bash +go test ./... +``` + +Run tests with coverage: +```bash +go test ./... -cover +``` + +Run specific test suite: +```bash +go test ./internal/api/handlers/... -v +``` + +### Build + +Build the application: +```bash +go build -o bin/api cmd/api/main.go +``` + +Run the binary: +```bash +./bin/api +``` + +## Architecture + +VoidRunner follows the standard Go project layout: + +``` +voidrunner/ +├── cmd/ # Application entrypoints +│ └── api/ # API server main +├── internal/ # Private application code +│ ├── api/ # API handlers and routes +│ │ ├── handlers/ # HTTP handlers +│ │ ├── middleware/ # HTTP middleware +│ │ └── routes/ # Route definitions +│ ├── config/ # Configuration management +│ ├── database/ # Database layer +│ └── models/ # Data models +├── pkg/ # Public libraries +│ ├── logger/ # Structured logging +│ ├── metrics/ # Prometheus metrics +│ └── utils/ # Shared utilities +├── migrations/ # Database migrations +├── scripts/ # Build and deployment scripts +└── docs/ # Documentation +``` + +## Configuration + +The application uses environment variables for configuration. See `.env.example` for available options: + +- `SERVER_HOST`: Server bind address (default: localhost) +- `SERVER_PORT`: Server port (default: 8080) +- `SERVER_ENV`: Environment (development/production) +- `LOG_LEVEL`: Logging level (debug/info/warn/error) +- `LOG_FORMAT`: Log format (json/text) +- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed origins + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..9a9e29e --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/voidrunnerhq/voidrunner/internal/api/routes" + "github.com/voidrunnerhq/voidrunner/internal/config" + "github.com/voidrunnerhq/voidrunner/pkg/logger" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Printf("Failed to load configuration: %v\n", err) + os.Exit(1) + } + + log := logger.New(cfg.Logger.Level, cfg.Logger.Format) + + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + routes.Setup(router, cfg, log) + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port), + Handler: router, + } + + go func() { + log.Info("starting server", + "host", cfg.Server.Host, + "port", cfg.Server.Port, + "env", cfg.Server.Env, + ) + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("server failed to start", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Error("server forced to shutdown", "error", err) + os.Exit(1) + } + + log.Info("server exited") +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2819f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module github.com/voidrunnerhq/voidrunner + +go 1.24.4 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.10.1 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + golang.org/x/arch v0.18.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b427cbc --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= +github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/joho/godotenv v1.6.0-pre.2 h1:SCkYm/XGeCcXItAv0Xofqsa4JPdDDkyNcG1Ush5cBLQ= +github.com/joho/godotenv v1.6.0-pre.2/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= +golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/api/handlers/health.go b/internal/api/handlers/health.go new file mode 100644 index 0000000..4f31fc7 --- /dev/null +++ b/internal/api/handlers/health.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { + startTime time.Time +} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{ + startTime: time.Now(), + } +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Uptime string `json:"uptime"` + Version string `json:"version,omitempty"` + Service string `json:"service"` +} + +type ReadinessResponse struct { + Status string `json:"status"` + Checks map[string]string `json:"checks"` + Timestamp time.Time `json:"timestamp"` +} + +func (h *HealthHandler) Health(c *gin.Context) { + uptime := time.Since(h.startTime) + + response := HealthResponse{ + Status: "healthy", + Timestamp: time.Now(), + Uptime: uptime.String(), + Version: "1.0.0", + Service: "voidrunner-api", + } + + c.JSON(http.StatusOK, response) +} + +func (h *HealthHandler) Readiness(c *gin.Context) { + checks := make(map[string]string) + + checks["server"] = "ready" + + allHealthy := true + for _, status := range checks { + if status != "ready" { + allHealthy = false + break + } + } + + response := ReadinessResponse{ + Status: "ready", + Checks: checks, + Timestamp: time.Now(), + } + + if !allHealthy { + response.Status = "not ready" + c.JSON(http.StatusServiceUnavailable, response) + return + } + + c.JSON(http.StatusOK, response) +} \ No newline at end of file diff --git a/internal/api/handlers/health_test.go b/internal/api/handlers/health_test.go new file mode 100644 index 0000000..ac8bae9 --- /dev/null +++ b/internal/api/handlers/health_test.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHealthHandler_Health(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + handler := NewHealthHandler() + router.GET("/health", handler.Health) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response HealthResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "healthy", response.Status) + assert.Equal(t, "voidrunner-api", response.Service) + assert.Equal(t, "1.0.0", response.Version) + assert.NotEmpty(t, response.Uptime) + assert.NotZero(t, response.Timestamp) +} + +func TestHealthHandler_Readiness(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + handler := NewHealthHandler() + router.GET("/ready", handler.Readiness) + + req := httptest.NewRequest(http.MethodGet, "/ready", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var response ReadinessResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + assert.Equal(t, "ready", response.Status) + assert.NotEmpty(t, response.Checks) + assert.Equal(t, "ready", response.Checks["server"]) + assert.NotZero(t, response.Timestamp) +} \ No newline at end of file diff --git a/internal/api/middleware/cors.go b/internal/api/middleware/cors.go new file mode 100644 index 0000000..0c44041 --- /dev/null +++ b/internal/api/middleware/cors.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS(allowedOrigins, allowedMethods, allowedHeaders []string) gin.HandlerFunc { + config := cors.Config{ + AllowOrigins: allowedOrigins, + AllowMethods: allowedMethods, + AllowHeaders: allowedHeaders, + ExposeHeaders: []string{"X-Request-ID"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + } + + return cors.New(config) +} \ No newline at end of file diff --git a/internal/api/middleware/cors_test.go b/internal/api/middleware/cors_test.go new file mode 100644 index 0000000..85b3723 --- /dev/null +++ b/internal/api/middleware/cors_test.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestCORS(t *testing.T) { + gin.SetMode(gin.TestMode) + + allowedOrigins := []string{"http://localhost:3000", "https://app.example.com"} + allowedMethods := []string{"GET", "POST", "PUT", "DELETE"} + allowedHeaders := []string{"Content-Type", "Authorization", "X-Request-ID"} + + router := gin.New() + router.Use(CORS(allowedOrigins, allowedMethods, allowedHeaders)) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "test") + }) + + t.Run("sets CORS headers correctly", func(t *testing.T) { + req := httptest.NewRequest(http.MethodOptions, "/test", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "http://localhost:3000", w.Header().Get("Access-Control-Allow-Origin")) + + exposeHeaders := w.Header().Get("Access-Control-Expose-Headers") + if exposeHeaders != "" { + assert.Contains(t, exposeHeaders, "X-Request-ID") + } + }) + + t.Run("handles actual request after preflight", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://localhost:3000") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "test", w.Body.String()) + }) +} \ No newline at end of file diff --git a/internal/api/middleware/error_handler.go b/internal/api/middleware/error_handler.go new file mode 100644 index 0000000..49121c7 --- /dev/null +++ b/internal/api/middleware/error_handler.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Code int `json:"code"` +} + +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + if len(c.Errors) > 0 { + err := c.Errors.Last() + + switch err.Type { + case gin.ErrorTypeBind: + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Bad Request", + Message: err.Error(), + Code: http.StatusBadRequest, + }) + case gin.ErrorTypePublic: + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Internal Server Error", + Message: err.Error(), + Code: http.StatusInternalServerError, + }) + default: + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Internal Server Error", + Message: "An unexpected error occurred", + Code: http.StatusInternalServerError, + }) + } + } + } +} \ No newline at end of file diff --git a/internal/api/middleware/request_id.go b/internal/api/middleware/request_id.go new file mode 100644 index 0000000..43e7eda --- /dev/null +++ b/internal/api/middleware/request_id.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = uuid.New().String() + } + + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} \ No newline at end of file diff --git a/internal/api/middleware/request_id_test.go b/internal/api/middleware/request_id_test.go new file mode 100644 index 0000000..f7241a9 --- /dev/null +++ b/internal/api/middleware/request_id_test.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(RequestID()) + router.GET("/test", func(c *gin.Context) { + requestID := c.GetString("request_id") + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + t.Run("generates request ID when not provided", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.NotEmpty(t, w.Header().Get("X-Request-ID")) + }) + + t.Run("uses existing request ID when provided", func(t *testing.T) { + expectedID := "test-request-id" + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", expectedID) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, expectedID, w.Header().Get("X-Request-ID")) + }) +} \ No newline at end of file diff --git a/internal/api/middleware/security.go b/internal/api/middleware/security.go new file mode 100644 index 0000000..e85a677 --- /dev/null +++ b/internal/api/middleware/security.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Content-Security-Policy", "default-src 'self'") + + if c.Request.TLS != nil { + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + + c.Next() + } +} \ No newline at end of file diff --git a/internal/api/middleware/security_test.go b/internal/api/middleware/security_test.go new file mode 100644 index 0000000..fb8d668 --- /dev/null +++ b/internal/api/middleware/security_test.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestSecurityHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + router := gin.New() + router.Use(SecurityHeaders()) + router.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "test") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + headers := w.Header() + assert.Equal(t, "nosniff", headers.Get("X-Content-Type-Options")) + assert.Equal(t, "DENY", headers.Get("X-Frame-Options")) + assert.Equal(t, "1; mode=block", headers.Get("X-XSS-Protection")) + assert.Equal(t, "strict-origin-when-cross-origin", headers.Get("Referrer-Policy")) + assert.Equal(t, "default-src 'self'", headers.Get("Content-Security-Policy")) + + assert.Empty(t, headers.Get("Strict-Transport-Security")) +} \ No newline at end of file diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go new file mode 100644 index 0000000..6896a9b --- /dev/null +++ b/internal/api/routes/routes.go @@ -0,0 +1,39 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + "github.com/voidrunnerhq/voidrunner/internal/api/handlers" + "github.com/voidrunnerhq/voidrunner/internal/api/middleware" + "github.com/voidrunnerhq/voidrunner/internal/config" + "github.com/voidrunnerhq/voidrunner/pkg/logger" +) + +func Setup(router *gin.Engine, cfg *config.Config, log *logger.Logger) { + setupMiddleware(router, cfg, log) + setupRoutes(router) +} + +func setupMiddleware(router *gin.Engine, cfg *config.Config, log *logger.Logger) { + router.Use(middleware.SecurityHeaders()) + router.Use(middleware.RequestID()) + router.Use(middleware.CORS(cfg.CORS.AllowedOrigins, cfg.CORS.AllowedMethods, cfg.CORS.AllowedHeaders)) + router.Use(log.GinLogger()) + router.Use(log.GinRecovery()) + router.Use(middleware.ErrorHandler()) +} + +func setupRoutes(router *gin.Engine) { + healthHandler := handlers.NewHealthHandler() + + router.GET("/health", healthHandler.Health) + router.GET("/ready", healthHandler.Readiness) + + v1 := router.Group("/api/v1") + { + v1.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{ + "message": "pong", + }) + }) + } +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..886054f --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + Logger LoggerConfig + CORS CORSConfig +} + +type ServerConfig struct { + Port string + Host string + Env string +} + +type DatabaseConfig struct { + Host string + Port string + User string + Password string + Database string + SSLMode string +} + +type LoggerConfig struct { + Level string + Format string +} + +type CORSConfig struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + config := &Config{ + Server: ServerConfig{ + Port: getEnv("SERVER_PORT", "8080"), + Host: getEnv("SERVER_HOST", "localhost"), + Env: getEnv("SERVER_ENV", "development"), + }, + Database: DatabaseConfig{ + Host: getEnv("DB_HOST", "localhost"), + Port: getEnv("DB_PORT", "5432"), + User: getEnv("DB_USER", "postgres"), + Password: getEnv("DB_PASSWORD", ""), + Database: getEnv("DB_NAME", "voidrunner"), + SSLMode: getEnv("DB_SSL_MODE", "disable"), + }, + Logger: LoggerConfig{ + Level: getEnv("LOG_LEVEL", "info"), + Format: getEnv("LOG_FORMAT", "json"), + }, + CORS: CORSConfig{ + AllowedOrigins: getEnvSlice("CORS_ALLOWED_ORIGINS", []string{"http://localhost:3000", "http://localhost:5173"}), + AllowedMethods: getEnvSlice("CORS_ALLOWED_METHODS", []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), + AllowedHeaders: getEnvSlice("CORS_ALLOWED_HEADERS", []string{"Content-Type", "Authorization", "X-Request-ID"}), + }, + } + + if err := config.validate(); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + + return config, nil +} + +func (c *Config) validate() error { + if c.Server.Port == "" { + return fmt.Errorf("server port is required") + } + + if _, err := strconv.Atoi(c.Server.Port); err != nil { + return fmt.Errorf("invalid server port: %s", c.Server.Port) + } + + if c.Database.Host == "" { + return fmt.Errorf("database host is required") + } + + if c.Database.User == "" { + return fmt.Errorf("database user is required") + } + + if c.Database.Database == "" { + return fmt.Errorf("database name is required") + } + + return nil +} + +func (c *Config) IsProduction() bool { + return strings.ToLower(c.Server.Env) == "production" +} + +func (c *Config) IsDevelopment() bool { + return strings.ToLower(c.Server.Env) == "development" +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvSlice(key string, defaultValue []string) []string { + if value := os.Getenv(key); value != "" { + result := strings.Split(value, ",") + for i, v := range result { + result[i] = strings.TrimSpace(v) + } + return result + } + return defaultValue +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..6ec8112 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoad(t *testing.T) { + t.Run("loads with defaults when no env file", func(t *testing.T) { + config, err := Load() + require.NoError(t, err) + + assert.Equal(t, "8080", config.Server.Port) + assert.Equal(t, "localhost", config.Server.Host) + assert.Equal(t, "development", config.Server.Env) + assert.True(t, config.IsDevelopment()) + assert.False(t, config.IsProduction()) + }) + + t.Run("loads from environment variables", func(t *testing.T) { + os.Setenv("SERVER_PORT", "9000") + os.Setenv("SERVER_ENV", "production") + defer func() { + os.Unsetenv("SERVER_PORT") + os.Unsetenv("SERVER_ENV") + }() + + config, err := Load() + require.NoError(t, err) + + assert.Equal(t, "9000", config.Server.Port) + assert.Equal(t, "production", config.Server.Env) + assert.True(t, config.IsProduction()) + assert.False(t, config.IsDevelopment()) + }) + + t.Run("validates port number", func(t *testing.T) { + os.Setenv("SERVER_PORT", "invalid") + defer os.Unsetenv("SERVER_PORT") + + _, err := Load() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid server port") + }) + + t.Run("parses CORS origins with spaces", func(t *testing.T) { + os.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000, http://localhost:5173 , https://app.example.com") + defer os.Unsetenv("CORS_ALLOWED_ORIGINS") + + config, err := Load() + require.NoError(t, err) + + expected := []string{"http://localhost:3000", "http://localhost:5173", "https://app.example.com"} + assert.Equal(t, expected, config.CORS.AllowedOrigins) + }) +} + +func TestConfigValidation(t *testing.T) { + t.Run("requires database configuration", func(t *testing.T) { + config := &Config{ + Server: ServerConfig{Port: "8080"}, + Database: DatabaseConfig{ + Host: "", + User: "postgres", + Database: "voidrunner", + }, + } + + err := config.validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "database host is required") + }) +} \ No newline at end of file diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..13dcafc --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,112 @@ +package logger + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type Logger struct { + *slog.Logger +} + +func New(level, format string) *Logger { + var logLevel slog.Level + switch strings.ToLower(level) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + + var handler slog.Handler + opts := &slog.HandlerOptions{ + Level: logLevel, + } + + switch strings.ToLower(format) { + case "json": + handler = slog.NewJSONHandler(os.Stdout, opts) + case "text": + handler = slog.NewTextHandler(os.Stdout, opts) + default: + handler = slog.NewJSONHandler(os.Stdout, opts) + } + + logger := slog.New(handler) + return &Logger{Logger: logger} +} + +func (l *Logger) WithRequestID(requestID string) *Logger { + return &Logger{Logger: l.Logger.With("request_id", requestID)} +} + +func (l *Logger) WithContext(ctx context.Context) *Logger { + if requestID := ctx.Value("request_id"); requestID != nil { + if reqIDStr, ok := requestID.(string); ok { + return l.WithRequestID(reqIDStr) + } + } + return l +} + +func (l *Logger) WithUserID(userID string) *Logger { + return &Logger{Logger: l.Logger.With("user_id", userID)} +} + +func (l *Logger) WithOperation(operation string) *Logger { + return &Logger{Logger: l.Logger.With("operation", operation)} +} + +func (l *Logger) WithError(err error) *Logger { + return &Logger{Logger: l.Logger.With("error", err.Error())} +} + +func (l *Logger) GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + requestID := c.GetString("request_id") + + c.Next() + + duration := time.Since(start) + + l.WithRequestID(requestID).Info("request completed", + "method", c.Request.Method, + "path", c.Request.URL.Path, + "status", c.Writer.Status(), + "duration_ms", duration.Milliseconds(), + "user_agent", c.Request.UserAgent(), + "client_ip", c.ClientIP(), + ) + } +} + +func (l *Logger) GinRecovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + requestID := c.GetString("request_id") + l.WithRequestID(requestID).Error("panic recovered", + "error", err, + "method", c.Request.Method, + "path", c.Request.URL.Path, + "client_ip", c.ClientIP(), + ) + c.JSON(500, gin.H{"error": "Internal server error"}) + c.Abort() + } + }() + c.Next() + } +} \ No newline at end of file diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..a1840fd --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +# Development script for VoidRunner + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if .env file exists +check_env() { + if [ ! -f ".env" ]; then + print_warning ".env file not found. Creating from .env.example..." + if [ -f ".env.example" ]; then + cp .env.example .env + print_status "Created .env file from .env.example" + print_warning "Please edit .env file with your configuration" + else + print_error ".env.example file not found" + exit 1 + fi + fi +} + +# Function to install dependencies +install_deps() { + print_status "Installing Go dependencies..." + go mod download + go mod tidy +} + +# Function to run tests +run_tests() { + print_status "Running tests..." + go test ./... -v -cover +} + +# Function to build the application +build_app() { + print_status "Building application..." + mkdir -p bin + go build -o bin/api cmd/api/main.go + print_status "Built application: bin/api" +} + +# Function to run the development server +run_server() { + print_status "Starting development server..." + go run cmd/api/main.go +} + +# Function to clean build artifacts +clean() { + print_status "Cleaning build artifacts..." + rm -rf bin/ + go clean +} + +# Function to run linter +lint() { + print_status "Running linter..." + if command -v golangci-lint &> /dev/null; then + golangci-lint run + else + print_warning "golangci-lint not found. Install it with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" + go fmt ./... + go vet ./... + fi +} + +# Function to format code +format() { + print_status "Formatting code..." + go fmt ./... +} + +# Function to show help +show_help() { + echo "VoidRunner Development Script" + echo + echo "Usage: $0 [command]" + echo + echo "Commands:" + echo " setup - Install dependencies and setup environment" + echo " test - Run tests" + echo " build - Build the application" + echo " run - Run the development server" + echo " clean - Clean build artifacts" + echo " lint - Run linter" + echo " format - Format code" + echo " help - Show this help message" + echo +} + +# Main script logic +case "$1" in + setup) + check_env + install_deps + print_status "Setup completed" + ;; + test) + run_tests + ;; + build) + build_app + ;; + run) + check_env + run_server + ;; + clean) + clean + ;; + lint) + lint + ;; + format) + format + ;; + help|--help|-h) + show_help + ;; + *) + if [ -z "$1" ]; then + show_help + else + print_error "Unknown command: $1" + show_help + exit 1 + fi + ;; +esac \ No newline at end of file