497 lines
13 KiB
Go
497 lines
13 KiB
Go
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()
|
|
}
|