Files
GitManager/internal/store/store.go
T
TBNilles 32ae17cc9f Config: .env = projects root only; derive each project's forge from git
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:52:03 -04:00

151 lines
4.5 KiB
Go

// 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"`
}
// 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 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
}
// --- 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
}