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:
+25
-18
@@ -1,8 +1,19 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GitManager configuration. Copy this file to `.env` and fill in values.
|
# 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.
|
# 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
|
# 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.
|
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
|
||||||
LISTEN_ADDR=127.0.0.1:8080
|
LISTEN_ADDR=127.0.0.1:8080
|
||||||
@@ -21,9 +32,9 @@ HTTPS_ADDR=
|
|||||||
TLS_CERT_FILE=
|
TLS_CERT_FILE=
|
||||||
TLS_KEY_FILE=
|
TLS_KEY_FILE=
|
||||||
|
|
||||||
# Roots to scan for Git repositories, comma-separated (absolute paths).
|
# SEED ONLY (first run) → project directories in the config DB. Comma-separated
|
||||||
# Inside Docker these must be the *container* paths that the host roots are
|
# *container* paths (the host roots are mounted here; see docker-compose.yml).
|
||||||
# mounted to (see docker-compose.yml). Example: /repos,/work/other
|
# After first run, manage these in Settings. Example: /repos,/work/other
|
||||||
GIT_REPO_ROOTS=/repos
|
GIT_REPO_ROOTS=/repos
|
||||||
|
|
||||||
# DOCKER ONLY: the HOST folder that holds your repositories. docker-compose
|
# 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.
|
# "dev" uses a readable console handler; anything else uses structured JSON.
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
|
|
||||||
# Commit identity for git actions the app runs (commit/etc.). Without these,
|
# SEED ONLY (first run) → git identity in the config DB. Without an identity,
|
||||||
# commits inside the container fail with "empty ident". Set to your name/email.
|
# commits inside the container fail with "empty ident". After first run, set it
|
||||||
|
# in Settings.
|
||||||
GIT_USER_NAME=
|
GIT_USER_NAME=
|
||||||
GIT_USER_EMAIL=
|
GIT_USER_EMAIL=
|
||||||
# Note: when GITEA_URL + GITEA_TOKEN are set, the app also configures git to
|
# The app configures git to authenticate to each configured forge over HTTPS
|
||||||
# authenticate to that host over HTTPS (an http.extraheader), so push/fetch/pull
|
# (an http.extraheader per host), so push/fetch/pull work from the container
|
||||||
# work from the container without a separate SSH key or credential helper.
|
# without a separate SSH key or credential helper.
|
||||||
|
|
||||||
# Optional: also append structured logs to this file. Leave empty to disable.
|
# Optional: also append structured logs to this file. Leave empty to disable.
|
||||||
LOG_FILE=
|
LOG_FILE=
|
||||||
|
|
||||||
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
|
# --- Forge integration — SEED ONLY (first run) → forges in the config DB ----
|
||||||
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
|
# Multiple forges are now supported and managed in Settings; this pair only
|
||||||
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
|
# seeds the FIRST one on an empty DB. Set BOTH to enable PRs + "Merge & clean up"
|
||||||
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
|
# for repos whose remote host matches this URL. Token scope: repo read + PR write
|
||||||
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
|
# + branch delete. Writes are confirmed per AGENT.md §1.4.
|
||||||
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
|
|
||||||
# branch delete.
|
|
||||||
GITEA_URL=
|
GITEA_URL=
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
# Later providers, behind the same interface (unused for now):
|
|
||||||
GITHUB_TOKEN=
|
|
||||||
GITLAB_TOKEN=
|
|
||||||
|
|||||||
@@ -121,10 +121,12 @@ values** rather than reintroducing hardcoded hex.
|
|||||||
- **The background scanner is read‑only.** It may run `status`, `rev-list`,
|
- **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
|
`for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never
|
||||||
run a command that changes local state.
|
run a command that changes local state.
|
||||||
- **Never invent local persistence for domain state.** If something must survive a
|
- **Git stays the system of record for repository data** — never mirror repo
|
||||||
restart and it isn't already in Git, it is either app config (`.env`, Section
|
contents into another store. The one sanctioned datastore is the **config store**
|
||||||
1.4) or per‑user UI state (browser `localStorage`, Section 4). Adding any other
|
(§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens,
|
||||||
datastore (SQLite, a server‑side DB) requires asking first — the default is no.
|
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
|
### 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
|
destructive default. Prefer the safe variant (`--force-with-lease` over
|
||||||
`--force`) and surface it as such.
|
`--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,
|
Configuration is split:
|
||||||
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`**
|
- **Bootstrap `.env`** — only what's needed to start the process and can't live in
|
||||||
and never hardcode paths, tokens, hosts, or the set of watched repositories.
|
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
|
### 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Bootstrap config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5 (bootstrap only). |
|
||||||
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
|
| 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. |
|
| 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
|
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
|
wait for approval.** Keep the dependency surface small. There is **no auth library**
|
||||||
**no database and no auth library** — do not add one without asking (Section 1.3,
|
(§0) and **no datastore beyond the config store** (§1.3, §1.5) — do not add one
|
||||||
Section 0).
|
without asking.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -211,7 +226,8 @@ Section 0).
|
|||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── server/main.go # entrypoint: wire config, git, scanner, router
|
│ └── server/main.go # entrypoint: wire config, git, scanner, router
|
||||||
├── internal/
|
├── 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)
|
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
|
||||||
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
|
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
|
||||||
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
|
│ ├── 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
|
> 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
|
> directory. Keep `components/` strictly for web components. The only datastore is
|
||||||
> **no `migrations/` and no `db/`** — see Section 1.3.
|
> 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 +
|
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"
|
delete branch). Note a merged PR remains in the host's history; "clean up"
|
||||||
means removing the **branch**, not falsifying history.
|
means removing the **branch**, not falsifying history.
|
||||||
- **Enablement is per‑host and token‑gated.** Tokens come from `.env`
|
- **Multiple hosts, configured in the store.** Forges (base URL + token) live in
|
||||||
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and
|
the **config store** (§1.5) and are managed in Settings — not `.env`. A repo is
|
||||||
the rest of the app works unchanged (**graceful degradation**).
|
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
|
- The provider is inferred from a repo's remote URL. Never send repo data to a
|
||||||
host the user didn't configure.
|
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**
|
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
|
(§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
|
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
|
record** (§1.3), treat **destructive operations** per §1.4, and don't mirror
|
||||||
datastore. A new capability generally means: service method → HTTP handler →
|
repo/PR data into a datastore (app *config* goes in the store, §1.5). A new
|
||||||
MCP tool → UI control.
|
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
|
5. If it needs a **new dependency** or a **new top‑level folder**, propose it and
|
||||||
wait for approval.
|
wait for approval.
|
||||||
6. Implement, run it in the **docker‑compose dev environment**, verify hot reload
|
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
|
*(Claude Code: surface these to the human at the first relevant moment; don't
|
||||||
silently guess.)*
|
silently guess.)*
|
||||||
|
|
||||||
- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth?
|
- ✅ **RESOLVED 2026-09-22:** **Domain config moved from `.env` to a private
|
||||||
ignore globs?) vs an explicit list of repo paths. Default assumption: recursive
|
SQLite store** (`internal/store`, §1.5): forges+tokens, project directories, git
|
||||||
scan with a configurable depth.
|
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
|
- **Background `fetch`:** off by default (no unsolicited network). Confirm whether
|
||||||
it should be enabled, and the interval / rate limit, before turning it on.
|
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
|
- **Git access library:** system `git` via `os/exec` is the locked default
|
||||||
|
|||||||
@@ -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`,
|
- **Affects:** `components/toast-host` (new), `components/repo-menu`,
|
||||||
`components/pr-list`, `web/templates/index.html`.
|
`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`) + `<settings-panel>` component + `<toast-host>`
|
||||||
|
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.<url>.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
|
## 2026-09-20 — Slice 13: repo search + filtering
|
||||||
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and
|
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and
|
||||||
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
|
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
|
||||||
|
|||||||
+140
-31
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,29 +27,36 @@ import (
|
|||||||
"gitmanager/internal/render"
|
"gitmanager/internal/render"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
|
"gitmanager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configureGit prepares the container's git for operating on the mounted repos:
|
// seedStore migrates config from .env into the store on first run only, so an
|
||||||
// a commit identity (so commits don't fail with "empty ident"), permission to
|
// existing deployment keeps working after the switch to the DB (§1.3).
|
||||||
// work on host-owned mounts, and — when a Gitea token is set — an auth header so
|
func seedStore(ctx context.Context, st *store.Store, cfg config.Config, log *slog.Logger) {
|
||||||
// pushes/fetches over HTTPS succeed. The token is written to the container's
|
empty, err := st.IsEmpty(ctx)
|
||||||
// gitconfig (ephemeral, localhost); see AGENT.md §11.
|
if err != nil {
|
||||||
func configureGit(ctx context.Context, g *git.CLI, cfg config.Config, log *slog.Logger) {
|
log.Warn("config store check failed", "err", err)
|
||||||
set := func(key, value string) {
|
return
|
||||||
if err := g.SetGlobalConfig(ctx, key, value); err != nil {
|
}
|
||||||
log.Warn("git config failed", "key", key, "err", err)
|
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 != "" {
|
if cfg.GitUserName != "" {
|
||||||
set("user.name", cfg.GitUserName)
|
_ = st.SetSetting(ctx, "git_user_name", cfg.GitUserName)
|
||||||
}
|
}
|
||||||
if cfg.GitUserEmail != "" {
|
if cfg.GitUserEmail != "" {
|
||||||
set("user.email", cfg.GitUserEmail)
|
_ = st.SetSetting(ctx, "git_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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,31 +80,39 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
log.Info("git detected", "version", v)
|
log.Info("git detected", "version", v)
|
||||||
}
|
}
|
||||||
configureGit(context.Background(), g, cfg, log)
|
|
||||||
|
|
||||||
// Start the read-only scanner in the background.
|
// Config store (forges, project dirs, git identity) on a private volume (§1.3).
|
||||||
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
|
st, err := store.Open(cfg.DBPath)
|
||||||
scanCtx, stopScan := context.WithCancel(context.Background())
|
if err != nil {
|
||||||
defer stopScan()
|
log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
|
||||||
go scanner.Run(scanCtx)
|
os.Exit(1)
|
||||||
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
|
}
|
||||||
|
defer st.Close()
|
||||||
|
seedStore(context.Background(), st, cfg, log)
|
||||||
|
|
||||||
// Coordination state: active project + activity feed (§8.2).
|
// Coordination state: active project + activity feed (§8.2).
|
||||||
feed := activity.New(log, 200)
|
feed := activity.New(log, 200)
|
||||||
|
|
||||||
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
|
// The read-only scanner reads its roots fresh from the store each cycle, so
|
||||||
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
|
// project-directory changes take effect without a restart (§5).
|
||||||
|
scanCfg := func(ctx context.Context) repos.Config {
|
||||||
|
roots, err := st.EnabledRoots(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("forge disabled — invalid config", "err", err)
|
log.Warn("could not read project dirs", "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")
|
|
||||||
}
|
}
|
||||||
|
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).
|
// 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.
|
// 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")
|
tmpl, err := render.New("web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,6 +149,9 @@ func main() {
|
|||||||
e.GET("/help", func(c echo.Context) error {
|
e.GET("/help", func(c echo.Context) error {
|
||||||
return c.Render(http.StatusOK, "help.html", nil)
|
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.
|
// JSON API — components self-fetch from here.
|
||||||
e.GET("/healthz", func(c echo.Context) error {
|
e.GET("/healthz", func(c echo.Context) error {
|
||||||
@@ -201,6 +220,96 @@ func main() {
|
|||||||
return c.JSON(http.StatusOK, map[string]any{"pending": false})
|
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,
|
// Plain-language git commands (the right-click menu, §6). One endpoint,
|
||||||
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
|
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
|
||||||
e.POST("/api/repo/git", func(c echo.Context) error {
|
e.POST("/api/repo/git", func(c echo.Context) error {
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// <settings-panel> — 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 = `<p class="muted">No forges configured.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.replaceChildren(...forges.map((f) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<span class="k">${this.#esc(f.name || f.kind)}</span>
|
||||||
|
<span class="url">${this.#esc(f.baseUrl)}</span>
|
||||||
|
<span class="badge ${f.hasToken ? 'ok' : 'warn'}">${f.hasToken ? 'token set' : 'no token'}</span>
|
||||||
|
</div>`;
|
||||||
|
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 = `<p class="error">${this.#esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<p class="muted">No project directories configured.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.replaceChildren(...dirs.map((d) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<label class="dir">
|
||||||
|
<input type="checkbox" ${d.enabled ? 'checked' : ''}>
|
||||||
|
<code>${this.#esc(d.path)}</code>
|
||||||
|
</label>`;
|
||||||
|
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 = `<p class="error">${this.#esc(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; }
|
||||||
|
section { background: var(--surface-1); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
|
||||||
|
h2 { font-size: 15px; margin: 0 0 4px; }
|
||||||
|
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; }
|
||||||
|
.row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
|
||||||
|
border-top: 1px solid var(--border); }
|
||||||
|
.row:first-of-type { border-top: none; }
|
||||||
|
.row > div:first-child, .dir { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
|
||||||
|
.k { font-weight: 600; }
|
||||||
|
.url { color: var(--color-fg-muted); }
|
||||||
|
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
|
||||||
|
.badge { font-size: 11px; padding: 1px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
|
||||||
|
.badge.ok { color: var(--color-success); border-color: var(--color-success); }
|
||||||
|
.badge.warn { color: var(--color-warning); border-color: var(--color-warning); }
|
||||||
|
.form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
font: inherit; background: var(--surface-2); color: var(--color-fg);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 9px; }
|
||||||
|
input.grow { flex: 1; min-width: 160px; }
|
||||||
|
button { font: inherit; cursor: pointer; padding: 6px 12px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--fill-accent); color: var(--fill-accent); background: transparent; }
|
||||||
|
button:hover { background: var(--surface-2); }
|
||||||
|
button.danger { border-color: var(--color-danger); color: var(--color-danger); margin-left: auto; }
|
||||||
|
.muted { color: var(--color-fg-muted); }
|
||||||
|
.error { color: var(--color-danger); }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Forges</h2>
|
||||||
|
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
|
||||||
|
Tokens are stored in the private config database and never shown again.</p>
|
||||||
|
<div id="forges"><p class="muted">Loading…</p></div>
|
||||||
|
<div class="form">
|
||||||
|
<input id="f-name" type="text" placeholder="Name (e.g. Gitea)">
|
||||||
|
<input id="f-url" type="text" class="grow" placeholder="https://git.example.com">
|
||||||
|
<input id="f-token" type="password" placeholder="Access token">
|
||||||
|
<button id="f-add">Add forge</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Project directories</h2>
|
||||||
|
<p class="hint">Directories to scan for repositories. Paths are inside the
|
||||||
|
container — they must be under a mounted directory (e.g. under <code>/repos</code>).</p>
|
||||||
|
<div id="dirs"><p class="muted">Loading…</p></div>
|
||||||
|
<div class="form">
|
||||||
|
<input id="d-path" type="text" class="grow" placeholder="/repos/some-folder">
|
||||||
|
<button id="d-add">Add directory</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Git identity</h2>
|
||||||
|
<p class="hint">Author used for commits the app makes.</p>
|
||||||
|
<div class="form">
|
||||||
|
<input id="i-name" type="text" placeholder="Your Name">
|
||||||
|
<input id="i-email" type="text" class="grow" placeholder="you@example.com">
|
||||||
|
<button id="i-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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:** `<settings-panel>`
|
||||||
|
- **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 `<toast-host>` 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.
|
||||||
+10
-1
@@ -18,8 +18,12 @@ services:
|
|||||||
- HTTPS_ADDR=0.0.0.0:8443
|
- HTTPS_ADDR=0.0.0.0:8443
|
||||||
- TLS_CERT_FILE=/app/certs/localhost.pem
|
- TLS_CERT_FILE=/app/certs/localhost.pem
|
||||||
- TLS_KEY_FILE=/app/certs/localhost-key.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
|
- 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:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
- "127.0.0.1:8443:8443"
|
- "127.0.0.1:8443:8443"
|
||||||
@@ -28,6 +32,10 @@ services:
|
|||||||
- .:/app
|
- .:/app
|
||||||
# Cache the Go module + build cache across restarts.
|
# Cache the Go module + build cache across restarts.
|
||||||
- gomod:/go/pkg/mod
|
- 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
|
# 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.
|
# host folder that holds them; defaults to ./repos next to this file.
|
||||||
- "${REPOS_HOST_PATH:-./repos}:/repos"
|
- "${REPOS_HOST_PATH:-./repos}:/repos"
|
||||||
@@ -43,3 +51,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
gomod:
|
gomod:
|
||||||
|
gmdata:
|
||||||
|
|||||||
@@ -7,17 +7,22 @@ require (
|
|||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/labstack/echo/v4 v4.15.4
|
github.com/labstack/echo/v4 v4.15.4
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||||
|
modernc.org/sqlite v1.59.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/42wim/httpsig v1.2.4 // indirect
|
github.com/42wim/httpsig v1.2.4 // indirect
|
||||||
github.com/davidmz/go-pageant v1.0.2 // 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/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // 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/hashicorp/go-version v1.9.0 // indirect
|
||||||
github.com/labstack/gommon v0.5.0 // indirect
|
github.com/labstack/gommon v0.5.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // 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/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // 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/crypto v0.53.0 // indirect
|
||||||
golang.org/x/net v0.56.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
golang.org/x/time v0.15.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
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 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
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=
|
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/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 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
|
||||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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=
|
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/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 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
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 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
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.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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
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 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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.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 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ type Config struct {
|
|||||||
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
|
||||||
TLSKeyFile string // PEM private key
|
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
|
GitBin string // path to the git binary
|
||||||
|
|
||||||
ScanInterval time.Duration // scanner refresh interval
|
ScanInterval time.Duration // scanner refresh interval
|
||||||
@@ -50,6 +54,7 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
|
||||||
|
DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"),
|
||||||
HTTPSAddr: env("HTTPS_ADDR", ""),
|
HTTPSAddr: env("HTTPS_ADDR", ""),
|
||||||
TLSCertFile: env("TLS_CERT_FILE", ""),
|
TLSCertFile: env("TLS_CERT_FILE", ""),
|
||||||
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
TLSKeyFile: env("TLS_KEY_FILE", ""),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
"gitmanager/internal/service"
|
"gitmanager/internal/service"
|
||||||
|
"gitmanager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
|
// 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.
|
// Populate the index via the real scanner, then build service + MCP server.
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
g := git.New("git")
|
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())
|
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")
|
srv := NewServer(svc, "test")
|
||||||
|
|
||||||
// Wire an in-memory client<->server session.
|
// Wire an in-memory client<->server session.
|
||||||
|
|||||||
+34
-25
@@ -61,33 +61,37 @@ func (i *Index) List() []State {
|
|||||||
return out
|
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.
|
// Scanner discovers repositories and refreshes the index on an interval.
|
||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
git *git.CLI
|
git *git.CLI
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
roots []string
|
cfgFn ConfigFunc
|
||||||
maxDepth int
|
|
||||||
ignore map[string]struct{}
|
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
fetchEnabled bool
|
|
||||||
|
|
||||||
Index *Index
|
Index *Index
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScanner builds a scanner. ignore is a set of directory names to skip.
|
// NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
|
||||||
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
|
// each cycle; interval is fixed for the life of the process.
|
||||||
ig := make(map[string]struct{}, len(ignore))
|
func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
|
||||||
for _, name := range ignore {
|
|
||||||
ig[name] = struct{}{}
|
|
||||||
}
|
|
||||||
return &Scanner{
|
return &Scanner{
|
||||||
git: g,
|
git: g,
|
||||||
log: log,
|
log: log,
|
||||||
roots: roots,
|
cfgFn: cfgFn,
|
||||||
maxDepth: maxDepth,
|
|
||||||
ignore: ig,
|
|
||||||
interval: interval,
|
interval: interval,
|
||||||
fetchEnabled: fetchEnabled,
|
|
||||||
Index: newIndex(),
|
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
|
// 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.
|
// its own timeout so a single slow/unreachable repo cannot stall the rest.
|
||||||
func (s *Scanner) Refresh(ctx context.Context) {
|
func (s *Scanner) Refresh(ctx context.Context) {
|
||||||
paths := s.discover()
|
cfg := s.cfgFn(ctx)
|
||||||
s.log.Debug("scan discovered repositories", "count", len(paths))
|
paths := discover(cfg)
|
||||||
|
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
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
|
// 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
|
// 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) {
|
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)
|
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
|
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 {
|
if err := s.git.Fetch(rctx, path); err != nil {
|
||||||
s.log.Warn("scan fetch failed", "repo", path, "err", err)
|
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,
|
// 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
|
// recording the parent as a repo and not descending into it. Depth is measured
|
||||||
// relative to each root; ignored directory names are skipped.
|
// 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{})
|
seen := make(map[string]struct{})
|
||||||
var out []string
|
var out []string
|
||||||
|
|
||||||
for _, root := range s.roots {
|
for _, root := range cfg.Roots {
|
||||||
root = filepath.Clean(root)
|
root = filepath.Clean(root)
|
||||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,12 +191,12 @@ func (s *Scanner) discover() []string {
|
|||||||
|
|
||||||
name := d.Name()
|
name := d.Name()
|
||||||
if path != root {
|
if path != root {
|
||||||
if _, skip := s.ignore[name]; skip {
|
if _, skip := ignore[name]; skip {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if depth(root, path) > s.maxDepth {
|
if depth(root, path) > cfg.MaxDepth {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+232
-33
@@ -8,13 +8,17 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"gitmanager/internal/activity"
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/forge"
|
"gitmanager/internal/forge"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"gitmanager/internal/repos"
|
||||||
|
"gitmanager/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service holds the shared dependencies the capabilities need.
|
// Service holds the shared dependencies the capabilities need.
|
||||||
@@ -22,15 +26,22 @@ type Service struct {
|
|||||||
git *git.CLI
|
git *git.CLI
|
||||||
index *repos.Index
|
index *repos.Index
|
||||||
feed *activity.Feed
|
feed *activity.Feed
|
||||||
forge *forge.Gitea // nil when no forge is configured
|
store *store.Store
|
||||||
|
log *slog.Logger
|
||||||
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
|
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
|
// 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
|
// activity feed, the config store, and a single-repo refresh hook (may be nil)
|
||||||
// (may be nil) used to re-scan a repo after a mutating action.
|
// 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 {
|
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, forge: fg, refresh: refresh}
|
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.
|
// 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) ---------------------
|
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
|
||||||
|
|
||||||
// ForgeConfigured reports whether any forge provider is set up.
|
// ForgeConfigured reports whether any forge is configured in the store.
|
||||||
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
|
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
|
// 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
|
// when no forge is configured, or forge.ErrNotSupported when the repo's remote is
|
||||||
// on the configured host.
|
// not on a configured host.
|
||||||
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// 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
|
// default branch) and records the action. The head branch must already exist on
|
||||||
// the remote (push it first).
|
// the remote (push it first).
|
||||||
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
|
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 {
|
if err != nil {
|
||||||
return forge.PullRequest{}, err
|
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 {
|
if err != nil {
|
||||||
return forge.PullRequest{}, err
|
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
|
// The caller is responsible for confirming with the user first (§1.4); actor
|
||||||
// distinguishes a UI action (user) from an MCP one (claude).
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return forge.MergeResult{}, err
|
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 {
|
if err != nil {
|
||||||
return forge.MergeResult{}, err
|
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
|
// resolveForge maps a repo path to (provider, owner, repo) by matching its git
|
||||||
// its git remotes, preferring "origin".
|
// remotes (preferring "origin") against the forges configured in the store.
|
||||||
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
|
func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
|
||||||
if s.forge == nil {
|
forges, err := s.store.ListForges(ctx)
|
||||||
return "", "", forge.ErrNotConfigured
|
if err != nil {
|
||||||
|
return nil, "", "", err
|
||||||
|
}
|
||||||
|
if len(forges) == 0 {
|
||||||
|
return nil, "", "", forge.ErrNotConfigured
|
||||||
}
|
}
|
||||||
base, ok := s.index.Get(filepath.Clean(repoPath))
|
base, ok := s.index.Get(filepath.Clean(repoPath))
|
||||||
if !ok {
|
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)
|
remotes, err := s.git.RemoteDetails(ctx, base.Path)
|
||||||
if err != nil {
|
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.
|
// Prefer origin, then any matching remote.
|
||||||
var fallback [2]string
|
var fb *forge.Gitea
|
||||||
haveFallback := false
|
var fo, fr string
|
||||||
for _, rm := range remotes {
|
for _, rm := range remotes {
|
||||||
host, o, r, ok := forge.ParseRemote(rm.URL)
|
prov, o, r, ok := match(rm.URL)
|
||||||
if !ok || !s.forge.Handles(host) {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rm.Name == "origin" {
|
if rm.Name == "origin" {
|
||||||
return o, r, nil
|
return prov, o, r, nil
|
||||||
}
|
}
|
||||||
if !haveFallback {
|
if fb == nil {
|
||||||
fallback = [2]string{o, r}
|
fb, fo, fr = prov, o, r
|
||||||
haveFallback = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if haveFallback {
|
if fb != nil {
|
||||||
return fallback[0], fallback[1], 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,27 @@ import (
|
|||||||
"gitmanager/internal/activity"
|
"gitmanager/internal/activity"
|
||||||
"gitmanager/internal/git"
|
"gitmanager/internal/git"
|
||||||
"gitmanager/internal/repos"
|
"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
|
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
|
||||||
// temp repo — never a real one.
|
// temp repo — never a real one.
|
||||||
func TestGitActions(t *testing.T) {
|
func TestGitActions(t *testing.T) {
|
||||||
@@ -31,10 +50,10 @@ func TestGitActions(t *testing.T) {
|
|||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
g := git.New("git")
|
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())
|
scanner.Refresh(context.Background())
|
||||||
feed := activity.New(log, 200)
|
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()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Commit a new file, then the repo should be clean in the index.
|
// Commit a new file, then the repo should be clean in the index.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -53,6 +53,21 @@
|
|||||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<p>
|
||||||
|
Open <a href="/settings">Settings</a> to manage your configuration (it's
|
||||||
|
stored in a private database inside the app, not in files):
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Forges</strong> — 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.</li>
|
||||||
|
<li><strong>Project directories</strong> — the folders scanned for
|
||||||
|
repositories. Paths are inside the app's container (under a mounted folder
|
||||||
|
such as <code>/repos</code>); enable, disable, or remove them.</li>
|
||||||
|
<li><strong>Git identity</strong> — the name and email used for commits.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>Right-click commands</h2>
|
<h2>Right-click commands</h2>
|
||||||
<p>
|
<p>
|
||||||
Right-click any repository for a menu of plain-language commands — no git
|
Right-click any repository for a menu of plain-language commands — no git
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>GitManager</h1>
|
<h1>GitManager</h1>
|
||||||
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
|
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
|
||||||
<nav><a href="/help">Help</a></nav>
|
<nav><a href="/settings">Settings</a> · <a href="/help">Help</a></nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<handoff-bar></handoff-bar>
|
<handoff-bar></handoff-bar>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>GitManager — Settings</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
<script type="module" src="/components/settings-panel/settings-panel.js"></script>
|
||||||
|
<script type="module" src="/components/toast-host/toast-host.js"></script>
|
||||||
|
<style>
|
||||||
|
header {
|
||||||
|
display: flex; align-items: baseline; gap: 16px;
|
||||||
|
padding: 12px 20px; background: var(--surface-1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header h1 { margin: 0; font-size: 16px; }
|
||||||
|
header nav { margin-left: auto; }
|
||||||
|
main { max-width: 820px; padding: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>GitManager</h1>
|
||||||
|
<span style="color: var(--color-fg-muted)">settings</span>
|
||||||
|
<nav><a href="/">← Dashboard</a></nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<settings-panel></settings-panel>
|
||||||
|
</main>
|
||||||
|
<toast-host></toast-host>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user