Compare commits

..

No commits in common. "dee239a3d4a0a3cb50c13a4d5a0fb4ca7e00deb0" and "2e2a95d7d6d8e70b1aeb81779370628770ae99bc" have entirely different histories.

19 changed files with 112 additions and 1136 deletions

View file

@ -6,7 +6,7 @@ linters-settings:
gofmt:
simplify: true
goimports:
local-prefixes: {{.GoModule.Name}}
local-prefixes: {{.GoModule}}
govet:
check-shadowing: true
enable-all: true

View file

@ -26,6 +26,13 @@ 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
@ -33,4 +40,4 @@ else
endif
# Include modular makefiles
include build/*.mk
include build/*.mk

View file

@ -10,22 +10,22 @@ BUILD_TAG_LATEST = $(shell git describe --tags --match 'v*' --abbrev=0 2>/dev/nu
BUILD_TAG_CURRENT = $(shell git tag --points-at HEAD)
# Extract the plugin id from the manifest.
PLUGIN_ID ?= $(shell pluginctl manifest get '{{.id}}')
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 get '{{.version}}')
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 get '{{.has_server}}')
HAS_SERVER ?= $(shell pluginctl manifest has_server)
# Determine if a webapp is defined in the manifest.
HAS_WEBAPP ?= $(shell pluginctl manifest get '{{.has_webapp}}')
HAS_WEBAPP ?= $(shell pluginctl manifest has_webapp)
# Determine if a /public folder is in use
HAS_PUBLIC ?= $(wildcard public/.)
@ -42,5 +42,3 @@ 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

@ -33,10 +33,10 @@ endif
mock:
ifneq ($(HAS_SERVER),)
go install github.com/golang/mock/mockgen@v1.6.0
mockgen -destination=server/command/mocks/mock_commands.go -package=mocks {{.GoModule.Name}}/server/command Command
mockgen -destination=server/command/mocks/mock_commands.go -package=mocks {{.GoModule}}/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
@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

View file

@ -4,7 +4,7 @@
# Used for semver bumping
PROTECTED_BRANCH := master
APP_NAME := $(shell pluginctl manifest get '{{.id}}')
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))
@ -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)
@echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)

View file

@ -26,6 +26,10 @@ const config = {
['@emotion/babel-preset-css-prop'],
],
plugins: [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-syntax-dynamic-import',
'@babel/proposal-object-rest-spread',
'@babel/plugin-proposal-optional-chaining',
'babel-plugin-typescript-to-proptypes',
],
};

View file

@ -2,12 +2,18 @@ const exec = require('child_process').exec;
const path = require('path');
const webpack = require('webpack');
const PLUGIN_ID = require('../plugin.json').id;
const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env
const isDev = NPM_TARGET === 'debug' || NPM_TARGET === 'debug:watch';
const plugins = [];
const plugins = [
new webpack.ProvidePlugin({
process: 'process/browser',
}),
];
if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') {
plugins.push({
apply: (compiler) => {
@ -48,6 +54,8 @@ const config = {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
//exclude: /node_modules\/(?!(mattermost-webapp|@mattermost)\/).*/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
@ -75,6 +83,10 @@ const config = {
},
],
},
{
test: /\.svg$/,
use: ['@svgr/webpack'],
},
],
},
externals: {

View file

@ -19,19 +19,10 @@ func main() {
pluginctl.InitLogger()
var pluginPath string
var showHelp bool
flag.StringVar(&pluginPath, "plugin-path", "", "Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)")
flag.BoolVar(&showHelp, "help", false, "Show help information")
flag.Parse()
// Show help if requested
if showHelp {
showUsage()
return
}
args := flag.Args()
if len(args) == 0 {
pluginctl.Logger.Error("No command specified")
@ -69,10 +60,12 @@ func runCommand(command string, args []string, pluginPath string) error {
return runManifestCommand(args, pluginPath)
case "logs":
return runLogsCommand(args, pluginPath)
case "help":
showUsage()
return nil
case "version":
return runVersionCommand(args)
case "create-plugin":
return runCreatePluginCommand(args, pluginPath)
default:
return fmt.Errorf("unknown command: %s", command)
}
@ -116,10 +109,6 @@ 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
@ -128,20 +117,39 @@ Usage:
Global Options:
--plugin-path PATH Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)
--help Show this help message
Commands:
info Display plugin information
enable Enable plugin in Mattermost server
disable Disable plugin in Mattermost server
reset Reset plugin (disable then enable)
deploy Upload and enable plugin bundle
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 Manage plugin manifest files
logs View plugin logs
create-plugin Create a new plugin from template
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
Examples:
pluginctl info # Show info for plugin in current directory
pluginctl --plugin-path /path/to/plugin info # Show info for plugin at specific path
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
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
pluginctl info # Show info using environment variable
pluginctl version # Show version information
Environment Variables:
PLUGINCTL_PLUGIN_PATH Default plugin directory path
MM_LOCALSOCKETPATH Path to Mattermost local socket
@ -150,8 +158,6 @@ Environment Variables:
MM_ADMIN_USERNAME Admin username for authentication
MM_ADMIN_PASSWORD Admin password for authentication
Use 'pluginctl <command> --help' for detailed information about a command.
For more information about Mattermost plugin development, visit:
https://developers.mattermost.com/integrate/plugins/
`

View file

@ -1,713 +0,0 @@
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
}

View file

@ -10,61 +10,15 @@ import (
)
func RunDeployCommand(args []string, pluginPath string) error {
helpText := getDeployHelpText()
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
bundlePath, err := parseDeployFlags(args, helpText)
if err != nil {
return err
}
bundlePath, err = resolveBundlePath(bundlePath, pluginPath)
if err != nil {
return err
}
pluginID, err := getPluginIDFromManifest(pluginPath)
if err != nil {
return err
}
return deployPluginBundle(pluginID, bundlePath)
}
func getDeployHelpText() string {
return `Upload and enable plugin bundle
Usage:
pluginctl deploy [options]
Options:
--bundle-path PATH Path to plugin bundle file (.tar.gz)
--help, -h Show this help message
Description:
Uploads a plugin bundle to the Mattermost server and enables it. If no
bundle path is specified, it will auto-discover the bundle from the dist/
directory based on the plugin manifest.
Examples:
pluginctl deploy # Deploy bundle from ./dist/
pluginctl deploy --bundle-path ./bundle.tar.gz # Deploy specific bundle file
pluginctl --plugin-path /path/to/plugin deploy # Deploy plugin at specific path`
}
func parseDeployFlags(args []string, helpText string) (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 "", ShowErrorWithHelp("--bundle-path flag requires a value", helpText)
return fmt.Errorf("--bundle-path flag requires a value")
}
bundlePath = args[i+1]
i += 2
@ -73,50 +27,37 @@ func parseDeployFlags(args []string, helpText string) (string, error) {
}
}
return bundlePath, nil
}
func resolveBundlePath(bundlePath, pluginPath string) (string, error) {
// If bundle path provided, validate it exists
if bundlePath != "" {
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
return "", fmt.Errorf("bundle file not found: %s", bundlePath)
// 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)
}
return bundlePath, nil
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)
}
}
// Auto-discover from dist folder
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)
// Validate bundle file exists
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)
return fmt.Errorf("bundle file not found: %s", bundlePath)
}
return bundlePath, nil
}
func getPluginIDFromManifest(pluginPath string) (string, error) {
// Load manifest to get plugin ID
manifest, err := LoadPluginManifestFromPath(pluginPath)
if err != nil {
return "", fmt.Errorf("failed to load plugin manifest: %w", err)
return fmt.Errorf("failed to load plugin manifest: %w", err)
}
pluginID := manifest.Id
if pluginID == "" {
return "", fmt.Errorf("plugin ID not found in manifest")
return fmt.Errorf("plugin ID not found in manifest")
}
return pluginID, nil
}
func deployPluginBundle(pluginID, bundlePath string) error {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()

View file

@ -8,27 +8,6 @@ import (
)
func RunDisableCommand(args []string, pluginPath string) error {
helpText := `Disable plugin in Mattermost server
Usage:
pluginctl disable [options]
Options:
--help, -h Show this help message
Description:
Disables the plugin in the connected Mattermost server. The plugin will
remain uploaded but will be inactive.
Examples:
pluginctl disable # Disable plugin from current directory
pluginctl --plugin-path /path/to/plugin disable # Disable plugin at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
return runPluginCommand(args, pluginPath, disablePlugin)
}

View file

@ -8,27 +8,6 @@ import (
)
func RunEnableCommand(args []string, pluginPath string) error {
helpText := `Enable plugin in Mattermost server
Usage:
pluginctl enable [options]
Options:
--help, -h Show this help message
Description:
Enables the plugin in the connected Mattermost server. The plugin must already
be uploaded to the server for this command to work.
Examples:
pluginctl enable # Enable plugin from current directory
pluginctl --plugin-path /path/to/plugin enable # Enable plugin at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
return runPluginCommand(args, pluginPath, enablePlugin)
}

4
go.mod
View file

@ -3,8 +3,6 @@ module github.com/mattermost/pluginctl
go 1.24.3
require (
github.com/charmbracelet/bubbletea v1.3.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/lmittmann/tint v1.1.2
github.com/mattermost/mattermost/server/public v0.1.15
)
@ -131,7 +129,9 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/charmbracelet/bubbletea v1.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect

21
info.go
View file

@ -109,27 +109,6 @@ func InfoCommandWithPath(path string) error {
// RunInfoCommand implements the 'info' command functionality with plugin path.
func RunInfoCommand(args []string, pluginPath string) error {
helpText := `Display plugin information
Usage:
pluginctl info [options]
Options:
--help, -h Show this help message
Description:
Shows detailed information about the plugin including ID, name, version,
supported components (server/webapp), and settings schema status.
Examples:
pluginctl info # Show info for plugin in current directory
pluginctl --plugin-path /path/to/plugin info # Show info for plugin at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
// Convert to absolute path
absPath, err := filepath.Abs(pluginPath)
if err != nil {

26
logs.go
View file

@ -20,32 +20,6 @@ const (
// RunLogsCommand executes the logs command with optional --watch flag.
func RunLogsCommand(args []string, pluginPath string) error {
helpText := `View plugin logs
Usage:
pluginctl logs [options]
Options:
--watch Follow logs in real-time
--help, -h Show this help message
Description:
Views plugin logs from the Mattermost server. By default, shows recent log
entries. Use --watch to follow logs in real-time.
Note: JSON output for file logs must be enabled in Mattermost configuration
(LogSettings.FileJson) for this command to work.
Examples:
pluginctl logs # View recent plugin logs
pluginctl logs --watch # Watch plugin logs in real-time
pluginctl --plugin-path /path/to/plugin logs # View logs for plugin at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
// Check for --watch flag
watch := false
if len(args) > 0 && args[0] == "--watch" {

View file

@ -1,122 +1,14 @@
package pluginctl
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"text/template"
"github.com/mattermost/mattermost/server/public/model"
)
const (
// File permissions for generated directories and files.
manifestDirPerm = 0o750
manifestFilePerm = 0o600
)
const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually.
package main
import (
"encoding/json"
"strings"
"github.com/mattermost/mattermost/server/public/model"
)
var manifest *model.Manifest
const manifestStr = ` + "`%s`" + `
func init() {
_ = json.NewDecoder(strings.NewReader(manifestStr)).Decode(&manifest)
}
`
const pluginIDJSFileTemplate = `// This file is automatically generated. Do not modify it manually.
const manifest = JSON.parse(` + "`%s`" + `);
export default manifest;
`
// applyManifest generates manifest files for server and webapp components.
func applyManifest(manifest *model.Manifest, pluginPath string) error {
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("failed to marshal manifest: %w", err)
}
manifestStr := string(manifestBytes)
// Generate server manifest file if server exists
if HasServerCode(manifest) {
serverDir := filepath.Join(pluginPath, "server")
if err := os.MkdirAll(serverDir, manifestDirPerm); err != nil {
return fmt.Errorf("failed to create server directory: %w", err)
}
serverManifestPath := filepath.Join(serverDir, "manifest.go")
serverContent := fmt.Sprintf(pluginIDGoFileTemplate, manifestStr)
if err := os.WriteFile(serverManifestPath, []byte(serverContent), manifestFilePerm); err != nil {
return fmt.Errorf("failed to write server manifest: %w", err)
}
Logger.Info("Generated server manifest", "path", serverManifestPath)
}
// Generate webapp manifest file if webapp exists
if HasWebappCode(manifest) {
webappDir := filepath.Join(pluginPath, "webapp", "src")
if err := os.MkdirAll(webappDir, manifestDirPerm); err != nil {
return fmt.Errorf("failed to create webapp directory: %w", err)
}
webappManifestPath := filepath.Join(webappDir, "manifest.ts")
webappContent := fmt.Sprintf(pluginIDJSFileTemplate, manifestStr)
if err := os.WriteFile(webappManifestPath, []byte(webappContent), manifestFilePerm); err != nil {
return fmt.Errorf("failed to write webapp manifest: %w", err)
}
Logger.Info("Generated webapp manifest", "path", webappManifestPath)
}
return nil
}
// RunManifestCommand implements the 'manifest' command functionality with subcommands.
func RunManifestCommand(args []string, pluginPath string) error {
helpText := `Manage plugin manifest files
Usage:
pluginctl manifest <subcommand> [options]
Subcommands:
get TEMPLATE Get manifest field using template syntax (e.g., {{.id}}, {{.version}})
apply Generate manifest files for server/webapp
check Validate plugin manifest
Options:
--help, -h Show this help message
Examples:
pluginctl manifest get '{{.id}}' # Get plugin ID
pluginctl manifest get '{{.version}}' # Get plugin version
pluginctl manifest apply # Generate server/webapp manifest files
pluginctl manifest check # Validate manifest`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
if len(args) == 0 {
return ShowErrorWithHelp("manifest command requires a subcommand", helpText)
return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp, check")
}
// Convert to absolute path
@ -133,27 +25,21 @@ Examples:
subcommand := args[0]
switch subcommand {
case "get":
if len(args) < 2 {
return ShowErrorWithHelp("get subcommand requires a template expression", helpText)
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")
}
templateStr := args[1]
// Parse and execute template with manifest as context
tmpl, err := template.New("manifest").Parse(templateStr)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, manifest); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
fmt.Print(buf.String())
case "apply":
if err := applyManifest(manifest, absPath); err != nil {
return fmt.Errorf("failed to apply manifest: %w", err)
case "has_webapp":
if HasWebappCode(manifest) {
fmt.Println("true")
} else {
fmt.Println("false")
}
case "check":
if err := manifest.IsValid(); err != nil {
@ -163,7 +49,8 @@ Examples:
}
Logger.Info("Plugin manifest is valid")
default:
return ShowErrorWithHelp(fmt.Sprintf("unknown subcommand: %s", subcommand), helpText)
return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp, check",
subcommand)
}
return nil

View file

@ -114,30 +114,3 @@ func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) {
return config, nil
}
const (
HelpFlagLong = "--help"
HelpFlagShort = "-h"
)
// CheckForHelpFlag checks if --help is in the arguments and shows help if found.
// Returns true if help was shown, false otherwise.
func CheckForHelpFlag(args []string, helpText string) bool {
for _, arg := range args {
if arg == HelpFlagLong || arg == HelpFlagShort {
Logger.Info(helpText)
return true
}
}
return false
}
// ShowErrorWithHelp displays an error message followed by command help.
func ShowErrorWithHelp(errorMsg, helpText string) error {
Logger.Error(errorMsg)
Logger.Info(helpText)
return fmt.Errorf("%s", errorMsg)
}

View file

@ -7,27 +7,6 @@ import (
)
func RunResetCommand(args []string, pluginPath string) error {
helpText := `Reset plugin (disable then enable)
Usage:
pluginctl reset [options]
Options:
--help, -h Show this help message
Description:
Resets the plugin by first disabling it and then enabling it. This is useful
for restarting a plugin without having to redeploy it.
Examples:
pluginctl reset # Reset plugin from current directory
pluginctl --plugin-path /path/to/plugin reset # Reset plugin at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
return runPluginCommand(args, pluginPath, resetPlugin)
}

View file

@ -13,8 +13,7 @@ import (
"github.com/mattermost/mattermost/server/public/model"
)
//go:embed assets/.editorconfig assets/.gitattributes assets/.nvmrc assets/Makefile assets/*.yml
//go:embed assets/build/*.mk assets/.github/**/*.yml assets/webapp/.npmrc assets/webapp/*.config.js
//go:embed assets/*
var assetsFS embed.FS
const (
@ -25,33 +24,8 @@ const (
)
func RunUpdateAssetsCommand(args []string, pluginPath string) error {
helpText := `Update plugin files from embedded assets
Usage:
pluginctl updateassets [options]
Options:
--help, -h Show this help message
Description:
Updates plugin development files such as Makefile, .editorconfig, build
configurations, and other assets from the embedded templates. This ensures
your plugin uses the latest development tooling and configurations.
Examples:
pluginctl updateassets # Update assets in current directory
pluginctl --plugin-path /path/to/plugin updateassets # Update assets at specific path`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
// Check for unexpected arguments
for _, arg := range args {
if arg != "--help" && arg != "-h" {
return ShowErrorWithHelp(fmt.Sprintf("unknown argument: %s", arg), helpText)
}
if len(args) > 0 {
return fmt.Errorf("updateassets command does not accept arguments")
}
Logger.Info("Updating assets in plugin directory", "path", pluginPath)
@ -156,7 +130,7 @@ type AssetProcessorConfig struct {
// GoModule represents information from go.mod file.
type GoModule struct {
Name string
Module string
Version string
}
@ -310,22 +284,19 @@ func parseGoModule(pluginPath string) (*GoModule, error) {
}
goMod := &GoModule{}
lines := strings.Split(string(content), "\n")
// Using strings.SplitAfter for more efficiency
for line := range strings.SplitSeq(string(content), "\n") {
for _, line := range lines {
line = strings.TrimSpace(line)
// Parse module line
if remainder, found := strings.CutPrefix(line, "module "); found {
goMod.Name = strings.TrimSpace(remainder)
if strings.HasPrefix(line, "module ") {
goMod.Module = strings.TrimSpace(strings.TrimPrefix(line, "module "))
}
// Parse go version line
if remainder, found := strings.CutPrefix(line, "go "); found {
goMod.Version = strings.TrimSpace(remainder)
// We don't need to parse any further
break
if strings.HasPrefix(line, "go ") {
goMod.Version = strings.TrimSpace(strings.TrimPrefix(line, "go "))
}
}