diff --git a/assets/.golangci.yml b/assets/.golangci.yml index 766b152..59726f7 100644 --- a/assets/.golangci.yml +++ b/assets/.golangci.yml @@ -6,7 +6,7 @@ linters-settings: gofmt: simplify: true goimports: - local-prefixes: {{.GoModule.Name}} + local-prefixes: {{.GoModule}} govet: check-shadowing: true enable-all: true diff --git a/assets/Makefile b/assets/Makefile index c3a899c..1ff7725 100644 --- a/assets/Makefile +++ b/assets/Makefile @@ -26,6 +26,13 @@ default: all # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. include build/setup.mk +BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz + +# Include custom makefile, if present +ifneq ($(wildcard build/custom.mk),) + include build/custom.mk +endif + ifneq ($(MM_DEBUG),) GO_BUILD_GCFLAGS = -gcflags "all=-N -l" else @@ -33,4 +40,4 @@ else endif # Include modular makefiles -include build/*.mk +include build/*.mk \ No newline at end of file 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 9e694f8..aab3bd0 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 get '{{.id}}') +PLUGIN_ID ?= $(shell pluginctl manifest id) ifeq ($(PLUGIN_ID),) $(error "Cannot parse id from $(MANIFEST_FILE)") endif # Extract the plugin version from the manifest. -PLUGIN_VERSION ?= $(shell pluginctl manifest get '{{.version}}') +PLUGIN_VERSION ?= $(shell pluginctl manifest version) ifeq ($(PLUGIN_VERSION),) $(error "Cannot parse version from $(MANIFEST_FILE)") endif # Determine if a server is defined in the manifest. -HAS_SERVER ?= $(shell pluginctl manifest get '{{.has_server}}') +HAS_SERVER ?= $(shell pluginctl manifest has_server) # Determine if a webapp is defined in the manifest. -HAS_WEBAPP ?= $(shell pluginctl manifest get '{{.has_webapp}}') +HAS_WEBAPP ?= $(shell pluginctl manifest has_webapp) # Determine if a /public folder is in use HAS_PUBLIC ?= $(wildcard public/.) @@ -42,5 +42,3 @@ ifeq ($(NPM),) $(error "npm is not available: see https://www.npmjs.com/get-npm") endif endif - -BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz diff --git a/assets/build/utils.mk b/assets/build/utils.mk index 8937445..eb44805 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.Name}}/server/command Command + mockgen -destination=server/command/mocks/mock_commands.go -package=mocks {{.GoModule}}/server/command Command endif ## Show help documentation. .PHONY: help help: - @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//g" | sed -e "s/^## //g" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort + @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//g" | sed -e "s/^## //g" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort \ No newline at end of file diff --git a/assets/build/versioning.mk b/assets/build/versioning.mk index ca7a31a..4616437 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 pluginctl manifest get '{{.id}}') +APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`) CURRENT_VERSION := $(shell git describe --abbrev=0 --tags) VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION)))) MAJOR := $(word 1,$(VERSION_PARTS)) @@ -108,4 +108,4 @@ major-rc: ## to bump major release candidate version (semver) @echo Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) - @echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) \ No newline at end of file diff --git a/assets/webapp/babel.config.js b/assets/webapp/babel.config.js index effdb31..2aa81bf 100644 --- a/assets/webapp/babel.config.js +++ b/assets/webapp/babel.config.js @@ -26,6 +26,10 @@ const config = { ['@emotion/babel-preset-css-prop'], ], plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-syntax-dynamic-import', + '@babel/proposal-object-rest-spread', + '@babel/plugin-proposal-optional-chaining', 'babel-plugin-typescript-to-proptypes', ], }; diff --git a/assets/webapp/webpack.config.js b/assets/webapp/webpack.config.js index 65f467e..a3d9032 100644 --- a/assets/webapp/webpack.config.js +++ b/assets/webapp/webpack.config.js @@ -2,12 +2,18 @@ const exec = require('child_process').exec; const path = require('path'); +const webpack = require('webpack'); + const PLUGIN_ID = require('../plugin.json').id; const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env const isDev = NPM_TARGET === 'debug' || NPM_TARGET === 'debug:watch'; -const plugins = []; +const plugins = [ + new webpack.ProvidePlugin({ + process: 'process/browser', + }), +]; if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') { plugins.push({ apply: (compiler) => { @@ -48,6 +54,8 @@ const config = { rules: [ { test: /\.(js|jsx|ts|tsx)$/, + + //exclude: /node_modules\/(?!(mattermost-webapp|@mattermost)\/).*/, exclude: /node_modules/, use: { loader: 'babel-loader', @@ -75,6 +83,10 @@ const config = { }, ], }, + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, ], }, externals: { diff --git a/cmd/pluginctl/main.go b/cmd/pluginctl/main.go index 0d3f2b8..ff96fc2 100644 --- a/cmd/pluginctl/main.go +++ b/cmd/pluginctl/main.go @@ -19,19 +19,10 @@ func main() { pluginctl.InitLogger() var pluginPath string - var showHelp bool flag.StringVar(&pluginPath, "plugin-path", "", "Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)") - flag.BoolVar(&showHelp, "help", false, "Show help information") flag.Parse() - // Show help if requested - if showHelp { - showUsage() - - return - } - args := flag.Args() if len(args) == 0 { pluginctl.Logger.Error("No command specified") @@ -69,10 +60,12 @@ func runCommand(command string, args []string, pluginPath string) error { return runManifestCommand(args, pluginPath) case "logs": return runLogsCommand(args, pluginPath) + case "help": + showUsage() + + return nil case "version": return runVersionCommand(args) - case "create-plugin": - return runCreatePluginCommand(args, pluginPath) default: return fmt.Errorf("unknown command: %s", command) } @@ -116,10 +109,6 @@ func runDeployCommand(args []string, pluginPath string) error { return pluginctl.RunDeployCommand(args, pluginPath) } -func runCreatePluginCommand(args []string, pluginPath string) error { - return pluginctl.RunCreatePluginCommand(args, pluginPath) -} - func showUsage() { usageText := `pluginctl - Mattermost Plugin Development CLI @@ -128,20 +117,39 @@ Usage: Global Options: --plugin-path PATH Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH) - --help Show this help message Commands: info Display plugin information - enable Enable plugin in Mattermost server - disable Disable plugin in Mattermost server - reset Reset plugin (disable then enable) - deploy Upload and enable plugin bundle + enable Enable plugin from current directory in Mattermost server + disable Disable plugin from current directory in Mattermost server + reset Reset plugin from current directory (disable then enable) + deploy Upload and enable plugin bundle to Mattermost server updateassets Update plugin files from embedded assets - manifest Manage plugin manifest files - logs View plugin logs - create-plugin Create a new plugin from template + manifest Get plugin manifest information (id, version, has_server, has_webapp, check) + logs View plugin logs (use --watch to follow logs in real-time) + help Show this help message version Show version information +Examples: + pluginctl info # Show info for plugin in current directory + pluginctl --plugin-path /path/to/plugin info # Show info for plugin at specific path + pluginctl enable # Enable plugin from current directory + pluginctl disable # Disable plugin from current directory + pluginctl reset # Reset plugin from current directory (disable then enable) + pluginctl deploy # Upload and enable plugin bundle from ./dist/ + pluginctl deploy --bundle-path ./bundle.tar.gz # Deploy specific bundle file + pluginctl updateassets # Update plugin files from embedded assets + pluginctl manifest id # Get plugin ID + pluginctl manifest version # Get plugin version + pluginctl manifest has_server # Check if plugin has server code + pluginctl manifest has_webapp # Check if plugin has webapp code + pluginctl manifest check # Validate plugin manifest + pluginctl logs # View recent plugin logs + pluginctl logs --watch # Watch plugin logs in real-time + export PLUGINCTL_PLUGIN_PATH=/path/to/plugin + pluginctl info # Show info using environment variable + pluginctl version # Show version information + Environment Variables: PLUGINCTL_PLUGIN_PATH Default plugin directory path MM_LOCALSOCKETPATH Path to Mattermost local socket @@ -150,8 +158,6 @@ Environment Variables: MM_ADMIN_USERNAME Admin username for authentication MM_ADMIN_PASSWORD Admin password for authentication -Use 'pluginctl --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 deleted file mode 100644 index 1628e49..0000000 --- a/create-plugin.go +++ /dev/null @@ -1,713 +0,0 @@ -package pluginctl - -import ( - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -const ( - starterTemplateURL = "https://github.com/mattermost/mattermost-plugin-starter-template.git" - starterTemplateWebURL = "https://github.com/mattermost/mattermost-plugin-starter-template" - pluginPrefix = "mattermost-plugin-" -) - -var ( - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("205")). - Bold(true). - Margin(1, 0) - - promptStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("36")). - Bold(true) - - inputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("212")). - Background(lipgloss.Color("235")). - Padding(0, 1) - - errorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true) - - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("46")). - Bold(true) - - infoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")) -) - -type inputModel struct { - input string - cursor int - placeholder string - prompt string - validation func(string) string - submitted bool - fixedPrefix string -} - -func (m *inputModel) Init() tea.Cmd { - return nil -} - -func (m *inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - keyMsg, ok := msg.(tea.KeyMsg) - if !ok { - return m, nil - } - - return m.handleKeyMsg(keyMsg) -} - -func (m *inputModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - //nolint:exhaustive - switch msg.Type { - case tea.KeyEsc, tea.KeyCtrlC: - return m, tea.Quit - case tea.KeyEnter: - if validationErr := m.validation(m.input); validationErr == "" { - m.submitted = true - - return m, tea.Quit - } - case tea.KeyLeft: - m.handleCursorLeft() - case tea.KeyRight: - m.handleCursorRight() - case tea.KeyBackspace: - m.handleBackspace() - case tea.KeyDelete: - m.handleDelete() - case tea.KeyHome: - m.cursor = 0 - case tea.KeyEnd: - m.cursor = len(m.input) - case tea.KeyRunes: - m.handleRunes(msg.Runes) - } - - return m, nil -} - -func (m *inputModel) handleCursorLeft() { - if m.cursor > 0 { - m.cursor-- - } -} - -func (m *inputModel) handleCursorRight() { - if m.cursor < len(m.input) { - m.cursor++ - } -} - -func (m *inputModel) handleBackspace() { - if m.cursor > 0 { - m.input = m.input[:m.cursor-1] + m.input[m.cursor:] - m.cursor-- - } -} - -func (m *inputModel) handleDelete() { - if m.cursor < len(m.input) { - m.input = m.input[:m.cursor] + m.input[m.cursor+1:] - } -} - -func (m *inputModel) handleRunes(runes []rune) { - m.input = m.input[:m.cursor] + string(runes) + m.input[m.cursor:] - m.cursor += len(runes) -} - -func (m *inputModel) View() string { - var s strings.Builder - - s.WriteString(titleStyle.Render("Create Mattermost Plugin")) - s.WriteString("\n\n") - - s.WriteString(promptStyle.Render(m.prompt)) - s.WriteString("\n") - - // Show input field with fixed prefix - displayValue := m.fixedPrefix + m.input - if m.input == "" { - displayValue = m.fixedPrefix + m.placeholder - } - - // Add cursor (adjust position for fixed prefix) - cursorPos := len(m.fixedPrefix) + m.cursor - if cursorPos <= len(displayValue) { - left := displayValue[:cursorPos] - right := displayValue[cursorPos:] - if cursorPos == len(displayValue) { - displayValue = left + "█" - } else { - displayValue = left + "█" + right[1:] - } - } - - s.WriteString(inputStyle.Render(displayValue)) - s.WriteString("\n") - - // Show validation message - if m.input != "" { - if validationErr := m.validation(m.input); validationErr != "" { - s.WriteString(errorStyle.Render("✗ " + validationErr)) - } else { - fullName := m.fixedPrefix + m.input - s.WriteString(successStyle.Render("✓ Valid plugin name: " + fullName)) - } - s.WriteString("\n") - } - - s.WriteString(infoStyle.Render("\nPress Enter to continue, Ctrl+C to cancel")) - - return s.String() -} - -func validatePluginSuffix(suffix string) string { - if suffix == "" { - return "Plugin name cannot be empty" - } - - // Check for valid Go module name (simplified) - validName := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) - if !validName.MatchString(suffix) { - return "Plugin name contains invalid characters" - } - - // Check for reasonable length - if len(suffix) < 2 { - return "Plugin name must be at least 2 characters long" - } - - return "" -} - -func validateModuleName(name string) string { - if name == "" { - return "Module name cannot be empty" - } - - // Check for valid Go module name - validModule := regexp.MustCompile(`^[a-zA-Z0-9._/-]+$`) - if !validModule.MatchString(name) { - return "Module name contains invalid characters" - } - - // Should look like a valid repository path - if !strings.Contains(name, "/") { - return "Module name should be a repository path (e.g., github.com/user/repo)" - } - - return "" -} - -func toTitleCase(s string) string { - words := strings.Fields(s) - for i, word := range words { - if word != "" { - words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:]) - } - } - - return strings.Join(words, " ") -} - -func promptForPluginName() (string, error) { - model := &inputModel{ - prompt: "Enter plugin name:", - placeholder: "example", - validation: validatePluginSuffix, - fixedPrefix: pluginPrefix, - } - - p := tea.NewProgram(model) - finalModel, err := p.Run() - if err != nil { - return "", fmt.Errorf("failed to run input prompt: %w", err) - } - - result, ok := finalModel.(*inputModel) - if !ok { - return "", fmt.Errorf("unexpected model type") - } - if !result.submitted { - return "", fmt.Errorf("operation canceled") - } - - // Return the full plugin name (prefix + suffix) - return result.fixedPrefix + result.input, nil -} - -func promptForModuleName(pluginName string) (string, error) { - // Generate a sensible default based on the plugin name - defaultModule := fmt.Sprintf("github.com/user/%s", pluginName) - model := &inputModel{ - prompt: "Enter Go module name (repository path):", - placeholder: defaultModule, - validation: validateModuleName, - } - - p := tea.NewProgram(model) - finalModel, err := p.Run() - if err != nil { - return "", fmt.Errorf("failed to run input prompt: %w", err) - } - - result, ok := finalModel.(*inputModel) - if !ok { - return "", fmt.Errorf("unexpected model type") - } - if !result.submitted { - return "", fmt.Errorf("operation canceled") - } - - return result.input, nil -} - -func parseCreatePluginFlags(args []string, helpText string) (pluginName, moduleName string, err error) { - pluginName, moduleName, err = parseFlags(args, helpText) - if err != nil { - return "", "", err - } - - pluginName, err = validatePluginName(pluginName, helpText) - if err != nil { - return "", "", err - } - - moduleName, err = validateModuleNameWithHelp(moduleName, helpText) - if err != nil { - return "", "", err - } - - return pluginName, moduleName, nil -} - -func parseFlags(args []string, helpText string) (pluginName, moduleName string, err error) { - for i, arg := range args { - switch arg { - case "--name": - if i+1 >= len(args) { - return "", "", ShowErrorWithHelp("--name flag requires a value", helpText) - } - pluginName = args[i+1] - case "--module": - if i+1 >= len(args) { - return "", "", ShowErrorWithHelp("--module flag requires a value", helpText) - } - moduleName = args[i+1] - case HelpFlagLong, HelpFlagShort: - // Skip help flags as they're handled earlier - default: - if strings.HasPrefix(arg, "--") { - return "", "", ShowErrorWithHelp(fmt.Sprintf("unknown flag: %s", arg), helpText) - } - } - } - - return pluginName, moduleName, nil -} - -func validatePluginName(pluginName, helpText string) (string, error) { - if pluginName == "" { - return "", nil - } - - validated, err := validateAndProcessPluginName(pluginName) - if err != nil { - return "", ShowErrorWithHelp(err.Error(), helpText) - } - - return validated, nil -} - -func validateModuleNameWithHelp(moduleName, helpText string) (string, error) { - if moduleName == "" { - return "", nil - } - - if validationErr := validateModuleName(moduleName); validationErr != "" { - return "", ShowErrorWithHelp(fmt.Sprintf("invalid module name: %s", validationErr), helpText) - } - - return moduleName, nil -} - -// validateAndProcessPluginName checks if the plugin name has the correct prefix and adds it if necessary. -// Example: -// - If the input is "my-plugin", it returns "mattermost-plugin-my-plugin". -// - If the input is "mattermost-plugin-my-plugin", it returns "mattermost-plugin-my-plugin". -func validateAndProcessPluginName(name string) (string, error) { - // Check if the name already has the prefix - if !strings.HasPrefix(name, pluginPrefix) { - // If not, validate the suffix and add prefix - if err := validatePluginSuffix(name); err != "" { - return "", fmt.Errorf("invalid plugin name: %s", err) - } - - return pluginPrefix + name, nil - } - - // If it has the prefix, validate the suffix part - suffix := strings.TrimPrefix(name, pluginPrefix) - if err := validatePluginSuffix(suffix); err != "" { - return "", fmt.Errorf("invalid plugin name: %s", err) - } - - return name, nil -} - -func RunCreatePluginCommand(args []string, pluginPath string) error { - helpText := `Create a new plugin from template - -Usage: - pluginctl create-plugin [options] - -Options: - --name PLUGIN_NAME Plugin name (will be prefixed with 'mattermost-plugin-') - --module MODULE_NAME Go module name (e.g., github.com/user/mattermost-plugin-example) - --help, -h Show this help message - -Description: - Creates a new Mattermost plugin from the starter template. If no options are - provided, the command will run in interactive mode prompting for values. - The plugin name will automatically be prefixed with 'mattermost-plugin-' if - not already present. - -Examples: - pluginctl create-plugin # Interactive mode - pluginctl create-plugin --name example --module github.com/user/mattermost-plugin-example - pluginctl --plugin-path /path/to/parent create-plugin # Create in specific directory` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - - // Parse flags - var pluginName, moduleName string - var err error - - pluginName, moduleName, err = parseCreatePluginFlags(args, helpText) - if err != nil { - return err - } - - Logger.Info("Starting plugin creation process") - - // If flags were not provided, fall back to interactive mode - if pluginName == "" { - pluginName, err = promptForPluginName() - if err != nil { - return fmt.Errorf("failed to get plugin name: %w", err) - } - } - - if moduleName == "" { - moduleName, err = promptForModuleName(pluginName) - if err != nil { - return fmt.Errorf("failed to get module name: %w", err) - } - } - - Logger.Info("Creating plugin", "name", pluginName, "module", moduleName) - - // Check if directory already exists - if _, err := os.Stat(pluginName); err == nil { - return fmt.Errorf("directory '%s' already exists", pluginName) - } - - // Clone the starter template - Logger.Info("Cloning starter template repository") - if err := cloneStarterTemplate(pluginName); err != nil { - return fmt.Errorf("failed to clone starter template: %w", err) - } - - // Process the template - Logger.Info("Processing template files") - if err := processPluginTemplate(pluginName, moduleName); err != nil { - return fmt.Errorf("failed to process template: %w", err) - } - - // Update assets - Logger.Info("Updating plugin assets") - if err := RunUpdateAssetsCommand([]string{}, pluginName); err != nil { - return fmt.Errorf("failed to update assets: %w", err) - } - - // Run go mod tidy - Logger.Info("Running go mod tidy") - if err := runGoModTidy(pluginName); err != nil { - return fmt.Errorf("failed to run go mod tidy: %w", err) - } - - // Initialize git and create initial commit - Logger.Info("Initializing git repository") - if err := initializeGitRepo(pluginName); err != nil { - return fmt.Errorf("failed to initialize git repository: %w", err) - } - - Logger.Info("Plugin created successfully!", "name", pluginName, "path", pluginName) - fmt.Printf("\n%s\n", successStyle.Render("✓ Plugin created successfully!")) - fmt.Printf("%s\n", infoStyle.Render(fmt.Sprintf("Plugin '%s' has been created in directory: %s", - pluginName, pluginName))) - fmt.Printf("%s\n", infoStyle.Render("Next steps:")) - fmt.Printf("%s\n", infoStyle.Render(" 1. cd "+pluginName)) - fmt.Printf("%s\n", infoStyle.Render(" 2. Review and modify the plugin.json file")) - fmt.Printf("%s\n", infoStyle.Render(" 3. Start developing your plugin!")) - - return nil -} - -func cloneStarterTemplate(pluginName string) error { - cmd := exec.Command("git", "clone", "--depth", "1", starterTemplateURL, pluginName) - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("git clone failed: %w", err) - } - - // Remove the .git directory from the template - gitDir := filepath.Join(pluginName, ".git") - if err := os.RemoveAll(gitDir); err != nil { - return fmt.Errorf("failed to remove .git directory: %w", err) - } - - return nil -} - -func processPluginTemplate(pluginName, moduleName string) error { - // Update go.mod - if err := updateGoMod(pluginName, moduleName); err != nil { - return fmt.Errorf("failed to update go.mod: %w", err) - } - - // Update plugin.json - if err := updatePluginJSON(pluginName, moduleName); err != nil { - return fmt.Errorf("failed to update plugin.json: %w", err) - } - - // Update Go code references - if err := updateGoCodeReferences(pluginName, moduleName); err != nil { - return fmt.Errorf("failed to update Go code references: %w", err) - } - - // Update README.md - if err := updateReadme(pluginName, moduleName); err != nil { - return fmt.Errorf("failed to update README.md: %w", err) - } - - // Update hello.html - if err := updateHelloHTML(pluginName); err != nil { - return fmt.Errorf("failed to update hello.html: %w", err) - } - - return nil -} - -func updateGoMod(pluginName, moduleName string) error { - goModPath := filepath.Join(pluginName, "go.mod") - content, err := os.ReadFile(goModPath) - if err != nil { - return fmt.Errorf("failed to read go.mod: %w", err) - } - - // Replace the module name - updated := strings.Replace(string(content), - "github.com/mattermost/mattermost-plugin-starter-template", moduleName, 1) - - if err := os.WriteFile(goModPath, []byte(updated), filePermissions); err != nil { - return fmt.Errorf("failed to write go.mod: %w", err) - } - - return nil -} - -func updatePluginJSON(pluginName, moduleName string) error { - pluginJSONPath := filepath.Join(pluginName, "plugin.json") - - // Load the manifest using the existing function - manifest, err := LoadPluginManifestFromPath(pluginName) - if err != nil { - return fmt.Errorf("failed to load plugin manifest: %w", err) - } - - // Update the plugin ID (remove the prefix for the ID) - pluginID := strings.TrimPrefix(pluginName, pluginPrefix) - manifest.Id = "com.mattermost." + pluginID - - // Update display name - displayName := strings.ReplaceAll(pluginID, "-", " ") - displayName = toTitleCase(displayName) - manifest.Name = displayName - - // Update homepage_url and support_url if module is a GitHub repository - if strings.HasPrefix(moduleName, "github.com/") { - newURL := "https://" + moduleName - manifest.HomepageURL = newURL - manifest.SupportURL = newURL + "/issues" - } - - // Update icon path - if manifest.IconPath == "assets/starter-template-icon.svg" { - manifest.IconPath = "assets/" + pluginID + "-icon.svg" - } - - // Write the updated manifest back to JSON - manifestJSON, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal plugin manifest: %w", err) - } - - if err := os.WriteFile(pluginJSONPath, manifestJSON, filePermissions); err != nil { - return fmt.Errorf("failed to write plugin.json: %w", err) - } - - return nil -} - -func updateGoCodeReferences(pluginName, moduleName string) error { - // Walk through Go files and update import references - return filepath.Walk(pluginName, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if !strings.HasSuffix(path, ".go") { - return nil - } - - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", path, err) - } - - // Replace import references - updated := strings.Replace(string(content), - "github.com/mattermost/mattermost-plugin-starter-template", moduleName, -1) - - // Update plugin ID in comments (like api.go) - pluginID := strings.TrimPrefix(pluginName, pluginPrefix) - updated = strings.Replace(updated, "com.mattermost.plugin-starter-template", - "com.mattermost."+pluginID, -1) - - if err := os.WriteFile(path, []byte(updated), filePermissions); err != nil { - return fmt.Errorf("failed to write file %s: %w", path, err) - } - - return nil - }) -} - -func runGoModTidy(pluginName string) error { - cmd := exec.Command("go", "mod", "tidy") - cmd.Dir = pluginName - cmd.Stdout = io.Discard - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("go mod tidy failed: %w", err) - } - - return nil -} - -func initializeGitRepo(pluginName string) error { - // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = pluginName - cmd.Stdout = io.Discard - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("git init failed: %w", err) - } - - // Add all files - cmd = exec.Command("git", "add", ".") - cmd.Dir = pluginName - cmd.Stdout = io.Discard - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("git add failed: %w", err) - } - - // Create initial commit - cmd = exec.Command("git", "commit", "-m", "Initial commit from mattermost-plugin-starter-template") - cmd.Dir = pluginName - cmd.Stdout = io.Discard - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("git commit failed: %w", err) - } - - return nil -} - -func updateReadme(pluginName, moduleName string) error { - readmePath := filepath.Join(pluginName, "README.md") - content, err := os.ReadFile(readmePath) - if err != nil { - // README.md might not exist, which is fine - if os.IsNotExist(err) { - return nil - } - - return fmt.Errorf("failed to read README.md: %w", err) - } - - updated := string(content) - - // Update all GitHub URLs to point to the new repository - if strings.HasPrefix(moduleName, "github.com/") { - newURL := "https://" + moduleName - updated = strings.Replace(updated, starterTemplateWebURL, newURL, -1) - } - - // Update plugin ID in comments and examples - pluginID := strings.TrimPrefix(pluginName, pluginPrefix) - updated = strings.Replace(updated, "com.mattermost.plugin-starter-template", - "com.mattermost."+pluginID, -1) - - // Update clone command example - updated = strings.Replace(updated, "com.example.my-plugin", "com.example."+pluginID, -1) - - if err := os.WriteFile(readmePath, []byte(updated), filePermissions); err != nil { - return fmt.Errorf("failed to write README.md: %w", err) - } - - return nil -} - -func updateHelloHTML(pluginName string) error { - helloPath := filepath.Join(pluginName, "public", "hello.html") - content, err := os.ReadFile(helloPath) - if err != nil { - // hello.html might not exist, which is fine - if os.IsNotExist(err) { - return nil - } - - return fmt.Errorf("failed to read hello.html: %w", err) - } - - pluginID := strings.TrimPrefix(pluginName, pluginPrefix) - updated := strings.Replace(string(content), "com.mattermost.plugin-starter-template", - "com.mattermost."+pluginID, -1) - - if err := os.WriteFile(helloPath, []byte(updated), filePermissions); err != nil { - return fmt.Errorf("failed to write hello.html: %w", err) - } - - return nil -} diff --git a/deploy.go b/deploy.go index a068f89..8997c39 100644 --- a/deploy.go +++ b/deploy.go @@ -10,61 +10,15 @@ import ( ) func RunDeployCommand(args []string, pluginPath string) error { - helpText := getDeployHelpText() - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - - bundlePath, err := parseDeployFlags(args, helpText) - if err != nil { - return err - } - - bundlePath, err = resolveBundlePath(bundlePath, pluginPath) - if err != nil { - return err - } - - pluginID, err := getPluginIDFromManifest(pluginPath) - if err != nil { - return err - } - - return deployPluginBundle(pluginID, bundlePath) -} - -func getDeployHelpText() string { - return `Upload and enable plugin bundle - -Usage: - pluginctl deploy [options] - -Options: - --bundle-path PATH Path to plugin bundle file (.tar.gz) - --help, -h Show this help message - -Description: - Uploads a plugin bundle to the Mattermost server and enables it. If no - bundle path is specified, it will auto-discover the bundle from the dist/ - directory based on the plugin manifest. - -Examples: - pluginctl deploy # Deploy bundle from ./dist/ - pluginctl deploy --bundle-path ./bundle.tar.gz # Deploy specific bundle file - pluginctl --plugin-path /path/to/plugin deploy # Deploy plugin at specific path` -} - -func parseDeployFlags(args []string, helpText string) (string, error) { var bundlePath string + // Parse flags i := 0 for i < len(args) { switch args[i] { case "--bundle-path": if i+1 >= len(args) { - return "", ShowErrorWithHelp("--bundle-path flag requires a value", helpText) + return fmt.Errorf("--bundle-path flag requires a value") } bundlePath = args[i+1] i += 2 @@ -73,50 +27,37 @@ func parseDeployFlags(args []string, helpText string) (string, error) { } } - return bundlePath, nil -} - -func resolveBundlePath(bundlePath, pluginPath string) (string, error) { - // If bundle path provided, validate it exists - if bundlePath != "" { - if _, err := os.Stat(bundlePath); os.IsNotExist(err) { - return "", fmt.Errorf("bundle file not found: %s", bundlePath) + // If no bundle path provided, auto-discover from dist folder + if bundlePath == "" { + manifest, err := LoadPluginManifestFromPath(pluginPath) + if err != nil { + return fmt.Errorf("failed to load plugin manifest: %w", err) } - return bundlePath, nil + expectedBundleName := fmt.Sprintf("%s-%s.tar.gz", manifest.Id, manifest.Version) + bundlePath = filepath.Join(pluginPath, "dist", expectedBundleName) + + if _, err := os.Stat(bundlePath); os.IsNotExist(err) { + return fmt.Errorf("bundle not found at %s - run 'make bundle' to build the plugin first", bundlePath) + } } - // Auto-discover from dist folder - manifest, err := LoadPluginManifestFromPath(pluginPath) - if err != nil { - return "", fmt.Errorf("failed to load plugin manifest: %w", err) - } - - expectedBundleName := fmt.Sprintf("%s-%s.tar.gz", manifest.Id, manifest.Version) - bundlePath = filepath.Join(pluginPath, "dist", expectedBundleName) - + // Validate bundle file exists if _, err := os.Stat(bundlePath); os.IsNotExist(err) { - return "", fmt.Errorf("bundle not found at %s - run 'make bundle' to build the plugin first", bundlePath) + return fmt.Errorf("bundle file not found: %s", bundlePath) } - return bundlePath, nil -} - -func getPluginIDFromManifest(pluginPath string) (string, error) { + // Load manifest to get plugin ID manifest, err := LoadPluginManifestFromPath(pluginPath) if err != nil { - return "", fmt.Errorf("failed to load plugin manifest: %w", err) + return fmt.Errorf("failed to load plugin manifest: %w", err) } pluginID := manifest.Id if pluginID == "" { - return "", fmt.Errorf("plugin ID not found in manifest") + return fmt.Errorf("plugin ID not found in manifest") } - return pluginID, nil -} - -func deployPluginBundle(pluginID, bundlePath string) error { ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) defer cancel() diff --git a/disable.go b/disable.go index 4fdae07..57cd2db 100644 --- a/disable.go +++ b/disable.go @@ -8,27 +8,6 @@ import ( ) func RunDisableCommand(args []string, pluginPath string) error { - helpText := `Disable plugin in Mattermost server - -Usage: - pluginctl disable [options] - -Options: - --help, -h Show this help message - -Description: - Disables the plugin in the connected Mattermost server. The plugin will - remain uploaded but will be inactive. - -Examples: - pluginctl disable # Disable plugin from current directory - pluginctl --plugin-path /path/to/plugin disable # Disable plugin at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - return runPluginCommand(args, pluginPath, disablePlugin) } diff --git a/enable.go b/enable.go index 34ad830..208e6c1 100644 --- a/enable.go +++ b/enable.go @@ -8,27 +8,6 @@ import ( ) func RunEnableCommand(args []string, pluginPath string) error { - helpText := `Enable plugin in Mattermost server - -Usage: - pluginctl enable [options] - -Options: - --help, -h Show this help message - -Description: - Enables the plugin in the connected Mattermost server. The plugin must already - be uploaded to the server for this command to work. - -Examples: - pluginctl enable # Enable plugin from current directory - pluginctl --plugin-path /path/to/plugin enable # Enable plugin at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - return runPluginCommand(args, pluginPath, enablePlugin) } diff --git a/go.mod b/go.mod index 736b43a..4bfac4f 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,6 @@ module github.com/mattermost/pluginctl go 1.24.3 require ( - github.com/charmbracelet/bubbletea v1.3.0 - github.com/charmbracelet/lipgloss v1.1.0 github.com/lmittmann/tint v1.1.2 github.com/mattermost/mattermost/server/public v0.1.15 ) @@ -131,7 +129,9 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charithe/durationcheck v0.0.10 // indirect + github.com/charmbracelet/bubbletea v1.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/info.go b/info.go index 662dc85..fb1e3b8 100644 --- a/info.go +++ b/info.go @@ -109,27 +109,6 @@ func InfoCommandWithPath(path string) error { // RunInfoCommand implements the 'info' command functionality with plugin path. func RunInfoCommand(args []string, pluginPath string) error { - helpText := `Display plugin information - -Usage: - pluginctl info [options] - -Options: - --help, -h Show this help message - -Description: - Shows detailed information about the plugin including ID, name, version, - supported components (server/webapp), and settings schema status. - -Examples: - pluginctl info # Show info for plugin in current directory - pluginctl --plugin-path /path/to/plugin info # Show info for plugin at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - // Convert to absolute path absPath, err := filepath.Abs(pluginPath) if err != nil { diff --git a/logs.go b/logs.go index ddde6d5..4a19597 100644 --- a/logs.go +++ b/logs.go @@ -20,32 +20,6 @@ const ( // RunLogsCommand executes the logs command with optional --watch flag. func RunLogsCommand(args []string, pluginPath string) error { - helpText := `View plugin logs - -Usage: - pluginctl logs [options] - -Options: - --watch Follow logs in real-time - --help, -h Show this help message - -Description: - Views plugin logs from the Mattermost server. By default, shows recent log - entries. Use --watch to follow logs in real-time. - - Note: JSON output for file logs must be enabled in Mattermost configuration - (LogSettings.FileJson) for this command to work. - -Examples: - pluginctl logs # View recent plugin logs - pluginctl logs --watch # Watch plugin logs in real-time - pluginctl --plugin-path /path/to/plugin logs # View logs for plugin at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - // Check for --watch flag watch := false if len(args) > 0 && args[0] == "--watch" { diff --git a/manifest.go b/manifest.go index c2ea165..02d7def 100644 --- a/manifest.go +++ b/manifest.go @@ -1,122 +1,14 @@ package pluginctl import ( - "bytes" - "encoding/json" "fmt" - "os" "path/filepath" - "text/template" - - "github.com/mattermost/mattermost/server/public/model" ) -const ( - // File permissions for generated directories and files. - manifestDirPerm = 0o750 - manifestFilePerm = 0o600 -) - -const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually. - -package main - -import ( - "encoding/json" - "strings" - - "github.com/mattermost/mattermost/server/public/model" -) - -var manifest *model.Manifest - -const manifestStr = ` + "`%s`" + ` - -func init() { - _ = json.NewDecoder(strings.NewReader(manifestStr)).Decode(&manifest) -} -` - -const pluginIDJSFileTemplate = `// This file is automatically generated. Do not modify it manually. - -const manifest = JSON.parse(` + "`%s`" + `); - -export default manifest; -` - -// applyManifest generates manifest files for server and webapp components. -func applyManifest(manifest *model.Manifest, pluginPath string) error { - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("failed to marshal manifest: %w", err) - } - manifestStr := string(manifestBytes) - - // Generate server manifest file if server exists - if HasServerCode(manifest) { - serverDir := filepath.Join(pluginPath, "server") - if err := os.MkdirAll(serverDir, manifestDirPerm); err != nil { - return fmt.Errorf("failed to create server directory: %w", err) - } - - serverManifestPath := filepath.Join(serverDir, "manifest.go") - serverContent := fmt.Sprintf(pluginIDGoFileTemplate, manifestStr) - - if err := os.WriteFile(serverManifestPath, []byte(serverContent), manifestFilePerm); err != nil { - return fmt.Errorf("failed to write server manifest: %w", err) - } - - Logger.Info("Generated server manifest", "path", serverManifestPath) - } - - // Generate webapp manifest file if webapp exists - if HasWebappCode(manifest) { - webappDir := filepath.Join(pluginPath, "webapp", "src") - if err := os.MkdirAll(webappDir, manifestDirPerm); err != nil { - return fmt.Errorf("failed to create webapp directory: %w", err) - } - - webappManifestPath := filepath.Join(webappDir, "manifest.ts") - webappContent := fmt.Sprintf(pluginIDJSFileTemplate, manifestStr) - - if err := os.WriteFile(webappManifestPath, []byte(webappContent), manifestFilePerm); err != nil { - return fmt.Errorf("failed to write webapp manifest: %w", err) - } - - Logger.Info("Generated webapp manifest", "path", webappManifestPath) - } - - return nil -} - // RunManifestCommand implements the 'manifest' command functionality with subcommands. func RunManifestCommand(args []string, pluginPath string) error { - helpText := `Manage plugin manifest files - -Usage: - pluginctl manifest [options] - -Subcommands: - get TEMPLATE Get manifest field using template syntax (e.g., {{.id}}, {{.version}}) - apply Generate manifest files for server/webapp - check Validate plugin manifest - -Options: - --help, -h Show this help message - -Examples: - pluginctl manifest get '{{.id}}' # Get plugin ID - pluginctl manifest get '{{.version}}' # Get plugin version - pluginctl manifest apply # Generate server/webapp manifest files - pluginctl manifest check # Validate manifest` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - if len(args) == 0 { - return ShowErrorWithHelp("manifest command requires a subcommand", helpText) + return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp, check") } // Convert to absolute path @@ -133,27 +25,21 @@ Examples: subcommand := args[0] switch subcommand { - case "get": - if len(args) < 2 { - return ShowErrorWithHelp("get subcommand requires a template expression", helpText) + case "id": + fmt.Println(manifest.Id) + case "version": + fmt.Println(manifest.Version) + case "has_server": + if HasServerCode(manifest) { + fmt.Println("true") + } else { + fmt.Println("false") } - templateStr := args[1] - - // Parse and execute template with manifest as context - tmpl, err := template.New("manifest").Parse(templateStr) - if err != nil { - return fmt.Errorf("failed to parse template: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, manifest); err != nil { - return fmt.Errorf("failed to execute template: %w", err) - } - - fmt.Print(buf.String()) - case "apply": - if err := applyManifest(manifest, absPath); err != nil { - return fmt.Errorf("failed to apply manifest: %w", err) + case "has_webapp": + if HasWebappCode(manifest) { + fmt.Println("true") + } else { + fmt.Println("false") } case "check": if err := manifest.IsValid(); err != nil { @@ -163,7 +49,8 @@ Examples: } Logger.Info("Plugin manifest is valid") default: - return ShowErrorWithHelp(fmt.Sprintf("unknown subcommand: %s", subcommand), helpText) + return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp, check", + subcommand) } return nil diff --git a/plugin.go b/plugin.go index 60df90c..b8612ad 100644 --- a/plugin.go +++ b/plugin.go @@ -114,30 +114,3 @@ func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) { return config, nil } - -const ( - HelpFlagLong = "--help" - HelpFlagShort = "-h" -) - -// CheckForHelpFlag checks if --help is in the arguments and shows help if found. -// Returns true if help was shown, false otherwise. -func CheckForHelpFlag(args []string, helpText string) bool { - for _, arg := range args { - if arg == HelpFlagLong || arg == HelpFlagShort { - Logger.Info(helpText) - - return true - } - } - - return false -} - -// ShowErrorWithHelp displays an error message followed by command help. -func ShowErrorWithHelp(errorMsg, helpText string) error { - Logger.Error(errorMsg) - Logger.Info(helpText) - - return fmt.Errorf("%s", errorMsg) -} diff --git a/reset.go b/reset.go index 73e7086..03f4ac7 100644 --- a/reset.go +++ b/reset.go @@ -7,27 +7,6 @@ import ( ) func RunResetCommand(args []string, pluginPath string) error { - helpText := `Reset plugin (disable then enable) - -Usage: - pluginctl reset [options] - -Options: - --help, -h Show this help message - -Description: - Resets the plugin by first disabling it and then enabling it. This is useful - for restarting a plugin without having to redeploy it. - -Examples: - pluginctl reset # Reset plugin from current directory - pluginctl --plugin-path /path/to/plugin reset # Reset plugin at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - return runPluginCommand(args, pluginPath, resetPlugin) } diff --git a/updateassets.go b/updateassets.go index 472b691..705c1db 100644 --- a/updateassets.go +++ b/updateassets.go @@ -13,8 +13,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" ) -//go:embed assets/.editorconfig assets/.gitattributes assets/.nvmrc assets/Makefile assets/*.yml -//go:embed assets/build/*.mk assets/.github/**/*.yml assets/webapp/.npmrc assets/webapp/*.config.js +//go:embed assets/* var assetsFS embed.FS const ( @@ -25,33 +24,8 @@ const ( ) func RunUpdateAssetsCommand(args []string, pluginPath string) error { - helpText := `Update plugin files from embedded assets - -Usage: - pluginctl updateassets [options] - -Options: - --help, -h Show this help message - -Description: - Updates plugin development files such as Makefile, .editorconfig, build - configurations, and other assets from the embedded templates. This ensures - your plugin uses the latest development tooling and configurations. - -Examples: - pluginctl updateassets # Update assets in current directory - pluginctl --plugin-path /path/to/plugin updateassets # Update assets at specific path` - - // Check for help flag - if CheckForHelpFlag(args, helpText) { - return nil - } - - // Check for unexpected arguments - for _, arg := range args { - if arg != "--help" && arg != "-h" { - return ShowErrorWithHelp(fmt.Sprintf("unknown argument: %s", arg), helpText) - } + if len(args) > 0 { + return fmt.Errorf("updateassets command does not accept arguments") } Logger.Info("Updating assets in plugin directory", "path", pluginPath) @@ -156,7 +130,7 @@ type AssetProcessorConfig struct { // GoModule represents information from go.mod file. type GoModule struct { - Name string + Module string Version string } @@ -310,22 +284,19 @@ func parseGoModule(pluginPath string) (*GoModule, error) { } goMod := &GoModule{} + lines := strings.Split(string(content), "\n") - // Using strings.SplitAfter for more efficiency - for line := range strings.SplitSeq(string(content), "\n") { + for _, line := range lines { line = strings.TrimSpace(line) // Parse module line - if remainder, found := strings.CutPrefix(line, "module "); found { - goMod.Name = strings.TrimSpace(remainder) + if strings.HasPrefix(line, "module ") { + goMod.Module = strings.TrimSpace(strings.TrimPrefix(line, "module ")) } // Parse go version line - if remainder, found := strings.CutPrefix(line, "go "); found { - goMod.Version = strings.TrimSpace(remainder) - - // We don't need to parse any further - break + if strings.HasPrefix(line, "go ") { + goMod.Version = strings.TrimSpace(strings.TrimPrefix(line, "go ")) } }