Add create-plugin command and improve asset management

- 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 <noreply@anthropic.com>
This commit is contained in:
Felipe M 2025-07-28 16:02:57 +02:00
parent d7ac783efc
commit 07f21c6812
No known key found for this signature in database
GPG key ID: 52E5D65FCF99808A
7 changed files with 602 additions and 11 deletions

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

586
create-plugin.go Normal file
View file

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

View file

@ -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":

View file

@ -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 (