Compare commits
7 commits
2e2a95d7d6
...
dee239a3d4
Author | SHA1 | Date | |
---|---|---|---|
dee239a3d4 | |||
59dd709d83 | |||
6403d1a51d | |||
0bc6d51b24 | |||
18bfca1c2c | |||
07f21c6812 | |||
d7ac783efc |
19 changed files with 1138 additions and 114 deletions
|
@ -6,7 +6,7 @@ linters-settings:
|
|||
gofmt:
|
||||
simplify: true
|
||||
goimports:
|
||||
local-prefixes: {{.GoModule}}
|
||||
local-prefixes: {{.GoModule.Name}}
|
||||
govet:
|
||||
check-shadowing: true
|
||||
enable-all: true
|
||||
|
|
|
@ -26,13 +26,6 @@ default: all
|
|||
# Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed.
|
||||
include build/setup.mk
|
||||
|
||||
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
|
||||
|
||||
# Include custom makefile, if present
|
||||
ifneq ($(wildcard build/custom.mk),)
|
||||
include build/custom.mk
|
||||
endif
|
||||
|
||||
ifneq ($(MM_DEBUG),)
|
||||
GO_BUILD_GCFLAGS = -gcflags "all=-N -l"
|
||||
else
|
||||
|
@ -40,4 +33,4 @@ else
|
|||
endif
|
||||
|
||||
# Include modular makefiles
|
||||
include build/*.mk
|
||||
include build/*.mk
|
||||
|
|
|
@ -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 id)
|
||||
PLUGIN_ID ?= $(shell pluginctl manifest get '{{.id}}')
|
||||
ifeq ($(PLUGIN_ID),)
|
||||
$(error "Cannot parse id from $(MANIFEST_FILE)")
|
||||
endif
|
||||
|
||||
# Extract the plugin version from the manifest.
|
||||
PLUGIN_VERSION ?= $(shell pluginctl manifest version)
|
||||
PLUGIN_VERSION ?= $(shell pluginctl manifest get '{{.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 has_server)
|
||||
HAS_SERVER ?= $(shell pluginctl manifest get '{{.has_server}}')
|
||||
|
||||
# Determine if a webapp is defined in the manifest.
|
||||
HAS_WEBAPP ?= $(shell pluginctl manifest has_webapp)
|
||||
HAS_WEBAPP ?= $(shell pluginctl manifest get '{{.has_webapp}}')
|
||||
|
||||
# Determine if a /public folder is in use
|
||||
HAS_PUBLIC ?= $(wildcard public/.)
|
||||
|
@ -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
|
|
@ -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}}/server/command Command
|
||||
mockgen -destination=server/command/mocks/mock_commands.go -package=mocks {{.GoModule.Name}}/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
|
||||
|
|
|
@ -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 get '{{.id}}')
|
||||
CURRENT_VERSION := $(shell git describe --abbrev=0 --tags)
|
||||
VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION))))
|
||||
MAJOR := $(word 1,$(VERSION_PARTS))
|
||||
|
@ -108,4 +108,4 @@ major-rc: ## to bump major release candidate version (semver)
|
|||
@echo Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
|
||||
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||
@echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||
@echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||
|
|
|
@ -26,10 +26,6 @@ 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',
|
||||
],
|
||||
};
|
||||
|
|
|
@ -2,18 +2,12 @@ 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 = [
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
}),
|
||||
];
|
||||
const plugins = [];
|
||||
if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') {
|
||||
plugins.push({
|
||||
apply: (compiler) => {
|
||||
|
@ -54,8 +48,6 @@ const config = {
|
|||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx|ts|tsx)$/,
|
||||
|
||||
//exclude: /node_modules\/(?!(mattermost-webapp|@mattermost)\/).*/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
|
@ -83,10 +75,6 @@ const config = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
],
|
||||
},
|
||||
externals: {
|
||||
|
|
|
@ -19,10 +19,19 @@ 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")
|
||||
|
@ -60,12 +69,10 @@ 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)
|
||||
}
|
||||
|
@ -109,6 +116,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
|
||||
|
||||
|
@ -117,39 +128,20 @@ 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 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
|
||||
enable Enable plugin in Mattermost server
|
||||
disable Disable plugin in Mattermost server
|
||||
reset Reset plugin (disable then enable)
|
||||
deploy Upload and enable plugin bundle
|
||||
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)
|
||||
help Show this help message
|
||||
manifest Manage plugin manifest files
|
||||
logs View plugin logs
|
||||
create-plugin Create a new plugin from template
|
||||
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
|
||||
|
@ -158,6 +150,8 @@ 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/
|
||||
`
|
||||
|
|
713
create-plugin.go
Normal file
713
create-plugin.go
Normal file
|
@ -0,0 +1,713 @@
|
|||
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
|
||||
}
|
99
deploy.go
99
deploy.go
|
@ -10,15 +10,61 @@ 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 fmt.Errorf("--bundle-path flag requires a value")
|
||||
return "", ShowErrorWithHelp("--bundle-path flag requires a value", helpText)
|
||||
}
|
||||
bundlePath = args[i+1]
|
||||
i += 2
|
||||
|
@ -27,37 +73,50 @@ func RunDeployCommand(args []string, pluginPath string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
expectedBundleName := fmt.Sprintf("%s-%s.tar.gz", manifest.Id, manifest.Version)
|
||||
bundlePath = filepath.Join(pluginPath, "dist", expectedBundleName)
|
||||
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 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
|
||||
}
|
||||
|
||||
// Validate bundle file exists
|
||||
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("bundle file not found: %s", bundlePath)
|
||||
}
|
||||
|
||||
// Load manifest to get plugin ID
|
||||
// Auto-discover from dist folder
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return bundlePath, nil
|
||||
}
|
||||
|
||||
func getPluginIDFromManifest(pluginPath string) (string, error) {
|
||||
manifest, err := LoadPluginManifestFromPath(pluginPath)
|
||||
if err != nil {
|
||||
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()
|
||||
|
||||
|
|
21
disable.go
21
disable.go
|
@ -8,6 +8,27 @@ 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)
|
||||
}
|
||||
|
||||
|
|
21
enable.go
21
enable.go
|
@ -8,6 +8,27 @@ 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
4
go.mod
|
@ -3,6 +3,8 @@ 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
|
||||
)
|
||||
|
@ -129,9 +131,7 @@ 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
21
info.go
|
@ -109,6 +109,27 @@ 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
26
logs.go
|
@ -20,6 +20,32 @@ 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" {
|
||||
|
|
147
manifest.go
147
manifest.go
|
@ -1,14 +1,122 @@
|
|||
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 fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp, check")
|
||||
return ShowErrorWithHelp("manifest command requires a subcommand", helpText)
|
||||
}
|
||||
|
||||
// Convert to absolute path
|
||||
|
@ -25,21 +133,27 @@ func RunManifestCommand(args []string, pluginPath string) error {
|
|||
|
||||
subcommand := args[0]
|
||||
switch subcommand {
|
||||
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")
|
||||
case "get":
|
||||
if len(args) < 2 {
|
||||
return ShowErrorWithHelp("get subcommand requires a template expression", helpText)
|
||||
}
|
||||
case "has_webapp":
|
||||
if HasWebappCode(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 "check":
|
||||
if err := manifest.IsValid(); err != nil {
|
||||
|
@ -49,8 +163,7 @@ func RunManifestCommand(args []string, pluginPath string) error {
|
|||
}
|
||||
Logger.Info("Plugin manifest is valid")
|
||||
default:
|
||||
return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp, check",
|
||||
subcommand)
|
||||
return ShowErrorWithHelp(fmt.Sprintf("unknown subcommand: %s", subcommand), helpText)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
27
plugin.go
27
plugin.go
|
@ -114,3 +114,30 @@ 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)
|
||||
}
|
||||
|
|
21
reset.go
21
reset.go
|
@ -7,6 +7,27 @@ 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)
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ import (
|
|||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
//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
|
||||
var assetsFS embed.FS
|
||||
|
||||
const (
|
||||
|
@ -24,8 +25,33 @@ const (
|
|||
)
|
||||
|
||||
func RunUpdateAssetsCommand(args []string, pluginPath string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("updateassets command does not accept arguments")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Info("Updating assets in plugin directory", "path", pluginPath)
|
||||
|
@ -130,7 +156,7 @@ type AssetProcessorConfig struct {
|
|||
|
||||
// GoModule represents information from go.mod file.
|
||||
type GoModule struct {
|
||||
Module string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
|
@ -284,19 +310,22 @@ func parseGoModule(pluginPath string) (*GoModule, error) {
|
|||
}
|
||||
|
||||
goMod := &GoModule{}
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
// Using strings.SplitAfter for more efficiency
|
||||
for line := range strings.SplitSeq(string(content), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Parse module line
|
||||
if strings.HasPrefix(line, "module ") {
|
||||
goMod.Module = strings.TrimSpace(strings.TrimPrefix(line, "module "))
|
||||
if remainder, found := strings.CutPrefix(line, "module "); found {
|
||||
goMod.Name = strings.TrimSpace(remainder)
|
||||
}
|
||||
|
||||
// Parse go version line
|
||||
if strings.HasPrefix(line, "go ") {
|
||||
goMod.Version = strings.TrimSpace(strings.TrimPrefix(line, "go "))
|
||||
if remainder, found := strings.CutPrefix(line, "go "); found {
|
||||
goMod.Version = strings.TrimSpace(remainder)
|
||||
|
||||
// We don't need to parse any further
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Reference in a new issue