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>
This commit is contained in:
parent
9d3c6b357f
commit
353cc9efc7
5 changed files with 286 additions and 51 deletions
|
@ -54,7 +54,7 @@ checksum:
|
|||
|
||||
# Snapshot configuration
|
||||
snapshot:
|
||||
version_template: "{{ incpatch .Version }}-next"
|
||||
version_template: "{{ incpatch .Version }}-{{ .Commit }}"
|
||||
|
||||
# Changelog configuration
|
||||
changelog:
|
||||
|
|
|
@ -45,6 +45,14 @@ func main() {
|
|||
// Determine plugin path from flag, environment variable, or current directory
|
||||
effectivePluginPath := pluginctl.GetEffectivePluginPath(pluginPath)
|
||||
|
||||
// Validate and update version before running command (except for version command)
|
||||
if command != pluginctl.VersionCommand {
|
||||
if err := pluginctl.ValidateAndUpdateVersion(effectivePluginPath); err != nil {
|
||||
pluginctl.Logger.Error("Version validation failed", "error", err)
|
||||
os.Exit(ExitError)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runCommand(command, commandArgs, effectivePluginPath); err != nil {
|
||||
pluginctl.Logger.Error("Command failed", "error", err)
|
||||
os.Exit(ExitError)
|
||||
|
|
67
manifest.go
67
manifest.go
|
@ -15,6 +15,9 @@ 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.
|
||||
|
@ -44,6 +47,51 @@ 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)
|
||||
|
@ -168,3 +216,22 @@ Examples:
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -4,56 +4,18 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
const PluginManifestName = "plugin.json"
|
||||
const (
|
||||
HelpFlagLong = "--help"
|
||||
HelpFlagShort = "-h"
|
||||
|
||||
// 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(".")
|
||||
}
|
||||
|
||||
// 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 != ""
|
||||
}
|
||||
VersionCommand = "version"
|
||||
DevVersion = "dev"
|
||||
UnknownVersion = "unknown"
|
||||
)
|
||||
|
||||
// IsValidPluginDirectory checks if the current directory contains a valid plugin.
|
||||
func IsValidPluginDirectory() error {
|
||||
|
@ -115,11 +77,6 @@ func ParsePluginCtlConfig(manifest *model.Manifest) (*PluginCtlConfig, error) {
|
|||
return config, nil
|
||||
}
|
||||
|
||||
const (
|
||||
HelpFlagLong = "--help"
|
||||
HelpFlagShort = "-h"
|
||||
)
|
||||
|
||||
// CheckForHelpFlag checks if --help is in the arguments and shows help if found.
|
||||
// Returns true if help was shown, false otherwise.
|
||||
func CheckForHelpFlag(args []string, helpText string) bool {
|
||||
|
@ -141,3 +98,72 @@ func ShowErrorWithHelp(errorMsg, helpText string) error {
|
|||
|
||||
return fmt.Errorf("%s", errorMsg)
|
||||
}
|
||||
|
||||
// SavePluginCtlConfig saves the pluginctl configuration to the manifest props.
|
||||
func SavePluginCtlConfig(manifest *model.Manifest, config *PluginCtlConfig) {
|
||||
if manifest.Props == nil {
|
||||
manifest.Props = make(map[string]interface{})
|
||||
}
|
||||
|
||||
manifest.Props["pluginctl"] = config
|
||||
}
|
||||
|
||||
// ValidateAndUpdateVersion checks the plugin version and updates it if necessary.
|
||||
func ValidateAndUpdateVersion(pluginPath string) error {
|
||||
// Load the manifest
|
||||
manifest, err := LoadPluginManifestFromPath(pluginPath)
|
||||
if err != nil {
|
||||
// If there's no plugin.json, skip version validation
|
||||
Logger.Debug("No plugin.json found, skipping version validation", "error", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get current pluginctl version
|
||||
currentVersion := GetVersion()
|
||||
|
||||
// Parse existing pluginctl config
|
||||
config, err := ParsePluginCtlConfig(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse pluginctl config: %w", err)
|
||||
}
|
||||
|
||||
// Check if stored version is higher than current version
|
||||
if config.Version != "" && isVersionHigher(config.Version, currentVersion) {
|
||||
return fmt.Errorf("plugin was last modified with pluginctl version %s, "+
|
||||
"which is higher than current version %s. Please upgrade pluginctl",
|
||||
config.Version, currentVersion)
|
||||
}
|
||||
|
||||
// Update version if different
|
||||
if config.Version != currentVersion {
|
||||
config.Version = currentVersion
|
||||
SavePluginCtlConfig(manifest, config)
|
||||
|
||||
// Save the updated manifest
|
||||
if err := WritePluginManifest(manifest, pluginPath); err != nil {
|
||||
return fmt.Errorf("failed to update version in manifest: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isVersionHigher compares two version strings and returns true if the stored version is higher
|
||||
// than the current version.
|
||||
func isVersionHigher(storedVersion, currentVersion string) bool {
|
||||
// Handle special cases
|
||||
if storedVersion == currentVersion {
|
||||
return false
|
||||
}
|
||||
if currentVersion == DevVersion || currentVersion == UnknownVersion {
|
||||
return false
|
||||
}
|
||||
if storedVersion == DevVersion || storedVersion == UnknownVersion {
|
||||
return false
|
||||
}
|
||||
|
||||
// Simple string comparison for versions
|
||||
// This is a basic implementation - in production you might want semantic versioning
|
||||
return storedVersion > currentVersion
|
||||
}
|
|
@ -606,3 +606,137 @@ func TestIsPathIgnored(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsVersionHigher(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
storedVersion string
|
||||
currentVersion string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "same versions",
|
||||
storedVersion: "v1.0.0",
|
||||
currentVersion: "v1.0.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "stored version higher",
|
||||
storedVersion: "v1.1.0",
|
||||
currentVersion: "v1.0.0",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "current version higher",
|
||||
storedVersion: "v1.0.0",
|
||||
currentVersion: "v1.1.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "current version is dev",
|
||||
storedVersion: "v1.0.0",
|
||||
currentVersion: "dev",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "current version is unknown",
|
||||
storedVersion: "v1.0.0",
|
||||
currentVersion: "unknown",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "stored version is dev",
|
||||
storedVersion: "dev",
|
||||
currentVersion: "v1.0.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "stored version is unknown",
|
||||
storedVersion: "unknown",
|
||||
currentVersion: "v1.0.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "both versions are dev",
|
||||
storedVersion: "dev",
|
||||
currentVersion: "dev",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "both versions are unknown",
|
||||
storedVersion: "unknown",
|
||||
currentVersion: "unknown",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "alphabetic comparison - stored higher",
|
||||
storedVersion: "v2.0.0",
|
||||
currentVersion: "v1.9.9",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "alphabetic comparison - current higher",
|
||||
storedVersion: "v1.9.9",
|
||||
currentVersion: "v2.0.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "snapshot version vs release - stored snapshot higher",
|
||||
storedVersion: "v1.1.0-abc123",
|
||||
currentVersion: "v1.0.0",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "snapshot version vs release - current release higher",
|
||||
storedVersion: "v1.0.0-abc123",
|
||||
currentVersion: "v1.1.0",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "both snapshot versions - stored higher",
|
||||
storedVersion: "v1.1.0-abc123",
|
||||
currentVersion: "v1.0.0-def456",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "both snapshot versions - current higher",
|
||||
storedVersion: "v1.0.0-abc123",
|
||||
currentVersion: "v1.1.0-def456",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "same base version different snapshots",
|
||||
storedVersion: "v1.0.0-def456",
|
||||
currentVersion: "v1.0.0-abc123",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "snapshot vs commit hash - stored snapshot higher",
|
||||
storedVersion: "v0.1.0-abc123",
|
||||
currentVersion: "def456",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "commit hash vs snapshot - stored hash lower",
|
||||
storedVersion: "abc123",
|
||||
currentVersion: "v0.1.0-def456",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "both commit hashes",
|
||||
storedVersion: "def456",
|
||||
currentVersion: "abc123",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isVersionHigher(tt.storedVersion, tt.currentVersion)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isVersionHigher(%q, %q) = %v, want %v",
|
||||
tt.storedVersion, tt.currentVersion, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue