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:
2026-09-22 05:31:28 -04:00
parent 69d38484e8
commit e30c3b632a
18 changed files with 1208 additions and 160 deletions
+233 -34
View File
@@ -8,13 +8,17 @@ package service
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/repos"
"gitmanager/internal/store"
)
// Service holds the shared dependencies the capabilities need.
@@ -22,15 +26,22 @@ type Service struct {
git *git.CLI
index *repos.Index
feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
store *store.Store
log *slog.Logger
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
mu sync.Mutex // guards forgeCache
forgeCache map[string]*forge.Gitea // base_url -> provider
}
// New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
// (may be nil) used to re-scan a repo after a mutating action.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
// activity feed, the config store, and a single-repo refresh hook (may be nil)
// used to re-scan a repo after a mutating action.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service {
return &Service{
git: g, index: index, feed: feed, store: st, log: log, refresh: refresh,
forgeCache: make(map[string]*forge.Gitea),
}
}
// ListRepos returns a snapshot of every discovered repository.
@@ -123,29 +134,32 @@ func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bo
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up.
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
// ForgeConfigured reports whether any forge is configured in the store.
func (s *Service) ForgeConfigured(ctx context.Context) bool {
forges, err := s.store.ListForges(ctx)
return err == nil && len(forges) > 0
}
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
// on the configured host.
// when no forge is configured, or forge.ErrNotSupported when the repo's remote is
// not on a configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return nil, err
}
return s.forge.ListPullRequests(ctx, owner, repo)
return prov.ListPullRequests(ctx, owner, repo)
}
// CreatePR opens a pull request from head into base (empty base = the repo's
// default branch) and records the action. The head branch must already exist on
// the remote (push it first).
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.PullRequest{}, err
}
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
pr, err := prov.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
if err != nil {
return forge.PullRequest{}, err
}
@@ -157,11 +171,11 @@ func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath,
// The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude).
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.MergeResult{}, err
}
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
res, err := prov.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil {
return forge.MergeResult{}, err
}
@@ -254,38 +268,223 @@ func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, rep
})
}
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
// its git remotes, preferring "origin".
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
if s.forge == nil {
return "", "", forge.ErrNotConfigured
// resolveForge maps a repo path to (provider, owner, repo) by matching its git
// remotes (preferring "origin") against the forges configured in the store.
func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
forges, err := s.store.ListForges(ctx)
if err != nil {
return nil, "", "", err
}
if len(forges) == 0 {
return nil, "", "", forge.ErrNotConfigured
}
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath)
return nil, "", "", fmt.Errorf("unknown repository %q", repoPath)
}
remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil {
return "", "", err
return nil, "", "", err
}
match := func(remoteURL string) (*forge.Gitea, string, string, bool) {
host, o, r, ok := forge.ParseRemote(remoteURL)
if !ok {
return nil, "", "", false
}
for _, f := range forges {
prov, perr := s.forgeFor(f)
if perr != nil {
continue
}
if prov.Handles(host) {
return prov, o, r, true
}
}
return nil, "", "", false
}
// Prefer origin, then any matching remote.
var fallback [2]string
haveFallback := false
var fb *forge.Gitea
var fo, fr string
for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL)
if !ok || !s.forge.Handles(host) {
prov, o, r, ok := match(rm.URL)
if !ok {
continue
}
if rm.Name == "origin" {
return o, r, nil
return prov, o, r, nil
}
if !haveFallback {
fallback = [2]string{o, r}
haveFallback = true
if fb == nil {
fb, fo, fr = prov, o, r
}
}
if haveFallback {
return fallback[0], fallback[1], nil
if fb != nil {
return fb, fo, fr, nil
}
return nil, "", "", forge.ErrNotSupported
}
// forgeFor returns a cached *forge.Gitea for a configured forge, building it on
// first use. The cache is cleared whenever forges change (invalidateForges).
func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) {
s.mu.Lock()
defer s.mu.Unlock()
if p, ok := s.forgeCache[f.BaseURL]; ok {
return p, nil
}
p, err := forge.NewGitea(f.BaseURL, f.Token)
if err != nil {
return nil, err
}
if p == nil {
return nil, forge.ErrNotConfigured
}
s.forgeCache[f.BaseURL] = p
return p, nil
}
func (s *Service) invalidateForges() {
s.mu.Lock()
s.forgeCache = make(map[string]*forge.Gitea)
s.mu.Unlock()
}
// --- Configuration store (forges, project dirs, git identity) — §1.3 --------
// Forges lists configured forges (tokens are never included).
func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) {
return s.store.ListForges(ctx)
}
// AddForge adds a forge, then reapplies git auth so pushes to it work.
func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) {
if strings.TrimSpace(baseURL) == "" {
return store.Forge{}, fmt.Errorf("a base URL is required")
}
f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token})
if err != nil {
return store.Forge{}, err
}
s.invalidateForges()
s.ApplyGitConfig(ctx)
s.feed.Record(activity.ActorUser, "forge-added", "", baseURL)
return f, nil
}
// UpdateForge edits a forge (empty token keeps the existing one).
func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error {
if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil {
return err
}
s.invalidateForges()
s.ApplyGitConfig(ctx)
return nil
}
// DeleteForge removes a forge.
func (s *Service) DeleteForge(ctx context.Context, id int64) error {
if err := s.store.DeleteForge(ctx, id); err != nil {
return err
}
s.invalidateForges()
return nil
}
// ProjectDirs lists configured project directories.
func (s *Service) ProjectDirs(ctx context.Context) ([]store.ProjectDir, error) {
return s.store.ListProjectDirs(ctx)
}
// AddProjectDir adds a directory to scan. The path must exist inside the
// container (i.e. be under a mounted base) and be a directory.
func (s *Service) AddProjectDir(ctx context.Context, path string) (store.ProjectDir, error) {
path = filepath.Clean(strings.TrimSpace(path))
if path == "" {
return store.ProjectDir{}, fmt.Errorf("a path is required")
}
info, err := os.Stat(path)
if err != nil {
return store.ProjectDir{}, fmt.Errorf("path not found in the container: %s (is it under a mounted directory?)", path)
}
if !info.IsDir() {
return store.ProjectDir{}, fmt.Errorf("not a directory: %s", path)
}
d, err := s.store.AddProjectDir(ctx, path)
if err != nil {
return store.ProjectDir{}, err
}
s.feed.Record(activity.ActorUser, "project-dir-added", path, "")
if s.refresh == nil {
return d, nil
}
return d, nil
}
// SetProjectDirEnabled toggles a project directory.
func (s *Service) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error {
return s.store.SetProjectDirEnabled(ctx, id, enabled)
}
// DeleteProjectDir removes a project directory.
func (s *Service) DeleteProjectDir(ctx context.Context, id int64) error {
return s.store.DeleteProjectDir(ctx, id)
}
// GitIdentity returns the configured commit identity.
func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
name, _ = s.store.GetSetting(ctx, "git_user_name", "")
email, _ = s.store.GetSetting(ctx, "git_user_email", "")
return name, email
}
// SetGitIdentity stores the commit identity and reapplies it to git config.
func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error {
if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil {
return err
}
if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil {
return err
}
s.ApplyGitConfig(ctx)
return nil
}
// ScanRoots returns the enabled project directories (for the scanner).
func (s *Service) ScanRoots(ctx context.Context) []string {
roots, err := s.store.EnabledRoots(ctx)
if err != nil && s.log != nil {
s.log.Warn("could not read project dirs", "err", err)
}
return roots
}
// ApplyGitConfig writes the container's git global config from the store: a
// commit identity, safe.directory for host-owned mounts, and an auth header per
// forge so push/fetch/pull over HTTPS work. Called at startup and after changes.
func (s *Service) ApplyGitConfig(ctx context.Context) {
set := func(key, value string) {
if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil {
s.log.Warn("git config failed", "key", key, "err", err)
}
}
set("safe.directory", "*")
if name, email := s.GitIdentity(ctx); name != "" || email != "" {
if name != "" {
set("user.name", name)
}
if email != "" {
set("user.email", email)
}
}
forges, err := s.store.ListForges(ctx)
if err != nil {
return
}
for _, f := range forges {
if f.Token == "" {
continue
}
set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token)
}
return "", "", forge.ErrNotSupported
}