Skip to content

Commit d050c75

Browse files
authored
Merge pull request #400 from depot/billy/feat/depot-cli-img
feat: add image list and rm commands
2 parents 53902f0 + 1619ec4 commit d050c75

File tree

8 files changed

+1122
-0
lines changed

8 files changed

+1122
-0
lines changed

pkg/api/rpc.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"buf.build/gen/go/depot/api/connectrpc/go/depot/core/v1/corev1connect"
1111
"connectrpc.com/connect"
1212
"github.com/depot/cli/pkg/proto/depot/agent/v1/agentv1connect"
13+
"github.com/depot/cli/pkg/proto/depot/build/v1/buildv1connect"
1314
"github.com/depot/cli/pkg/proto/depot/cli/v1/cliv1connect"
1415
"github.com/depot/cli/pkg/proto/depot/cli/v1beta1/cliv1beta1connect"
1516
cliCorev1connect "github.com/depot/cli/pkg/proto/depot/core/v1/corev1connect"
@@ -52,6 +53,10 @@ func NewSandboxClient() agentv1connect.SandboxServiceClient {
5253
return agentv1connect.NewSandboxServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
5354
}
5455

56+
func NewRegistryClient() buildv1connect.RegistryServiceClient {
57+
return buildv1connect.NewRegistryServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent())
58+
}
59+
5560
func WithAuthentication[T any](req *connect.Request[T], token string) *connect.Request[T] {
5661
req.Header().Add("Authorization", "Bearer "+token)
5762
return req

pkg/cmd/image/image.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package image
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func NewCmdImage() *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "image",
12+
Short: "Manage container images in the registry",
13+
RunE: func(cmd *cobra.Command, args []string) error {
14+
return fmt.Errorf("missing subcommand, please run `depot image --help`")
15+
},
16+
}
17+
18+
cmd.AddCommand(NewCmdList())
19+
cmd.AddCommand(NewCmdRM())
20+
21+
return cmd
22+
}

pkg/cmd/image/list.go

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
package image
2+
3+
import (
4+
"context"
5+
"encoding/csv"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"sort"
10+
"time"
11+
12+
"connectrpc.com/connect"
13+
"github.com/charmbracelet/bubbles/table"
14+
tea "github.com/charmbracelet/bubbletea"
15+
"github.com/charmbracelet/lipgloss"
16+
"github.com/depot/cli/pkg/api"
17+
"github.com/depot/cli/pkg/helpers"
18+
v1 "github.com/depot/cli/pkg/proto/depot/build/v1"
19+
"github.com/depot/cli/pkg/proto/depot/build/v1/buildv1connect"
20+
"github.com/pkg/errors"
21+
"github.com/spf13/cobra"
22+
)
23+
24+
func NewCmdList() *cobra.Command {
25+
var projectID string
26+
var token string
27+
var outputFormat string
28+
29+
cmd := &cobra.Command{
30+
Use: "list",
31+
Aliases: []string{"ls"},
32+
Short: "List images in the registry",
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
cwd, _ := os.Getwd()
35+
resolvedProjectID := helpers.ResolveProjectID(projectID, cwd)
36+
if resolvedProjectID == "" {
37+
return errors.Errorf("unknown project ID (run `depot init` or use --project or $DEPOT_PROJECT_ID)")
38+
}
39+
40+
token, err := helpers.ResolveProjectAuth(context.Background(), token)
41+
if err != nil {
42+
return err
43+
}
44+
45+
if token == "" {
46+
return fmt.Errorf("missing API token, please run `depot login`")
47+
}
48+
49+
client := api.NewRegistryClient()
50+
51+
// Auto-detect CSV output for non-terminal
52+
if !helpers.IsTerminal() && outputFormat == "" {
53+
outputFormat = "csv"
54+
}
55+
56+
if outputFormat != "" {
57+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
58+
defer cancel()
59+
60+
images, err := fetchAllImages(ctx, resolvedProjectID, token, client)
61+
if err != nil {
62+
return err
63+
}
64+
65+
if len(images) == 0 {
66+
fmt.Println("No images found")
67+
return nil
68+
}
69+
70+
switch outputFormat {
71+
case "csv":
72+
return images.WriteCSV()
73+
case "json":
74+
return images.WriteJSON()
75+
default:
76+
return errors.Errorf("unknown format: %s. Requires csv or json", outputFormat)
77+
}
78+
}
79+
80+
// Interactive table view
81+
columns := []table.Column{
82+
{Title: "Tag", Width: 50},
83+
{Title: "Size", Width: 15},
84+
{Title: "Pushed", Width: 20},
85+
{Title: "Digest", Width: 30},
86+
}
87+
88+
styles := table.DefaultStyles()
89+
styles.Header = styles.Header.
90+
BorderStyle(lipgloss.NormalBorder()).
91+
BorderForeground(lipgloss.Color("240")).
92+
BorderBottom(true).
93+
Bold(false)
94+
95+
styles.Selected = styles.Selected.
96+
Foreground(lipgloss.Color("229")).
97+
Background(lipgloss.Color("57")).
98+
Bold(false)
99+
100+
tbl := table.New(
101+
table.WithColumns(columns),
102+
table.WithFocused(true),
103+
table.WithStyles(styles),
104+
)
105+
106+
m := imagesModel{
107+
client: client,
108+
imagesTable: tbl,
109+
columns: columns,
110+
projectID: resolvedProjectID,
111+
token: token,
112+
}
113+
114+
_, err = tea.NewProgram(m, tea.WithAltScreen()).Run()
115+
return err
116+
},
117+
}
118+
119+
flags := cmd.Flags()
120+
flags.StringVar(&projectID, "project", "", "Depot project ID")
121+
flags.StringVar(&token, "token", "", "Depot token")
122+
flags.StringVar(&outputFormat, "output", "", "Non-interactive output format (json, csv)")
123+
124+
return cmd
125+
}
126+
127+
type DepotImage struct {
128+
Tag string `json:"tag"`
129+
Digest string `json:"digest"`
130+
SizeBytes uint64 `json:"size_bytes"`
131+
PushedAt *time.Time `json:"pushed_at,omitempty"`
132+
}
133+
134+
type DepotImages []DepotImage
135+
136+
func fetchAllImages(ctx context.Context, projectID, token string, client buildv1connect.RegistryServiceClient) (DepotImages, error) {
137+
var allImages DepotImages
138+
var pageToken string
139+
140+
for {
141+
pageSize := int32(100)
142+
req := connect.NewRequest(&v1.ListImagesRequest{
143+
ProjectId: projectID,
144+
PageSize: &pageSize,
145+
})
146+
if pageToken != "" {
147+
req.Msg.PageToken = &pageToken
148+
}
149+
150+
req = api.WithAuthentication(req, token)
151+
resp, err := client.ListImages(ctx, req)
152+
if err != nil {
153+
return nil, fmt.Errorf("failed to list images: %w", err)
154+
}
155+
156+
for _, img := range resp.Msg.Images {
157+
var pushedAt *time.Time
158+
if img.PushedAt != nil {
159+
t := img.PushedAt.AsTime()
160+
pushedAt = &t
161+
}
162+
allImages = append(allImages, DepotImage{
163+
Tag: img.Tag,
164+
Digest: img.Digest,
165+
SizeBytes: img.SizeBytes,
166+
PushedAt: pushedAt,
167+
})
168+
}
169+
170+
if resp.Msg.NextPageToken == nil || *resp.Msg.NextPageToken == "" {
171+
break
172+
}
173+
pageToken = *resp.Msg.NextPageToken
174+
}
175+
176+
// Sort images by pushedAt timestamp, newest first
177+
sort.Slice(allImages, func(i, j int) bool {
178+
// Handle nil timestamps - put images without timestamps at the end
179+
if allImages[i].PushedAt == nil && allImages[j].PushedAt == nil {
180+
return false
181+
}
182+
if allImages[i].PushedAt == nil {
183+
return false
184+
}
185+
if allImages[j].PushedAt == nil {
186+
return true
187+
}
188+
// Sort by newest first
189+
return allImages[i].PushedAt.After(*allImages[j].PushedAt)
190+
})
191+
192+
return allImages, nil
193+
}
194+
195+
func (images DepotImages) WriteCSV() error {
196+
w := csv.NewWriter(os.Stdout)
197+
if len(images) > 0 {
198+
if err := w.Write([]string{"Tag", "Digest", "Size (bytes)", "Pushed At"}); err != nil {
199+
return err
200+
}
201+
}
202+
203+
for _, img := range images {
204+
var pushedAt string
205+
if img.PushedAt != nil {
206+
pushedAt = img.PushedAt.Format(time.RFC3339)
207+
} else {
208+
pushedAt = ""
209+
}
210+
211+
row := []string{img.Tag, img.Digest, fmt.Sprintf("%d", img.SizeBytes), pushedAt}
212+
if err := w.Write(row); err != nil {
213+
return err
214+
}
215+
}
216+
217+
w.Flush()
218+
return w.Error()
219+
}
220+
221+
// WriteJSON outputs images in JSON format
222+
func (images DepotImages) WriteJSON() error {
223+
enc := json.NewEncoder(os.Stdout)
224+
enc.SetIndent("", " ")
225+
return enc.Encode(images)
226+
}
227+
228+
// Bubbletea model for interactive image list
229+
type imagesModel struct {
230+
client buildv1connect.RegistryServiceClient
231+
imagesTable table.Model
232+
columns []table.Column
233+
projectID string
234+
token string
235+
err error
236+
}
237+
238+
func (m imagesModel) Init() tea.Cmd {
239+
return m.loadImages()
240+
}
241+
242+
func (m imagesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
243+
var cmd tea.Cmd
244+
switch msg := msg.(type) {
245+
case tea.KeyMsg:
246+
if msg.Type == tea.KeyCtrlC || msg.Type == tea.KeyEsc {
247+
return m, tea.Quit
248+
}
249+
250+
if msg.String() == "q" {
251+
return m, tea.Quit
252+
}
253+
254+
if msg.String() == "r" {
255+
return m, m.loadImages()
256+
}
257+
258+
case tea.WindowSizeMsg:
259+
m.resizeTable(msg)
260+
261+
case imageRows:
262+
m.err = nil
263+
m.imagesTable.SetRows(msg)
264+
265+
case errMsg:
266+
m.err = msg.error
267+
}
268+
269+
m.imagesTable, cmd = m.imagesTable.Update(msg)
270+
return m, cmd
271+
}
272+
273+
func (m *imagesModel) resizeTable(msg tea.WindowSizeMsg) {
274+
h, v := baseStyle.GetFrameSize()
275+
m.imagesTable.SetHeight(msg.Height - v - 3)
276+
m.imagesTable.SetWidth(msg.Width - h)
277+
278+
colWidth := 0
279+
for _, col := range m.columns {
280+
colWidth += col.Width
281+
}
282+
283+
remainingWidth := msg.Width - colWidth
284+
if remainingWidth > 0 {
285+
m.columns[len(m.columns)-1].Width += remainingWidth - h - 4
286+
m.imagesTable.SetColumns(m.columns)
287+
}
288+
}
289+
290+
func (m imagesModel) View() string {
291+
s := baseStyle.Render(m.imagesTable.View()) + "\n"
292+
if m.err != nil {
293+
s = "Error: " + m.err.Error() + "\n"
294+
}
295+
return s
296+
}
297+
298+
type imageRows []table.Row
299+
type errMsg struct{ error }
300+
301+
func (m imagesModel) loadImages() tea.Cmd {
302+
return func() tea.Msg {
303+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
304+
defer cancel()
305+
306+
images, err := fetchAllImages(ctx, m.projectID, m.token, m.client)
307+
if err != nil {
308+
return errMsg{err}
309+
}
310+
311+
rows := []table.Row{}
312+
for _, img := range images {
313+
tag := img.Tag
314+
if len(tag) > 50 {
315+
tag = tag[:47] + "..."
316+
}
317+
318+
size := formatSize(img.SizeBytes)
319+
320+
var pushedStr string
321+
if img.PushedAt != nil {
322+
pushedStr = img.PushedAt.Format(time.RFC3339)
323+
} else {
324+
pushedStr = "-"
325+
}
326+
327+
digest := img.Digest
328+
if len(digest) > 30 {
329+
digest = digest[:27] + "..."
330+
}
331+
332+
rows = append(rows, table.Row{tag, size, pushedStr, digest})
333+
}
334+
335+
return imageRows(rows)
336+
}
337+
}
338+
339+
var baseStyle = lipgloss.NewStyle().
340+
BorderStyle(lipgloss.NormalBorder()).
341+
BorderForeground(lipgloss.Color("240"))
342+
343+
func formatSize(bytes uint64) string {
344+
const unit = 1024
345+
if bytes < unit {
346+
return fmt.Sprintf("%d B", bytes)
347+
}
348+
div, exp := uint64(unit), 0
349+
for n := bytes / unit; n >= unit; n /= unit {
350+
div *= unit
351+
exp++
352+
}
353+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
354+
}

0 commit comments

Comments
 (0)