From 12a6a83840d659c590d1c84be9bb3aa7bc5d7e17 Mon Sep 17 00:00:00 2001 From: wdh-home <243823965@qq.com> Date: Wed, 13 May 2026 16:40:29 +0800 Subject: [PATCH] feat: add skill management service --- app.go | 121 +++++++- internal/service/service.go | 496 +++++++++++++++++++++++++++++++ internal/service/service_test.go | 309 +++++++++++++++++++ internal/service/updater.go | 32 ++ 4 files changed, 948 insertions(+), 10 deletions(-) create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go create mode 100644 internal/service/updater.go diff --git a/app.go b/app.go index af53038..c266398 100644 --- a/app.go +++ b/app.go @@ -2,26 +2,127 @@ package main import ( "context" - "fmt" + + "sgg-ai-skill-manager/internal/domain" + "sgg-ai-skill-manager/internal/service" ) -// App struct type App struct { - ctx context.Context + ctx context.Context + service *service.Service + initErr error } -// NewApp creates a new App application struct func NewApp() *App { - return &App{} + svc, err := service.NewDefault() + return &App{service: svc, initErr: err} } -// startup is called when the app starts. The context is saved -// so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx + if a.service != nil { + a.service.StartAutoUpdate(ctx) + } } -// Greet returns a greeting for the given name -func (a *App) Greet(name string) string { - return fmt.Sprintf("Hello %s, It's show time!", name) +func (a *App) LoadConfig() (domain.Config, error) { + if err := a.ready(); err != nil { + return domain.Config{}, err + } + return a.service.LoadConfig() +} + +func (a *App) SaveConfig(req domain.SaveConfigRequest) error { + if err := a.ready(); err != nil { + return err + } + return a.service.SaveConfig(a.context(), req) +} + +func (a *App) TestConnection(req domain.SaveConfigRequest) (domain.TestConnectionResult, error) { + if err := a.ready(); err != nil { + return domain.TestConnectionResult{}, err + } + return a.service.TestConnection(a.context(), req) +} + +func (a *App) ListRemoteSkills() ([]domain.RemoteSkill, error) { + if err := a.ready(); err != nil { + return nil, err + } + return a.service.ListRemoteSkills(a.context()) +} + +func (a *App) ListLocalSkills() ([]domain.SkillState, error) { + if err := a.ready(); err != nil { + return nil, err + } + return a.service.ListLocalSkills(a.context()) +} + +func (a *App) DownloadSkill(repo domain.RemoteSkill) (domain.SkillState, error) { + if err := a.ready(); err != nil { + return domain.SkillState{}, err + } + return a.service.DownloadSkill(a.context(), repo) +} + +func (a *App) UpdateSkill(org, repo string) (domain.SkillState, error) { + if err := a.ready(); err != nil { + return domain.SkillState{}, err + } + return a.service.UpdateSkill(a.context(), org, repo) +} + +func (a *App) InstallSkill(org, repo, targetID string) (domain.SkillState, error) { + if err := a.ready(); err != nil { + return domain.SkillState{}, err + } + return a.service.InstallSkill(a.context(), org, repo, targetID) +} + +func (a *App) UninstallSkill(org, repo, targetID string) (domain.SkillState, error) { + if err := a.ready(); err != nil { + return domain.SkillState{}, err + } + return a.service.UninstallSkill(a.context(), org, repo, targetID) +} + +func (a *App) DeleteSkill(org, repo string) error { + if err := a.ready(); err != nil { + return err + } + return a.service.DeleteSkill(a.context(), org, repo) +} + +func (a *App) OpenInVSCode(org, repo string) error { + if err := a.ready(); err != nil { + return err + } + return a.service.OpenInVSCode(a.context(), org, repo) +} + +func (a *App) OpenFolder(org, repo string) error { + if err := a.ready(); err != nil { + return err + } + return a.service.OpenFolder(a.context(), org, repo) +} + +func (a *App) RunAutoUpdate() error { + if err := a.ready(); err != nil { + return err + } + return a.service.RunAutoUpdate(a.context()) +} + +func (a *App) ready() error { + return a.initErr +} + +func (a *App) context() context.Context { + if a.ctx == nil { + return context.Background() + } + return a.ctx } diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..f5a2732 --- /dev/null +++ b/internal/service/service.go @@ -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() +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..85aa250 --- /dev/null +++ b/internal/service/service_test.go @@ -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 } diff --git a/internal/service/updater.go b/internal/service/updater.go new file mode 100644 index 0000000..dc2946b --- /dev/null +++ b/internal/service/updater.go @@ -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) + } + } + }() +}