Compare commits

..

10 commits

Author SHA1 Message Date
03c521f237
Fix linting issues in manifest.go and plugin_test.go
- Add blank line before return statement in manifest.go
- Fix table formatting alignment in plugin_test.go

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 19:19:11 +02:00
278958d1e4
Add deploy command to upload and enable plugin bundles
- New deploy.go implements RunDeployCommand function
- Auto-discovers plugin bundle from ./dist/ folder based on manifest
- Supports --bundle-path flag for custom bundle location
- Reuses existing client connection logic for server authentication
- Updated main.go to register deploy command and add help documentation
- Follows existing patterns for error handling and structured logging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 19:17:25 +02:00
6639dad2d6
Add check subcommand to manifest command
- Add 'check' case to manifest command that validates plugin manifest
  using manifest.IsValid()
- Return appropriate exit codes: 0 for valid, 1 for invalid manifests
- Update help text and error messages to include the new subcommand
- Log validation results using structured logging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 17:53:47 +02:00
873bf78c22
Add custom parser for props["pluginctl"] with ignore assets support
- Add PluginCtlConfig struct with IgnoreAssets field for glob patterns
- Add ParsePluginCtlConfig function to parse manifest props["pluginctl"]
- Update updateassets command to respect ignore patterns with glob matching
- Add comprehensive logging when files are skipped due to ignore patterns
- Support patterns like *.test.js, build/, node_modules for flexible exclusion
- Add extensive tests for config parsing and path matching functionality
- Maintain backward compatibility with existing manifests
- Fix Makefile check-changes target and add logger init to tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 17:38:38 +02:00
4d9c958fc9
moved test json files to folders 2025-07-14 17:24:32 +02:00
71a7b0de11
Refactor codebase with improved structure and logging
- 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 <noreply@anthropic.com>
2025-07-14 17:14:12 +02:00
c01c9c2843
Add manifest command with subcommands for plugin information
- Add manifest command with id, version, has_server, has_webapp subcommands
- Provides simple text output for easy parsing/scripting
- Update help documentation with usage examples
- Follow existing command patterns and architecture

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 17:13:08 +02:00
3cbe5e4a9f
Compacted README.md
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 17:04:27 +02:00
4f0b94354c
Compacted CLAUDE.md file
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 16:58:34 +02:00
73149001eb
Replace logging system with slog and tint for structured colored output
- Add logger.go with public Logger variable and InitLogger function
- Replace all fmt.Printf/fmt.Fprintf calls with structured Logger.Info/Logger.Error
- Initialize logger in main function for consistent access across packages
- Keep fmt.Errorf for proper error creation (standard Go practice)
- Add tint dependency for colorized terminal output with timestamps
- Convert user output to structured logging with key-value pairs
- Update info command to use structured logging for plugin details
- Update updateassets command to use structured progress logging
- Update version command to use structured logging
- Update authentication logging in client.go with structured fields
- Update enable/disable commands to use structured logging
- Remove unused fmt imports after conversion

All output now uses slog with tint for beautiful, structured, colorized logging
while maintaining proper error handling with fmt.Errorf for error creation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 16:51:40 +02:00
36 changed files with 1501 additions and 566 deletions

287
CLAUDE.md
View file

@ -1,261 +1,58 @@
# pluginctl - Mattermost Plugin Development CLI
# pluginctl - Claude Memory
## 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.
## Critical Architecture Rules
## Architecture Guidelines
### Command Structure
### 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
```
- **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
### Design Principles
### Dependencies & Types
#### 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`
**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.
#### 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
#### 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
#### 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
### Current Commands
#### `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
#### Step 1: Create Command File
Create a new file named `[command].go` with the command implementation:
```go
package main
import (
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
func run[Command]Command(args []string, pluginPath string) error {
// Command implementation here
// Use pluginPath to load plugin manifest
return nil
}
```
#### 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
- Use `github.com/mattermost/mattermost/server/public/model.Manifest`
- Commands in `pluginctl` package, main.go calls them
### 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
1. `--plugin-path` flag
2. `PLUGINCTL_PLUGIN_PATH` env var
3. Current directory (default)
#### 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
### Logging
### Plugin Validation
- **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.*`
#### Required Checks
- Plugin.json file must exist
- Plugin.json must be valid JSON
- Plugin.json must conform to Mattermost manifest schema
### Build & Development
#### 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
- **CRITICAL**: Use `make dev` for testing builds, NOT `go build`
- **Before commit**: `make check-changes`
- **Dependencies**: `make deps && go mod tidy`
### 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
### Error Handling
### Version Management
- Current version: 0.1.0
- Update version in `main.go` when releasing
- Follow semantic versioning
- Commands return errors, main.go handles exit codes
- Use `fmt.Errorf("context: %w", err)` for wrapping
### 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
### Adding New Commands
### 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
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
### Key Patterns
- 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
### Documentation
- **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

View file

@ -136,11 +136,12 @@ verify: clean lint test build ## Verify build (clean, lint, test, build)
# Quick development build
.PHONY: dev
dev: fmt lint build ## Quick development build (fmt, lint, build)
dev: fmt lint snapshot ## 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

206
README.md
View file

@ -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
@ -38,11 +31,11 @@ scoop install pluginctl
# Display plugin information
pluginctl info
# Show help
# Show help and available commands
pluginctl --help
# Show version
pluginctl --version
pluginctl version
```
### Plugin Path Configuration
@ -68,170 +61,43 @@ pluginctl --version
pluginctl info
```
### Commands
### Available Commands
#### `info`
Run `pluginctl --help` to see all available commands and options.
Display comprehensive information about a Mattermost plugin:
## Development
### Quick Start
```bash
pluginctl info
```
# Set up development environment
make dev-setup
**Output includes:**
# Install dependencies
make deps
- 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
**Example output:**
```
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 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)
# Run tests and checks before committing
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
See `make help` for the complete list of available targets.
### Adding New Commands
1. Create a new command file (e.g., `build.go`)
2. Implement the command following the patterns in `info.go`
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 and documentation
4. Update the help text
See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines.
## Contributing
We welcome contributions to `pluginctl`! Please see the [CLAUDE.md](CLAUDE.md) file for architecture guidelines and development patterns.
### Code Style
- Follow Go best practices and conventions
@ -239,31 +105,6 @@ See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines.
- 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.
@ -278,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
- 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

2
assets/.gitattributes vendored Normal file
View file

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

1
assets/.gitpod.yml Normal file
View file

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

1
assets/.nvmrc Normal file
View file

@ -0,0 +1 @@
20.11

43
assets/Makefile Normal file
View file

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

83
assets/build/build.mk Normal file
View file

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

47
assets/build/deploy.mk Normal file
View file

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

53
assets/build/dev.mk Normal file
View file

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

44
assets/build/setup.mk Normal file
View file

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

57
assets/build/test.mk Normal file
View file

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

42
assets/build/utils.mk Normal file
View file

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

111
assets/build/versioning.mk Normal file
View file

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

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"log"
"net"
"os"
"time"
@ -22,14 +21,14 @@ func getClient(ctx context.Context) (*model.Client4, error) {
client, connected := getUnixClient(socketPath)
if connected {
log.Printf("Connecting using local mode over %s", socketPath)
Logger.Info("Connecting using local mode", "socket_path", socketPath)
return client, nil
}
if os.Getenv("MM_LOCALSOCKETPATH") != "" {
log.Printf("No socket found at %s for local mode deployment. "+
"Attempting to authenticate with credentials.", socketPath)
Logger.Info("No socket found for local mode deployment. Attempting to authenticate with credentials.",
"socket_path", socketPath)
}
siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL")
@ -44,7 +43,7 @@ func getClient(ctx context.Context) (*model.Client4, error) {
client = model.NewAPIv4Client(siteURL)
if adminToken != "" {
log.Printf("Authenticating using token against %s.", siteURL)
Logger.Info("Authenticating using token", "site_url", siteURL)
client.SetToken(adminToken)
return client, nil
@ -52,7 +51,7 @@ func getClient(ctx context.Context) (*model.Client4, error) {
if adminUsername != "" && adminPassword != "" {
client := model.NewAPIv4Client(siteURL)
log.Printf("Authenticating as %s against %s.", adminUsername, siteURL)
Logger.Info("Authenticating with credentials", "username", adminUsername, "site_url", 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,6 +15,9 @@ const (
)
func main() {
// Initialize logger
pluginctl.InitLogger()
var pluginPath string
flag.StringVar(&pluginPath, "plugin-path", "", "Path to plugin directory (overrides PLUGINCTL_PLUGIN_PATH)")
@ -22,7 +25,7 @@ func main() {
args := flag.Args()
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Error: No command specified\n\n")
pluginctl.Logger.Error("No command specified")
showUsage()
os.Exit(ExitError)
}
@ -34,7 +37,7 @@ func main() {
effectivePluginPath := pluginctl.GetEffectivePluginPath(pluginPath)
if err := runCommand(command, commandArgs, effectivePluginPath); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
pluginctl.Logger.Error("Command failed", "error", err)
os.Exit(ExitError)
}
}
@ -49,8 +52,14 @@ 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()
@ -68,7 +77,7 @@ func runInfoCommand(args []string, pluginPath string) error {
func runVersionCommand(_ []string) error {
version := pluginctl.GetVersion()
fmt.Printf("pluginctl version %s\n", version)
pluginctl.Logger.Info("pluginctl version", "version", version)
return nil
}
@ -88,8 +97,20 @@ 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() {
fmt.Printf(`pluginctl - Mattermost Plugin Development CLI
usageText := `pluginctl - Mattermost Plugin Development CLI
Usage:
pluginctl [global options] <command> [command options] [arguments...]
@ -102,7 +123,10 @@ 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
@ -112,7 +136,16 @@ 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
@ -127,5 +160,6 @@ Environment Variables:
For more information about Mattermost plugin development, visit:
https://developers.mattermost.com/integrate/plugins/
`)
`
pluginctl.Logger.Info(usageText)
}

98
deploy.go Normal file
View file

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

View file

@ -3,7 +3,6 @@ package pluginctl
import (
"context"
"fmt"
"log"
"github.com/mattermost/mattermost/server/public/model"
)
@ -13,7 +12,7 @@ func RunEnableCommand(args []string, pluginPath string) error {
}
func enablePlugin(ctx context.Context, client *model.Client4, pluginID string) error {
log.Print("Enabling plugin.")
Logger.Info("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,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

2
go.sum
View file

@ -870,6 +870,8 @@ 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,68 +19,79 @@ func InfoCommand() error {
// PrintPluginInfo displays formatted plugin information.
func PrintPluginInfo(manifest *model.Manifest) error {
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")
}
printBasicInfo(manifest)
printCodeComponents(manifest)
printSettingsSchema(manifest)
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,6 +125,9 @@ 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()

37
logger.go Normal file
View file

@ -0,0 +1,37 @@
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 Normal file
View file

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

57
manifest.go Normal file
View file

@ -0,0 +1,57 @@
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,6 +11,11 @@ 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(".")
@ -78,3 +83,34 @@ 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,3 +402,207 @@ 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,89 +10,206 @@ 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")
}
fmt.Printf("Updating assets in plugin directory: %s\n", pluginPath)
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
pluginCtlConfig, err := ParsePluginCtlConfig(manifest)
if err != nil {
return fmt.Errorf("failed to parse pluginctl config: %w", err)
}
hasWebapp := HasWebappCode(manifest)
updatedCount := 0
// Counter for updated files
var updatedCount int
config := AssetProcessorConfig{
pluginPath: pluginPath,
hasWebapp: hasWebapp,
updatedCount: &updatedCount,
pluginCtlConfig: pluginCtlConfig,
}
// 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)
}
fmt.Printf("Updated file: %s\n", relativePath)
updatedCount++
}
return nil
return processAssetEntry(path, d, err, config)
})
if err != nil {
return fmt.Errorf("failed to update assets: %w", err)
}
fmt.Printf("Assets updated successfully! (%d files updated)\n", updatedCount)
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)
return nil
}

View file

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