diff --git a/internal/apperrors/errors.go b/internal/apperrors/errors.go new file mode 100644 index 0000000..ef984f0 --- /dev/null +++ b/internal/apperrors/errors.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d6f9e2c --- /dev/null +++ b/internal/config/config.go @@ -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 + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..82fe683 --- /dev/null +++ b/internal/config/config_test.go @@ -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 +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..8a4b262 --- /dev/null +++ b/internal/config/credentials.go @@ -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) +} diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..ed1caae --- /dev/null +++ b/internal/domain/types.go @@ -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"` +}