From e30c3b632a06d6396976a33bef406468ca137adc Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Tue, 22 Sep 2026 05:31:28 -0400 Subject: [PATCH] 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) + 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 --- .env.example | 43 ++-- AGENT.md | 87 +++++-- CHANGELOG.md | 32 +++ cmd/server/main.go | 173 ++++++++++--- components/settings-panel/settings-panel.js | 247 ++++++++++++++++++ components/settings-panel/settings-panel.md | 30 +++ docker-compose.yml | 11 +- go.mod | 14 +- go.sum | 58 ++++- internal/config/config.go | 7 +- internal/mcp/mcp_test.go | 12 +- internal/repos/repos.go | 73 +++--- internal/service/service.go | 267 +++++++++++++++++--- internal/service/service_test.go | 23 +- internal/store/store.go | 242 ++++++++++++++++++ web/templates/help.html | 15 ++ web/templates/index.html | 2 +- web/templates/settings.html | 32 +++ 18 files changed, 1208 insertions(+), 160 deletions(-) create mode 100644 components/settings-panel/settings-panel.js create mode 100644 components/settings-panel/settings-panel.md create mode 100644 internal/store/store.go create mode 100644 web/templates/settings.html diff --git a/.env.example b/.env.example index 69d206e..764224e 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,19 @@ # --------------------------------------------------------------------------- # GitManager configuration. Copy this file to `.env` and fill in values. # NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5. +# +# NOTE: forges (URL + token), project directories, and the git identity now live +# in the config DATABASE (a private SQLite store, AGENT.md §1.3), managed in the +# app's Settings. The GITEA_*, GIT_REPO_ROOTS, and GIT_USER_* values below are +# used ONLY to seed that DB the first time the app starts with an empty store; +# after that, edit them in Settings (changing .env has no effect). # --------------------------------------------------------------------------- +# Config store location (SQLite). docker-compose points this at a PRIVATE named +# volume that is not bind-mounted or exposed, so credentials in it are only +# reachable inside the container (AGENT.md §1.3). +GITMANAGER_DB=/data/gitmanager.db + # Address the HTTP server binds to. Localhost-only by default: there is NO # authentication (AGENT.md §0). Only bind to a non-local interface deliberately. LISTEN_ADDR=127.0.0.1:8080 @@ -21,9 +32,9 @@ HTTPS_ADDR= TLS_CERT_FILE= TLS_KEY_FILE= -# Roots to scan for Git repositories, comma-separated (absolute paths). -# Inside Docker these must be the *container* paths that the host roots are -# mounted to (see docker-compose.yml). Example: /repos,/work/other +# SEED ONLY (first run) → project directories in the config DB. Comma-separated +# *container* paths (the host roots are mounted here; see docker-compose.yml). +# After first run, manage these in Settings. Example: /repos,/work/other GIT_REPO_ROOTS=/repos # DOCKER ONLY: the HOST folder that holds your repositories. docker-compose @@ -55,26 +66,22 @@ SCAN_FETCH_ENABLED=false # "dev" uses a readable console handler; anything else uses structured JSON. APP_ENV=dev -# Commit identity for git actions the app runs (commit/etc.). Without these, -# commits inside the container fail with "empty ident". Set to your name/email. +# SEED ONLY (first run) → git identity in the config DB. Without an identity, +# commits inside the container fail with "empty ident". After first run, set it +# in Settings. GIT_USER_NAME= GIT_USER_EMAIL= -# Note: when GITEA_URL + GITEA_TOKEN are set, the app also configures git to -# authenticate to that host over HTTPS (an http.extraheader), so push/fetch/pull -# work from the container without a separate SSH key or credential helper. +# The app configures git to authenticate to each configured forge over HTTPS +# (an http.extraheader per host), so push/fetch/pull work from the container +# without a separate SSH key or credential helper. # Optional: also append structured logs to this file. Leave empty to disable. LOG_FILE= -# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ---------- -# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a -# token to enable PRs + "Merge & clean up"; with neither, the forge features are -# simply absent and the rest of the app is unaffected. A repo is forge-enabled -# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete -# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write + -# branch delete. +# --- Forge integration — SEED ONLY (first run) → forges in the config DB ---- +# Multiple forges are now supported and managed in Settings; this pair only +# seeds the FIRST one on an empty DB. Set BOTH to enable PRs + "Merge & clean up" +# for repos whose remote host matches this URL. Token scope: repo read + PR write +# + branch delete. Writes are confirmed per AGENT.md §1.4. GITEA_URL= GITEA_TOKEN= -# Later providers, behind the same interface (unused for now): -GITHUB_TOKEN= -GITLAB_TOKEN= diff --git a/AGENT.md b/AGENT.md index 9972d02..2eda251 100644 --- a/AGENT.md +++ b/AGENT.md @@ -121,10 +121,12 @@ values** rather than reintroducing hardcoded hex. - **The background scanner is read‑only.** It may run `status`, `rev-list`, `for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never run a command that changes local state. -- **Never invent local persistence for domain state.** If something must survive a - restart and it isn't already in Git, it is either app config (`.env`, Section - 1.4) or per‑user UI state (browser `localStorage`, Section 4). Adding any other - datastore (SQLite, a server‑side DB) requires asking first — the default is no. +- **Git stays the system of record for repository data** — never mirror repo + contents into another store. The one sanctioned datastore is the **config store** + (§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens, + the project directories to scan, git identity), not git data. Anything beyond + that (mirroring repo/PR data, a server‑side app DB) still requires asking first. + Per‑user UI state remains in browser `localStorage` (§4). ### 1.4 Destructive Git operations are explicit, confirmed, and never automatic @@ -142,12 +144,24 @@ must be treated as such: destructive default. Prefer the safe variant (`--force-with-lease` over `--force`) and surface it as such. -### 1.5 Configuration via `.env` +### 1.5 Configuration: bootstrap `.env` + a private config store -Repo scan roots, the `git` binary path, scan/fetch behavior, the listen address, -and any optional forge tokens are all configurable through a single `.env` file. A -committed `.env.example` documents every variable. **Never commit a real `.env`** -and never hardcode paths, tokens, hosts, or the set of watched repositories. +Configuration is split: + +- **Bootstrap `.env`** — only what's needed to start the process and can't live in + the DB: listen address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan + tuning (interval/depth/ignore/fetch), and the **config DB path**. A committed + `.env.example` documents every variable; **never commit a real `.env`**. +- **Config store (`internal/store`, SQLite)** — the domain config that used to + live in `.env`: **forge hosts + access tokens, the project directories to scan, + and the git commit identity.** Managed at runtime in the app's **Settings** + (`/settings`), not by editing files. On first run with an empty DB it is + **seeded** from the `.env` values (`GITEA_*`, `GIT_REPO_ROOTS`, `GIT_USER_*`); + after that those `.env` values are ignored. +- **The DB must not be reachable outside the container.** It lives on a **private + named Docker volume** (`/data`) — never bind‑mounted into the project, never on a + published port. Tokens are stored there relying on that isolation. Never hardcode + tokens/hosts/paths in code. ### 1.6 Everything runs in Docker / docker‑compose @@ -187,14 +201,15 @@ MCP handler. | MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. | | Real‑time (app → browser) | **Server‑Sent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. | | Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. | -| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. | -| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). | +| Bootstrap config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5 (bootstrap only). | +| Config store | **SQLite via `modernc.org/sqlite`** (pure Go, no CGO) in `internal/store`, on a private `/data` volume | Forges + tokens, project dirs, git identity. Not the git data store (§1.3). | +| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. | | Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. | Anything not in this table that you want to add as a dependency: **propose it and -wait for approval.** Keep the dependency surface small. Note in particular there is -**no database and no auth library** — do not add one without asking (Section 1.3, -Section 0). +wait for approval.** Keep the dependency surface small. There is **no auth library** +(§0) and **no datastore beyond the config store** (§1.3, §1.5) — do not add one +without asking. --- @@ -211,7 +226,8 @@ Section 0). ├── cmd/ │ └── server/main.go # entrypoint: wire config, git, scanner, router ├── internal/ -│ ├── config/ # .env loading, typed config struct +│ ├── config/ # bootstrap .env loading, typed config struct (§1.5) +│ ├── store/ # SQLite config store: forges+tokens, project dirs, identity (§1.5) │ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops) │ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker │ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7) @@ -231,8 +247,10 @@ Section 0). ``` > If a new concern doesn't fit cleanly, **ask** before inventing a new top‑level -> directory. Keep `components/` strictly for web components. There is deliberately -> **no `migrations/` and no `db/`** — see Section 1.3. +> directory. Keep `components/` strictly for web components. The only datastore is +> the SQLite **config store** (`internal/store`) on the private `/data` volume +> (§1.5) — schema is created in‑code (`CREATE TABLE IF NOT EXISTS`); there is no +> `migrations/` framework yet (add one if the schema grows non‑trivially). --- @@ -432,9 +450,12 @@ later behind the same interface). per §1.4**, names the PR/branch, and prefers the tidy default (squash‑merge + delete branch). Note a merged PR remains in the host's history; "clean up" means removing the **branch**, not falsifying history. -- **Enablement is per‑host and token‑gated.** Tokens come from `.env` - (e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and - the rest of the app works unchanged (**graceful degradation**). +- **Multiple hosts, configured in the store.** Forges (base URL + token) live in + the **config store** (§1.5) and are managed in Settings — not `.env`. A repo is + forge‑enabled when a remote's host matches a configured forge; with none + matching the forge features are simply absent (**graceful degradation**). The + service resolves a repo → provider by matching remotes (preferring `origin`) + against the stored forges, caching a client per host. - The provider is inferred from a repo's remote URL. Never send repo data to a host the user didn't configure. @@ -502,9 +523,10 @@ component carries its own context. 4. If it touches repositories or forges: put the logic in the **service layer** (§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP or MCP handler — so both the GUI and Claude get it. Respect **Git = system of - record** (§1.3), treat **destructive operations** per §1.4, and never add a - datastore. A new capability generally means: service method → HTTP handler → - MCP tool → UI control. + record** (§1.3), treat **destructive operations** per §1.4, and don't mirror + repo/PR data into a datastore (app *config* goes in the store, §1.5). A new + capability generally means: service method → HTTP handler → MCP tool → UI + control. 5. If it needs a **new dependency** or a **new top‑level folder**, propose it and wait for approval. 6. Implement, run it in the **docker‑compose dev environment**, verify hot reload @@ -518,9 +540,22 @@ component carries its own context. *(Claude Code: surface these to the human at the first relevant moment; don't silently guess.)* -- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth? - ignore globs?) vs an explicit list of repo paths. Default assumption: recursive - scan with a configurable depth. +- ✅ **RESOLVED 2026-09-22:** **Domain config moved from `.env` to a private + SQLite store** (`internal/store`, §1.5): forges+tokens, project directories, git + identity — managed in Settings (`/settings`), seeded from `.env` on first run. + DB on a private `/data` volume (not bind‑mounted, no port). Forge is now + **multi‑host**; git auth sets an `http.extraheader` per forge. +- **Container mount constraint (multiple project dirs):** the container can only + scan host paths that are **bind‑mounted at `up` time**. Today `~/Projects` is + mounted to `/repos`, so project dirs added in Settings must resolve under a + mounted base. A dir outside it needs a new compose mount — surface this if asked + to add such a path. +- **Token encryption at rest:** tokens are stored plaintext in the private DB + (isolation is the control). Confirm before adding encryption‑at‑rest (a key + would then need storing too). +- **Repo discovery strategy:** recursive scan of the store's project directories + (max depth / ignore from `.env` scan tuning). Default: recursive with a + configurable depth. - **Background `fetch`:** off by default (no unsolicited network). Confirm whether it should be enabled, and the interval / rate limit, before turning it on. - **Git access library:** system `git` via `os/exec` is the locked default diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ced9a..48a81af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,6 +261,38 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. - **Affects:** `components/toast-host` (new), `components/repo-menu`, `components/pr-list`, `web/templates/index.html`. +## 2026-09-22 — Config store: move forges/dirs/identity from .env to a private DB +- **What:** Domain config now lives in a **private SQLite store** (`internal/store`, + modernc.org/sqlite, pure Go) instead of `.env`: **forge hosts + tokens** + (multi‑host), the **project directories** to scan, and the **git identity**. + New Settings page (`/settings`) + `` component + `` + there; `/api/config/{forges,project-dirs,identity}` CRUD endpoints. The scanner + now reads its roots **fresh from the store each cycle** (add/remove dirs without + a restart); the service resolves a repo → forge by matching remotes against the + stored forges (client cached per host) and reapplies per‑forge git auth + (`http..extraheader`) on change; git identity comes from the store. On first + run with an empty DB, the store is **seeded from `.env`** (`GITEA_*`, + `GIT_REPO_ROOTS`, `GIT_USER_*`), so existing deploys keep working. The DB lives + on a **private named Docker volume `/data`** — not bind‑mounted, no port — so + credentials aren't reachable outside the container. +- **Why:** Support multiple repos/forges and project directories with credentials, + managed at runtime, without hand‑editing `.env` (user request). Overturns the + old "no datastore" / "config via .env" laws — AGENT.md §0/§1.3/§1.5/§2/§3/§8.4 + updated. +- **Affects:** `internal/store` (new), `internal/config`, `internal/repos` + (scanner now dynamic), `internal/service` (store‑backed forges + config CRUD + + `ApplyGitConfig`), `cmd/server/main.go`, `components/settings-panel` (new), + `web/templates/{settings,index,help}.html`, `docker-compose.yml` (private + `gmdata` volume + `GITMANAGER_DB`), `.env.example`, `go.mod`. +- **Verified live:** seeded on first run; forges/dirs/identity served from the DB + (tokens never returned); repos still discovered (now both GitManager and + app-template under /repos); forge PRs work from the stored token; add/validate/ + delete of project dirs works; DB is not present in the project directory; the + Settings UI renders and manages all three. +- **Notes:** tokens stored plaintext relying on volume isolation (encryption‑at‑ + rest is an open item); scan tuning (interval/depth/ignore/fetch) stays in `.env` + for now; container can only scan paths under a mounted base (mount constraint). + ## 2026-09-20 — Slice 13: repo search + filtering - **What:** `` gained a search box (name/path, case-insensitive) and "Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is diff --git a/cmd/server/main.go b/cmd/server/main.go index 393b245..3b18f0c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/components/settings-panel/settings-panel.js b/components/settings-panel/settings-panel.js new file mode 100644 index 0000000..dc3b6e2 --- /dev/null +++ b/components/settings-panel/settings-panel.js @@ -0,0 +1,247 @@ +// — manage the config store (AGENT.md §1.3): forge hosts + +// tokens, project directories to scan, and the git commit identity. Replaces +// editing .env for domain config. +// +// A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the +// /api/config/* endpoints, and reports results via `toast` events. Tokens are +// write-only from here — the server never returns them (only whether one is set). + +class SettingsPanel extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#renderShell(); + this.#loadForges(); + this.#loadDirs(); + this.#loadIdentity(); + } + + // --- forges --------------------------------------------------------------- + + async #loadForges() { + const box = this.shadowRoot.getElementById('forges'); + try { + const forges = await this.#json('GET', '/api/config/forges'); + if (!forges || forges.length === 0) { + box.innerHTML = `

No forges configured.

`; + return; + } + box.replaceChildren(...forges.map((f) => { + const row = document.createElement('div'); + row.className = 'row'; + row.innerHTML = ` +
+ ${this.#esc(f.name || f.kind)} + ${this.#esc(f.baseUrl)} + ${f.hasToken ? 'token set' : 'no token'} +
`; + const del = document.createElement('button'); + del.className = 'danger'; + del.textContent = 'Remove'; + del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges()); + row.appendChild(del); + return row; + })); + } catch (err) { + box.innerHTML = `

${this.#esc(err.message)}

`; + } + } + + async #addForge() { + const name = this.shadowRoot.getElementById('f-name').value.trim(); + const baseUrl = this.shadowRoot.getElementById('f-url').value.trim(); + const token = this.shadowRoot.getElementById('f-token').value; + if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; } + try { + await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token }); + this.shadowRoot.getElementById('f-name').value = ''; + this.shadowRoot.getElementById('f-url').value = ''; + this.shadowRoot.getElementById('f-token').value = ''; + this.#toast('Forge added', 'success'); + this.#loadForges(); + } catch (err) { + this.#toast(`Add forge failed: ${err.message}`, 'error'); + } + } + + // --- project directories -------------------------------------------------- + + async #loadDirs() { + const box = this.shadowRoot.getElementById('dirs'); + try { + const dirs = await this.#json('GET', '/api/config/project-dirs'); + if (!dirs || dirs.length === 0) { + box.innerHTML = `

No project directories configured.

`; + return; + } + box.replaceChildren(...dirs.map((d) => { + const row = document.createElement('div'); + row.className = 'row'; + row.innerHTML = ` + `; + row.querySelector('input').onchange = (e) => + this.#put(`/api/config/project-dirs/${d.id}`, { enabled: e.target.checked }, e.target.checked ? 'Directory enabled' : 'Directory disabled'); + const del = document.createElement('button'); + del.className = 'danger'; + del.textContent = 'Remove'; + del.onclick = () => this.#delete(`/api/config/project-dirs/${d.id}`, 'Directory removed', () => this.#loadDirs()); + row.appendChild(del); + return row; + })); + } catch (err) { + box.innerHTML = `

${this.#esc(err.message)}

`; + } + } + + async #addDir() { + const path = this.shadowRoot.getElementById('d-path').value.trim(); + if (!path) { this.#toast('A path is required', 'error'); return; } + try { + await this.#json('POST', '/api/config/project-dirs', { path }); + this.shadowRoot.getElementById('d-path').value = ''; + this.#toast('Directory added', 'success'); + this.#loadDirs(); + } catch (err) { + this.#toast(`Add directory failed: ${err.message}`, 'error'); + } + } + + // --- git identity --------------------------------------------------------- + + async #loadIdentity() { + try { + const id = await this.#json('GET', '/api/config/identity'); + this.shadowRoot.getElementById('i-name').value = id.name || ''; + this.shadowRoot.getElementById('i-email').value = id.email || ''; + } catch { /* leave blank */ } + } + + async #saveIdentity() { + const name = this.shadowRoot.getElementById('i-name').value.trim(); + const email = this.shadowRoot.getElementById('i-email').value.trim(); + try { + await this.#put('/api/config/identity', { name, email }, 'Identity saved'); + } catch (err) { + this.#toast(`Save failed: ${err.message}`, 'error'); + } + } + + // --- helpers -------------------------------------------------------------- + + async #json(method, url, body) { + const opts = { method }; + if (body !== undefined) { + opts.headers = { 'Content-Type': 'application/json' }; + opts.body = JSON.stringify(body); + } + const res = await fetch(url, opts); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; + } + + async #put(url, body, okMsg) { + await this.#json('PUT', url, body); + if (okMsg) this.#toast(okMsg, 'success'); + } + + async #delete(url, okMsg, after) { + if (!window.confirm('Remove this entry?')) return; + try { + await this.#json('DELETE', url); + this.#toast(okMsg, 'success'); + after?.(); + } catch (err) { + this.#toast(`Remove failed: ${err.message}`, 'error'); + } + } + + #toast(message, kind) { + document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } })); + } + + #renderShell() { + this.shadowRoot.innerHTML = ` + + +
+

Forges

+

Hosting servers (Gitea/Forgejo) and their access tokens. + Tokens are stored in the private config database and never shown again.

+

Loading…

+
+ + + + +
+
+ +
+

Project directories

+

Directories to scan for repositories. Paths are inside the + container — they must be under a mounted directory (e.g. under /repos).

+

Loading…

+
+ + +
+
+ +
+

Git identity

+

Author used for commits the app makes.

+
+ + + +
+
+ `; + + this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge(); + this.shadowRoot.getElementById('d-add').onclick = () => this.#addDir(); + this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity(); + } + + #esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } +} + +customElements.define('settings-panel', SettingsPanel); diff --git a/components/settings-panel/settings-panel.md b/components/settings-panel/settings-panel.md new file mode 100644 index 0000000..7e52ffb --- /dev/null +++ b/components/settings-panel/settings-panel.md @@ -0,0 +1,30 @@ +# settings-panel + +## Intent +The UI for the config store (AGENT.md §1.3) — manage forge hosts + tokens, +project directories to scan, and the git commit identity, replacing hand-editing +`.env` for domain config. Served at `/settings`. + +## Public surface +- **Tag:** `` +- **Fetches:** `GET /api/config/forges`, `GET /api/config/project-dirs`, + `GET /api/config/identity`. +- **Writes:** + - Forges: `POST /api/config/forges` (add), `DELETE /api/config/forges/:id`. + - Project dirs: `POST /api/config/project-dirs` (add), + `PUT /api/config/project-dirs/:id` (enable/disable), + `DELETE /api/config/project-dirs/:id`. + - Identity: `PUT /api/config/identity`. +- **Reports:** results via `toast` CustomEvents (needs `` on the page). + +## History +- 2026-09-22: created — settings UI for the SQLite config store (forges, project + dirs, git identity). + +## Notes / gotchas +- Tokens are **write-only** from the client: the server returns only `hasToken`, + never the value. Removing + re-adding a forge is how you rotate a token. +- Project-dir paths are **container paths** and must resolve inside the container + (under a mounted base); the server validates existence before adding. +- Adding/removing a forge reapplies git auth (the server sets an `http.extraheader` + per forge); changes take effect immediately. diff --git a/docker-compose.yml b/docker-compose.yml index 6c49fed..b6dc11b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,8 +18,12 @@ services: - HTTPS_ADDR=0.0.0.0:8443 - TLS_CERT_FILE=/app/certs/localhost.pem - TLS_KEY_FILE=/app/certs/localhost-key.pem - # The scanner looks here; matches the volume mount below. + # SEED ONLY (first run): the scanner's roots now live in the config DB. + # This is copied into the DB the first time the app starts with an empty DB. - GIT_REPO_ROOTS=/repos + # Config store (forges, project dirs, git identity) on the PRIVATE volume + # below — not bind-mounted into the project, no network port (§1.3). + - GITMANAGER_DB=/data/gitmanager.db ports: - "127.0.0.1:8080:8080" - "127.0.0.1:8443:8443" @@ -28,6 +32,10 @@ services: - .:/app # Cache the Go module + build cache across restarts. - gomod:/go/pkg/mod + # Config store — a PRIVATE named volume, deliberately NOT bind-mounted to + # the host project and NOT exposed on any port, so the credentials it holds + # are only reachable by the app inside the container (§1.3). + - gmdata:/data # Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the # host folder that holds them; defaults to ./repos next to this file. - "${REPOS_HOST_PATH:-./repos}:/repos" @@ -43,3 +51,4 @@ services: volumes: gomod: + gmdata: diff --git a/go.mod b/go.mod index c7eb806..463aa1a 100644 --- a/go.mod +++ b/go.mod @@ -7,17 +7,22 @@ require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.15.4 github.com/modelcontextprotocol/go-sdk v1.8.0 + modernc.org/sqlite v1.59.0 ) require ( github.com/42wim/httpsig v1.2.4 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/labstack/gommon v0.5.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -26,8 +31,11 @@ require ( golang.org/x/crypto v0.53.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect ) diff --git a/go.sum b/go.sum index 7252149..9f69145 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -14,8 +16,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= @@ -24,12 +32,16 @@ github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= @@ -47,19 +59,21 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= @@ -70,7 +84,35 @@ golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go index 985ca48..dc1bbe0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,7 +23,11 @@ type Config struct { TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store) TLSKeyFile string // PEM private key - RepoRoots []string // roots to scan for git repositories + // DBPath is the SQLite config store (forges, project dirs, git identity). + // It lives on a private volume, not accessible outside the container (§1.3). + DBPath string + + RepoRoots []string // SEED ONLY: roots to scan, used to seed the store on first run GitBin string // path to the git binary ScanInterval time.Duration // scanner refresh interval @@ -50,6 +54,7 @@ func Load() (Config, error) { c := Config{ ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"), + DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"), HTTPSAddr: env("HTTPS_ADDR", ""), TLSCertFile: env("TLS_CERT_FILE", ""), TLSKeyFile: env("TLS_KEY_FILE", ""), diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index ae22dd8..b366fe6 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -17,6 +17,7 @@ import ( "gitmanager/internal/git" "gitmanager/internal/repos" "gitmanager/internal/service" + "gitmanager/internal/store" ) // TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner -> @@ -39,10 +40,17 @@ func TestMCPRoundTrip(t *testing.T) { // Populate the index via the real scanner, then build service + MCP server. log := slog.New(slog.NewTextHandler(io.Discard, nil)) g := git.New("git") - scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) + scanner := repos.NewScanner(g, log, func(context.Context) repos.Config { + return repos.Config{Roots: []string{root}, MaxDepth: 3, Fetch: false} + }, time.Minute) scanner.Refresh(context.Background()) - svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo) + st, err := store.Open(filepath.Join(t.TempDir(), "config.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + svc := service.New(g, scanner.Index, activity.New(log, 200), st, log, scanner.RefreshRepo) srv := NewServer(svc, "test") // Wire an in-memory client<->server session. diff --git a/internal/repos/repos.go b/internal/repos/repos.go index 047298e..ca079bc 100644 --- a/internal/repos/repos.go +++ b/internal/repos/repos.go @@ -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 } diff --git a/internal/service/service.go b/internal/service/service.go index e1dc426..f75e61c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 } diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 22981d5..e7e0b47 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -14,8 +14,27 @@ import ( "gitmanager/internal/activity" "gitmanager/internal/git" "gitmanager/internal/repos" + "gitmanager/internal/store" ) +// testScanner builds a scanner whose roots are fixed to the given paths. +func testScanner(g *git.CLI, log *slog.Logger, roots ...string) *repos.Scanner { + return repos.NewScanner(g, log, func(context.Context) repos.Config { + return repos.Config{Roots: roots, MaxDepth: 3, Fetch: false} + }, time.Minute) +} + +// testStore opens a throwaway SQLite store. +func testStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "config.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + // TestGitActions exercises the mutating commands (commit, discard) on a throwaway // temp repo — never a real one. func TestGitActions(t *testing.T) { @@ -31,10 +50,10 @@ func TestGitActions(t *testing.T) { log := slog.New(slog.NewTextHandler(io.Discard, nil)) g := git.New("git") - scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) + scanner := testScanner(g, log, root) scanner.Refresh(context.Background()) feed := activity.New(log, 200) - svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo) + svc := New(g, scanner.Index, feed, testStore(t), log, scanner.RefreshRepo) ctx := context.Background() // Commit a new file, then the repo should be clean in the index. diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..3d696c7 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,242 @@ +// Package store is GitManager's small SQLite-backed configuration store: forge +// hosts + credentials, the project directories to scan, and a few settings +// (git identity). It replaces the domain config that used to live in .env. +// +// The DB file lives on a PRIVATE named Docker volume (not bind-mounted into the +// project, no network port), so it is not accessible outside the container +// (AGENT.md §0/§1.3). Tokens are stored as-is, relying on that isolation. Pure-Go +// driver (modernc.org/sqlite) so the static CGO_ENABLED=0 build keeps working. +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// Forge is a configured hosting provider (Gitea/Forgejo, later GitHub/GitLab). +type Forge struct { + ID int64 `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` // "gitea" (only kind for now) + BaseURL string `json:"baseUrl"` // e.g. https://git.nilles.net + Token string `json:"-"` // never serialized to the client + HasToken bool `json:"hasToken"` + CreatedAt time.Time `json:"createdAt"` +} + +// ProjectDir is a directory (inside the container) to scan for repositories. +type ProjectDir struct { + ID int64 `json:"id"` + Path string `json:"path"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"createdAt"` +} + +// Store wraps the SQLite connection. +type Store struct { + db *sql.DB +} + +// Open opens (creating if needed) the SQLite DB at path and runs migrations. +func Open(path string) (*Store, error) { + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) // SQLite: serialize writers to avoid "database is locked" + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) migrate() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS forges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'gitea', + base_url TEXT NOT NULL UNIQUE, + token TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS project_dirs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +);`) + return err +} + +// --- forges ---------------------------------------------------------------- + +func (s *Store) ListForges(ctx context.Context) ([]Forge, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, kind, base_url, token, created_at FROM forges ORDER BY base_url`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Forge + for rows.Next() { + var f Forge + var created string + if err := rows.Scan(&f.ID, &f.Name, &f.Kind, &f.BaseURL, &f.Token, &created); err != nil { + return nil, err + } + f.CreatedAt, _ = time.Parse(time.RFC3339, created) + f.HasToken = f.Token != "" + out = append(out, f) + } + return out, rows.Err() +} + +func (s *Store) AddForge(ctx context.Context, f Forge) (Forge, error) { + if f.Kind == "" { + f.Kind = "gitea" + } + f.CreatedAt = time.Now() + res, err := s.db.ExecContext(ctx, + `INSERT INTO forges (name, kind, base_url, token, created_at) VALUES (?,?,?,?,?)`, + f.Name, f.Kind, f.BaseURL, f.Token, f.CreatedAt.Format(time.RFC3339)) + if err != nil { + return Forge{}, err + } + f.ID, _ = res.LastInsertId() + f.HasToken = f.Token != "" + return f, nil +} + +// UpdateForge updates name/base_url/kind, and the token only when newToken != "" +// (empty means "keep the existing token"). +func (s *Store) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, newToken string) error { + if kind == "" { + kind = "gitea" + } + if newToken != "" { + _, err := s.db.ExecContext(ctx, + `UPDATE forges SET name=?, kind=?, base_url=?, token=? WHERE id=?`, + name, kind, baseURL, newToken, id) + return err + } + _, err := s.db.ExecContext(ctx, + `UPDATE forges SET name=?, kind=?, base_url=? WHERE id=?`, name, kind, baseURL, id) + return err +} + +func (s *Store) DeleteForge(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM forges WHERE id=?`, id) + return err +} + +// --- project dirs ---------------------------------------------------------- + +func (s *Store) ListProjectDirs(ctx context.Context) ([]ProjectDir, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, path, enabled, created_at FROM project_dirs ORDER BY path`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ProjectDir + for rows.Next() { + var d ProjectDir + var created string + var enabled int + if err := rows.Scan(&d.ID, &d.Path, &enabled, &created); err != nil { + return nil, err + } + d.Enabled = enabled != 0 + d.CreatedAt, _ = time.Parse(time.RFC3339, created) + out = append(out, d) + } + return out, rows.Err() +} + +// EnabledRoots returns the paths of enabled project directories. +func (s *Store) EnabledRoots(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT path FROM project_dirs WHERE enabled=1 ORDER BY path`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var p string + if err := rows.Scan(&p); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (s *Store) AddProjectDir(ctx context.Context, path string) (ProjectDir, error) { + d := ProjectDir{Path: path, Enabled: true, CreatedAt: time.Now()} + res, err := s.db.ExecContext(ctx, + `INSERT INTO project_dirs (path, enabled, created_at) VALUES (?,1,?)`, + path, d.CreatedAt.Format(time.RFC3339)) + if err != nil { + return ProjectDir{}, err + } + d.ID, _ = res.LastInsertId() + return d, nil +} + +func (s *Store) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error { + v := 0 + if enabled { + v = 1 + } + _, err := s.db.ExecContext(ctx, `UPDATE project_dirs SET enabled=? WHERE id=?`, v, id) + return err +} + +func (s *Store) DeleteProjectDir(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM project_dirs WHERE id=?`, id) + return err +} + +// --- settings (kv) --------------------------------------------------------- + +func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=?`, key).Scan(&v) + if err == sql.ErrNoRows { + return def, nil + } + if err != nil { + return def, err + } + return v, nil +} + +func (s *Store) SetSetting(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO settings (key, value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value) + return err +} + +// IsEmpty reports whether the store has no forges and no project dirs (used to +// decide whether to seed from .env on first run). +func (s *Store) IsEmpty(ctx context.Context) (bool, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT (SELECT COUNT(*) FROM forges) + (SELECT COUNT(*) FROM project_dirs)`).Scan(&n); err != nil { + return false, err + } + return n == 0, nil +} diff --git a/web/templates/help.html b/web/templates/help.html index 5e4053c..0f17fea 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -53,6 +53,21 @@
  • Ahead / behind — commits your branch leads or trails its upstream by.
  • +

    Settings

    +

    + Open Settings to manage your configuration (it's + stored in a private database inside the app, not in files): +

    +
      +
    • Forges — add your Git hosting servers (Gitea) and their + access tokens. Tokens are saved securely and never shown again; to change + one, remove the forge and add it back.
    • +
    • Project directories — the folders scanned for + repositories. Paths are inside the app's container (under a mounted folder + such as /repos); enable, disable, or remove them.
    • +
    • Git identity — the name and email used for commits.
    • +
    +

    Right-click commands

    Right-click any repository for a menu of plain-language commands — no git diff --git a/web/templates/index.html b/web/templates/index.html index 253fd85..bd89d41 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -42,7 +42,7 @@

    GitManager

    multi-repo dashboard - +
    diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..89d42d7 --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,32 @@ + + + + + + GitManager — Settings + + + + + + +
    +

    GitManager

    + settings + +
    +
    + +
    + + +