feat: implement Milestone 3 integration points
Add comprehensive integration capabilities to Pop CLI: - Multi-account support with named profiles - Webhook management with signature verification - External PGP key management (import/export/encrypt/decrypt/sign/verify) - CLI plugin system for extensibility - Complete documentation in README.md All compilation errors fixed and build verified CLEAN. Security review delegated to FRE-5202. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
291
internal/plugin/plugin.go
Normal file
291
internal/plugin/plugin.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/frenocorp/pop/internal/config"
|
||||
)
|
||||
|
||||
// Plugin represents a CLI plugin that can extend Pop's functionality.
|
||||
type Plugin struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Binary string `json:"binary"`
|
||||
InstalledAt string `json:"installed_at,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Commands []PluginCommand `json:"commands,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// PluginCommand represents a command exposed by a plugin.
|
||||
type PluginCommand struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// PluginRegistry manages installed and available plugins.
|
||||
type PluginRegistry struct {
|
||||
configDir string
|
||||
pluginsDir string
|
||||
registryFile string
|
||||
}
|
||||
|
||||
// NewPluginRegistry creates a new plugin registry.
|
||||
func NewPluginRegistry() (*PluginRegistry, error) {
|
||||
cfg := config.NewConfigManager()
|
||||
configDir := cfg.ConfigDir()
|
||||
|
||||
pluginsDir := filepath.Join(configDir, "plugins")
|
||||
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
}
|
||||
|
||||
return &PluginRegistry{
|
||||
configDir: configDir,
|
||||
pluginsDir: pluginsDir,
|
||||
registryFile: filepath.Join(configDir, "plugins.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListPlugins returns all installed plugins.
|
||||
func (pr *PluginRegistry) ListPlugins() ([]Plugin, error) {
|
||||
data, err := os.ReadFile(pr.registryFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Plugin{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read plugins registry: %w", err)
|
||||
}
|
||||
|
||||
var plugins []Plugin
|
||||
if err := json.Unmarshal(data, &plugins); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse plugins registry: %w", err)
|
||||
}
|
||||
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// GetPlugin retrieves a plugin by name.
|
||||
func (pr *PluginRegistry) GetPlugin(name string) (*Plugin, error) {
|
||||
plugins, err := pr.ListPlugins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range plugins {
|
||||
if p.Name == name {
|
||||
return &p, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
// InstallPlugin installs a plugin binary and registers it.
|
||||
func (pr *PluginRegistry) InstallPlugin(plugin Plugin) error {
|
||||
if plugin.Name == "" {
|
||||
return fmt.Errorf("plugin name is required")
|
||||
}
|
||||
if plugin.Binary == "" {
|
||||
return fmt.Errorf("plugin binary path is required")
|
||||
}
|
||||
|
||||
plugins, err := pr.ListPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range plugins {
|
||||
if p.Name == plugin.Name {
|
||||
return fmt.Errorf("plugin %q is already installed (use --force to reinstall)", plugin.Name)
|
||||
}
|
||||
}
|
||||
|
||||
plugin.Binary = filepath.Join(pr.pluginsDir, plugin.Binary)
|
||||
plugin.InstalledAt = plugin.InstalledAt
|
||||
|
||||
if err := os.MkdirAll(pr.pluginsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create plugins directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(plugin.Binary, 0755); err != nil {
|
||||
return fmt.Errorf("failed to set executable permission: %w", err)
|
||||
}
|
||||
|
||||
plugins = append(plugins, plugin)
|
||||
data, err := json.MarshalIndent(plugins, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal plugins registry: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(pr.registryFile, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write plugins registry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallPlugin removes a plugin.
|
||||
func (pr *PluginRegistry) UninstallPlugin(name string) error {
|
||||
plugins, err := pr.ListPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pluginToRemove *Plugin
|
||||
newPlugins := make([]Plugin, 0, len(plugins))
|
||||
for _, p := range plugins {
|
||||
if p.Name == name {
|
||||
pluginToRemove = &p
|
||||
continue
|
||||
}
|
||||
newPlugins = append(newPlugins, p)
|
||||
}
|
||||
|
||||
if pluginToRemove == nil {
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
if pluginToRemove.Binary != "" {
|
||||
os.Remove(pluginToRemove.Binary)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(newPlugins, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal plugins registry: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(pr.registryFile, data, 0600)
|
||||
}
|
||||
|
||||
// ExecutePlugin runs a plugin with the given arguments.
|
||||
func (pr *PluginRegistry) ExecutePlugin(name string, args []string) error {
|
||||
plugin, err := pr.GetPlugin(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(plugin.Binary); err != nil {
|
||||
return fmt.Errorf("plugin binary not found at %s: %w", plugin.Binary, err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(plugin.Binary, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
"POP_PLUGIN_NAME="+plugin.Name,
|
||||
"POP_PLUGIN_VERSION="+plugin.Version,
|
||||
"POP_CONFIG_DIR="+pr.configDir,
|
||||
)
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// DiscoverPlugins scans the plugins directory for unregistered binaries.
|
||||
func (pr *PluginRegistry) DiscoverPlugins() ([]string, error) {
|
||||
entries, err := os.ReadDir(pr.pluginsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var binaries []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if runtime.GOOS == "windows" && !strings.HasSuffix(entry.Name(), ".exe") {
|
||||
continue
|
||||
}
|
||||
if runtime.GOOS != "windows" && strings.HasSuffix(entry.Name(), ".exe") {
|
||||
continue
|
||||
}
|
||||
binaries = append(binaries, entry.Name())
|
||||
}
|
||||
|
||||
return binaries, nil
|
||||
}
|
||||
|
||||
// PluginBinaryPath returns the expected binary path for a plugin name.
|
||||
func (pr *PluginRegistry) PluginBinaryPath(name string) string {
|
||||
binary := "pop-" + name
|
||||
if runtime.GOOS == "windows" {
|
||||
binary += ".exe"
|
||||
}
|
||||
return filepath.Join(pr.pluginsDir, binary)
|
||||
}
|
||||
|
||||
// PluginsDir returns the plugins directory path.
|
||||
func (pr *PluginRegistry) PluginsDir() string {
|
||||
return pr.pluginsDir
|
||||
}
|
||||
|
||||
// EnablePlugin enables a plugin by name.
|
||||
func (pr *PluginRegistry) EnablePlugin(name string) error {
|
||||
plugins, err := pr.ListPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, p := range plugins {
|
||||
if p.Name == name {
|
||||
plugins[i].Enabled = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(plugins, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal plugins registry: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(pr.registryFile, data, 0600)
|
||||
}
|
||||
|
||||
// DisablePlugin disables a plugin by name.
|
||||
func (pr *PluginRegistry) DisablePlugin(name string) error {
|
||||
plugins, err := pr.ListPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, p := range plugins {
|
||||
if p.Name == name {
|
||||
plugins[i].Enabled = false
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %q not found", name)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(plugins, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal plugins registry: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(pr.registryFile, data, 0600)
|
||||
}
|
||||
Reference in New Issue
Block a user