diff --git a/assets/.golangci.yml b/assets/.golangci.yml index 59726f7..766b152 100644 --- a/assets/.golangci.yml +++ b/assets/.golangci.yml @@ -6,7 +6,7 @@ linters-settings: gofmt: simplify: true goimports: - local-prefixes: {{.GoModule}} + local-prefixes: {{.GoModule.Name}} govet: check-shadowing: true enable-all: true diff --git a/assets/Makefile b/assets/Makefile index 1ff7725..c3a899c 100644 --- a/assets/Makefile +++ b/assets/Makefile @@ -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 \ No newline at end of file +include build/*.mk diff --git a/assets/build/setup.mk b/assets/build/_setup.mk similarity index 80% rename from assets/build/setup.mk rename to assets/build/_setup.mk index aab3bd0..9e694f8 100644 --- a/assets/build/setup.mk +++ b/assets/build/_setup.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 diff --git a/assets/build/utils.mk b/assets/build/utils.mk index eb44805..8937445 100644 --- a/assets/build/utils.mk +++ b/assets/build/utils.mk @@ -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 \ No newline at end of file + @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 diff --git a/assets/build/versioning.mk b/assets/build/versioning.mk index 4616437..ca7a31a 100644 --- a/assets/build/versioning.mk +++ b/assets/build/versioning.mk @@ -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) \ No newline at end of file + @echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) diff --git a/assets/webapp/babel.config.js b/assets/webapp/babel.config.js index 2aa81bf..effdb31 100644 --- a/assets/webapp/babel.config.js +++ b/assets/webapp/babel.config.js @@ -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', ], }; diff --git a/assets/webapp/webpack.config.js b/assets/webapp/webpack.config.js index a3d9032..65f467e 100644 --- a/assets/webapp/webpack.config.js +++ b/assets/webapp/webpack.config.js @@ -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: { diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index ff96fc2..0d3f2b8 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -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 --help' for detailed information about a command. + For more information about Mattermost plugin development, visit: https://developers.mattermost.com/integrate/plugins/ ` diff --git a/create-plugin.go b/create-plugin.go new file mode 100644 index 0000000..1628e49 --- /dev/null +++ b/create-plugin.go @@ -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 +} diff --git a/deploy.go b/deploy.go index 8997c39..a068f89 100644 --- a/deploy.go +++ b/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() diff --git a/disable.go b/disable.go index 57cd2db..4fdae07 100644 --- a/disable.go +++ b/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) } diff --git a/enable.go b/enable.go index 208e6c1..34ad830 100644 --- a/enable.go +++ b/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) } diff --git a/go.mod b/go.mod index 4bfac4f..736b43a 100644 --- a/go.mod +++ b/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 diff --git a/info.go b/info.go index fb1e3b8..662dc85 100644 --- a/info.go +++ b/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 { diff --git a/logs.go b/logs.go index 4a19597..ddde6d5 100644 --- a/logs.go +++ b/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" { diff --git a/manifest.go b/manifest.go index 02d7def..c2ea165 100644 --- a/manifest.go +++ b/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 [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 diff --git a/plugin.go b/plugin.go index b8612ad..60df90c 100644 --- a/plugin.go +++ b/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) +} diff --git a/reset.go b/reset.go index 03f4ac7..73e7086 100644 --- a/reset.go +++ b/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) } diff --git a/updateassets.go b/updateassets.go index 705c1db..472b691 100644 --- a/updateassets.go +++ b/updateassets.go @@ -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 } }