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) }