package server import ( "embed" "io/fs" "net/http" "os" "path/filepath" "ci/internal/handler" "ci/internal/service" "ci/internal/store" "ci/internal/workspace" "github.com/gin-gonic/gin" ) // Config holds server configuration. type Config struct { Port string DataDir string } // DefaultConfig returns the default server configuration. func DefaultConfig() Config { return Config{ Port: "8080", DataDir: "data", } } // Server wraps the Gin engine and related services. type Server struct { engine *gin.Engine config Config svc *service.ProjectService } // New creates a new Server with the given config and embedded frontend. // Pass nil for frontendFS when using local file serving (dev mode). func New(config Config, frontendFS *embed.FS) (*Server, error) { // Resolve absolute data directory absDataDir, err := filepath.Abs(config.DataDir) if err != nil { absDataDir = config.DataDir } // Initialize store st, err := store.New(absDataDir) if err != nil { return nil, err } // Initialize workspace workspaceDir := filepath.Join(absDataDir, "workspaces") wm, err := workspace.New(workspaceDir) if err != nil { return nil, err } // Initialize service svc := service.NewProjectService(st, wm) // Initialize handlers projectH := handler.NewProjectHandler(svc) buildH := handler.NewBuildHandler(svc) uploadH := handler.NewUploadHandler(svc) deployH := handler.NewDeployHandler(svc) gin.SetMode(gin.ReleaseMode) r := gin.Default() // CORS middleware for development r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) // Health check r.GET("/api/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) // API routes api := r.Group("/api") { // Projects CRUD api.GET("/projects", projectH.ListProjects) api.POST("/projects", projectH.CreateProject) api.GET("/projects/:id", projectH.GetProject) api.PUT("/projects/:id", projectH.UpdateProject) api.DELETE("/projects/:id", projectH.DeleteProject) // Build & Run api.POST("/projects/:id/build", buildH.TriggerBuild) api.POST("/projects/:id/start", buildH.StartProject) api.POST("/projects/:id/stop", buildH.StopProject) api.POST("/projects/:id/restart", buildH.RestartProject) api.GET("/projects/:id/builds", buildH.GetBuilds) api.GET("/projects/:id/logs/:buildId", buildH.GetBuildLog) api.GET("/projects/:id/logs/stream", buildH.StreamLogs) // Deploy config api.GET("/projects/:id/deploy-config", deployH.GetDeployConfig) api.PUT("/projects/:id/deploy-config", deployH.UpdateDeployConfig) api.DELETE("/projects/:id/deploy-config", deployH.DeleteDeployConfig) // Deploy execution api.POST("/projects/:id/deploy", deployH.TriggerDeploy) api.GET("/projects/:id/deploy-records", deployH.GetDeployRecords) api.GET("/projects/:id/deploy-records/:did/log", deployH.GetDeployLog) // Upload api.POST("/projects/:id/upload", uploadH.UploadFiles) // Webhook api.POST("/webhook/:id", buildH.Webhook) } // Serve frontend if frontendFS != nil { // Embedded mode: serve from embedded dist files distFS, err := fs.Sub(frontendFS, "web/dist") if err != nil { // Try without the prefix distFS = frontendFS } staticFS, err := fs.Sub(distFS, ".") _ = staticFS _ = err // Use gin's static file serving with embedded FS r.NoRoute(gin.WrapH(http.FileServer(http.FS(distFS)))) } else { // Dev mode: try serving from web/dist directory localDist := "web/dist" if _, err := os.Stat(localDist); err == nil { r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if path == "/" { c.File(filepath.Join(localDist, "index.html")) return } // Try the specific file first targetPath := filepath.Join(localDist, path) if _, err := os.Stat(targetPath); err == nil { c.File(targetPath) return } // SPA fallback c.File(filepath.Join(localDist, "index.html")) }) } else { r.NoRoute(func(c *gin.Context) { c.String(http.StatusOK, "CI/CD Server is running. Frontend not yet built — run `cd web && npm run build`.") }) } } srv := &Server{ engine: r, config: config, svc: svc, } return srv, nil } // Run starts the HTTP server. func (s *Server) Run() error { return s.engine.Run(":" + s.config.Port) } // Shutdown gracefully shuts down the server. func (s *Server) Shutdown() { s.svc.Shutdown() }