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