// Command server is the GitManager entrypoint. It wires config, logging, the // Git boundary, the repo scanner, and the Echo HTTP server, then serves the // dashboard shell and the JSON endpoints the web components fetch from. package main import ( "context" "net/http" "os" "os/signal" "syscall" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "gitmanager/internal/config" "gitmanager/internal/git" "gitmanager/internal/logging" mcpserver "gitmanager/internal/mcp" "gitmanager/internal/render" "gitmanager/internal/repos" "gitmanager/internal/service" ) func main() { cfg, err := config.Load() if err != nil { panic(err) } log, closer, err := logging.Setup(cfg.Dev, cfg.LogFile) if err != nil { panic(err) } if closer != nil { defer closer.Close() } g := git.New(cfg.GitBin) if v, err := g.Version(context.Background()); err != nil { log.Warn("git binary not usable — repo operations will fail", "bin", cfg.GitBin, "err", err) } else { log.Info("git detected", "version", v) } // Start the read-only scanner in the background. scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled) scanCtx, stopScan := context.WithCancel(context.Background()) defer stopScan() go scanner.Run(scanCtx) log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled) // The one service layer both the HTTP API and the MCP server call (§1.7). svc := service.New(g, scanner.Index) tmpl, err := render.New("web/templates") if err != nil { log.Error("failed to parse templates", "err", err) os.Exit(1) } e := echo.New() e.HideBanner = true e.Renderer = tmpl e.Use(middleware.Recover()) e.Use(middleware.RequestID()) // Static assets and component sources. e.Static("/static", "web/static") e.Static("/components", "components") // Page shells. e.GET("/", func(c echo.Context) error { return c.Render(http.StatusOK, "index.html", nil) }) e.GET("/help", func(c echo.Context) error { return c.Render(http.StatusOK, "help.html", nil) }) // JSON API — components self-fetch from here. e.GET("/healthz", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) e.GET("/api/repos", func(c echo.Context) error { return c.JSON(http.StatusOK, svc.ListRepos()) }) e.GET("/api/repo", func(c echo.Context) error { // The service only serves details for an already-discovered repo — it // never runs git against an arbitrary caller-supplied path (§1.3). detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path")) if !ok { return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"}) } return c.JSON(http.StatusOK, detail) }) // MCP server — Claude connects here as a custom connector (§8.1). Same // service layer as the HTTP API (§1.7); localhost-bound like everything else. mcpSrv := mcpserver.NewServer(svc, "0.1.0") e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv))) log.Info("mcp server mounted", "path", "/mcp") // Serve with graceful shutdown. go func() { if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed { log.Error("server error", "err", err) os.Exit(1) } }() log.Info("listening", "addr", cfg.ListenAddr) // Optional HTTPS listener (same Echo app). Required for the MCP connector, // which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable // cert logs a warning and leaves the app running over HTTP. if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { if _, err := os.Stat(cfg.TLSCertFile); err != nil { log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err) } else { go func() { if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { log.Error("TLS server error", "err", err) } }() log.Info("listening (https)", "addr", cfg.HTTPSAddr) } } quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) <-quit log.Info("shutting down") stopScan() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := e.Shutdown(ctx); err != nil { log.Error("graceful shutdown failed", "err", err) } }