feat: add config and credential storage
This commit is contained in:
17
internal/apperrors/errors.go
Normal file
17
internal/apperrors/errors.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package apperrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func User(message string) error {
|
||||||
|
return errors.New(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Wrap(message string, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", message, err)
|
||||||
|
}
|
||||||
89
internal/config/config.go
Normal file
89
internal/config/config.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sgg-ai-skill-manager/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthPassword = "password"
|
||||||
|
AuthToken = "token"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Default() domain.Config {
|
||||||
|
return domain.Config{
|
||||||
|
Gitea: domain.GiteaConfig{
|
||||||
|
AuthType: AuthPassword,
|
||||||
|
CredentialKey: "sgg-ai-skill-manager:gitea",
|
||||||
|
},
|
||||||
|
Update: domain.UpdateConfig{
|
||||||
|
AutoUpdate: true,
|
||||||
|
CheckOnStartup: true,
|
||||||
|
IntervalMinutes: 60,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (domain.Config, error) {
|
||||||
|
cfg := Default()
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(string(raw)) == "" {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Save(path string, cfg domain.Config) error {
|
||||||
|
applyDefaults(&cfg)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, append(raw, '\n'), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Validate(cfg domain.Config) error {
|
||||||
|
if strings.TrimSpace(cfg.Gitea.BaseURL) == "" {
|
||||||
|
return errors.New("gitea baseURL is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Gitea.Org) == "" {
|
||||||
|
return errors.New("gitea org is required")
|
||||||
|
}
|
||||||
|
if cfg.Gitea.AuthType != AuthPassword && cfg.Gitea.AuthType != AuthToken {
|
||||||
|
return errors.New("authType must be password or token")
|
||||||
|
}
|
||||||
|
if cfg.Update.IntervalMinutes <= 0 {
|
||||||
|
return errors.New("update interval must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *domain.Config) {
|
||||||
|
if cfg.Gitea.AuthType == "" {
|
||||||
|
cfg.Gitea.AuthType = AuthPassword
|
||||||
|
}
|
||||||
|
if cfg.Gitea.CredentialKey == "" {
|
||||||
|
cfg.Gitea.CredentialKey = "sgg-ai-skill-manager:gitea"
|
||||||
|
}
|
||||||
|
if cfg.Update.IntervalMinutes <= 0 {
|
||||||
|
cfg.Update.IntervalMinutes = 60
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/config/config_test.go
Normal file
77
internal/config/config_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMissingConfigReturnsDefaults(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cfg, err := Load(filepath.Join(dir, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Gitea.AuthType != "password" {
|
||||||
|
t.Fatalf("default auth type = %q, want password", cfg.Gitea.AuthType)
|
||||||
|
}
|
||||||
|
if !cfg.Update.AutoUpdate || !cfg.Update.CheckOnStartup {
|
||||||
|
t.Fatalf("update defaults should be enabled: %+v", cfg.Update)
|
||||||
|
}
|
||||||
|
if cfg.Update.IntervalMinutes != 60 {
|
||||||
|
t.Fatalf("interval = %d, want 60", cfg.Update.IntervalMinutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDoesNotPersistSecrets(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
cfg := Default()
|
||||||
|
cfg.Gitea.BaseURL = "https://gitea.example.com"
|
||||||
|
cfg.Gitea.Org = "skills"
|
||||||
|
cfg.Gitea.Username = "alice"
|
||||||
|
cfg.Gitea.CredentialKey = "sgg-ai-skill-manager:gitea"
|
||||||
|
|
||||||
|
if err := Save(path, cfg); err != nil {
|
||||||
|
t.Fatalf("Save returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
text := string(raw)
|
||||||
|
if containsAny(text, []string{"password-secret", "token-secret"}) {
|
||||||
|
t.Fatalf("config persisted a secret: %s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsMissingConnectionFields(t *testing.T) {
|
||||||
|
cfg := Default()
|
||||||
|
|
||||||
|
if err := Validate(cfg); err == nil {
|
||||||
|
t.Fatal("Validate returned nil error for empty config")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Gitea.BaseURL = "https://gitea.example.com"
|
||||||
|
if err := Validate(cfg); err == nil {
|
||||||
|
t.Fatal("Validate returned nil error without org")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Gitea.Org = "skills"
|
||||||
|
if err := Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate returned error for valid config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(text string, needles []string) bool {
|
||||||
|
for _, needle := range needles {
|
||||||
|
if needle != "" && strings.Contains(text, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
23
internal/config/credentials.go
Normal file
23
internal/config/credentials.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "github.com/zalando/go-keyring"
|
||||||
|
|
||||||
|
type SecretStore interface {
|
||||||
|
Set(service, user, secret string) error
|
||||||
|
Get(service, user string) (string, error)
|
||||||
|
Delete(service, user string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyringStore struct{}
|
||||||
|
|
||||||
|
func (KeyringStore) Set(service, user, secret string) error {
|
||||||
|
return keyring.Set(service, user, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (KeyringStore) Get(service, user string) (string, error) {
|
||||||
|
return keyring.Get(service, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (KeyringStore) Delete(service, user string) error {
|
||||||
|
return keyring.Delete(service, user)
|
||||||
|
}
|
||||||
48
internal/domain/types.go
Normal file
48
internal/domain/types.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Gitea GiteaConfig `json:"gitea"`
|
||||||
|
Update UpdateConfig `json:"update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiteaConfig struct {
|
||||||
|
BaseURL string `json:"baseURL"`
|
||||||
|
Org string `json:"org"`
|
||||||
|
AuthType string `json:"authType"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
CredentialKey string `json:"credentialKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateConfig struct {
|
||||||
|
AutoUpdate bool `json:"autoUpdate"`
|
||||||
|
CheckOnStartup bool `json:"checkOnStartup"`
|
||||||
|
IntervalMinutes int `json:"intervalMinutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveConfigRequest struct {
|
||||||
|
Config Config `json:"config"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillState struct {
|
||||||
|
Org string `json:"org"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
LocalPath string `json:"localPath"`
|
||||||
|
RemoteURL string `json:"remoteURL"`
|
||||||
|
DefaultBranch string `json:"defaultBranch"`
|
||||||
|
CurrentCommit string `json:"currentCommit"`
|
||||||
|
LastCheckedAt string `json:"lastCheckedAt"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
InstalledTargets map[string]InstalledTarget `json:"installedTargets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstalledTarget struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
LinkType string `json:"linkType"`
|
||||||
|
TargetPath string `json:"targetPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Skills []SkillState `json:"skills"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user