feat: add skill management service
This commit is contained in:
496
internal/service/service.go
Normal file
496
internal/service/service.go
Normal 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()
|
||||
}
|
||||
309
internal/service/service_test.go
Normal file
309
internal/service/service_test.go
Normal 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 }
|
||||
32
internal/service/updater.go
Normal file
32
internal/service/updater.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user