pluginctl/manifest.go
Felipe Martin 353cc9efc7
Add version tracking and validation to prevent downgrade issues
Store the last pluginctl version used in plugin.json props and validate
before running commands. Prevents issues when using older pluginctl
versions on plugins modified by newer versions.

- Add Version field to PluginCtlConfig struct
- Implement version validation before command execution
- Add WritePluginManifest and SavePluginCtlConfig helper functions
- Add comprehensive tests for version comparison logic
- Skip validation for 'version' command and when no plugin.json exists

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-31 11:54:46 +02:00

237 lines
6.8 KiB
Go

package pluginctl
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"text/template"
"github.com/mattermost/mattermost/server/public/model"
)
const (
// File permissions for generated directories and files.
manifestDirPerm = 0o750
manifestFilePerm = 0o600
// Name of the plugin manifest file.
PluginManifestName = "plugin.json"
)
const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually.
package main
import (
"encoding/json"
"strings"
"github.com/mattermost/mattermost/server/public/model"
)
var manifest *model.Manifest
const manifestStr = ` + "`%s`" + `
func init() {
_ = json.NewDecoder(strings.NewReader(manifestStr)).Decode(&manifest)
}
`
const pluginIDJSFileTemplate = `// This file is automatically generated. Do not modify it manually.
const manifest = JSON.parse(` + "`%s`" + `);
export default manifest;
`
// PluginCtlConfig represents the configuration for pluginctl stored in the manifest props.
type PluginCtlConfig struct {
Version string `json:"version,omitempty"`
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(".")
}
// LoadPluginManifestFromPath loads and parses the plugin.json file from the specified directory.
func LoadPluginManifestFromPath(dir string) (*model.Manifest, error) {
manifestPath := filepath.Join(dir, PluginManifestName)
// Check if plugin.json exists
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
return nil, fmt.Errorf("plugin.json not found in directory %s", dir)
}
// Read the file
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("failed to read plugin.json: %w", err)
}
// Parse JSON into Manifest struct
var manifest model.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("failed to parse plugin.json: %w", err)
}
return &manifest, nil
}
// HasServerCode checks if the plugin contains server-side code.
func HasServerCode(manifest *model.Manifest) bool {
return manifest.Server != nil && len(manifest.Server.Executables) > 0
}
// HasWebappCode checks if the plugin contains webapp code.
func HasWebappCode(manifest *model.Manifest) bool {
return manifest.Webapp != nil && manifest.Webapp.BundlePath != ""
}
// applyManifest generates manifest files for server and webapp components.
func applyManifest(manifest *model.Manifest, pluginPath string) error {
manifestBytes, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("failed to marshal manifest: %w", err)
}
manifestStr := string(manifestBytes)
// Generate server manifest file if server exists
if HasServerCode(manifest) {
serverDir := filepath.Join(pluginPath, "server")
if err := os.MkdirAll(serverDir, manifestDirPerm); err != nil {
return fmt.Errorf("failed to create server directory: %w", err)
}
serverManifestPath := filepath.Join(serverDir, "manifest.go")
serverContent := fmt.Sprintf(pluginIDGoFileTemplate, manifestStr)
if err := os.WriteFile(serverManifestPath, []byte(serverContent), manifestFilePerm); err != nil {
return fmt.Errorf("failed to write server manifest: %w", err)
}
Logger.Info("Generated server manifest", "path", serverManifestPath)
}
// Generate webapp manifest file if webapp exists
if HasWebappCode(manifest) {
webappDir := filepath.Join(pluginPath, "webapp", "src")
if err := os.MkdirAll(webappDir, manifestDirPerm); err != nil {
return fmt.Errorf("failed to create webapp directory: %w", err)
}
webappManifestPath := filepath.Join(webappDir, "manifest.ts")
webappContent := fmt.Sprintf(pluginIDJSFileTemplate, manifestStr)
if err := os.WriteFile(webappManifestPath, []byte(webappContent), manifestFilePerm); err != nil {
return fmt.Errorf("failed to write webapp manifest: %w", err)
}
Logger.Info("Generated webapp manifest", "path", webappManifestPath)
}
return nil
}
// RunManifestCommand implements the 'manifest' command functionality with subcommands.
func RunManifestCommand(args []string, pluginPath string) error {
helpText := `Manage plugin manifest files
Usage:
pluginctl manifest <subcommand> [options]
Subcommands:
get TEMPLATE Get manifest field using template syntax (e.g., {{.id}}, {{.version}})
apply Generate manifest files for server/webapp
check Validate plugin manifest
Options:
--help, -h Show this help message
Examples:
pluginctl manifest get '{{.Id}}' # Get plugin ID
pluginctl manifest get '{{.Version}}' # Get plugin version
pluginctl manifest apply # Generate server/webapp manifest files
pluginctl manifest check # Validate manifest`
// Check for help flag
if CheckForHelpFlag(args, helpText) {
return nil
}
if len(args) == 0 {
return ShowErrorWithHelp("manifest command requires a subcommand", helpText)
}
// 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 "get":
if len(args) < 2 {
return ShowErrorWithHelp("get subcommand requires a template expression", helpText)
}
templateStr := args[1]
// Parse and execute template with manifest as context
tmpl, err := template.New("manifest").Parse(templateStr)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, *manifest); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
fmt.Print(buf.String())
case "apply":
if err := applyManifest(manifest, absPath); err != nil {
return fmt.Errorf("failed to apply manifest: %w", err)
}
case "check":
if err := manifest.IsValid(); err != nil {
Logger.Error("Plugin manifest validation failed", "error", err)
return err
}
Logger.Info("Plugin manifest is valid")
default:
return ShowErrorWithHelp(fmt.Sprintf("unknown subcommand: %s", subcommand), helpText)
}
return nil
}
// WritePluginManifest saves the manifest to plugin.json in the specified path.
func WritePluginManifest(manifest *model.Manifest, path string) error {
manifestPath := filepath.Join(path, PluginManifestName)
// Marshal manifest to JSON with proper indentation
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal manifest: %w", err)
}
// Write to file
const fileMode = 0600
if err := os.WriteFile(manifestPath, data, fileMode); err != nil {
return fmt.Errorf("failed to write plugin.json: %w", err)
}
return nil
}