Repository: helm/helm
Pull request #13592 (2025-01-02 12:43:58)

feat: Add multi-platform plugin hook support [v3 backport]
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Make sure to read the Contributing Guide before submitting your PR: https://github.com/helm/helm/blob/main/CONTRIBUTING.md
2. If this PR closes another issue, add 'closes #<issue number>' somewhere in the PR summary. GitHub will automatically close that issue when this PR gets merged. Alternatively, adding 'refs #<issue number>' will not close the issue, but help provide the reviewer more context.-->

**What this PR does / why we need it**:
This PR adds the changes from #12962 to the Helm v3 branch.

**Special notes for your reviewer**:
It looks like the docs for this functionality have been accidentally released already (https://github.com/helm/helm-www/pull/1627).

CC @sabre1041 @gjenkins8 @yxxhero 

**If applicable**:
- [ ] this PR contains user facing changes (the `docs needed` label should be applied if so)
- [ ] this PR contains unit tests
- [ ] this PR has been tested for backwards compatibility



Patch:
diff --git a/cmd/helm/plugin.go b/cmd/helm/plugin.go
index 8e1044f54..27ed70727 100644
--- a/cmd/helm/plugin.go
+++ b/cmd/helm/plugin.go
@@ -47,19 +47,27 @@ func newPluginCmd(out io.Writer) *cobra.Command {
 
 // runHook will execute a plugin hook.
 func runHook(p *plugin.Plugin, event string) error {
-	hook := p.Metadata.Hooks[event]
-	if hook == "" {
+	plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
+
+	cmds := p.Metadata.PlatformHooks[event]
+	expandArgs := true
+	if len(cmds) == 0 && len(p.Metadata.Hooks) > 0 {
+		cmd := p.Metadata.Hooks[event]
+		if len(cmd) > 0 {
+			cmds = []plugin.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
+			expandArgs = false
+		}
+	}
+
+	main, argv, err := plugin.PrepareCommands(cmds, expandArgs, []string{})
+	if err != nil {
 		return nil
 	}
 
-	prog := exec.Command("sh", "-c", hook)
-	// TODO make this work on windows
-	// I think its ... ¯\_(ツ)_/¯
-	// prog := exec.Command("cmd", "/C", p.Metadata.Hooks.Install())
+	prog := exec.Command(main, argv...)
 
 	debug("running %s hook: %s", event, prog)
 
-	plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
 	prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
 	if err := prog.Run(); err != nil {
 		if eerr, ok := err.(*exec.ExitError); ok {
diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go
index e3481515f..34d3163a4 100644
--- a/pkg/plugin/hooks.go
+++ b/pkg/plugin/hooks.go
@@ -25,5 +25,8 @@ const (
 	Update = "update"
 )
 
+// PlatformHooks is a map of events to a command for a particular operating system and architecture.
+type PlatformHooks map[string][]PlatformCommand
+
 // Hooks is a map of events to commands.
 type Hooks map[string]string
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
index 5bb743481..8eac1f74d 100644
--- a/pkg/plugin/plugin.go
+++ b/pkg/plugin/plugin.go
@@ -44,9 +44,10 @@ type Downloaders struct {
 
 // PlatformCommand represents a command for a particular operating system and architecture
 type PlatformCommand struct {
-	OperatingSystem string `json:"os"`
-	Architecture    string `json:"arch"`
-	Command         string `json:"command"`
+	OperatingSystem string   `json:"os"`
+	Architecture    string   `json:"arch"`
+	Command         string   `json:"command"`
+	Args            []string `json:"args"`
 }
 
 // Metadata describes a plugin.
@@ -65,23 +66,35 @@ type Metadata struct {
 	// Description is a long description shown in places like `helm help`
 	Description string `json:"description"`
 
-	// Command is the command, as a single string.
+	// PlatformCommand is the plugin command, with a platform selector and support for args.
 	//
-	// The command will be passed through environment expansion, so env vars can
+	// The command and args will be passed through environment expansion, so env vars can
 	// be present in this command. Unless IgnoreFlags is set, this will
 	// also merge the flags passed from Helm.
 	//
-	// Note that command is not executed in a shell. To do so, we suggest
+	// Note that the command is not executed in a shell. To do so, we suggest
 	// pointing the command to a shell script.
 	//
-	// The following rules will apply to processing commands:
-	// - If platformCommand is present, it will be searched first
+	// The following rules will apply to processing platform commands:
+	// - If PlatformCommand is present, it will be used
 	// - If both OS and Arch match the current platform, search will stop and the command will be executed
-	// - If OS matches and there is no more specific match, the command will be executed
+	// - If OS matches and Arch is empty, the command will be executed
 	// - If no OS/Arch match is found, the default command will be executed
-	// - If no command is present and no matches are found in platformCommand, Helm will exit with an error
+	// - If no matches are found in platformCommand, Helm will exit with an error
 	PlatformCommand []PlatformCommand `json:"platformCommand"`
-	Command         string            `json:"command"`
+
+	// Command is the plugin command, as a single string.
+	// Providing a command will result in an error if PlatformCommand is also set.
+	//
+	// The command will be passed through environment expansion, so env vars can
+	// be present in this command. Unless IgnoreFlags is set, this will
+	// also merge the flags passed from Helm.
+	//
+	// Note that command is not executed in a shell. To do so, we suggest
+	// pointing the command to a shell script.
+	//
+	// DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
+	Command string `json:"command"`
 
 	// IgnoreFlags ignores any flags passed in from Helm
 	//
@@ -90,7 +103,31 @@ type Metadata struct {
 	// the `--debug` flag will be discarded.
 	IgnoreFlags bool `json:"ignoreFlags"`
 
-	// Hooks are commands that will run on events.
+	// PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
+	//
+	// The command and args will be passed through environment expansion, so env vars can
+	// be present in the command.
+	//
+	// Note that the command is not executed in a shell. To do so, we suggest
+	// pointing the command to a shell script.
+	//
+	// The following rules will apply to processing platform hooks:
+	// - If PlatformHooks is present, it will be used
+	// - If both OS and Arch match the current platform, search will stop and the command will be executed
+	// - If OS matches and Arch is empty, the command will be executed
+	// - If no OS/Arch match is found, the default command will be executed
+	// - If no matches are found in platformHooks, Helm will skip the event
+	PlatformHooks PlatformHooks `json:"platformHooks"`
+
+	// Hooks are commands that will run on plugin events, as a single string.
+	// Providing a hooks will result in an error if PlatformHooks is also set.
+	//
+	// The command will be passed through environment expansion, so env vars can
+	// be present in this command.
+	//
+	// Note that the command is executed in the sh shell.
+	//
+	// DEPRECATED: Use PlatformHooks instead. Remove in Helm 4.
 	Hooks Hooks
 
 	// Downloaders field is used if the plugin supply downloader mechanism
@@ -112,62 +149,106 @@ type Plugin struct {
 	Dir string
 }
 
-// The following rules will apply to processing the Plugin.PlatformCommand.Command:
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, return nil
-func getPlatformCommand(cmds []PlatformCommand) []string {
-	var command []string
+// Returns command and args strings based on the following rules in priority order:
+// - From the PlatformCommand where OS and Arch match the current platform
+// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
+// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
+// - From the PlatformCommand where OS and Arch are both empty/unspecified
+// - Return nil, nil
+func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
+	var command, args []string
+	found := false
+	foundOs := false
+
 	eq := strings.EqualFold
 	for _, c := range cmds {
-		if eq(c.OperatingSystem, runtime.GOOS) {
-			command = strings.Split(c.Command, " ")
-		}
 		if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
-			return strings.Split(c.Command, " ")
+			// Return early for an exact match
+			return strings.Split(c.Command, " "), c.Args
+		}
+
+		if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
+			// Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
+			continue
+		}
+
+		if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
+			// First OS match with empty arch, can only be overridden by a direct match
+			command = strings.Split(c.Command, " ")
+			args = c.Args
+			found = true
+			foundOs = true
+		} else if !found {
+			// First empty match, can be overridden by a direct match or an OS match
+			command = strings.Split(c.Command, " ")
+			args = c.Args
+			found = true
 		}
 	}
-	return command
+
+	return command, args
 }
 
-// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
-// - If platformCommand is present, it will be searched first
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, the default command will be prepared for execution
-// - If no command is present and no matches are found in platformCommand, will exit with an error
+// PrepareCommands takes a []Plugin.PlatformCommand
+// and prepares the command and arguments for execution.
 //
 // It merges extraArgs into any arguments supplied in the plugin. It
-// returns the name of the command and an args array.
+// returns the main command and an args array.
 //
 // The result is suitable to pass to exec.Command.
-func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
-	var parts []string
-	platCmdLen := len(p.Metadata.PlatformCommand)
-	if platCmdLen > 0 {
-		parts = getPlatformCommand(p.Metadata.PlatformCommand)
-	}
-	if platCmdLen == 0 || parts == nil {
-		parts = strings.Split(p.Metadata.Command, " ")
-	}
-	if len(parts) == 0 || parts[0] == "" {
+func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string) (string, []string, error) {
+	cmdParts, args := getPlatformCommand(cmds)
+	if len(cmdParts) == 0 || cmdParts[0] == "" {
 		return "", nil, fmt.Errorf("no plugin command is applicable")
 	}
 
-	main := os.ExpandEnv(parts[0])
+	main := os.ExpandEnv(cmdParts[0])
 	baseArgs := []string{}
-	if len(parts) > 1 {
-		for _, cmdpart := range parts[1:] {
-			cmdexp := os.ExpandEnv(cmdpart)
-			baseArgs = append(baseArgs, cmdexp)
+	if len(cmdParts) > 1 {
+		for _, cmdPart := range cmdParts[1:] {
+			if expandArgs {
+				baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
+			} else {
+				baseArgs = append(baseArgs, cmdPart)
+			}
 		}
 	}
-	if !p.Metadata.IgnoreFlags {
+
+	for _, arg := range args {
+		if expandArgs {
+			baseArgs = append(baseArgs, os.ExpandEnv(arg))
+		} else {
+			baseArgs = append(baseArgs, arg)
+		}
+	}
+
+	if len(extraArgs) > 0 {
 		baseArgs = append(baseArgs, extraArgs...)
 	}
+
 	return main, baseArgs, nil
 }
 
+// PrepareCommand gets the correct command and arguments for a plugin.
+//
+// It merges extraArgs into any arguments supplied in the plugin. It returns the name of the command and an args array.
+//
+// The result is suitable to pass to exec.Command.
+func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
+	var extraArgsIn []string
+
+	if !p.Metadata.IgnoreFlags {
+		extraArgsIn = extraArgs
+	}
+
+	cmds := p.Metadata.PlatformCommand
+	if len(cmds) == 0 && len(p.Metadata.Command) > 0 {
+		cmds = []PlatformCommand{{Command: p.Metadata.Command}}
+	}
+
+	return PrepareCommands(cmds, true, extraArgsIn)
+}
+
 // validPluginName is a regular expression that validates plugin names.
 //
 // Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-.
@@ -184,6 +265,14 @@ func validatePluginData(plug *Plugin, filepath string) error {
 	}
 	plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
 
+	if len(plug.Metadata.PlatformCommand) > 0 && len(plug.Metadata.Command) > 0 {
+		return fmt.Errorf("both platformCommand and command are set in %q", filepath)
+	}
+
+	if len(plug.Metadata.PlatformHooks) > 0 && len(plug.Metadata.Hooks) > 0 {
+		return fmt.Errorf("both platformHooks and hooks are set in %q", filepath)
+	}
+
 	// We could also validate SemVer, executable, and other fields should we so choose.
 	return nil
 }


Test patch:
diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go
index b81797390..770d701fa 100644
--- a/pkg/plugin/plugin_test.go
+++ b/pkg/plugin/plugin_test.go
@@ -26,163 +26,256 @@ import (
 	"helm.sh/helm/v3/pkg/cli"
 )
 
-func checkCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) {
-	cmd, args, err := p.PrepareCommand(extraArgs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if cmd != "echo" {
-		t.Fatalf("Expected echo, got %q", cmd)
-	}
-
-	if l := len(args); l != 5 {
-		t.Fatalf("expected 5 args, got %d", l)
-	}
+func TestPrepareCommand(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
 
-	expect := []string{"-n", osStrCmp, "--debug", "--foo", "bar"}
-	for i := 0; i < len(args); i++ {
-		if expect[i] != args[i] {
-			t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
-		}
+	p := &Plugin{
+		Dir: "/tmp", // Unused
+		Metadata: &Metadata{
+			Name:    "test",
+			Command: "echo \"error\"",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+			},
+		},
 	}
 
-	// Test with IgnoreFlags. This should omit --debug, --foo, bar
-	p.Metadata.IgnoreFlags = true
-	cmd, args, err = p.PrepareCommand(extraArgs)
+	cmd, args, err := p.PrepareCommand([]string{})
 	if err != nil {
 		t.Fatal(err)
 	}
-	if cmd != "echo" {
-		t.Fatalf("Expected echo, got %q", cmd)
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
 	}
-	if l := len(args); l != 2 {
-		t.Fatalf("expected 2 args, got %d", l)
-	}
-	expect = []string{"-n", osStrCmp}
-	for i := 0; i < len(args); i++ {
-		if expect[i] != args[i] {
-			t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
-		}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
 	}
 }
 
-func TestPrepareCommand(t *testing.T) {
+func TestPrepareCommandExtraArgs(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
 	p := &Plugin{
 		Dir: "/tmp", // Unused
 		Metadata: &Metadata{
 			Name:    "test",
-			Command: "echo -n foo",
+			Command: "echo \"error\"",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+			},
 		},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	checkCommand(p, argv, "foo", t)
+	expectedArgs := append(cmdArgs, extraArgs...)
+
+	cmd, args, err := p.PrepareCommand(extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
 }
 
-func TestPlatformPrepareCommand(t *testing.T) {
+func TestPrepareCommandExtraArgsIgnored(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
 	p := &Plugin{
 		Dir: "/tmp", // Unused
 		Metadata: &Metadata{
 			Name:    "test",
-			Command: "echo -n os-arch",
+			Command: "echo \"error\"",
 			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
-				{OperatingSystem: "linux", Architecture: "amd64", Command: "echo -n linux-amd64"},
-				{OperatingSystem: "linux", Architecture: "arm64", Command: "echo -n linux-arm64"},
-				{OperatingSystem: "linux", Architecture: "ppc64le", Command: "echo -n linux-ppc64le"},
-				{OperatingSystem: "linux", Architecture: "s390x", Command: "echo -n linux-s390x"},
-				{OperatingSystem: "linux", Architecture: "riscv64", Command: "echo -n linux-riscv64"},
-				{OperatingSystem: "linux", Architecture: "loong64", Command: "echo -n linux-loong64"},
-				{OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
+				{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+				{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+				{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 			},
+			IgnoreFlags: true,
 		},
 	}
-	var osStrCmp string
-	os := runtime.GOOS
-	arch := runtime.GOARCH
-	if os == "linux" && arch == "386" {
-		osStrCmp = "linux-386"
-	} else if os == "linux" && arch == "amd64" {
-		osStrCmp = "linux-amd64"
-	} else if os == "linux" && arch == "arm64" {
-		osStrCmp = "linux-arm64"
-	} else if os == "linux" && arch == "ppc64le" {
-		osStrCmp = "linux-ppc64le"
-	} else if os == "linux" && arch == "s390x" {
-		osStrCmp = "linux-s390x"
-	} else if os == "linux" && arch == "riscv64" {
-		osStrCmp = "linux-riscv64"
-	} else if os == "linux" && arch == "loong64" {
-		osStrCmp = "linux-loong64"
-	} else if os == "windows" && arch == "amd64" {
-		osStrCmp = "win-64"
-	} else {
-		osStrCmp = "os-arch"
-	}
-
-	argv := []string{"--debug", "--foo", "bar"}
-	checkCommand(p, argv, osStrCmp, t)
+
+	cmd, args, err := p.PrepareCommand(extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
 }
 
-func TestPartialPlatformPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name:    "test",
-			Command: "echo -n os-arch",
-			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
-				{OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
-			},
-		},
+func TestPrepareCommands(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+	}
+
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
 	}
-	var osStrCmp string
-	os := runtime.GOOS
-	arch := runtime.GOARCH
-	if os == "linux" {
-		osStrCmp = "linux-386"
-	} else if os == "windows" && arch == "amd64" {
-		osStrCmp = "win-64"
-	} else {
-		osStrCmp = "os-arch"
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
+func TestPrepareCommandsExtraArgs(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+	extraArgs := []string{"--debug", "--foo", "bar"}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
 
-	argv := []string{"--debug", "--foo", "bar"}
-	checkCommand(p, argv, osStrCmp, t)
+	expectedArgs := append(cmdArgs, extraArgs...)
+
+	cmd, args, err := PrepareCommands(cmds, true, extraArgs)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
 }
 
-func TestNoPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name: "test",
-		},
+func TestPrepareCommandsNoArch(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	_, _, err := p.PrepareCommand(argv)
-	if err == nil {
-		t.Fatalf("Expected error to be returned")
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
 	}
 }
 
-func TestNoMatchPrepareCommand(t *testing.T) {
-	p := &Plugin{
-		Dir: "/tmp", // Unused
-		Metadata: &Metadata{
-			Name: "test",
-			PlatformCommand: []PlatformCommand{
-				{OperatingSystem: "no-os", Architecture: "amd64", Command: "echo -n linux-386"},
-			},
-		},
+func TestPrepareCommandsNoOsNoArch(t *testing.T) {
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"test\""}
+
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+		{OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
 	}
-	argv := []string{"--debug", "--foo", "bar"}
 
-	if _, _, err := p.PrepareCommand(argv); err == nil {
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
+func TestPrepareCommandsNoMatch(t *testing.T) {
+	cmds := []PlatformCommand{
+		{OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+		{OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+	}
+
+	if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
 		t.Fatalf("Expected error to be returned")
 	}
 }
 
+func TestPrepareCommandsNoCommands(t *testing.T) {
+	cmds := []PlatformCommand{}
+
+	if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
+		t.Fatalf("Expected error to be returned")
+	}
+}
+
+func TestPrepareCommandsExpand(t *testing.T) {
+	t.Setenv("TEST", "test")
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"${TEST}\""}
+	cmds := []PlatformCommand{
+		{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+	}
+
+	expectedArgs := []string{"-c", "echo \"test\""}
+
+	cmd, args, err := PrepareCommands(cmds, true, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, expectedArgs) {
+		t.Fatalf("Expected %v, got %v", expectedArgs, args)
+	}
+}
+
+func TestPrepareCommandsNoExpand(t *testing.T) {
+	t.Setenv("TEST", "test")
+	cmdMain := "sh"
+	cmdArgs := []string{"-c", "echo \"${TEST}\""}
+	cmds := []PlatformCommand{
+		{OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+	}
+
+	cmd, args, err := PrepareCommands(cmds, false, []string{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cmd != cmdMain {
+		t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+	}
+	if !reflect.DeepEqual(args, cmdArgs) {
+		t.Fatalf("Expected %v, got %v", cmdArgs, args)
+	}
+}
+
 func TestLoadDir(t *testing.T) {
 	dirname := "testdata/plugdir/good/hello"
 	plug, err := LoadDir(dirname)
@@ -199,10 +292,16 @@ func TestLoadDir(t *testing.T) {
 		Version:     "0.1.0",
 		Usage:       "usage",
 		Description: "description",
-		Command:     "$HELM_PLUGIN_DIR/hello.sh",
+		PlatformCommand: []PlatformCommand{
+			{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
+			{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
+		},
 		IgnoreFlags: true,
-		Hooks: map[string]string{
-			Install: "echo installing...",
+		PlatformHooks: map[string][]PlatformCommand{
+			Install: {
+				{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+				{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+			},
 		},
 	}
 
@@ -249,7 +348,6 @@ func TestDownloader(t *testing.T) {
 }
 
 func TestLoadAll(t *testing.T) {
-
 	// Verify that empty dir loads:
 	if plugs, err := LoadAll("testdata"); err != nil {
 		t.Fatalf("error loading dir with no plugins: %s", err)
@@ -361,6 +459,30 @@ func TestValidatePluginData(t *testing.T) {
 		Dir: "no-such-dir",
 	}
 
+	// A mock plugin with no commands
+	mockNoCommand := mockPlugin("foo")
+	mockNoCommand.Metadata.PlatformCommand = []PlatformCommand{}
+	mockNoCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
+
+	// A mock plugin with legacy commands
+	mockLegacyCommand := mockPlugin("foo")
+	mockLegacyCommand.Metadata.PlatformCommand = []PlatformCommand{}
+	mockLegacyCommand.Metadata.Command = "echo \"mock plugin\""
+	mockLegacyCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{}
+	mockLegacyCommand.Metadata.Hooks = map[string]string{
+		Install: "echo installing...",
+	}
+
+	// A mock plugin with a command also set
+	mockWithCommand := mockPlugin("foo")
+	mockWithCommand.Metadata.Command = "echo \"mock plugin\""
+
+	// A mock plugin with a hooks also set
+	mockWithHooks := mockPlugin("foo")
+	mockWithHooks.Metadata.Hooks = map[string]string{
+		Install: "echo installing...",
+	}
+
 	for i, item := range []struct {
 		pass bool
 		plug *Plugin
@@ -372,6 +494,10 @@ func TestValidatePluginData(t *testing.T) {
 		{false, mockPlugin("foo -bar ")}, // Test trailing chars
 		{false, mockPlugin("foo\nbar")},  // Test newline
 		{false, mockMissingMeta},         // Test if the metadata section missing
+		{true, mockNoCommand},            // Test no command metadata works
+		{true, mockLegacyCommand},        // Test legacy command metadata works
+		{false, mockWithCommand},         // Test platformCommand and command both set fails
+		{false, mockWithHooks},           // Test platformHooks and hooks both set fails
 	} {
 		err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i))
 		if item.pass && err != nil {
@@ -403,7 +529,16 @@ func mockPlugin(name string) *Plugin {
 			Version:     "v0.1.2",
 			Usage:       "Mock plugin",
 			Description: "Mock plugin for testing",
-			Command:     "echo mock plugin",
+			PlatformCommand: []PlatformCommand{
+				{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
+				{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
+			},
+			PlatformHooks: map[string][]PlatformCommand{
+				Install: {
+					{OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+					{OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+				},
+			},
 		},
 		Dir: "no-such-dir",
 	}
diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.ps1 b/pkg/plugin/testdata/plugdir/good/hello/hello.ps1
new file mode 100644
index 000000000..bee61f27d
--- /dev/null
+++ b/pkg/plugin/testdata/plugdir/good/hello/hello.ps1
@@ -0,0 +1,3 @@
+#!/usr/bin/env pwsh
+
+Write-Host "Hello, world!"
diff --git a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
index b857b55ee..71dc88259 100644
--- a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
+++ b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
@@ -3,7 +3,23 @@ version: "0.1.0"
 usage: "usage"
 description: |-
   description
-command: "$HELM_PLUGIN_DIR/hello.sh"
+platformCommand:
+  - os: linux
+    arch:
+    command: "sh"
+    args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
+  - os: windows
+    arch:
+    command: "pwsh"
+    args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
 ignoreFlags: true
-hooks:
-  install: "echo installing..."
+platformHooks:
+  install:
+    - os: linux
+      arch: ""
+      command: "sh"
+      args: ["-c", 'echo "installing..."']
+    - os: windows
+      arch: ""
+      command: "pwsh"
+      args: ["-c", 'echo "installing..."']