Move domain config from .env to a private SQLite store
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>
This commit is contained in:
+141
-32
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -26,29 +27,36 @@ import (
|
||||
"gitmanager/internal/render"
|
||||
"gitmanager/internal/repos"
|
||||
"gitmanager/internal/service"
|
||||
"gitmanager/internal/store"
|
||||
)
|
||||
|
||||
// configureGit prepares the container's git for operating on the mounted repos:
|
||||
// a commit identity (so commits don't fail with "empty ident"), permission to
|
||||
// work on host-owned mounts, and — when a Gitea token is set — an auth header so
|
||||
// pushes/fetches over HTTPS succeed. The token is written to the container's
|
||||
// gitconfig (ephemeral, localhost); see AGENT.md §11.
|
||||
func configureGit(ctx context.Context, g *git.CLI, cfg config.Config, log *slog.Logger) {
|
||||
set := func(key, value string) {
|
||||
if err := g.SetGlobalConfig(ctx, key, value); err != nil {
|
||||
log.Warn("git config failed", "key", key, "err", err)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
set("safe.directory", "*") // mounted repos are host-owned
|
||||
if cfg.GitUserName != "" {
|
||||
set("user.name", cfg.GitUserName)
|
||||
_ = st.SetSetting(ctx, "git_user_name", cfg.GitUserName)
|
||||
}
|
||||
if cfg.GitUserEmail != "" {
|
||||
set("user.email", cfg.GitUserEmail)
|
||||
}
|
||||
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
|
||||
set("http."+cfg.GiteaURL+".extraheader", "Authorization: token "+cfg.GiteaToken)
|
||||
log.Info("git remote auth configured", "host", cfg.GiteaURL)
|
||||
_ = st.SetSetting(ctx, "git_user_email", cfg.GitUserEmail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,31 +80,39 @@ func main() {
|
||||
} else {
|
||||
log.Info("git detected", "version", v)
|
||||
}
|
||||
configureGit(context.Background(), g, cfg, log)
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
|
||||
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
|
||||
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
|
||||
if err != nil {
|
||||
log.Warn("forge disabled — invalid config", "err", err)
|
||||
} else if fg != nil {
|
||||
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL)
|
||||
} else {
|
||||
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable")
|
||||
// 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, fg, scanner.RefreshRepo)
|
||||
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 {
|
||||
@@ -133,6 +149,9 @@ func main() {
|
||||
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 {
|
||||
@@ -201,6 +220,96 @@ func main() {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user