Compare commits

..

No commits in common. "03c521f237395b9f08a03bf06c7b3a84fa7a9e24" and "b43e7ac3ec0692918af73e630379184d9855e172" have entirely different histories.

36 changed files with 570 additions and 1505 deletions

283
CLAUDE.md
View file

@ -1,58 +1,261 @@
# pluginctl - Claude Memory
# pluginctl - Mattermost Plugin Development CLI
## Critical Architecture Rules
## Project Overview
`pluginctl` is a command-line interface tool for Mattermost plugin development. It provides utilities to manage, inspect, and work with Mattermost plugins from the command line.
### Command Structure
## Architecture Guidelines
- **CRITICAL**: ALL command logic in separate files in ROOT folder (e.g., `info.go`, `enable.go`)
- **NEVER** put command implementation in `cmd/pluginctl/main.go` - only CLI framework code
- **Command pattern**: `run[Command]Command(args []string, pluginPath string) error`
- **Registration**: Add to `runCommand()` switch in main.go
### Project Structure
```
pluginctl/
├── cmd/pluginctl/main.go # CLI entrypoint with command routing
├── plugin.go # Plugin manifest handling utilities
├── info.go # Info command implementation
├── [command].go # Additional command implementations
├── go.mod # Go module definition
├── go.sum # Go module dependencies
├── pluginctl # Built binary (gitignored)
└── CLAUDE.md # This architecture document
```
### Dependencies & Types
### Design Principles
- Use `github.com/mattermost/mattermost/server/public/model.Manifest`
- Commands in `pluginctl` package, main.go calls them
#### 1. **Separation of Concerns**
- **CLI Framework**: `cmd/pluginctl/main.go` handles argument parsing, command routing, and error handling
- **Command Implementation**: Each command gets its own file (e.g., `info.go`, `build.go`, `deploy.go`) **IN THE ROOT FOLDER, NOT IN cmd/pluginctl/**
- **Utility Functions**: Common plugin operations in `plugin.go`
### Plugin Path Resolution
**CRITICAL ARCHITECTURE RULE**: ALL COMMAND LOGIC MUST BE IN SEPARATE FILES IN THE ROOT FOLDER. The cmd/pluginctl/main.go file should ONLY contain CLI framework code (argument parsing, command routing, wrapper functions). Never put command implementation logic directly in main.go.
1. `--plugin-path` flag
2. `PLUGINCTL_PLUGIN_PATH` env var
3. Current directory (default)
#### 2. **Plugin Manifest Handling**
- **Always use official Mattermost types**: Import `github.com/mattermost/mattermost/server/public/model` and use `model.Manifest`
- **Validation**: Always validate plugin.json existence and format before operations
- **Path handling**: Support both current directory and custom path operations
### Logging
#### 3. **Command Structure**
- **Main command router**: Add new commands to the `runCommand()` function in `cmd/pluginctl/main.go`
- **Command functions**: Name pattern: `run[Command]Command(args []string, pluginPath string) error`
- **Error handling**: Return descriptive errors, let main.go handle exit codes
- **Command implementation**: Each command's logic goes in a separate file in the root folder (e.g., `enable.go`, `disable.go`, `reset.go`)
- **Command wrapper functions**: The main.go file contains simple wrapper functions that call the actual command implementations
- **CRITICAL**: Use `pluginctl.Logger` (global slog instance)
- **Error**: `pluginctl.Logger.Error("message", "error", err)`
- **Info**: `pluginctl.Logger.Info("message")`
- **NEVER** use `fmt.Print*` or `log.*`
#### 4. **Code Organization**
- **No inline implementations**: Keep command logic in separate files
- **Reusable utilities**: Common operations go in `plugin.go`
- **Self-contained**: Each command file should be importable and testable
### Build & Development
### Current Commands
- **CRITICAL**: Use `make dev` for testing builds, NOT `go build`
- **Before commit**: `make check-changes`
- **Dependencies**: `make deps && go mod tidy`
### Error Handling
- Commands return errors, main.go handles exit codes
- Use `fmt.Errorf("context: %w", err)` for wrapping
#### `info`
- **Purpose**: Display plugin manifest information
- **Implementation**: `info.go`
- **Usage**: `pluginctl info`
- **Features**:
- Shows plugin ID, name, version
- Displays minimum Mattermost version
- Indicates server/webapp code presence
- Shows settings schema availability
- **Path Resolution**: Uses global path logic (--plugin-path flag, environment variable, or current directory)
### Adding New Commands
1. Create `[command].go` in root with `Run[Command]Command` function
2. Add case to `runCommand()` switch in main.go
3. Update `showUsage()` in main.go
#### Step 1: Create Command File
Create a new file named `[command].go` with the command implementation:
### Key Patterns
```go
package main
- Always validate plugin.json exists before operations
- Use structured logging with key-value pairs
- Follow existing naming conventions
- Keep command files self-contained and testable
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
### Documentation
func run[Command]Command(args []string, pluginPath string) error {
// Command implementation here
// Use pluginPath to load plugin manifest
return nil
}
```
- **CRITICAL**: Keep README.md updated when adding new commands or changing functionality
- README.md should be user-facing - focus on usage, not implementation details
- Use `pluginctl --help` for command documentation, not hardcoded lists in README
#### Step 2: Register in Main Router
Add the command to the `runCommand()` function in `cmd/pluginctl/main.go`:
```go
func runCommand(command string, args []string, pluginPath string) error {
switch command {
case "info":
return runInfoCommand(args, pluginPath)
case "new-command":
return runNewCommandCommand(args, pluginPath)
// ... other commands
}
}
```
#### Step 3: Update Help Text
Add the command to the `showUsage()` function in `main.go`.
### Dependencies
#### Core Dependencies
- `github.com/mattermost/mattermost/server/public/model` - Official Mattermost plugin types
- Standard Go library for CLI operations
#### Dependency Management
- Use `go mod tidy` to manage dependencies
- Prefer standard library over external packages when possible
- Only add dependencies that provide significant value
### Build System and Development Tools
#### Tool Versions
The project uses pinned versions for reproducible builds:
- **golangci-lint**: v1.62.2
- **goreleaser**: v2.6.2
- **gosec**: v2.22.0
- **Go**: 1.24.3
#### Makefile Targets
**Development Workflow:**
- `make dev` - Quick development build (fmt, lint, build)
- `make check-changes` - Check changes (lint, security, test)
- `make verify` - Full verification (clean, lint, test, build)
**Building:**
- `make build` - Build binary for current platform
- `make build-all` - Build for all supported platforms
- `make install` - Install binary to GOPATH/bin
**Testing and Quality:**
- `make test` - Run tests
- `make test-coverage` - Run tests with coverage report
- `make lint` - Run linter
- `make lint-fix` - Fix linting issues automatically
- `make security` - Run security scan with gosec
**Development Setup:**
- `make dev-setup` - Install all development tools with pinned versions
- `make deps` - Install/update dependencies
- `make fmt` - Format code
**Release Management:**
- `make release` - Create production release (requires goreleaser)
- `make snapshot` - Create snapshot release for testing
**Utilities:**
- `make clean` - Clean build artifacts
- `make version` - Show version and tool information
- `make help` - Show all available targets
#### Configuration Files
**Makefile**
- Uses `go get -tool` for Go 1.24+ tool management
- Cross-platform build support (Linux, macOS, Windows)
- Git-based version information in binaries
**.goreleaser.yml**
- Multi-platform release automation
- GitHub releases with changelog generation
- Package manager integration (Homebrew, Scoop)
- Docker image building support
**.golangci.yml**
- 40+ enabled linters for comprehensive code quality
- Optimized for Go 1.24
- Security scanning integration
- Test file exclusions for appropriate linters
#### Development Workflow
1. **Setup**: `make dev-setup` (one-time)
2. **Development**: `make dev` (format, lint, build)
3. **Before commit**: `make check-changes` (lint, security, test)
4. **Full verification**: `make verify` (complete build verification)
#### Building
```bash
# Quick build
make build
# Cross-platform builds
make build-all
# Development build with checks
make dev
```
#### Testing
- Always test with a sample plugin.json file
- Test both current directory and custom path operations
- Verify help and version commands work correctly
- Use `make test-coverage` for coverage reports
### Error Handling Standards
#### Error Messages
- Use descriptive error messages that help users understand what went wrong
- Include file paths in error messages when relevant
- Wrap errors with context using `fmt.Errorf("operation failed: %w", err)`
#### Exit Codes
- `0`: Success
- `1`: General error
- Let main.go handle all exit codes - command functions should return errors
### Plugin Path Resolution
#### Priority Order
1. **Command-line flag**: `--plugin-path /path/to/plugin`
2. **Environment variable**: `PLUGINCTL_PLUGIN_PATH=/path/to/plugin`
3. **Current directory**: Default fallback
#### Implementation
- `getEffectivePluginPath(flagPath string) string` - Determines effective plugin path
- All commands receive the resolved plugin path as a parameter
- Path is resolved to absolute path before use
### Plugin Validation
#### Required Checks
- Plugin.json file must exist
- Plugin.json must be valid JSON
- Plugin.json must conform to Mattermost manifest schema
#### Utility Functions (plugin.go)
- `LoadPluginManifest()` - Load from current directory
- `LoadPluginManifestFromPath(path)` - Load from specific path
- `HasServerCode(manifest)` - Check for server-side code
- `HasWebappCode(manifest)` - Check for webapp code
- `IsValidPluginDirectory()` - Validate current directory
### Future Command Ideas
- `init` - Initialize a new plugin project
- `build` - Build plugin for distribution
- `deploy` - Deploy plugin to Mattermost instance
- `validate` - Validate plugin structure and manifest
- `package` - Package plugin for distribution
- `test` - Run plugin tests
### Version Management
- Current version: 0.1.0
- Update version in `main.go` when releasing
- Follow semantic versioning
### Documentation Maintenance
- **CRITICAL**: Always keep README.md up to date with any changes
- When adding new commands, update both CLAUDE.md and README.md
- When changing build processes, update both architecture docs and user docs
- When adding new dependencies or tools, document them in both files
- README.md is the user-facing documentation - it must be comprehensive and current
### Notes for Claude Sessions
- Always maintain the separation between CLI framework and command implementation
- Use the official Mattermost model types - never create custom manifest structs
- Keep command implementations in separate files for maintainability
- Always validate plugin.json before performing operations
- Test new commands with the sample plugin.json file
- Follow the established error handling patterns
- Use the build system: `make check-changes` before any commits
- Use pinned tool versions for reproducible development environments

View file

@ -136,12 +136,11 @@ verify: clean lint test build ## Verify build (clean, lint, test, build)
# Quick development build
.PHONY: dev
dev: fmt lint snapshot ## Quick development build (fmt, lint, build)
dev: fmt lint build ## Quick development build (fmt, lint, build)
# Check changes target
.PHONY: check-changes
check-changes: lint test ## Check changes (lint, test)
@echo "All checks passed!"
# CI target
.PHONY: ci

218
README.md
View file

@ -20,7 +20,14 @@ Download the latest binary from the [releases page](https://github.com/mattermos
```bash
# Using go install
go install github.com/mattermost/pluginctl/cmd/pluginctl
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
```
## Usage
@ -31,11 +38,11 @@ go install github.com/mattermost/pluginctl/cmd/pluginctl
# Display plugin information
pluginctl info
# Show help and available commands
# Show help
pluginctl --help
# Show version
pluginctl version
pluginctl --version
```
### Plugin Path Configuration
@ -61,42 +68,169 @@ pluginctl version
pluginctl info
```
### Available Commands
### Commands
Run `pluginctl --help` to see all available commands and options.
#### `info`
## Development
### Quick Start
Display comprehensive information about a Mattermost plugin:
```bash
# Set up development environment
make dev-setup
# Install dependencies
make deps
# Quick development build (format, lint, build)
make dev
# Run tests and checks before committing
make check-changes
pluginctl info
```
See `make help` for the complete list of available targets.
**Output includes:**
### Adding New Commands
- Plugin ID, name, and version
- Minimum Mattermost server version required
- Description (if available)
- Server code presence and supported platforms
- Webapp code presence and bundle path
- Settings schema availability
1. Create a new command file (e.g., `build.go`) in the root directory
2. Implement the command following existing patterns
3. Register the command in `cmd/pluginctl/main.go`
4. Update the help text
**Example output:**
See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines.
```
Plugin Information:
==================
ID: com.example.testplugin
Name: Test Plugin
Version: 1.0.0
Min MM Version: 7.0.0
Description: A test plugin for demonstrating pluginctl functionality
Code Components:
================
Server Code: Yes
Executables: linux-amd64, darwin-amd64, windows-amd64
Webapp Code: Yes
Bundle Path: webapp/dist/main.js
Settings Schema: Yes
```
## Requirements
- Go 1.24.3 or later
- Valid Mattermost plugin directory with `plugin.json` manifest file
## Development Tools
The project uses the following tools for development and release automation:
- **golangci-lint** v1.62.2 - Code linting and quality checks
- **goreleaser** v2.6.2 - Automated releases and cross-platform builds
- **gosec** v2.22.0 - Security vulnerability scanning
## Plugin Directory Structure
`pluginctl` expects to work with standard Mattermost plugin directories containing a `plugin.json` file. For more information about Mattermost plugin structure, visit the [official documentation](https://developers.mattermost.com/integrate/plugins/).
## Environment Variables
| Variable | Description |
| ----------------------- | ----------------------------- |
| `PLUGINCTL_PLUGIN_PATH` | Default plugin directory path |
## Contributing
We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) file for architecture guidelines and development patterns.
We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) file for architecture guidelines and development instructions.
### Development Setup
1. Clone the repository:
```bash
git clone https://github.com/mattermost/pluginctl.git
cd pluginctl
```
2. Set up development environment (installs pinned tool versions):
```bash
make dev-setup
```
3. Install dependencies:
```bash
make deps
```
4. Build the project:
```bash
make build
```
5. Test with a sample plugin:
```bash
./pluginctl info
```
### Development Workflow
Use these Make targets for efficient development:
```bash
# Quick development build (format, lint, build)
make dev
# Check all changes before committing (lint, security, test)
make check-changes
# Full verification (clean, lint, test, build)
make verify
# Run tests with coverage
make test-coverage
# Build for all platforms
make build-all
```
### Available Make Targets
**Development:**
- `make dev` - Quick development build
- `make check-changes` - Validate changes (lint, security, test)
- `make verify` - Full build verification
- `make fmt` - Format code
- `make clean` - Clean build artifacts
**Testing:**
- `make test` - Run tests
- `make test-coverage` - Run tests with coverage report
- `make lint` - Run linter
- `make lint-fix` - Fix linting issues automatically
- `make security` - Run security scan
**Building:**
- `make build` - Build for current platform
- `make build-all` - Build for all platforms
- `make install` - Install to GOPATH/bin
**Release:**
- `make release` - Create production release
- `make snapshot` - Create snapshot release
**Utilities:**
- `make help` - Show all available targets
- `make version` - Show version information
- `make dev-setup` - Install development tools
### Adding New Commands
1. Create a new command file (e.g., `build.go`)
2. Implement the command following the patterns in `info.go`
3. Register the command in `cmd/pluginctl/main.go`
4. Update the help text and documentation
See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines.
### Code Style
@ -105,6 +239,31 @@ We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) f
- Maintain separation between CLI framework and command implementation
- Include comprehensive error handling with descriptive messages
### Testing
Test your changes with various plugin configurations:
```bash
# Run all tests
make test
# Run tests with coverage
make test-coverage
# Test CLI functionality
./pluginctl info
# Test with command-line flag
./pluginctl --plugin-path /path/to/plugin info
# Test with environment variable
export PLUGINCTL_PLUGIN_PATH=/path/to/plugin
./pluginctl info
# Validate all changes before committing
make check-changes
```
## License
This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.
@ -119,5 +278,6 @@ This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENS
For questions, issues, or feature requests, please:
- 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
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

View file

@ -1,2 +0,0 @@
server/manifest.go linguist-generated=true
webapp/src/manifest.js linguist-generated=true

View file

@ -1 +0,0 @@
mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config

View file

@ -1 +0,0 @@
20.11

View file

@ -1,43 +0,0 @@
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

View file

@ -1,83 +0,0 @@
# ====================================================================================
# 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:
pluginctl 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)
cp plugin.json dist/$(PLUGIN_ID)/plugin.json
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

View file

@ -1,47 +0,0 @@
# ====================================================================================
# Deployment and Plugin Management
# ====================================================================================
## Builds and installs the plugin to a server.
.PHONY: deploy
deploy: dist
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

View file

@ -1,53 +0,0 @@
# ====================================================================================
# 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; \

View file

@ -1,44 +0,0 @@
# 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

View file

@ -1,57 +0,0 @@
# ====================================================================================
# 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

View file

@ -1,42 +0,0 @@
# ====================================================================================
# 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

View file

@ -1,111 +0,0 @@
# ====================================================================================
# 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)

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"net"
"os"
"time"
@ -21,14 +22,14 @@ func getClient(ctx context.Context) (*model.Client4, error) {
client, connected := getUnixClient(socketPath)
if connected {
Logger.Info("Connecting using local mode", "socket_path", socketPath)
log.Printf("Connecting using local mode over %s", socketPath)
return client, nil
}
if os.Getenv("MM_LOCALSOCKETPATH") != "" {
Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.",
"socket_path", socketPath)
log.Printf("No socket found at %s for local mode deployment. "+
"Attempting to authenticate with credentials.", socketPath)
}
siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL")
@ -43,7 +44,7 @@ func getClient(ctx context.Context) (*model.Client4, error) {
client = model.NewAPIv4Client(siteURL)
if adminToken != "" {
Logger.Info("Authenticating using token", "site_url", siteURL)
log.Printf("Authenticating using token against %s.", siteURL)
client.SetToken(adminToken)
return client, nil
@ -51,7 +52,7 @@ func getClient(ctx context.Context) (*model.Client4, error) {
if adminUsername != "" && adminPassword != "" {
client := model.NewAPIv4Client(siteURL)
Logger.Info("Authenticating with credentials", "username", adminUsername, "site_url", siteURL)
log.Printf("Authenticating as %s against %s.", adminUsername, siteURL)
_, _, err := client.Login(ctx, adminUsername, adminPassword)
if err != nil {
return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, err)

View file

@ -15,9 +15,6 @@ const (
)
func main() {
// Initialize logger
pluginctl.InitLogger()
var pluginPath string
flag.StringVar(&pluginPath, "plugin-path", "", "Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)")
@ -25,7 +22,7 @@ func main() {
args := flag.Args()
if len(args) == 0 {
pluginctl.Logger.Error("No command specified")
fmt.Fprintf(os.Stderr, "Error: No command specified\n\n")
showUsage()
os.Exit(ExitError)
}
@ -37,7 +34,7 @@ func main() {
effectivePluginPath := pluginctl.GetEffectivePluginPath(pluginPath)
if err := runCommand(command, commandArgs, effectivePluginPath); err != nil {
pluginctl.Logger.Error("Command failed", "error", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(ExitError)
}
}
@ -52,14 +49,8 @@ func runCommand(command string, args []string, pluginPath string) error {
return runDisableCommand(args, pluginPath)
case "reset":
return runResetCommand(args, pluginPath)
case "deploy":
return runDeployCommand(args, pluginPath)
case "updateassets":
return runUpdateAssetsCommand(args, pluginPath)
case "manifest":
return runManifestCommand(args, pluginPath)
case "logs":
return runLogsCommand(args, pluginPath)
case "help":
showUsage()
@ -77,7 +68,7 @@ func runInfoCommand(args []string, pluginPath string) error {
func runVersionCommand(_ []string) error {
version := pluginctl.GetVersion()
pluginctl.Logger.Info("pluginctl version", "version", version)
fmt.Printf("pluginctl version %s\n", version)
return nil
}
@ -97,20 +88,8 @@ func runUpdateAssetsCommand(args []string, pluginPath string) error {
return pluginctl.RunUpdateAssetsCommand(args, pluginPath)
}
func runManifestCommand(args []string, pluginPath string) error {
return pluginctl.RunManifestCommand(args, pluginPath)
}
func runLogsCommand(args []string, pluginPath string) error {
return pluginctl.RunLogsCommand(args, pluginPath)
}
func runDeployCommand(args []string, pluginPath string) error {
return pluginctl.RunDeployCommand(args, pluginPath)
}
func showUsage() {
usageText := `pluginctl - Mattermost Plugin Development CLI
fmt.Printf(`pluginctl - Mattermost Plugin Development CLI
Usage:
pluginctl [global options] <command> [command options] [arguments...]
@ -123,10 +102,7 @@ Commands:
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 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
@ -136,16 +112,7 @@ Examples:
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
@ -160,6 +127,5 @@ Environment Variables:
For more information about Mattermost plugin development, visit:
https://developers.mattermost.com/integrate/plugins/
`
pluginctl.Logger.Info(usageText)
`)
}

View file

@ -1,98 +0,0 @@
package pluginctl
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/mattermost/mattermost/server/public/model"
)
func RunDeployCommand(args []string, pluginPath 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")
}
bundlePath = args[i+1]
i += 2
default:
i++
}
}
// 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)
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)
}
}
// 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
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")
}
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
client, err := getClient(ctx)
if err != nil {
return err
}
return deployPlugin(ctx, client, pluginID, bundlePath)
}
func deployPlugin(ctx context.Context, client *model.Client4, pluginID, bundlePath string) error {
pluginBundle, err := os.Open(bundlePath)
if err != nil {
return fmt.Errorf("failed to open bundle file %s: %w", bundlePath, err)
}
defer func() {
if closeErr := pluginBundle.Close(); closeErr != nil {
Logger.Error("Failed to close plugin bundle", "error", closeErr)
}
}()
Logger.Info("Uploading plugin bundle", "bundle_path", bundlePath)
_, _, err = client.UploadPluginForced(ctx, pluginBundle)
if err != nil {
return fmt.Errorf("failed to upload plugin bundle: %w", err)
}
Logger.Info("Enabling plugin", "plugin_id", pluginID)
_, err = client.EnablePlugin(ctx, pluginID)
if err != nil {
return fmt.Errorf("failed to enable plugin: %w", err)
}
Logger.Info("Plugin deployed successfully", "plugin_id", pluginID)
return nil
}

View file

@ -3,6 +3,7 @@ package pluginctl
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
)
@ -12,7 +13,7 @@ func RunDisableCommand(args []string, pluginPath string) error {
}
func disablePlugin(ctx context.Context, client *model.Client4, pluginID string) error {
Logger.Info("Disabling plugin")
log.Print("Disabling plugin.")
_, err := client.DisablePlugin(ctx, pluginID)
if err != nil {
return fmt.Errorf("failed to disable plugin: %w", err)

View file

@ -3,6 +3,7 @@ package pluginctl
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
)
@ -12,7 +13,7 @@ func RunEnableCommand(args []string, pluginPath string) error {
}
func enablePlugin(ctx context.Context, client *model.Client4, pluginID string) error {
Logger.Info("Enabling plugin")
log.Print("Enabling plugin.")
_, err := client.EnablePlugin(ctx, pluginID)
if err != nil {
return fmt.Errorf("failed to enable plugin: %w", err)

5
go.mod
View file

@ -2,10 +2,7 @@ module github.com/mattermost/pluginctl
go 1.24.3
require (
github.com/lmittmann/tint v1.1.2
github.com/mattermost/mattermost/server/public v0.1.15
)
require github.com/mattermost/mattermost/server/public v0.1.15
require (
4d63.com/gocheckcompilerdirectives v1.2.1 // indirect

2
go.sum
View file

@ -870,8 +870,6 @@ github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84Yrj
github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA=
github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666 h1:ndfLOJNaxu0fX358UKxtq2bU8IMASWi87Hn0Nv/TIoY=
github.com/letsencrypt/boulder v0.0.0-20250411005613-d800055fe666/go.mod h1:WGXwLq/jKt0kng727wv6a0h0q7TVC+MwS2S75rcqL+4=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=

127
info.go
View file

@ -19,79 +19,68 @@ func InfoCommand() error {
// PrintPluginInfo displays formatted plugin information.
func PrintPluginInfo(manifest *model.Manifest) error {
printBasicInfo(manifest)
printCodeComponents(manifest)
printSettingsSchema(manifest)
fmt.Printf("Plugin Information:\n")
fmt.Printf("==================\n\n")
// Basic plugin info
fmt.Printf("ID: %s\n", manifest.Id)
fmt.Printf("Name: %s\n", manifest.Name)
fmt.Printf("Version: %s\n", manifest.Version)
// Minimum Mattermost version
if manifest.MinServerVersion != "" {
fmt.Printf("Min MM Version: %s\n", manifest.MinServerVersion)
} else {
fmt.Printf("Min MM Version: Not specified\n")
}
// Description if available
if manifest.Description != "" {
fmt.Printf("Description: %s\n", manifest.Description)
}
fmt.Printf("\nCode Components:\n")
fmt.Printf("================\n")
// Server code presence
if HasServerCode(manifest) {
fmt.Printf("Server Code: Yes\n")
if manifest.Server != nil && len(manifest.Server.Executables) > 0 {
fmt.Printf(" Executables: ")
first := true
for platform := range manifest.Server.Executables {
if !first {
fmt.Printf(", ")
}
fmt.Printf("%s", platform)
first = false
}
fmt.Printf("\n")
}
} else {
fmt.Printf("Server Code: No\n")
}
// Webapp code presence
if HasWebappCode(manifest) {
fmt.Printf("Webapp Code: Yes\n")
if manifest.Webapp != nil && manifest.Webapp.BundlePath != "" {
fmt.Printf(" Bundle Path: %s\n", manifest.Webapp.BundlePath)
}
} else {
fmt.Printf("Webapp Code: No\n")
}
// Settings schema
if manifest.SettingsSchema != nil {
fmt.Printf("Settings Schema: Yes\n")
} else {
fmt.Printf("Settings Schema: No\n")
}
return nil
}
// printBasicInfo prints basic plugin information.
func printBasicInfo(manifest *model.Manifest) {
Logger.Info("Plugin Information:")
Logger.Info("==================")
Logger.Info("ID:", "value", manifest.Id)
Logger.Info("Name:", "value", manifest.Name)
Logger.Info("Version:", "value", manifest.Version)
minVersion := manifest.MinServerVersion
if minVersion == "" {
minVersion = "Not specified"
}
Logger.Info("Min MM Version:", "value", minVersion)
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("================")
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 {
var executables []string
for platform := range manifest.Server.Executables {
executables = append(executables, platform)
}
Logger.Info("Executables:", "platforms", executables)
}
} else {
Logger.Info("Server Code:", "value", "No")
}
}
// 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 != "" {
Logger.Info("Bundle Path:", "value", manifest.Webapp.BundlePath)
}
} else {
Logger.Info("Webapp Code:", "value", "No")
}
}
// printSettingsSchema prints settings schema information.
func printSettingsSchema(manifest *model.Manifest) {
value := "No"
if manifest.SettingsSchema != nil {
value = "Yes"
}
Logger.Info("Settings Schema:", "value", value)
}
// InfoCommandWithPath implements the 'info' command with a custom path.
func InfoCommandWithPath(path string) error {
manifest, err := LoadPluginManifestFromPath(path)

View file

@ -125,9 +125,6 @@ func TestPrintPluginInfo(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize logger for testing
InitLogger()
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()

View file

@ -1,37 +0,0 @@
package pluginctl
import (
"log/slog"
"os"
"time"
"github.com/lmittmann/tint"
)
// Logger is the global logger instance.
var Logger *slog.Logger
// InitLogger initializes the global logger.
func InitLogger() {
// Create a tint handler for colorized output
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: slog.LevelInfo,
TimeFormat: time.Kitchen,
AddSource: false,
NoColor: false,
})
Logger = slog.New(handler)
}
// SetLogLevel sets the minimum logging level.
func SetLogLevel(level slog.Level) {
handler := tint.NewHandler(os.Stderr, &tint.Options{
Level: level,
TimeFormat: time.Kitchen,
AddSource: false,
NoColor: false,
})
Logger = slog.New(handler)
}

215
logs.go
View file

@ -1,215 +0,0 @@
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
}

View file

@ -1,57 +0,0 @@
package pluginctl
import (
"fmt"
"path/filepath"
)
// RunManifestCommand implements the 'manifest' command functionality with subcommands.
func RunManifestCommand(args []string, pluginPath string) error {
if len(args) == 0 {
return fmt.Errorf("manifest command requires a subcommand: id, version, has_server, has_webapp, check")
}
// Convert to absolute path
absPath, err := filepath.Abs(pluginPath)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Load plugin manifest
manifest, err := LoadPluginManifestFromPath(absPath)
if err != nil {
return fmt.Errorf("failed to load plugin manifest: %w", err)
}
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 "has_webapp":
if HasWebappCode(manifest) {
fmt.Println("true")
} else {
fmt.Println("false")
}
case "check":
if err := manifest.IsValid(); err != nil {
Logger.Error("Plugin manifest validation failed", "error", err)
return err
}
Logger.Info("Plugin manifest is valid")
default:
return fmt.Errorf("unknown subcommand: %s. Available subcommands: id, version, has_server, has_webapp, check",
subcommand)
}
return nil
}

View file

@ -11,11 +11,6 @@ import (
const PluginManifestName = "plugin.json"
// PluginCtlConfig represents the configuration for pluginctl stored in the manifest props.
type PluginCtlConfig struct {
IgnoreAssets []string `json:"ignore_assets,omitempty"`
}
// LoadPluginManifest loads and parses the plugin.json file from the current directory.
func LoadPluginManifest() (*model.Manifest, error) {
return LoadPluginManifestFromPath(".")
@ -83,34 +78,3 @@ func GetEffectivePluginPath(flagPath string) string {
return cwd
}
// ParsePluginCtlConfig extracts and parses the pluginctl configuration from the manifest props.
func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) {
// Default configuration
config := &PluginCtlConfig{
IgnoreAssets: []string{},
}
// Check if props exist
if manifest.Props == nil {
return config, nil
}
// Check if pluginctl config exists in props
pluginctlData, exists := manifest.Props["pluginctl"]
if !exists {
return config, nil
}
// Convert to JSON and parse
jsonData, err := json.Marshal(pluginctlData)
if err != nil {
return nil, fmt.Errorf("failed to marshal pluginctl config: %w", err)
}
if err := json.Unmarshal(jsonData, config); err != nil {
return nil, fmt.Errorf("failed to parse pluginctl config: %w", err)
}
return config, nil
}

View file

@ -402,207 +402,3 @@ func TestHasServerCodeAndWebappCode(t *testing.T) {
})
}
}
func TestParsePluginCtlConfig(t *testing.T) {
tests := []struct {
name string
manifest *model.Manifest
expectedConfig *PluginCtlConfig
expectError bool
errorContains string
}{
{
name: "Manifest with valid pluginctl config",
manifest: &model.Manifest{
Props: map[string]interface{}{
"pluginctl": map[string]interface{}{
"ignore_assets": []string{"*.test.js", "build/", "temp/**"},
},
},
},
expectedConfig: &PluginCtlConfig{
IgnoreAssets: []string{"*.test.js", "build/", "temp/**"},
},
expectError: false,
},
{
name: "Manifest with no props",
manifest: &model.Manifest{
Props: nil,
},
expectedConfig: &PluginCtlConfig{
IgnoreAssets: []string{},
},
expectError: false,
},
{
name: "Manifest with empty props",
manifest: &model.Manifest{
Props: map[string]interface{}{},
},
expectedConfig: &PluginCtlConfig{
IgnoreAssets: []string{},
},
expectError: false,
},
{
name: "Manifest with no pluginctl config",
manifest: &model.Manifest{
Props: map[string]interface{}{
"other": "value",
},
},
expectedConfig: &PluginCtlConfig{
IgnoreAssets: []string{},
},
expectError: false,
},
{
name: "Manifest with empty pluginctl config",
manifest: &model.Manifest{
Props: map[string]interface{}{
"pluginctl": map[string]interface{}{},
},
},
expectedConfig: &PluginCtlConfig{
IgnoreAssets: []string{},
},
expectError: false,
},
{
name: "Manifest with invalid pluginctl config",
manifest: &model.Manifest{
Props: map[string]interface{}{
"pluginctl": "invalid",
},
},
expectedConfig: nil,
expectError: true,
errorContains: "failed to parse pluginctl config",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config, err := ParsePluginCtlConfig(tt.manifest)
if tt.expectError {
if err == nil {
t.Error("Expected error but got nil")
} else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
t.Errorf("Expected error to contain %q but got: %v", tt.errorContains, err)
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if config == nil {
t.Error("Expected config but got nil")
return
}
if len(config.IgnoreAssets) != len(tt.expectedConfig.IgnoreAssets) {
t.Errorf("Expected %d ignore assets but got %d", len(tt.expectedConfig.IgnoreAssets), len(config.IgnoreAssets))
return
}
for i, expected := range tt.expectedConfig.IgnoreAssets {
if config.IgnoreAssets[i] != expected {
t.Errorf("Expected ignore asset %d to be %q but got %q", i, expected, config.IgnoreAssets[i])
}
}
})
}
}
func TestIsPathIgnored(t *testing.T) {
tests := []struct {
name string
relativePath string
ignorePatterns []string
expectedIgnore bool
expectedPattern string
}{
{
name: "No ignore patterns",
relativePath: "webapp/dist/main.js",
ignorePatterns: []string{},
expectedIgnore: false,
expectedPattern: "",
},
{
name: "Direct file match",
relativePath: "test.js",
ignorePatterns: []string{"*.js"},
expectedIgnore: true,
expectedPattern: "*.js",
},
{
name: "Directory pattern with slash",
relativePath: "build/output.js",
ignorePatterns: []string{"build/"},
expectedIgnore: true,
expectedPattern: "build/",
},
{
name: "Directory pattern without slash",
relativePath: "build/output.js",
ignorePatterns: []string{"build"},
expectedIgnore: true,
expectedPattern: "build",
},
{
name: "Nested directory match",
relativePath: "webapp/dist/main.js",
ignorePatterns: []string{"dist"},
expectedIgnore: true,
expectedPattern: "dist",
},
{
name: "Multiple patterns - first match",
relativePath: "test.js",
ignorePatterns: []string{"*.js", "*.css"},
expectedIgnore: true,
expectedPattern: "*.js",
},
{
name: "Multiple patterns - second match",
relativePath: "style.css",
ignorePatterns: []string{"*.js", "*.css"},
expectedIgnore: true,
expectedPattern: "*.css",
},
{
name: "No match",
relativePath: "README.md",
ignorePatterns: []string{"*.js", "*.css"},
expectedIgnore: false,
expectedPattern: "",
},
{
name: "Complex path with match",
relativePath: "webapp/node_modules/package/file.js",
ignorePatterns: []string{"node_modules"},
expectedIgnore: true,
expectedPattern: "node_modules",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ignored, pattern := isPathIgnored(tt.relativePath, tt.ignorePatterns)
if ignored != tt.expectedIgnore {
t.Errorf("Expected ignore result %v but got %v", tt.expectedIgnore, ignored)
}
if pattern != tt.expectedPattern {
t.Errorf("Expected pattern %q but got %q", tt.expectedPattern, pattern)
}
})
}
}

View file

@ -10,206 +10,89 @@ 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")
}
Logger.Info("Updating assets in plugin directory", "path", pluginPath)
fmt.Printf("Updating assets in plugin directory: %s\n", 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)
}
pluginCtlConfig, err := ParsePluginCtlConfig(manifest)
if err != nil {
return fmt.Errorf("failed to parse pluginctl config: %w", err)
}
// Check if the plugin has webapp code according to manifest
hasWebapp := HasWebappCode(manifest)
updatedCount := 0
config := AssetProcessorConfig{
pluginPath: pluginPath,
hasWebapp: hasWebapp,
updatedCount: &updatedCount,
pluginCtlConfig: pluginCtlConfig,
}
// Counter for updated files
var updatedCount int
// Walk through the embedded assets
err = fs.WalkDir(assetsFS, "assets", func(path string, d fs.DirEntry, err error) error {
return processAssetEntry(path, d, err, config)
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)
}
fmt.Printf("Updated file: %s\n", relativePath)
updatedCount++
}
return nil
})
if err != nil {
return fmt.Errorf("failed to update assets: %w", err)
}
Logger.Info("Assets updated successfully!", "files_updated", updatedCount)
return nil
}
// isPathIgnored checks if a path matches any of the ignore patterns.
func isPathIgnored(relativePath string, ignorePatterns []string) (ignored bool, matchedPattern string) {
for _, pattern := range ignorePatterns {
// Direct file or path match
if matched, err := filepath.Match(pattern, relativePath); err == nil && matched {
return true, pattern
}
// Check if the path starts with the pattern (for directory patterns)
if strings.HasSuffix(pattern, "/") && strings.HasPrefix(relativePath, pattern) {
return true, pattern
}
// Check if any parent directory matches the pattern
if matchesParentDirectory(relativePath, pattern) {
return true, pattern
}
// Check if any directory component matches the pattern
if matchesDirectoryComponent(relativePath, pattern) {
return true, pattern
}
}
return false, ""
}
// matchesParentDirectory checks if any parent directory matches the pattern.
func matchesParentDirectory(relativePath, pattern string) bool {
dir := filepath.Dir(relativePath)
for dir != "." && dir != "/" {
if matched, err := filepath.Match(pattern, dir); err == nil && matched {
return true
}
// Also check direct string match for directory names
if filepath.Base(dir) == pattern {
return true
}
dir = filepath.Dir(dir)
}
return false
}
// matchesDirectoryComponent checks if any directory component matches the pattern.
func matchesDirectoryComponent(relativePath, pattern string) bool {
parts := strings.Split(relativePath, "/")
for _, part := range parts {
if matched, err := filepath.Match(pattern, part); err == nil && matched {
return true
}
}
return false
}
type AssetProcessorConfig struct {
pluginPath string
hasWebapp bool
updatedCount *int
pluginCtlConfig *PluginCtlConfig
}
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
}
// Check if path is ignored by pluginctl config
if ignored, pattern := isPathIgnored(relativePath, config.pluginCtlConfig.IgnoreAssets); ignored {
Logger.Info("Skipping asset due to ignore pattern", "path", relativePath, "pattern", pattern)
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)
fmt.Printf("Assets updated successfully! (%d files updated)\n", updatedCount)
return nil
}

View file

@ -1,13 +1,14 @@
package pluginctl
import (
"fmt"
"runtime/debug"
)
// RunVersionCommand implements the 'version' command functionality.
func RunVersionCommand(args []string) error {
version := GetVersion()
Logger.Info("pluginctl version", "version", version)
fmt.Printf("pluginctl version %s\n", version)
return nil
}