From 07f21c6812ecb3ccafc83513516283c32d36e65d Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 28 Jul 2025 16:02:57 +0200 Subject: [PATCH] Add create-plugin command and improve asset management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new create-plugin command to generate plugins from starter template - Add manifest name subcommand for template context - Replace hardcoded asset paths with explicit file patterns in embed directive - Refactor asset build system to use _setup.mk instead of setup.mk - Update versioning to use pluginctl manifest id for APP_NAME - Clean up Makefile by removing redundant bundle name definition 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/Makefile | 9 +- assets/build/{setup.mk => _setup.mk} | 2 + assets/build/versioning.mk | 4 +- cmd/pluginctl/main.go | 8 + create-plugin.go | 586 +++++++++++++++++++++++++++ manifest.go | 2 + updateassets.go | 2 +- 7 files changed, 602 insertions(+), 11 deletions(-) rename assets/build/{setup.mk => _setup.mk} (96%) create mode 100644 create-plugin.go diff --git a/assets/Makefile b/assets/Makefile index 1ff7725..c3a899c 100644 --- a/assets/Makefile +++ b/assets/Makefile @@ -26,13 +26,6 @@ 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 @@ -40,4 +33,4 @@ else endif # Include modular makefiles -include build/*.mk \ No newline at end of file +include build/*.mk diff --git a/assets/build/setup.mk b/assets/build/_setup.mk similarity index 96% rename from assets/build/setup.mk rename to assets/build/_setup.mk index aab3bd0..4dbc745 100644 --- a/assets/build/setup.mk +++ b/assets/build/_setup.mk @@ -42,3 +42,5 @@ ifeq ($(NPM),) $(error "npm is not available: see https://www.npmjs.com/get-npm") endif endif + +BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz diff --git a/assets/build/versioning.mk b/assets/build/versioning.mk index 4616437..1c63bba 100644 --- a/assets/build/versioning.mk +++ b/assets/build/versioning.mk @@ -4,7 +4,7 @@ # Used for semver bumping PROTECTED_BRANCH := master -APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`) +APP_NAME := $(shell pluginctl manifest id) CURRENT_VERSION := $(shell git describe --abbrev=0 --tags) VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION)))) MAJOR := $(word 1,$(VERSION_PARTS)) @@ -108,4 +108,4 @@ major-rc: ## to bump major release candidate version (semver) @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 + @echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index ff96fc2..ebaed47 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -66,6 +66,8 @@ func runCommand(command string, args []string, pluginPath string) error { return nil case "version": return runVersionCommand(args) + case "create-plugin": + return runCreatePluginCommand(args, pluginPath) default: return fmt.Errorf("unknown command: %s", command) } @@ -109,6 +111,10 @@ func runDeployCommand(args []string, pluginPath string) error { return pluginctl.RunDeployCommand(args, pluginPath) } +func runCreatePluginCommand(args []string, pluginPath string) error { + return pluginctl.RunCreatePluginCommand(args, pluginPath) +} + func showUsage() { usageText := `pluginctl - Mattermost Plugin Development CLI @@ -127,6 +133,7 @@ Commands: 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) + create-plugin Create a new plugin from the starter template help Show this help message version Show version information @@ -146,6 +153,7 @@ Examples: pluginctl manifest check # Validate plugin manifest pluginctl logs # View recent plugin logs pluginctl logs --watch # Watch plugin logs in real-time + pluginctl create-plugin # Create a new plugin from the starter template export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info # Show info using environment variable pluginctl version # Show version information diff --git a/create-plugin.go b/create-plugin.go new file mode 100644 index 0000000..ec31a6a --- /dev/null +++ b/create-plugin.go @@ -0,0 +1,586 @@ +package pluginctl + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + starterTemplateURL = "https://github.com/mattermost/mattermost-plugin-starter-template.git" + starterTemplateWebURL = "https://github.com/mattermost/mattermost-plugin-starter-template" + pluginPrefix = "mattermost-plugin-" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + Bold(true). + Margin(1, 0) + + promptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("36")). + Bold(true) + + inputStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Background(lipgloss.Color("235")). + Padding(0, 1) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")). + Bold(true) + + infoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")) +) + +type inputModel struct { + input string + cursor int + placeholder string + prompt string + validation func(string) string + submitted bool + fixedPrefix string +} + +func (m *inputModel) Init() tea.Cmd { + return nil +} + +func (m *inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + return m.handleKeyMsg(keyMsg) +} + +func (m *inputModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + //nolint:exhaustive + switch msg.Type { + case tea.KeyEsc, tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEnter: + if validationErr := m.validation(m.input); validationErr == "" { + m.submitted = true + + return m, tea.Quit + } + case tea.KeyLeft: + m.handleCursorLeft() + case tea.KeyRight: + m.handleCursorRight() + case tea.KeyBackspace: + m.handleBackspace() + case tea.KeyDelete: + m.handleDelete() + case tea.KeyHome: + m.cursor = 0 + case tea.KeyEnd: + m.cursor = len(m.input) + case tea.KeyRunes: + m.handleRunes(msg.Runes) + } + + return m, nil +} + +func (m *inputModel) handleCursorLeft() { + if m.cursor > 0 { + m.cursor-- + } +} + +func (m *inputModel) handleCursorRight() { + if m.cursor < len(m.input) { + m.cursor++ + } +} + +func (m *inputModel) handleBackspace() { + if m.cursor > 0 { + m.input = m.input[:m.cursor-1] + m.input[m.cursor:] + m.cursor-- + } +} + +func (m *inputModel) handleDelete() { + if m.cursor < len(m.input) { + m.input = m.input[:m.cursor] + m.input[m.cursor+1:] + } +} + +func (m *inputModel) handleRunes(runes []rune) { + m.input = m.input[:m.cursor] + string(runes) + m.input[m.cursor:] + m.cursor += len(runes) +} + +func (m *inputModel) View() string { + var s strings.Builder + + s.WriteString(titleStyle.Render("Create Mattermost Plugin")) + s.WriteString("\n\n") + + s.WriteString(promptStyle.Render(m.prompt)) + s.WriteString("\n") + + // Show input field with fixed prefix + displayValue := m.fixedPrefix + m.input + if m.input == "" { + displayValue = m.fixedPrefix + m.placeholder + } + + // Add cursor (adjust position for fixed prefix) + cursorPos := len(m.fixedPrefix) + m.cursor + if cursorPos <= len(displayValue) { + left := displayValue[:cursorPos] + right := displayValue[cursorPos:] + if cursorPos == len(displayValue) { + displayValue = left + "█" + } else { + displayValue = left + "█" + right[1:] + } + } + + s.WriteString(inputStyle.Render(displayValue)) + s.WriteString("\n") + + // Show validation message + if m.input != "" { + if validationErr := m.validation(m.input); validationErr != "" { + s.WriteString(errorStyle.Render("✗ " + validationErr)) + } else { + fullName := m.fixedPrefix + m.input + s.WriteString(successStyle.Render("✓ Valid plugin name: " + fullName)) + } + s.WriteString("\n") + } + + s.WriteString(infoStyle.Render("\nPress Enter to continue, Ctrl+C to cancel")) + + return s.String() +} + +func validatePluginSuffix(suffix string) string { + if suffix == "" { + return "Plugin name cannot be empty" + } + + // Check for valid Go module name (simplified) + validName := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + if !validName.MatchString(suffix) { + return "Plugin name contains invalid characters" + } + + // Check for reasonable length + if len(suffix) < 2 { + return "Plugin name must be at least 2 characters long" + } + + return "" +} + +func validateModuleName(name string) string { + if name == "" { + return "Module name cannot be empty" + } + + // Check for valid Go module name + validModule := regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) + if !validModule.MatchString(name) { + return "Module name contains invalid characters" + } + + // Should look like a valid repository path + if !strings.Contains(name, "/") { + return "Module name should be a repository path (e.g., github.com/user/repo)" + } + + return "" +} + +func toTitleCase(s string) string { + words := strings.Fields(s) + for i, word := range words { + if word != "" { + words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) + } + } + + return strings.Join(words, " ") +} + +func promptForPluginName() (string, error) { + model := &inputModel{ + prompt: "Enter plugin name:", + placeholder: "example", + validation: validatePluginSuffix, + fixedPrefix: pluginPrefix, + } + + p := tea.NewProgram(model) + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("failed to run input prompt: %w", err) + } + + result, ok := finalModel.(*inputModel) + if !ok { + return "", fmt.Errorf("unexpected model type") + } + if !result.submitted { + return "", fmt.Errorf("operation canceled") + } + + // Return the full plugin name (prefix + suffix) + return result.fixedPrefix + result.input, nil +} + +func promptForModuleName(pluginName string) (string, error) { + // Generate a sensible default based on the plugin name + defaultModule := fmt.Sprintf("github.com/user/%s", pluginName) + model := &inputModel{ + prompt: "Enter Go module name (repository path):", + placeholder: defaultModule, + validation: validateModuleName, + } + + p := tea.NewProgram(model) + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("failed to run input prompt: %w", err) + } + + result, ok := finalModel.(*inputModel) + if !ok { + return "", fmt.Errorf("unexpected model type") + } + if !result.submitted { + return "", fmt.Errorf("operation canceled") + } + + return result.input, nil +} + +func RunCreatePluginCommand(args []string, pluginPath string) error { + if len(args) > 0 { + return fmt.Errorf("create-plugin command does not accept arguments") + } + + Logger.Info("Starting plugin creation process") + + // Prompt for plugin name + pluginName, err := promptForPluginName() + if err != nil { + return fmt.Errorf("failed to get plugin name: %w", err) + } + + // Prompt for module name + moduleName, err := promptForModuleName(pluginName) + if err != nil { + return fmt.Errorf("failed to get module name: %w", err) + } + + Logger.Info("Creating plugin", "name", pluginName, "module", moduleName) + + // Check if directory already exists + if _, err := os.Stat(pluginName); err == nil { + return fmt.Errorf("directory '%s' already exists", pluginName) + } + + // Clone the starter template + Logger.Info("Cloning starter template repository") + if err := cloneStarterTemplate(pluginName); err != nil { + return fmt.Errorf("failed to clone starter template: %w", err) + } + + // Process the template + Logger.Info("Processing template files") + if err := processPluginTemplate(pluginName, moduleName); err != nil { + return fmt.Errorf("failed to process template: %w", err) + } + + // Update assets + Logger.Info("Updating plugin assets") + if err := RunUpdateAssetsCommand([]string{}, pluginName); err != nil { + return fmt.Errorf("failed to update assets: %w", err) + } + + // Run go mod tidy + Logger.Info("Running go mod tidy") + if err := runGoModTidy(pluginName); err != nil { + return fmt.Errorf("failed to run go mod tidy: %w", err) + } + + // Initialize git and create initial commit + Logger.Info("Initializing git repository") + if err := initializeGitRepo(pluginName); err != nil { + return fmt.Errorf("failed to initialize git repository: %w", err) + } + + Logger.Info("Plugin created successfully!", "name", pluginName, "path", pluginName) + fmt.Printf("\n%s\n", successStyle.Render("✓ Plugin created successfully!")) + fmt.Printf("%s\n", infoStyle.Render(fmt.Sprintf("Plugin '%s' has been created in directory: %s", + pluginName, pluginName))) + fmt.Printf("%s\n", infoStyle.Render("Next steps:")) + fmt.Printf("%s\n", infoStyle.Render(" 1. cd "+pluginName)) + fmt.Printf("%s\n", infoStyle.Render(" 2. Review and modify the plugin.json file")) + fmt.Printf("%s\n", infoStyle.Render(" 3. Start developing your plugin!")) + + return nil +} + +func cloneStarterTemplate(pluginName string) error { + cmd := exec.Command("git", "clone", "--depth", "1", starterTemplateURL, pluginName) + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + + // Remove the .git directory from the template + gitDir := filepath.Join(pluginName, ".git") + if err := os.RemoveAll(gitDir); err != nil { + return fmt.Errorf("failed to remove .git directory: %w", err) + } + + return nil +} + +func processPluginTemplate(pluginName, moduleName string) error { + // Update go.mod + if err := updateGoMod(pluginName, moduleName); err != nil { + return fmt.Errorf("failed to update go.mod: %w", err) + } + + // Update plugin.json + if err := updatePluginJSON(pluginName, moduleName); err != nil { + return fmt.Errorf("failed to update plugin.json: %w", err) + } + + // Update Go code references + if err := updateGoCodeReferences(pluginName, moduleName); err != nil { + return fmt.Errorf("failed to update Go code references: %w", err) + } + + // Update README.md + if err := updateReadme(pluginName, moduleName); err != nil { + return fmt.Errorf("failed to update README.md: %w", err) + } + + // Update hello.html + if err := updateHelloHTML(pluginName); err != nil { + return fmt.Errorf("failed to update hello.html: %w", err) + } + + return nil +} + +func updateGoMod(pluginName, moduleName string) error { + goModPath := filepath.Join(pluginName, "go.mod") + content, err := os.ReadFile(goModPath) + if err != nil { + return fmt.Errorf("failed to read go.mod: %w", err) + } + + // Replace the module name + updated := strings.Replace(string(content), + "github.com/mattermost/mattermost-plugin-starter-template", moduleName, 1) + + if err := os.WriteFile(goModPath, []byte(updated), filePermissions); err != nil { + return fmt.Errorf("failed to write go.mod: %w", err) + } + + return nil +} + +func updatePluginJSON(pluginName, moduleName string) error { + pluginJSONPath := filepath.Join(pluginName, "plugin.json") + + // Load the manifest using the existing function + manifest, err := LoadPluginManifestFromPath(pluginName) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) + } + + // Update the plugin ID (remove the prefix for the ID) + pluginID := strings.TrimPrefix(pluginName, pluginPrefix) + manifest.Id = "com.mattermost." + pluginID + + // Update display name + displayName := strings.ReplaceAll(pluginID, "-", " ") + displayName = toTitleCase(displayName) + manifest.Name = displayName + + // Update homepage_url and support_url if module is a GitHub repository + if strings.HasPrefix(moduleName, "github.com/") { + newURL := "https://" + moduleName + manifest.HomepageURL = newURL + manifest.SupportURL = newURL + "/issues" + } + + // Update icon path + if manifest.IconPath == "assets/starter-template-icon.svg" { + manifest.IconPath = "assets/" + pluginID + "-icon.svg" + } + + // Write the updated manifest back to JSON + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plugin manifest: %w", err) + } + + if err := os.WriteFile(pluginJSONPath, manifestJSON, filePermissions); err != nil { + return fmt.Errorf("failed to write plugin.json: %w", err) + } + + return nil +} + +func updateGoCodeReferences(pluginName, moduleName string) error { + // Walk through Go files and update import references + return filepath.Walk(pluginName, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !strings.HasSuffix(path, ".go") { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + // Replace import references + updated := strings.Replace(string(content), + "github.com/mattermost/mattermost-plugin-starter-template", moduleName, -1) + + // Update plugin ID in comments (like api.go) + pluginID := strings.TrimPrefix(pluginName, pluginPrefix) + updated = strings.Replace(updated, "com.mattermost.plugin-starter-template", + "com.mattermost."+pluginID, -1) + + if err := os.WriteFile(path, []byte(updated), filePermissions); err != nil { + return fmt.Errorf("failed to write file %s: %w", path, err) + } + + return nil + }) +} + +func runGoModTidy(pluginName string) error { + cmd := exec.Command("go", "mod", "tidy") + cmd.Dir = pluginName + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("go mod tidy failed: %w", err) + } + + return nil +} + +func initializeGitRepo(pluginName string) error { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = pluginName + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git init failed: %w", err) + } + + // Add all files + cmd = exec.Command("git", "add", ".") + cmd.Dir = pluginName + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git add failed: %w", err) + } + + // Create initial commit + cmd = exec.Command("git", "commit", "-m", "Initial commit from mattermost-plugin-starter-template") + cmd.Dir = pluginName + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git commit failed: %w", err) + } + + return nil +} + +func updateReadme(pluginName, moduleName string) error { + readmePath := filepath.Join(pluginName, "README.md") + content, err := os.ReadFile(readmePath) + if err != nil { + // README.md might not exist, which is fine + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to read README.md: %w", err) + } + + updated := string(content) + + // Update all GitHub URLs to point to the new repository + if strings.HasPrefix(moduleName, "github.com/") { + newURL := "https://" + moduleName + updated = strings.Replace(updated, starterTemplateWebURL, newURL, -1) + } + + // Update plugin ID in comments and examples + pluginID := strings.TrimPrefix(pluginName, pluginPrefix) + updated = strings.Replace(updated, "com.mattermost.plugin-starter-template", + "com.mattermost."+pluginID, -1) + + // Update clone command example + updated = strings.Replace(updated, "com.example.my-plugin", "com.example."+pluginID, -1) + + if err := os.WriteFile(readmePath, []byte(updated), filePermissions); err != nil { + return fmt.Errorf("failed to write README.md: %w", err) + } + + return nil +} + +func updateHelloHTML(pluginName string) error { + helloPath := filepath.Join(pluginName, "public", "hello.html") + content, err := os.ReadFile(helloPath) + if err != nil { + // hello.html might not exist, which is fine + if os.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to read hello.html: %w", err) + } + + pluginID := strings.TrimPrefix(pluginName, pluginPrefix) + updated := strings.Replace(string(content), "com.mattermost.plugin-starter-template", + "com.mattermost."+pluginID, -1) + + if err := os.WriteFile(helloPath, []byte(updated), filePermissions); err != nil { + return fmt.Errorf("failed to write hello.html: %w", err) + } + + return nil +} diff --git a/manifest.go b/manifest.go index 02d7def..a5f959a 100644 --- a/manifest.go +++ b/manifest.go @@ -27,6 +27,8 @@ func RunManifestCommand(args []string, pluginPath string) error { switch subcommand { case "id": fmt.Println(manifest.Id) + case "name": + fmt.Println(manifest.Name) case "version": fmt.Println(manifest.Version) case "has_server": diff --git a/updateassets.go b/updateassets.go index c435c1c..0822b8f 100644 --- a/updateassets.go +++ b/updateassets.go @@ -13,7 +13,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" ) -//go:embed assets/* +//go:embed assets/.editorconfig assets/.gitattributes assets/.nvmrc assets/Makefile assets/*.yml assets/build/*.mk assets/.github/**/*.yml assets/webapp/.npmrc assets/webapp/*.config.js var assetsFS embed.FS const (