pluginctl/create-plugin.go
Felipe Martin dee239a3d4
Refactor help system to consolidate error messages and command-specific help
- Simplify main help to show brief command descriptions only
- Add --help support to all commands with detailed usage information
- Replace duplicated help text in error messages with error + help pattern
- Remove 'help' command in favor of consistent --help flag usage
- Add helper functions CheckForHelpFlag() and ShowErrorWithHelp() for standardization
- Refactor deploy command to reduce cognitive complexity and improve maintainability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 19:20:36 +02:00

713 lines
19 KiB
Go

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 parseCreatePluginFlags(args []string, helpText string) (pluginName, moduleName string, err error) {
pluginName, moduleName, err = parseFlags(args, helpText)
if err != nil {
return "", "", err
}
pluginName, err = validatePluginName(pluginName, helpText)
if err != nil {
return "", "", err
}
moduleName, err = validateModuleNameWithHelp(moduleName, helpText)
if err != nil {
return "", "", err
}
return pluginName, moduleName, nil
}
func parseFlags(args []string, helpText string) (pluginName, moduleName string, err error) {
for i, arg := range args {
switch arg {
case "--name":
if i+1 >= len(args) {
return "", "", ShowErrorWithHelp("--name flag requires a value", helpText)
}
pluginName = args[i+1]
case "--module":
if i+1 >= len(args) {
return "", "", ShowErrorWithHelp("--module flag requires a value", helpText)
}
moduleName = args[i+1]
case HelpFlagLong, HelpFlagShort:
// Skip help flags as they're handled earlier
default:
if strings.HasPrefix(arg, "--") {
return "", "", ShowErrorWithHelp(fmt.Sprintf("unknown flag: %s", arg), helpText)
}
}
}
return pluginName, moduleName, nil
}
func validatePluginName(pluginName, helpText string) (string, error) {
if pluginName == "" {
return "", nil
}
validated, err := validateAndProcessPluginName(pluginName)
if err != nil {
return "", ShowErrorWithHelp(err.Error(), helpText)
}
return validated, nil
}
func validateModuleNameWithHelp(moduleName, helpText string) (string, error) {
if moduleName == "" {
return "", nil
}
if validationErr := validateModuleName(moduleName); validationErr != "" {
return "", ShowErrorWithHelp(fmt.Sprintf("invalid module name: %s", validationErr), helpText)
}
return moduleName, nil
}
// validateAndProcessPluginName checks if the plugin name has the correct prefix and adds it if necessary.
// Example:
// - If the input is "my-plugin", it returns "mattermost-plugin-my-plugin".
// - If the input is "mattermost-plugin-my-plugin", it returns "mattermost-plugin-my-plugin".
func validateAndProcessPluginName(name string) (string, error) {
// Check if the name already has the prefix
if !strings.HasPrefix(name, pluginPrefix) {
// If not, validate the suffix and add prefix
if err := validatePluginSuffix(name); err != "" {
return "", fmt.Errorf("invalid plugin name: %s", err)
}
return pluginPrefix + name, nil
}
// If it has the prefix, validate the suffix part
suffix := strings.TrimPrefix(name, pluginPrefix)
if err := validatePluginSuffix(suffix); err != "" {
return "", fmt.Errorf("invalid plugin name: %s", err)
}
return name, nil
}
func RunCreatePluginCommand(args []string, pluginPath string) error {
helpText := `Create a new plugin from template
Usage:
pluginctl create-plugin [options]
Options:
--name PLUGIN_NAME Plugin name (will be prefixed with 'mattermost-plugin-')
--module MODULE_NAME Go module name (e.g., github.com/user/mattermost-plugin-example)
--help, -h Show this help message
Description:
Creates a new Mattermost plugin from the starter template. If no options are
provided, the command will run in interactive mode prompting for values.
The plugin name will automatically be prefixed with 'mattermost-plugin-' if
not already present.
Examples:
pluginctl create-plugin # Interactive mode
pluginctl create-plugin --name example --module github.com/user/mattermost-plugin-example
pluginctl --plugin-path /path/to/parent create-plugin # Create in specific directory`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
// Parse flags
var pluginName, moduleName string
var err error
pluginName, moduleName, err = parseCreatePluginFlags(args, helpText)
if err != nil {
return err
}
Logger.Info("Starting plugin creation process")
// If flags were not provided, fall back to interactive mode
if pluginName == "" {
pluginName, err = promptForPluginName()
if err != nil {
return fmt.Errorf("failed to get plugin name: %w", err)
}
}
if moduleName == "" {
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
}