227 lines
6.8 KiB
Go
227 lines
6.8 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
filepath "dappco.re/go/core/scm/internal/ax/filepathx"
|
|
fmt "dappco.re/go/core/scm/internal/ax/fmtx"
|
|
strings "dappco.re/go/core/scm/internal/ax/stringsx"
|
|
exec "golang.org/x/sys/execabs"
|
|
"net/url"
|
|
"time"
|
|
|
|
"dappco.re/go/core/io"
|
|
coreerr "dappco.re/go/core/log"
|
|
"dappco.re/go/core/scm/agentci"
|
|
)
|
|
|
|
// Installer handles plugin installation from GitHub.
|
|
type Installer struct {
|
|
medium io.Medium
|
|
registry *Registry
|
|
}
|
|
|
|
// NewInstaller creates a new plugin installer.
|
|
func NewInstaller(m io.Medium, registry *Registry) *Installer {
|
|
return &Installer{
|
|
medium: m,
|
|
registry: registry,
|
|
}
|
|
}
|
|
|
|
// Install downloads and installs a plugin from GitHub.
|
|
// The source format is "org/repo" or "org/repo@version".
|
|
func (i *Installer) Install(ctx context.Context, source string) error {
|
|
org, repo, version, err := ParseSource(source)
|
|
if err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "invalid source", err)
|
|
}
|
|
|
|
// Check if already installed
|
|
if _, exists := i.registry.Get(repo); exists {
|
|
return coreerr.E("plugin.Installer.Install", "plugin already installed: "+repo, nil)
|
|
}
|
|
|
|
// Clone the repository
|
|
_, pluginDir, err := i.resolvePluginPath(repo)
|
|
if err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "invalid plugin path", err)
|
|
}
|
|
if err := i.medium.EnsureDir(pluginDir); err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "failed to create plugin directory", err)
|
|
}
|
|
|
|
if err := i.cloneRepo(ctx, org, repo, version, pluginDir); err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "failed to clone repository", err)
|
|
}
|
|
|
|
// Load and validate manifest
|
|
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
|
manifest, err := LoadManifest(i.medium, manifestPath)
|
|
if err != nil {
|
|
// Clean up on failure
|
|
_ = i.medium.DeleteAll(pluginDir)
|
|
return coreerr.E("plugin.Installer.Install", "failed to load manifest", err)
|
|
}
|
|
|
|
if err := manifest.Validate(); err != nil {
|
|
_ = i.medium.DeleteAll(pluginDir)
|
|
return coreerr.E("plugin.Installer.Install", "invalid manifest", err)
|
|
}
|
|
|
|
// Resolve version
|
|
if version == "" {
|
|
version = manifest.Version
|
|
}
|
|
|
|
// Register in the registry
|
|
cfg := &PluginConfig{
|
|
Name: manifest.Name,
|
|
Version: version,
|
|
Source: fmt.Sprintf("github:%s/%s", org, repo),
|
|
Enabled: true,
|
|
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if err := i.registry.Add(cfg); err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "failed to register plugin", err)
|
|
}
|
|
|
|
if err := i.registry.Save(); err != nil {
|
|
return coreerr.E("plugin.Installer.Install", "failed to save registry", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Update updates a plugin to the latest version.
|
|
func (i *Installer) Update(ctx context.Context, name string) error {
|
|
safeName, pluginDir, err := i.resolvePluginPath(name)
|
|
if err != nil {
|
|
return coreerr.E("plugin.Installer.Update", "invalid plugin name", err)
|
|
}
|
|
|
|
cfg, ok := i.registry.Get(safeName)
|
|
if !ok {
|
|
return coreerr.E("plugin.Installer.Update", "plugin not found: "+safeName, nil)
|
|
}
|
|
|
|
// Pull latest changes
|
|
cmd := exec.CommandContext(ctx, "git", "-C", pluginDir, "pull", "--ff-only")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return coreerr.E("plugin.Installer.Update", "failed to pull updates: "+strings.TrimSpace(string(output)), err)
|
|
}
|
|
|
|
// Reload manifest to get updated version
|
|
manifestPath := filepath.Join(pluginDir, "plugin.json")
|
|
manifest, err := LoadManifest(i.medium, manifestPath)
|
|
if err != nil {
|
|
return coreerr.E("plugin.Installer.Update", "failed to read updated manifest", err)
|
|
}
|
|
|
|
// Update registry
|
|
cfg.Version = manifest.Version
|
|
if err := i.registry.Save(); err != nil {
|
|
return coreerr.E("plugin.Installer.Update", "failed to save registry", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Remove uninstalls a plugin by removing its files and registry entry.
|
|
func (i *Installer) Remove(name string) error {
|
|
safeName, pluginDir, err := i.resolvePluginPath(name)
|
|
if err != nil {
|
|
return coreerr.E("plugin.Installer.Remove", "invalid plugin name", err)
|
|
}
|
|
|
|
if _, ok := i.registry.Get(safeName); !ok {
|
|
return coreerr.E("plugin.Installer.Remove", "plugin not found: "+safeName, nil)
|
|
}
|
|
|
|
// Delete plugin directory
|
|
if i.medium.Exists(pluginDir) {
|
|
if err := i.medium.DeleteAll(pluginDir); err != nil {
|
|
return coreerr.E("plugin.Installer.Remove", "failed to delete plugin files", err)
|
|
}
|
|
}
|
|
|
|
// Remove from registry
|
|
if err := i.registry.Remove(safeName); err != nil {
|
|
return coreerr.E("plugin.Installer.Remove", "failed to unregister plugin", err)
|
|
}
|
|
|
|
if err := i.registry.Save(); err != nil {
|
|
return coreerr.E("plugin.Installer.Remove", "failed to save registry", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cloneRepo clones a GitHub repository using the gh CLI.
|
|
func (i *Installer) cloneRepo(ctx context.Context, org, repo, version, dest string) error {
|
|
repoURL := fmt.Sprintf("%s/%s", org, repo)
|
|
|
|
args := []string{"repo", "clone", repoURL, dest}
|
|
if version != "" {
|
|
args = append(args, "--", "--branch", version)
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "gh", args...)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
return coreerr.E("plugin.Installer.cloneRepo", strings.TrimSpace(string(output)), err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ParseSource parses a plugin source string into org, repo, and version.
|
|
// Accepted formats:
|
|
// - "org/repo" -> org="org", repo="repo", version=""
|
|
// - "org/repo@v1.0" -> org="org", repo="repo", version="v1.0"
|
|
func ParseSource(source string) (org, repo, version string, err error) {
|
|
source, err = url.PathUnescape(source)
|
|
if err != nil {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "invalid source path", err)
|
|
}
|
|
if source == "" {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "source is empty", nil)
|
|
}
|
|
|
|
// Split off version if present
|
|
atIdx := strings.LastIndex(source, "@")
|
|
path := source
|
|
if atIdx != -1 {
|
|
path = source[:atIdx]
|
|
version = source[atIdx+1:]
|
|
if version == "" {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "version is empty after @", nil)
|
|
}
|
|
}
|
|
|
|
// Split org/repo
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "source must be in format org/repo[@version]", nil)
|
|
}
|
|
|
|
org, err = agentci.ValidatePathElement(parts[0])
|
|
if err != nil {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "invalid org", err)
|
|
}
|
|
repo, err = agentci.ValidatePathElement(parts[1])
|
|
if err != nil {
|
|
return "", "", "", coreerr.E("plugin.ParseSource", "invalid repo", err)
|
|
}
|
|
|
|
return org, repo, version, nil
|
|
}
|
|
|
|
func (i *Installer) resolvePluginPath(name string) (string, string, error) {
|
|
safeName, path, err := agentci.ResolvePathWithinRoot(i.registry.basePath, name)
|
|
if err != nil {
|
|
return "", "", coreerr.E("plugin.Installer.resolvePluginPath", "resolve plugin path", err)
|
|
}
|
|
return safeName, path, nil
|
|
}
|