feat: add basic cli and list command (#4)

* feat: add containermanager package and docker provider

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

* feat: add list command

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

* feat: add root command

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

* feat: add list command

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

* feat: wire-up root command in main

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

* refactor(docker): use format json

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>

---------

Signed-off-by: Fabrizio Sestito <fabrizio.sestito@suse.com>
This commit is contained in:
Fabrizio Sestito
2026-06-11 18:24:39 +02:00
committed by Alessio Biancalana
parent e18f7abec7
commit 1f5c3c3441
6 changed files with 319 additions and 2 deletions
+7 -2
View File
@@ -2,11 +2,16 @@ package main
import (
"context"
"log"
"os"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/internal/cli"
)
func main() {
(&cli.Command{}).Run(context.Background(), os.Args)
cmd := cli.NewRootCommand()
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package cli
import (
"context"
"fmt"
"os"
"github.com/89luca89/distrobox/pkg/commands"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/urfave/cli/v3"
)
const (
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorReset = "\033[0m"
)
func newListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List distroboxes",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "no-color",
Usage: "Disable color output",
},
},
Action: listAction,
}
}
func listAction(ctx context.Context, cmd *cli.Command) error {
containerManager, ok := ctx.Value(containerManagerKey).(containermanager.ContainerManager)
if !ok {
return fmt.Errorf("container manager not found in context")
}
listCmd := commands.NewListCommand(containerManager)
result, err := listCmd.Execute(ctx)
if err != nil {
return fmt.Errorf("failed to execute list command: %w", err)
}
noColor := cmd.Bool("no-color") || !isTerminal()
printResult(result, noColor)
return nil
}
func printResult(result *commands.ListResult, noColor bool) {
fmt.Printf("%-12s | %-20s | %-18s | %-30s\n",
"ID", "NAME", "STATUS", "IMAGE")
for _, c := range result.Containers {
if noColor {
fmt.Printf("%-12s | %-20s | %-18s | %-30s\n",
c.ID, c.Name, c.Status, c.Image)
} else {
color := colorYellow
if c.IsRunning() {
color = colorGreen
}
fmt.Printf("%s%-12s | %-20s | %-18s | %-30s%s\n",
color, c.ID, c.Name, c.Status, c.Image, colorReset)
}
}
}
func isTerminal() bool {
stat, _ := os.Stdout.Stat()
return (stat.Mode() & os.ModeCharDevice) != 0
}
+61
View File
@@ -0,0 +1,61 @@
package cli
import (
"context"
"github.com/urfave/cli/v3"
"github.com/89luca89/distrobox/pkg/containermanager"
"github.com/89luca89/distrobox/pkg/containermanager/providers"
)
type contextKey string
const containerManagerKey contextKey = "containerManager"
func NewRootCommand() *cli.Command {
return &cli.Command{
Name: "distrobox",
Version: "1.0.0",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "container-manager",
Usage: "",
Sources: cli.EnvVars("DBX_CONTAINER_MANAGER", "container_manager"),
Hidden: true,
},
&cli.StringFlag{
Name: "sudo-command",
Usage: "",
Sources: cli.EnvVars("DBX_SUDO_COMMAND", "sudo_command"),
Hidden: true,
Value: "sudo",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "show more verbosity",
Sources: cli.EnvVars("DBX_VERBOSE", "verbose"),
},
},
Before: beforeAction,
Commands: []*cli.Command{
newListCommand(),
},
}
}
func beforeAction(ctx context.Context, cmd *cli.Command) (context.Context, error) {
containerManagerType := cmd.String("container-manager")
verbose := cmd.Bool("verbose")
var containerManager containermanager.ContainerManager
switch containerManagerType {
case "docker":
containerManager = providers.NewDocker(verbose)
default:
containerManager = providers.NewDocker(verbose)
}
return context.WithValue(ctx, contextKey("containerManager"), containerManager), nil
}
+39
View File
@@ -0,0 +1,39 @@
package commands
import (
"context"
"fmt"
"github.com/89luca89/distrobox/pkg/containermanager"
)
type ListResult struct {
Containers []containermanager.Container
}
type ListCommand struct {
containerManager containermanager.ContainerManager
}
func NewListCommand(cm containermanager.ContainerManager) *ListCommand {
return &ListCommand{
containerManager: cm,
}
}
func (c *ListCommand) Execute(ctx context.Context) (*ListResult, error) {
containers, err := c.containerManager.ListContainers(ctx)
if err != nil {
return nil, fmt.Errorf("failed while listing contaiers: %w", err)
}
var distroboxes []containermanager.Container
for _, container := range containers {
if container.IsDistrobox() {
distroboxes = append(distroboxes, container)
}
}
return &ListResult{Containers: distroboxes}, nil
}
@@ -0,0 +1,30 @@
package containermanager
import (
"context"
"strings"
)
type Container struct {
ID string
Image string
Name string
Status string
Labels map[string]string
}
func (c Container) IsDistrobox() bool {
return c.Labels["manager"] == "distrobox"
}
func (c Container) IsRunning() bool {
s := strings.ToLower(c.Status)
return strings.Contains(s, "up") || strings.Contains(s, "running")
}
type ContanerManagerType string
type ContainerManager interface {
Name() string
ListContainers(ctx context.Context) ([]Container, error)
}
@@ -0,0 +1,108 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"github.com/89luca89/distrobox/pkg/containermanager"
)
type Docker struct {
verbose bool
}
var _ containermanager.ContainerManager = &Docker{}
func NewDocker(verbose bool) *Docker {
return &Docker{
verbose: verbose,
}
}
func (d *Docker) Name() string {
return "docker"
}
// dockerContainer represents the JSON output from `docker ps --format json`.
type dockerContainer struct {
ID string `json:"ID"`
Image string `json:"Image"`
Names string `json:"Names"`
Status string `json:"Status"`
Labels string `json:"Labels"`
}
func (d *Docker) ListContainers(ctx context.Context) ([]containermanager.Container, error) {
args := []string{"ps", "-a", "--no-trunc", "--format", "json"}
out, err := d.run(ctx, args)
if err != nil {
return nil, err
}
return parseContainerList(out)
}
func (d *Docker) run(ctx context.Context, args []string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
captured := strings.TrimSpace(stderr.String())
if captured != "" {
return "", fmt.Errorf("command execution failed: %s", captured)
}
return "", fmt.Errorf("command execution failed: %w", err)
}
return stdout.String(), nil
}
func parseContainerList(output string) ([]containermanager.Container, error) {
var containers []containermanager.Container
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
var dc dockerContainer
if err := json.Unmarshal([]byte(line), &dc); err != nil {
return nil, fmt.Errorf("failed to parse container JSON: %w", err)
}
id := dc.ID
if len(id) > 12 {
id = id[:12]
}
containers = append(containers, containermanager.Container{
ID: id,
Image: dc.Image,
Name: dc.Names,
Status: dc.Status,
Labels: parseLabels(dc.Labels),
})
}
return containers, nil
}
func parseLabels(labels string) map[string]string {
result := make(map[string]string)
if labels == "" {
return result
}
for label := range strings.SplitSeq(labels, ",") {
key, value, found := strings.Cut(label, "=")
if found {
result[key] = value
}
}
return result
}