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:
+41
-32
@@ -61,34 +61,38 @@ func (i *Index) List() []State {
|
||||
return out
|
||||
}
|
||||
|
||||
// Config is the per-cycle scan configuration. It is fetched fresh on every scan
|
||||
// (from the store), so changing project directories at runtime takes effect
|
||||
// without a restart.
|
||||
type Config struct {
|
||||
Roots []string
|
||||
MaxDepth int
|
||||
Ignore []string
|
||||
Fetch bool
|
||||
}
|
||||
|
||||
// ConfigFunc supplies the current scan configuration.
|
||||
type ConfigFunc func(context.Context) Config
|
||||
|
||||
// Scanner discovers repositories and refreshes the index on an interval.
|
||||
type Scanner struct {
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
roots []string
|
||||
maxDepth int
|
||||
ignore map[string]struct{}
|
||||
interval time.Duration
|
||||
fetchEnabled bool
|
||||
git *git.CLI
|
||||
log *slog.Logger
|
||||
cfgFn ConfigFunc
|
||||
interval time.Duration
|
||||
|
||||
Index *Index
|
||||
}
|
||||
|
||||
// NewScanner builds a scanner. ignore is a set of directory names to skip.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
|
||||
ig := make(map[string]struct{}, len(ignore))
|
||||
for _, name := range ignore {
|
||||
ig[name] = struct{}{}
|
||||
}
|
||||
// NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
|
||||
// each cycle; interval is fixed for the life of the process.
|
||||
func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
|
||||
return &Scanner{
|
||||
git: g,
|
||||
log: log,
|
||||
roots: roots,
|
||||
maxDepth: maxDepth,
|
||||
ignore: ig,
|
||||
interval: interval,
|
||||
fetchEnabled: fetchEnabled,
|
||||
Index: newIndex(),
|
||||
git: g,
|
||||
log: log,
|
||||
cfgFn: cfgFn,
|
||||
interval: interval,
|
||||
Index: newIndex(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,32 +114,33 @@ func (s *Scanner) Run(ctx context.Context) {
|
||||
// Refresh discovers repos and updates the index. Each repo is refreshed under
|
||||
// its own timeout so a single slow/unreachable repo cannot stall the rest.
|
||||
func (s *Scanner) Refresh(ctx context.Context) {
|
||||
paths := s.discover()
|
||||
s.log.Debug("scan discovered repositories", "count", len(paths))
|
||||
cfg := s.cfgFn(ctx)
|
||||
paths := discover(cfg)
|
||||
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
|
||||
for _, p := range paths {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.Index.set(s.refreshOne(ctx, p))
|
||||
s.Index.set(s.refreshOne(ctx, p, cfg.Fetch))
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshRepo re-scans a single repository and updates the index. Used after a
|
||||
// mutating action so the UI reflects the new state without waiting for the next
|
||||
// full scan.
|
||||
// full scan. It never fetches (network) — it only re-reads local state.
|
||||
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
|
||||
s.Index.set(s.refreshOne(ctx, path))
|
||||
s.Index.set(s.refreshOne(ctx, path, false))
|
||||
}
|
||||
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) State {
|
||||
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
|
||||
|
||||
if s.fetchEnabled {
|
||||
if fetch {
|
||||
if err := s.git.Fetch(rctx, path); err != nil {
|
||||
s.log.Warn("scan fetch failed", "repo", path, "err", err)
|
||||
}
|
||||
@@ -166,11 +171,15 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
|
||||
// discover walks each root looking for directories that contain a .git entry,
|
||||
// recording the parent as a repo and not descending into it. Depth is measured
|
||||
// relative to each root; ignored directory names are skipped.
|
||||
func (s *Scanner) discover() []string {
|
||||
func discover(cfg Config) []string {
|
||||
ignore := make(map[string]struct{}, len(cfg.Ignore))
|
||||
for _, name := range cfg.Ignore {
|
||||
ignore[name] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
|
||||
for _, root := range s.roots {
|
||||
for _, root := range cfg.Roots {
|
||||
root = filepath.Clean(root)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -182,12 +191,12 @@ func (s *Scanner) discover() []string {
|
||||
|
||||
name := d.Name()
|
||||
if path != root {
|
||||
if _, skip := s.ignore[name]; skip {
|
||||
if _, skip := ignore[name]; skip {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if depth(root, path) > s.maxDepth {
|
||||
if depth(root, path) > cfg.MaxDepth {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user