diff --git a/cli-plugins/hooks/hook_types.go b/cli-plugins/hooks/hook_types.go new file mode 100644 index 000000000000..02cd93c8f1b7 --- /dev/null +++ b/cli-plugins/hooks/hook_types.go @@ -0,0 +1,85 @@ +// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16: +//go:build go1.24 + +// Package hooks defines the contract between the Docker CLI and CLI plugin hook +// implementations. +// +// # Audience +// +// This package is intended to be imported by CLI plugin implementations that +// implement a "hooks" subcommand, and by the Docker CLI when invoking those +// hooks. +// +// # Contract and wire format +// +// Hook inputs (see [Request]) are serialized as JSON and passed to the plugin hook +// subcommand (currently as a command-line argument). Hook outputs are emitted by +// the plugin as JSON (see [Response]). +// +// # Stability +// +// The types that represent the hook contract ([Request], [Response] and related +// constants) are considered part of Docker CLI's public Go API. +// Fields and values may be extended in a backwards-compatible way (for example, +// adding new fields), but existing fields and their meaning should remain stable. +// Plugins should ignore unknown fields and unknown hook types to remain +// forwards-compatible. +package hooks + +// ResponseType is the type of response from the plugin. +type ResponseType int + +const ( + NextSteps ResponseType = 0 +) + +// Request is the type representing the information +// that plugins declaring support for hooks get passed when +// being invoked following a CLI command execution. +type Request struct { + // RootCmd is a string representing the matching hook configuration + // which is currently being invoked. If a hook for "docker context" + // is configured and the user executes "docker context ls", the plugin + // is invoked with "context". + RootCmd string `json:"RootCmd,omitzero"` + + // Flags contains flags that were set on the command for which the + // hook was invoked. It uses flag names as key, with leading hyphens + // removed ("--flag" and "-flag" are included as "flag" and "f"). + // + // Flag values are not included and are set to an empty string, + // except for boolean flags known to the CLI itself, for which + // the value is either "true", or "false". + // + // Plugins can use this information to adjust their [Response] + // based on whether the command triggering the hook was invoked + // with. + Flags map[string]string `json:"Flags,omitzero"` + + // CommandError is a string containing the error output (if any) + // of the command for which the hook was invoked. + CommandError string `json:"CommandError,omitzero"` +} + +// Response represents a plugin hook response. Plugins +// declaring support for CLI hooks need to print a JSON +// representation of this type when their hook subcommand +// is invoked. +type Response struct { + Type ResponseType `json:"Type"` + Template string `json:"Template,omitzero"` +} + +// HookType is the type of response from the plugin. +// +// Deprecated: use [ResponseType] instead. +// +//go:fix inline +type HookType = ResponseType + +// HookMessage represents a plugin hook response. +// +// Deprecated: use [Response] instead. +// +//go:fix inline +type HookMessage = Response diff --git a/cli-plugins/hooks/hook_utils.go b/cli-plugins/hooks/hook_utils.go new file mode 100644 index 000000000000..c6babbe469a9 --- /dev/null +++ b/cli-plugins/hooks/hook_utils.go @@ -0,0 +1,75 @@ +package hooks + +import ( + "fmt" +) + +const ( + hookTemplateCommandName = `{{command}}` + hookTemplateFlagValue = `{{flagValue %q}}` + hookTemplateArg = `{{argValue %d}}` +) + +// TemplateReplaceSubcommandName returns a hook template string +// that will be replaced by the CLI subcommand being executed +// +// Example: +// +// Response{ +// Type: NextSteps, +// Template: "you ran the subcommand: " + TemplateReplaceSubcommandName(), +// } +// +// When being executed after the command: +// +// docker run --name "my-container" alpine +// +// It results in the message: +// +// you ran the subcommand: run +func TemplateReplaceSubcommandName() string { + return hookTemplateCommandName +} + +// TemplateReplaceFlagValue returns a hook template string that will be +// replaced with the flags value when printed by the CLI. +// +// Example: +// +// Response{ +// Type: NextSteps, +// Template: "you ran a container named: " + TemplateReplaceFlagValue("name"), +// } +// +// when executed after the command: +// +// docker run --name "my-container" alpine +// +// it results in the message: +// +// you ran a container named: my-container +func TemplateReplaceFlagValue(flag string) string { + return fmt.Sprintf(hookTemplateFlagValue, flag) +} + +// TemplateReplaceArg takes an index i and returns a hook +// template string that the CLI will replace the template with +// the ith argument after processing the passed flags. +// +// Example: +// +// Response{ +// Type: NextSteps, +// Template: "run this image with `docker run " + TemplateReplaceArg(0) + "`", +// } +// +// when being executed after the command: +// +// docker pull alpine +// +// It results in the message: +// +// Run this image with `docker run alpine` +func TemplateReplaceArg(i int) string { + return fmt.Sprintf(hookTemplateArg, i) +} diff --git a/cli-plugins/hooks/hooks_utils_test.go b/cli-plugins/hooks/hooks_utils_test.go new file mode 100644 index 000000000000..877909109cc7 --- /dev/null +++ b/cli-plugins/hooks/hooks_utils_test.go @@ -0,0 +1,50 @@ +package hooks_test + +import ( + "testing" + + "github.com/docker/cli/cli-plugins/hooks" +) + +func TestTemplateHelpers(t *testing.T) { + tests := []struct { + doc string + got func() string + want string + }{ + { + doc: "subcommand name", + got: hooks.TemplateReplaceSubcommandName, + want: `{{command}}`, + }, + { + doc: "flag value", + got: func() string { + return hooks.TemplateReplaceFlagValue("name") + }, + want: `{{flagValue "name"}}`, + }, + { + doc: "arg", + got: func() string { + return hooks.TemplateReplaceArg(0) + }, + want: `{{argValue 0}}`, + }, + { + doc: "arg", + got: func() string { + return hooks.TemplateReplaceArg(3) + }, + want: `{{argValue 3}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + if got := tc.got(); got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} diff --git a/cli-plugins/hooks/printer.go b/cli-plugins/hooks/printer.go index f6d4b28ef488..c9b0bd9bcd9f 100644 --- a/cli-plugins/hooks/printer.go +++ b/cli-plugins/hooks/printer.go @@ -1,18 +1,23 @@ package hooks -import ( - "fmt" - "io" +import "io" - "github.com/morikuni/aec" +const ( + whatsNext = "\n\033[1mWhat's next:\033[0m\n" + indent = " " ) +// PrintNextSteps renders list of [NextSteps] messages and writes them +// to out. It is a no-op if messages is empty. func PrintNextSteps(out io.Writer, messages []string) { if len(messages) == 0 { return } - _, _ = fmt.Fprintln(out, aec.Bold.Apply("\nWhat's next:")) - for _, n := range messages { - _, _ = fmt.Fprintln(out, " ", n) + + _, _ = io.WriteString(out, whatsNext) + for _, msg := range messages { + _, _ = io.WriteString(out, indent) + _, _ = io.WriteString(out, msg) + _, _ = io.WriteString(out, "\n") } } diff --git a/cli-plugins/hooks/printer_test.go b/cli-plugins/hooks/printer_test.go index efe1fe598930..8b1ca9234ed8 100644 --- a/cli-plugins/hooks/printer_test.go +++ b/cli-plugins/hooks/printer_test.go @@ -1,38 +1,45 @@ -package hooks +package hooks_test import ( - "bytes" + "strings" "testing" - "github.com/morikuni/aec" + "github.com/docker/cli/cli-plugins/hooks" "gotest.tools/v3/assert" ) func TestPrintHookMessages(t *testing.T) { - testCases := []struct { + const header = "\n\x1b[1mWhat's next:\x1b[0m\n" + + tests := []struct { + doc string messages []string expectedOutput string }{ { - messages: []string{}, + doc: "no messages", + messages: nil, expectedOutput: "", }, { + doc: "single message", messages: []string{"Bork!"}, - expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + + expectedOutput: header + " Bork!\n", }, { + doc: "multiple messages", messages: []string{"Foo", "bar"}, - expectedOutput: aec.Bold.Apply("\nWhat's next:") + "\n" + + expectedOutput: header + " Foo\n" + " bar\n", }, } - - for _, tc := range testCases { - w := bytes.Buffer{} - PrintNextSteps(&w, tc.messages) - assert.Equal(t, w.String(), tc.expectedOutput) + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + var w strings.Builder + hooks.PrintNextSteps(&w, tc.messages) + assert.Equal(t, w.String(), tc.expectedOutput) + }) } } diff --git a/cli-plugins/hooks/template.go b/cli-plugins/hooks/template.go index e6bd69f38779..ba77dd3f6840 100644 --- a/cli-plugins/hooks/template.go +++ b/cli-plugins/hooks/template.go @@ -4,113 +4,84 @@ import ( "bytes" "errors" "fmt" - "strconv" "strings" "text/template" "github.com/spf13/cobra" ) -type HookType int +func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { + out := hookTemplate + if strings.Contains(hookTemplate, "{{") { + // Message may be a template. + msgContext := commandInfo{cmd: cmd} -const ( - NextSteps = iota -) + tmpl, err := template.New("").Funcs(template.FuncMap{ + "command": msgContext.command, + "flagValue": msgContext.flagValue, + "argValue": msgContext.argValue, -// HookMessage represents a plugin hook response. Plugins -// declaring support for CLI hooks need to print a json -// representation of this type when their hook subcommand -// is invoked. -type HookMessage struct { - Type HookType - Template string + // kept for backward-compatibility with old templates. + "flag": func(_ any, flagName string) (string, error) { return msgContext.flagValue(flagName) }, + "arg": func(_ any, i int) (string, error) { return msgContext.argValue(i) }, + }).Parse(hookTemplate) + if err != nil { + return nil, err + } + var b bytes.Buffer + err = tmpl.Execute(&b, msgContext) + if err != nil { + return nil, err + } + out = b.String() + } + return strings.Split(out, "\n"), nil } -// TemplateReplaceSubcommandName returns a hook template string -// that will be replaced by the CLI subcommand being executed -// -// Example: -// -// "you ran the subcommand: " + TemplateReplaceSubcommandName() -// -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran the subcommand: run` -func TemplateReplaceSubcommandName() string { - return hookTemplateCommandName -} +var ErrHookTemplateParse = errors.New("failed to parse hook template") -// TemplateReplaceFlagValue returns a hook template string -// that will be replaced by the flags value. -// -// Example: -// -// "you ran a container named: " + TemplateReplaceFlagValue("name") -// -// when being executed after the command: -// `docker run --name "my-container" alpine` -// will result in the message: -// `you ran a container named: my-container` -func TemplateReplaceFlagValue(flag string) string { - return fmt.Sprintf(hookTemplateFlagValue, flag) +// commandInfo provides info about the command for which the hook was invoked. +// It is used for templated hook-messages. +type commandInfo struct { + cmd *cobra.Command } -// TemplateReplaceArg takes an index i and returns a hook -// template string that the CLI will replace the template with -// the ith argument, after processing the passed flags. -// -// Example: +// Name returns the name of the (sub)command for which the hook was invoked. // -// "run this image with `docker run " + TemplateReplaceArg(0) + "`" -// -// when being executed after the command: -// `docker pull alpine` -// will result in the message: -// "Run this image with `docker run alpine`" -func TemplateReplaceArg(i int) string { - return fmt.Sprintf(hookTemplateArg, strconv.Itoa(i)) +// It's used for backward-compatibility with old templates. +func (c commandInfo) Name() string { + return c.command() } -func ParseTemplate(hookTemplate string, cmd *cobra.Command) ([]string, error) { - tmpl := template.New("").Funcs(commandFunctions) - tmpl, err := tmpl.Parse(hookTemplate) - if err != nil { - return nil, err - } - b := bytes.Buffer{} - err = tmpl.Execute(&b, cmd) - if err != nil { - return nil, err +// command returns the name of the (sub)command for which the hook was invoked. +func (c commandInfo) command() string { + if c.cmd == nil { + return "" } - return strings.Split(b.String(), "\n"), nil + return c.cmd.Name() } -var ErrHookTemplateParse = errors.New("failed to parse hook template") - -const ( - hookTemplateCommandName = "{{.Name}}" - hookTemplateFlagValue = `{{flag . "%s"}}` - hookTemplateArg = "{{arg . %s}}" -) - -var commandFunctions = template.FuncMap{ - "flag": getFlagValue, - "arg": getArgValue, -} - -func getFlagValue(cmd *cobra.Command, flag string) (string, error) { - cmdFlag := cmd.Flag(flag) - if cmdFlag == nil { - return "", ErrHookTemplateParse +// flagValue returns the value that was set for the given flag when the hook was invoked. +func (c commandInfo) flagValue(flagName string) (string, error) { + if c.cmd == nil { + return "", fmt.Errorf("%w: flagValue: cmd is nil", ErrHookTemplateParse) } - return cmdFlag.Value.String(), nil + f := c.cmd.Flag(flagName) + if f == nil { + return "", fmt.Errorf("%w: flagValue: no flags found", ErrHookTemplateParse) + } + return f.Value.String(), nil } -func getArgValue(cmd *cobra.Command, i int) (string, error) { - flags := cmd.Flags() - if flags == nil { - return "", ErrHookTemplateParse +// argValue returns the value of the nth argument. +func (c commandInfo) argValue(n int) (string, error) { + if c.cmd == nil { + return "", fmt.Errorf("%w: arg: cmd is nil", ErrHookTemplateParse) + } + flags := c.cmd.Flags() + v := flags.Arg(n) + if v == "" && n >= flags.NArg() { + return "", fmt.Errorf("%w: arg: %dth argument not set", ErrHookTemplateParse, n) } - return flags.Arg(i), nil + return v, nil } diff --git a/cli-plugins/hooks/template_test.go b/cli-plugins/hooks/template_test.go index 3154eaf36d3e..28b290696f1d 100644 --- a/cli-plugins/hooks/template_test.go +++ b/cli-plugins/hooks/template_test.go @@ -1,43 +1,67 @@ -package hooks +package hooks_test import ( "testing" + "github.com/docker/cli/cli-plugins/hooks" "github.com/spf13/cobra" "gotest.tools/v3/assert" ) +// TestParseTemplate tests parsing templates as returned by plugins. +// +// It uses fixed string fixtures to lock in compatibility with existing +// plugin templates, so older formats continue to work even if we add new +// template forms. +// +// For helper-backed cases, it also verifies that templates produced by the +// current TemplateReplace* helpers parse to the same output. This lets us +// evolve the emitted template format without breaking older plugins. func TestParseTemplate(t *testing.T) { type testFlag struct { name string value string } - testCases := []struct { - template string + tests := []struct { + doc string + template string // compatibility fixture; keep even if helpers emit a newer form + templateFunc func() string flags []testFlag args []string expectedOutput []string }{ { + doc: "empty template", template: "", expectedOutput: []string{""}, }, { + doc: "plain message", template: "a plain template message", expectedOutput: []string{"a plain template message"}, }, { - template: TemplateReplaceFlagValue("tag"), + doc: "subcommand name", + template: "hello {{.Name}}", // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return "hello " + hooks.TemplateReplaceSubcommandName() }, + + expectedOutput: []string{"hello pull"}, + }, + { + doc: "single flag", + template: `{{flag . "tag"}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return hooks.TemplateReplaceFlagValue("tag") }, flags: []testFlag{ - { - name: "tag", - value: "my-tag", - }, + {name: "tag", value: "my-tag"}, }, expectedOutput: []string{"my-tag"}, }, { - template: TemplateReplaceFlagValue("test-one") + " " + TemplateReplaceFlagValue("test2"), + doc: "multiple flags", + template: `{{flag . "test-one"}} {{flag . "test2"}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { + return hooks.TemplateReplaceFlagValue("test-one") + " " + hooks.TemplateReplaceFlagValue("test2") + }, flags: []testFlag{ { name: "test-one", @@ -51,36 +75,51 @@ func TestParseTemplate(t *testing.T) { expectedOutput: []string{"value value2"}, }, { - template: TemplateReplaceArg(0) + " " + TemplateReplaceArg(1), + doc: "multiple args", + template: `{{arg . 0}} {{arg . 1}}`, // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return hooks.TemplateReplaceArg(0) + " " + hooks.TemplateReplaceArg(1) }, args: []string{"zero", "one"}, expectedOutput: []string{"zero one"}, }, { - template: "You just pulled " + TemplateReplaceArg(0), + doc: "arg in sentence", + template: "You just pulled {{arg . 0}}", // NOTE: fixture; do not modify without considering plugin compatibility + templateFunc: func() string { return "You just pulled " + hooks.TemplateReplaceArg(0) }, args: []string{"alpine"}, expectedOutput: []string{"You just pulled alpine"}, }, { + doc: "multiline output", template: "one line\nanother line!", expectedOutput: []string{"one line", "another line!"}, }, } - for _, tc := range testCases { - testCmd := &cobra.Command{ - Use: "pull", - Args: cobra.ExactArgs(len(tc.args)), - } - for _, f := range tc.flags { - _ = testCmd.Flags().String(f.name, "", "") - err := testCmd.Flag(f.name).Value.Set(f.value) + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + testCmd := &cobra.Command{ + Use: "pull", + Args: cobra.ExactArgs(len(tc.args)), + } + for _, f := range tc.flags { + _ = testCmd.Flags().String(f.name, "", "") + err := testCmd.Flag(f.name).Value.Set(f.value) + assert.NilError(t, err) + } + err := testCmd.Flags().Parse(tc.args) + assert.NilError(t, err) + + // Validate using fixtures. + out, err := hooks.ParseTemplate(tc.template, testCmd) assert.NilError(t, err) - } - err := testCmd.Flags().Parse(tc.args) - assert.NilError(t, err) + assert.DeepEqual(t, out, tc.expectedOutput) - out, err := ParseTemplate(tc.template, testCmd) - assert.NilError(t, err) - assert.DeepEqual(t, out, tc.expectedOutput) + if tc.templateFunc != nil { + // Validate using the current template function equivalent. + out, err = hooks.ParseTemplate(tc.templateFunc(), testCmd) + assert.NilError(t, err) + assert.DeepEqual(t, out, tc.expectedOutput) + } + }) } } diff --git a/cli-plugins/manager/hooks.go b/cli-plugins/manager/hooks.go index 6a3212315f9a..a58a4439449a 100644 --- a/cli-plugins/manager/hooks.go +++ b/cli-plugins/manager/hooks.go @@ -6,6 +6,9 @@ package manager import ( "context" "encoding/json" + "errors" + "fmt" + "strconv" "strings" "github.com/docker/cli/cli-plugins/hooks" @@ -19,15 +22,11 @@ import ( // HookPluginData is the type representing the information // that plugins declaring support for hooks get passed when // being invoked following a CLI command execution. -type HookPluginData struct { - // RootCmd is a string representing the matching hook configuration - // which is currently being invoked. If a hook for `docker context` is - // configured and the user executes `docker context ls`, the plugin will - // be invoked with `context`. - RootCmd string - Flags map[string]string - CommandError string -} +// +// Deprecated: use [hooks.Request] instead. +// +//go:fix inline +type HookPluginData = hooks.Request // RunCLICommandHooks is the entrypoint into the hooks execution flow after // a main CLI command was executed. It calls the hook subcommand for all @@ -55,11 +54,8 @@ func runHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subComma } func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, rootCmd, subCmd *cobra.Command, subCmdStr string, flags map[string]string, cmdErrorMessage string) []string { - // check if the context was cancelled before invoking hooks - select { - case <-ctx.Done(): + if ctx.Err() != nil { return nil - default: } pluginsCfg := cfg.Plugins @@ -69,47 +65,65 @@ func invokeAndCollectHooks(ctx context.Context, cfg *configfile.ConfigFile, root pluginDirs := getPluginDirs(cfg) nextSteps := make([]string, 0, len(pluginsCfg)) - for pluginName, pluginCfg := range pluginsCfg { - match, ok := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) - if !ok { - continue + + tryInvokeHook := func(pluginName string, pluginCfg map[string]string) (messages []string, ok bool, err error) { + match, matched := pluginMatch(pluginCfg, subCmdStr, cmdErrorMessage) + if !matched { + return nil, false, nil } p, err := getPlugin(pluginName, pluginDirs, rootCmd) if err != nil { - continue + return nil, false, err } - hookReturn, err := p.RunHook(ctx, HookPluginData{ + resp, err := p.RunHook(ctx, hooks.Request{ RootCmd: match, Flags: flags, CommandError: cmdErrorMessage, }) if err != nil { - // skip misbehaving plugins, but don't halt execution - continue + return nil, false, err } - var hookMessageData hooks.HookMessage - err = json.Unmarshal(hookReturn, &hookMessageData) - if err != nil { - continue + var message hooks.Response + if err := json.Unmarshal(resp, &message); err != nil { + return nil, false, fmt.Errorf("failed to unmarshal hook response (%q): %w", string(resp), err) } // currently the only hook type - if hookMessageData.Type != hooks.NextSteps { - continue + if message.Type != hooks.NextSteps { + return nil, false, errors.New("unexpected hook response type: " + strconv.Itoa(int(message.Type))) } - processedHook, err := hooks.ParseTemplate(hookMessageData.Template, subCmd) + messages, err = hooks.ParseTemplate(message.Template, subCmd) if err != nil { + return nil, false, err + } + + return messages, true, nil + } + + for pluginName, pluginCfg := range pluginsCfg { + messages, ok, err := tryInvokeHook(pluginName, pluginCfg) + if err != nil { + // skip misbehaving plugins, but don't halt execution + logrus.WithFields(logrus.Fields{ + "error": err, + "plugin": pluginName, + }).Debug("Plugin hook invocation failed") + continue + } + if !ok { continue } var appended bool - nextSteps, appended = appendNextSteps(nextSteps, processedHook) + nextSteps, appended = appendNextSteps(nextSteps, messages) if !appended { - logrus.Debugf("Plugin %s responded with an empty hook message %q. Ignoring.", pluginName, string(hookReturn)) + logrus.WithFields(logrus.Fields{ + "plugin": pluginName, + }).Debug("Plugin responded with an empty hook message; ignoring") } } return nextSteps diff --git a/cli-plugins/manager/plugin.go b/cli-plugins/manager/plugin.go index 824d82d12f1f..4270bac2ccf8 100644 --- a/cli-plugins/manager/plugin.go +++ b/cli-plugins/manager/plugin.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" + "github.com/docker/cli/cli-plugins/hooks" "github.com/docker/cli/cli-plugins/metadata" "github.com/spf13/cobra" ) @@ -154,7 +155,7 @@ func validateSchemaVersion(version string) error { // RunHook executes the plugin's hooks command // and returns its unprocessed output. -func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, error) { +func (p *Plugin) RunHook(ctx context.Context, hookData hooks.Request) ([]byte, error) { hDataBytes, err := json.Marshal(hookData) if err != nil { return nil, wrapAsPluginError(err, "failed to marshall hook data") @@ -163,12 +164,16 @@ func (p *Plugin) RunHook(ctx context.Context, hookData HookPluginData) ([]byte, pCmd := exec.CommandContext(ctx, p.Path, p.Name, metadata.HookSubcommandName, string(hDataBytes)) // #nosec G204 -- ignore "Subprocess launched with a potential tainted input or cmd arguments" pCmd.Env = os.Environ() pCmd.Env = append(pCmd.Env, metadata.ReexecEnvvar+"="+os.Args[0]) - hookCmdOutput, err := pCmd.Output() + + out, err := pCmd.Output() if err != nil { - return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand") + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, wrapAsPluginError(err, "plugin hook subcommand exited unsuccessfully") + } + return nil, wrapAsPluginError(err, "failed to execute plugin hook subcommand: "+pCmd.String()) } - - return hookCmdOutput, nil + return out, nil } // pluginNameFormat is used as part of errors for invalid plugin-names.