Compare commits

...

7 commits

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 19:20:36 +02:00
59dd709d83
chroe: updated webapp files 2025-07-28 18:01:22 +02:00
6403d1a51d
Add manifest apply command to generate server and webapp manifest files
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 18:00:41 +02:00
0bc6d51b24
Refactor manifest command to use template-based field access
Replace individual subcommands (id, version, has_server, has_webapp) with
a unified 'get {{.field_name}}' pattern that uses Go templates to access
any field from the manifest JSON dynamically.

- Update manifest.go to parse and execute Go templates against manifest data
- Update makefile calls in _setup.mk and versioning.mk to use new syntax
- Update help documentation to reflect template-based usage
- Provides more flexibility for accessing any manifest field without code changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 17:50:42 +02:00
18bfca1c2c
Add non-interactive flag support to create-plugin command
- Add --name and --module flags for non-interactive plugin creation
- Implement flag parsing and validation using existing validation functions
- Support plugin names with or without mattermost-plugin- prefix
- Maintain backward compatibility with interactive mode as fallback
- Update help text and examples to document new flag options

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 16:50:42 +02:00
07f21c6812
Add create-plugin command and improve asset management
- Add new create-plugin command to generate plugins from starter template
- Add manifest name subcommand for template context
- Replace hardcoded asset paths with explicit file patterns in embed directive
- Refactor asset build system to use _setup.mk instead of setup.mk
- Update versioning to use pluginctl manifest id for APP_NAME
- Clean up Makefile by removing redundant bundle name definition

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 16:02:57 +02:00
d7ac783efc
Fix parseGoModule function to use strings.SplitSeq and improve efficiency
- Update parseGoModule to use strings.SplitSeq for better performance
- Change GoModule.Module field to GoModule.Name for consistency
- Use strings.CutPrefix instead of HasPrefix + TrimPrefix pattern
- Add early break after parsing go version line
- Update template references to use .GoModule.Name instead of .GoModule

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 14:45:11 +02:00
19 changed files with 1138 additions and 114 deletions

View file

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

View file

@ -26,13 +26,6 @@ default: all
# Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed.
include build/setup.mk
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
# Include custom makefile, if present
ifneq ($(wildcard build/custom.mk),)
include build/custom.mk
endif
ifneq ($(MM_DEBUG),)
GO_BUILD_GCFLAGS = -gcflags "all=-N -l"
else
@ -40,4 +33,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 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

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

View file

@ -4,7 +4,7 @@
# Used for semver bumping
PROTECTED_BRANCH := master
APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`)
APP_NAME := $(shell pluginctl manifest 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)

View file

@ -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',
],
};

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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