From 73149001eb7e44691726ec201713155a0aafd7bf Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Wed, 9 Jul 2025 16:51:40 +0200 Subject: [PATCH 01/10] Replace logging system with slog and tint for structured colored output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add logger.go with public Logger variable and InitLogger function - Replace all fmt.Printf/fmt.Fprintf calls with structured Logger.Info/Logger.Error - Initialize logger in main function for consistent access across packages - Keep fmt.Errorf for proper error creation (standard Go practice) - Add tint dependency for colorized terminal output with timestamps - Convert user output to structured logging with key-value pairs - Update info command to use structured logging for plugin details - Update updateassets command to use structured progress logging - Update version command to use structured logging - Update authentication logging in client.go with structured fields - Update enable/disable commands to use structured logging - Remove unused fmt imports after conversion All output now uses slog with tint for beautiful, structured, colorized logging while maintaining proper error handling with fmt.Errorf for error creation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Makefile | 2 +- client.go | 10 ++++------ cmd/pluginctl/main.go | 14 +++++++++----- disable.go | 3 +-- enable.go | 3 +-- go.mod | 1 + go.sum | 2 ++ info.go | 45 +++++++++++++++++++------------------------ logger.go | 37 +++++++++++++++++++++++++++++++++++ updateassets.go | 6 +++--- version.go | 3 +-- 11 files changed, 80 insertions(+), 46 deletions(-) create mode 100644 logger.go diff --git a/Makefile b/Makefile index 42b82bd..ab8b81c 100644 --- a/Makefile +++ b/Makefile @@ -136,7 +136,7 @@ verify: clean lint test build ## Verify build (clean, lint, test, build) # Quick development build .PHONY: dev -dev: fmt lint build ## Quick development build (fmt, lint, build) +dev: fmt lint snapshot ## Quick development build (fmt, lint, build) # Check changes target .PHONY: check-changes diff --git a/client.go b/client.go index a5e3707..b006192 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "net" "os" "time" @@ -22,14 +21,13 @@ func getClient(ctx context.Context) (*model.Client4, error) { client, connected := getUnixClient(socketPath) if connected { - log.Printf("Connecting using local mode over %s", socketPath) + Logger.Info("Connecting using local mode", "socket_path", 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) + Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.", "socket_path", socketPath) } siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL") @@ -44,7 +42,7 @@ func getClient(ctx context.Context) (*model.Client4, error) { client = model.NewAPIv4Client(siteURL) if adminToken != "" { - log.Printf("Authenticating using token against %s.", siteURL) + Logger.Info("Authenticating using token", "site_url", siteURL) client.SetToken(adminToken) return client, nil @@ -52,7 +50,7 @@ func getClient(ctx context.Context) (*model.Client4, error) { if adminUsername != "" && adminPassword != "" { client := model.NewAPIv4Client(siteURL) - log.Printf("Authenticating as %s against %s.", adminUsername, siteURL) + Logger.Info("Authenticating with credentials", "username", adminUsername, "site_url", siteURL) _, _, err := client.Login(ctx, adminUsername, adminPassword) if err != nil { return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, err) diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 02a30f5..876ee59 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -15,6 +15,9 @@ const ( ) func main() { + // Initialize logger + pluginctl.InitLogger() + var pluginPath string flag.StringVar(&pluginPath, "plugin-path", "", "Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)") @@ -22,7 +25,7 @@ func main() { args := flag.Args() if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Error: No command specified\n\n") + pluginctl.Logger.Error("No command specified") showUsage() os.Exit(ExitError) } @@ -34,7 +37,7 @@ func main() { effectivePluginPath := pluginctl.GetEffectivePluginPath(pluginPath) if err := runCommand(command, commandArgs, effectivePluginPath); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) + pluginctl.Logger.Error("Command failed", "error", err) os.Exit(ExitError) } } @@ -68,7 +71,7 @@ func runInfoCommand(args []string, pluginPath string) error { func runVersionCommand(_ []string) error { version := pluginctl.GetVersion() - fmt.Printf("pluginctl version %s\n", version) + pluginctl.Logger.Info("pluginctl version", "version", version) return nil } @@ -89,7 +92,7 @@ func runUpdateAssetsCommand(args []string, pluginPath string) error { } func showUsage() { - fmt.Printf(`pluginctl - Mattermost Plugin Development CLI + usageText := `pluginctl - Mattermost Plugin Development CLI Usage: pluginctl [global options] [command options] [arguments...] @@ -127,5 +130,6 @@ Environment Variables: For more information about Mattermost plugin development, visit: https://developers.mattermost.com/integrate/plugins/ -`) +` + pluginctl.Logger.Info(usageText) } diff --git a/disable.go b/disable.go index ce43eb6..57cd2db 100644 --- a/disable.go +++ b/disable.go @@ -3,7 +3,6 @@ package pluginctl import ( "context" "fmt" - "log" "github.com/mattermost/mattermost/server/public/model" ) @@ -13,7 +12,7 @@ func RunDisableCommand(args []string, pluginPath string) error { } func disablePlugin(ctx context.Context, client *model.Client4, pluginID string) error { - log.Print("Disabling plugin.") + Logger.Info("Disabling plugin") _, err := client.DisablePlugin(ctx, pluginID) if err != nil { return fmt.Errorf("failed to disable plugin: %w", err) diff --git a/enable.go b/enable.go index 5f659d2..208e6c1 100644 --- a/enable.go +++ b/enable.go @@ -3,7 +3,6 @@ package pluginctl import ( "context" "fmt" - "log" "github.com/mattermost/mattermost/server/public/model" ) @@ -13,7 +12,7 @@ func RunEnableCommand(args []string, pluginPath string) error { } func enablePlugin(ctx context.Context, client *model.Client4, pluginID string) error { - log.Print("Enabling plugin.") + Logger.Info("Enabling plugin") _, err := client.EnablePlugin(ctx, pluginID) if err != nil { return fmt.Errorf("failed to enable plugin: %w", err) diff --git a/go.mod b/go.mod index a127a91..02d6ccf 100644 --- a/go.mod +++ b/go.mod @@ -303,6 +303,7 @@ require ( github.com/ldez/tagliatelle v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 // indirect + github.com/lmittmann/tint v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.1.3 // indirect github.com/mailru/easyjson v0.9.0 // indirect diff --git a/go.sum b/go.sum index 08913b7..86a9a75 100644 --- a/go.sum +++ b/go.sum @@ -870,6 +870,8 @@ github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84Yrj github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 h1:ndfLOJNaxu0fX358UKxtq2bU8IMASWi87Hn0Nv/TIoY= github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666/go.mod h1:WGXwLq/jKt0kng727wv6a0h0q7TVC+MwS2S75rcqL+4= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= diff --git a/info.go b/info.go index e442f56..f037e33 100644 --- a/info.go +++ b/info.go @@ -19,63 +19,58 @@ func InfoCommand() error { // PrintPluginInfo displays formatted plugin information. func PrintPluginInfo(manifest *model.Manifest) error { - fmt.Printf("Plugin Information:\n") - fmt.Printf("==================\n\n") + Logger.Info("Plugin Information:") + Logger.Info("==================") // Basic plugin info - fmt.Printf("ID: %s\n", manifest.Id) - fmt.Printf("Name: %s\n", manifest.Name) - fmt.Printf("Version: %s\n", manifest.Version) + Logger.Info("ID:", "value", manifest.Id) + Logger.Info("Name:", "value", manifest.Name) + Logger.Info("Version:", "value", manifest.Version) // Minimum Mattermost version if manifest.MinServerVersion != "" { - fmt.Printf("Min MM Version: %s\n", manifest.MinServerVersion) + Logger.Info("Min MM Version:", "value", manifest.MinServerVersion) } else { - fmt.Printf("Min MM Version: Not specified\n") + Logger.Info("Min MM Version:", "value", "Not specified") } // Description if available if manifest.Description != "" { - fmt.Printf("Description: %s\n", manifest.Description) + Logger.Info("Description:", "value", manifest.Description) } - fmt.Printf("\nCode Components:\n") - fmt.Printf("================\n") + Logger.Info("Code Components:") + Logger.Info("================") // Server code presence if HasServerCode(manifest) { - fmt.Printf("Server Code: Yes\n") + Logger.Info("Server Code:", "value", "Yes") if manifest.Server != nil && len(manifest.Server.Executables) > 0 { - fmt.Printf(" Executables: ") - first := true + var executables []string for platform := range manifest.Server.Executables { - if !first { - fmt.Printf(", ") - } - fmt.Printf("%s", platform) - first = false + executables = append(executables, platform) } - fmt.Printf("\n") + Logger.Info("Executables:", "platforms", executables) } } else { - fmt.Printf("Server Code: No\n") + Logger.Info("Server Code:", "value", "No") } // Webapp code presence if HasWebappCode(manifest) { - fmt.Printf("Webapp Code: Yes\n") + Logger.Info("Webapp Code:", "value", "Yes") if manifest.Webapp != nil && manifest.Webapp.BundlePath != "" { - fmt.Printf(" Bundle Path: %s\n", manifest.Webapp.BundlePath) + Logger.Info("Bundle Path:", "value", manifest.Webapp.BundlePath) } } else { - fmt.Printf("Webapp Code: No\n") + Logger.Info("Webapp Code:", "value", "No") } // Settings schema if manifest.SettingsSchema != nil { - fmt.Printf("Settings Schema: Yes\n") + Logger.Info("Settings Schema:", "value", "Yes") } else { - fmt.Printf("Settings Schema: No\n") + Logger.Info("Settings Schema:", "value", "No") } return nil diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..fffe857 --- /dev/null +++ b/logger.go @@ -0,0 +1,37 @@ +package pluginctl + +import ( + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" +) + +// Logger is the global logger instance +var Logger *slog.Logger + +// InitLogger initializes the global logger +func InitLogger() { + // Create a tint handler for colorized output + handler := tint.NewHandler(os.Stderr, &tint.Options{ + Level: slog.LevelInfo, + TimeFormat: time.Kitchen, + AddSource: false, + NoColor: false, + }) + + Logger = slog.New(handler) +} + +// SetLogLevel sets the minimum logging level +func SetLogLevel(level slog.Level) { + handler := tint.NewHandler(os.Stderr, &tint.Options{ + Level: level, + TimeFormat: time.Kitchen, + AddSource: false, + NoColor: false, + }) + + Logger = slog.New(handler) +} \ No newline at end of file diff --git a/updateassets.go b/updateassets.go index f9e3d03..5abfb2a 100644 --- a/updateassets.go +++ b/updateassets.go @@ -18,7 +18,7 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return fmt.Errorf("updateassets command does not accept arguments") } - fmt.Printf("Updating assets in plugin directory: %s\n", pluginPath) + Logger.Info("Updating assets in plugin directory", "path", pluginPath) // Load plugin manifest to check for webapp code manifest, err := LoadPluginManifestFromPath(pluginPath) @@ -82,7 +82,7 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { if err := os.WriteFile(targetPath, content, 0644); err != nil { return fmt.Errorf("failed to write file %s: %w", targetPath, err) } - fmt.Printf("Updated file: %s\n", relativePath) + Logger.Info("Updated file", "path", relativePath) updatedCount++ } @@ -93,6 +93,6 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return fmt.Errorf("failed to update assets: %w", err) } - fmt.Printf("Assets updated successfully! (%d files updated)\n", updatedCount) + Logger.Info("Assets updated successfully!", "files_updated", updatedCount) return nil } diff --git a/version.go b/version.go index f01127d..d07c573 100644 --- a/version.go +++ b/version.go @@ -1,14 +1,13 @@ package pluginctl import ( - "fmt" "runtime/debug" ) // RunVersionCommand implements the 'version' command functionality. func RunVersionCommand(args []string) error { version := GetVersion() - fmt.Printf("pluginctl version %s\n", version) + Logger.Info("pluginctl version", "version", version) return nil } From 4f0b94354c1bb946d02e2a5fdeffa99df0d129ce Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Wed, 9 Jul 2025 16:58:34 +0200 Subject: [PATCH 02/10] Compacted CLAUDE.md file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 281 +++++++----------------------------------------------- 1 file changed, 36 insertions(+), 245 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73ce74b..48781e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,261 +1,52 @@ -# pluginctl - Mattermost Plugin Development CLI +# pluginctl - Claude Memory -## Project Overview -`pluginctl` is a command-line interface tool for Mattermost plugin development. It provides utilities to manage, inspect, and work with Mattermost plugins from the command line. +## Critical Architecture Rules -## Architecture Guidelines +### Command Structure -### Project Structure -``` -pluginctl/ -├── cmd/pluginctl/main.go # CLI entrypoint with command routing -├── plugin.go # Plugin manifest handling utilities -├── info.go # Info command implementation -├── [command].go # Additional command implementations -├── go.mod # Go module definition -├── go.sum # Go module dependencies -├── pluginctl # Built binary (gitignored) -└── CLAUDE.md # This architecture document -``` +- **CRITICAL**: ALL command logic in separate files in ROOT folder (e.g., `info.go`, `enable.go`) +- **NEVER** put command implementation in `cmd/pluginctl/main.go` - only CLI framework code +- **Command pattern**: `run[Command]Command(args []string, pluginPath string) error` +- **Registration**: Add to `runCommand()` switch in main.go -### Design Principles +### Dependencies & Types -#### 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`) **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 `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 -- **Reusable utilities**: Common operations go in `plugin.go` -- **Self-contained**: Each command file should be importable and testable - -### Current Commands - -#### `info` -- **Purpose**: Display plugin manifest information -- **Implementation**: `info.go` -- **Usage**: `pluginctl info` -- **Features**: - - Shows plugin ID, name, version - - Displays minimum Mattermost version - - Indicates server/webapp code presence - - Shows settings schema availability -- **Path Resolution**: Uses global path logic (--plugin-path flag, environment variable, or current directory) - -### Adding New Commands - -#### Step 1: Create Command File -Create a new file named `[command].go` with the command implementation: - -```go -package main - -import ( - "fmt" - "github.com/mattermost/mattermost/server/public/model" -) - -func run[Command]Command(args []string, pluginPath string) error { - // Command implementation here - // Use pluginPath to load plugin manifest - return nil -} -``` - -#### Step 2: Register in Main Router -Add the command to the `runCommand()` function in `cmd/pluginctl/main.go`: - -```go -func runCommand(command string, args []string, pluginPath string) error { - switch command { - case "info": - return runInfoCommand(args, pluginPath) - case "new-command": - return runNewCommandCommand(args, pluginPath) - // ... other commands - } -} -``` - -#### Step 3: Update Help Text -Add the command to the `showUsage()` function in `main.go`. - -### Dependencies - -#### Core Dependencies -- `github.com/mattermost/mattermost/server/public/model` - Official Mattermost plugin types -- Standard Go library for CLI operations - -#### Dependency Management -- Use `go mod tidy` to manage dependencies -- Prefer standard library over external packages when possible -- Only add dependencies that provide significant value - -### Build System and Development Tools - -#### Tool Versions -The project uses pinned versions for reproducible builds: -- **golangci-lint**: v1.62.2 -- **goreleaser**: v2.6.2 -- **gosec**: v2.22.0 -- **Go**: 1.24.3 - -#### Makefile Targets - -**Development Workflow:** -- `make dev` - Quick development build (fmt, lint, build) -- `make check-changes` - Check changes (lint, security, test) -- `make verify` - Full verification (clean, lint, test, build) - -**Building:** -- `make build` - Build binary for current platform -- `make build-all` - Build for all supported platforms -- `make install` - Install binary to GOPATH/bin - -**Testing and Quality:** -- `make test` - Run tests -- `make test-coverage` - Run tests with coverage report -- `make lint` - Run linter -- `make lint-fix` - Fix linting issues automatically -- `make security` - Run security scan with gosec - -**Development Setup:** -- `make dev-setup` - Install all development tools with pinned versions -- `make deps` - Install/update dependencies -- `make fmt` - Format code - -**Release Management:** -- `make release` - Create production release (requires goreleaser) -- `make snapshot` - Create snapshot release for testing - -**Utilities:** -- `make clean` - Clean build artifacts -- `make version` - Show version and tool information -- `make help` - Show all available targets - -#### Configuration Files - -**Makefile** -- Uses `go get -tool` for Go 1.24+ tool management -- Cross-platform build support (Linux, macOS, Windows) -- Git-based version information in binaries - -**.goreleaser.yml** -- Multi-platform release automation -- GitHub releases with changelog generation -- Package manager integration (Homebrew, Scoop) -- Docker image building support - -**.golangci.yml** -- 40+ enabled linters for comprehensive code quality -- Optimized for Go 1.24 -- Security scanning integration -- Test file exclusions for appropriate linters - -#### Development Workflow - -1. **Setup**: `make dev-setup` (one-time) -2. **Development**: `make dev` (format, lint, build) -3. **Before commit**: `make check-changes` (lint, security, test) -4. **Full verification**: `make verify` (complete build verification) - -#### Building -```bash -# Quick build -make build - -# Cross-platform builds -make build-all - -# Development build with checks -make dev -``` - -#### Testing -- Always test with a sample plugin.json file -- Test both current directory and custom path operations -- Verify help and version commands work correctly -- Use `make test-coverage` for coverage reports - -### Error Handling Standards - -#### Error Messages -- Use descriptive error messages that help users understand what went wrong -- Include file paths in error messages when relevant -- Wrap errors with context using `fmt.Errorf("operation failed: %w", err)` - -#### Exit Codes -- `0`: Success -- `1`: General error -- Let main.go handle all exit codes - command functions should return errors +- Use `github.com/mattermost/mattermost/server/public/model.Manifest` +- Commands in `pluginctl` package, main.go calls them ### Plugin Path Resolution -#### Priority Order -1. **Command-line flag**: `--plugin-path /path/to/plugin` -2. **Environment variable**: `PLUGINCTL_PLUGIN_PATH=/path/to/plugin` -3. **Current directory**: Default fallback +1. `--plugin-path` flag +2. `PLUGINCTL_PLUGIN_PATH` env var +3. Current directory (default) -#### Implementation -- `getEffectivePluginPath(flagPath string) string` - Determines effective plugin path -- All commands receive the resolved plugin path as a parameter -- Path is resolved to absolute path before use +### Logging -### Plugin Validation +- **CRITICAL**: Use `pluginctl.Logger` (global slog instance) +- **Error**: `pluginctl.Logger.Error("message", "error", err)` +- **Info**: `pluginctl.Logger.Info("message")` +- **NEVER** use `fmt.Print*` or `log.*` -#### Required Checks -- Plugin.json file must exist -- Plugin.json must be valid JSON -- Plugin.json must conform to Mattermost manifest schema +### Build & Development -#### Utility Functions (plugin.go) -- `LoadPluginManifest()` - Load from current directory -- `LoadPluginManifestFromPath(path)` - Load from specific path -- `HasServerCode(manifest)` - Check for server-side code -- `HasWebappCode(manifest)` - Check for webapp code -- `IsValidPluginDirectory()` - Validate current directory +- **CRITICAL**: Use `make dev` for testing builds, NOT `go build` +- **Before commit**: `make check-changes` +- **Dependencies**: `make deps && go mod tidy` -### Future Command Ideas -- `init` - Initialize a new plugin project -- `build` - Build plugin for distribution -- `deploy` - Deploy plugin to Mattermost instance -- `validate` - Validate plugin structure and manifest -- `package` - Package plugin for distribution -- `test` - Run plugin tests +### Error Handling -### Version Management -- Current version: 0.1.0 -- Update version in `main.go` when releasing -- Follow semantic versioning +- Commands return errors, main.go handles exit codes +- Use `fmt.Errorf("context: %w", err)` for wrapping -### Documentation Maintenance -- **CRITICAL**: Always keep README.md up to date with any changes -- When adding new commands, update both CLAUDE.md and README.md -- When changing build processes, update both architecture docs and user docs -- When adding new dependencies or tools, document them in both files -- README.md is the user-facing documentation - it must be comprehensive and current +### Adding New Commands -### Notes for Claude Sessions -- Always maintain the separation between CLI framework and command implementation -- Use the official Mattermost model types - never create custom manifest structs -- Keep command implementations in separate files for maintainability -- Always validate plugin.json before performing operations -- Test new commands with the sample plugin.json file -- Follow the established error handling patterns -- Use the build system: `make check-changes` before any commits -- Use pinned tool versions for reproducible development environments \ No newline at end of file +1. Create `[command].go` in root with `Run[Command]Command` function +2. Add case to `runCommand()` switch in main.go +3. Update `showUsage()` in main.go + +### Key Patterns + +- Always validate plugin.json exists before operations +- Use structured logging with key-value pairs +- Follow existing naming conventions +- Keep command files self-contained and testable From 3cbe5e4a9f065fcfc565bf209746b2200ea89d44 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Wed, 9 Jul 2025 17:04:27 +0200 Subject: [PATCH 03/10] Compacted README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 6 ++ README.md | 190 +++++++----------------------------------------------- 2 files changed, 30 insertions(+), 166 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 48781e8..d63a2e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,3 +50,9 @@ - Use structured logging with key-value pairs - Follow existing naming conventions - Keep command files self-contained and testable + +### Documentation + +- **CRITICAL**: Keep README.md updated when adding new commands or changing functionality +- README.md should be user-facing - focus on usage, not implementation details +- Use `pluginctl --help` for command documentation, not hardcoded lists in README diff --git a/README.md b/README.md index aa60a0b..c731855 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ scoop install pluginctl # Display plugin information pluginctl info -# Show help +# Show help and available commands pluginctl --help # Show version @@ -50,13 +50,11 @@ pluginctl --version `pluginctl` supports multiple ways to specify the plugin directory: 1. **Command-line flag** (highest priority): - ```bash pluginctl --plugin-path /path/to/plugin info ``` 2. **Environment variable**: - ```bash export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info @@ -68,170 +66,55 @@ pluginctl --version pluginctl info ``` -### Commands +### Available Commands -#### `info` - -Display comprehensive information about a Mattermost plugin: - -```bash -pluginctl info -``` - -**Output includes:** - -- Plugin ID, name, and version -- Minimum Mattermost server version required -- Description (if available) -- Server code presence and supported platforms -- Webapp code presence and bundle path -- Settings schema availability - -**Example output:** - -``` -Plugin Information: -================== - -ID: com.example.testplugin -Name: Test Plugin -Version: 1.0.0 -Min MM Version: 7.0.0 -Description: A test plugin for demonstrating pluginctl functionality - -Code Components: -================ -Server Code: Yes - Executables: linux-amd64, darwin-amd64, windows-amd64 -Webapp Code: Yes - Bundle Path: webapp/dist/main.js -Settings Schema: Yes -``` +Run `pluginctl --help` to see all available commands and options. ## Requirements -- Go 1.24.3 or later +- Go 1.24.3 or later (for building from source) - Valid Mattermost plugin directory with `plugin.json` manifest file -## Development Tools +## Development -The project uses the following tools for development and release automation: - -- **golangci-lint** v1.62.2 - Code linting and quality checks -- **goreleaser** v2.6.2 - Automated releases and cross-platform builds -- **gosec** v2.22.0 - Security vulnerability scanning - -## Plugin Directory Structure - -`pluginctl` expects to work with standard Mattermost plugin directories containing a `plugin.json` file. For more information about Mattermost plugin structure, visit the [official documentation](https://developers.mattermost.com/integrate/plugins/). - -## Environment Variables - -| Variable | Description | -| ----------------------- | ----------------------------- | -| `PLUGINCTL_PLUGIN_PATH` | Default plugin directory path | - -## Contributing - -We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) file for architecture guidelines and development instructions. - -### Development Setup - -1. Clone the repository: - - ```bash - git clone https://github.com/mattermost/pluginctl.git - cd pluginctl - ``` - -2. Set up development environment (installs pinned tool versions): - - ```bash - make dev-setup - ``` - -3. Install dependencies: - - ```bash - make deps - ``` - -4. Build the project: - - ```bash - make build - ``` - -5. Test with a sample plugin: - ```bash - ./pluginctl info - ``` - -### Development Workflow - -Use these Make targets for efficient development: +### Quick Start ```bash +# Set up development environment +make dev-setup + +# Install dependencies +make deps + # Quick development build (format, lint, build) make dev -# Check all changes before committing (lint, security, test) +# Run tests and checks before committing make check-changes - -# Full verification (clean, lint, test, build) -make verify - -# Run tests with coverage -make test-coverage - -# Build for all platforms -make build-all ``` -### Available Make Targets - -**Development:** +### Key Make Targets - `make dev` - Quick development build - `make check-changes` - Validate changes (lint, security, test) -- `make verify` - Full build verification -- `make fmt` - Format code -- `make clean` - Clean build artifacts - -**Testing:** - - `make test` - Run tests -- `make test-coverage` - Run tests with coverage report -- `make lint` - Run linter -- `make lint-fix` - Fix linting issues automatically -- `make security` - Run security scan - -**Building:** - -- `make build` - Build for current platform -- `make build-all` - Build for all platforms -- `make install` - Install to GOPATH/bin - -**Release:** - -- `make release` - Create production release -- `make snapshot` - Create snapshot release - -**Utilities:** - - `make help` - Show all available targets -- `make version` - Show version information -- `make dev-setup` - Install development tools + +See `make help` for the complete list of available targets. ### Adding New Commands -1. Create a new command file (e.g., `build.go`) -2. Implement the command following the patterns in `info.go` +1. Create a new command file (e.g., `build.go`) in the root directory +2. Implement the command following existing patterns 3. Register the command in `cmd/pluginctl/main.go` -4. Update the help text and documentation +4. Update the help text See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines. +## Contributing + +We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) file for architecture guidelines and development patterns. + ### Code Style - Follow Go best practices and conventions @@ -239,31 +122,6 @@ See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines. - Maintain separation between CLI framework and command implementation - Include comprehensive error handling with descriptive messages -### Testing - -Test your changes with various plugin configurations: - -```bash -# Run all tests -make test - -# Run tests with coverage -make test-coverage - -# Test CLI functionality -./pluginctl info - -# Test with command-line flag -./pluginctl --plugin-path /path/to/plugin info - -# Test with environment variable -export PLUGINCTL_PLUGIN_PATH=/path/to/plugin -./pluginctl info - -# Validate all changes before committing -make check-changes -``` - ## License This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. @@ -280,4 +138,4 @@ For questions, issues, or feature requests, please: 1. Check the [issues](https://github.com/mattermost/pluginctl/issues) page 2. Create a new issue if your problem isn't already reported -3. Join the [Mattermost Community](https://community.mattermost.com/) for general discussion +3. Join the [Mattermost Community](https://community.mattermost.com/) for general discussion \ No newline at end of file From c01c9c28434cb05c41231ec67f1f20d9553eba70 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:13:08 +0200 Subject: [PATCH 04/10] Add manifest command with subcommands for plugin information MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add manifest command with id, version, has_server, has_webapp subcommands - Provides simple text output for easy parsing/scripting - Update help documentation with usage examples - Follow existing command patterns and architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/pluginctl/main.go | 20 +++++++++++++++++ manifest.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 manifest.go diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 876ee59..59688c9 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -54,6 +54,10 @@ func runCommand(command string, args []string, pluginPath string) error { return runResetCommand(args, pluginPath) case "updateassets": return runUpdateAssetsCommand(args, pluginPath) + case "manifest": + return runManifestCommand(args, pluginPath) + case "logs": + return runLogsCommand(args, pluginPath) case "help": showUsage() @@ -91,6 +95,14 @@ func runUpdateAssetsCommand(args []string, pluginPath string) error { return pluginctl.RunUpdateAssetsCommand(args, pluginPath) } +func runManifestCommand(args []string, pluginPath string) error { + return pluginctl.RunManifestCommand(args, pluginPath) +} + +func runLogsCommand(args []string, pluginPath string) error { + return pluginctl.RunLogsCommand(args, pluginPath) +} + func showUsage() { usageText := `pluginctl - Mattermost Plugin Development CLI @@ -106,6 +118,8 @@ Commands: disable Disable plugin from current directory in Mattermost server reset Reset plugin from current directory (disable then enable) updateassets Update plugin files from embedded assets + manifest Get plugin manifest information (id, version, has_server, has_webapp) + logs View plugin logs (use --watch to follow logs in real-time) help Show this help message version Show version information @@ -116,6 +130,12 @@ Examples: pluginctl disable # Disable plugin from current directory pluginctl reset # Reset plugin from current directory (disable then enable) pluginctl updateassets # Update plugin files from embedded assets + pluginctl manifest id # Get plugin ID + pluginctl manifest version # Get plugin version + pluginctl manifest has_server # Check if plugin has server code + pluginctl manifest has_webapp # Check if plugin has webapp code + pluginctl logs # View recent plugin logs + pluginctl logs --watch # Watch plugin logs in real-time export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info # Show info using environment variable pluginctl version # Show version information diff --git a/manifest.go b/manifest.go new file mode 100644 index 0000000..6192057 --- /dev/null +++ b/manifest.go @@ -0,0 +1,50 @@ +package pluginctl + +import ( + "fmt" + "path/filepath" +) + +// RunManifestCommand implements the 'manifest' command functionality with subcommands. +func RunManifestCommand(args []string, pluginPath string) error { + if len(args) == 0 { + return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp") + } + + // Convert to absolute path + absPath, err := filepath.Abs(pluginPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Load plugin manifest + manifest, err := LoadPluginManifestFromPath(absPath) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) + } + + subcommand := args[0] + switch subcommand { + case "id": + fmt.Println(manifest.Id) + case "version": + fmt.Println(manifest.Version) + case "has_server": + if HasServerCode(manifest) { + fmt.Println("true") + } else { + fmt.Println("false") + } + case "has_webapp": + if HasWebappCode(manifest) { + fmt.Println("true") + } else { + fmt.Println("false") + } + default: + return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp", + subcommand) + } + + return nil +} From 71a7b0de1152382b7d4ca12792ac0f9dfed0fb60 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:14:12 +0200 Subject: [PATCH 05/10] Refactor codebase with improved structure and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructured info.go with extracted helper functions for better readability - Enhanced updateassets.go with cleaner asset processing logic and better error handling - Improved client.go formatting and logging consistency - Added logs.go for centralized logging functionality - Updated dependencies in go.mod to include tint as direct dependency - Cleaned up README.md with simplified installation instructions and structure - Added comprehensive assets/ directory with build configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 30 ++---- assets/.gitattributes | 2 + assets/.gitpod.yml | 1 + assets/.nvmrc | 1 + assets/Makefile | 43 ++++++++ assets/build/build.mk | 83 ++++++++++++++ assets/build/deploy.mk | 47 ++++++++ assets/build/dev.mk | 53 +++++++++ assets/build/setup.mk | 44 ++++++++ assets/build/test.mk | 57 ++++++++++ assets/build/utils.mk | 42 ++++++++ assets/build/versioning.mk | 111 +++++++++++++++++++ client.go | 3 +- go.mod | 6 +- info.go | 46 +++++--- logger.go | 8 +- logs.go | 215 +++++++++++++++++++++++++++++++++++++ updateassets.go | 165 ++++++++++++++++++---------- 18 files changed, 852 insertions(+), 105 deletions(-) create mode 100644 assets/.gitattributes create mode 100644 assets/.gitpod.yml create mode 100644 assets/.nvmrc create mode 100644 assets/Makefile create mode 100644 assets/build/build.mk create mode 100644 assets/build/deploy.mk create mode 100644 assets/build/dev.mk create mode 100644 assets/build/setup.mk create mode 100644 assets/build/test.mk create mode 100644 assets/build/utils.mk create mode 100644 assets/build/versioning.mk create mode 100644 logs.go diff --git a/README.md b/README.md index c731855..0ee3426 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,7 @@ Download the latest binary from the [releases page](https://github.com/mattermos ```bash # Using go install -go install github.com/mattermost/pluginctl/cmd/pluginctl@latest - -# Using Homebrew (if available) -brew install mattermost/tap/pluginctl - -# Using Scoop on Windows (if available) -scoop bucket add mattermost https://github.com/mattermost/scoop-bucket.git -scoop install pluginctl +go install github.com/mattermost/pluginctl/cmd/pluginctl ``` ## Usage @@ -42,7 +35,7 @@ pluginctl info pluginctl --help # Show version -pluginctl --version +pluginctl version ``` ### Plugin Path Configuration @@ -50,11 +43,13 @@ pluginctl --version `pluginctl` supports multiple ways to specify the plugin directory: 1. **Command-line flag** (highest priority): + ```bash pluginctl --plugin-path /path/to/plugin info ``` 2. **Environment variable**: + ```bash export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info @@ -70,11 +65,6 @@ pluginctl --version Run `pluginctl --help` to see all available commands and options. -## Requirements - -- Go 1.24.3 or later (for building from source) -- Valid Mattermost plugin directory with `plugin.json` manifest file - ## Development ### Quick Start @@ -93,13 +83,6 @@ make dev make check-changes ``` -### Key Make Targets - -- `make dev` - Quick development build -- `make check-changes` - Validate changes (lint, security, test) -- `make test` - Run tests -- `make help` - Show all available targets - See `make help` for the complete list of available targets. ### Adding New Commands @@ -136,6 +119,5 @@ This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENS For questions, issues, or feature requests, please: -1. Check the [issues](https://github.com/mattermost/pluginctl/issues) page -2. Create a new issue if your problem isn't already reported -3. Join the [Mattermost Community](https://community.mattermost.com/) for general discussion \ No newline at end of file +- Check the [issues](https://github.com/mattermost/pluginctl/issues) or create a new issue if your problem isn't already reported +- Join the [Mattermost Community](https://community.mattermost.com/) for general discussion diff --git a/assets/.gitattributes b/assets/.gitattributes new file mode 100644 index 0000000..4bd338f --- /dev/null +++ b/assets/.gitattributes @@ -0,0 +1,2 @@ +server/manifest.go linguist-generated=true +webapp/src/manifest.js linguist-generated=true diff --git a/assets/.gitpod.yml b/assets/.gitpod.yml new file mode 100644 index 0000000..901a3dc --- /dev/null +++ b/assets/.gitpod.yml @@ -0,0 +1 @@ +mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config diff --git a/assets/.nvmrc b/assets/.nvmrc new file mode 100644 index 0000000..a3597ec --- /dev/null +++ b/assets/.nvmrc @@ -0,0 +1 @@ +20.11 diff --git a/assets/Makefile b/assets/Makefile new file mode 100644 index 0000000..1ff7725 --- /dev/null +++ b/assets/Makefile @@ -0,0 +1,43 @@ +GO ?= $(shell command -v go 2> /dev/null) +NPM ?= $(shell command -v npm 2> /dev/null) +CURL ?= $(shell command -v curl 2> /dev/null) +MM_DEBUG ?= +GOPATH ?= $(shell go env GOPATH) +GO_TEST_FLAGS ?= -race +GO_BUILD_FLAGS ?= +MM_UTILITIES_DIR ?= ../mattermost-utilities +DLV_DEBUG_PORT := 2346 +DEFAULT_GOOS := $(shell go env GOOS) +DEFAULT_GOARCH := $(shell go env GOARCH) + +export GO111MODULE=on + +# We need to export GOBIN to allow it to be set +# for processes spawned from the Makefile +export GOBIN ?= $(PWD)/bin + +# You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. +ASSETS_DIR ?= assets + +## Define the default target (make all) +.PHONY: default +default: all + +# Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. +include build/setup.mk + +BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz + +# Include custom makefile, if present +ifneq ($(wildcard build/custom.mk),) + include build/custom.mk +endif + +ifneq ($(MM_DEBUG),) + GO_BUILD_GCFLAGS = -gcflags "all=-N -l" +else + GO_BUILD_GCFLAGS = +endif + +# Include modular makefiles +include build/*.mk \ No newline at end of file diff --git a/assets/build/build.mk b/assets/build/build.mk new file mode 100644 index 0000000..8be43ec --- /dev/null +++ b/assets/build/build.mk @@ -0,0 +1,83 @@ +# ==================================================================================== +# Build Targets +# ==================================================================================== + +## Checks the code style, tests, builds and bundles the plugin. +.PHONY: all +all: check-style test dist + +## Ensures the plugin manifest is valid +.PHONY: manifest-check +manifest-check: + ./build/bin/manifest check + + +## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. +.PHONY: server +server: +ifneq ($(HAS_SERVER),) +ifneq ($(MM_DEBUG),) + $(info DEBUG mode is on; to disable, unset MM_DEBUG) +endif + mkdir -p server/dist; +ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) + @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled + cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); +else + cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-amd64; + cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-arm64; + cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-amd64; + cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-arm64; + cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-windows-amd64.exe; +endif +endif + +## Ensures NPM dependencies are installed without having to run this all the time. +webapp/node_modules: $(wildcard webapp/package.json) +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) install + touch $@ +endif + +## Builds the webapp, if it exists. +.PHONY: webapp +webapp: webapp/node_modules +ifneq ($(HAS_WEBAPP),) +ifeq ($(MM_DEBUG),) + cd webapp && $(NPM) run build; +else + cd webapp && $(NPM) run debug; +endif +endif + +## Generates a tar bundle of the plugin for install. +.PHONY: bundle +bundle: + rm -rf dist/ + mkdir -p dist/$(PLUGIN_ID) + ./build/bin/manifest dist +ifneq ($(wildcard $(ASSETS_DIR)/.),) + cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ +endif +ifneq ($(HAS_PUBLIC),) + cp -r public dist/$(PLUGIN_ID)/ +endif +ifneq ($(HAS_SERVER),) + mkdir -p dist/$(PLUGIN_ID)/server + cp -r server/dist dist/$(PLUGIN_ID)/server/ +endif +ifneq ($(HAS_WEBAPP),) + mkdir -p dist/$(PLUGIN_ID)/webapp + cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ +endif +ifeq ($(shell uname),Darwin) + cd dist && tar --disable-copyfile -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) +else + cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) +endif + + @echo plugin built at: dist/$(BUNDLE_NAME) + +## Builds and bundles the plugin. +.PHONY: dist +dist: server webapp bundle diff --git a/assets/build/deploy.mk b/assets/build/deploy.mk new file mode 100644 index 0000000..7c05b31 --- /dev/null +++ b/assets/build/deploy.mk @@ -0,0 +1,47 @@ +# ==================================================================================== +# Deployment and Plugin Management +# ==================================================================================== + +## Builds and installs the plugin to a server. +.PHONY: deploy +deploy: dist + ./build/bin/pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) + +## Builds and installs the plugin to a server, updating the webapp automatically when changed. +.PHONY: watch +watch: server bundle +ifeq ($(MM_DEBUG),) + cd webapp && $(NPM) run build:watch +else + cd webapp && $(NPM) run debug:watch +endif + +## Installs a previous built plugin with updated webpack assets to a server. +.PHONY: deploy-from-watch +deploy-from-watch: bundle + pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) + +## Disable the plugin. +.PHONY: disable +disable: detach + pluginctl disable + +## Enable the plugin. +.PHONY: enable +enable: + pluginctl enable + +## Reset the plugin, effectively disabling and re-enabling it on the server. +.PHONY: reset +reset: detach + pluginctl reset + +## View plugin logs. +.PHONY: logs +logs: + pluginctl logs + +## Watch plugin logs. +.PHONY: logs-watch +logs-watch: + pluginctl logs --watch diff --git a/assets/build/dev.mk b/assets/build/dev.mk new file mode 100644 index 0000000..a8ca0ce --- /dev/null +++ b/assets/build/dev.mk @@ -0,0 +1,53 @@ +# ==================================================================================== +# Development and Debugging +# ==================================================================================== + +## Setup dlv for attaching, identifying the plugin PID for other targets. +.PHONY: setup-attach +setup-attach: + $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) + $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) + + @if [ ${NUM_PID} -gt 2 ]; then \ + echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ + exit 1; \ + fi + +## Check if setup-attach succeeded. +.PHONY: check-attach +check-attach: + @if [ -z ${PLUGIN_PID} ]; then \ + echo "Could not find plugin PID; the plugin is not running. Exiting."; \ + exit 1; \ + else \ + echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ + fi + +## Attach dlv to an existing plugin instance. +.PHONY: attach +attach: setup-attach check-attach + dlv attach ${PLUGIN_PID} + +## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. +.PHONY: attach-headless +attach-headless: setup-attach check-attach + dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient + +## Detach dlv from an existing plugin instance, if previously attached. +.PHONY: detach +detach: setup-attach + @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ + if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ + echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ + kill -9 $$DELVE_PID ; \ + fi + +## Kill all instances of the plugin, detaching any existing dlv instance. +.PHONY: kill +kill: detach + $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) + + @for PID in ${PLUGIN_PID}; do \ + echo "Killing plugin pid $$PID"; \ + kill -9 $$PID; \ + done; \ \ No newline at end of file diff --git a/assets/build/setup.mk b/assets/build/setup.mk new file mode 100644 index 0000000..aab3bd0 --- /dev/null +++ b/assets/build/setup.mk @@ -0,0 +1,44 @@ +# Ensure that go is installed. Note that this is independent of whether or not a server is being +# built, since the build script itself uses go. +ifeq ($(GO),) + $(error "go is not available: see https://golang.org/doc/install") +endif + +# Gather build variables to inject into the manifest tool +BUILD_HASH_SHORT = $(shell git rev-parse --short HEAD) +BUILD_TAG_LATEST = $(shell git describe --tags --match 'v*' --abbrev=0 2>/dev/null) +BUILD_TAG_CURRENT = $(shell git tag --points-at HEAD) + +# Extract the plugin id from the manifest. +PLUGIN_ID ?= $(shell pluginctl manifest id) +ifeq ($(PLUGIN_ID),) + $(error "Cannot parse id from $(MANIFEST_FILE)") +endif + +# Extract the plugin version from the manifest. +PLUGIN_VERSION ?= $(shell pluginctl manifest version) +ifeq ($(PLUGIN_VERSION),) + $(error "Cannot parse version from $(MANIFEST_FILE)") +endif + +# Determine if a server is defined in the manifest. +HAS_SERVER ?= $(shell pluginctl manifest has_server) + +# Determine if a webapp is defined in the manifest. +HAS_WEBAPP ?= $(shell pluginctl manifest has_webapp) + +# Determine if a /public folder is in use +HAS_PUBLIC ?= $(wildcard public/.) + +# Determine if the mattermost-utilities repo is present +HAS_MM_UTILITIES ?= $(wildcard $(MM_UTILITIES_DIR)/.) + +# Store the current path for later use +PWD ?= $(shell pwd) + +# Ensure that npm (and thus node) is installed. +ifneq ($(HAS_WEBAPP),) +ifeq ($(NPM),) + $(error "npm is not available: see https://www.npmjs.com/get-npm") +endif +endif diff --git a/assets/build/test.mk b/assets/build/test.mk new file mode 100644 index 0000000..f084664 --- /dev/null +++ b/assets/build/test.mk @@ -0,0 +1,57 @@ +# ==================================================================================== +# Testing and Quality Assurance +# ==================================================================================== + +## Install go tools +install-go-tools: + @echo Installing go tools + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 + $(GO) install gotest.tools/gotestsum@v1.7.0 + +## Runs eslint and golangci-lint +.PHONY: check-style +check-style: manifest-check webapp/node_modules install-go-tools + @echo Checking for style guide compliance + +ifneq ($(HAS_WEBAPP),) + cd webapp && npm run lint + cd webapp && npm run check-types +endif + +# It's highly recommended to run go-vet first +# to find potential compile errors that could introduce +# weird reports at golangci-lint step +ifneq ($(HAS_SERVER),) + @echo Running golangci-lint + $(GO) vet ./... + $(GOBIN)/golangci-lint run ./... +endif + +## Runs any lints and unit tests defined for the server and webapp, if they exist. +.PHONY: test +test: webapp/node_modules install-go-tools +ifneq ($(HAS_SERVER),) + $(GOBIN)/gotestsum -- -v ./... +endif +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) run test; +endif + +## Runs any lints and unit tests defined for the server and webapp, if they exist, optimized +## for a CI environment. +.PHONY: test-ci +test-ci: webapp/node_modules install-go-tools +ifneq ($(HAS_SERVER),) + $(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./... +endif +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) run test; +endif + +## Creates a coverage report for the server code. +.PHONY: coverage +coverage: webapp/node_modules +ifneq ($(HAS_SERVER),) + $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... + $(GO) tool cover -html=server/coverage.txt +endif diff --git a/assets/build/utils.mk b/assets/build/utils.mk new file mode 100644 index 0000000..3dd5c52 --- /dev/null +++ b/assets/build/utils.mk @@ -0,0 +1,42 @@ +# ==================================================================================== +# Utilities +# ==================================================================================== + +## Clean removes all build artifacts. +.PHONY: clean +clean: + rm -fr dist/ +ifneq ($(HAS_SERVER),) + rm -fr server/coverage.txt + rm -fr server/dist +endif +ifneq ($(HAS_WEBAPP),) + rm -fr webapp/junit.xml + rm -fr webapp/dist + rm -fr webapp/node_modules +endif + rm -fr build/bin/ + +## Extract strings for translation from the source code. +.PHONY: i18n-extract +i18n-extract: +ifneq ($(HAS_WEBAPP),) +ifeq ($(HAS_MM_UTILITIES),) + @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" +else + cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp +endif +endif + +## Generate mocks for testing. +.PHONY: mock +mock: +ifneq ($(HAS_SERVER),) + go install github.com/golang/mock/mockgen@v1.6.0 + mockgen -destination=server/command/mocks/mock_commands.go -package=mocks github.com/mattermost/mattermost-plugin-starter-template/server/command Command +endif + +## Show help documentation. +.PHONY: help +help: + @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//g" | sed -e "s/^## //g" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort \ No newline at end of file diff --git a/assets/build/versioning.mk b/assets/build/versioning.mk new file mode 100644 index 0000000..4616437 --- /dev/null +++ b/assets/build/versioning.mk @@ -0,0 +1,111 @@ +# ==================================================================================== +# Semantic Versioning +# ==================================================================================== + +# Used for semver bumping +PROTECTED_BRANCH := master +APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`) +CURRENT_VERSION := $(shell git describe --abbrev=0 --tags) +VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION)))) +MAJOR := $(word 1,$(VERSION_PARTS)) +MINOR := $(word 2,$(VERSION_PARTS)) +PATCH := $(word 3,$(VERSION_PARTS)) +RC := $(shell echo $(CURRENT_VERSION) | grep -oE 'rc[0-9]+' | sed 's/rc//') + +# Check if current branch is protected +define check_protected_branch + @current_branch=$$(git rev-parse --abbrev-ref HEAD); \ + if ! echo "$(PROTECTED_BRANCH)" | grep -wq "$$current_branch" && ! echo "$$current_branch" | grep -q "^release"; then \ + echo "Error: Tagging is only allowed from $(PROTECTED_BRANCH) or release branches. You are on $$current_branch branch."; \ + exit 1; \ + fi +endef + +# Check if there are pending pulls +define check_pending_pulls + @git fetch; \ + current_branch=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/$$current_branch)" ]; then \ + echo "Error: Your branch is not up to date with upstream. Please pull the latest changes before performing a release"; \ + exit 1; \ + fi +endef + +# Prompt for approval +define prompt_approval + @read -p "About to bump $(APP_NAME) to version $(1), approve? (y/n) " userinput; \ + if [ "$$userinput" != "y" ]; then \ + echo "Bump aborted."; \ + exit 1; \ + fi +endef + +.PHONY: patch minor major patch-rc minor-rc major-rc + +patch: ## to bump patch version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval PATCH := $(shell echo $$(($(PATCH)+1)))) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH) + +minor: ## to bump minor version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MINOR := $(shell echo $$(($(MINOR)+1)))) + @$(eval PATCH := 0) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH) + +major: ## to bump major version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + $(eval MAJOR := $(shell echo $$(($(MAJOR)+1)))) + $(eval MINOR := 0) + $(eval PATCH := 0) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH) + +patch-rc: ## to bump patch release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval RC := $(shell echo $$(($(RC)+1)))) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @echo Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" + git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + +minor-rc: ## to bump minor release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MINOR := $(shell echo $$(($(MINOR)+1)))) + @$(eval PATCH := 0) + @$(eval RC := 1) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @echo Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" + git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + +major-rc: ## to bump major release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MAJOR := $(shell echo $$(($(MAJOR)+1)))) + @$(eval MINOR := 0) + @$(eval PATCH := 0) + @$(eval RC := 1) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @echo Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" + git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) \ No newline at end of file diff --git a/client.go b/client.go index b006192..cd58701 100644 --- a/client.go +++ b/client.go @@ -27,7 +27,8 @@ func getClient(ctx context.Context) (*model.Client4, error) { } if os.Getenv("MM_LOCALSOCKETPATH") != "" { - Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.", "socket_path", socketPath) + Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.", + "socket_path", socketPath) } siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL") diff --git a/go.mod b/go.mod index 02d6ccf..4bfac4f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/mattermost/pluginctl go 1.24.3 -require github.com/mattermost/mattermost/server/public v0.1.15 +require ( + github.com/lmittmann/tint v1.1.2 + github.com/mattermost/mattermost/server/public v0.1.15 +) require ( 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect @@ -303,7 +306,6 @@ require ( github.com/ldez/tagliatelle v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 // indirect - github.com/lmittmann/tint v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.1.3 // indirect github.com/mailru/easyjson v0.9.0 // indirect diff --git a/info.go b/info.go index f037e33..24a12df 100644 --- a/info.go +++ b/info.go @@ -19,30 +19,44 @@ func InfoCommand() error { // PrintPluginInfo displays formatted plugin information. func PrintPluginInfo(manifest *model.Manifest) error { + printBasicInfo(manifest) + printCodeComponents(manifest) + printSettingsSchema(manifest) + + return nil +} + +// printBasicInfo prints basic plugin information. +func printBasicInfo(manifest *model.Manifest) { Logger.Info("Plugin Information:") Logger.Info("==================") - // Basic plugin info Logger.Info("ID:", "value", manifest.Id) Logger.Info("Name:", "value", manifest.Name) Logger.Info("Version:", "value", manifest.Version) - // Minimum Mattermost version - if manifest.MinServerVersion != "" { - Logger.Info("Min MM Version:", "value", manifest.MinServerVersion) - } else { - Logger.Info("Min MM Version:", "value", "Not specified") + minVersion := manifest.MinServerVersion + if minVersion == "" { + minVersion = "Not specified" } + Logger.Info("Min MM Version:", "value", minVersion) - // Description if available if manifest.Description != "" { Logger.Info("Description:", "value", manifest.Description) } +} +// printCodeComponents prints information about server and webapp code. +func printCodeComponents(manifest *model.Manifest) { Logger.Info("Code Components:") Logger.Info("================") - // Server code presence + printServerCodeInfo(manifest) + printWebappCodeInfo(manifest) +} + +// printServerCodeInfo prints server code information. +func printServerCodeInfo(manifest *model.Manifest) { if HasServerCode(manifest) { Logger.Info("Server Code:", "value", "Yes") if manifest.Server != nil && len(manifest.Server.Executables) > 0 { @@ -55,8 +69,10 @@ func PrintPluginInfo(manifest *model.Manifest) error { } else { Logger.Info("Server Code:", "value", "No") } +} - // Webapp code presence +// printWebappCodeInfo prints webapp code information. +func printWebappCodeInfo(manifest *model.Manifest) { if HasWebappCode(manifest) { Logger.Info("Webapp Code:", "value", "Yes") if manifest.Webapp != nil && manifest.Webapp.BundlePath != "" { @@ -65,15 +81,15 @@ func PrintPluginInfo(manifest *model.Manifest) error { } else { Logger.Info("Webapp Code:", "value", "No") } +} - // Settings schema +// printSettingsSchema prints settings schema information. +func printSettingsSchema(manifest *model.Manifest) { + value := "No" if manifest.SettingsSchema != nil { - Logger.Info("Settings Schema:", "value", "Yes") - } else { - Logger.Info("Settings Schema:", "value", "No") + value = "Yes" } - - return nil + Logger.Info("Settings Schema:", "value", value) } // InfoCommandWithPath implements the 'info' command with a custom path. diff --git a/logger.go b/logger.go index fffe857..1f0f3a2 100644 --- a/logger.go +++ b/logger.go @@ -8,10 +8,10 @@ import ( "github.com/lmittmann/tint" ) -// Logger is the global logger instance +// Logger is the global logger instance. var Logger *slog.Logger -// InitLogger initializes the global logger +// InitLogger initializes the global logger. func InitLogger() { // Create a tint handler for colorized output handler := tint.NewHandler(os.Stderr, &tint.Options{ @@ -24,7 +24,7 @@ func InitLogger() { Logger = slog.New(handler) } -// SetLogLevel sets the minimum logging level +// SetLogLevel sets the minimum logging level. func SetLogLevel(level slog.Level) { handler := tint.NewHandler(os.Stderr, &tint.Options{ Level: level, @@ -34,4 +34,4 @@ func SetLogLevel(level slog.Level) { }) Logger = slog.New(handler) -} \ No newline at end of file +} diff --git a/logs.go b/logs.go new file mode 100644 index 0000000..4a19597 --- /dev/null +++ b/logs.go @@ -0,0 +1,215 @@ +package pluginctl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + logsPerPage = 100 + defaultLogsPerPage = 500 + timeStampFormat = "2006-01-02 15:04:05.000 Z07:00" +) + +// RunLogsCommand executes the logs command with optional --watch flag. +func RunLogsCommand(args []string, pluginPath string) error { + // Check for --watch flag + watch := false + if len(args) > 0 && args[0] == "--watch" { + watch = true + } + + if watch { + return runPluginCommand(args, pluginPath, watchPluginLogs) + } + + return runPluginCommand(args, pluginPath, getPluginLogs) +} + +// getPluginLogs fetches the latest 500 log entries from Mattermost, +// and prints only the ones related to the plugin to stdout. +func getPluginLogs(ctx context.Context, client *model.Client4, pluginID string) error { + Logger.Info("Getting plugin logs", "plugin_id", pluginID) + + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + logs, err := fetchLogs(ctx, client, LogsRequest{ + Page: 0, + PerPage: defaultLogsPerPage, + PluginID: pluginID, + Since: time.Unix(0, 0), + }) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + printLogEntries(logs) + + return nil +} + +// watchPluginLogs fetches log entries from Mattermost and prints them continuously. +// It will return without an error when ctx is canceled. +func watchPluginLogs(ctx context.Context, client *model.Client4, pluginID string) error { + Logger.Info("Watching plugin logs", "plugin_id", pluginID) + + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + now := time.Now() + var oldestEntry string + + // Use context.WithoutCancel to keep watching even if parent context times out + watchCtx := context.WithoutCancel(ctx) + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-watchCtx.Done(): + return nil + case <-ticker.C: + var page int + for { + logs, err := fetchLogs(watchCtx, client, LogsRequest{ + Page: page, + PerPage: logsPerPage, + PluginID: pluginID, + Since: now, + }) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + var allNew bool + logs, oldestEntry, allNew = checkOldestEntry(logs, oldestEntry) + + printLogEntries(logs) + + if !allNew { + // No more logs to fetch + break + } + page++ + } + } + } +} + +// checkOldestEntry checks if logs contains new log entries. +// It returns the filtered slice of log entries, the new oldest entry and whether or not all entries were new. +func checkOldestEntry(logs []string, oldest string) (filteredLogs []string, newOldest string, allNew bool) { + if len(logs) == 0 { + return nil, oldest, false + } + + newOldestEntry := logs[len(logs)-1] + + i := slices.Index(logs, oldest) + switch i { + case -1: + // Every log entry is new + return logs, newOldestEntry, true + case len(logs) - 1: + // No new log entries + return nil, oldest, false + default: + // Filter out oldest log entry + return logs[i+1:], newOldestEntry, false + } +} + +// LogsRequest contains parameters for fetching logs. +type LogsRequest struct { + Page int + PerPage int + PluginID string + Since time.Time +} + +// fetchLogs fetches log entries from Mattermost +// and filters them based on pluginID and timestamp. +func fetchLogs(ctx context.Context, client *model.Client4, req LogsRequest) ([]string, error) { + logs, _, err := client.GetLogs(ctx, req.Page, req.PerPage) + if err != nil { + return nil, fmt.Errorf("failed to get logs from Mattermost: %w", err) + } + + logs, err = filterLogEntries(logs, req.PluginID, req.Since) + if err != nil { + return nil, fmt.Errorf("failed to filter log entries: %w", err) + } + + return logs, nil +} + +// filterLogEntries filters a given slice of log entries by pluginID. +// It also filters out any entries which timestamps are older than since. +func filterLogEntries(logs []string, pluginID string, since time.Time) ([]string, error) { + type logEntry struct { + PluginID string `json:"plugin_id"` + Timestamp string `json:"timestamp"` + } + + ret := make([]string, 0) + + for _, e := range logs { + var le logEntry + err := json.Unmarshal([]byte(e), &le) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal log entry into JSON: %w", err) + } + if le.PluginID != pluginID { + continue + } + + let, err := time.Parse(timeStampFormat, le.Timestamp) + if err != nil { + return nil, fmt.Errorf("unknown timestamp format: %w", err) + } + if let.Before(since) { + continue + } + + // Log entries returned by the API have a newline as prefix. + // Remove that to make printing consistent. + e = strings.TrimPrefix(e, "\n") + + ret = append(ret, e) + } + + return ret, nil +} + +// printLogEntries prints a slice of log entries to stdout. +func printLogEntries(entries []string) { + for _, e := range entries { + fmt.Println(e) + } +} + +func checkJSONLogsSetting(ctx context.Context, client *model.Client4) error { + cfg, _, err := client.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to fetch config: %w", err) + } + if cfg.LogSettings.FileJson == nil || !*cfg.LogSettings.FileJson { + return errors.New("JSON output for file logs are disabled. " + + "Please enable LogSettings.FileJson via the configuration in Mattermost") + } + + return nil +} diff --git a/updateassets.go b/updateassets.go index 5abfb2a..96d7822 100644 --- a/updateassets.go +++ b/updateassets.go @@ -10,9 +10,16 @@ import ( "strings" ) -//go:embed assets/**/* +//go:embed assets/* var assetsFS embed.FS +const ( + assetsPrefix = "assets/" + assetsPrefixLen = 7 + directoryPermissions = 0o750 + filePermissions = 0o600 +) + func RunUpdateAssetsCommand(args []string, pluginPath string) error { if len(args) > 0 { return fmt.Errorf("updateassets command does not accept arguments") @@ -20,73 +27,22 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { Logger.Info("Updating assets in plugin directory", "path", pluginPath) - // Load plugin manifest to check for webapp code manifest, err := LoadPluginManifestFromPath(pluginPath) if err != nil { return fmt.Errorf("failed to load plugin manifest: %w", err) } - // Check if the plugin has webapp code according to manifest hasWebapp := HasWebappCode(manifest) + updatedCount := 0 - // Counter for updated files - var updatedCount int + config := AssetProcessorConfig{ + pluginPath: pluginPath, + hasWebapp: hasWebapp, + updatedCount: &updatedCount, + } - // Walk through the embedded assets err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip the root assets directory - if path == "assets" { - return nil - } - - // Remove the "assets/" prefix to get the relative path - relativePath := path[7:] // len("assets/") = 7 - - // Skip webapp assets if plugin doesn't have webapp code - if !hasWebapp && strings.HasPrefix(relativePath, "webapp") { - return nil - } - - targetPath := filepath.Join(pluginPath, relativePath) - - if d.IsDir() { - // Create directory if it doesn't exist - if err := os.MkdirAll(targetPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", targetPath, err) - } - } else { - // Read file content from embedded FS - content, err := assetsFS.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read embedded file %s: %w", path, err) - } - - // Check if target file exists and compare content - existingContent, err := os.ReadFile(targetPath) - if err == nil && bytes.Equal(existingContent, content) { - // File exists and content is identical, skip update - return nil - } - - // Create parent directory if it doesn't exist - parentDir := filepath.Dir(targetPath) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) - } - - // Write file to target location - if err := os.WriteFile(targetPath, content, 0644); err != nil { - return fmt.Errorf("failed to write file %s: %w", targetPath, err) - } - Logger.Info("Updated file", "path", relativePath) - updatedCount++ - } - - return nil + return processAssetEntry(path, d, err, config) }) if err != nil { @@ -94,5 +50,96 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { } Logger.Info("Assets updated successfully!", "files_updated", updatedCount) + + return nil +} + +type AssetProcessorConfig struct { + pluginPath string + hasWebapp bool + updatedCount *int +} + +func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProcessorConfig) error { + if err != nil { + return err + } + + if path == "assets" { + return nil + } + + relativePath := path[assetsPrefixLen:] + + if !config.hasWebapp && strings.HasPrefix(relativePath, "webapp") { + return nil + } + + targetPath := filepath.Join(config.pluginPath, relativePath) + + if d.IsDir() { + return createDirectory(targetPath) + } + + return processAssetFile(path, targetPath, relativePath, config.updatedCount) +} + +func processAssetFile(embeddedPath, targetPath, relativePath string, updatedCount *int) error { + shouldUpdate, err := shouldUpdateFile(embeddedPath, targetPath) + if err != nil { + return err + } + + if shouldUpdate { + err = updateFile(embeddedPath, targetPath, relativePath) + if err != nil { + return err + } + (*updatedCount)++ + } + + return nil +} + +func createDirectory(targetPath string) error { + if err := os.MkdirAll(targetPath, directoryPermissions); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetPath, err) + } + + return nil +} + +func shouldUpdateFile(embeddedPath, targetPath string) (bool, error) { + content, err := assetsFS.ReadFile(embeddedPath) + if err != nil { + return false, fmt.Errorf("failed to read embedded file %s: %w", embeddedPath, err) + } + + existingContent, err := os.ReadFile(targetPath) + if err != nil { + // File doesn't exist or other error, should update + return true, nil //nolint:nilerr + } + + return !bytes.Equal(existingContent, content), nil +} + +func updateFile(embeddedPath, targetPath, relativePath string) error { + content, err := assetsFS.ReadFile(embeddedPath) + if err != nil { + return fmt.Errorf("failed to read embedded file %s: %w", embeddedPath, err) + } + + parentDir := filepath.Dir(targetPath) + if err := os.MkdirAll(parentDir, directoryPermissions); err != nil { + return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) + } + + if err := os.WriteFile(targetPath, content, filePermissions); err != nil { + return fmt.Errorf("failed to write file %s: %w", targetPath, err) + } + + Logger.Info("Updated file", "path", relativePath) + return nil } From 4d9c958fc970e34bf1289e488c49540cfac9fe5d Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:24:32 +0200 Subject: [PATCH 06/10] moved test json files to folders --- testdata/{complete_plugin.json => complete/plugin.json} | 0 testdata/{ => default}/plugin.json | 0 testdata/{invalid_plugin.json => invalid/plugin.json} | 0 testdata/{minimal_plugin.json => minimal/plugin.json} | 0 testdata/{server_only_plugin.json => server_only/plugin.json} | 0 testdata/{webapp_only_plugin.json => webapp_only/plugin.json} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename testdata/{complete_plugin.json => complete/plugin.json} (100%) rename testdata/{ => default}/plugin.json (100%) rename testdata/{invalid_plugin.json => invalid/plugin.json} (100%) rename testdata/{minimal_plugin.json => minimal/plugin.json} (100%) rename testdata/{server_only_plugin.json => server_only/plugin.json} (100%) rename testdata/{webapp_only_plugin.json => webapp_only/plugin.json} (100%) diff --git a/testdata/complete_plugin.json b/testdata/complete/plugin.json similarity index 100% rename from testdata/complete_plugin.json rename to testdata/complete/plugin.json diff --git a/testdata/plugin.json b/testdata/default/plugin.json similarity index 100% rename from testdata/plugin.json rename to testdata/default/plugin.json diff --git a/testdata/invalid_plugin.json b/testdata/invalid/plugin.json similarity index 100% rename from testdata/invalid_plugin.json rename to testdata/invalid/plugin.json diff --git a/testdata/minimal_plugin.json b/testdata/minimal/plugin.json similarity index 100% rename from testdata/minimal_plugin.json rename to testdata/minimal/plugin.json diff --git a/testdata/server_only_plugin.json b/testdata/server_only/plugin.json similarity index 100% rename from testdata/server_only_plugin.json rename to testdata/server_only/plugin.json diff --git a/testdata/webapp_only_plugin.json b/testdata/webapp_only/plugin.json similarity index 100% rename from testdata/webapp_only_plugin.json rename to testdata/webapp_only/plugin.json From 873bf78c22ae8101732d5762e6a31c73c1d836f1 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:38:38 +0200 Subject: [PATCH 07/10] Add custom parser for props["pluginctl"] with ignore assets support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PluginCtlConfig struct with IgnoreAssets field for glob patterns - Add ParsePluginCtlConfig function to parse manifest props["pluginctl"] - Update updateassets command to respect ignore patterns with glob matching - Add comprehensive logging when files are skipped due to ignore patterns - Support patterns like *.test.js, build/, node_modules for flexible exclusion - Add extensive tests for config parsing and path matching functionality - Maintain backward compatibility with existing manifests - Fix Makefile check-changes target and add logger init to tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Makefile | 1 + info_test.go | 3 + plugin.go | 36 +++++++++ plugin_test.go | 204 ++++++++++++++++++++++++++++++++++++++++++++++++ updateassets.go | 82 +++++++++++++++++-- 5 files changed, 320 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index ab8b81c..e754f18 100644 --- a/Makefile +++ b/Makefile @@ -141,6 +141,7 @@ dev: fmt lint snapshot ## Quick development build (fmt, lint, build) # Check changes target .PHONY: check-changes check-changes: lint test ## Check changes (lint, test) + @echo "All checks passed!" # CI target .PHONY: ci diff --git a/info_test.go b/info_test.go index fa0abde..4e1eed2 100644 --- a/info_test.go +++ b/info_test.go @@ -125,6 +125,9 @@ func TestPrintPluginInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Initialize logger for testing + InitLogger() + // Capture stdout oldStdout := os.Stdout r, w, _ := os.Pipe() diff --git a/plugin.go b/plugin.go index 7995333..b8612ad 100644 --- a/plugin.go +++ b/plugin.go @@ -11,6 +11,11 @@ import ( const PluginManifestName = "plugin.json" +// PluginCtlConfig represents the configuration for pluginctl stored in the manifest props. +type PluginCtlConfig struct { + IgnoreAssets []string `json:"ignore_assets,omitempty"` +} + // LoadPluginManifest loads and parses the plugin.json file from the current directory. func LoadPluginManifest() (*model.Manifest, error) { return LoadPluginManifestFromPath(".") @@ -78,3 +83,34 @@ func GetEffectivePluginPath(flagPath string) string { return cwd } + +// ParsePluginCtlConfig extracts and parses the pluginctl configuration from the manifest props. +func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) { + // Default configuration + config := &PluginCtlConfig{ + IgnoreAssets: []string{}, + } + + // Check if props exist + if manifest.Props == nil { + return config, nil + } + + // Check if pluginctl config exists in props + pluginctlData, exists := manifest.Props["pluginctl"] + if !exists { + return config, nil + } + + // Convert to JSON and parse + jsonData, err := json.Marshal(pluginctlData) + if err != nil { + return nil, fmt.Errorf("failed to marshal pluginctl config: %w", err) + } + + if err := json.Unmarshal(jsonData, config); err != nil { + return nil, fmt.Errorf("failed to parse pluginctl config: %w", err) + } + + return config, nil +} diff --git a/plugin_test.go b/plugin_test.go index 9f143d9..834620f 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -402,3 +402,207 @@ func TestHasServerCodeAndWebappCode(t *testing.T) { }) } } + +func TestParsePluginCtlConfig(t *testing.T) { + tests := []struct { + name string + manifest *model.Manifest + expectedConfig *PluginCtlConfig + expectError bool + errorContains string + }{ + { + name: "Manifest with valid pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": map[string]interface{}{ + "ignore_assets": []string{"*.test.js", "build/", "temp/**"}, + }, + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{"*.test.js", "build/", "temp/**"}, + }, + expectError: false, + }, + { + name: "Manifest with no props", + manifest: &model.Manifest{ + Props: nil, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with empty props", + manifest: &model.Manifest{ + Props: map[string]interface{}{}, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with no pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "other": "value", + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with empty pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": map[string]interface{}{}, + }, + }, + expectedConfig: &PluginCtlConfig{ + IgnoreAssets: []string{}, + }, + expectError: false, + }, + { + name: "Manifest with invalid pluginctl config", + manifest: &model.Manifest{ + Props: map[string]interface{}{ + "pluginctl": "invalid", + }, + }, + expectedConfig: nil, + expectError: true, + errorContains: "failed to parse pluginctl config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := ParsePluginCtlConfig(tt.manifest) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got nil") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error to contain %q but got: %v", tt.errorContains, err) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if config == nil { + t.Error("Expected config but got nil") + return + } + + if len(config.IgnoreAssets) != len(tt.expectedConfig.IgnoreAssets) { + t.Errorf("Expected %d ignore assets but got %d", len(tt.expectedConfig.IgnoreAssets), len(config.IgnoreAssets)) + return + } + + for i, expected := range tt.expectedConfig.IgnoreAssets { + if config.IgnoreAssets[i] != expected { + t.Errorf("Expected ignore asset %d to be %q but got %q", i, expected, config.IgnoreAssets[i]) + } + } + }) + } +} + +func TestIsPathIgnored(t *testing.T) { + tests := []struct { + name string + relativePath string + ignorePatterns []string + expectedIgnore bool + expectedPattern string + }{ + { + name: "No ignore patterns", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{}, + expectedIgnore: false, + expectedPattern: "", + }, + { + name: "Direct file match", + relativePath: "test.js", + ignorePatterns: []string{"*.js"}, + expectedIgnore: true, + expectedPattern: "*.js", + }, + { + name: "Directory pattern with slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build/"}, + expectedIgnore: true, + expectedPattern: "build/", + }, + { + name: "Directory pattern without slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build"}, + expectedIgnore: true, + expectedPattern: "build", + }, + { + name: "Nested directory match", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{"dist"}, + expectedIgnore: true, + expectedPattern: "dist", + }, + { + name: "Multiple patterns - first match", + relativePath: "test.js", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, + expectedPattern: "*.js", + }, + { + name: "Multiple patterns - second match", + relativePath: "style.css", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, + expectedPattern: "*.css", + }, + { + name: "No match", + relativePath: "README.md", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: false, + expectedPattern: "", + }, + { + name: "Complex path with match", + relativePath: "webapp/node_modules/package/file.js", + ignorePatterns: []string{"node_modules"}, + expectedIgnore: true, + expectedPattern: "node_modules", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ignored, pattern := isPathIgnored(tt.relativePath, tt.ignorePatterns) + + if ignored != tt.expectedIgnore { + t.Errorf("Expected ignore result %v but got %v", tt.expectedIgnore, ignored) + } + + if pattern != tt.expectedPattern { + t.Errorf("Expected pattern %q but got %q", tt.expectedPattern, pattern) + } + }) + } +} diff --git a/updateassets.go b/updateassets.go index 96d7822..60196cd 100644 --- a/updateassets.go +++ b/updateassets.go @@ -32,13 +32,19 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return fmt.Errorf("failed to load plugin manifest: %w", err) } + pluginCtlConfig, err := ParsePluginCtlConfig(manifest) + if err != nil { + return fmt.Errorf("failed to parse pluginctl config: %w", err) + } + hasWebapp := HasWebappCode(manifest) updatedCount := 0 config := AssetProcessorConfig{ - pluginPath: pluginPath, - hasWebapp: hasWebapp, - updatedCount: &updatedCount, + pluginPath: pluginPath, + hasWebapp: hasWebapp, + updatedCount: &updatedCount, + pluginCtlConfig: pluginCtlConfig, } err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error { @@ -54,10 +60,67 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { return nil } +// isPathIgnored checks if a path matches any of the ignore patterns. +func isPathIgnored(relativePath string, ignorePatterns []string) (ignored bool, matchedPattern string) { + for _, pattern := range ignorePatterns { + // Direct file or path match + if matched, err := filepath.Match(pattern, relativePath); err == nil && matched { + return true, pattern + } + + // Check if the path starts with the pattern (for directory patterns) + if strings.HasSuffix(pattern, "/") && strings.HasPrefix(relativePath, pattern) { + return true, pattern + } + + // Check if any parent directory matches the pattern + if matchesParentDirectory(relativePath, pattern) { + return true, pattern + } + + // Check if any directory component matches the pattern + if matchesDirectoryComponent(relativePath, pattern) { + return true, pattern + } + } + + return false, "" +} + +// matchesParentDirectory checks if any parent directory matches the pattern. +func matchesParentDirectory(relativePath, pattern string) bool { + dir := filepath.Dir(relativePath) + for dir != "." && dir != "/" { + if matched, err := filepath.Match(pattern, dir); err == nil && matched { + return true + } + // Also check direct string match for directory names + if filepath.Base(dir) == pattern { + return true + } + dir = filepath.Dir(dir) + } + + return false +} + +// matchesDirectoryComponent checks if any directory component matches the pattern. +func matchesDirectoryComponent(relativePath, pattern string) bool { + parts := strings.Split(relativePath, "/") + for _, part := range parts { + if matched, err := filepath.Match(pattern, part); err == nil && matched { + return true + } + } + + return false +} + type AssetProcessorConfig struct { - pluginPath string - hasWebapp bool - updatedCount *int + pluginPath string + hasWebapp bool + updatedCount *int + pluginCtlConfig *PluginCtlConfig } func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProcessorConfig) error { @@ -75,6 +138,13 @@ func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProces return nil } + // Check if path is ignored by pluginctl config + if ignored, pattern := isPathIgnored(relativePath, config.pluginCtlConfig.IgnoreAssets); ignored { + Logger.Info("Skipping asset due to ignore pattern", "path", relativePath, "pattern", pattern) + + return nil + } + targetPath := filepath.Join(config.pluginPath, relativePath) if d.IsDir() { From 6639dad2d6116c038b067893f48c9f71784c6c22 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:53:10 +0200 Subject: [PATCH 08/10] Add check subcommand to manifest command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'check' case to manifest command that validates plugin manifest using manifest.IsValid() - Return appropriate exit codes: 0 for valid, 1 for invalid manifests - Update help text and error messages to include the new subcommand - Log validation results using structured logging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/build/build.mk | 4 ++-- cmd/pluginctl/main.go | 3 ++- manifest.go | 10 ++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/assets/build/build.mk b/assets/build/build.mk index 8be43ec..4833938 100644 --- a/assets/build/build.mk +++ b/assets/build/build.mk @@ -9,7 +9,7 @@ all: check-style test dist ## Ensures the plugin manifest is valid .PHONY: manifest-check manifest-check: - ./build/bin/manifest check + pluginctl manifest check ## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. @@ -55,7 +55,7 @@ endif bundle: rm -rf dist/ mkdir -p dist/$(PLUGIN_ID) - ./build/bin/manifest dist + cp plugin.json dist/$(PLUGIN_ID)/plugin.json ifneq ($(wildcard $(ASSETS_DIR)/.),) cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ endif diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 59688c9..1031cbb 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -118,7 +118,7 @@ Commands: disable Disable plugin from current directory in Mattermost server reset Reset plugin from current directory (disable then enable) updateassets Update plugin files from embedded assets - manifest Get plugin manifest information (id, version, has_server, has_webapp) + manifest Get plugin manifest information (id, version, has_server, has_webapp, check) logs View plugin logs (use --watch to follow logs in real-time) help Show this help message version Show version information @@ -134,6 +134,7 @@ Examples: pluginctl manifest version # Get plugin version pluginctl manifest has_server # Check if plugin has server code pluginctl manifest has_webapp # Check if plugin has webapp code + pluginctl manifest check # Validate plugin manifest pluginctl logs # View recent plugin logs pluginctl logs --watch # Watch plugin logs in real-time export PLUGINCTL_PLUGIN_PATH=/path/to/plugin diff --git a/manifest.go b/manifest.go index 6192057..ce1c83c 100644 --- a/manifest.go +++ b/manifest.go @@ -8,7 +8,7 @@ import ( // RunManifestCommand implements the 'manifest' command functionality with subcommands. func RunManifestCommand(args []string, pluginPath string) error { if len(args) == 0 { - return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp") + return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp, check") } // Convert to absolute path @@ -41,8 +41,14 @@ func RunManifestCommand(args []string, pluginPath string) error { } else { fmt.Println("false") } + case "check": + if err := manifest.IsValid(); err != nil { + Logger.Error("Plugin manifest validation failed", "error", err) + return err + } + Logger.Info("Plugin manifest is valid") default: - return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp", + return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp, check", subcommand) } From 278958d1e420cb7156461dd60b00cd7ce4729aca Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 19:17:25 +0200 Subject: [PATCH 09/10] Add deploy command to upload and enable plugin bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New deploy.go implements RunDeployCommand function - Auto-discovers plugin bundle from ./dist/ folder based on manifest - Supports --bundle-path flag for custom bundle location - Reuses existing client connection logic for server authentication - Updated main.go to register deploy command and add help documentation - Follows existing patterns for error handling and structured logging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/build/deploy.mk | 2 +- cmd/pluginctl/main.go | 9 ++++ deploy.go | 98 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 deploy.go diff --git a/assets/build/deploy.mk b/assets/build/deploy.mk index 7c05b31..9b37e21 100644 --- a/assets/build/deploy.mk +++ b/assets/build/deploy.mk @@ -5,7 +5,7 @@ ## Builds and installs the plugin to a server. .PHONY: deploy deploy: dist - ./build/bin/pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) + pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) ## Builds and installs the plugin to a server, updating the webapp automatically when changed. .PHONY: watch diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 1031cbb..ff96fc2 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -52,6 +52,8 @@ func runCommand(command string, args []string, pluginPath string) error { return runDisableCommand(args, pluginPath) case "reset": return runResetCommand(args, pluginPath) + case "deploy": + return runDeployCommand(args, pluginPath) case "updateassets": return runUpdateAssetsCommand(args, pluginPath) case "manifest": @@ -103,6 +105,10 @@ func runLogsCommand(args []string, pluginPath string) error { return pluginctl.RunLogsCommand(args, pluginPath) } +func runDeployCommand(args []string, pluginPath string) error { + return pluginctl.RunDeployCommand(args, pluginPath) +} + func showUsage() { usageText := `pluginctl - Mattermost Plugin Development CLI @@ -117,6 +123,7 @@ Commands: 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) + deploy Upload and enable plugin bundle to Mattermost server updateassets Update plugin files from embedded assets manifest Get plugin manifest information (id, version, has_server, has_webapp, check) logs View plugin logs (use --watch to follow logs in real-time) @@ -129,6 +136,8 @@ Examples: pluginctl enable # Enable plugin from current directory pluginctl disable # Disable plugin from current directory pluginctl reset # Reset plugin from current directory (disable then enable) + pluginctl deploy # Upload and enable plugin bundle from ./dist/ + pluginctl deploy --bundle-path ./bundle.tar.gz # Deploy specific bundle file pluginctl updateassets # Update plugin files from embedded assets pluginctl manifest id # Get plugin ID pluginctl manifest version # Get plugin version diff --git a/deploy.go b/deploy.go new file mode 100644 index 0000000..8997c39 --- /dev/null +++ b/deploy.go @@ -0,0 +1,98 @@ +package pluginctl + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/mattermost/mattermost/server/public/model" +) + +func RunDeployCommand(args []string, pluginPath string) error { + var bundlePath string + + // Parse flags + i := 0 + for i < len(args) { + switch args[i] { + case "--bundle-path": + if i+1 >= len(args) { + return fmt.Errorf("--bundle-path flag requires a value") + } + bundlePath = args[i+1] + i += 2 + default: + i++ + } + } + + // If no bundle path provided, auto-discover from dist folder + if bundlePath == "" { + manifest, err := LoadPluginManifestFromPath(pluginPath) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) + } + + expectedBundleName := fmt.Sprintf("%s-%s.tar.gz", manifest.Id, manifest.Version) + bundlePath = filepath.Join(pluginPath, "dist", expectedBundleName) + + if _, err := os.Stat(bundlePath); os.IsNotExist(err) { + return fmt.Errorf("bundle not found at %s - run 'make bundle' to build the plugin first", bundlePath) + } + } + + // Validate bundle file exists + if _, err := os.Stat(bundlePath); os.IsNotExist(err) { + return fmt.Errorf("bundle file not found: %s", bundlePath) + } + + // Load manifest to get 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 fmt.Errorf("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 deployPlugin(ctx, client, pluginID, bundlePath) +} + +func deployPlugin(ctx context.Context, client *model.Client4, pluginID, bundlePath string) error { + pluginBundle, err := os.Open(bundlePath) + if err != nil { + return fmt.Errorf("failed to open bundle file %s: %w", bundlePath, err) + } + defer func() { + if closeErr := pluginBundle.Close(); closeErr != nil { + Logger.Error("Failed to close plugin bundle", "error", closeErr) + } + }() + + Logger.Info("Uploading plugin bundle", "bundle_path", bundlePath) + _, _, err = client.UploadPluginForced(ctx, pluginBundle) + if err != nil { + return fmt.Errorf("failed to upload plugin bundle: %w", err) + } + + Logger.Info("Enabling plugin", "plugin_id", pluginID) + _, err = client.EnablePlugin(ctx, pluginID) + if err != nil { + return fmt.Errorf("failed to enable plugin: %w", err) + } + + Logger.Info("Plugin deployed successfully", "plugin_id", pluginID) + + return nil +} From 03c521f237395b9f08a03bf06c7b3a84fa7a9e24 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 19:19:11 +0200 Subject: [PATCH 10/10] Fix linting issues in manifest.go and plugin_test.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add blank line before return statement in manifest.go - Fix table formatting alignment in plugin_test.go 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- manifest.go | 1 + plugin_test.go | 80 +++++++++++++++++++++++++------------------------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/manifest.go b/manifest.go index ce1c83c..02d7def 100644 --- a/manifest.go +++ b/manifest.go @@ -44,6 +44,7 @@ func RunManifestCommand(args []string, pluginPath string) error { case "check": if err := manifest.IsValid(); err != nil { Logger.Error("Plugin manifest validation failed", "error", err) + return err } Logger.Info("Plugin manifest is valid") diff --git a/plugin_test.go b/plugin_test.go index 834620f..83a0009 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -521,73 +521,73 @@ func TestParsePluginCtlConfig(t *testing.T) { func TestIsPathIgnored(t *testing.T) { tests := []struct { - name string - relativePath string - ignorePatterns []string - expectedIgnore bool + name string + relativePath string + ignorePatterns []string + expectedIgnore bool expectedPattern string }{ { - name: "No ignore patterns", - relativePath: "webapp/dist/main.js", - ignorePatterns: []string{}, - expectedIgnore: false, + name: "No ignore patterns", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{}, + expectedIgnore: false, expectedPattern: "", }, { - name: "Direct file match", - relativePath: "test.js", - ignorePatterns: []string{"*.js"}, - expectedIgnore: true, + name: "Direct file match", + relativePath: "test.js", + ignorePatterns: []string{"*.js"}, + expectedIgnore: true, expectedPattern: "*.js", }, { - name: "Directory pattern with slash", - relativePath: "build/output.js", - ignorePatterns: []string{"build/"}, - expectedIgnore: true, + name: "Directory pattern with slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build/"}, + expectedIgnore: true, expectedPattern: "build/", }, { - name: "Directory pattern without slash", - relativePath: "build/output.js", - ignorePatterns: []string{"build"}, - expectedIgnore: true, + name: "Directory pattern without slash", + relativePath: "build/output.js", + ignorePatterns: []string{"build"}, + expectedIgnore: true, expectedPattern: "build", }, { - name: "Nested directory match", - relativePath: "webapp/dist/main.js", - ignorePatterns: []string{"dist"}, - expectedIgnore: true, + name: "Nested directory match", + relativePath: "webapp/dist/main.js", + ignorePatterns: []string{"dist"}, + expectedIgnore: true, expectedPattern: "dist", }, { - name: "Multiple patterns - first match", - relativePath: "test.js", - ignorePatterns: []string{"*.js", "*.css"}, - expectedIgnore: true, + name: "Multiple patterns - first match", + relativePath: "test.js", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, expectedPattern: "*.js", }, { - name: "Multiple patterns - second match", - relativePath: "style.css", - ignorePatterns: []string{"*.js", "*.css"}, - expectedIgnore: true, + name: "Multiple patterns - second match", + relativePath: "style.css", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: true, expectedPattern: "*.css", }, { - name: "No match", - relativePath: "README.md", - ignorePatterns: []string{"*.js", "*.css"}, - expectedIgnore: false, + name: "No match", + relativePath: "README.md", + ignorePatterns: []string{"*.js", "*.css"}, + expectedIgnore: false, expectedPattern: "", }, { - name: "Complex path with match", - relativePath: "webapp/node_modules/package/file.js", - ignorePatterns: []string{"node_modules"}, - expectedIgnore: true, + name: "Complex path with match", + relativePath: "webapp/node_modules/package/file.js", + ignorePatterns: []string{"node_modules"}, + expectedIgnore: true, expectedPattern: "node_modules", }, }