From 1ea8f2b38aace037092457804d0653ddb52db404 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Wed, 9 Jul 2025 14:01:23 +0200 Subject: [PATCH] Add enable/disable/reset commands --- CLAUDE.md | 10 ++- client.go | 102 ++++++++++++++++++++++++ cmd/pluginctl/main.go | 179 +++++++----------------------------------- disable.go | 23 ++++++ enable.go | 23 ++++++ info.go | 2 +- info_test.go | 2 +- plugin.go | 2 +- plugin_test.go | 2 +- reset.go | 25 ++++++ version.go | 14 ++-- 11 files changed, 221 insertions(+), 163 deletions(-) create mode 100644 client.go create mode 100644 disable.go create mode 100644 enable.go create mode 100644 reset.go diff --git a/CLAUDE.md b/CLAUDE.md index 059215e..73ce74b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,18 +22,22 @@ pluginctl/ #### 1. **Separation of Concerns** - **CLI Framework**: `cmd/pluginctl/main.go` handles argument parsing, command routing, and error handling -- **Command Implementation**: Each command gets its own file (e.g., `info.go`, `build.go`, `deploy.go`) +- **Command Implementation**: Each command gets its own file (e.g., `info.go`, `build.go`, `deploy.go`) **IN THE ROOT FOLDER, NOT IN cmd/pluginctl/** - **Utility Functions**: Common plugin operations in `plugin.go` +**CRITICAL ARCHITECTURE RULE**: ALL COMMAND LOGIC MUST BE IN SEPARATE FILES IN THE ROOT FOLDER. The cmd/pluginctl/main.go file should ONLY contain CLI framework code (argument parsing, command routing, wrapper functions). Never put command implementation logic directly in main.go. + #### 2. **Plugin Manifest Handling** - **Always use official Mattermost types**: Import `github.com/mattermost/mattermost/server/public/model` and use `model.Manifest` - **Validation**: Always validate plugin.json existence and format before operations - **Path handling**: Support both current directory and custom path operations #### 3. **Command Structure** -- **Main command router**: Add new commands to the `runCommand()` function in `main.go` -- **Command functions**: Name pattern: `run[Command]Command(args []string) error` +- **Main command router**: Add new commands to the `runCommand()` function in `cmd/pluginctl/main.go` +- **Command functions**: Name pattern: `run[Command]Command(args []string, pluginPath string) error` - **Error handling**: Return descriptive errors, let main.go handle exit codes +- **Command implementation**: Each command's logic goes in a separate file in the root folder (e.g., `enable.go`, `disable.go`, `reset.go`) +- **Command wrapper functions**: The main.go file contains simple wrapper functions that call the actual command implementations #### 4. **Code Organization** - **No inline implementations**: Keep command logic in separate files diff --git a/client.go b/client.go new file mode 100644 index 0000000..a5e3707 --- /dev/null +++ b/client.go @@ -0,0 +1,102 @@ +package pluginctl + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "os" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +const commandTimeout = 120 * time.Second + +func getClient(ctx context.Context) (*model.Client4, error) { + socketPath := os.Getenv("MM_LOCALSOCKETPATH") + if socketPath == "" { + socketPath = model.LocalModeSocketPath + } + + client, connected := getUnixClient(socketPath) + if connected { + log.Printf("Connecting using local mode over %s", socketPath) + + return client, nil + } + + if os.Getenv("MM_LOCALSOCKETPATH") != "" { + log.Printf("No socket found at %s for local mode deployment. "+ + "Attempting to authenticate with credentials.", socketPath) + } + + siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL") + adminToken := os.Getenv("MM_ADMIN_TOKEN") + adminUsername := os.Getenv("MM_ADMIN_USERNAME") + adminPassword := os.Getenv("MM_ADMIN_PASSWORD") + + if siteURL == "" { + return nil, errors.New("MM_SERVICESETTINGS_SITEURL is not set") + } + + client = model.NewAPIv4Client(siteURL) + + if adminToken != "" { + log.Printf("Authenticating using token against %s.", siteURL) + client.SetToken(adminToken) + + return client, nil + } + + if adminUsername != "" && adminPassword != "" { + client := model.NewAPIv4Client(siteURL) + log.Printf("Authenticating as %s against %s.", adminUsername, siteURL) + _, _, err := client.Login(ctx, adminUsername, adminPassword) + if err != nil { + return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, err) + } + + return client, nil + } + + return nil, errors.New("one of MM_ADMIN_TOKEN or MM_ADMIN_USERNAME/MM_ADMIN_PASSWORD must be defined") +} + +func getUnixClient(socketPath string) (*model.Client4, bool) { + _, err := net.Dial("unix", socketPath) + if err != nil { + return nil, false + } + + return model.NewAPIv4SocketClient(socketPath), true +} + +// runPluginCommand executes a plugin command using the plugin from the current folder. +func runPluginCommand( + _ []string, + pluginPath string, + action func(context.Context, *model.Client4, string) error, +) error { + // Load plugin manifest to get the plugin ID + manifest, err := LoadPluginManifestFromPath(pluginPath) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) + } + + pluginID := manifest.Id + if pluginID == "" { + return errors.New("plugin ID not found in manifest") + } + + ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) + defer cancel() + + client, err := getClient(ctx) + if err != nil { + return err + } + + return action(ctx, client, pluginID) +} diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 9e265ff..552c2c4 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -1,14 +1,11 @@ package main import ( - "encoding/json" "flag" "fmt" "os" - "path/filepath" - "runtime/debug" - "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/pluginctl" ) const ( @@ -34,7 +31,7 @@ func main() { commandArgs := args[1:] // Determine plugin path from flag, environment variable, or current directory - effectivePluginPath := getEffectivePluginPath(pluginPath) + effectivePluginPath := pluginctl.GetEffectivePluginPath(pluginPath) if err := runCommand(command, commandArgs, effectivePluginPath); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -46,6 +43,12 @@ func runCommand(command string, args []string, pluginPath string) error { switch command { case "info": return runInfoCommand(args, pluginPath) + case "enable": + return runEnableCommand(args, pluginPath) + case "disable": + return runDisableCommand(args, pluginPath) + case "reset": + return runResetCommand(args, pluginPath) case "help": showUsage() @@ -58,160 +61,25 @@ func runCommand(command string, args []string, pluginPath string) error { } func runInfoCommand(args []string, pluginPath string) error { - // Convert to absolute path - absPath, err := filepath.Abs(pluginPath) - if err != nil { - return fmt.Errorf("failed to resolve path: %w", err) - } - - return infoCommandWithPath(absPath) + return pluginctl.RunInfoCommand(args, pluginPath) } -func runVersionCommand(args []string) error { - version := getVersion() +func runVersionCommand(_ []string) error { + version := pluginctl.GetVersion() fmt.Printf("pluginctl version %s\n", version) return nil } - -// getVersion returns the version information from build info. -func getVersion() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return "unknown" - } - - // First try to get version from main module - if info.Main.Version != "" && info.Main.Version != "(devel)" { - return info.Main.Version - } - - // Look for version in build settings (set by goreleaser) - for _, setting := range info.Settings { - if setting.Key == "vcs.revision" { - // Return short commit hash if no version tag - if len(setting.Value) >= 7 { - return setting.Value[:7] - } - return setting.Value - } - } - - return "dev" +func runEnableCommand(args []string, pluginPath string) error { + return pluginctl.RunEnableCommand(args, pluginPath) } -// getEffectivePluginPath determines the plugin path from flag, environment variable, or current directory. -func getEffectivePluginPath(flagPath string) string { - // Priority: 1. Command line flag, 2. Environment variable, 3. Current directory - if flagPath != "" { - return flagPath - } - - if envPath := os.Getenv(EnvPluginPath); envPath != "" { - return envPath - } - - // Default to current directory - cwd, err := os.Getwd() - if err != nil { - return "." - } - - return cwd +func runDisableCommand(args []string, pluginPath string) error { + return pluginctl.RunDisableCommand(args, pluginPath) } -func infoCommandWithPath(path string) error { - manifest, err := loadPluginManifestFromPath(path) - if err != nil { - return fmt.Errorf("failed to load plugin manifest from %s: %w", path, err) - } - - return printPluginInfo(manifest) -} - -func loadPluginManifestFromPath(dir string) (*model.Manifest, error) { - manifestPath := filepath.Join(dir, "plugin.json") - - if _, err := os.Stat(manifestPath); os.IsNotExist(err) { - return nil, fmt.Errorf("plugin.json not found in directory %s", dir) - } - - data, err := os.ReadFile(manifestPath) - if err != nil { - return nil, fmt.Errorf("failed to read plugin.json: %w", err) - } - - var manifest model.Manifest - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, fmt.Errorf("failed to parse plugin.json: %w", err) - } - - return &manifest, nil -} - -func printPluginInfo(manifest *model.Manifest) error { - fmt.Printf("Plugin Information:\n") - fmt.Printf("==================\n\n") - - fmt.Printf("ID: %s\n", manifest.Id) - fmt.Printf("Name: %s\n", manifest.Name) - fmt.Printf("Version: %s\n", manifest.Version) - - if manifest.MinServerVersion != "" { - fmt.Printf("Min MM Version: %s\n", manifest.MinServerVersion) - } else { - fmt.Printf("Min MM Version: Not specified\n") - } - - if manifest.Description != "" { - fmt.Printf("Description: %s\n", manifest.Description) - } - - fmt.Printf("\nCode Components:\n") - fmt.Printf("================\n") - - if hasServerCode(manifest) { - fmt.Printf("Server Code: Yes\n") - if manifest.Server != nil && len(manifest.Server.Executables) > 0 { - fmt.Printf(" Executables: ") - first := true - for platform := range manifest.Server.Executables { - if !first { - fmt.Printf(", ") - } - fmt.Printf("%s", platform) - first = false - } - fmt.Printf("\n") - } - } else { - fmt.Printf("Server Code: No\n") - } - - if hasWebappCode(manifest) { - fmt.Printf("Webapp Code: Yes\n") - if manifest.Webapp != nil && manifest.Webapp.BundlePath != "" { - fmt.Printf(" Bundle Path: %s\n", manifest.Webapp.BundlePath) - } - } else { - fmt.Printf("Webapp Code: No\n") - } - - if manifest.SettingsSchema != nil { - fmt.Printf("Settings Schema: Yes\n") - } else { - fmt.Printf("Settings Schema: No\n") - } - - return nil -} - -func hasServerCode(manifest *model.Manifest) bool { - return manifest.Server != nil && len(manifest.Server.Executables) > 0 -} - -func hasWebappCode(manifest *model.Manifest) bool { - return manifest.Webapp != nil && manifest.Webapp.BundlePath != "" +func runResetCommand(args []string, pluginPath string) error { + return pluginctl.RunResetCommand(args, pluginPath) } func showUsage() { @@ -225,18 +93,29 @@ Global Options: Commands: info Display plugin information + enable Enable plugin from current directory in Mattermost server + disable Disable plugin from current directory in Mattermost server + reset Reset plugin from current directory (disable then enable) help Show this help message version Show version information Examples: pluginctl info # Show info for plugin in current directory pluginctl --plugin-path /path/to/plugin info # Show info for plugin at specific path + pluginctl enable # Enable plugin from current directory + pluginctl disable # Disable plugin from current directory + pluginctl reset # Reset plugin from current directory (disable then enable) export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info # Show info using environment variable pluginctl version # Show version information Environment Variables: - PLUGINCTL_PLUGIN_PATH Default plugin directory path + PLUGINCTL_PLUGIN_PATH Default plugin directory path + MM_LOCALSOCKETPATH Path to Mattermost local socket + MM_SERVICESETTINGS_SITEURL Mattermost server URL + MM_ADMIN_TOKEN Admin token for authentication + MM_ADMIN_USERNAME Admin username for authentication + MM_ADMIN_PASSWORD Admin password for authentication For more information about Mattermost plugin development, visit: https://developers.mattermost.com/integrate/plugins/ diff --git a/disable.go b/disable.go new file mode 100644 index 0000000..ce43eb6 --- /dev/null +++ b/disable.go @@ -0,0 +1,23 @@ +package pluginctl + +import ( + "context" + "fmt" + "log" + + "github.com/mattermost/mattermost/server/public/model" +) + +func RunDisableCommand(args []string, pluginPath string) error { + return runPluginCommand(args, pluginPath, disablePlugin) +} + +func disablePlugin(ctx context.Context, client *model.Client4, pluginID string) error { + log.Print("Disabling plugin.") + _, err := client.DisablePlugin(ctx, pluginID) + if err != nil { + return fmt.Errorf("failed to disable plugin: %w", err) + } + + return nil +} diff --git a/enable.go b/enable.go new file mode 100644 index 0000000..5f659d2 --- /dev/null +++ b/enable.go @@ -0,0 +1,23 @@ +package pluginctl + +import ( + "context" + "fmt" + "log" + + "github.com/mattermost/mattermost/server/public/model" +) + +func RunEnableCommand(args []string, pluginPath string) error { + return runPluginCommand(args, pluginPath, enablePlugin) +} + +func enablePlugin(ctx context.Context, client *model.Client4, pluginID string) error { + log.Print("Enabling plugin.") + _, err := client.EnablePlugin(ctx, pluginID) + if err != nil { + return fmt.Errorf("failed to enable plugin: %w", err) + } + + return nil +} diff --git a/info.go b/info.go index bb44943..e442f56 100644 --- a/info.go +++ b/info.go @@ -1,4 +1,4 @@ -package main +package pluginctl import ( "fmt" diff --git a/info_test.go b/info_test.go index 11f6260..fa0abde 100644 --- a/info_test.go +++ b/info_test.go @@ -1,4 +1,4 @@ -package main +package pluginctl import ( "bytes" diff --git a/plugin.go b/plugin.go index 302c3ba..7995333 100644 --- a/plugin.go +++ b/plugin.go @@ -1,4 +1,4 @@ -package main +package pluginctl import ( "encoding/json" diff --git a/plugin_test.go b/plugin_test.go index 9897296..9f143d9 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,4 +1,4 @@ -package main +package pluginctl import ( "encoding/json" diff --git a/reset.go b/reset.go new file mode 100644 index 0000000..03f4ac7 --- /dev/null +++ b/reset.go @@ -0,0 +1,25 @@ +package pluginctl + +import ( + "context" + + "github.com/mattermost/mattermost/server/public/model" +) + +func RunResetCommand(args []string, pluginPath string) error { + return runPluginCommand(args, pluginPath, resetPlugin) +} + +func resetPlugin(ctx context.Context, client *model.Client4, pluginID string) error { + err := disablePlugin(ctx, client, pluginID) + if err != nil { + return err + } + + err = enablePlugin(ctx, client, pluginID) + if err != nil { + return err + } + + return nil +} diff --git a/version.go b/version.go index 59d9ba8..f01127d 100644 --- a/version.go +++ b/version.go @@ -1,4 +1,4 @@ -package main +package pluginctl import ( "fmt" @@ -7,14 +7,14 @@ import ( // RunVersionCommand implements the 'version' command functionality. func RunVersionCommand(args []string) error { - version := getVersion() + version := GetVersion() fmt.Printf("pluginctl version %s\n", version) return nil } -// getVersion returns the version information from build info. -func getVersion() string { +// GetVersion returns the version information from build info. +func GetVersion() string { info, ok := debug.ReadBuildInfo() if !ok { return "unknown" @@ -29,9 +29,11 @@ func getVersion() string { for _, setting := range info.Settings { if setting.Key == "vcs.revision" { // Return short commit hash if no version tag - if len(setting.Value) >= 7 { - return setting.Value[:7] + const shortHashLength = 7 + if len(setting.Value) >= shortHashLength { + return setting.Value[:shortHashLength] } + return setting.Value } }