e30c3b632a
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
477 lines
17 KiB
Go
477 lines
17 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"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/labstack/echo/v4/middleware"
|
|
|
|
"gitmanager/internal/activity"
|
|
"gitmanager/internal/config"
|
|
"gitmanager/internal/forge"
|
|
"gitmanager/internal/git"
|
|
"gitmanager/internal/logging"
|
|
mcpserver "gitmanager/internal/mcp"
|
|
"gitmanager/internal/render"
|
|
"gitmanager/internal/repos"
|
|
"gitmanager/internal/service"
|
|
"gitmanager/internal/store"
|
|
)
|
|
|
|
// seedStore migrates config from .env into the store on first run only, so an
|
|
// existing deployment keeps working after the switch to the DB (§1.3).
|
|
func seedStore(ctx context.Context, st *store.Store, cfg config.Config, log *slog.Logger) {
|
|
empty, err := st.IsEmpty(ctx)
|
|
if err != nil {
|
|
log.Warn("config store check failed", "err", err)
|
|
return
|
|
}
|
|
if !empty {
|
|
return
|
|
}
|
|
log.Info("seeding config store from environment (first run)")
|
|
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
|
|
if _, err := st.AddForge(ctx, store.Forge{Name: "Gitea", Kind: "gitea", BaseURL: strings.TrimRight(cfg.GiteaURL, "/"), Token: cfg.GiteaToken}); err != nil {
|
|
log.Warn("seed forge failed", "err", err)
|
|
}
|
|
}
|
|
for _, r := range cfg.RepoRoots {
|
|
if _, err := st.AddProjectDir(ctx, r); err != nil {
|
|
log.Warn("seed project dir failed", "dir", r, "err", err)
|
|
}
|
|
}
|
|
if cfg.GitUserName != "" {
|
|
_ = st.SetSetting(ctx, "git_user_name", cfg.GitUserName)
|
|
}
|
|
if cfg.GitUserEmail != "" {
|
|
_ = st.SetSetting(ctx, "git_user_email", cfg.GitUserEmail)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Config store (forges, project dirs, git identity) on a private volume (§1.3).
|
|
st, err := store.Open(cfg.DBPath)
|
|
if err != nil {
|
|
log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer st.Close()
|
|
seedStore(context.Background(), st, cfg, log)
|
|
|
|
// Coordination state: active project + activity feed (§8.2).
|
|
feed := activity.New(log, 200)
|
|
|
|
// The read-only scanner reads its roots fresh from the store each cycle, so
|
|
// project-directory changes take effect without a restart (§5).
|
|
scanCfg := func(ctx context.Context) repos.Config {
|
|
roots, err := st.EnabledRoots(ctx)
|
|
if err != nil {
|
|
log.Warn("could not read project dirs", "err", err)
|
|
}
|
|
return repos.Config{Roots: roots, MaxDepth: cfg.ScanMaxDepth, Ignore: cfg.ScanIgnore, Fetch: cfg.ScanFetchEnabled}
|
|
}
|
|
scanner := repos.NewScanner(g, log, scanCfg, cfg.ScanInterval)
|
|
scanCtx, stopScan := context.WithCancel(context.Background())
|
|
defer stopScan()
|
|
go scanner.Run(scanCtx)
|
|
log.Info("scanner started", "interval", cfg.ScanInterval.String())
|
|
|
|
// The one service layer both the HTTP API and the MCP server call (§1.7).
|
|
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
|
|
svc := service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
|
|
// Configure git (identity + per-forge auth) from the store.
|
|
svc.ApplyGitConfig(context.Background())
|
|
|
|
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())
|
|
|
|
// Ask browsers to revalidate component/static assets so edits show up on
|
|
// reload (the dev server hot-reloads; cached ES modules would defeat that).
|
|
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
p := c.Request().URL.Path
|
|
if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") {
|
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
return next(c)
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
})
|
|
e.GET("/settings", func(c echo.Context) error {
|
|
return c.Render(http.StatusOK, "settings.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)
|
|
})
|
|
|
|
// Active project + activity (§8.2).
|
|
e.GET("/api/active-project", func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
|
})
|
|
e.POST("/api/active-project", func(c echo.Context) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
// A user action in the UI (actor=user) — distinct from Claude's own switches.
|
|
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
|
|
})
|
|
e.GET("/api/activity", func(c echo.Context) error {
|
|
return c.JSON(http.StatusOK, svc.Activity(0))
|
|
})
|
|
|
|
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
|
|
e.GET("/api/switch", func(c echo.Context) error {
|
|
p, ok := svc.PendingSwitch()
|
|
if !ok {
|
|
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
|
|
})
|
|
})
|
|
e.POST("/api/switch", func(c echo.Context) error {
|
|
var body struct {
|
|
Target string `json:"target"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
|
|
})
|
|
e.DELETE("/api/switch", func(c echo.Context) error {
|
|
svc.CancelSwitch(activity.ActorUser)
|
|
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
|
})
|
|
|
|
// Configuration store: forges, project directories, git identity (§1.3).
|
|
e.GET("/api/config/forges", func(c echo.Context) error {
|
|
forges, err := svc.Forges(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, forges)
|
|
})
|
|
e.POST("/api/config/forges", func(c echo.Context) error {
|
|
var b struct{ Name, Kind, BaseURL, Token string }
|
|
if err := c.Bind(&b); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
f, err := svc.AddForge(c.Request().Context(), b.Name, b.Kind, b.BaseURL, b.Token)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, f)
|
|
})
|
|
e.PUT("/api/config/forges/:id", func(c echo.Context) error {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
var b struct{ Name, Kind, BaseURL, Token string }
|
|
if err := c.Bind(&b); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
if err := svc.UpdateForge(c.Request().Context(), id, b.Name, b.Kind, b.BaseURL, b.Token); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
e.DELETE("/api/config/forges/:id", func(c echo.Context) error {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err := svc.DeleteForge(c.Request().Context(), id); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
e.GET("/api/config/project-dirs", func(c echo.Context) error {
|
|
dirs, err := svc.ProjectDirs(c.Request().Context())
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, dirs)
|
|
})
|
|
e.POST("/api/config/project-dirs", func(c echo.Context) error {
|
|
var b struct{ Path string }
|
|
if err := c.Bind(&b); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
d, err := svc.AddProjectDir(c.Request().Context(), b.Path)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, d)
|
|
})
|
|
e.PUT("/api/config/project-dirs/:id", func(c echo.Context) error {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
var b struct{ Enabled bool }
|
|
if err := c.Bind(&b); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
if err := svc.SetProjectDirEnabled(c.Request().Context(), id, b.Enabled); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
e.DELETE("/api/config/project-dirs/:id", func(c echo.Context) error {
|
|
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
if err := svc.DeleteProjectDir(c.Request().Context(), id); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
e.GET("/api/config/identity", func(c echo.Context) error {
|
|
name, email := svc.GitIdentity(c.Request().Context())
|
|
return c.JSON(http.StatusOK, map[string]string{"name": name, "email": email})
|
|
})
|
|
e.PUT("/api/config/identity", func(c echo.Context) error {
|
|
var b struct{ Name, Email string }
|
|
if err := c.Bind(&b); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
if err := svc.SetGitIdentity(c.Request().Context(), b.Name, b.Email); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
})
|
|
|
|
// Plain-language git commands (the right-click menu, §6). One endpoint,
|
|
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
|
|
e.POST("/api/repo/git", func(c echo.Context) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
Op string `json:"op"`
|
|
Message string `json:"message"`
|
|
Branch string `json:"branch"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
ctx := c.Request().Context()
|
|
var out string
|
|
var err error
|
|
switch body.Op {
|
|
case "fetch":
|
|
out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path)
|
|
case "pull":
|
|
out, err = svc.GitPull(ctx, activity.ActorUser, body.Path)
|
|
case "push":
|
|
out, err = svc.GitPush(ctx, activity.ActorUser, body.Path)
|
|
case "commit":
|
|
out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message)
|
|
case "discard":
|
|
out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path)
|
|
case "checkout":
|
|
out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch)
|
|
case "create-branch":
|
|
out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch)
|
|
default:
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op})
|
|
}
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out})
|
|
})
|
|
|
|
// Forge PRs + "Merge & clean up" (§8.4).
|
|
e.GET("/api/repo/prs", func(c echo.Context) error {
|
|
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
|
|
if err != nil {
|
|
// Not configured / not on the forge host is a normal "no PRs here"
|
|
// state — tell the UI to hide the section rather than error.
|
|
if err == forge.ErrNotConfigured || err == forge.ErrNotSupported {
|
|
return c.JSON(http.StatusOK, map[string]any{"supported": false})
|
|
}
|
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
|
|
})
|
|
e.POST("/api/repo/pr/create", func(c echo.Context) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
Head string `json:"head"`
|
|
Base string `json:"base"`
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, pr)
|
|
})
|
|
e.POST("/api/repo/pr/merge", func(c echo.Context) error {
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
Number int64 `json:"number"`
|
|
}
|
|
if err := c.Bind(&body); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
|
|
}
|
|
res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, res)
|
|
})
|
|
|
|
// SSE stream of activity events for live UI (§8.2).
|
|
e.GET("/events", func(c echo.Context) error {
|
|
w := c.Response()
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Flush()
|
|
|
|
ch, unsub := svc.SubscribeActivity()
|
|
defer unsub()
|
|
|
|
keepalive := time.NewTicker(25 * time.Second)
|
|
defer keepalive.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-c.Request().Context().Done():
|
|
return nil
|
|
case <-keepalive.C:
|
|
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
|
|
return nil
|
|
}
|
|
w.Flush()
|
|
case ev := <-ch:
|
|
data, err := json.Marshal(ev)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
|
|
return nil
|
|
}
|
|
w.Flush()
|
|
}
|
|
}
|
|
})
|
|
|
|
// MCP server — Claude connects via the local stdio bridge (§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)
|
|
}
|
|
}
|