From 71a7b0de1152382b7d4ca12792ac0f9dfed0fb60 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Mon, 14 Jul 2025 17:14:12 +0200 Subject: [PATCH] Refactor codebase with improved structure and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructured info.go with extracted helper functions for better readability - Enhanced updateassets.go with cleaner asset processing logic and better error handling - Improved client.go formatting and logging consistency - Added logs.go for centralized logging functionality - Updated dependencies in go.mod to include tint as direct dependency - Cleaned up README.md with simplified installation instructions and structure - Added comprehensive assets/ directory with build configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 30 ++---- assets/.gitattributes | 2 + assets/.gitpod.yml | 1 + assets/.nvmrc | 1 + assets/Makefile | 43 ++++++++ assets/build/build.mk | 83 ++++++++++++++ assets/build/deploy.mk | 47 ++++++++ assets/build/dev.mk | 53 +++++++++ assets/build/setup.mk | 44 ++++++++ assets/build/test.mk | 57 ++++++++++ assets/build/utils.mk | 42 ++++++++ assets/build/versioning.mk | 111 +++++++++++++++++++ client.go | 3 +- go.mod | 6 +- info.go | 46 +++++--- logger.go | 8 +- logs.go | 215 +++++++++++++++++++++++++++++++++++++ updateassets.go | 165 ++++++++++++++++++---------- 18 files changed, 852 insertions(+), 105 deletions(-) create mode 100644 assets/.gitattributes create mode 100644 assets/.gitpod.yml create mode 100644 assets/.nvmrc create mode 100644 assets/Makefile create mode 100644 assets/build/build.mk create mode 100644 assets/build/deploy.mk create mode 100644 assets/build/dev.mk create mode 100644 assets/build/setup.mk create mode 100644 assets/build/test.mk create mode 100644 assets/build/utils.mk create mode 100644 assets/build/versioning.mk create mode 100644 logs.go diff --git a/README.md b/README.md index c731855..0ee3426 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,7 @@ Download the latest binary from the [releases page](https://github.com/mattermos ```bash # Using go install -go install github.com/mattermost/pluginctl/cmd/pluginctl@latest - -# Using Homebrew (if available) -brew install mattermost/tap/pluginctl - -# Using Scoop on Windows (if available) -scoop bucket add mattermost https://github.com/mattermost/scoop-bucket.git -scoop install pluginctl +go install github.com/mattermost/pluginctl/cmd/pluginctl ``` ## Usage @@ -42,7 +35,7 @@ pluginctl info pluginctl --help # Show version -pluginctl --version +pluginctl version ``` ### Plugin Path Configuration @@ -50,11 +43,13 @@ pluginctl --version `pluginctl` supports multiple ways to specify the plugin directory: 1. **Command-line flag** (highest priority): + ```bash pluginctl --plugin-path /path/to/plugin info ``` 2. **Environment variable**: + ```bash export PLUGINCTL_PLUGIN_PATH=/path/to/plugin pluginctl info @@ -70,11 +65,6 @@ pluginctl --version Run `pluginctl --help` to see all available commands and options. -## Requirements - -- Go 1.24.3 or later (for building from source) -- Valid Mattermost plugin directory with `plugin.json` manifest file - ## Development ### Quick Start @@ -93,13 +83,6 @@ make dev make check-changes ``` -### Key Make Targets - -- `make dev` - Quick development build -- `make check-changes` - Validate changes (lint, security, test) -- `make test` - Run tests -- `make help` - Show all available targets - See `make help` for the complete list of available targets. ### Adding New Commands @@ -136,6 +119,5 @@ This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENS For questions, issues, or feature requests, please: -1. Check the [issues](https://github.com/mattermost/pluginctl/issues) page -2. Create a new issue if your problem isn't already reported -3. Join the [Mattermost Community](https://community.mattermost.com/) for general discussion \ No newline at end of file +- Check the [issues](https://github.com/mattermost/pluginctl/issues) or create a new issue if your problem isn't already reported +- Join the [Mattermost Community](https://community.mattermost.com/) for general discussion diff --git a/assets/.gitattributes b/assets/.gitattributes new file mode 100644 index 0000000..4bd338f --- /dev/null +++ b/assets/.gitattributes @@ -0,0 +1,2 @@ +server/manifest.go linguist-generated=true +webapp/src/manifest.js linguist-generated=true diff --git a/assets/.gitpod.yml b/assets/.gitpod.yml new file mode 100644 index 0000000..901a3dc --- /dev/null +++ b/assets/.gitpod.yml @@ -0,0 +1 @@ +mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config diff --git a/assets/.nvmrc b/assets/.nvmrc new file mode 100644 index 0000000..a3597ec --- /dev/null +++ b/assets/.nvmrc @@ -0,0 +1 @@ +20.11 diff --git a/assets/Makefile b/assets/Makefile new file mode 100644 index 0000000..1ff7725 --- /dev/null +++ b/assets/Makefile @@ -0,0 +1,43 @@ +GO ?= $(shell command -v go 2> /dev/null) +NPM ?= $(shell command -v npm 2> /dev/null) +CURL ?= $(shell command -v curl 2> /dev/null) +MM_DEBUG ?= +GOPATH ?= $(shell go env GOPATH) +GO_TEST_FLAGS ?= -race +GO_BUILD_FLAGS ?= +MM_UTILITIES_DIR ?= ../mattermost-utilities +DLV_DEBUG_PORT := 2346 +DEFAULT_GOOS := $(shell go env GOOS) +DEFAULT_GOARCH := $(shell go env GOARCH) + +export GO111MODULE=on + +# We need to export GOBIN to allow it to be set +# for processes spawned from the Makefile +export GOBIN ?= $(PWD)/bin + +# You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. +ASSETS_DIR ?= assets + +## Define the default target (make all) +.PHONY: default +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 + GO_BUILD_GCFLAGS = +endif + +# Include modular makefiles +include build/*.mk \ No newline at end of file diff --git a/assets/build/build.mk b/assets/build/build.mk new file mode 100644 index 0000000..8be43ec --- /dev/null +++ b/assets/build/build.mk @@ -0,0 +1,83 @@ +# ==================================================================================== +# Build Targets +# ==================================================================================== + +## Checks the code style, tests, builds and bundles the plugin. +.PHONY: all +all: check-style test dist + +## Ensures the plugin manifest is valid +.PHONY: manifest-check +manifest-check: + ./build/bin/manifest check + + +## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. +.PHONY: server +server: +ifneq ($(HAS_SERVER),) +ifneq ($(MM_DEBUG),) + $(info DEBUG mode is on; to disable, unset MM_DEBUG) +endif + mkdir -p server/dist; +ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) + @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled + cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); +else + cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-amd64; + cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-linux-arm64; + cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-amd64; + cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-darwin-arm64; + cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) $(GO_BUILD_GCFLAGS) -trimpath -o dist/plugin-windows-amd64.exe; +endif +endif + +## Ensures NPM dependencies are installed without having to run this all the time. +webapp/node_modules: $(wildcard webapp/package.json) +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) install + touch $@ +endif + +## Builds the webapp, if it exists. +.PHONY: webapp +webapp: webapp/node_modules +ifneq ($(HAS_WEBAPP),) +ifeq ($(MM_DEBUG),) + cd webapp && $(NPM) run build; +else + cd webapp && $(NPM) run debug; +endif +endif + +## Generates a tar bundle of the plugin for install. +.PHONY: bundle +bundle: + rm -rf dist/ + mkdir -p dist/$(PLUGIN_ID) + ./build/bin/manifest dist +ifneq ($(wildcard $(ASSETS_DIR)/.),) + cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ +endif +ifneq ($(HAS_PUBLIC),) + cp -r public dist/$(PLUGIN_ID)/ +endif +ifneq ($(HAS_SERVER),) + mkdir -p dist/$(PLUGIN_ID)/server + cp -r server/dist dist/$(PLUGIN_ID)/server/ +endif +ifneq ($(HAS_WEBAPP),) + mkdir -p dist/$(PLUGIN_ID)/webapp + cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ +endif +ifeq ($(shell uname),Darwin) + cd dist && tar --disable-copyfile -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) +else + cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) +endif + + @echo plugin built at: dist/$(BUNDLE_NAME) + +## Builds and bundles the plugin. +.PHONY: dist +dist: server webapp bundle diff --git a/assets/build/deploy.mk b/assets/build/deploy.mk new file mode 100644 index 0000000..7c05b31 --- /dev/null +++ b/assets/build/deploy.mk @@ -0,0 +1,47 @@ +# ==================================================================================== +# Deployment and Plugin Management +# ==================================================================================== + +## Builds and installs the plugin to a server. +.PHONY: deploy +deploy: dist + ./build/bin/pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) + +## Builds and installs the plugin to a server, updating the webapp automatically when changed. +.PHONY: watch +watch: server bundle +ifeq ($(MM_DEBUG),) + cd webapp && $(NPM) run build:watch +else + cd webapp && $(NPM) run debug:watch +endif + +## Installs a previous built plugin with updated webpack assets to a server. +.PHONY: deploy-from-watch +deploy-from-watch: bundle + pluginctl deploy --bundle-path dist/$(BUNDLE_NAME) + +## Disable the plugin. +.PHONY: disable +disable: detach + pluginctl disable + +## Enable the plugin. +.PHONY: enable +enable: + pluginctl enable + +## Reset the plugin, effectively disabling and re-enabling it on the server. +.PHONY: reset +reset: detach + pluginctl reset + +## View plugin logs. +.PHONY: logs +logs: + pluginctl logs + +## Watch plugin logs. +.PHONY: logs-watch +logs-watch: + pluginctl logs --watch diff --git a/assets/build/dev.mk b/assets/build/dev.mk new file mode 100644 index 0000000..a8ca0ce --- /dev/null +++ b/assets/build/dev.mk @@ -0,0 +1,53 @@ +# ==================================================================================== +# Development and Debugging +# ==================================================================================== + +## Setup dlv for attaching, identifying the plugin PID for other targets. +.PHONY: setup-attach +setup-attach: + $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) + $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) + + @if [ ${NUM_PID} -gt 2 ]; then \ + echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ + exit 1; \ + fi + +## Check if setup-attach succeeded. +.PHONY: check-attach +check-attach: + @if [ -z ${PLUGIN_PID} ]; then \ + echo "Could not find plugin PID; the plugin is not running. Exiting."; \ + exit 1; \ + else \ + echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ + fi + +## Attach dlv to an existing plugin instance. +.PHONY: attach +attach: setup-attach check-attach + dlv attach ${PLUGIN_PID} + +## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. +.PHONY: attach-headless +attach-headless: setup-attach check-attach + dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient + +## Detach dlv from an existing plugin instance, if previously attached. +.PHONY: detach +detach: setup-attach + @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ + if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ + echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ + kill -9 $$DELVE_PID ; \ + fi + +## Kill all instances of the plugin, detaching any existing dlv instance. +.PHONY: kill +kill: detach + $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) + + @for PID in ${PLUGIN_PID}; do \ + echo "Killing plugin pid $$PID"; \ + kill -9 $$PID; \ + done; \ \ No newline at end of file diff --git a/assets/build/setup.mk b/assets/build/setup.mk new file mode 100644 index 0000000..aab3bd0 --- /dev/null +++ b/assets/build/setup.mk @@ -0,0 +1,44 @@ +# Ensure that go is installed. Note that this is independent of whether or not a server is being +# built, since the build script itself uses go. +ifeq ($(GO),) + $(error "go is not available: see https://golang.org/doc/install") +endif + +# Gather build variables to inject into the manifest tool +BUILD_HASH_SHORT = $(shell git rev-parse --short HEAD) +BUILD_TAG_LATEST = $(shell git describe --tags --match 'v*' --abbrev=0 2>/dev/null) +BUILD_TAG_CURRENT = $(shell git tag --points-at HEAD) + +# Extract the plugin id from the manifest. +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 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) + +# Determine if a webapp is defined in the manifest. +HAS_WEBAPP ?= $(shell pluginctl manifest has_webapp) + +# Determine if a /public folder is in use +HAS_PUBLIC ?= $(wildcard public/.) + +# Determine if the mattermost-utilities repo is present +HAS_MM_UTILITIES ?= $(wildcard $(MM_UTILITIES_DIR)/.) + +# Store the current path for later use +PWD ?= $(shell pwd) + +# Ensure that npm (and thus node) is installed. +ifneq ($(HAS_WEBAPP),) +ifeq ($(NPM),) + $(error "npm is not available: see https://www.npmjs.com/get-npm") +endif +endif diff --git a/assets/build/test.mk b/assets/build/test.mk new file mode 100644 index 0000000..f084664 --- /dev/null +++ b/assets/build/test.mk @@ -0,0 +1,57 @@ +# ==================================================================================== +# Testing and Quality Assurance +# ==================================================================================== + +## Install go tools +install-go-tools: + @echo Installing go tools + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 + $(GO) install gotest.tools/gotestsum@v1.7.0 + +## Runs eslint and golangci-lint +.PHONY: check-style +check-style: manifest-check webapp/node_modules install-go-tools + @echo Checking for style guide compliance + +ifneq ($(HAS_WEBAPP),) + cd webapp && npm run lint + cd webapp && npm run check-types +endif + +# It's highly recommended to run go-vet first +# to find potential compile errors that could introduce +# weird reports at golangci-lint step +ifneq ($(HAS_SERVER),) + @echo Running golangci-lint + $(GO) vet ./... + $(GOBIN)/golangci-lint run ./... +endif + +## Runs any lints and unit tests defined for the server and webapp, if they exist. +.PHONY: test +test: webapp/node_modules install-go-tools +ifneq ($(HAS_SERVER),) + $(GOBIN)/gotestsum -- -v ./... +endif +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) run test; +endif + +## Runs any lints and unit tests defined for the server and webapp, if they exist, optimized +## for a CI environment. +.PHONY: test-ci +test-ci: webapp/node_modules install-go-tools +ifneq ($(HAS_SERVER),) + $(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./... +endif +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) run test; +endif + +## Creates a coverage report for the server code. +.PHONY: coverage +coverage: webapp/node_modules +ifneq ($(HAS_SERVER),) + $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... + $(GO) tool cover -html=server/coverage.txt +endif diff --git a/assets/build/utils.mk b/assets/build/utils.mk new file mode 100644 index 0000000..3dd5c52 --- /dev/null +++ b/assets/build/utils.mk @@ -0,0 +1,42 @@ +# ==================================================================================== +# Utilities +# ==================================================================================== + +## Clean removes all build artifacts. +.PHONY: clean +clean: + rm -fr dist/ +ifneq ($(HAS_SERVER),) + rm -fr server/coverage.txt + rm -fr server/dist +endif +ifneq ($(HAS_WEBAPP),) + rm -fr webapp/junit.xml + rm -fr webapp/dist + rm -fr webapp/node_modules +endif + rm -fr build/bin/ + +## Extract strings for translation from the source code. +.PHONY: i18n-extract +i18n-extract: +ifneq ($(HAS_WEBAPP),) +ifeq ($(HAS_MM_UTILITIES),) + @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" +else + cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp +endif +endif + +## Generate mocks for testing. +.PHONY: mock +mock: +ifneq ($(HAS_SERVER),) + go install github.com/golang/mock/mockgen@v1.6.0 + mockgen -destination=server/command/mocks/mock_commands.go -package=mocks github.com/mattermost/mattermost-plugin-starter-template/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 diff --git a/assets/build/versioning.mk b/assets/build/versioning.mk new file mode 100644 index 0000000..4616437 --- /dev/null +++ b/assets/build/versioning.mk @@ -0,0 +1,111 @@ +# ==================================================================================== +# Semantic Versioning +# ==================================================================================== + +# Used for semver bumping +PROTECTED_BRANCH := master +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)) +MINOR := $(word 2,$(VERSION_PARTS)) +PATCH := $(word 3,$(VERSION_PARTS)) +RC := $(shell echo $(CURRENT_VERSION) | grep -oE 'rc[0-9]+' | sed 's/rc//') + +# Check if current branch is protected +define check_protected_branch + @current_branch=$$(git rev-parse --abbrev-ref HEAD); \ + if ! echo "$(PROTECTED_BRANCH)" | grep -wq "$$current_branch" && ! echo "$$current_branch" | grep -q "^release"; then \ + echo "Error: Tagging is only allowed from $(PROTECTED_BRANCH) or release branches. You are on $$current_branch branch."; \ + exit 1; \ + fi +endef + +# Check if there are pending pulls +define check_pending_pulls + @git fetch; \ + current_branch=$$(git rev-parse --abbrev-ref HEAD); \ + if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/$$current_branch)" ]; then \ + echo "Error: Your branch is not up to date with upstream. Please pull the latest changes before performing a release"; \ + exit 1; \ + fi +endef + +# Prompt for approval +define prompt_approval + @read -p "About to bump $(APP_NAME) to version $(1), approve? (y/n) " userinput; \ + if [ "$$userinput" != "y" ]; then \ + echo "Bump aborted."; \ + exit 1; \ + fi +endef + +.PHONY: patch minor major patch-rc minor-rc major-rc + +patch: ## to bump patch version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval PATCH := $(shell echo $$(($(PATCH)+1)))) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH) + +minor: ## to bump minor version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MINOR := $(shell echo $$(($(MINOR)+1)))) + @$(eval PATCH := 0) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH) + +major: ## to bump major version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + $(eval MAJOR := $(shell echo $$(($(MAJOR)+1)))) + $(eval MINOR := 0) + $(eval PATCH := 0) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)) + @echo Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)" + git push origin v$(MAJOR).$(MINOR).$(PATCH) + @echo Bumped $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH) + +patch-rc: ## to bump patch release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval RC := $(shell echo $$(($(RC)+1)))) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @echo Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" + git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + +minor-rc: ## to bump minor release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MINOR := $(shell echo $$(($(MINOR)+1)))) + @$(eval PATCH := 0) + @$(eval RC := 1) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @echo Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)" + git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + @echo Bumped $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC) + +major-rc: ## to bump major release candidate version (semver) + $(call check_protected_branch) + $(call check_pending_pulls) + @$(eval MAJOR := $(shell echo $$(($(MAJOR)+1)))) + @$(eval MINOR := 0) + @$(eval PATCH := 0) + @$(eval RC := 1) + $(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)) + @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 diff --git a/client.go b/client.go index b006192..cd58701 100644 --- a/client.go +++ b/client.go @@ -27,7 +27,8 @@ func getClient(ctx context.Context) (*model.Client4, error) { } if os.Getenv("MM_LOCALSOCKETPATH") != "" { - Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.", "socket_path", socketPath) + Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.", + "socket_path", socketPath) } siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL") diff --git a/go.mod b/go.mod index 02d6ccf..4bfac4f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/mattermost/pluginctl go 1.24.3 -require github.com/mattermost/mattermost/server/public v0.1.15 +require ( + github.com/lmittmann/tint v1.1.2 + github.com/mattermost/mattermost/server/public v0.1.15 +) require ( 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect @@ -303,7 +306,6 @@ require ( github.com/ldez/tagliatelle v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 // indirect - github.com/lmittmann/tint v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.1.3 // indirect github.com/mailru/easyjson v0.9.0 // indirect diff --git a/info.go b/info.go index f037e33..24a12df 100644 --- a/info.go +++ b/info.go @@ -19,30 +19,44 @@ func InfoCommand() error { // PrintPluginInfo displays formatted plugin information. func PrintPluginInfo(manifest *model.Manifest) error { + printBasicInfo(manifest) + printCodeComponents(manifest) + printSettingsSchema(manifest) + + return nil +} + +// printBasicInfo prints basic plugin information. +func printBasicInfo(manifest *model.Manifest) { Logger.Info("Plugin Information:") Logger.Info("==================") - // Basic plugin info Logger.Info("ID:", "value", manifest.Id) Logger.Info("Name:", "value", manifest.Name) Logger.Info("Version:", "value", manifest.Version) - // Minimum Mattermost version - if manifest.MinServerVersion != "" { - Logger.Info("Min MM Version:", "value", manifest.MinServerVersion) - } else { - Logger.Info("Min MM Version:", "value", "Not specified") + minVersion := manifest.MinServerVersion + if minVersion == "" { + minVersion = "Not specified" } + Logger.Info("Min MM Version:", "value", minVersion) - // Description if available if manifest.Description != "" { Logger.Info("Description:", "value", manifest.Description) } +} +// printCodeComponents prints information about server and webapp code. +func printCodeComponents(manifest *model.Manifest) { Logger.Info("Code Components:") Logger.Info("================") - // Server code presence + printServerCodeInfo(manifest) + printWebappCodeInfo(manifest) +} + +// printServerCodeInfo prints server code information. +func printServerCodeInfo(manifest *model.Manifest) { if HasServerCode(manifest) { Logger.Info("Server Code:", "value", "Yes") if manifest.Server != nil && len(manifest.Server.Executables) > 0 { @@ -55,8 +69,10 @@ func PrintPluginInfo(manifest *model.Manifest) error { } else { Logger.Info("Server Code:", "value", "No") } +} - // Webapp code presence +// printWebappCodeInfo prints webapp code information. +func printWebappCodeInfo(manifest *model.Manifest) { if HasWebappCode(manifest) { Logger.Info("Webapp Code:", "value", "Yes") if manifest.Webapp != nil && manifest.Webapp.BundlePath != "" { @@ -65,15 +81,15 @@ func PrintPluginInfo(manifest *model.Manifest) error { } else { Logger.Info("Webapp Code:", "value", "No") } +} - // Settings schema +// printSettingsSchema prints settings schema information. +func printSettingsSchema(manifest *model.Manifest) { + value := "No" if manifest.SettingsSchema != nil { - Logger.Info("Settings Schema:", "value", "Yes") - } else { - Logger.Info("Settings Schema:", "value", "No") + value = "Yes" } - - return nil + Logger.Info("Settings Schema:", "value", value) } // InfoCommandWithPath implements the 'info' command with a custom path. diff --git a/logger.go b/logger.go index fffe857..1f0f3a2 100644 --- a/logger.go +++ b/logger.go @@ -8,10 +8,10 @@ import ( "github.com/lmittmann/tint" ) -// Logger is the global logger instance +// Logger is the global logger instance. var Logger *slog.Logger -// InitLogger initializes the global logger +// InitLogger initializes the global logger. func InitLogger() { // Create a tint handler for colorized output handler := tint.NewHandler(os.Stderr, &tint.Options{ @@ -24,7 +24,7 @@ func InitLogger() { Logger = slog.New(handler) } -// SetLogLevel sets the minimum logging level +// SetLogLevel sets the minimum logging level. func SetLogLevel(level slog.Level) { handler := tint.NewHandler(os.Stderr, &tint.Options{ Level: level, @@ -34,4 +34,4 @@ func SetLogLevel(level slog.Level) { }) Logger = slog.New(handler) -} \ No newline at end of file +} diff --git a/logs.go b/logs.go new file mode 100644 index 0000000..4a19597 --- /dev/null +++ b/logs.go @@ -0,0 +1,215 @@ +package pluginctl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + logsPerPage = 100 + defaultLogsPerPage = 500 + timeStampFormat = "2006-01-02 15:04:05.000 Z07:00" +) + +// RunLogsCommand executes the logs command with optional --watch flag. +func RunLogsCommand(args []string, pluginPath string) error { + // Check for --watch flag + watch := false + if len(args) > 0 && args[0] == "--watch" { + watch = true + } + + if watch { + return runPluginCommand(args, pluginPath, watchPluginLogs) + } + + return runPluginCommand(args, pluginPath, getPluginLogs) +} + +// getPluginLogs fetches the latest 500 log entries from Mattermost, +// and prints only the ones related to the plugin to stdout. +func getPluginLogs(ctx context.Context, client *model.Client4, pluginID string) error { + Logger.Info("Getting plugin logs", "plugin_id", pluginID) + + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + logs, err := fetchLogs(ctx, client, LogsRequest{ + Page: 0, + PerPage: defaultLogsPerPage, + PluginID: pluginID, + Since: time.Unix(0, 0), + }) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + printLogEntries(logs) + + return nil +} + +// watchPluginLogs fetches log entries from Mattermost and prints them continuously. +// It will return without an error when ctx is canceled. +func watchPluginLogs(ctx context.Context, client *model.Client4, pluginID string) error { + Logger.Info("Watching plugin logs", "plugin_id", pluginID) + + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + now := time.Now() + var oldestEntry string + + // Use context.WithoutCancel to keep watching even if parent context times out + watchCtx := context.WithoutCancel(ctx) + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-watchCtx.Done(): + return nil + case <-ticker.C: + var page int + for { + logs, err := fetchLogs(watchCtx, client, LogsRequest{ + Page: page, + PerPage: logsPerPage, + PluginID: pluginID, + Since: now, + }) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + var allNew bool + logs, oldestEntry, allNew = checkOldestEntry(logs, oldestEntry) + + printLogEntries(logs) + + if !allNew { + // No more logs to fetch + break + } + page++ + } + } + } +} + +// checkOldestEntry checks if logs contains new log entries. +// It returns the filtered slice of log entries, the new oldest entry and whether or not all entries were new. +func checkOldestEntry(logs []string, oldest string) (filteredLogs []string, newOldest string, allNew bool) { + if len(logs) == 0 { + return nil, oldest, false + } + + newOldestEntry := logs[len(logs)-1] + + i := slices.Index(logs, oldest) + switch i { + case -1: + // Every log entry is new + return logs, newOldestEntry, true + case len(logs) - 1: + // No new log entries + return nil, oldest, false + default: + // Filter out oldest log entry + return logs[i+1:], newOldestEntry, false + } +} + +// LogsRequest contains parameters for fetching logs. +type LogsRequest struct { + Page int + PerPage int + PluginID string + Since time.Time +} + +// fetchLogs fetches log entries from Mattermost +// and filters them based on pluginID and timestamp. +func fetchLogs(ctx context.Context, client *model.Client4, req LogsRequest) ([]string, error) { + logs, _, err := client.GetLogs(ctx, req.Page, req.PerPage) + if err != nil { + return nil, fmt.Errorf("failed to get logs from Mattermost: %w", err) + } + + logs, err = filterLogEntries(logs, req.PluginID, req.Since) + if err != nil { + return nil, fmt.Errorf("failed to filter log entries: %w", err) + } + + return logs, nil +} + +// filterLogEntries filters a given slice of log entries by pluginID. +// It also filters out any entries which timestamps are older than since. +func filterLogEntries(logs []string, pluginID string, since time.Time) ([]string, error) { + type logEntry struct { + PluginID string `json:"plugin_id"` + Timestamp string `json:"timestamp"` + } + + ret := make([]string, 0) + + for _, e := range logs { + var le logEntry + err := json.Unmarshal([]byte(e), &le) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal log entry into JSON: %w", err) + } + if le.PluginID != pluginID { + continue + } + + let, err := time.Parse(timeStampFormat, le.Timestamp) + if err != nil { + return nil, fmt.Errorf("unknown timestamp format: %w", err) + } + if let.Before(since) { + continue + } + + // Log entries returned by the API have a newline as prefix. + // Remove that to make printing consistent. + e = strings.TrimPrefix(e, "\n") + + ret = append(ret, e) + } + + return ret, nil +} + +// printLogEntries prints a slice of log entries to stdout. +func printLogEntries(entries []string) { + for _, e := range entries { + fmt.Println(e) + } +} + +func checkJSONLogsSetting(ctx context.Context, client *model.Client4) error { + cfg, _, err := client.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to fetch config: %w", err) + } + if cfg.LogSettings.FileJson == nil || !*cfg.LogSettings.FileJson { + return errors.New("JSON output for file logs are disabled. " + + "Please enable LogSettings.FileJson via the configuration in Mattermost") + } + + return nil +} diff --git a/updateassets.go b/updateassets.go index 5abfb2a..96d7822 100644 --- a/updateassets.go +++ b/updateassets.go @@ -10,9 +10,16 @@ import ( "strings" ) -//go:embed assets/**/* +//go:embed assets/* var assetsFS embed.FS +const ( + assetsPrefix = "assets/" + assetsPrefixLen = 7 + directoryPermissions = 0o750 + filePermissions = 0o600 +) + func RunUpdateAssetsCommand(args []string, pluginPath string) error { if len(args) > 0 { return fmt.Errorf("updateassets command does not accept arguments") @@ -20,73 +27,22 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { Logger.Info("Updating assets in plugin directory", "path", pluginPath) - // Load plugin manifest to check for webapp code manifest, err := LoadPluginManifestFromPath(pluginPath) if err != nil { return fmt.Errorf("failed to load plugin manifest: %w", err) } - // Check if the plugin has webapp code according to manifest hasWebapp := HasWebappCode(manifest) + updatedCount := 0 - // Counter for updated files - var updatedCount int + config := AssetProcessorConfig{ + pluginPath: pluginPath, + hasWebapp: hasWebapp, + updatedCount: &updatedCount, + } - // Walk through the embedded assets err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - - // Skip the root assets directory - if path == "assets" { - return nil - } - - // Remove the "assets/" prefix to get the relative path - relativePath := path[7:] // len("assets/") = 7 - - // Skip webapp assets if plugin doesn't have webapp code - if !hasWebapp && strings.HasPrefix(relativePath, "webapp") { - return nil - } - - targetPath := filepath.Join(pluginPath, relativePath) - - if d.IsDir() { - // Create directory if it doesn't exist - if err := os.MkdirAll(targetPath, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", targetPath, err) - } - } else { - // Read file content from embedded FS - content, err := assetsFS.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read embedded file %s: %w", path, err) - } - - // Check if target file exists and compare content - existingContent, err := os.ReadFile(targetPath) - if err == nil && bytes.Equal(existingContent, content) { - // File exists and content is identical, skip update - return nil - } - - // Create parent directory if it doesn't exist - parentDir := filepath.Dir(targetPath) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) - } - - // Write file to target location - if err := os.WriteFile(targetPath, content, 0644); err != nil { - return fmt.Errorf("failed to write file %s: %w", targetPath, err) - } - Logger.Info("Updated file", "path", relativePath) - updatedCount++ - } - - return nil + return processAssetEntry(path, d, err, config) }) if err != nil { @@ -94,5 +50,96 @@ func RunUpdateAssetsCommand(args []string, pluginPath string) error { } Logger.Info("Assets updated successfully!", "files_updated", updatedCount) + + return nil +} + +type AssetProcessorConfig struct { + pluginPath string + hasWebapp bool + updatedCount *int +} + +func processAssetEntry(path string, d fs.DirEntry, err error, config AssetProcessorConfig) error { + if err != nil { + return err + } + + if path == "assets" { + return nil + } + + relativePath := path[assetsPrefixLen:] + + if !config.hasWebapp && strings.HasPrefix(relativePath, "webapp") { + return nil + } + + targetPath := filepath.Join(config.pluginPath, relativePath) + + if d.IsDir() { + return createDirectory(targetPath) + } + + return processAssetFile(path, targetPath, relativePath, config.updatedCount) +} + +func processAssetFile(embeddedPath, targetPath, relativePath string, updatedCount *int) error { + shouldUpdate, err := shouldUpdateFile(embeddedPath, targetPath) + if err != nil { + return err + } + + if shouldUpdate { + err = updateFile(embeddedPath, targetPath, relativePath) + if err != nil { + return err + } + (*updatedCount)++ + } + + return nil +} + +func createDirectory(targetPath string) error { + if err := os.MkdirAll(targetPath, directoryPermissions); err != nil { + return fmt.Errorf("failed to create directory %s: %w", targetPath, err) + } + + return nil +} + +func shouldUpdateFile(embeddedPath, targetPath string) (bool, error) { + content, err := assetsFS.ReadFile(embeddedPath) + if err != nil { + return false, fmt.Errorf("failed to read embedded file %s: %w", embeddedPath, err) + } + + existingContent, err := os.ReadFile(targetPath) + if err != nil { + // File doesn't exist or other error, should update + return true, nil //nolint:nilerr + } + + return !bytes.Equal(existingContent, content), nil +} + +func updateFile(embeddedPath, targetPath, relativePath string) error { + content, err := assetsFS.ReadFile(embeddedPath) + if err != nil { + return fmt.Errorf("failed to read embedded file %s: %w", embeddedPath, err) + } + + parentDir := filepath.Dir(targetPath) + if err := os.MkdirAll(parentDir, directoryPermissions); err != nil { + return fmt.Errorf("failed to create parent directory %s: %w", parentDir, err) + } + + if err := os.WriteFile(targetPath, content, filePermissions); err != nil { + return fmt.Errorf("failed to write file %s: %w", targetPath, err) + } + + Logger.Info("Updated file", "path", relativePath) + return nil }