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:
parent
d7ac783efc
commit
07f21c6812
7 changed files with 602 additions and 11 deletions
586
create-plugin.go
Normal file
586
create-plugin.go
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue