2d7f814a23
Slice 1: internal/service is the one capability layer both the HTTP API and the MCP server call (AGENT.md 1.7); /api/repos and /api/repo route through it. Slice 2: internal/mcp serves an MCP server over Streamable HTTP at /mcp (go-sdk v1.8.0) with read tools list_repos and get_repo as thin adapters over the service, plus a round-trip test. Enabled air polling so hot reload works across the Docker-on-Windows bind mount. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
3.6 KiB
Go
125 lines
3.6 KiB
Go
// 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)
|
|
|
|
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)
|
|
}
|
|
}
|