From c8485ed7fc3682de06d82a0c9588b2fa07f1fd34 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 31 Jul 2026 14:53:43 +0200 Subject: [PATCH] fix(dev): address CLI review feedback --- .../command-line/development/index.mdx | 37 +++++++++ .../advanced/development/getting-started.mdx | 6 +- scripts/setup-dev.sh | 38 ++++++++- tools/homarr-dev/README.md | 2 +- tools/homarr-dev/internal/docker/docker.go | 67 ++++++++++------ .../homarr-dev/internal/docker/docker_test.go | 15 ++++ tools/homarr-dev/internal/docker/images.go | 14 ++-- .../homarr-dev/internal/docker/images_test.go | 4 +- tools/homarr-dev/internal/gh/gh.go | 9 ++- tools/homarr-dev/internal/gh/gh_test.go | 2 +- tools/homarr-dev/internal/run/run.go | 55 +++++-------- tools/homarr-dev/internal/run/run_test.go | 9 ++- tools/homarr-dev/internal/tui/dashboard.go | 58 +++++++------- .../homarr-dev/internal/tui/dashboard_test.go | 7 +- tools/homarr-dev/internal/tui/dev.go | 79 ++++++++++++------- tools/homarr-dev/internal/tui/dev_test.go | 18 +++++ tools/homarr-dev/main.go | 36 ++++++--- tools/homarr-dev/main_test.go | 11 +++ 18 files changed, 307 insertions(+), 160 deletions(-) create mode 100644 apps/docs/docs/advanced/command-line/development/index.mdx diff --git a/apps/docs/docs/advanced/command-line/development/index.mdx b/apps/docs/docs/advanced/command-line/development/index.mdx new file mode 100644 index 000000000..f4b25b67e --- /dev/null +++ b/apps/docs/docs/advanced/command-line/development/index.mdx @@ -0,0 +1,37 @@ +# Developer Docker CLI + +The host-side developer CLI builds and runs local Homarr images and pull-request previews. It is installed from a Homarr checkout with `pnpm dev:setup` and is separate from the recovery CLI included in production images. + +## Browse development images + +```bash +homarr dev +``` + +The interactive browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to rebuild a local image, or use `p` to pull a remote preview. + +## Build images + +```bash +homarr build +homarr build --pr [name] +``` + +The first command builds the current checkout as `homarr:`. The pull-request form clones the requested branch into a temporary directory and defaults to `homarr:pr-` when `name` is omitted. + +## Rebuild images + +```bash +homarr rebuild +``` + +Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout. + +## Run images + +```bash +homarr run +homarr run --pr +``` + +Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables. diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index da02effa6..9a1f8546b 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -16,9 +16,9 @@ This guide will help you set up your development environment to start contributi - **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). - **pnpm (latest)**: You can install it by running `npm install -g pnpm`. -- **Go (latest)**: You can download it from the [official website](https://go.dev/). +- **Go 1.25+**: You can download it from the [official website](https://go.dev/). - **Git**: You can download it from the [official website](https://git-scm.com/). -- **GitHub CLI**: You can download it from the [official website](https://cli.github.com/). +- **GitHub CLI**: Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`. - **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/). - **A GitHub account**: You can create one on the [official website](https://github.com/). @@ -67,4 +67,4 @@ This guide will help you set up your development environment to start contributi - `pnpm build`: Build the project for production. - `homarr build `: Build a `homarr:` image and record its checkout for later rebuilds. -- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image. +- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:`: Run the created docker image. diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 9a159605d..c89243f70 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -10,6 +10,18 @@ for command in corepack go docker gh; do exit 1 fi done +if ! docker compose version >/dev/null 2>&1; then + printf 'Required command not found: docker compose\n' >&2 + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + printf 'Docker daemon is not available.\n' >&2 + exit 1 +fi +if ! gh auth status >/dev/null 2>&1; then + printf 'GitHub CLI is not authenticated. Run `gh auth login`.\n' >&2 + exit 1 +fi if [ ! -f "$repository_root/.env" ]; then database_directory="$HOME/.local/share/homarr" @@ -22,10 +34,22 @@ fi corepack enable pnpm pnpm --dir "$repository_root" install -if [ -z "$(docker ps --filter publish=6379 --format '{{.ID}}')" ]; then - docker compose -f "$repository_root/development/development.docker-compose.yml" up -d --no-recreate redis -else +compose_file="$repository_root/development/development.docker-compose.yml" +redis_id="$(docker compose -f "$compose_file" ps -q redis)" +if [ -n "$redis_id" ] && [ "$(docker inspect --format '{{.State.Running}}' "$redis_id")" = "true" ] && [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping)" = "PONG" ]; then printf 'Redis is already available on port 6379.\n' +else + docker compose -f "$compose_file" up -d --no-recreate redis + for _ in {1..30}; do + if [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping 2>/dev/null)" = "PONG" ]; then + break + fi + sleep 1 + done + if [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping 2>/dev/null)" != "PONG" ]; then + printf 'Redis did not become ready.\n' >&2 + exit 1 + fi fi pnpm --dir "$repository_root" db:migration:sqlite:run mkdir -p "$HOME/.local/bin" @@ -42,4 +66,10 @@ if [ -d "$HOME/.config/fish/completions" ]; then "$HOME/.local/bin/homarr" completion fish > "$HOME/.config/fish/completions/homarr.fish" fi -printf 'Development environment ready. Run `homarr dev` or `pnpm dev`.\n' +cli_command="$HOME/.local/bin/homarr" +case ":$PATH:" in + *":$HOME/.local/bin:"*) cli_command="homarr" ;; + *) printf 'Add %s to PATH to run `homarr` directly.\n' "$HOME/.local/bin" ;; +esac + +printf 'Development environment ready. Run `%s dev` or `pnpm dev`.\n' "$cli_command" diff --git a/tools/homarr-dev/README.md b/tools/homarr-dev/README.md index 43b7f2470..793a34b9b 100644 --- a/tools/homarr-dev/README.md +++ b/tools/homarr-dev/README.md @@ -14,7 +14,7 @@ Build and launch local Homarr images and GHCR pull-request builds, then manage t pnpm dev:setup ``` -This installs monorepo dependencies, creates and migrates the local SQLite database, starts or reuses Redis, installs `homarr` to `~/.local/bin`, and generates Fish completions when Fish is configured. +This installs monorepo dependencies, creates and migrates the local SQLite database, starts or reuses Redis, installs `homarr` to `~/.local/bin`, and generates Fish completions when Fish is configured. Add `~/.local/bin` to `PATH` to invoke `homarr` directly; setup prints the absolute command when it is not available on `PATH`. ## Build diff --git a/tools/homarr-dev/internal/docker/docker.go b/tools/homarr-dev/internal/docker/docker.go index 009093703..0070bd7cb 100644 --- a/tools/homarr-dev/internal/docker/docker.go +++ b/tools/homarr-dev/internal/docker/docker.go @@ -12,6 +12,8 @@ import ( "strings" ) +const defaultDevelopmentEncryptionKey = "cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe" + type Container struct { ID string Name string @@ -82,8 +84,12 @@ func List() ([]Container, error) { } func FindFreePort(start int) int { + if start > 65535 { + return 0 + } used := usedDockerPorts() - for port := start; ; port++ { + start = max(start, 1) + for port := start; port <= 65535; port++ { if used[port] { continue } @@ -93,36 +99,43 @@ func FindFreePort(start int) int { return port } } + return 0 } func usedDockerPorts() map[int]bool { - used := make(map[int]bool) out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output() if err != nil { - return used + return make(map[int]bool) } - for _, mapping := range strings.Split(string(out), ",") { - left, _, found := strings.Cut(mapping, "->") - if !found { - continue - } - colon := strings.LastIndex(left, ":") - if colon < 0 { - continue - } - ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2) - first, err := strconv.Atoi(ports[0]) - if err != nil { - continue - } - last := first - if len(ports) == 2 { - if parsed, err := strconv.Atoi(ports[1]); err == nil { - last = parsed + return parseUsedDockerPorts(out) +} + +func parseUsedDockerPorts(out []byte) map[int]bool { + used := make(map[int]bool) + for _, line := range strings.Split(string(out), "\n") { + for _, mapping := range strings.Split(line, ",") { + left, _, found := strings.Cut(mapping, "->") + if !found { + continue + } + colon := strings.LastIndex(left, ":") + if colon < 0 { + continue + } + ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2) + first, err := strconv.Atoi(ports[0]) + if err != nil { + continue + } + last := first + if len(ports) == 2 { + if parsed, err := strconv.Atoi(ports[1]); err == nil { + last = parsed + } + } + for port := first; port <= last; port++ { + used[port] = true } - } - for port := first; port <= last; port++ { - used[port] = true } } return used @@ -162,8 +175,12 @@ func Start(opts StartOptions) error { _ = exec.Command("docker", "rm", "-f", opts.Name).Run() + encryptionKey := os.Getenv("HOMARR_DEV_SECRET_ENCRYPTION_KEY") + if encryptionKey == "" { + encryptionKey = defaultDevelopmentEncryptionKey + } args := []string{"run", "--rm", "--name", opts.Name, - "-e", "SECRET_ENCRYPTION_KEY=cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe", + "-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey, } if opts.Platform != "" { args = append(args, "--platform", opts.Platform) diff --git a/tools/homarr-dev/internal/docker/docker_test.go b/tools/homarr-dev/internal/docker/docker_test.go index d477b6d9d..de9cb41d1 100644 --- a/tools/homarr-dev/internal/docker/docker_test.go +++ b/tools/homarr-dev/internal/docker/docker_test.go @@ -25,3 +25,18 @@ func TestIsPortConflict(t *testing.T) { t.Fatal("manifest error classified as port conflict") } } + +func TestParseUsedDockerPortsPreservesLineBoundaries(t *testing.T) { + ports := parseUsedDockerPorts([]byte("6379/tcp\n0.0.0.0:7575->7575/tcp, [::]:7575->7575/tcp\n0.0.0.0:8000-8002->8000-8002/tcp\n")) + for _, port := range []int{7575, 8000, 8001, 8002} { + if !ports[port] { + t.Fatalf("port %d was not detected", port) + } + } +} + +func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) { + if port := FindFreePort(65536); port != 0 { + t.Fatalf("port = %d, want 0", port) + } +} diff --git a/tools/homarr-dev/internal/docker/images.go b/tools/homarr-dev/internal/docker/images.go index 21511d8b5..cb4f16e66 100644 --- a/tools/homarr-dev/internal/docker/images.go +++ b/tools/homarr-dev/internal/docker/images.go @@ -56,8 +56,8 @@ func parseImageList(output []byte) ([]Image, error) { return images, nil } -func ListLocalImages() ([]Image, error) { - out, err := exec.Command("docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output() +func ListLocalImages(ctx context.Context) ([]Image, error) { + out, err := exec.CommandContext(ctx, "docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output() if err != nil { return nil, fmt.Errorf("list local Homarr images: %w", err) } @@ -65,7 +65,7 @@ func ListLocalImages() ([]Image, error) { if err != nil { return nil, err } - labelsByImage, err := inspectImageLabels(images) + labelsByImage, err := inspectImageLabels(ctx, images) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func ListLocalImages() ([]Image, error) { return images, nil } -func inspectImageLabels(images []Image) ([]map[string]string, error) { +func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]string, error) { if len(images) == 0 { return nil, nil } @@ -85,7 +85,7 @@ func inspectImageLabels(images []Image) ([]map[string]string, error) { for _, image := range images { args = append(args, image.Reference()) } - out, err := exec.Command("docker", args...).Output() + out, err := exec.CommandContext(ctx, "docker", args...).Output() if err != nil { return nil, fmt.Errorf("inspect local Homarr images: %w", err) } @@ -135,10 +135,6 @@ func BuildCommand(ctx context.Context, options BuildOptions) (*exec.Cmd, error) return command, nil } -func Build(options BuildOptions) error { - return BuildContext(context.Background(), options, os.Stdout, os.Stderr) -} - func BuildContext(ctx context.Context, options BuildOptions, stdout, stderr io.Writer) error { command, err := BuildCommand(ctx, options) if err != nil { diff --git a/tools/homarr-dev/internal/docker/images_test.go b/tools/homarr-dev/internal/docker/images_test.go index ad1363569..559a9423a 100644 --- a/tools/homarr-dev/internal/docker/images_test.go +++ b/tools/homarr-dev/internal/docker/images_test.go @@ -17,7 +17,7 @@ func TestParseImageListFiltersHomarrTags(t *testing.T) { } func TestInspectImageLabelsWithNoImages(t *testing.T) { - labels, err := inspectImageLabels(nil) + labels, err := inspectImageLabels(t.Context(), nil) if err != nil { t.Fatal(err) } @@ -53,7 +53,7 @@ func TestListLocalImagesIntegration(t *testing.T) { if wantTag == "" { t.Skip("set HOMARR_TEST_LOCAL_IMAGE to a local homarr image tag") } - images, err := ListLocalImages() + images, err := ListLocalImages(t.Context()) if err != nil { t.Fatal(err) } diff --git a/tools/homarr-dev/internal/gh/gh.go b/tools/homarr-dev/internal/gh/gh.go index bf302b054..405d9d1cb 100644 --- a/tools/homarr-dev/internal/gh/gh.go +++ b/tools/homarr-dev/internal/gh/gh.go @@ -1,6 +1,7 @@ package gh import ( + "context" "encoding/json" "fmt" "os/exec" @@ -76,8 +77,8 @@ func rollupState(checks []rawCheck) string { return "SUCCESS" } -func ListPRs(limit int, includeBots bool) ([]PR, error) { - out, err := exec.Command("gh", "pr", "list", +func ListPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) { + out, err := exec.CommandContext(ctx, "gh", "pr", "list", "--repo", Repo, "--state", "open", "--limit", fmt.Sprint(limit), @@ -110,8 +111,8 @@ func ListPRs(limit int, includeBots bool) ([]PR, error) { return prs, nil } -func GetPR(number int) (*PR, error) { - out, err := exec.Command("gh", "pr", "view", fmt.Sprint(number), +func GetPR(ctx context.Context, number int) (*PR, error) { + out, err := exec.CommandContext(ctx, "gh", "pr", "view", fmt.Sprint(number), "--repo", Repo, "--json", "number,title,author,headRefName,updatedAt,isDraft,statusCheckRollup", ).Output() diff --git a/tools/homarr-dev/internal/gh/gh_test.go b/tools/homarr-dev/internal/gh/gh_test.go index 061777587..1dee513c9 100644 --- a/tools/homarr-dev/internal/gh/gh_test.go +++ b/tools/homarr-dev/internal/gh/gh_test.go @@ -47,7 +47,7 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) { t.Skip("set HOMARR_INTEGRATION_TEST=1 to query GitHub") } - prs, err := ListPRs(50, false) + prs, err := ListPRs(t.Context(), 50, false) if err != nil { t.Fatal(err) } diff --git a/tools/homarr-dev/internal/run/run.go b/tools/homarr-dev/internal/run/run.go index 9a7e4e77c..a144ee3df 100644 --- a/tools/homarr-dev/internal/run/run.go +++ b/tools/homarr-dev/internal/run/run.go @@ -1,20 +1,22 @@ package run import ( + "context" "fmt" "os" - "strings" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" ) type Options struct { + Context context.Context PR int Tag string Demo bool Env []string FetchTitle bool + FindPort func(int) int } type Plan struct { @@ -22,7 +24,6 @@ type Plan struct { Name string Volume string Label string - TabTitle string HostPort int Env []string Platform string @@ -32,6 +33,14 @@ type Plan struct { func BuildPlan(opts Options) (*Plan, error) { p := &Plan{} + ctx := opts.Context + if ctx == nil { + ctx = context.Background() + } + findPort := opts.FindPort + if findPort == nil { + findPort = docker.FindFreePort + } if opts.PR > 0 { p.PRNumber = opts.PR @@ -42,9 +51,8 @@ func BuildPlan(opts Options) (*Plan, error) { p.Pull = true p.Platform = "linux/amd64" if opts.FetchTitle { - if pr, err := gh.GetPR(opts.PR); err == nil && pr.Title != "" { + if pr, err := gh.GetPR(ctx, opts.PR); err == nil && pr.Title != "" { p.Label = fmt.Sprintf("PR #%d — %s", opts.PR, pr.Title) - p.TabTitle = truncate(fmt.Sprintf("🦞 %s", pr.Title), 40) } } } else if opts.Tag != "" { @@ -56,11 +64,10 @@ func BuildPlan(opts Options) (*Plan, error) { return nil, fmt.Errorf("need a tag or --pr") } - if p.TabTitle == "" { - p.TabTitle = "🦞 " + strings.TrimPrefix(p.Label, "tag: ") + p.HostPort = findPort(7575) + if p.HostPort == 0 { + return nil, fmt.Errorf("no free host port is available") } - - p.HostPort = docker.FindFreePort(7575) p.Env = append(p.Env, opts.Env...) if opts.Demo { p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true") @@ -101,38 +108,12 @@ func start(p *Plan, daemon bool) error { } previousPort := p.HostPort p.HostPort = docker.FindFreePort(p.HostPort + 1) + if p.HostPort == 0 { + return fmt.Errorf("no free host port is available: %w", err) + } if !daemon { fmt.Fprintf(os.Stderr, "Port %d unavailable; retrying on %d.\n", previousPort, p.HostPort) } } return nil } - -func SetTerminalChrome(title string) { - fmt.Printf("\033]0;%s\007", title) - if os.Getenv("ITERM_SESSION_ID") != "" { - fmt.Printf("\033]6;1;bg;red;brightness;%d\007", randInt()) - fmt.Printf("\033]6;1;bg;green;brightness;%d\007", randInt()) - fmt.Printf("\033]6;1;bg;blue;brightness;%d\007", randInt()) - } - if tabID := os.Getenv("TABBY_TAB_ID"); tabID != "" { - if dir := os.Getenv("TABBY_TITLES_DIR"); dir != "" { - os.WriteFile(fmt.Sprintf("%s/%s.txt", dir, tabID), []byte(title), 0644) - } - } -} - -var seed = int64(os.Getpid()) - -func randInt() int { - seed = seed*1103515245 + 12345 - return int((seed / 65536) % 256) -} - -func truncate(s string, n int) string { - r := []rune(s) - if len(r) <= n { - return s - } - return string(r[:n-1]) + "…" -} diff --git a/tools/homarr-dev/internal/run/run_test.go b/tools/homarr-dev/internal/run/run_test.go index 7289be74c..1d12d250e 100644 --- a/tools/homarr-dev/internal/run/run_test.go +++ b/tools/homarr-dev/internal/run/run_test.go @@ -9,7 +9,7 @@ import ( ) func TestPRPlanUsesAMD64(t *testing.T) { - plan, err := BuildPlan(Options{PR: 1}) + plan, err := BuildPlan(Options{PR: 1, FindPort: func(int) int { return 7575 }}) if err != nil { t.Fatal(err) } @@ -18,6 +18,13 @@ func TestPRPlanUsesAMD64(t *testing.T) { } } +func TestBuildPlanFailsWhenNoPortIsAvailable(t *testing.T) { + _, err := BuildPlan(Options{Tag: "dev", FindPort: func(int) int { return 0 }}) + if err == nil { + t.Fatal("expected no-free-port error") + } +} + func TestStartPRIntegration(t *testing.T) { if os.Getenv("HOMARR_DOCKER_INTEGRATION_TEST") != "1" { t.Skip("set HOMARR_DOCKER_INTEGRATION_TEST=1 and HOMARR_TEST_PR=") diff --git a/tools/homarr-dev/internal/tui/dashboard.go b/tools/homarr-dev/internal/tui/dashboard.go index 5642dde0a..a709d87dd 100644 --- a/tools/homarr-dev/internal/tui/dashboard.go +++ b/tools/homarr-dev/internal/tui/dashboard.go @@ -1,6 +1,7 @@ package tui import ( + "context" "fmt" "os/exec" "strings" @@ -40,6 +41,7 @@ type dashModel struct { type tickMsg time.Time type actionMsg string +type rowsMsg []table.Row type logsMsg struct { name string content string @@ -54,7 +56,9 @@ func fetchLogs(name string) tea.Cmd { if name == "" { return logsMsg{} } - out, err := exec.Command("docker", "logs", "--tail", "200", name).CombinedOutput() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", name).CombinedOutput() if err != nil { return logsMsg{name: name, content: "logs unavailable: " + err.Error()} } @@ -62,6 +66,10 @@ func fetchLogs(name string) tea.Cmd { } } +func loadRows() tea.Cmd { + return func() tea.Msg { return rowsMsg(refreshRows()) } +} + func refreshRows() []table.Row { containers, _ := docker.List() rows := make([]table.Row, 0, len(containers)) @@ -103,7 +111,6 @@ func dashboardColumns(width int) []table.Column { func newDashModel() dashModel { t := table.New( table.WithColumns(dashboardColumns(104)), - table.WithRows(refreshRows()), table.WithFocused(true), table.WithHeight(10), table.WithWidth(104), @@ -122,8 +129,7 @@ func newDashModel() dashModel { styles.Focused.Prompt = styles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true) styles.Focused.Text = styles.Focused.Text.Foreground(lipgloss.Color("229")) filter.SetStyles(styles) - rows := refreshRows() - return dashModel{table: t, allRows: rows, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30} + return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30} } func (m *dashModel) applyFilter() { @@ -141,11 +147,6 @@ func (m *dashModel) applyFilter() { m.table.SetRows(rows) } -func (m *dashModel) refresh() { - m.allRows = refreshRows() - m.applyFilter() -} - func (m dashModel) selected() string { row := m.table.SelectedRow() if len(row) == 0 { @@ -154,8 +155,16 @@ func (m dashModel) selected() string { return row[0] } +func (m dashModel) selectedPort() string { + row := m.table.SelectedRow() + if len(row) < 4 { + return "" + } + return row[3] +} + func (m dashModel) Init() tea.Cmd { - return tea.Batch(tick(), fetchLogs(m.selected())) + return tea.Batch(tick(), loadRows(), fetchLogs(m.selected())) } func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -170,8 +179,11 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.layout() return m, nil case tickMsg: - m.refresh() - return m, tea.Batch(tick(), fetchLogs(m.selected())) + return m, tea.Batch(tick(), loadRows()) + case rowsMsg: + m.allRows = []table.Row(msg) + m.applyFilter() + return m, fetchLogs(m.selected()) case logsMsg: if msg.name != "" && msg.name != m.selected() { return m, nil @@ -184,8 +196,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case actionMsg: m.status = string(msg) m.confirmRemove = "" - m.refresh() - return m, nil + return m, loadRows() case tea.KeyPressMsg: if m.filtering { switch msg.Key().Code { @@ -278,7 +289,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "o": if name != "" { - if port := portOf(name); port != "" { + if port := m.selectedPort(); port != "" { if err := platform.OpenURL("http://localhost:" + port); err != nil { m.status = "open failed: " + err.Error() } @@ -286,7 +297,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "c": if name != "" { - if port := portOf(name); port != "" { + if port := m.selectedPort(); port != "" { url := "http://localhost:" + port if err := platform.CopyText(url); err != nil { m.status = "copy failed: " + err.Error() @@ -296,8 +307,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case "r": - m.refresh() - return m, fetchLogs(name) + return m, loadRows() case "k": m.table.MoveUp(1) return m, fetchLogs(m.selected()) @@ -348,16 +358,6 @@ func (m dashModel) selectedDetailView() string { return title + "\n" + helpStyle.Render(meta) } -func portOf(name string) string { - containers, _ := docker.List() - for _, c := range containers { - if c.Name == name { - return c.HostPort() - } - } - return "" -} - func (m dashModel) View() tea.View { var b strings.Builder running := 0 @@ -372,7 +372,7 @@ func (m dashModel) View() tea.View { b.WriteString(m.filter.View() + "\n\n") } if len(m.table.Rows()) == 0 { - message := "no homarr containers — start one with `homarr ` or `homarr --pr N`" + message := "no homarr containers — start one with `homarr run ` or `homarr run --pr N`" if len(m.allRows) > 0 { message = "no instances match " + m.filter.Value() } diff --git a/tools/homarr-dev/internal/tui/dashboard_test.go b/tools/homarr-dev/internal/tui/dashboard_test.go index 412b6c669..4456dc117 100644 --- a/tools/homarr-dev/internal/tui/dashboard_test.go +++ b/tools/homarr-dev/internal/tui/dashboard_test.go @@ -16,6 +16,7 @@ func TestDashboardRendersRowsAndLogs(t *testing.T) { "homarr-test:pr-6441", "7575", "http://localhost:7575", + "Up 2 minutes", }}) m.logs.SetWidth(104) m.logs.SetHeight(5) @@ -35,8 +36,8 @@ func TestDashboardRendersRowsAndLogs(t *testing.T) { func TestDashboardArrowNavigation(t *testing.T) { m := newDashModel() m.table.SetRows([]table.Row{ - {"homarr_first", "running", "homarr:first", "7575", ""}, - {"homarr_second", "running", "homarr:second", "7576", ""}, + {"homarr_first", "running", "homarr:first", "7575", "", "Up 1 minute"}, + {"homarr_second", "running", "homarr:second", "7576", "", "Up 1 minute"}, }) updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) @@ -48,7 +49,7 @@ func TestDashboardArrowNavigation(t *testing.T) { func TestDashboardIgnoresStaleLogs(t *testing.T) { m := newDashModel() - m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", ""}}) + m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", "", "Up 1 minute"}}) m.logs.SetWidth(104) m.logs.SetHeight(5) m.logs.SetContent("current logs") diff --git a/tools/homarr-dev/internal/tui/dev.go b/tools/homarr-dev/internal/tui/dev.go index 37ac675ce..2ec3f54e7 100644 --- a/tools/homarr-dev/internal/tui/dev.go +++ b/tools/homarr-dev/internal/tui/dev.go @@ -109,6 +109,10 @@ type rebuildDoneMsg struct { err error canceled bool } +type runningRefreshedMsg struct { + rows []prRow + generation int +} type pullReadyMsg struct{} type prLogTickMsg struct{} type pullEvent struct { @@ -129,7 +133,7 @@ func splitDockerProgress(data []byte, atEOF bool) (advance int, token []byte, er return 0, nil, nil } -func startPull(cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd { +func startPull(ctx context.Context, cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd { return func() tea.Msg { reader, writer, err := os.Pipe() if err != nil { @@ -150,7 +154,7 @@ func startPull(cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd { scanner.Split(splitDockerProgress) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) - if line != "" { + if line != "" && ctx.Err() == nil && len(events) < cap(events)-1 { events <- pullEvent{line: line} } } @@ -180,8 +184,10 @@ func prLogTick() tea.Cmd { func loadDev(includeBots bool) tea.Cmd { return func() tea.Msg { - prs, prErr := gh.ListPRs(50, includeBots) - images, imgErr := docker.ListLocalImages() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + prs, prErr := gh.ListPRs(ctx, 50, includeBots) + images, imgErr := docker.ListLocalImages(ctx) return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr} } } @@ -422,7 +428,7 @@ func (m *prsModel) refreshSelectedLogs() tea.Cmd { } func (m *prsModel) beginPull(row prRow) tea.Cmd { - plan, err := run.BuildPlan(run.Options{PR: row.pr.Number, Demo: m.demo}) + plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo}) if err != nil { m.status = err.Error() return nil @@ -433,7 +439,7 @@ func (m *prsModel) beginPull(row prRow) tea.Cmd { } } ctx, cancel := context.WithCancel(context.Background()) - events := make(chan pullEvent) + events := make(chan pullEvent, 128) m.pulling = true m.pullPlan = plan m.pullCancel = cancel @@ -450,7 +456,7 @@ func (m *prsModel) beginPull(row prRow) tea.Cmd { m.status = fmt.Sprintf("pulling latest image for PR #%d…", row.pr.Number) } m.layout() - return tea.Batch(startPull(docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick) + return tea.Batch(startPull(ctx, docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick) } func (m *prsModel) appendPullLine(line string) { @@ -488,24 +494,27 @@ func (m prsModel) Init() tea.Cmd { return tea.Batch(loadDev(m.includeBots), m.spinner.Tick) } -func refreshRunning(rows []prRow) []prRow { - containers, _ := docker.List() - for i, r := range rows { - name := fmt.Sprintf("homarr_pr_%d", r.pr.Number) - if r.kind == "local" { - name = "homarr_" + r.local.Tag - } - r.running = false - r.port = "" - for _, c := range containers { - if c.Name == name && c.Running() { - r.running = true - r.port = c.HostPort() +func refreshRunning(rows []prRow, generation int) tea.Cmd { + return func() tea.Msg { + containers, _ := docker.List() + updated := append([]prRow(nil), rows...) + for index, row := range updated { + name := fmt.Sprintf("homarr_pr_%d", row.pr.Number) + if row.kind == "local" { + name = "homarr_" + row.local.Tag } + row.running = false + row.port = "" + for _, container := range containers { + if container.Name == name && container.Running() { + row.running = true + row.port = container.HostPort() + } + } + updated[index] = row } - rows[i] = r + return runningRefreshedMsg{rows: updated, generation: generation} } - return rows } func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -542,9 +551,8 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, image := range msg.images { m.rows = append(m.rows, prRow{kind: "local", local: image, imageState: "yes"}) } - m.rows = refreshRunning(m.rows) m.applyFilter() - return m, m.startImageChecks() + return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen)) case imageCheckedMsg: if msg.gen != m.imageGen { return m, nil @@ -566,11 +574,19 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.startImageChecks() case prActionMsg: m.status = msg.text - m.rows = refreshRunning(m.rows) + return m, refreshRunning(m.rows, m.imageGen) + case runningRefreshedMsg: + if msg.generation != m.imageGen { + return m, nil + } + m.rows = msg.rows m.applyFilter() return m, m.refreshSelectedLogs() case rebuildDoneMsg: m.rebuilding = false + if m.rebuildCancel != nil { + m.rebuildCancel() + } m.rebuildCancel = nil m.loading = true if msg.canceled { @@ -604,6 +620,9 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.pulling = false m.pullEvents = nil + if m.pullCancel != nil { + m.pullCancel() + } m.pullCancel = nil m.layout() if msg.err != nil { @@ -619,6 +638,9 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pullCanceled = false plan := m.pullPlan m.pullPlan = nil + if plan == nil { + return m, nil + } for i := range m.rows { if m.rows[i].pr.Number == plan.PRNumber { m.rows[i].imageState = "yes" @@ -731,7 +753,7 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.beginPull(*row) } - plan, err := run.BuildPlan(run.Options{Tag: row.local.Tag, Demo: m.demo}) + plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo}) if err != nil { m.status = err.Error() return m, nil @@ -897,10 +919,11 @@ func (m prsModel) selectedDetailView() string { } func truncateText(value string, length int) string { - if len(value) <= length { + runes := []rune(value) + if len(runes) <= length { return value } - return value[:length] + return string(runes[:length]) } func (m prsModel) View() tea.View { diff --git a/tools/homarr-dev/internal/tui/dev_test.go b/tools/homarr-dev/internal/tui/dev_test.go index 51cdcc8ce..f0652c90a 100644 --- a/tools/homarr-dev/internal/tui/dev_test.go +++ b/tools/homarr-dev/internal/tui/dev_test.go @@ -115,3 +115,21 @@ func TestRebuildCanBeCanceled(t *testing.T) { t.Fatalf("cancel state: canceled=%v status=%q", canceled, got.status) } } + +func TestCompletedPullWithoutPlanDoesNotPanic(t *testing.T) { + m := newPRsModel() + m.pulling = true + m.pullCancel = func() {} + + updated, cmd := m.Update(pullEvent{done: true}) + got := updated.(prsModel) + if got.pulling || cmd != nil { + t.Fatalf("pulling=%v cmd=%v", got.pulling, cmd) + } +} + +func TestTruncateTextPreservesUTF8(t *testing.T) { + if got := truncateText("🦞homarr", 2); got != "🦞h" { + t.Fatalf("truncateText() = %q", got) + } +} diff --git a/tools/homarr-dev/main.go b/tools/homarr-dev/main.go index 3199f4660..b103f52fc 100644 --- a/tools/homarr-dev/main.go +++ b/tools/homarr-dev/main.go @@ -105,11 +105,11 @@ var buildCmd = &cobra.Command{ } var rebuildCmd = &cobra.Command{ - Use: "rebuild [name]", + Use: "rebuild ", Short: "Rebuild a local image from its recorded checkout or pull request", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - images, err := docker.ListLocalImages() + images, err := docker.ListLocalImages(cmd.Context()) if err != nil { return err } @@ -123,7 +123,7 @@ var rebuildCmd = &cobra.Command{ } var logsCmd = &cobra.Command{ - Use: "logs [container]", + Use: "logs ", Short: "Follow logs of a running instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -132,7 +132,7 @@ var logsCmd = &cobra.Command{ } var stopCmd = &cobra.Command{ - Use: "stop [container]", + Use: "stop ", Short: "Stop a running instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -141,7 +141,7 @@ var stopCmd = &cobra.Command{ } var restartCmd = &cobra.Command{ - Use: "restart [container]", + Use: "restart ", Short: "Restart a running instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -150,7 +150,7 @@ var restartCmd = &cobra.Command{ } var removeCmd = &cobra.Command{ - Use: "remove [container]", + Use: "remove ", Short: "Force-remove an instance", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -159,7 +159,7 @@ var removeCmd = &cobra.Command{ } var openCmd = &cobra.Command{ - Use: "open [container]", + Use: "open ", Short: "Open a running instance in the browser", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -168,11 +168,19 @@ var openCmd = &cobra.Command{ return err } for _, container := range containers { - if container.Name == args[0] && container.Running() && container.HostPort() != "" { - return platform.OpenURL("http://localhost:" + container.HostPort()) + 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("running container %q has no Homarr port", args[0]) + return fmt.Errorf("container %q was not found", args[0]) }, } @@ -253,10 +261,14 @@ func runLaunch(cmd *cobra.Command, args []string) error { return fmt.Errorf("tag and --pr cannot be used together") } if flagPR == 0 && tag == "" { - return cmd.Usage() + 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: flagPR, Tag: tag, Demo: flagDemo, @@ -267,8 +279,6 @@ func runLaunch(cmd *cobra.Command, args []string) error { return err } - run.SetTerminalChrome(plan.TabTitle) - fmt.Printf("Starting Homarr (%s)\n", plan.Label) fmt.Printf(" Image : %s\n", plan.Image) fmt.Printf(" Container : %s\n", plan.Name) diff --git a/tools/homarr-dev/main_test.go b/tools/homarr-dev/main_test.go index dc6d0bcf0..c1806075d 100644 --- a/tools/homarr-dev/main_test.go +++ b/tools/homarr-dev/main_test.go @@ -26,3 +26,14 @@ func TestRunLaunchRejectsNegativePR(t *testing.T) { t.Fatalf("error = %v", err) } } + +func TestRunLaunchRejectsMissingImage(t *testing.T) { + previousPR := flagPR + t.Cleanup(func() { flagPR = previousPR }) + flagPR = 0 + + err := runLaunch(runCmd, nil) + if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") { + t.Fatalf("error = %v", err) + } +}