Browse code

Move pkg/jsonmessage to client/pkg/jsonmessage

Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>

Derek McGowan authored on 2025/07/30 05:28:46
Showing 18 changed files
... ...
@@ -8,7 +8,9 @@ require (
8 8
 	github.com/containerd/errdefs/pkg v0.3.0
9 9
 	github.com/distribution/reference v0.6.0
10 10
 	github.com/docker/go-connections v0.5.0
11
+	github.com/docker/go-units v0.5.0
11 12
 	github.com/moby/moby/api v0.0.0
13
+	github.com/moby/term v0.5.2
12 14
 	github.com/opencontainers/go-digest v1.0.0
13 15
 	github.com/opencontainers/image-spec v1.1.1
14 16
 	github.com/pkg/errors v0.9.1
... ...
@@ -18,7 +20,7 @@ require (
18 18
 )
19 19
 
20 20
 require (
21
-	github.com/docker/go-units v0.5.0 // indirect
21
+	github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
22 22
 	github.com/felixge/httpsnoop v1.0.4 // indirect
23 23
 	github.com/go-logr/logr v1.4.2 // indirect
24 24
 	github.com/go-logr/stdr v1.2.2 // indirect
... ...
@@ -1,9 +1,13 @@
1
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
2
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
1 3
 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
2 4
 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
3 5
 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
4 6
 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
5 7
 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
6 8
 github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
9
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
10
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
7 11
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8 12
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9 13
 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
... ...
@@ -25,6 +29,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
25 25
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
26 26
 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
27 27
 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
28
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
29
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
28 30
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
29 31
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
30 32
 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
... ...
@@ -49,6 +55,7 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5J
49 49
 go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
50 50
 go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
51 51
 go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
52
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
52 53
 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
53 54
 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
54 55
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
55 56
new file mode 100644
... ...
@@ -0,0 +1,309 @@
0
+package jsonmessage
1
+
2
+import (
3
+	"encoding/json"
4
+	"fmt"
5
+	"io"
6
+	"strings"
7
+	"time"
8
+
9
+	"github.com/docker/go-units"
10
+	"github.com/moby/moby/api/types/jsonstream"
11
+	"github.com/moby/term"
12
+)
13
+
14
+// RFC3339NanoFixed is time.RFC3339Nano with nanoseconds padded using zeros to
15
+// ensure the formatted time isalways the same number of characters.
16
+const RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
17
+
18
+// JSONProgress describes a progress message in a JSON stream.
19
+type JSONProgress struct {
20
+	jsonstream.Progress
21
+
22
+	// terminalFd is the fd of the current terminal, if any. It is used
23
+	// to get the terminal width.
24
+	terminalFd uintptr
25
+
26
+	// nowFunc is used to override the current time in tests.
27
+	nowFunc func() time.Time
28
+
29
+	// winSize is used to override the terminal width in tests.
30
+	winSize int
31
+}
32
+
33
+func (p *JSONProgress) String() string {
34
+	var (
35
+		width      = p.width()
36
+		pbBox      string
37
+		numbersBox string
38
+	)
39
+	if p.Current <= 0 && p.Total <= 0 {
40
+		return ""
41
+	}
42
+	if p.Total <= 0 {
43
+		switch p.Units {
44
+		case "":
45
+			return fmt.Sprintf("%8v", units.HumanSize(float64(p.Current)))
46
+		default:
47
+			return fmt.Sprintf("%d %s", p.Current, p.Units)
48
+		}
49
+	}
50
+
51
+	percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
52
+	if percentage > 50 {
53
+		percentage = 50
54
+	}
55
+	if width > 110 {
56
+		// this number can't be negative gh#7136
57
+		numSpaces := 0
58
+		if 50-percentage > 0 {
59
+			numSpaces = 50 - percentage
60
+		}
61
+		pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
62
+	}
63
+
64
+	switch {
65
+	case p.HideCounts:
66
+	case p.Units == "": // no units, use bytes
67
+		current := units.HumanSize(float64(p.Current))
68
+		total := units.HumanSize(float64(p.Total))
69
+
70
+		numbersBox = fmt.Sprintf("%8v/%v", current, total)
71
+
72
+		if p.Current > p.Total {
73
+			// remove total display if the reported current is wonky.
74
+			numbersBox = fmt.Sprintf("%8v", current)
75
+		}
76
+	default:
77
+		numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
78
+
79
+		if p.Current > p.Total {
80
+			// remove total display if the reported current is wonky.
81
+			numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
82
+		}
83
+	}
84
+
85
+	// Show approximation of remaining time if there's enough width.
86
+	var timeLeftBox string
87
+	if width > 50 {
88
+		if p.Current > 0 && p.Start > 0 && percentage < 50 {
89
+			fromStart := p.now().Sub(time.Unix(p.Start, 0))
90
+			perEntry := fromStart / time.Duration(p.Current)
91
+			left := time.Duration(p.Total-p.Current) * perEntry
92
+			timeLeftBox = " " + left.Round(time.Second).String()
93
+		}
94
+	}
95
+	return pbBox + numbersBox + timeLeftBox
96
+}
97
+
98
+// now returns the current time in UTC, but can be overridden in tests
99
+// by setting JSONProgress.nowFunc to a custom function.
100
+func (p *JSONProgress) now() time.Time {
101
+	if p.nowFunc != nil {
102
+		return p.nowFunc()
103
+	}
104
+	return time.Now().UTC()
105
+}
106
+
107
+// width returns the current terminal's width, but can be overridden
108
+// in tests by setting JSONProgress.winSize to a non-zero value.
109
+func (p *JSONProgress) width() int {
110
+	if p.winSize != 0 {
111
+		return p.winSize
112
+	}
113
+	ws, err := term.GetWinsize(p.terminalFd)
114
+	if err == nil {
115
+		return int(ws.Width)
116
+	}
117
+	return 200
118
+}
119
+
120
+// JSONMessage defines a message struct. It describes
121
+// the created time, where it from, status, ID of the
122
+// message. It's used for docker events.
123
+type JSONMessage struct {
124
+	Stream   string        `json:"stream,omitempty"`
125
+	Status   string        `json:"status,omitempty"`
126
+	Progress *JSONProgress `json:"progressDetail,omitempty"`
127
+
128
+	// ProgressMessage is a pre-formatted presentation of [Progress].
129
+	//
130
+	// Deprecated: this field is deprecated since docker v0.7.1 / API v1.8. Use the information in [Progress] instead. This field will be omitted in a future release.
131
+	ProgressMessage string            `json:"progress,omitempty"`
132
+	ID              string            `json:"id,omitempty"`
133
+	From            string            `json:"from,omitempty"`
134
+	Time            int64             `json:"time,omitempty"`
135
+	TimeNano        int64             `json:"timeNano,omitempty"`
136
+	Error           *jsonstream.Error `json:"errorDetail,omitempty"`
137
+
138
+	// ErrorMessage contains errors encountered during the operation.
139
+	//
140
+	// Deprecated: this field is deprecated since docker v0.6.0 / API v1.4. Use [Error.Message] instead. This field will be omitted in a future release.
141
+	ErrorMessage string `json:"error,omitempty"` // deprecated
142
+	// Aux contains out-of-band data, such as digests for push signing and image id after building.
143
+	Aux *json.RawMessage `json:"aux,omitempty"`
144
+}
145
+
146
+// We can probably use [aec.EmptyBuilder] for managing the output, but
147
+// currently we're doing it all manually, so defining some consts for
148
+// the basics we use.
149
+//
150
+// [aec.EmptyBuilder]: https://pkg.go.dev/github.com/morikuni/aec#EmptyBuilder
151
+const (
152
+	ansiEraseLine     = "\x1b[2K"  // Erase entire line
153
+	ansiCursorUpFmt   = "\x1b[%dA" // Move cursor up N lines
154
+	ansiCursorDownFmt = "\x1b[%dB" // Move cursor down N lines
155
+)
156
+
157
+func clearLine(out io.Writer) {
158
+	_, _ = out.Write([]byte(ansiEraseLine))
159
+}
160
+
161
+func cursorUp(out io.Writer, l uint) {
162
+	if l == 0 {
163
+		return
164
+	}
165
+	_, _ = fmt.Fprintf(out, ansiCursorUpFmt, l)
166
+}
167
+
168
+func cursorDown(out io.Writer, l uint) {
169
+	if l == 0 {
170
+		return
171
+	}
172
+	_, _ = fmt.Fprintf(out, ansiCursorDownFmt, l)
173
+}
174
+
175
+// Display prints the JSONMessage to out. If isTerminal is true, it erases
176
+// the entire current line when displaying the progressbar. It returns an
177
+// error if the [JSONMessage.Error] field is non-nil.
178
+func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
179
+	if jm.Error != nil {
180
+		return jm.Error
181
+	}
182
+	var endl string
183
+	if isTerminal && jm.Stream == "" && jm.Progress != nil {
184
+		clearLine(out)
185
+		endl = "\r"
186
+		_, _ = fmt.Fprint(out, endl)
187
+	} else if jm.Progress != nil && jm.Progress.String() != "" { // disable progressbar in non-terminal
188
+		return nil
189
+	}
190
+	if jm.TimeNano != 0 {
191
+		_, _ = fmt.Fprintf(out, "%s ", time.Unix(0, jm.TimeNano).Format(RFC3339NanoFixed))
192
+	} else if jm.Time != 0 {
193
+		_, _ = fmt.Fprintf(out, "%s ", time.Unix(jm.Time, 0).Format(RFC3339NanoFixed))
194
+	}
195
+	if jm.ID != "" {
196
+		_, _ = fmt.Fprintf(out, "%s: ", jm.ID)
197
+	}
198
+	if jm.From != "" {
199
+		_, _ = fmt.Fprintf(out, "(from %s) ", jm.From)
200
+	}
201
+	if jm.Progress != nil && isTerminal {
202
+		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
203
+	} else if jm.ProgressMessage != "" { // deprecated
204
+		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
205
+	} else if jm.Stream != "" {
206
+		_, _ = fmt.Fprintf(out, "%s%s", jm.Stream, endl)
207
+	} else {
208
+		_, _ = fmt.Fprintf(out, "%s%s\n", jm.Status, endl)
209
+	}
210
+	return nil
211
+}
212
+
213
+// DisplayJSONMessagesStream reads a JSON message stream from in, and writes
214
+// each [JSONMessage] to out. It returns an error if an invalid JSONMessage
215
+// is received, or if a JSONMessage containers a non-zero [JSONMessage.Error].
216
+//
217
+// Presentation of the JSONMessage depends on whether a terminal is attached,
218
+// and on the terminal width. Progress bars ([JSONProgress]) are suppressed
219
+// on narrower terminals (< 110 characters).
220
+//
221
+//   - isTerminal describes if out is a terminal, in which case it prints
222
+//     a newline ("\n") at the end of each line and moves the cursor while
223
+//     displaying.
224
+//   - terminalFd is the fd of the current terminal (if any), and used
225
+//     to get the terminal width.
226
+//   - auxCallback allows handling the [JSONMessage.Aux] field. It is
227
+//     called if a JSONMessage contains an Aux field, in which case
228
+//     DisplayJSONMessagesStream does not present the JSONMessage.
229
+func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr, isTerminal bool, auxCallback func(JSONMessage)) error {
230
+	var (
231
+		dec = json.NewDecoder(in)
232
+		ids = make(map[string]uint)
233
+	)
234
+
235
+	for {
236
+		var diff uint
237
+		var jm JSONMessage
238
+		if err := dec.Decode(&jm); err != nil {
239
+			if err == io.EOF {
240
+				break
241
+			}
242
+			return err
243
+		}
244
+
245
+		if jm.Aux != nil {
246
+			if auxCallback != nil {
247
+				auxCallback(jm)
248
+			}
249
+			continue
250
+		}
251
+
252
+		if jm.Progress != nil {
253
+			jm.Progress.terminalFd = terminalFd
254
+		}
255
+		if jm.ID != "" && (jm.Progress != nil || jm.ProgressMessage != "") {
256
+			line, ok := ids[jm.ID]
257
+			if !ok {
258
+				// NOTE: This approach of using len(id) to
259
+				// figure out the number of lines of history
260
+				// only works as long as we clear the history
261
+				// when we output something that's not
262
+				// accounted for in the map, such as a line
263
+				// with no ID.
264
+				line = uint(len(ids))
265
+				ids[jm.ID] = line
266
+				if isTerminal {
267
+					_, _ = fmt.Fprintf(out, "\n")
268
+				}
269
+			}
270
+			diff = uint(len(ids)) - line
271
+			if isTerminal {
272
+				cursorUp(out, diff)
273
+			}
274
+		} else {
275
+			// When outputting something that isn't progress
276
+			// output, clear the history of previous lines. We
277
+			// don't want progress entries from some previous
278
+			// operation to be updated (for example, pull -a
279
+			// with multiple tags).
280
+			ids = make(map[string]uint)
281
+		}
282
+		err := jm.Display(out, isTerminal)
283
+		if jm.ID != "" && isTerminal {
284
+			cursorDown(out, diff)
285
+		}
286
+		if err != nil {
287
+			return err
288
+		}
289
+	}
290
+	return nil
291
+}
292
+
293
+// Stream is an io.Writer for output with utilities to get the output's file
294
+// descriptor and to detect whether it's a terminal.
295
+//
296
+// it is subset of the streams.Out type in
297
+// https://pkg.go.dev/github.com/docker/cli@v20.10.17+incompatible/cli/streams#Out
298
+type Stream interface {
299
+	io.Writer
300
+	FD() uintptr
301
+	IsTerminal() bool
302
+}
303
+
304
+// DisplayJSONMessagesToStream prints json messages to the output Stream. It is
305
+// used by the Docker CLI to print JSONMessage streams.
306
+func DisplayJSONMessagesToStream(in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
307
+	return DisplayJSONMessagesStream(in, stream, stream.FD(), stream.IsTerminal(), auxCallback)
308
+}
0 309
new file mode 100644
... ...
@@ -0,0 +1,290 @@
0
+package jsonmessage
1
+
2
+import (
3
+	"bytes"
4
+	"fmt"
5
+	"strings"
6
+	"testing"
7
+	"time"
8
+
9
+	"github.com/moby/moby/api/types/jsonstream"
10
+	"github.com/moby/term"
11
+	"gotest.tools/v3/assert"
12
+	is "gotest.tools/v3/assert/cmp"
13
+)
14
+
15
+func TestProgressString(t *testing.T) {
16
+	type expected struct {
17
+		short string
18
+		long  string
19
+	}
20
+
21
+	shortAndLong := func(short, long string) expected {
22
+		return expected{short: short, long: long}
23
+	}
24
+
25
+	start := time.Date(2017, 12, 3, 15, 10, 1, 0, time.UTC)
26
+	timeAfter := func(delta time.Duration) func() time.Time {
27
+		return func() time.Time {
28
+			return start.Add(delta)
29
+		}
30
+	}
31
+
32
+	testcases := []struct {
33
+		name     string
34
+		progress JSONProgress
35
+		expected expected
36
+	}{
37
+		{
38
+			name: "no progress",
39
+		},
40
+		{
41
+			name:     "progress 1",
42
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 1}},
43
+			expected: shortAndLong("      1B", "      1B"),
44
+		},
45
+		{
46
+			name: "some progress with a start time",
47
+			progress: JSONProgress{
48
+				Progress: jsonstream.Progress{
49
+					Current: 20,
50
+					Total:   100,
51
+					Start:   start.Unix(),
52
+				},
53
+				nowFunc: timeAfter(time.Second),
54
+			},
55
+			expected: shortAndLong(
56
+				"     20B/100B 4s",
57
+				"[==========>                                        ]      20B/100B 4s",
58
+			),
59
+		},
60
+		{
61
+			name:     "some progress without a start time",
62
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100}},
63
+			expected: shortAndLong(
64
+				"     50B/100B",
65
+				"[=========================>                         ]      50B/100B",
66
+			),
67
+		},
68
+		{
69
+			name:     "current more than total is not negative gh#7136",
70
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 40}},
71
+			expected: shortAndLong(
72
+				"     50B",
73
+				"[==================================================>]      50B",
74
+			),
75
+		},
76
+		{
77
+			name:     "with units",
78
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100, Units: "units"}},
79
+			expected: shortAndLong(
80
+				"50/100 units",
81
+				"[=========================>                         ] 50/100 units",
82
+			),
83
+		},
84
+		{
85
+			name:     "current more than total with units is not negative ",
86
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 40, Units: "units"}},
87
+			expected: shortAndLong(
88
+				"50 units",
89
+				"[==================================================>] 50 units",
90
+			),
91
+		},
92
+		{
93
+			name:     "hide counts",
94
+			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100, HideCounts: true}},
95
+			expected: shortAndLong(
96
+				"",
97
+				"[=========================>                         ] ",
98
+			),
99
+		},
100
+	}
101
+
102
+	for _, testcase := range testcases {
103
+		t.Run(testcase.name, func(t *testing.T) {
104
+			testcase.progress.winSize = 100
105
+			assert.Equal(t, testcase.progress.String(), testcase.expected.short)
106
+
107
+			testcase.progress.winSize = 200
108
+			assert.Equal(t, testcase.progress.String(), testcase.expected.long)
109
+		})
110
+	}
111
+}
112
+
113
+func TestJSONMessageDisplay(t *testing.T) {
114
+	now := time.Now()
115
+	messages := map[JSONMessage][]string{
116
+		// Empty
117
+		{}: {"\n", "\n"},
118
+		// Status
119
+		{
120
+			Status: "status",
121
+		}: {
122
+			"status\n",
123
+			"status\n",
124
+		},
125
+		// General
126
+		{
127
+			Time:   now.Unix(),
128
+			ID:     "ID",
129
+			From:   "From",
130
+			Status: "status",
131
+		}: {
132
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(RFC3339NanoFixed)),
133
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(RFC3339NanoFixed)),
134
+		},
135
+		// General, with nano precision time
136
+		{
137
+			TimeNano: now.UnixNano(),
138
+			ID:       "ID",
139
+			From:     "From",
140
+			Status:   "status",
141
+		}: {
142
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
143
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
144
+		},
145
+		// General, with both times Nano is preferred
146
+		{
147
+			Time:     now.Unix(),
148
+			TimeNano: now.UnixNano(),
149
+			ID:       "ID",
150
+			From:     "From",
151
+			Status:   "status",
152
+		}: {
153
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
154
+			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
155
+		},
156
+		// Stream over status
157
+		{
158
+			Status: "status",
159
+			Stream: "stream",
160
+		}: {
161
+			"stream",
162
+			"stream",
163
+		},
164
+		// With progress message
165
+		{
166
+			Status:          "status",
167
+			ProgressMessage: "progressMessage",
168
+		}: {
169
+			"status progressMessage",
170
+			"status progressMessage",
171
+		},
172
+		// With progress, stream empty
173
+		{
174
+			Status:   "status",
175
+			Stream:   "",
176
+			Progress: &JSONProgress{Progress: jsonstream.Progress{Current: 1}},
177
+		}: {
178
+			"",
179
+			fmt.Sprintf("%c[2K\rstatus       1B\r", 27),
180
+		},
181
+	}
182
+
183
+	// The tests :)
184
+	for jsonMessage, expectedMessages := range messages {
185
+		// Without terminal
186
+		data := bytes.NewBuffer([]byte{})
187
+		if err := jsonMessage.Display(data, false); err != nil {
188
+			t.Fatal(err)
189
+		}
190
+		if data.String() != expectedMessages[0] {
191
+			t.Fatalf("Expected %q,got %q", expectedMessages[0], data.String())
192
+		}
193
+		// With terminal
194
+		data = bytes.NewBuffer([]byte{})
195
+		if err := jsonMessage.Display(data, true); err != nil {
196
+			t.Fatal(err)
197
+		}
198
+		if data.String() != expectedMessages[1] {
199
+			t.Fatalf("\nExpected %q\n     got %q", expectedMessages[1], data.String())
200
+		}
201
+	}
202
+}
203
+
204
+// Test JSONMessage with an Error. It will return an error with the text as error, not the meaning of the HTTP code.
205
+func TestJSONMessageDisplayWithJSONError(t *testing.T) {
206
+	data := bytes.NewBuffer([]byte{})
207
+	jsonMessage := JSONMessage{Error: &jsonstream.Error{Code: 404, Message: "Can't find it"}}
208
+
209
+	err := jsonMessage.Display(data, true)
210
+	if err == nil || err.Error() != "Can't find it" {
211
+		t.Fatalf("Expected a jsonstream.Error 404, got %q", err)
212
+	}
213
+
214
+	jsonMessage = JSONMessage{Error: &jsonstream.Error{Code: 401, Message: "Anything"}}
215
+	err = jsonMessage.Display(data, true)
216
+	assert.Check(t, is.Error(err, "Anything"))
217
+}
218
+
219
+func TestDisplayJSONMessagesStreamInvalidJSON(t *testing.T) {
220
+	var inFd uintptr
221
+	data := bytes.NewBuffer([]byte{})
222
+	reader := strings.NewReader("This is not a 'valid' JSON []")
223
+	inFd, _ = term.GetFdInfo(reader)
224
+
225
+	exp := "invalid character "
226
+	if err := DisplayJSONMessagesStream(reader, data, inFd, false, nil); err == nil || !strings.HasPrefix(err.Error(), exp) {
227
+		t.Fatalf("Expected error (%s...), got %q", exp, err)
228
+	}
229
+}
230
+
231
+func TestDisplayJSONMessagesStream(t *testing.T) {
232
+	var inFd uintptr
233
+
234
+	messages := map[string][]string{
235
+		// empty string
236
+		"": {
237
+			"",
238
+			"",
239
+		},
240
+		// Without progress & ID
241
+		`{ "status": "status" }`: {
242
+			"status\n",
243
+			"status\n",
244
+		},
245
+		// Without progress, with ID
246
+		`{ "id": "ID","status": "status" }`: {
247
+			"ID: status\n",
248
+			"ID: status\n",
249
+		},
250
+		// With progress
251
+		`{ "id": "ID", "status": "status", "progress": "ProgressMessage" }`: {
252
+			"ID: status ProgressMessage",
253
+			fmt.Sprintf("\n%c[%dAID: status ProgressMessage%c[%dB", 27, 1, 27, 1),
254
+		},
255
+		// With progressDetail
256
+		`{ "id": "ID", "status": "status", "progressDetail": { "Current": 1} }`: {
257
+			"", // progressbar is disabled in non-terminal
258
+			fmt.Sprintf("\n%c[%dA%c[2K\rID: status       1B\r%c[%dB", 27, 1, 27, 27, 1),
259
+		},
260
+	}
261
+
262
+	// Use $TERM which is unlikely to exist, forcing DisplayJSONMessageStream to
263
+	// (hopefully) use &noTermInfo.
264
+	t.Setenv("TERM", "xyzzy-non-existent-terminfo")
265
+
266
+	for jsonMessage, expectedMessages := range messages {
267
+		data := bytes.NewBuffer([]byte{})
268
+		reader := strings.NewReader(jsonMessage)
269
+		inFd, _ = term.GetFdInfo(reader)
270
+
271
+		// Without terminal
272
+		if err := DisplayJSONMessagesStream(reader, data, inFd, false, nil); err != nil {
273
+			t.Fatal(err)
274
+		}
275
+		if data.String() != expectedMessages[0] {
276
+			t.Fatalf("Expected an %q, got %q", expectedMessages[0], data.String())
277
+		}
278
+
279
+		// With terminal
280
+		data = bytes.NewBuffer([]byte{})
281
+		reader = strings.NewReader(jsonMessage)
282
+		if err := DisplayJSONMessagesStream(reader, data, inFd, true, nil); err != nil {
283
+			t.Fatal(err)
284
+		}
285
+		if data.String() != expectedMessages[1] {
286
+			t.Fatalf("\nExpected %q\n     got %q", expectedMessages[1], data.String())
287
+		}
288
+	}
289
+}
... ...
@@ -8,11 +8,11 @@ import (
8 8
 	"testing"
9 9
 
10 10
 	"github.com/docker/docker/integration/internal/requirement"
11
-	"github.com/docker/docker/pkg/jsonmessage"
12 11
 	"github.com/docker/docker/testutil"
13 12
 	"github.com/docker/docker/testutil/daemon"
14 13
 	"github.com/docker/docker/testutil/fakecontext"
15 14
 	"github.com/moby/moby/api/types/build"
15
+	"github.com/moby/moby/client/pkg/jsonmessage"
16 16
 	"gotest.tools/v3/assert"
17 17
 	"gotest.tools/v3/skip"
18 18
 )
... ...
@@ -15,7 +15,6 @@ import (
15 15
 	"time"
16 16
 
17 17
 	cerrdefs "github.com/containerd/errdefs"
18
-	"github.com/docker/docker/pkg/jsonmessage"
19 18
 	"github.com/docker/docker/testutil"
20 19
 	"github.com/docker/docker/testutil/fakecontext"
21 20
 	"github.com/moby/moby/api/types/build"
... ...
@@ -23,6 +22,7 @@ import (
23 23
 	"github.com/moby/moby/api/types/events"
24 24
 	"github.com/moby/moby/api/types/filters"
25 25
 	"github.com/moby/moby/api/types/image"
26
+	"github.com/moby/moby/client/pkg/jsonmessage"
26 27
 	"gotest.tools/v3/assert"
27 28
 	is "gotest.tools/v3/assert/cmp"
28 29
 	"gotest.tools/v3/skip"
... ...
@@ -10,7 +10,6 @@ import (
10 10
 	"testing"
11 11
 
12 12
 	"github.com/docker/docker/integration/internal/container"
13
-	"github.com/docker/docker/pkg/jsonmessage"
14 13
 	"github.com/docker/docker/testutil"
15 14
 	"github.com/docker/docker/testutil/daemon"
16 15
 	"github.com/docker/docker/testutil/fakecontext"
... ...
@@ -18,6 +17,7 @@ import (
18 18
 	"github.com/moby/moby/api/pkg/stdcopy"
19 19
 	"github.com/moby/moby/api/types/build"
20 20
 	containertypes "github.com/moby/moby/api/types/container"
21
+	"github.com/moby/moby/client/pkg/jsonmessage"
21 22
 	"gotest.tools/v3/assert"
22 23
 	"gotest.tools/v3/poll"
23 24
 	"gotest.tools/v3/skip"
... ...
@@ -14,11 +14,11 @@ import (
14 14
 
15 15
 	cerrdefs "github.com/containerd/errdefs"
16 16
 	"github.com/docker/docker/integration/internal/container"
17
-	"github.com/docker/docker/pkg/jsonmessage"
18 17
 	"github.com/docker/docker/testutil/fakecontext"
19 18
 	"github.com/moby/go-archive"
20 19
 	"github.com/moby/moby/api/types/build"
21 20
 	containertypes "github.com/moby/moby/api/types/container"
21
+	"github.com/moby/moby/client/pkg/jsonmessage"
22 22
 	"gotest.tools/v3/assert"
23 23
 	is "gotest.tools/v3/assert/cmp"
24 24
 	"gotest.tools/v3/skip"
... ...
@@ -6,11 +6,11 @@ import (
6 6
 	"testing"
7 7
 
8 8
 	"github.com/docker/docker/integration/internal/container"
9
-	"github.com/docker/docker/pkg/jsonmessage"
10 9
 	"github.com/docker/docker/testutil"
11 10
 	"github.com/docker/docker/testutil/daemon"
12 11
 	"github.com/moby/moby/api/types/filters"
13 12
 	"github.com/moby/moby/api/types/image"
13
+	"github.com/moby/moby/client/pkg/jsonmessage"
14 14
 	"gotest.tools/v3/assert"
15 15
 	is "gotest.tools/v3/assert/cmp"
16 16
 	"gotest.tools/v3/poll"
... ...
@@ -6,11 +6,11 @@ import (
6 6
 	"io"
7 7
 	"testing"
8 8
 
9
-	"github.com/docker/docker/pkg/jsonmessage"
10 9
 	"github.com/docker/docker/testutil/fakecontext"
11 10
 	"github.com/moby/moby/api/types/build"
12 11
 	"github.com/moby/moby/api/types/image"
13 12
 	"github.com/moby/moby/client"
13
+	"github.com/moby/moby/client/pkg/jsonmessage"
14 14
 	"gotest.tools/v3/assert"
15 15
 )
16 16
 
... ...
@@ -10,9 +10,9 @@ import (
10 10
 	"testing"
11 11
 
12 12
 	"github.com/docker/docker/internal/testutils/specialimage"
13
-	"github.com/docker/docker/pkg/jsonmessage"
14 13
 	"github.com/moby/go-archive"
15 14
 	"github.com/moby/moby/client"
15
+	"github.com/moby/moby/client/pkg/jsonmessage"
16 16
 	"gotest.tools/v3/assert"
17 17
 )
18 18
 
... ...
@@ -15,7 +15,6 @@ import (
15 15
 
16 16
 	c8dimages "github.com/containerd/containerd/v2/core/images"
17 17
 	"github.com/containerd/containerd/v2/core/remotes/docker"
18
-	"github.com/docker/docker/pkg/jsonmessage"
19 18
 	"github.com/docker/docker/testutil"
20 19
 	"github.com/docker/docker/testutil/daemon"
21 20
 	"github.com/docker/docker/testutil/fixtures/plugin"
... ...
@@ -25,6 +24,7 @@ import (
25 25
 	registrytypes "github.com/moby/moby/api/types/registry"
26 26
 	"github.com/moby/moby/api/types/system"
27 27
 	"github.com/moby/moby/client"
28
+	"github.com/moby/moby/client/pkg/jsonmessage"
28 29
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
29 30
 	"gotest.tools/v3/assert"
30 31
 	is "gotest.tools/v3/assert/cmp"
... ...
@@ -12,13 +12,13 @@ import (
12 12
 	"time"
13 13
 
14 14
 	"github.com/docker/docker/integration/internal/container"
15
-	"github.com/docker/docker/pkg/jsonmessage"
16 15
 	"github.com/docker/docker/testutil/request"
17 16
 	containertypes "github.com/moby/moby/api/types/container"
18 17
 	"github.com/moby/moby/api/types/events"
19 18
 	"github.com/moby/moby/api/types/filters"
20 19
 	"github.com/moby/moby/api/types/mount"
21 20
 	"github.com/moby/moby/api/types/volume"
21
+	"github.com/moby/moby/client/pkg/jsonmessage"
22 22
 	"gotest.tools/v3/assert"
23 23
 	is "gotest.tools/v3/assert/cmp"
24 24
 	"gotest.tools/v3/skip"
25 25
deleted file mode 100644
... ...
@@ -1,309 +0,0 @@
1
-package jsonmessage
2
-
3
-import (
4
-	"encoding/json"
5
-	"fmt"
6
-	"io"
7
-	"strings"
8
-	"time"
9
-
10
-	"github.com/docker/go-units"
11
-	"github.com/moby/moby/api/types/jsonstream"
12
-	"github.com/moby/term"
13
-)
14
-
15
-// RFC3339NanoFixed is time.RFC3339Nano with nanoseconds padded using zeros to
16
-// ensure the formatted time isalways the same number of characters.
17
-const RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
18
-
19
-// JSONProgress describes a progress message in a JSON stream.
20
-type JSONProgress struct {
21
-	jsonstream.Progress
22
-
23
-	// terminalFd is the fd of the current terminal, if any. It is used
24
-	// to get the terminal width.
25
-	terminalFd uintptr
26
-
27
-	// nowFunc is used to override the current time in tests.
28
-	nowFunc func() time.Time
29
-
30
-	// winSize is used to override the terminal width in tests.
31
-	winSize int
32
-}
33
-
34
-func (p *JSONProgress) String() string {
35
-	var (
36
-		width      = p.width()
37
-		pbBox      string
38
-		numbersBox string
39
-	)
40
-	if p.Current <= 0 && p.Total <= 0 {
41
-		return ""
42
-	}
43
-	if p.Total <= 0 {
44
-		switch p.Units {
45
-		case "":
46
-			return fmt.Sprintf("%8v", units.HumanSize(float64(p.Current)))
47
-		default:
48
-			return fmt.Sprintf("%d %s", p.Current, p.Units)
49
-		}
50
-	}
51
-
52
-	percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
53
-	if percentage > 50 {
54
-		percentage = 50
55
-	}
56
-	if width > 110 {
57
-		// this number can't be negative gh#7136
58
-		numSpaces := 0
59
-		if 50-percentage > 0 {
60
-			numSpaces = 50 - percentage
61
-		}
62
-		pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
63
-	}
64
-
65
-	switch {
66
-	case p.HideCounts:
67
-	case p.Units == "": // no units, use bytes
68
-		current := units.HumanSize(float64(p.Current))
69
-		total := units.HumanSize(float64(p.Total))
70
-
71
-		numbersBox = fmt.Sprintf("%8v/%v", current, total)
72
-
73
-		if p.Current > p.Total {
74
-			// remove total display if the reported current is wonky.
75
-			numbersBox = fmt.Sprintf("%8v", current)
76
-		}
77
-	default:
78
-		numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
79
-
80
-		if p.Current > p.Total {
81
-			// remove total display if the reported current is wonky.
82
-			numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
83
-		}
84
-	}
85
-
86
-	// Show approximation of remaining time if there's enough width.
87
-	var timeLeftBox string
88
-	if width > 50 {
89
-		if p.Current > 0 && p.Start > 0 && percentage < 50 {
90
-			fromStart := p.now().Sub(time.Unix(p.Start, 0))
91
-			perEntry := fromStart / time.Duration(p.Current)
92
-			left := time.Duration(p.Total-p.Current) * perEntry
93
-			timeLeftBox = " " + left.Round(time.Second).String()
94
-		}
95
-	}
96
-	return pbBox + numbersBox + timeLeftBox
97
-}
98
-
99
-// now returns the current time in UTC, but can be overridden in tests
100
-// by setting JSONProgress.nowFunc to a custom function.
101
-func (p *JSONProgress) now() time.Time {
102
-	if p.nowFunc != nil {
103
-		return p.nowFunc()
104
-	}
105
-	return time.Now().UTC()
106
-}
107
-
108
-// width returns the current terminal's width, but can be overridden
109
-// in tests by setting JSONProgress.winSize to a non-zero value.
110
-func (p *JSONProgress) width() int {
111
-	if p.winSize != 0 {
112
-		return p.winSize
113
-	}
114
-	ws, err := term.GetWinsize(p.terminalFd)
115
-	if err == nil {
116
-		return int(ws.Width)
117
-	}
118
-	return 200
119
-}
120
-
121
-// JSONMessage defines a message struct. It describes
122
-// the created time, where it from, status, ID of the
123
-// message. It's used for docker events.
124
-type JSONMessage struct {
125
-	Stream   string        `json:"stream,omitempty"`
126
-	Status   string        `json:"status,omitempty"`
127
-	Progress *JSONProgress `json:"progressDetail,omitempty"`
128
-
129
-	// ProgressMessage is a pre-formatted presentation of [Progress].
130
-	//
131
-	// Deprecated: this field is deprecated since docker v0.7.1 / API v1.8. Use the information in [Progress] instead. This field will be omitted in a future release.
132
-	ProgressMessage string            `json:"progress,omitempty"`
133
-	ID              string            `json:"id,omitempty"`
134
-	From            string            `json:"from,omitempty"`
135
-	Time            int64             `json:"time,omitempty"`
136
-	TimeNano        int64             `json:"timeNano,omitempty"`
137
-	Error           *jsonstream.Error `json:"errorDetail,omitempty"`
138
-
139
-	// ErrorMessage contains errors encountered during the operation.
140
-	//
141
-	// Deprecated: this field is deprecated since docker v0.6.0 / API v1.4. Use [Error.Message] instead. This field will be omitted in a future release.
142
-	ErrorMessage string `json:"error,omitempty"` // deprecated
143
-	// Aux contains out-of-band data, such as digests for push signing and image id after building.
144
-	Aux *json.RawMessage `json:"aux,omitempty"`
145
-}
146
-
147
-// We can probably use [aec.EmptyBuilder] for managing the output, but
148
-// currently we're doing it all manually, so defining some consts for
149
-// the basics we use.
150
-//
151
-// [aec.EmptyBuilder]: https://pkg.go.dev/github.com/morikuni/aec#EmptyBuilder
152
-const (
153
-	ansiEraseLine     = "\x1b[2K"  // Erase entire line
154
-	ansiCursorUpFmt   = "\x1b[%dA" // Move cursor up N lines
155
-	ansiCursorDownFmt = "\x1b[%dB" // Move cursor down N lines
156
-)
157
-
158
-func clearLine(out io.Writer) {
159
-	_, _ = out.Write([]byte(ansiEraseLine))
160
-}
161
-
162
-func cursorUp(out io.Writer, l uint) {
163
-	if l == 0 {
164
-		return
165
-	}
166
-	_, _ = fmt.Fprintf(out, ansiCursorUpFmt, l)
167
-}
168
-
169
-func cursorDown(out io.Writer, l uint) {
170
-	if l == 0 {
171
-		return
172
-	}
173
-	_, _ = fmt.Fprintf(out, ansiCursorDownFmt, l)
174
-}
175
-
176
-// Display prints the JSONMessage to out. If isTerminal is true, it erases
177
-// the entire current line when displaying the progressbar. It returns an
178
-// error if the [JSONMessage.Error] field is non-nil.
179
-func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
180
-	if jm.Error != nil {
181
-		return jm.Error
182
-	}
183
-	var endl string
184
-	if isTerminal && jm.Stream == "" && jm.Progress != nil {
185
-		clearLine(out)
186
-		endl = "\r"
187
-		_, _ = fmt.Fprint(out, endl)
188
-	} else if jm.Progress != nil && jm.Progress.String() != "" { // disable progressbar in non-terminal
189
-		return nil
190
-	}
191
-	if jm.TimeNano != 0 {
192
-		_, _ = fmt.Fprintf(out, "%s ", time.Unix(0, jm.TimeNano).Format(RFC3339NanoFixed))
193
-	} else if jm.Time != 0 {
194
-		_, _ = fmt.Fprintf(out, "%s ", time.Unix(jm.Time, 0).Format(RFC3339NanoFixed))
195
-	}
196
-	if jm.ID != "" {
197
-		_, _ = fmt.Fprintf(out, "%s: ", jm.ID)
198
-	}
199
-	if jm.From != "" {
200
-		_, _ = fmt.Fprintf(out, "(from %s) ", jm.From)
201
-	}
202
-	if jm.Progress != nil && isTerminal {
203
-		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
204
-	} else if jm.ProgressMessage != "" { // deprecated
205
-		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
206
-	} else if jm.Stream != "" {
207
-		_, _ = fmt.Fprintf(out, "%s%s", jm.Stream, endl)
208
-	} else {
209
-		_, _ = fmt.Fprintf(out, "%s%s\n", jm.Status, endl)
210
-	}
211
-	return nil
212
-}
213
-
214
-// DisplayJSONMessagesStream reads a JSON message stream from in, and writes
215
-// each [JSONMessage] to out. It returns an error if an invalid JSONMessage
216
-// is received, or if a JSONMessage containers a non-zero [JSONMessage.Error].
217
-//
218
-// Presentation of the JSONMessage depends on whether a terminal is attached,
219
-// and on the terminal width. Progress bars ([JSONProgress]) are suppressed
220
-// on narrower terminals (< 110 characters).
221
-//
222
-//   - isTerminal describes if out is a terminal, in which case it prints
223
-//     a newline ("\n") at the end of each line and moves the cursor while
224
-//     displaying.
225
-//   - terminalFd is the fd of the current terminal (if any), and used
226
-//     to get the terminal width.
227
-//   - auxCallback allows handling the [JSONMessage.Aux] field. It is
228
-//     called if a JSONMessage contains an Aux field, in which case
229
-//     DisplayJSONMessagesStream does not present the JSONMessage.
230
-func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr, isTerminal bool, auxCallback func(JSONMessage)) error {
231
-	var (
232
-		dec = json.NewDecoder(in)
233
-		ids = make(map[string]uint)
234
-	)
235
-
236
-	for {
237
-		var diff uint
238
-		var jm JSONMessage
239
-		if err := dec.Decode(&jm); err != nil {
240
-			if err == io.EOF {
241
-				break
242
-			}
243
-			return err
244
-		}
245
-
246
-		if jm.Aux != nil {
247
-			if auxCallback != nil {
248
-				auxCallback(jm)
249
-			}
250
-			continue
251
-		}
252
-
253
-		if jm.Progress != nil {
254
-			jm.Progress.terminalFd = terminalFd
255
-		}
256
-		if jm.ID != "" && (jm.Progress != nil || jm.ProgressMessage != "") {
257
-			line, ok := ids[jm.ID]
258
-			if !ok {
259
-				// NOTE: This approach of using len(id) to
260
-				// figure out the number of lines of history
261
-				// only works as long as we clear the history
262
-				// when we output something that's not
263
-				// accounted for in the map, such as a line
264
-				// with no ID.
265
-				line = uint(len(ids))
266
-				ids[jm.ID] = line
267
-				if isTerminal {
268
-					_, _ = fmt.Fprintf(out, "\n")
269
-				}
270
-			}
271
-			diff = uint(len(ids)) - line
272
-			if isTerminal {
273
-				cursorUp(out, diff)
274
-			}
275
-		} else {
276
-			// When outputting something that isn't progress
277
-			// output, clear the history of previous lines. We
278
-			// don't want progress entries from some previous
279
-			// operation to be updated (for example, pull -a
280
-			// with multiple tags).
281
-			ids = make(map[string]uint)
282
-		}
283
-		err := jm.Display(out, isTerminal)
284
-		if jm.ID != "" && isTerminal {
285
-			cursorDown(out, diff)
286
-		}
287
-		if err != nil {
288
-			return err
289
-		}
290
-	}
291
-	return nil
292
-}
293
-
294
-// Stream is an io.Writer for output with utilities to get the output's file
295
-// descriptor and to detect whether it's a terminal.
296
-//
297
-// it is subset of the streams.Out type in
298
-// https://pkg.go.dev/github.com/docker/cli@v20.10.17+incompatible/cli/streams#Out
299
-type Stream interface {
300
-	io.Writer
301
-	FD() uintptr
302
-	IsTerminal() bool
303
-}
304
-
305
-// DisplayJSONMessagesToStream prints json messages to the output Stream. It is
306
-// used by the Docker CLI to print JSONMessage streams.
307
-func DisplayJSONMessagesToStream(in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
308
-	return DisplayJSONMessagesStream(in, stream, stream.FD(), stream.IsTerminal(), auxCallback)
309
-}
310 1
deleted file mode 100644
... ...
@@ -1,290 +0,0 @@
1
-package jsonmessage
2
-
3
-import (
4
-	"bytes"
5
-	"fmt"
6
-	"strings"
7
-	"testing"
8
-	"time"
9
-
10
-	"github.com/moby/moby/api/types/jsonstream"
11
-	"github.com/moby/term"
12
-	"gotest.tools/v3/assert"
13
-	is "gotest.tools/v3/assert/cmp"
14
-)
15
-
16
-func TestProgressString(t *testing.T) {
17
-	type expected struct {
18
-		short string
19
-		long  string
20
-	}
21
-
22
-	shortAndLong := func(short, long string) expected {
23
-		return expected{short: short, long: long}
24
-	}
25
-
26
-	start := time.Date(2017, 12, 3, 15, 10, 1, 0, time.UTC)
27
-	timeAfter := func(delta time.Duration) func() time.Time {
28
-		return func() time.Time {
29
-			return start.Add(delta)
30
-		}
31
-	}
32
-
33
-	testcases := []struct {
34
-		name     string
35
-		progress JSONProgress
36
-		expected expected
37
-	}{
38
-		{
39
-			name: "no progress",
40
-		},
41
-		{
42
-			name:     "progress 1",
43
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 1}},
44
-			expected: shortAndLong("      1B", "      1B"),
45
-		},
46
-		{
47
-			name: "some progress with a start time",
48
-			progress: JSONProgress{
49
-				Progress: jsonstream.Progress{
50
-					Current: 20,
51
-					Total:   100,
52
-					Start:   start.Unix(),
53
-				},
54
-				nowFunc: timeAfter(time.Second),
55
-			},
56
-			expected: shortAndLong(
57
-				"     20B/100B 4s",
58
-				"[==========>                                        ]      20B/100B 4s",
59
-			),
60
-		},
61
-		{
62
-			name:     "some progress without a start time",
63
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100}},
64
-			expected: shortAndLong(
65
-				"     50B/100B",
66
-				"[=========================>                         ]      50B/100B",
67
-			),
68
-		},
69
-		{
70
-			name:     "current more than total is not negative gh#7136",
71
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 40}},
72
-			expected: shortAndLong(
73
-				"     50B",
74
-				"[==================================================>]      50B",
75
-			),
76
-		},
77
-		{
78
-			name:     "with units",
79
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100, Units: "units"}},
80
-			expected: shortAndLong(
81
-				"50/100 units",
82
-				"[=========================>                         ] 50/100 units",
83
-			),
84
-		},
85
-		{
86
-			name:     "current more than total with units is not negative ",
87
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 40, Units: "units"}},
88
-			expected: shortAndLong(
89
-				"50 units",
90
-				"[==================================================>] 50 units",
91
-			),
92
-		},
93
-		{
94
-			name:     "hide counts",
95
-			progress: JSONProgress{Progress: jsonstream.Progress{Current: 50, Total: 100, HideCounts: true}},
96
-			expected: shortAndLong(
97
-				"",
98
-				"[=========================>                         ] ",
99
-			),
100
-		},
101
-	}
102
-
103
-	for _, testcase := range testcases {
104
-		t.Run(testcase.name, func(t *testing.T) {
105
-			testcase.progress.winSize = 100
106
-			assert.Equal(t, testcase.progress.String(), testcase.expected.short)
107
-
108
-			testcase.progress.winSize = 200
109
-			assert.Equal(t, testcase.progress.String(), testcase.expected.long)
110
-		})
111
-	}
112
-}
113
-
114
-func TestJSONMessageDisplay(t *testing.T) {
115
-	now := time.Now()
116
-	messages := map[JSONMessage][]string{
117
-		// Empty
118
-		{}: {"\n", "\n"},
119
-		// Status
120
-		{
121
-			Status: "status",
122
-		}: {
123
-			"status\n",
124
-			"status\n",
125
-		},
126
-		// General
127
-		{
128
-			Time:   now.Unix(),
129
-			ID:     "ID",
130
-			From:   "From",
131
-			Status: "status",
132
-		}: {
133
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(RFC3339NanoFixed)),
134
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(now.Unix(), 0).Format(RFC3339NanoFixed)),
135
-		},
136
-		// General, with nano precision time
137
-		{
138
-			TimeNano: now.UnixNano(),
139
-			ID:       "ID",
140
-			From:     "From",
141
-			Status:   "status",
142
-		}: {
143
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
144
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
145
-		},
146
-		// General, with both times Nano is preferred
147
-		{
148
-			Time:     now.Unix(),
149
-			TimeNano: now.UnixNano(),
150
-			ID:       "ID",
151
-			From:     "From",
152
-			Status:   "status",
153
-		}: {
154
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
155
-			fmt.Sprintf("%v ID: (from From) status\n", time.Unix(0, now.UnixNano()).Format(RFC3339NanoFixed)),
156
-		},
157
-		// Stream over status
158
-		{
159
-			Status: "status",
160
-			Stream: "stream",
161
-		}: {
162
-			"stream",
163
-			"stream",
164
-		},
165
-		// With progress message
166
-		{
167
-			Status:          "status",
168
-			ProgressMessage: "progressMessage",
169
-		}: {
170
-			"status progressMessage",
171
-			"status progressMessage",
172
-		},
173
-		// With progress, stream empty
174
-		{
175
-			Status:   "status",
176
-			Stream:   "",
177
-			Progress: &JSONProgress{Progress: jsonstream.Progress{Current: 1}},
178
-		}: {
179
-			"",
180
-			fmt.Sprintf("%c[2K\rstatus       1B\r", 27),
181
-		},
182
-	}
183
-
184
-	// The tests :)
185
-	for jsonMessage, expectedMessages := range messages {
186
-		// Without terminal
187
-		data := bytes.NewBuffer([]byte{})
188
-		if err := jsonMessage.Display(data, false); err != nil {
189
-			t.Fatal(err)
190
-		}
191
-		if data.String() != expectedMessages[0] {
192
-			t.Fatalf("Expected %q,got %q", expectedMessages[0], data.String())
193
-		}
194
-		// With terminal
195
-		data = bytes.NewBuffer([]byte{})
196
-		if err := jsonMessage.Display(data, true); err != nil {
197
-			t.Fatal(err)
198
-		}
199
-		if data.String() != expectedMessages[1] {
200
-			t.Fatalf("\nExpected %q\n     got %q", expectedMessages[1], data.String())
201
-		}
202
-	}
203
-}
204
-
205
-// Test JSONMessage with an Error. It will return an error with the text as error, not the meaning of the HTTP code.
206
-func TestJSONMessageDisplayWithJSONError(t *testing.T) {
207
-	data := bytes.NewBuffer([]byte{})
208
-	jsonMessage := JSONMessage{Error: &jsonstream.Error{Code: 404, Message: "Can't find it"}}
209
-
210
-	err := jsonMessage.Display(data, true)
211
-	if err == nil || err.Error() != "Can't find it" {
212
-		t.Fatalf("Expected a jsonstream.Error 404, got %q", err)
213
-	}
214
-
215
-	jsonMessage = JSONMessage{Error: &jsonstream.Error{Code: 401, Message: "Anything"}}
216
-	err = jsonMessage.Display(data, true)
217
-	assert.Check(t, is.Error(err, "Anything"))
218
-}
219
-
220
-func TestDisplayJSONMessagesStreamInvalidJSON(t *testing.T) {
221
-	var inFd uintptr
222
-	data := bytes.NewBuffer([]byte{})
223
-	reader := strings.NewReader("This is not a 'valid' JSON []")
224
-	inFd, _ = term.GetFdInfo(reader)
225
-
226
-	exp := "invalid character "
227
-	if err := DisplayJSONMessagesStream(reader, data, inFd, false, nil); err == nil || !strings.HasPrefix(err.Error(), exp) {
228
-		t.Fatalf("Expected error (%s...), got %q", exp, err)
229
-	}
230
-}
231
-
232
-func TestDisplayJSONMessagesStream(t *testing.T) {
233
-	var inFd uintptr
234
-
235
-	messages := map[string][]string{
236
-		// empty string
237
-		"": {
238
-			"",
239
-			"",
240
-		},
241
-		// Without progress & ID
242
-		`{ "status": "status" }`: {
243
-			"status\n",
244
-			"status\n",
245
-		},
246
-		// Without progress, with ID
247
-		`{ "id": "ID","status": "status" }`: {
248
-			"ID: status\n",
249
-			"ID: status\n",
250
-		},
251
-		// With progress
252
-		`{ "id": "ID", "status": "status", "progress": "ProgressMessage" }`: {
253
-			"ID: status ProgressMessage",
254
-			fmt.Sprintf("\n%c[%dAID: status ProgressMessage%c[%dB", 27, 1, 27, 1),
255
-		},
256
-		// With progressDetail
257
-		`{ "id": "ID", "status": "status", "progressDetail": { "Current": 1} }`: {
258
-			"", // progressbar is disabled in non-terminal
259
-			fmt.Sprintf("\n%c[%dA%c[2K\rID: status       1B\r%c[%dB", 27, 1, 27, 27, 1),
260
-		},
261
-	}
262
-
263
-	// Use $TERM which is unlikely to exist, forcing DisplayJSONMessageStream to
264
-	// (hopefully) use &noTermInfo.
265
-	t.Setenv("TERM", "xyzzy-non-existent-terminfo")
266
-
267
-	for jsonMessage, expectedMessages := range messages {
268
-		data := bytes.NewBuffer([]byte{})
269
-		reader := strings.NewReader(jsonMessage)
270
-		inFd, _ = term.GetFdInfo(reader)
271
-
272
-		// Without terminal
273
-		if err := DisplayJSONMessagesStream(reader, data, inFd, false, nil); err != nil {
274
-			t.Fatal(err)
275
-		}
276
-		if data.String() != expectedMessages[0] {
277
-			t.Fatalf("Expected an %q, got %q", expectedMessages[0], data.String())
278
-		}
279
-
280
-		// With terminal
281
-		data = bytes.NewBuffer([]byte{})
282
-		reader = strings.NewReader(jsonMessage)
283
-		if err := DisplayJSONMessagesStream(reader, data, inFd, true, nil); err != nil {
284
-			t.Fatal(err)
285
-		}
286
-		if data.String() != expectedMessages[1] {
287
-			t.Fatalf("\nExpected %q\n     got %q", expectedMessages[1], data.String())
288
-		}
289
-	}
290
-}
... ...
@@ -10,9 +10,9 @@ import (
10 10
 	"strings"
11 11
 	"sync"
12 12
 
13
-	"github.com/docker/docker/pkg/jsonmessage"
14 13
 	"github.com/moby/moby/api/types/image"
15 14
 	"github.com/moby/moby/client"
15
+	"github.com/moby/moby/client/pkg/jsonmessage"
16 16
 	"github.com/moby/term"
17 17
 	"github.com/pkg/errors"
18 18
 	"go.opentelemetry.io/otel"
19 19
new file mode 100644
... ...
@@ -0,0 +1,309 @@
0
+package jsonmessage
1
+
2
+import (
3
+	"encoding/json"
4
+	"fmt"
5
+	"io"
6
+	"strings"
7
+	"time"
8
+
9
+	"github.com/docker/go-units"
10
+	"github.com/moby/moby/api/types/jsonstream"
11
+	"github.com/moby/term"
12
+)
13
+
14
+// RFC3339NanoFixed is time.RFC3339Nano with nanoseconds padded using zeros to
15
+// ensure the formatted time isalways the same number of characters.
16
+const RFC3339NanoFixed = "2006-01-02T15:04:05.000000000Z07:00"
17
+
18
+// JSONProgress describes a progress message in a JSON stream.
19
+type JSONProgress struct {
20
+	jsonstream.Progress
21
+
22
+	// terminalFd is the fd of the current terminal, if any. It is used
23
+	// to get the terminal width.
24
+	terminalFd uintptr
25
+
26
+	// nowFunc is used to override the current time in tests.
27
+	nowFunc func() time.Time
28
+
29
+	// winSize is used to override the terminal width in tests.
30
+	winSize int
31
+}
32
+
33
+func (p *JSONProgress) String() string {
34
+	var (
35
+		width      = p.width()
36
+		pbBox      string
37
+		numbersBox string
38
+	)
39
+	if p.Current <= 0 && p.Total <= 0 {
40
+		return ""
41
+	}
42
+	if p.Total <= 0 {
43
+		switch p.Units {
44
+		case "":
45
+			return fmt.Sprintf("%8v", units.HumanSize(float64(p.Current)))
46
+		default:
47
+			return fmt.Sprintf("%d %s", p.Current, p.Units)
48
+		}
49
+	}
50
+
51
+	percentage := int(float64(p.Current)/float64(p.Total)*100) / 2
52
+	if percentage > 50 {
53
+		percentage = 50
54
+	}
55
+	if width > 110 {
56
+		// this number can't be negative gh#7136
57
+		numSpaces := 0
58
+		if 50-percentage > 0 {
59
+			numSpaces = 50 - percentage
60
+		}
61
+		pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces))
62
+	}
63
+
64
+	switch {
65
+	case p.HideCounts:
66
+	case p.Units == "": // no units, use bytes
67
+		current := units.HumanSize(float64(p.Current))
68
+		total := units.HumanSize(float64(p.Total))
69
+
70
+		numbersBox = fmt.Sprintf("%8v/%v", current, total)
71
+
72
+		if p.Current > p.Total {
73
+			// remove total display if the reported current is wonky.
74
+			numbersBox = fmt.Sprintf("%8v", current)
75
+		}
76
+	default:
77
+		numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units)
78
+
79
+		if p.Current > p.Total {
80
+			// remove total display if the reported current is wonky.
81
+			numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units)
82
+		}
83
+	}
84
+
85
+	// Show approximation of remaining time if there's enough width.
86
+	var timeLeftBox string
87
+	if width > 50 {
88
+		if p.Current > 0 && p.Start > 0 && percentage < 50 {
89
+			fromStart := p.now().Sub(time.Unix(p.Start, 0))
90
+			perEntry := fromStart / time.Duration(p.Current)
91
+			left := time.Duration(p.Total-p.Current) * perEntry
92
+			timeLeftBox = " " + left.Round(time.Second).String()
93
+		}
94
+	}
95
+	return pbBox + numbersBox + timeLeftBox
96
+}
97
+
98
+// now returns the current time in UTC, but can be overridden in tests
99
+// by setting JSONProgress.nowFunc to a custom function.
100
+func (p *JSONProgress) now() time.Time {
101
+	if p.nowFunc != nil {
102
+		return p.nowFunc()
103
+	}
104
+	return time.Now().UTC()
105
+}
106
+
107
+// width returns the current terminal's width, but can be overridden
108
+// in tests by setting JSONProgress.winSize to a non-zero value.
109
+func (p *JSONProgress) width() int {
110
+	if p.winSize != 0 {
111
+		return p.winSize
112
+	}
113
+	ws, err := term.GetWinsize(p.terminalFd)
114
+	if err == nil {
115
+		return int(ws.Width)
116
+	}
117
+	return 200
118
+}
119
+
120
+// JSONMessage defines a message struct. It describes
121
+// the created time, where it from, status, ID of the
122
+// message. It's used for docker events.
123
+type JSONMessage struct {
124
+	Stream   string        `json:"stream,omitempty"`
125
+	Status   string        `json:"status,omitempty"`
126
+	Progress *JSONProgress `json:"progressDetail,omitempty"`
127
+
128
+	// ProgressMessage is a pre-formatted presentation of [Progress].
129
+	//
130
+	// Deprecated: this field is deprecated since docker v0.7.1 / API v1.8. Use the information in [Progress] instead. This field will be omitted in a future release.
131
+	ProgressMessage string            `json:"progress,omitempty"`
132
+	ID              string            `json:"id,omitempty"`
133
+	From            string            `json:"from,omitempty"`
134
+	Time            int64             `json:"time,omitempty"`
135
+	TimeNano        int64             `json:"timeNano,omitempty"`
136
+	Error           *jsonstream.Error `json:"errorDetail,omitempty"`
137
+
138
+	// ErrorMessage contains errors encountered during the operation.
139
+	//
140
+	// Deprecated: this field is deprecated since docker v0.6.0 / API v1.4. Use [Error.Message] instead. This field will be omitted in a future release.
141
+	ErrorMessage string `json:"error,omitempty"` // deprecated
142
+	// Aux contains out-of-band data, such as digests for push signing and image id after building.
143
+	Aux *json.RawMessage `json:"aux,omitempty"`
144
+}
145
+
146
+// We can probably use [aec.EmptyBuilder] for managing the output, but
147
+// currently we're doing it all manually, so defining some consts for
148
+// the basics we use.
149
+//
150
+// [aec.EmptyBuilder]: https://pkg.go.dev/github.com/morikuni/aec#EmptyBuilder
151
+const (
152
+	ansiEraseLine     = "\x1b[2K"  // Erase entire line
153
+	ansiCursorUpFmt   = "\x1b[%dA" // Move cursor up N lines
154
+	ansiCursorDownFmt = "\x1b[%dB" // Move cursor down N lines
155
+)
156
+
157
+func clearLine(out io.Writer) {
158
+	_, _ = out.Write([]byte(ansiEraseLine))
159
+}
160
+
161
+func cursorUp(out io.Writer, l uint) {
162
+	if l == 0 {
163
+		return
164
+	}
165
+	_, _ = fmt.Fprintf(out, ansiCursorUpFmt, l)
166
+}
167
+
168
+func cursorDown(out io.Writer, l uint) {
169
+	if l == 0 {
170
+		return
171
+	}
172
+	_, _ = fmt.Fprintf(out, ansiCursorDownFmt, l)
173
+}
174
+
175
+// Display prints the JSONMessage to out. If isTerminal is true, it erases
176
+// the entire current line when displaying the progressbar. It returns an
177
+// error if the [JSONMessage.Error] field is non-nil.
178
+func (jm *JSONMessage) Display(out io.Writer, isTerminal bool) error {
179
+	if jm.Error != nil {
180
+		return jm.Error
181
+	}
182
+	var endl string
183
+	if isTerminal && jm.Stream == "" && jm.Progress != nil {
184
+		clearLine(out)
185
+		endl = "\r"
186
+		_, _ = fmt.Fprint(out, endl)
187
+	} else if jm.Progress != nil && jm.Progress.String() != "" { // disable progressbar in non-terminal
188
+		return nil
189
+	}
190
+	if jm.TimeNano != 0 {
191
+		_, _ = fmt.Fprintf(out, "%s ", time.Unix(0, jm.TimeNano).Format(RFC3339NanoFixed))
192
+	} else if jm.Time != 0 {
193
+		_, _ = fmt.Fprintf(out, "%s ", time.Unix(jm.Time, 0).Format(RFC3339NanoFixed))
194
+	}
195
+	if jm.ID != "" {
196
+		_, _ = fmt.Fprintf(out, "%s: ", jm.ID)
197
+	}
198
+	if jm.From != "" {
199
+		_, _ = fmt.Fprintf(out, "(from %s) ", jm.From)
200
+	}
201
+	if jm.Progress != nil && isTerminal {
202
+		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.Progress.String(), endl)
203
+	} else if jm.ProgressMessage != "" { // deprecated
204
+		_, _ = fmt.Fprintf(out, "%s %s%s", jm.Status, jm.ProgressMessage, endl)
205
+	} else if jm.Stream != "" {
206
+		_, _ = fmt.Fprintf(out, "%s%s", jm.Stream, endl)
207
+	} else {
208
+		_, _ = fmt.Fprintf(out, "%s%s\n", jm.Status, endl)
209
+	}
210
+	return nil
211
+}
212
+
213
+// DisplayJSONMessagesStream reads a JSON message stream from in, and writes
214
+// each [JSONMessage] to out. It returns an error if an invalid JSONMessage
215
+// is received, or if a JSONMessage containers a non-zero [JSONMessage.Error].
216
+//
217
+// Presentation of the JSONMessage depends on whether a terminal is attached,
218
+// and on the terminal width. Progress bars ([JSONProgress]) are suppressed
219
+// on narrower terminals (< 110 characters).
220
+//
221
+//   - isTerminal describes if out is a terminal, in which case it prints
222
+//     a newline ("\n") at the end of each line and moves the cursor while
223
+//     displaying.
224
+//   - terminalFd is the fd of the current terminal (if any), and used
225
+//     to get the terminal width.
226
+//   - auxCallback allows handling the [JSONMessage.Aux] field. It is
227
+//     called if a JSONMessage contains an Aux field, in which case
228
+//     DisplayJSONMessagesStream does not present the JSONMessage.
229
+func DisplayJSONMessagesStream(in io.Reader, out io.Writer, terminalFd uintptr, isTerminal bool, auxCallback func(JSONMessage)) error {
230
+	var (
231
+		dec = json.NewDecoder(in)
232
+		ids = make(map[string]uint)
233
+	)
234
+
235
+	for {
236
+		var diff uint
237
+		var jm JSONMessage
238
+		if err := dec.Decode(&jm); err != nil {
239
+			if err == io.EOF {
240
+				break
241
+			}
242
+			return err
243
+		}
244
+
245
+		if jm.Aux != nil {
246
+			if auxCallback != nil {
247
+				auxCallback(jm)
248
+			}
249
+			continue
250
+		}
251
+
252
+		if jm.Progress != nil {
253
+			jm.Progress.terminalFd = terminalFd
254
+		}
255
+		if jm.ID != "" && (jm.Progress != nil || jm.ProgressMessage != "") {
256
+			line, ok := ids[jm.ID]
257
+			if !ok {
258
+				// NOTE: This approach of using len(id) to
259
+				// figure out the number of lines of history
260
+				// only works as long as we clear the history
261
+				// when we output something that's not
262
+				// accounted for in the map, such as a line
263
+				// with no ID.
264
+				line = uint(len(ids))
265
+				ids[jm.ID] = line
266
+				if isTerminal {
267
+					_, _ = fmt.Fprintf(out, "\n")
268
+				}
269
+			}
270
+			diff = uint(len(ids)) - line
271
+			if isTerminal {
272
+				cursorUp(out, diff)
273
+			}
274
+		} else {
275
+			// When outputting something that isn't progress
276
+			// output, clear the history of previous lines. We
277
+			// don't want progress entries from some previous
278
+			// operation to be updated (for example, pull -a
279
+			// with multiple tags).
280
+			ids = make(map[string]uint)
281
+		}
282
+		err := jm.Display(out, isTerminal)
283
+		if jm.ID != "" && isTerminal {
284
+			cursorDown(out, diff)
285
+		}
286
+		if err != nil {
287
+			return err
288
+		}
289
+	}
290
+	return nil
291
+}
292
+
293
+// Stream is an io.Writer for output with utilities to get the output's file
294
+// descriptor and to detect whether it's a terminal.
295
+//
296
+// it is subset of the streams.Out type in
297
+// https://pkg.go.dev/github.com/docker/cli@v20.10.17+incompatible/cli/streams#Out
298
+type Stream interface {
299
+	io.Writer
300
+	FD() uintptr
301
+	IsTerminal() bool
302
+}
303
+
304
+// DisplayJSONMessagesToStream prints json messages to the output Stream. It is
305
+// used by the Docker CLI to print JSONMessage streams.
306
+func DisplayJSONMessagesToStream(in io.Reader, stream Stream, auxCallback func(JSONMessage)) error {
307
+	return DisplayJSONMessagesStream(in, stream, stream.FD(), stream.IsTerminal(), auxCallback)
308
+}
... ...
@@ -966,6 +966,7 @@ github.com/moby/moby/api/types/volume
966 966
 # github.com/moby/moby/client v0.0.0 => ./client
967 967
 ## explicit; go 1.23.0
968 968
 github.com/moby/moby/client
969
+github.com/moby/moby/client/pkg/jsonmessage
969 970
 github.com/moby/moby/client/pkg/stringid
970 971
 # github.com/moby/patternmatcher v0.6.0
971 972
 ## explicit; go 1.19