Files

510 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
}
secret, err := s.resolveSecret(req)
if err != nil {
return domain.TestConnectionResult{}, err
}
client, err := s.remoteMaker(ctx, cfg, secret)
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
}
commit, err := s.git.CurrentCommit(skill.LocalPath)
if err != nil {
skill.LastError = err.Error()
state.Skills[i] = skill
continue
}
skill.CurrentCommit = commit
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 (s *Service) resolveSecret(req domain.SaveConfigRequest) (string, error) {
if secret := requestSecret(req); secret != "" {
return secret, nil
}
return s.secrets.Get(req.Config.Gitea.CredentialKey, credentialUser(req.Config))
}
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()
}