feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com> Co-authored-by: arturict <arturict@users.noreply.github.com> Co-authored-by: Thomas <thomas@ajnart.fr> Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com> Co-authored-by: Danura30082 <danura30082+github@gmail.com> Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com> Co-authored-by: ajnart <thomas@ajnart.fr> Co-authored-by: ajnart <thomas@ajnart.dev> Co-authored-by: chenjun <chenjun@foxmail.com> Co-authored-by: Wikibear <wikibear@users.noreply.github.com> Co-authored-by: Bergasha <bergasha@gmail.com> Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com> Co-authored-by: Lucas Araujo <lucas.lra@gmail.com> Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com> Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social> Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com> Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com> Co-authored-by: thrawnx <tight.bulb9384@fastmail.com> Co-authored-by: Chris <58918656+nordada@users.noreply.github.com> Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com> Co-authored-by: Yanis <naamaneyanis@gmail.com> Co-authored-by: Wikibear <me@sej.de> Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386
-139176
No files matched your search
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||
)
|
||||
|
||||
func (a *App) buildCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "build [name]",
|
||||
Short: "Build a local Homarr image from this checkout or a pull request",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
tag := ""
|
||||
if len(args) > 0 {
|
||||
tag = args[0]
|
||||
}
|
||||
if a.buildPR < 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
if a.buildPR > 0 {
|
||||
if tag == "" {
|
||||
tag = fmt.Sprintf("pr-%d", a.buildPR)
|
||||
}
|
||||
return run.BuildPRImage(a.buildPR, tag)
|
||||
}
|
||||
if tag == "" {
|
||||
return fmt.Errorf("image name is required when --pr is not set")
|
||||
}
|
||||
return run.BuildCurrentImage(tag, ".")
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&a.buildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) rebuildCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rebuild <name>",
|
||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
image, err := findLocalImage(cmd, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.RebuildImage(image)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findLocalImage resolves a tag or full reference to a local image, so both
|
||||
// `homarr rebuild dev` and `homarr rebuild homarr:dev` work.
|
||||
func findLocalImage(cmd *cobra.Command, name string) (docker.Image, error) {
|
||||
images, err := docker.ListLocalImages(cmd.Context())
|
||||
if err != nil {
|
||||
return docker.Image{}, err
|
||||
}
|
||||
tag := name
|
||||
if strings.Contains(name, ":") {
|
||||
_, tag = docker.SplitImageReference(name)
|
||||
}
|
||||
for _, image := range images {
|
||||
if image.Tag == tag {
|
||||
return image, nil
|
||||
}
|
||||
}
|
||||
return docker.Image{}, fmt.Errorf("local image homarr:%s was not found", tag)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package cli wires the command line surface. The interactive application
|
||||
// lives in internal/tui; everything here is the scriptable path that does not
|
||||
// need a terminal.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui"
|
||||
)
|
||||
|
||||
// App holds command state. The three function fields are seams: they let the
|
||||
// command wiring be tested without opening a terminal, and they are the only
|
||||
// mutable state in the package — the previous version reached for package-level
|
||||
// variables, which meant tests had to mutate globals to observe dispatch.
|
||||
type App struct {
|
||||
demo bool
|
||||
pr int
|
||||
env []string
|
||||
detach bool
|
||||
buildPR int
|
||||
|
||||
RunBrowser func() error
|
||||
RunInstances func() error
|
||||
Launch func(cmd *cobra.Command, args []string) error
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
app := &App{RunBrowser: tui.RunDev, RunInstances: tui.RunDashboard}
|
||||
app.Launch = app.launch
|
||||
return app
|
||||
}
|
||||
|
||||
// Execute runs the CLI.
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && args[0] == "--" {
|
||||
args = args[1:]
|
||||
}
|
||||
root := NewApp().Root()
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func (a *App) Root() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "homarr",
|
||||
Short: "Launch and manage Homarr Docker instances",
|
||||
SilenceUsage: true,
|
||||
Long: `Launch and manage Homarr Docker containers.
|
||||
|
||||
Start a local image, a pull-request build from ghcr.io, browse open pull
|
||||
requests, or manage running instances — all from one interactive terminal
|
||||
application where builds and pulls run in the background.
|
||||
|
||||
Examples:
|
||||
homarr # interactive browser
|
||||
homarr --pr 6390 # launch a pull-request image directly
|
||||
homarr --pr 6390 -e FOO=bar # with environment overrides
|
||||
homarr run dev # launch local homarr:dev
|
||||
homarr run --detach dev # start in the background
|
||||
homarr dash # open on the instances screen
|
||||
homarr build feature # build this checkout as homarr:feature
|
||||
homarr build --pr 6390 # build a pull request locally
|
||||
homarr images # local images with provenance
|
||||
homarr data # instance data volumes
|
||||
homarr prune # remove stopped instances
|
||||
homarr doctor # check Docker and optional integrations`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: a.runRoot,
|
||||
}
|
||||
|
||||
a.addRunFlags(root)
|
||||
root.AddCommand(
|
||||
a.runCommand(),
|
||||
a.devCommand(),
|
||||
a.dashCommand(),
|
||||
a.buildCommand(),
|
||||
a.rebuildCommand(),
|
||||
a.listCommand(),
|
||||
a.imagesCommand(),
|
||||
a.dataCommand(),
|
||||
a.pruneCommand(),
|
||||
a.logsCommand(),
|
||||
a.stopCommand(),
|
||||
a.restartCommand(),
|
||||
a.removeCommand(),
|
||||
a.openCommand(),
|
||||
a.shellCommand(),
|
||||
a.ciCommand(),
|
||||
a.doctorCommand(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func (a *App) addRunFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolVarP(&a.demo, "demo", "m", false, "Demo mode (mock integrations)")
|
||||
cmd.Flags().IntVarP(&a.pr, "pr", "p", 0, "Launch a pull-request image from ghcr.io")
|
||||
cmd.Flags().StringArrayVarP(&a.env, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
|
||||
cmd.Flags().BoolVar(&a.detach, "detach", false, "Start in the background")
|
||||
}
|
||||
|
||||
// runRoot opens the browser unless a launch flag was given, in which case the
|
||||
// bare `homarr` invocation is shorthand for `homarr run`.
|
||||
func (a *App) runRoot(cmd *cobra.Command, args []string) error {
|
||||
for _, name := range []string{"pr", "env", "demo", "detach"} {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return a.Launch(cmd, args)
|
||||
}
|
||||
}
|
||||
return a.RunBrowser()
|
||||
}
|
||||
|
||||
func (a *App) runCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "run [tag]",
|
||||
Short: "Launch a local image or a pull-request image",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return a.Launch(cmd, args) },
|
||||
}
|
||||
a.addRunFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) devCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dev",
|
||||
Short: "Browse pull requests and local development images",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error { return a.RunBrowser() },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dashCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dash",
|
||||
Aliases: []string{"instances"},
|
||||
Short: "Open the interactive application on the instances screen",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error { return a.RunInstances() },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) launch(cmd *cobra.Command, args []string) error {
|
||||
tag := ""
|
||||
if len(args) > 0 {
|
||||
tag = args[0]
|
||||
}
|
||||
if a.pr < 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
if a.pr > 0 && tag != "" {
|
||||
return fmt.Errorf("tag and --pr cannot be used together")
|
||||
}
|
||||
if a.pr == 0 && tag == "" {
|
||||
if err := cmd.Usage(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("provide a tag or --pr")
|
||||
}
|
||||
|
||||
plan, err := run.BuildPlan(run.Options{
|
||||
Context: cmd.Context(),
|
||||
PR: a.pr,
|
||||
Tag: tag,
|
||||
Demo: a.demo,
|
||||
Env: a.env,
|
||||
FetchTitle: a.pr > 0,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
|
||||
fmt.Printf(" Image : %s\n", plan.Image)
|
||||
fmt.Printf(" Container : %s\n", plan.Name)
|
||||
fmt.Printf(" Volume : %s\n", plan.Volume)
|
||||
fmt.Printf(" Port : %d:7575\n", plan.HostPort)
|
||||
fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort)
|
||||
if plan.PRNumber > 0 {
|
||||
fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if a.detach {
|
||||
if err := run.StartDetached(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort)
|
||||
return nil
|
||||
}
|
||||
return run.StartForeground(plan)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootCommandOpensBrowser(t *testing.T) {
|
||||
want := errors.New("browser started")
|
||||
app := NewApp()
|
||||
app.RunBrowser = func() error { return want }
|
||||
|
||||
if err := app.runRoot(&cobra.Command{}, nil); !errors.Is(err, want) {
|
||||
t.Fatalf("root command error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandLaunchesWhenRunFlagIsProvided(t *testing.T) {
|
||||
want := errors.New("PR launched")
|
||||
app := NewApp()
|
||||
app.Launch = func(*cobra.Command, []string) error { return want }
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("pr", false, "")
|
||||
if err := cmd.Flags().Set("pr", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.runRoot(cmd, nil); !errors.Is(err, want) {
|
||||
t.Fatalf("root command error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsTagWithPR(t *testing.T) {
|
||||
app := NewApp()
|
||||
// Registering the flags binds them to app.pr and resets it, so the command
|
||||
// has to exist before the test sets the value it wants to assert on.
|
||||
cmd := app.runCommand()
|
||||
app.pr = 42
|
||||
|
||||
err := app.launch(cmd, []string{"feature"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsNegativePR(t *testing.T) {
|
||||
app := NewApp()
|
||||
cmd := app.runCommand()
|
||||
app.pr = -1
|
||||
|
||||
err := app.launch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be positive") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsMissingImage(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
err := app.launch(app.runCommand(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The root command doubles as `run`, so both must accept the same launch flags.
|
||||
func TestRootAndRunShareLaunchFlags(t *testing.T) {
|
||||
app := NewApp()
|
||||
root := app.Root()
|
||||
run, _, err := root.Find([]string{"run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"pr", "demo", "env", "detach"} {
|
||||
if root.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("root command is missing the %q flag", name)
|
||||
}
|
||||
if run.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("run command is missing the %q flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCICommandRegisteredAndValidated(t *testing.T) {
|
||||
app := NewApp()
|
||||
root := app.Root()
|
||||
ciCmd, _, err := root.Find([]string{"ci"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find 'ci' command: %v", err)
|
||||
}
|
||||
if ciCmd.Name() != "ci" {
|
||||
t.Fatalf("expected command name 'ci', got %q", ciCmd.Name())
|
||||
}
|
||||
if ciCmd.Flags().Lookup("watch") == nil {
|
||||
t.Fatalf("expected --watch flag on 'ci' command")
|
||||
}
|
||||
|
||||
// Test invalid PR argument
|
||||
err = ciCmd.RunE(ciCmd, []string{"invalid-num"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid PR number") {
|
||||
t.Fatalf("expected invalid PR number error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||
)
|
||||
|
||||
func (a *App) doctorCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Check Docker, the Engine API, GHCR and optional integrations",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
failed := false
|
||||
if output, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil {
|
||||
failed = true
|
||||
fail("Docker daemon", strings.TrimSpace(string(output)))
|
||||
} else {
|
||||
pass("Docker daemon", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// The Engine API is what gives pulls byte-level progress. Losing it
|
||||
// is not fatal — the CLI falls back to parsing `docker pull` — so
|
||||
// this reports a downgrade rather than a failure.
|
||||
host := docker.EngineHost()
|
||||
if docker.EngineAvailable(ctx) {
|
||||
pass("Engine API", host)
|
||||
} else {
|
||||
optional("Engine API unreachable at "+host, "pull progress falls back to coarse layer states")
|
||||
}
|
||||
|
||||
if tags, err := registry.PublishedTags(ctx, true); err != nil {
|
||||
optional("GHCR tag listing unavailable", err.Error())
|
||||
} else {
|
||||
pass("GHCR test images", fmt.Sprintf("%d published tags", len(tags)))
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
optional("GitHub CLI not found", "required for pull-request features")
|
||||
} else if output, err := exec.CommandContext(ctx, "gh", "auth", "status").CombinedOutput(); err != nil {
|
||||
optional("GitHub CLI is not authenticated", strings.TrimSpace(string(output)))
|
||||
} else {
|
||||
pass("GitHub CLI authentication", "")
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("homarr"); err != nil {
|
||||
optional("homarr is not on PATH", "use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`")
|
||||
} else {
|
||||
pass("homarr is on PATH", "")
|
||||
}
|
||||
|
||||
if failed {
|
||||
return fmt.Errorf("one or more checks failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pass(label, detail string) {
|
||||
if detail == "" {
|
||||
fmt.Printf("%s %s\n", ui.OK.Render(ui.IconPass), label)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s %s: %s\n", ui.OK.Render(ui.IconPass), label, detail)
|
||||
}
|
||||
|
||||
func fail(label, detail string) {
|
||||
fmt.Printf("%s %s: %s\n", ui.Alert.Render(ui.IconFail), label, detail)
|
||||
}
|
||||
|
||||
func optional(label, detail string) {
|
||||
if detail == "" {
|
||||
fmt.Printf("%s %s (optional)\n", ui.Dim.Render(ui.IconNone), label)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s %s (optional; %s)\n", ui.Dim.Render(ui.IconNone), label, detail)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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 <name>`.")
|
||||
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 <name>...",
|
||||
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 <volume>...",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container> [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 <number>`")
|
||||
}
|
||||
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"))
|
||||
}
|
||||
Reference in new issue
Block a user