feat: add skill management service

This commit is contained in:
2026-05-13 16:40:29 +08:00
parent 3636952dca
commit 12a6a83840
4 changed files with 948 additions and 10 deletions

496
internal/service/service.go Normal file
View File

@@ -0,0 +1,496 @@
package service
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"sgg-ai-skill-manager/internal/config"
"sgg-ai-skill-manager/internal/domain"
"sgg-ai-skill-manager/internal/gitea"
"sgg-ai-skill-manager/internal/gitops"
"sgg-ai-skill-manager/internal/skillstore"
"sgg-ai-skill-manager/internal/targets"
)
type Paths struct {
AppDir string
ConfigPath string
StatePath string
ReposRoot string
HomeDir string
}
type RemoteClient interface {
ListOrgSkills(ctx context.Context, org string) ([]domain.RemoteSkill, error)
TestConnection(ctx context.Context, org string) (domain.TestConnectionResult, error)
}
type RemoteMaker func(ctx context.Context, cfg domain.Config, secret string) (RemoteClient, error)
type GitRunner interface {
Clone(ctx context.Context, remoteURL, dest string, creds gitops.Credentials) error
Pull(ctx context.Context, repoPath string, creds gitops.Credentials) error
CurrentCommit(repoPath string) (string, error)
RemoteCommit(ctx context.Context, repoPath, branch string, creds gitops.Credentials) (string, error)
HasLocalChanges(repoPath string) (bool, error)
}
type TargetManager interface {
Install(skill domain.SkillState, targetID string) (domain.SkillState, error)
Uninstall(skill domain.SkillState, targetID string) (domain.SkillState, error)
Status(skill domain.SkillState, targetID string) string
}
type Opener interface {
OpenVSCode(path string) error
OpenFolder(path string) error
}
type Options struct {
Paths Paths
Secrets config.SecretStore
Git GitRunner
Targets TargetManager
RemoteMaker RemoteMaker
Opener Opener
}
type Service struct {
paths Paths
secrets config.SecretStore
git GitRunner
targets TargetManager
remoteMaker RemoteMaker
opener Opener
store *skillstore.Store
}
func New(opts Options) *Service {
paths := opts.Paths
if paths.AppDir == "" {
paths, _ = DefaultPaths()
}
secrets := opts.Secrets
if secrets == nil {
secrets = config.KeyringStore{}
}
gitRunner := opts.Git
if gitRunner == nil {
gitRunner = gitops.New()
}
targetManager := opts.Targets
if targetManager == nil {
targetManager = targets.New(targets.DefaultTargets(paths.HomeDir))
}
remoteMaker := opts.RemoteMaker
if remoteMaker == nil {
remoteMaker = defaultRemoteMaker
}
opener := opts.Opener
if opener == nil {
opener = systemOpener{}
}
return &Service{
paths: paths,
secrets: secrets,
git: gitRunner,
targets: targetManager,
remoteMaker: remoteMaker,
opener: opener,
store: skillstore.New(paths.StatePath, paths.ReposRoot),
}
}
func NewDefault() (*Service, error) {
paths, err := DefaultPaths()
if err != nil {
return nil, err
}
return New(Options{Paths: paths}), nil
}
func DefaultPaths() (Paths, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return Paths{}, err
}
home, err := os.UserHomeDir()
if err != nil {
return Paths{}, err
}
appDir := filepath.Join(configDir, "sgg-ai-skill-manager")
return Paths{
AppDir: appDir,
ConfigPath: filepath.Join(appDir, "config.json"),
StatePath: filepath.Join(appDir, "state.json"),
ReposRoot: filepath.Join(appDir, "repos"),
HomeDir: home,
}, nil
}
func (s *Service) LoadConfig() (domain.Config, error) {
return config.Load(s.paths.ConfigPath)
}
func (s *Service) SaveConfig(ctx context.Context, req domain.SaveConfigRequest) error {
_ = ctx
cfg := req.Config
if err := config.Validate(cfg); err != nil {
return err
}
secret := requestSecret(req)
if secret != "" {
if err := s.secrets.Set(cfg.Gitea.CredentialKey, credentialUser(cfg), secret); err != nil {
return err
}
}
return config.Save(s.paths.ConfigPath, cfg)
}
func (s *Service) TestConnection(ctx context.Context, req domain.SaveConfigRequest) (domain.TestConnectionResult, error) {
cfg := req.Config
if err := config.Validate(cfg); err != nil {
return domain.TestConnectionResult{}, err
}
client, err := s.remoteMaker(ctx, cfg, requestSecret(req))
if err != nil {
return domain.TestConnectionResult{}, err
}
return client.TestConnection(ctx, cfg.Gitea.Org)
}
func (s *Service) ListRemoteSkills(ctx context.Context) ([]domain.RemoteSkill, error) {
cfg, client, err := s.savedRemoteClient(ctx)
if err != nil {
return nil, err
}
remoteSkills, err := client.ListOrgSkills(ctx, cfg.Gitea.Org)
if err != nil {
return nil, err
}
state, err := s.store.Load()
if err != nil {
return nil, err
}
for i := range remoteSkills {
if _, ok := skillstore.FindSkill(state, cfg.Gitea.Org, remoteSkills[i].Name); ok {
remoteSkills[i].IsDownloaded = true
remoteSkills[i].Status = "downloaded"
}
}
return remoteSkills, nil
}
func (s *Service) ListLocalSkills(ctx context.Context) ([]domain.SkillState, error) {
_ = ctx
state, err := s.store.Load()
if err != nil {
return nil, err
}
return state.Skills, nil
}
func (s *Service) DownloadSkill(ctx context.Context, repo domain.RemoteSkill) (domain.SkillState, error) {
cfg, secret, err := s.savedConfigAndSecret()
if err != nil {
return domain.SkillState{}, err
}
localPath, err := s.store.SafeLocalRepoPath(cfg.Gitea.Org, repo.Name)
if err != nil {
return domain.SkillState{}, err
}
if _, err := os.Stat(localPath); errors.Is(err, os.ErrNotExist) {
if err := s.git.Clone(ctx, repo.CloneURL, localPath, gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret}); err != nil {
return domain.SkillState{}, err
}
} else if err != nil {
return domain.SkillState{}, err
}
commit, err := s.git.CurrentCommit(localPath)
if err != nil {
return domain.SkillState{}, err
}
skill := domain.SkillState{
Org: cfg.Gitea.Org,
Repo: repo.Name,
LocalPath: localPath,
RemoteURL: repo.CloneURL,
DefaultBranch: defaultBranch(repo.DefaultBranch),
CurrentCommit: commit,
LastCheckedAt: time.Now().Format(time.RFC3339),
InstalledTargets: map[string]domain.InstalledTarget{},
}
return s.saveSkill(skill)
}
func (s *Service) UpdateSkill(ctx context.Context, org, repo string) (domain.SkillState, error) {
cfg, secret, err := s.savedConfigAndSecret()
if err != nil {
return domain.SkillState{}, err
}
state, skill, err := s.findSkill(org, repo)
if err != nil {
return domain.SkillState{}, err
}
dirty, err := s.git.HasLocalChanges(skill.LocalPath)
if err != nil {
return domain.SkillState{}, err
}
if dirty {
skill.LastError = "local changes present"
state = skillstore.UpsertSkill(state, skill)
return skill, s.store.Save(state)
}
if err := s.git.Pull(ctx, skill.LocalPath, gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret}); err != nil {
return domain.SkillState{}, err
}
commit, err := s.git.CurrentCommit(skill.LocalPath)
if err != nil {
return domain.SkillState{}, err
}
skill.CurrentCommit = commit
skill.LastCheckedAt = time.Now().Format(time.RFC3339)
skill.LastError = ""
state = skillstore.UpsertSkill(state, skill)
return skill, s.store.Save(state)
}
func (s *Service) InstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error) {
_ = ctx
state, skill, err := s.findSkill(org, repo)
if err != nil {
return domain.SkillState{}, err
}
installed, err := s.targets.Install(skill, targetID)
if err != nil {
return domain.SkillState{}, err
}
state = skillstore.UpsertSkill(state, installed)
return installed, s.store.Save(state)
}
func (s *Service) UninstallSkill(ctx context.Context, org, repo, targetID string) (domain.SkillState, error) {
_ = ctx
state, skill, err := s.findSkill(org, repo)
if err != nil {
return domain.SkillState{}, err
}
uninstalled, err := s.targets.Uninstall(skill, targetID)
if err != nil {
return domain.SkillState{}, err
}
state = skillstore.UpsertSkill(state, uninstalled)
return uninstalled, s.store.Save(state)
}
func (s *Service) DeleteSkill(ctx context.Context, org, repo string) error {
_ = ctx
state, skill, err := s.findSkill(org, repo)
if err != nil {
return err
}
for targetID := range skill.InstalledTargets {
var uninstallErr error
skill, uninstallErr = s.targets.Uninstall(skill, targetID)
if uninstallErr != nil {
return uninstallErr
}
}
if err := s.ensureManagedPath(skill.LocalPath); err != nil {
return err
}
if err := os.RemoveAll(skill.LocalPath); err != nil {
return err
}
state = skillstore.RemoveSkill(state, org, repo)
return s.store.Save(state)
}
func (s *Service) OpenInVSCode(ctx context.Context, org, repo string) error {
_ = ctx
_, skill, err := s.findSkill(org, repo)
if err != nil {
return err
}
return s.opener.OpenVSCode(skill.LocalPath)
}
func (s *Service) OpenFolder(ctx context.Context, org, repo string) error {
_ = ctx
_, skill, err := s.findSkill(org, repo)
if err != nil {
return err
}
return s.opener.OpenFolder(skill.LocalPath)
}
func (s *Service) RunAutoUpdate(ctx context.Context) error {
cfg, secret, err := s.savedConfigAndSecret()
if err != nil {
return err
}
if !cfg.Update.AutoUpdate {
return nil
}
state, err := s.store.Load()
if err != nil {
return err
}
creds := gitops.Credentials{Username: cfg.Gitea.Username, Secret: secret}
for i := range state.Skills {
skill := state.Skills[i]
dirty, err := s.git.HasLocalChanges(skill.LocalPath)
if err != nil {
skill.LastError = err.Error()
state.Skills[i] = skill
continue
}
if dirty {
skill.LastError = "local changes present"
skill.LastCheckedAt = time.Now().Format(time.RFC3339)
state.Skills[i] = skill
continue
}
branch := defaultBranch(skill.DefaultBranch)
remoteCommit, err := s.git.RemoteCommit(ctx, skill.LocalPath, branch, creds)
if err != nil {
skill.LastError = err.Error()
state.Skills[i] = skill
continue
}
if remoteCommit != "" && skill.CurrentCommit == remoteCommit {
skill.LastError = ""
skill.LastCheckedAt = time.Now().Format(time.RFC3339)
state.Skills[i] = skill
continue
}
if err := s.git.Pull(ctx, skill.LocalPath, creds); err != nil {
skill.LastError = err.Error()
state.Skills[i] = skill
continue
}
if commit, err := s.git.CurrentCommit(skill.LocalPath); err == nil {
skill.CurrentCommit = commit
} else {
skill.LastError = err.Error()
}
skill.LastError = ""
skill.LastCheckedAt = time.Now().Format(time.RFC3339)
state.Skills[i] = skill
}
return s.store.Save(state)
}
func (s *Service) savedRemoteClient(ctx context.Context) (domain.Config, RemoteClient, error) {
cfg, secret, err := s.savedConfigAndSecret()
if err != nil {
return domain.Config{}, nil, err
}
client, err := s.remoteMaker(ctx, cfg, secret)
return cfg, client, err
}
func (s *Service) savedConfigAndSecret() (domain.Config, string, error) {
cfg, err := config.Load(s.paths.ConfigPath)
if err != nil {
return domain.Config{}, "", err
}
if err := config.Validate(cfg); err != nil {
return domain.Config{}, "", err
}
secret, err := s.secrets.Get(cfg.Gitea.CredentialKey, credentialUser(cfg))
if err != nil {
return domain.Config{}, "", err
}
return cfg, secret, nil
}
func (s *Service) saveSkill(skill domain.SkillState) (domain.SkillState, error) {
state, err := s.store.Load()
if err != nil {
return domain.SkillState{}, err
}
state = skillstore.UpsertSkill(state, skill)
return skill, s.store.Save(state)
}
func (s *Service) findSkill(org, repo string) (domain.State, domain.SkillState, error) {
state, err := s.store.Load()
if err != nil {
return domain.State{}, domain.SkillState{}, err
}
skill, ok := skillstore.FindSkill(state, org, repo)
if !ok {
return state, domain.SkillState{}, fmt.Errorf("skill %s/%s not found", org, repo)
}
if skill.InstalledTargets == nil {
skill.InstalledTargets = map[string]domain.InstalledTarget{}
}
return state, skill, nil
}
func (s *Service) ensureManagedPath(path string) error {
absRoot, err := filepath.Abs(s.paths.ReposRoot)
if err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return err
}
if rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return fmt.Errorf("refusing to delete unmanaged path: %s", path)
}
return nil
}
func defaultRemoteMaker(_ context.Context, cfg domain.Config, secret string) (RemoteClient, error) {
return gitea.NewClient(cfg.Gitea.BaseURL, gitea.Auth{
Type: cfg.Gitea.AuthType,
Username: cfg.Gitea.Username,
Secret: secret,
}), nil
}
func requestSecret(req domain.SaveConfigRequest) string {
if req.Config.Gitea.AuthType == config.AuthToken {
return req.Token
}
return req.Password
}
func credentialUser(cfg domain.Config) string {
if cfg.Gitea.AuthType == config.AuthToken {
return "token"
}
return cfg.Gitea.Username
}
func defaultBranch(branch string) string {
if strings.TrimSpace(branch) == "" {
return "main"
}
return branch
}
type systemOpener struct{}
func (systemOpener) OpenVSCode(path string) error {
cmd := exec.Command("code", ".", "-n")
cmd.Dir = path
return cmd.Start()
}
func (systemOpener) OpenFolder(path string) error {
return exec.Command("explorer.exe", path).Start()
}

View File

@@ -0,0 +1,309 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"sgg-ai-skill-manager/internal/domain"
"sgg-ai-skill-manager/internal/gitops"
"sgg-ai-skill-manager/internal/skillstore"
)
func TestSaveConfigStoresSecretOutsideJSON(t *testing.T) {
svc, paths, secrets, _, _, _ := newTestService(t)
req := domain.SaveConfigRequest{
Config: domain.Config{
Gitea: domain.GiteaConfig{
BaseURL: "https://gitea.example.com",
Org: "skills",
AuthType: "password",
Username: "alice",
CredentialKey: "sgg-ai-skill-manager:gitea",
},
Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60},
},
Password: "password-secret",
}
if err := svc.SaveConfig(context.Background(), req); err != nil {
t.Fatalf("SaveConfig returned error: %v", err)
}
if got := secrets.values["sgg-ai-skill-manager:gitea/alice"]; got != "password-secret" {
t.Fatalf("stored secret = %q", got)
}
raw, err := os.ReadFile(paths.ConfigPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if stringContains(string(raw), "password-secret") {
t.Fatalf("config leaked secret: %s", string(raw))
}
}
func TestListRemoteSkillsMergesDownloadedStatus(t *testing.T) {
svc, _, _, remote, _, _ := newTestService(t)
saveValidConfig(t, svc)
state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: "local"}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
remote.skills = []domain.RemoteSkill{
{Name: "demo", FullName: "skills/demo", CloneURL: "https://example/demo.git"},
{Name: "new", FullName: "skills/new", CloneURL: "https://example/new.git"},
}
got, err := svc.ListRemoteSkills(context.Background())
if err != nil {
t.Fatalf("ListRemoteSkills returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("skill count = %d", len(got))
}
if !got[0].IsDownloaded || got[0].Status != "downloaded" {
t.Fatalf("downloaded skill not marked: %+v", got[0])
}
if got[1].IsDownloaded {
t.Fatalf("new skill should not be downloaded: %+v", got[1])
}
}
func TestInstallSkillUpdatesStateAfterTargetInstall(t *testing.T) {
svc, _, _, _, _, target := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(t.TempDir(), "demo")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatal(err)
}
state := domain.State{Skills: []domain.SkillState{{Org: "skills", Repo: "demo", LocalPath: local}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
target.installResult = domain.SkillState{
Org: "skills",
Repo: "demo",
LocalPath: local,
InstalledTargets: map[string]domain.InstalledTarget{
"codex": {Path: "link", LinkType: "junction", TargetPath: local},
},
}
got, err := svc.InstallSkill(context.Background(), "skills", "demo", "codex")
if err != nil {
t.Fatalf("InstallSkill returned error: %v", err)
}
if got.InstalledTargets["codex"].LinkType != "junction" {
t.Fatalf("install record = %+v", got.InstalledTargets)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
if saved.InstalledTargets["codex"].Path != "link" {
t.Fatalf("saved install record = %+v", saved.InstalledTargets)
}
}
func TestDeleteSkillUninstallsBeforeRemovingLocalRepo(t *testing.T) {
svc, paths, _, _, _, target := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(paths.ReposRoot, "skills", "demo")
if err := os.MkdirAll(local, 0o755); err != nil {
t.Fatal(err)
}
state := domain.State{Skills: []domain.SkillState{{
Org: "skills",
Repo: "demo",
LocalPath: local,
InstalledTargets: map[string]domain.InstalledTarget{
"codex": {Path: "link", LinkType: "junction", TargetPath: local},
},
}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
if err := svc.DeleteSkill(context.Background(), "skills", "demo"); err != nil {
t.Fatalf("DeleteSkill returned error: %v", err)
}
if target.uninstallCalls != 1 {
t.Fatalf("uninstall calls = %d, want 1", target.uninstallCalls)
}
if _, err := os.Stat(local); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("local repo should be removed, err=%v", err)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
if _, ok := skillstore.FindSkill(reloaded, "skills", "demo"); ok {
t.Fatal("deleted skill still exists in state")
}
}
func TestRunAutoUpdateSkipsDirtyRepositories(t *testing.T) {
svc, _, _, _, git, _ := newTestService(t)
saveValidConfig(t, svc)
local := filepath.Join(t.TempDir(), "demo")
state := domain.State{Skills: []domain.SkillState{{
Org: "skills",
Repo: "demo",
LocalPath: local,
DefaultBranch: "main",
CurrentCommit: "old",
}}}
if err := svc.store.Save(state); err != nil {
t.Fatal(err)
}
git.dirty[local] = true
git.remoteCommit[local] = "new"
if err := svc.RunAutoUpdate(context.Background()); err != nil {
t.Fatalf("RunAutoUpdate returned error: %v", err)
}
if git.pullCalls != 0 {
t.Fatalf("pull calls = %d, want 0", git.pullCalls)
}
reloaded, err := svc.store.Load()
if err != nil {
t.Fatal(err)
}
saved, _ := skillstore.FindSkill(reloaded, "skills", "demo")
if saved.LastError != "local changes present" {
t.Fatalf("LastError = %q", saved.LastError)
}
}
func newTestService(t *testing.T) (*Service, Paths, *fakeSecretStore, *fakeRemoteClient, *fakeGit, *fakeTargets) {
t.Helper()
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, "config.json"),
StatePath: filepath.Join(dir, "state.json"),
ReposRoot: filepath.Join(dir, "repos"),
HomeDir: filepath.Join(dir, "home"),
}
secrets := &fakeSecretStore{values: map[string]string{}}
remote := &fakeRemoteClient{}
git := &fakeGit{dirty: map[string]bool{}, currentCommit: map[string]string{}, remoteCommit: map[string]string{}}
target := &fakeTargets{}
svc := New(Options{
Paths: paths,
Secrets: secrets,
Git: git,
Targets: target,
RemoteMaker: func(context.Context, domain.Config, string) (RemoteClient, error) { return remote, nil },
Opener: fakeOpener{},
})
return svc, paths, secrets, remote, git, target
}
func saveValidConfig(t *testing.T, svc *Service) {
t.Helper()
req := domain.SaveConfigRequest{
Config: domain.Config{
Gitea: domain.GiteaConfig{
BaseURL: "https://gitea.example.com",
Org: "skills",
AuthType: "password",
Username: "alice",
CredentialKey: "sgg-ai-skill-manager:gitea",
},
Update: domain.UpdateConfig{AutoUpdate: true, CheckOnStartup: true, IntervalMinutes: 60},
},
Password: "secret",
}
if err := svc.SaveConfig(context.Background(), req); err != nil {
t.Fatal(err)
}
}
func stringContains(text, needle string) bool {
return strings.Contains(text, needle)
}
type fakeSecretStore struct {
values map[string]string
}
func (f *fakeSecretStore) Set(service, user, secret string) error {
f.values[service+"/"+user] = secret
return nil
}
func (f *fakeSecretStore) Get(service, user string) (string, error) {
return f.values[service+"/"+user], nil
}
func (f *fakeSecretStore) Delete(service, user string) error {
delete(f.values, service+"/"+user)
return nil
}
type fakeRemoteClient struct {
skills []domain.RemoteSkill
}
func (f *fakeRemoteClient) ListOrgSkills(context.Context, string) ([]domain.RemoteSkill, error) {
return f.skills, nil
}
func (f *fakeRemoteClient) TestConnection(context.Context, string) (domain.TestConnectionResult, error) {
return domain.TestConnectionResult{OK: true, Username: "alice", Org: "skills"}, nil
}
type fakeGit struct {
dirty map[string]bool
currentCommit map[string]string
remoteCommit map[string]string
pullCalls int
}
func (f *fakeGit) Clone(context.Context, string, string, gitops.Credentials) error { return nil }
func (f *fakeGit) Pull(context.Context, string, gitops.Credentials) error {
f.pullCalls++
return nil
}
func (f *fakeGit) CurrentCommit(path string) (string, error) {
if commit := f.currentCommit[path]; commit != "" {
return commit, nil
}
return "current", nil
}
func (f *fakeGit) RemoteCommit(_ context.Context, path, _ string, _ gitops.Credentials) (string, error) {
return f.remoteCommit[path], nil
}
func (f *fakeGit) HasLocalChanges(path string) (bool, error) {
return f.dirty[path], nil
}
type fakeTargets struct {
installResult domain.SkillState
uninstallCalls int
}
func (f *fakeTargets) Install(domain.SkillState, string) (domain.SkillState, error) {
return f.installResult, nil
}
func (f *fakeTargets) Uninstall(skill domain.SkillState, targetID string) (domain.SkillState, error) {
f.uninstallCalls++
delete(skill.InstalledTargets, targetID)
return skill, nil
}
func (f *fakeTargets) Status(domain.SkillState, string) string {
return "not_installed"
}
type fakeOpener struct{}
func (fakeOpener) OpenVSCode(string) error { return nil }
func (fakeOpener) OpenFolder(string) error { return nil }

View File

@@ -0,0 +1,32 @@
package service
import (
"context"
"time"
)
func (s *Service) StartAutoUpdate(ctx context.Context) {
go func() {
cfg, err := s.LoadConfig()
if err != nil {
return
}
if cfg.Update.CheckOnStartup {
_ = s.RunAutoUpdate(ctx)
}
interval := cfg.Update.IntervalMinutes
if interval <= 0 {
interval = 60
}
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = s.RunAutoUpdate(ctx)
}
}
}()
}