Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions cli-plugins/hooks/hook_types.go
Original file line number Diff line number Diff line change
@@ -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"`
Comment on lines +40 to +61
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously these were using the default JSON casing so (RootCmd not rootCmd).

Can we break the format here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, shouldn't ... really; when parsing, Golang is still case-insensitive, but if there's multiple fields only differing in casing, it will prefer the one added here.

}

// 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,omitzero"`
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
75 changes: 75 additions & 0 deletions cli-plugins/hooks/hook_utils.go
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 50 additions & 0 deletions cli-plugins/hooks/hooks_utils_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
18 changes: 10 additions & 8 deletions cli-plugins/hooks/printer.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
package hooks

import (
"fmt"
"io"
import "io"

"github.com/morikuni/aec"
)
const whatsNext = "\033[1mWhat's next:\033[0m"

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, "\n")
_, _ = io.WriteString(out, whatsNext)
_, _ = io.WriteString(out, "\n")
for _, msg := range messages {
_, _ = io.WriteString(out, " ")
_, _ = io.WriteString(out, msg)
_, _ = io.WriteString(out, "\n")
}
}
31 changes: 19 additions & 12 deletions cli-plugins/hooks/printer_test.go
Original file line number Diff line number Diff line change
@@ -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 = "\x1b[1m\nWhat'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)
})
}
}
Loading
Loading