package cli import ( "fmt" "os" "strconv" "strings" "text/tabwriter" "github.com/spf13/cobra" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform" ) func (a *App) listCommand() *cobra.Command { return &cobra.Command{ Use: "list", Aliases: []string{"ls", "ps"}, Short: "List Homarr instances without opening the application", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { containers, err := docker.List() if err != nil { return err } if len(containers) == 0 { fmt.Println("No Homarr instances found.") return nil } writer := newTable("NAME", "STATE", "PORT", "IMAGE", "STATUS") for _, container := range containers { row(writer, container.Name, container.State, container.HostPort(), container.Image, container.Status) } return writer.Flush() }, } } func (a *App) imagesCommand() *cobra.Command { images := &cobra.Command{ Use: "images", Aliases: []string{"image"}, Short: "List local Homarr images and their build provenance", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { found, err := docker.ListLocalImages(cmd.Context()) if err != nil { return err } if len(found) == 0 { fmt.Println("No local homarr images found. Build one with `homarr build `.") return nil } writer := newTable("IMAGE", "SIZE", "CREATED", "PR", "REVISION", "SOURCE") for _, image := range found { pr := "" if image.PRNumber > 0 { pr = fmt.Sprintf("#%d", image.PRNumber) } revision := image.Revision if len(revision) > 12 { revision = revision[:12] } source := image.Source if source == "" { source = "unknown" } row(writer, image.Reference(), image.Size, image.Created, pr, revision, source) } return writer.Flush() }, } images.AddCommand(&cobra.Command{ Use: "rm ...", Aliases: []string{"remove", "delete"}, Short: "Delete local Homarr images", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { for _, name := range args { reference := name if !strings.Contains(reference, ":") { reference = "homarr:" + reference } if err := docker.RemoveImage(cmd.Context(), reference); err != nil { return err } fmt.Println("removed " + reference) } return nil }, }) return images } func (a *App) dataCommand() *cobra.Command { var force bool data := &cobra.Command{ Use: "data", Aliases: []string{"volumes", "volume"}, Short: "List Homarr instance data volumes", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { volumes, err := docker.ListVolumes(cmd.Context(), true) if err != nil { return err } if len(volumes) == 0 { fmt.Println("No Homarr data volumes found.") return nil } writer := newTable("VOLUME", "SIZE", "IN USE", "BELONGS TO") for _, volume := range volumes { owner := volume.Tag() if number := volume.PRNumber(); number > 0 { owner = fmt.Sprintf("PR #%d", number) } inUse := "no" if volume.InUse { inUse = "yes" } row(writer, volume.Name, volume.Size, inUse, owner) } return writer.Flush() }, } remove := &cobra.Command{ Use: "rm ...", Aliases: []string{"remove", "delete"}, Short: "Preview instance data deletion; use --force to apply", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() available, err := docker.ListVolumes(ctx, false) if err != nil { return err } requested, err := selectRequestedVolumes(available, args) if err != nil { return err } if !force { fmt.Printf("Would permanently delete %d Homarr data volume(s) and remove their owning containers:\n", len(requested)) for _, volume := range requested { owner, err := volumeOwnerContainer(volume.Name) if err != nil { return err } fmt.Printf(" %s (container: %s)\n", volume.Name, owner) } fmt.Println("\nRe-run with --force to delete this data.") return nil } for _, volume := range requested { // Docker refuses to delete an attached volume, and the data is // going away regardless, so the container goes first. owner, err := volumeOwnerContainer(volume.Name) if err != nil { return err } containerID, mountsVolume, err := docker.InspectContainerVolumeMount(ctx, owner, volume.Name) if err != nil && !docker.IsContainerNotFound(err) { return fmt.Errorf("verify owning container %s: %w", owner, err) } if err == nil { if !mountsVolume { return fmt.Errorf("refusing to remove container %s: it does not mount volume %s", owner, volume.Name) } if err := docker.RemoveContext(ctx, containerID); err != nil && !docker.IsContainerNotFound(err) { return fmt.Errorf("remove owning container %s: %w", owner, err) } } if err := docker.RemoveVolume(ctx, volume.Name); err != nil { return err } fmt.Println("removed " + volume.Name) } return nil }, } remove.Flags().BoolVarP(&force, "force", "f", false, "Permanently delete the data volumes") data.AddCommand(remove) return data } func selectRequestedVolumes(available []docker.Volume, names []string) ([]docker.Volume, error) { availableByName := make(map[string]docker.Volume, len(available)) for _, volume := range available { availableByName[volume.Name] = volume } requested := make([]docker.Volume, 0, len(names)) seen := make(map[string]bool, len(names)) for _, name := range names { if seen[name] { return nil, fmt.Errorf("volume %q was requested more than once", name) } seen[name] = true volume, found := availableByName[name] if !found { return nil, fmt.Errorf("Homarr data volume %q was not found", name) } if _, err := volumeOwnerContainer(volume.Name); err != nil { return nil, err } requested = append(requested, volume) } return requested, nil } func volumeOwnerContainer(name string) (string, error) { if name == "homarr" { return "homarr", nil } if !strings.HasSuffix(name, "_data") { return "", fmt.Errorf("volume %q is not a Homarr instance data volume", name) } owner := strings.TrimSuffix(name, "_data") if owner == "" { return "", fmt.Errorf("volume %q has no owning container name", name) } return owner, nil } func (a *App) pruneCommand() *cobra.Command { var force bool cmd := &cobra.Command{ Use: "prune", Short: "Remove stopped Homarr containers, keeping their data", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { containers, err := docker.List() if err != nil { return err } stopped := make([]docker.Container, 0, len(containers)) for _, container := range containers { if !container.Running() { stopped = append(stopped, container) } } if len(stopped) == 0 { fmt.Println("No stopped Homarr containers.") return nil } if !force { fmt.Printf("Would remove %d stopped containers:\n", len(stopped)) for _, container := range stopped { fmt.Println(" " + container.Name) } fmt.Println("\nRe-run with --force to remove them.") return nil } for _, container := range stopped { if err := docker.Remove(container.Name); err != nil { return err } fmt.Println("removed " + container.Name) } return nil }, } cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually remove the containers") return cmd } func (a *App) logsCommand() *cobra.Command { return &cobra.Command{ Use: "logs ", Short: "Follow logs of a running instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return docker.FollowLogs(args[0]) }, } } func (a *App) stopCommand() *cobra.Command { return &cobra.Command{ Use: "stop ", Short: "Stop a running instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return docker.Stop(args[0]) }, } } func (a *App) restartCommand() *cobra.Command { return &cobra.Command{ Use: "restart ", Short: "Restart an instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return docker.Restart(args[0]) }, } } func (a *App) removeCommand() *cobra.Command { return &cobra.Command{ Use: "remove ", Short: "Force-remove an instance, keeping its data", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return docker.Remove(args[0]) }, } } func (a *App) openCommand() *cobra.Command { return &cobra.Command{ Use: "open ", Short: "Open a running instance in the browser", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { containers, err := docker.List() if err != nil { return err } for _, container := range containers { if container.Name != args[0] { continue } if !container.Running() { return fmt.Errorf("container %q is not running", args[0]) } port := container.HostPort() if port == "" { return fmt.Errorf("container %q does not publish the Homarr port", args[0]) } return platform.OpenURL("http://localhost:" + port) } return fmt.Errorf("container %q was not found", args[0]) }, } } func (a *App) shellCommand() *cobra.Command { return &cobra.Command{ Use: "shell [cmd...]", Aliases: []string{"sh", "exec"}, Short: "Open an interactive shell or run a command in a running Homarr container", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { shellArgs := []string{"sh"} if len(args) > 1 { shellArgs = args[1:] } return docker.ExecInteractive(cmd.Context(), args[0], shellArgs...) }, } } func (a *App) ciCommand() *cobra.Command { var watch bool cmd := &cobra.Command{ Use: "ci [pr]", Aliases: []string{"checks", "check"}, Short: "View or watch GitHub CI checks for a pull request", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var prNumber int if len(args) > 0 { num, err := strconv.Atoi(strings.TrimPrefix(args[0], "#")) if err != nil || num <= 0 { return fmt.Errorf("invalid PR number %q", args[0]) } prNumber = num } else { currentPR, err := gh.CurrentBranchPR(cmd.Context()) if err != nil || currentPR == nil { return fmt.Errorf("could not detect PR for current branch: specify a PR number with `homarr ci `") } prNumber = currentPR.Number } if watch { return gh.WatchPRChecks(cmd.Context(), prNumber) } checks, err := gh.GetPRChecks(cmd.Context(), prNumber, true) if err != nil { return err } if len(checks) == 0 { fmt.Printf("No CI checks found for PR #%d.\n", prNumber) return nil } writer := newTable("CHECK", "STATUS", "DURATION", "WORKFLOW", "LINK") for _, check := range checks { dur := check.Duration() if dur == "" { dur = "—" } row(writer, check.Name, check.State, dur, check.Workflow, check.Link) } return writer.Flush() }, } cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch CI checks until completion") return cmd } func newTable(headers ...string) *tabwriter.Writer { writer := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) row(writer, headers...) return writer } func row(writer *tabwriter.Writer, cells ...string) { fmt.Fprintln(writer, strings.Join(cells, "\t")) }