feat(dev): add Docker development CLI

This commit is contained in:
Thomas Camlong committed 2026-07-31 14:28:31 +02:00
1 parent 61128ddcc8
commit e97eb7fc58
26 files changed
+3277 -11

No files matched your search

+10
View File
@@ -78,6 +78,16 @@ jobs:
- name: Setup
uses: ./tooling/github/setup
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: tools/homarr-dev/go.mod
cache-dependency-path: tools/homarr-dev/go.sum
- name: Validate developer CLI
working-directory: tools/homarr-dev
run: go test -race ./... && go vet ./...
- name: Copy environment defaults
run: cp .env.example .env
+8
View File
@@ -46,6 +46,8 @@ homarr/
├── tooling/
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── tools/
│ └── homarr-dev/ # Go CLI for local and PR Docker images
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
@@ -68,6 +70,7 @@ When modifying user-facing code, you MUST also update the corresponding document
## Monorepo Commands
- `pnpm dev` — Next.js app only
- `pnpm dev:setup` — install dependencies, prepare Redis and SQLite, and install the `homarr` developer CLI
- `pnpm dev:docs` — Docusaurus docs site only
- `pnpm turbo build` — build all packages
- `pnpm turbo build --filter=@homarr/docs` — build docs only
@@ -81,3 +84,8 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `homarr dev` to browse local `homarr:*` images and remote PR images.
- Run `homarr build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `homarr build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
- Changes to `tools/homarr-dev` are validated by the fast-gate job in `.github/workflows/ci.yml`.
@@ -16,7 +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/).
- **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/).
- **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/).
@@ -32,23 +34,22 @@ This guide will help you set up your development environment to start contributi
3. Change directory to the project folder with `cd homarr`.
4. Install the dependencies with `pnpm install`.
4. Set up the project with `pnpm dev:setup`. This installs dependencies, starts Redis, creates and migrates a local SQLite database, and installs the `homarr` developer CLI.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
5. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
9. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time)
6. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time)
### Useful npm scripts
#### During development
- `pnpm dev`: Start the development server.
- `pnpm dev:setup`: Set up dependencies, services, database, and developer CLI.
- `homarr dev`: Browse and run local images and remote pull-request images.
- `homarr build <name>`: Build the current checkout as `homarr:<name>`.
- `homarr build --pr <number>`: Build a pull request locally from a temporary checkout.
- `homarr rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
@@ -63,6 +64,7 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker.
#### Building the project
- `pnpm build`: Build the project for production.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
- `homarr build <name>`: Build a `homarr:<name>` 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.
+1
View File
@@ -21,6 +21,7 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:setup": "bash scripts/setup-dev.sh",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
for command in corepack go docker gh; do
if ! command -v "$command" >/dev/null 2>&1; then
printf 'Required command not found: %s\n' "$command" >&2
exit 1
fi
done
if [ ! -f "$repository_root/.env" ]; then
database_directory="$HOME/.local/share/homarr"
database_path="$database_directory/dev.sqlite"
mkdir -p "$database_directory"
while IFS= read -r line || [ -n "$line" ]; do
printf '%s\n' "${line/FULL_PATH_TO_YOUR_SQLITE_DB_FILE/$database_path}"
done < "$repository_root/.env.example" > "$repository_root/.env"
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
printf 'Redis is already available on port 6379.\n'
fi
pnpm --dir "$repository_root" db:migration:sqlite:run
mkdir -p "$HOME/.local/bin"
(
cd "$repository_root/tools/homarr-dev"
go build -trimpath -o "$HOME/.local/bin/homarr" .
)
if [ "$(uname -s)" = "Darwin" ]; then
codesign --sign - --force "$HOME/.local/bin/homarr"
fi
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'
+94
View File
@@ -0,0 +1,94 @@
# Homarr Developer CLI
Build and launch local Homarr images and GHCR pull-request builds, then manage them from responsive terminal dashboards.
## Requirements
- Docker or OrbStack
- GitHub CLI authenticated with `gh auth login`
- Go 1.25+ to build from source
## Setup
```sh
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.
## Build
```sh
homarr build feature
homarr build --pr 6441
homarr rebuild feature
```
Checkout builds are tagged as `homarr:<name>`. The source checkout and revision are recorded on the image so `homarr rebuild` and the development dashboard can rebuild it later. PR builds use a temporary checkout and default to `homarr:pr-<number>`.
## Launch
```sh
homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr run --env FOO=bar --env FEATURE=true dev
```
PR launches always pull the latest GHCR tag before starting. Local tags remain local.
## Commands
```text
homarr dash Interactive instance dashboard
homarr dev Interactive local and remote image browser
homarr build <name> Build this checkout as homarr:<name>
homarr build --pr <number> Build a PR locally from a temporary checkout
homarr rebuild <name> Rebuild from recorded provenance
homarr list Script-friendly instance list
homarr logs <container> Follow logs
homarr open <container> Open in browser
homarr stop <container> Stop
homarr restart <container> Restart
homarr remove <container> Force-remove
homarr doctor Check Docker, GitHub auth, and PATH
```
## Development Browser
```text
/ Filter by PR number, title, author, branch, CI, image, state, or port
↑/↓ or j/k Select
Enter or Space Pull latest image and start, or stop when already running
R Rebuild a local image from its checkout or PR
p Pull a remote PR image and redeploy, preserving port and volume
m Toggle demo mode for the next launch
o Open PR on GitHub
a Open running app
l Toggle inline logs for the selected running PR
f Follow or pause inline logs
Page Up/Down Scroll inline logs
b Show or hide bot PRs
r Refresh
q Quit; during a pull, cancel it
```
Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI.
## Instance Dashboard
```text
/ Filter by name, image, state, health, port, or URL
↑/↓ or j/k Select
l Toggle inline logs
f Follow or pause logs
Page Up/Down Scroll logs
s Stop
R Restart
x Remove with confirmation
o Open in browser
c Copy URL
r Refresh
q Quit
```
+32
View File
@@ -0,0 +1,32 @@
module github.com/homarr-labs/homarr/tools/homarr-dev
go 1.25.0
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/lipgloss/v2 v2.0.5
github.com/atotto/clipboard v0.1.4
github.com/spf13/cobra v1.10.2
)
require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
)
+56
View File
@@ -0,0 +1,56 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+317
View File
@@ -0,0 +1,317 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
"strconv"
"strings"
)
type Container struct {
ID string
Name string
Image string
Status string
Ports string
State string
}
func (c Container) HostPort() string {
for _, mapping := range strings.Split(c.Ports, ", ") {
if strings.HasSuffix(mapping, "->7575/tcp") {
parts := strings.Split(mapping, "->")
left := parts[0]
if i := strings.LastIndex(left, ":"); i >= 0 {
return left[i+1:]
}
return left
}
}
return ""
}
func (c Container) Running() bool {
return c.State == "running"
}
func List() ([]Container, error) {
out, err := exec.Command("docker", "ps", "-a",
"--filter", "name=homarr",
"--format", "{{json .}}").Output()
if err != nil {
return nil, err
}
var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
var raw struct {
ID string
Names string
Image string
Status string
Ports string
State string
}
if err := json.Unmarshal([]byte(line), &raw); err != nil {
continue
}
containers = append(containers, Container{
ID: raw.ID,
Name: raw.Names,
Image: raw.Image,
Status: raw.Status,
Ports: raw.Ports,
State: raw.State,
})
}
filtered := containers[:0]
for _, c := range containers {
if c.Name == "homarr" || strings.HasPrefix(c.Name, "homarr_") {
filtered = append(filtered, c)
}
}
return filtered, nil
}
func FindFreePort(start int) int {
used := usedDockerPorts()
for port := start; ; port++ {
if used[port] {
continue
}
listener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", port))
if err == nil {
_ = listener.Close()
return port
}
}
}
func usedDockerPorts() map[int]bool {
used := make(map[int]bool)
out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output()
if err != nil {
return used
}
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
}
}
for port := first; port <= last; port++ {
used[port] = true
}
}
return used
}
func IsRunning(name string) bool {
containers, _ := List()
for _, c := range containers {
if c.Name == name && c.Running() {
return true
}
}
return false
}
type StartOptions struct {
Name string
Image string
Volume string
HostPort int
Env []string
Platform string
PullAlways bool
Daemon bool
}
func Start(opts StartOptions) error {
if opts.PullAlways {
cmd := PullCommand(opts.Image, opts.Platform)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("pull %s: %w", opts.Image, err)
}
}
_ = exec.Command("docker", "rm", "-f", opts.Name).Run()
args := []string{"run", "--rm", "--name", opts.Name,
"-e", "SECRET_ENCRYPTION_KEY=cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe",
}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
}
if opts.Daemon {
args = append(args, "-d")
} else {
args = append(args, "-it")
}
for _, env := range opts.Env {
args = append(args, "-e", env)
}
args = append(args,
"-v", opts.Volume+":/appdata",
"-v", "/var/run/docker.sock:/var/run/docker.sock:ro",
"-p", fmt.Sprintf("%d:7575", opts.HostPort),
opts.Image,
)
cmd := exec.Command("docker", args...)
if opts.Daemon {
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, cleanOutput(out))
}
return nil
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
stderr := &tailBuffer{}
cmd.Stderr = io.MultiWriter(os.Stderr, stderr)
if err := cmd.Run(); err != nil {
return fmt.Errorf("%w: %s", err, cleanOutput(stderr.data))
}
return nil
}
type tailBuffer struct {
data []byte
}
func (b *tailBuffer) Write(p []byte) (int, error) {
const limit = 32 * 1024
b.data = append(b.data, p...)
if len(b.data) > limit {
b.data = b.data[len(b.data)-limit:]
}
return len(p), nil
}
func cleanOutput(out []byte) string {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "WARNING: The requested image's platform") || isContainerID(line) {
continue
}
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, "\n")
}
func isContainerID(value string) bool {
if len(value) != 64 {
return false
}
for _, r := range value {
if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
return false
}
}
return true
}
func PullCommand(image, platform string) *exec.Cmd {
return PullCommandContext(context.Background(), image, platform)
}
func PullCommandContext(ctx context.Context, image, platform string) *exec.Cmd {
args := []string{"pull"}
if platform != "" {
args = append(args, "--platform", platform)
}
args = append(args, image)
cmd := exec.CommandContext(ctx, "docker", args...)
if platform != "" {
cmd.Env = append(os.Environ(), "DOCKER_DEFAULT_PLATFORM="+platform)
}
return cmd
}
func IsPortConflict(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "port is already allocated") ||
strings.Contains(message, "address already in use") ||
strings.Contains(message, "failed programming external connectivity")
}
func Stop(name string) error {
cmd := exec.Command("docker", "stop", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func Restart(name string) error {
cmd := exec.Command("docker", "restart", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func Remove(name string) error {
cmd := exec.Command("docker", "rm", "-f", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func CheckImage(image string) (bool, error) {
cmd := exec.Command("docker", "manifest", "inspect", image)
out, err := cmd.CombinedOutput()
if err == nil {
return true, nil
}
message := strings.ToLower(string(out))
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
return false, nil
}
return false, fmt.Errorf("check image %s: %w: %s", image, err, strings.TrimSpace(string(out)))
}
func FollowLogs(name string) error {
cmd := exec.Command("docker", "logs", "-f", "--tail", "100", name)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
@@ -0,0 +1,27 @@
package docker
import (
"errors"
"testing"
)
func TestContainerHostPort(t *testing.T) {
c := Container{Ports: "0.0.0.0:7576->7575/tcp, [::]:7576->7575/tcp"}
if got := c.HostPort(); got != "7576" {
t.Fatalf("HostPort() = %q, want 7576", got)
}
}
func TestIsPortConflict(t *testing.T) {
for _, message := range []string{
"Bind for 0.0.0.0:7575 failed: port is already allocated",
"driver failed programming external connectivity on endpoint",
} {
if !IsPortConflict(errors.New(message)) {
t.Fatalf("expected port conflict for %q", message)
}
}
if IsPortConflict(errors.New("manifest unknown")) {
t.Fatal("manifest error classified as port conflict")
}
}
+153
View File
@@ -0,0 +1,153 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
const (
SourceLabel = "org.homarr.dev.source"
RevisionLabel = "org.homarr.dev.revision"
PRLabel = "org.homarr.dev.pr"
)
var tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
type Image struct {
ID string
Tag string
Source string
Revision string
PRNumber int
}
func (image Image) Reference() string {
return "homarr:" + image.Tag
}
type imageListRow struct {
ID string
Repository string
Tag string
}
func parseImageList(output []byte) ([]Image, error) {
images := make([]Image, 0)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
var row imageListRow
if err := json.Unmarshal([]byte(line), &row); err != nil {
return nil, fmt.Errorf("parse local Docker image: %w", err)
}
if row.Repository == "homarr" && row.Tag != "<none>" {
images = append(images, Image{ID: row.ID, Tag: row.Tag})
}
}
return images, nil
}
func ListLocalImages() ([]Image, error) {
out, err := exec.Command("docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("list local Homarr images: %w", err)
}
images, err := parseImageList(out)
if err != nil {
return nil, err
}
labelsByImage, err := inspectImageLabels(images)
if err != nil {
return nil, err
}
for index, labels := range labelsByImage {
images[index].Source = labels[SourceLabel]
images[index].Revision = labels[RevisionLabel]
images[index].PRNumber, _ = strconv.Atoi(labels[PRLabel])
}
return images, nil
}
func inspectImageLabels(images []Image) ([]map[string]string, error) {
if len(images) == 0 {
return nil, nil
}
args := []string{"image", "inspect", "--format", "{{json .Config.Labels}}"}
for _, image := range images {
args = append(args, image.Reference())
}
out, err := exec.Command("docker", args...).Output()
if err != nil {
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(images) {
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
}
labelsByImage := make([]map[string]string, len(images))
for index, line := range lines {
labelsByImage[index] = make(map[string]string)
if line == "null" {
continue
}
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
}
}
return labelsByImage, nil
}
type BuildOptions struct {
Context string
Tag string
Source string
Revision string
PRNumber int
}
func BuildCommand(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
if !tagPattern.MatchString(options.Tag) {
return nil, fmt.Errorf("invalid image name %q", options.Tag)
}
contextPath, err := filepath.Abs(options.Context)
if err != nil {
return nil, fmt.Errorf("resolve build context: %w", err)
}
if info, err := os.Stat(filepath.Join(contextPath, "Dockerfile")); err != nil || info.IsDir() {
return nil, fmt.Errorf("%s does not contain a Dockerfile", contextPath)
}
args := []string{"build", "--label", SourceLabel + "=" + options.Source, "--label", RevisionLabel + "=" + options.Revision}
if options.PRNumber > 0 {
args = append(args, "--label", fmt.Sprintf("%s=%d", PRLabel, options.PRNumber))
}
args = append(args, "-t", "homarr:"+options.Tag, ".")
command := exec.CommandContext(ctx, "docker", args...)
command.Dir = contextPath
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 {
return err
}
command.Stdout = stdout
command.Stderr = stderr
if err := command.Run(); err != nil {
return fmt.Errorf("build homarr:%s: %w", options.Tag, err)
}
return nil
}
@@ -0,0 +1,69 @@
package docker
import (
"os"
"testing"
)
func TestParseImageListFiltersHomarrTags(t *testing.T) {
output := []byte("{\"ID\":\"sha256:1\",\"Repository\":\"homarr\",\"Tag\":\"dev\"}\n{\"ID\":\"sha256:2\",\"Repository\":\"other\",\"Tag\":\"dev\"}\n")
images, err := parseImageList(output)
if err != nil {
t.Fatal(err)
}
if len(images) != 1 || images[0].Reference() != "homarr:dev" {
t.Fatalf("images = %#v, want homarr:dev", images)
}
}
func TestInspectImageLabelsWithNoImages(t *testing.T) {
labels, err := inspectImageLabels(nil)
if err != nil {
t.Fatal(err)
}
if labels != nil {
t.Fatalf("labels = %#v, want nil", labels)
}
}
func TestBuildCommandIncludesProvenance(t *testing.T) {
command, err := BuildCommand(t.Context(), BuildOptions{
Context: "../../../..",
Tag: "feature",
Source: "/tmp/homarr",
Revision: "abc123",
PRNumber: 42,
})
if err != nil {
t.Fatal(err)
}
want := []string{"docker", "build", "--label", SourceLabel + "=/tmp/homarr", "--label", RevisionLabel + "=abc123", "--label", PRLabel + "=42", "-t", "homarr:feature", "."}
if len(command.Args) != len(want) {
t.Fatalf("args = %v, want %v", command.Args, want)
}
for index := range want {
if command.Args[index] != want[index] {
t.Fatalf("args = %v, want %v", command.Args, want)
}
}
}
func TestListLocalImagesIntegration(t *testing.T) {
wantTag := os.Getenv("HOMARR_TEST_LOCAL_IMAGE")
if wantTag == "" {
t.Skip("set HOMARR_TEST_LOCAL_IMAGE to a local homarr image tag")
}
images, err := ListLocalImages()
if err != nil {
t.Fatal(err)
}
for _, image := range images {
if image.Tag == wantTag {
if image.Source == "" || image.Revision == "" {
t.Fatalf("image %#v is missing provenance", image)
}
return
}
}
t.Fatalf("homarr:%s was not discovered", wantTag)
}
+135
View File
@@ -0,0 +1,135 @@
package gh
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
const Repo = "homarr-labs/homarr"
func isBot(login string) bool {
return strings.HasSuffix(login, "[bot]") || strings.HasPrefix(login, "app/")
}
type PR struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
HeadRef string `json:"headRefName"`
CIState string `json:"ciState"`
IsDraft bool `json:"isDraft"`
UpdatedAt string `json:"updatedAt"`
}
type rawPR struct {
Number int `json:"number"`
Title string `json:"title"`
IsDraft bool `json:"isDraft"`
Author struct {
Login string `json:"login"`
} `json:"author"`
HeadRefName string `json:"headRefName"`
UpdatedAt string `json:"updatedAt"`
StatusCheckRollup []rawCheck `json:"statusCheckRollup"`
}
type rawCheck struct {
Status string `json:"status"`
Conclusion string `json:"conclusion"`
State string `json:"state"`
}
func rollupState(checks []rawCheck) string {
if len(checks) == 0 {
return "NONE"
}
failed, pending := 0, 0
for _, c := range checks {
if c.State != "" {
switch c.State {
case "SUCCESS":
case "PENDING", "EXPECTED":
pending++
default:
failed++
}
continue
}
if c.Status != "COMPLETED" {
pending++
continue
}
switch c.Conclusion {
case "SUCCESS", "SKIPPED", "NEUTRAL":
default:
failed++
}
}
if failed > 0 {
return "FAILURE"
}
if pending > 0 {
return "PENDING"
}
return "SUCCESS"
}
func ListPRs(limit int, includeBots bool) ([]PR, error) {
out, err := exec.Command("gh", "pr", "list",
"--repo", Repo,
"--state", "open",
"--limit", fmt.Sprint(limit),
"--json", "number,title,author,headRefName,updatedAt,isDraft,statusCheckRollup",
).Output()
if err != nil {
return nil, err
}
var raws []rawPR
if err := json.Unmarshal(out, &raws); err != nil {
return nil, err
}
prs := make([]PR, 0, len(raws))
for _, r := range raws {
if !includeBots && isBot(r.Author.Login) {
continue
}
prs = append(prs, PR{
Number: r.Number,
Title: r.Title,
Author: r.Author.Login,
HeadRef: r.HeadRefName,
CIState: rollupState(r.StatusCheckRollup),
IsDraft: r.IsDraft,
UpdatedAt: r.UpdatedAt,
})
}
return prs, nil
}
func GetPR(number int) (*PR, error) {
out, err := exec.Command("gh", "pr", "view", fmt.Sprint(number),
"--repo", Repo,
"--json", "number,title,author,headRefName,updatedAt,isDraft,statusCheckRollup",
).Output()
if err != nil {
return nil, err
}
var r rawPR
if err := json.Unmarshal(out, &r); err != nil {
return nil, err
}
return &PR{
Number: r.Number,
Title: r.Title,
Author: r.Author.Login,
HeadRef: r.HeadRefName,
CIState: rollupState(r.StatusCheckRollup),
IsDraft: r.IsDraft,
UpdatedAt: r.UpdatedAt,
}, nil
}
+62
View File
@@ -0,0 +1,62 @@
package gh
import (
"os"
"testing"
)
func TestIsBot(t *testing.T) {
tests := []struct {
login string
want bool
}{
{login: "app/homarr-renovate", want: true},
{login: "dependabot[bot]", want: true},
{login: "ajnart", want: false},
}
for _, test := range tests {
if got := isBot(test.login); got != test.want {
t.Errorf("isBot(%q) = %v, want %v", test.login, got, test.want)
}
}
}
func TestRollupStateHandlesChecksAndStatuses(t *testing.T) {
tests := []struct {
name string
checks []rawCheck
want string
}{
{name: "check success", checks: []rawCheck{{Status: "COMPLETED", Conclusion: "SUCCESS"}}, want: "SUCCESS"},
{name: "status failure", checks: []rawCheck{{State: "FAILURE"}}, want: "FAILURE"},
{name: "status pending", checks: []rawCheck{{State: "PENDING"}}, want: "PENDING"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := rollupState(test.checks); got != test.want {
t.Fatalf("rollupState() = %q, want %q", got, test.want)
}
})
}
}
func TestListPRsExcludesBotsIntegration(t *testing.T) {
if os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
t.Skip("set HOMARR_INTEGRATION_TEST=1 to query GitHub")
}
prs, err := ListPRs(50, false)
if err != nil {
t.Fatal(err)
}
if len(prs) == 0 {
t.Fatal("expected at least one human-authored open PR")
}
for _, pr := range prs {
if isBot(pr.Author) {
t.Fatalf("bot PR was not filtered: #%d by %s", pr.Number, pr.Author)
}
}
}
@@ -0,0 +1,7 @@
package platform
import "github.com/atotto/clipboard"
func CopyText(value string) error {
return clipboard.WriteAll(value)
}
@@ -0,0 +1,28 @@
package platform
import (
"fmt"
"os/exec"
"runtime"
)
func OpenURL(url string) error {
name, args, err := openCommand(runtime.GOOS, url)
if err != nil {
return err
}
return exec.Command(name, args...).Start()
}
func openCommand(goos, url string) (string, []string, error) {
switch goos {
case "darwin":
return "open", []string{url}, nil
case "linux":
return "xdg-open", []string{url}, nil
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
default:
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
}
}
@@ -0,0 +1,25 @@
package platform
import "testing"
func TestOpenCommand(t *testing.T) {
tests := []struct {
goos string
name string
}{
{goos: "darwin", name: "open"},
{goos: "linux", name: "xdg-open"},
{goos: "windows", name: "rundll32"},
}
for _, test := range tests {
t.Run(test.goos, func(t *testing.T) {
name, args, err := openCommand(test.goos, "https://example.com")
if err != nil {
t.Fatal(err)
}
if name != test.name || len(args) == 0 {
t.Fatalf("command = %s %v", name, args)
}
})
}
}
+101
View File
@@ -0,0 +1,101 @@
package run
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
func BuildCurrentImage(tag, directory string) error {
return buildCurrentImage(context.Background(), tag, directory, os.Stdout, os.Stderr)
}
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
root, revision, err := checkoutDetails(ctx, directory)
if err != nil {
return err
}
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
}
func BuildPRImage(number int, tag string) error {
return buildPRImage(context.Background(), number, tag, os.Stdout, os.Stderr)
}
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
if number <= 0 {
return fmt.Errorf("PR number must be positive")
}
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
if err != nil {
return fmt.Errorf("create temporary checkout: %w", err)
}
defer os.RemoveAll(temporaryRoot)
checkout := filepath.Join(temporaryRoot, "homarr")
clone := exec.CommandContext(ctx, "gh", "repo", "clone", gh.Repo, checkout, "--", "--filter=blob:none")
clone.Stdout = stdout
clone.Stderr = stderr
if err := clone.Run(); err != nil {
return fmt.Errorf("clone %s: %w", gh.Repo, err)
}
command := exec.CommandContext(ctx, "gh", "pr", "checkout", strconv.Itoa(number), "--detach")
command.Dir = checkout
command.Stdout = stdout
command.Stderr = stderr
if err := command.Run(); err != nil {
return fmt.Errorf("checkout PR #%d: %w", number, err)
}
_, revision, err := checkoutDetails(ctx, checkout)
if err != nil {
return err
}
return docker.BuildContext(ctx, docker.BuildOptions{
Context: checkout,
Tag: tag,
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
Revision: revision,
PRNumber: number,
}, stdout, stderr)
}
func RebuildImage(image docker.Image) error {
return RebuildImageContext(context.Background(), image, os.Stdout, os.Stderr)
}
func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr io.Writer) error {
if image.PRNumber > 0 {
return buildPRImage(ctx, image.PRNumber, image.Tag, stdout, stderr)
}
if image.Source == "" {
return fmt.Errorf("homarr:%s has no build source metadata", image.Tag)
}
if info, err := os.Stat(image.Source); err != nil || !info.IsDir() {
return fmt.Errorf("build source %s is no longer available", image.Source)
}
return buildCurrentImage(ctx, image.Tag, image.Source, stdout, stderr)
}
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
}
root = strings.TrimSpace(string(rootOutput))
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
}
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
}
return root, strings.TrimSpace(string(revisionOutput)), nil
}
+138
View File
@@ -0,0 +1,138 @@
package run
import (
"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 {
PR int
Tag string
Demo bool
Env []string
FetchTitle bool
}
type Plan struct {
Image string
Name string
Volume string
Label string
TabTitle string
HostPort int
Env []string
Platform string
Pull bool
PRNumber int
}
func BuildPlan(opts Options) (*Plan, error) {
p := &Plan{}
if opts.PR > 0 {
p.PRNumber = opts.PR
p.Image = fmt.Sprintf("ghcr.io/homarr-labs/homarr-test:pr-%d", opts.PR)
p.Name = fmt.Sprintf("homarr_pr_%d", opts.PR)
p.Volume = fmt.Sprintf("homarr_pr_%d_data", opts.PR)
p.Label = fmt.Sprintf("PR #%d", opts.PR)
p.Pull = true
p.Platform = "linux/amd64"
if opts.FetchTitle {
if pr, err := gh.GetPR(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 != "" {
p.Image = "homarr:" + opts.Tag
p.Name = "homarr_" + opts.Tag
p.Volume = "homarr_" + opts.Tag + "_data"
p.Label = "tag: " + opts.Tag
} else {
return nil, fmt.Errorf("need a tag or --pr")
}
if p.TabTitle == "" {
p.TabTitle = "🦞 " + strings.TrimPrefix(p.Label, "tag: ")
}
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")
p.Label += " (demo)"
}
return p, nil
}
func StartDetached(p *Plan) error {
return start(p, true)
}
func StartForeground(p *Plan) error {
return start(p, false)
}
func start(p *Plan, daemon bool) error {
const attempts = 5
for attempt := 0; attempt < attempts; attempt++ {
err := docker.Start(docker.StartOptions{
Name: p.Name,
Image: p.Image,
Volume: p.Volume,
HostPort: p.HostPort,
Env: p.Env,
Platform: p.Platform,
PullAlways: p.Pull && attempt == 0,
Daemon: daemon,
})
if err == nil {
return nil
}
if !docker.IsPortConflict(err) || attempt == attempts-1 {
if docker.IsPortConflict(err) {
return fmt.Errorf("unable to bind a host port after %d attempts; restart Docker or OrbStack: %w", attempts, err)
}
return err
}
previousPort := p.HostPort
p.HostPort = docker.FindFreePort(p.HostPort + 1)
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]) + "…"
}
+44
View File
@@ -0,0 +1,44 @@
package run
import (
"os"
"strconv"
"testing"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
)
func TestPRPlanUsesAMD64(t *testing.T) {
plan, err := BuildPlan(Options{PR: 1})
if err != nil {
t.Fatal(err)
}
if plan.Platform != "linux/amd64" {
t.Fatalf("platform = %q, want linux/amd64", plan.Platform)
}
}
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=<number>")
}
pr, err := strconv.Atoi(os.Getenv("HOMARR_TEST_PR"))
if err != nil || pr <= 0 {
t.Fatal("HOMARR_TEST_PR must be a positive PR number")
}
plan, err := BuildPlan(Options{PR: pr})
if err != nil {
t.Fatal(err)
}
plan.Name += "_integration"
plan.Volume += "_integration"
defer docker.Remove(plan.Name)
if err := StartDetached(plan); err != nil {
t.Fatal(err)
}
if !docker.IsRunning(plan.Name) {
t.Fatalf("container %s did not remain running", plan.Name)
}
}
+417
View File
@@ -0,0 +1,417 @@
package tui
import (
"fmt"
"os/exec"
"strings"
"time"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
logHeadSt = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
runningLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("● running")
stoppedLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("○ stopped")
)
type dashModel struct {
table table.Model
allRows []table.Row
filter textinput.Model
filtering bool
logs viewport.Model
showLogs bool
follow bool
status string
confirmRemove string
width int
height int
}
type tickMsg time.Time
type actionMsg string
type logsMsg struct {
name string
content string
}
func tick() tea.Cmd {
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func fetchLogs(name string) tea.Cmd {
return func() tea.Msg {
if name == "" {
return logsMsg{}
}
out, err := exec.Command("docker", "logs", "--tail", "200", name).CombinedOutput()
if err != nil {
return logsMsg{name: name, content: "logs unavailable: " + err.Error()}
}
return logsMsg{name: name, content: string(out)}
}
}
func refreshRows() []table.Row {
containers, _ := docker.List()
rows := make([]table.Row, 0, len(containers))
for _, c := range containers {
port := c.HostPort()
url := ""
if port != "" && c.Running() {
url = "http://localhost:" + port
}
state := "● " + c.State
if !c.Running() {
state = "○ " + c.State
}
img := strings.TrimPrefix(c.Image, "ghcr.io/homarr-labs/")
rows = append(rows, table.Row{c.Name, state, img, port, url, c.Status})
}
return rows
}
func dashboardColumns(width int) []table.Column {
nameWidth := 26
imageWidth := 30
urlWidth := 24
if width < 100 {
nameWidth = 22
imageWidth = max(width-54, 18)
urlWidth = 0
}
return []table.Column{
{Title: "NAME", Width: nameWidth},
{Title: "STATE", Width: 14},
{Title: "IMAGE", Width: imageWidth},
{Title: "PORT", Width: 6},
{Title: "URL", Width: urlWidth},
{Title: "STATUS", Width: 0},
}
}
func newDashModel() dashModel {
t := table.New(
table.WithColumns(dashboardColumns(104)),
table.WithRows(refreshRows()),
table.WithFocused(true),
table.WithHeight(10),
table.WithWidth(104),
)
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
t.SetStyles(s)
logs := viewport.New(viewport.WithWidth(104), viewport.WithHeight(12))
logs.SoftWrap = true
filter := textinput.New()
filter.Prompt = "/ "
filter.Placeholder = "filter name, image, port, state…"
filter.SetWidth(50)
styles := textinput.DefaultDarkStyles()
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}
}
func (m *dashModel) applyFilter() {
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
if query == "" {
m.table.SetRows(m.allRows)
return
}
rows := make([]table.Row, 0, len(m.allRows))
for _, row := range m.allRows {
if strings.Contains(strings.ToLower(strings.Join(row, " ")), query) {
rows = append(rows, row)
}
}
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 {
return ""
}
return row[0]
}
func (m dashModel) Init() tea.Cmd {
return tea.Batch(tick(), fetchLogs(m.selected()))
}
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.table.SetColumns(dashboardColumns(msg.Width))
m.table.SetWidth(msg.Width)
m.logs.SetWidth(msg.Width)
m.filter.SetWidth(max(min(msg.Width-4, 60), 10))
m.layout()
return m, nil
case tickMsg:
m.refresh()
return m, tea.Batch(tick(), fetchLogs(m.selected()))
case logsMsg:
if msg.name != "" && msg.name != m.selected() {
return m, nil
}
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
if m.follow {
m.logs.GotoBottom()
}
return m, nil
case actionMsg:
m.status = string(msg)
m.confirmRemove = ""
m.refresh()
return m, nil
case tea.KeyPressMsg:
if m.filtering {
switch msg.Key().Code {
case tea.KeyEscape:
m.filtering = false
m.filter.Reset()
m.filter.Blur()
m.applyFilter()
return m, fetchLogs(m.selected())
case tea.KeyEnter:
m.filtering = false
m.filter.Blur()
return m, fetchLogs(m.selected())
}
var cmd tea.Cmd
m.filter, cmd = m.filter.Update(msg)
m.applyFilter()
return m, tea.Batch(cmd, fetchLogs(m.selected()))
}
name := m.selected()
switch msg.Key().Code {
case tea.KeyUp, tea.KeyKpUp:
m.table.MoveUp(1)
return m, fetchLogs(m.selected())
case tea.KeyDown, tea.KeyKpDown:
m.table.MoveDown(1)
return m, fetchLogs(m.selected())
case tea.KeyPgUp, tea.KeyKpPgUp:
m.follow = false
m.logs.PageUp()
return m, nil
case tea.KeyPgDown, tea.KeyKpPgDown:
m.logs.PageDown()
m.follow = m.logs.AtBottom()
return m, nil
}
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "l":
m.showLogs = !m.showLogs
m.layout()
if m.showLogs {
return m, fetchLogs(name)
}
return m, nil
case "/":
m.filtering = true
return m, m.filter.Focus()
case "f":
m.follow = !m.follow
if m.follow {
m.logs.GotoBottom()
}
return m, nil
case "s":
if name != "" {
m.status = "stopping " + name + "…"
return m, func() tea.Msg {
if err := docker.Stop(name); err != nil {
return actionMsg("stop failed: " + err.Error())
}
return actionMsg("stopped " + name)
}
}
case "x":
if name != "" {
if m.confirmRemove != name {
m.confirmRemove = name
m.status = "press x again to remove " + name
return m, nil
}
m.status = "removing " + name + "…"
return m, func() tea.Msg {
if err := docker.Remove(name); err != nil {
return actionMsg("remove failed: " + err.Error())
}
return actionMsg("removed " + name)
}
}
case "R":
if name != "" {
m.status = "restarting " + name + "…"
return m, func() tea.Msg {
if err := docker.Restart(name); err != nil {
return actionMsg("restart failed: " + err.Error())
}
return actionMsg("restarted " + name)
}
}
case "o":
if name != "" {
if port := portOf(name); port != "" {
if err := platform.OpenURL("http://localhost:" + port); err != nil {
m.status = "open failed: " + err.Error()
}
}
}
case "c":
if name != "" {
if port := portOf(name); port != "" {
url := "http://localhost:" + port
if err := platform.CopyText(url); err != nil {
m.status = "copy failed: " + err.Error()
} else {
m.status = "copied " + url
}
}
}
case "r":
m.refresh()
return m, fetchLogs(name)
case "k":
m.table.MoveUp(1)
return m, fetchLogs(m.selected())
case "j":
m.table.MoveDown(1)
return m, fetchLogs(m.selected())
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *dashModel) layout() {
available := m.height - 11
if m.filtering || m.filter.Value() != "" {
available -= 2
}
if !m.showLogs {
m.table.SetHeight(max(available, 5))
return
}
tableH := min(12, max(available/2, 5))
m.table.SetHeight(tableH)
logsH := available - tableH
if logsH < 3 {
logsH = 3
}
m.logs.SetHeight(logsH)
}
func (m dashModel) selectedDetailView() string {
row := m.table.SelectedRow()
if len(row) < 6 {
return ""
}
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row[0])
meta := row[2] + " · " + row[1]
if row[3] != "" {
meta += " · port " + row[3]
}
if row[4] != "" {
meta += " · " + row[4]
}
if row[5] != "" {
meta += " · " + row[5]
}
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
for _, row := range m.allRows {
if len(row) > 1 && strings.Contains(row[1], "running") {
running++
}
}
b.WriteString(titleStyle.Render("🦞 homarr instances") + " " + helpStyle.Render(fmt.Sprintf("%d total · %d running · %d shown", len(m.allRows), running, len(m.table.Rows()))))
b.WriteString("\n\n")
if m.filtering || m.filter.Value() != "" {
b.WriteString(m.filter.View() + "\n\n")
}
if len(m.table.Rows()) == 0 {
message := "no homarr containers — start one with `homarr <tag>` or `homarr --pr N`"
if len(m.allRows) > 0 {
message = "no instances match " + m.filter.Value()
}
b.WriteString(helpStyle.Render(" " + message + "\n"))
} else {
b.WriteString(m.table.View())
b.WriteString("\n")
}
if detail := m.selectedDetailView(); detail != "" {
b.WriteString("\n" + detail + "\n")
}
if m.showLogs && len(m.table.Rows()) > 0 {
mode := "paused"
if m.follow {
mode = "following"
}
b.WriteString("\n" + logHeadSt.Render("── logs: "+m.selected()+" ("+mode+") ──") + "\n")
b.WriteString(m.logs.View())
}
if m.status != "" {
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
lower := strings.ToLower(m.status)
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") {
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
} else if strings.Contains(lower, "press x") {
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
}
b.WriteString("\n" + statusStyle.Render(m.status))
}
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · q quit"))
b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend))
v := tea.NewView(b.String())
v.AltScreen = true
v.WindowTitle = "🦞 Homarr instances"
return v
}
func RunDashboard() error {
p := tea.NewProgram(newDashModel())
_, err := p.Run()
return err
}
@@ -0,0 +1,61 @@
package tui
import (
"strings"
"testing"
"charm.land/bubbles/v2/table"
tea "charm.land/bubbletea/v2"
)
func TestDashboardRendersRowsAndLogs(t *testing.T) {
m := newDashModel()
m.table.SetRows([]table.Row{{
"homarr_pr_6441",
"running",
"homarr-test:pr-6441",
"7575",
"http://localhost:7575",
}})
m.logs.SetWidth(104)
m.logs.SetHeight(5)
m.logs.SetContent("server ready")
got := m.View().Content
for _, want := range []string{"homarr_pr_6441", "server ready"} {
if !strings.Contains(got, want) {
t.Fatalf("dashboard omitted %q: %q", want, got)
}
}
if !m.View().AltScreen {
t.Fatal("dashboard must use the alternate screen")
}
}
func TestDashboardArrowNavigation(t *testing.T) {
m := newDashModel()
m.table.SetRows([]table.Row{
{"homarr_first", "running", "homarr:first", "7575", ""},
{"homarr_second", "running", "homarr:second", "7576", ""},
})
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
got := updated.(dashModel).selected()
if got != "homarr_second" {
t.Fatalf("down arrow selected %q, want homarr_second", got)
}
}
func TestDashboardIgnoresStaleLogs(t *testing.T) {
m := newDashModel()
m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", ""}})
m.logs.SetWidth(104)
m.logs.SetHeight(5)
m.logs.SetContent("current logs")
updated, _ := m.Update(logsMsg{name: "homarr_old", content: "stale logs"})
got := updated.(dashModel).View().Content
if strings.Contains(got, "stale logs") || !strings.Contains(got, "current logs") {
t.Fatalf("stale log result replaced current content: %q", got)
}
}
+998
View File
@@ -0,0 +1,998 @@
package tui
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/table"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"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"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
)
const (
ciOK = "✓"
ciFail = "✗"
ciPend = "●"
ciNone = "–"
imgYes = "✓"
imgNo = "…"
imgCheck = "?"
imgError = "!"
runMark = "▶"
idleMark = " "
)
var (
legendCIYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(ciOK)
legendCINo = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(ciFail)
legendCIPend = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(ciPend)
legendNone = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(ciNone)
legendImageYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(imgYes)
legendImageNo = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(imgNo)
)
type prRow struct {
kind string
pr gh.PR
local docker.Image
imageState string
running bool
port string
}
type prsModel struct {
table table.Model
filter textinput.Model
filtering bool
pullView viewport.Model
pulling bool
pullLines []string
pullLayers map[string]string
pullProgress progress.Model
pullCanceled bool
pullEvents <-chan pullEvent
pullPlan *run.Plan
pullCancel context.CancelFunc
rebuilding bool
rebuildCancel context.CancelFunc
logs viewport.Model
showLogs bool
followLogs bool
logsName string
spinner spinner.Model
rows []prRow
loading bool
status string
includeBots bool
demo bool
width int
height int
imageQueue []int
imageActive int
imageGen int
}
type prsLoadedMsg struct {
prs []gh.PR
images []docker.Image
prErr error
imgErr error
}
type imageCheckedMsg struct {
number int
exists bool
err error
gen int
}
type prActionMsg struct {
text string
}
type rebuildDoneMsg struct {
image docker.Image
err error
canceled bool
}
type pullReadyMsg struct{}
type prLogTickMsg struct{}
type pullEvent struct {
line string
done bool
err error
}
func splitDockerProgress(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i, b := range data {
if b == '\n' || b == '\r' {
return i + 1, data[:i], nil
}
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil
}
func startPull(cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd {
return func() tea.Msg {
reader, writer, err := os.Pipe()
if err != nil {
return pullEvent{done: true, err: err}
}
cmd.Stdout = writer
cmd.Stderr = writer
if err := cmd.Start(); err != nil {
_ = reader.Close()
_ = writer.Close()
return pullEvent{done: true, err: err}
}
_ = writer.Close()
go func() {
defer close(events)
defer reader.Close()
scanner := bufio.NewScanner(reader)
scanner.Split(splitDockerProgress)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
events <- pullEvent{line: line}
}
}
err := cmd.Wait()
if err == nil {
err = scanner.Err()
}
events <- pullEvent{done: true, err: err}
}()
return pullReadyMsg{}
}
}
func waitPull(events <-chan pullEvent) tea.Cmd {
return func() tea.Msg {
event, ok := <-events
if !ok {
return nil
}
return event
}
}
func prLogTick() tea.Cmd {
return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return prLogTickMsg{} })
}
func loadDev(includeBots bool) tea.Cmd {
return func() tea.Msg {
prs, prErr := gh.ListPRs(50, includeBots)
images, imgErr := docker.ListLocalImages()
return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr}
}
}
func checkImage(number, generation int) tea.Cmd {
return func() tea.Msg {
img := fmt.Sprintf("ghcr.io/homarr-labs/homarr-test:pr-%d", number)
exists, err := docker.CheckImage(img)
return imageCheckedMsg{number: number, exists: exists, err: err, gen: generation}
}
}
func (m *prsModel) startImageChecks() tea.Cmd {
const concurrency = 6
cmds := make([]tea.Cmd, 0, concurrency)
for m.imageActive < concurrency && len(m.imageQueue) > 0 {
number := m.imageQueue[0]
m.imageQueue = m.imageQueue[1:]
m.imageActive++
cmds = append(cmds, checkImage(number, m.imageGen))
}
return tea.Batch(cmds...)
}
func ciIcon(state string) string {
switch state {
case "SUCCESS":
return ciOK
case "FAILURE":
return ciFail
case "PENDING":
return ciPend
default:
return ciNone
}
}
func buildTableRows(rows []prRow) []table.Row {
out := make([]table.Row, 0, len(rows))
for _, r := range rows {
mark := idleMark
port := ""
if r.running {
mark = runMark
port = r.port
}
img := imgCheck
switch r.imageState {
case "yes":
img = imgYes
case "no":
img = imgNo
case "error":
img = imgError
}
source := "remote"
ref := fmt.Sprint(r.pr.Number)
title := r.pr.Title
author := r.pr.Author
ci := ciIcon(r.pr.CIState)
if r.kind == "local" {
source = "local"
ref = r.local.Tag
title = r.local.Reference()
author = "untracked build"
if r.local.Source != "" {
author = filepath.Base(r.local.Source)
}
ci = ciNone
} else if r.pr.IsDraft {
title = "DRAFT · " + title
}
if len([]rune(title)) > 44 {
title = string([]rune(title)[:43]) + "…"
}
out = append(out, table.Row{
mark,
source,
ref,
title,
author,
ci,
img,
port,
})
}
return out
}
func prColumns(width int) []table.Column {
authorWidth := 16
portWidth := 6
if width < 90 {
authorWidth = 0
}
if width < 72 {
portWidth = 0
}
titleWidth := max(min(width-60, 45), 18)
return []table.Column{
{Title: "", Width: 2},
{Title: "SOURCE", Width: 8},
{Title: "REF", Width: 12},
{Title: "TITLE", Width: titleWidth},
{Title: "AUTHOR", Width: authorWidth},
{Title: "CI", Width: 3},
{Title: "IMAGE", Width: 5},
{Title: "PORT", Width: portWidth},
}
}
func newPRsModel() prsModel {
t := table.New(
table.WithColumns(prColumns(110)),
table.WithFocused(true),
table.WithHeight(18),
table.WithWidth(110),
)
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
t.SetStyles(s)
sp := spinner.New()
sp.Spinner = spinner.Dot
filter := textinput.New()
filter.Prompt = "/ "
filter.Placeholder = "filter source, PR, image, title, author, branch, CI, port…"
filter.SetWidth(65)
filterStyles := textinput.DefaultDarkStyles()
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true)
filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(lipgloss.Color("229"))
filter.SetStyles(filterStyles)
pullView := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7))
pullView.SoftWrap = true
pullView.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
pullProgress := progress.New(progress.WithWidth(60), progress.WithColors(lipgloss.Color("212")))
logs := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7))
logs.SoftWrap = true
logs.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
return prsModel{table: t, filter: filter, pullView: pullView, pullLayers: make(map[string]string), pullProgress: pullProgress, logs: logs, followLogs: true, spinner: sp, loading: true, status: "loading development images…", width: 110, height: 32}
}
func (m *prsModel) layout() {
reserved := 12
if m.filtering || m.filter.Value() != "" {
reserved += 2
}
if m.pulling {
reserved += 9
}
if m.showLogs {
reserved += 9
}
m.table.SetHeight(max(m.height-reserved, 6))
m.pullView.SetWidth(max(m.width-2, 20))
m.pullView.SetHeight(7)
m.pullProgress.SetWidth(max(min(m.width-12, 70), 20))
m.logs.SetWidth(max(m.width-2, 20))
m.logs.SetHeight(7)
}
func (m *prsModel) applyFilter() {
rendered := buildTableRows(m.rows)
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
if query == "" {
m.table.SetRows(rendered)
return
}
rows := make([]table.Row, 0, len(rendered))
for i, row := range m.rows {
terms := []string{
row.kind,
fmt.Sprint(row.pr.Number),
fmt.Sprintf("#%d", row.pr.Number),
fmt.Sprintf("pr-%d", row.pr.Number),
row.pr.Title,
row.pr.Author,
row.pr.HeadRef,
row.pr.CIState,
row.imageState,
row.port,
row.local.Tag,
row.local.Source,
row.local.Revision,
}
if row.pr.IsDraft {
terms = append(terms, "draft")
}
if row.running {
terms = append(terms, "running started")
}
if strings.Contains(strings.ToLower(strings.Join(terms, " ")), query) {
rows = append(rows, rendered[i])
}
}
m.table.SetRows(rows)
}
func (m prsModel) selectedRow() *prRow {
selected := m.table.SelectedRow()
if len(selected) < 3 {
return nil
}
for i := range m.rows {
if m.rows[i].kind == selected[1] && ((selected[1] == "remote" && fmt.Sprint(m.rows[i].pr.Number) == selected[2]) || (selected[1] == "local" && m.rows[i].local.Tag == selected[2])) {
return &m.rows[i]
}
}
return nil
}
func (m prsModel) selectedPR() *prRow {
row := m.selectedRow()
if row == nil || row.kind != "remote" {
return nil
}
return row
}
func (m *prsModel) refreshSelectedLogs() tea.Cmd {
row := m.selectedRow()
if row == nil || !row.running {
m.logsName = ""
m.logs.SetContent("selected image is not running")
return nil
}
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
if row.kind == "local" {
name = "homarr_" + row.local.Tag
}
if name != m.logsName {
m.logsName = name
m.logs.SetContent("loading logs…")
}
return fetchLogs(name)
}
func (m *prsModel) beginPull(row prRow) tea.Cmd {
plan, err := run.BuildPlan(run.Options{PR: row.pr.Number, Demo: m.demo})
if err != nil {
m.status = err.Error()
return nil
}
if row.running && row.port != "" {
if port, err := strconv.Atoi(row.port); err == nil {
plan.HostPort = port
}
}
ctx, cancel := context.WithCancel(context.Background())
events := make(chan pullEvent)
m.pulling = true
m.pullPlan = plan
m.pullCancel = cancel
m.pullEvents = events
m.pullLines = nil
m.pullLayers = make(map[string]string)
m.pullCanceled = false
m.pullView.SetContent("")
m.showLogs = false
m.logsName = ""
if row.running {
m.status = fmt.Sprintf("pulling latest image and redeploying PR #%d…", row.pr.Number)
} else {
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)
}
func (m *prsModel) appendPullLine(line string) {
const retainedLines = 40
m.pullLines = append(m.pullLines, line)
if len(m.pullLines) > retainedLines {
m.pullLines = m.pullLines[len(m.pullLines)-retainedLines:]
}
m.pullView.SetContent(strings.Join(m.pullLines, "\n"))
m.pullView.GotoBottom()
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
layer := strings.TrimSpace(parts[0])
if len(layer) == 12 {
m.pullLayers[layer] = strings.TrimSpace(parts[1])
}
}
}
func (m prsModel) pullCompletion() (complete, total int, percent float64) {
for _, state := range m.pullLayers {
total++
lower := strings.ToLower(state)
if strings.Contains(lower, "pull complete") || strings.Contains(lower, "already exists") {
complete++
}
}
if total > 0 {
percent = float64(complete) / float64(total)
}
return complete, total, percent
}
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()
}
}
rows[i] = r
}
return rows
}
func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.table.SetColumns(prColumns(msg.Width))
m.table.SetWidth(msg.Width)
m.filter.SetWidth(max(min(msg.Width-4, 75), 10))
m.layout()
return m, nil
case prsLoadedMsg:
if msg.prErr != nil && msg.imgErr != nil {
m.loading = false
m.status = "failed to load development images: " + msg.prErr.Error() + "; " + msg.imgErr.Error()
return m, nil
}
m.loading = false
m.status = fmt.Sprintf("%d remote PRs · %d local images", len(msg.prs), len(msg.images))
if msg.prErr != nil {
m.status = "remote PRs unavailable: " + msg.prErr.Error()
} else if msg.imgErr != nil {
m.status = "local images unavailable: " + msg.imgErr.Error()
}
m.rows = make([]prRow, 0, len(msg.prs)+len(msg.images))
m.imageGen++
m.imageQueue = m.imageQueue[:0]
m.imageActive = 0
for _, pr := range msg.prs {
m.rows = append(m.rows, prRow{kind: "remote", pr: pr, imageState: "checking"})
m.imageQueue = append(m.imageQueue, pr.Number)
}
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()
case imageCheckedMsg:
if msg.gen != m.imageGen {
return m, nil
}
m.imageActive--
for i, r := range m.rows {
if r.pr.Number == msg.number {
if msg.err != nil {
m.rows[i].imageState = "error"
m.status = msg.err.Error()
} else if msg.exists {
m.rows[i].imageState = "yes"
} else {
m.rows[i].imageState = "no"
}
}
}
m.applyFilter()
return m, m.startImageChecks()
case prActionMsg:
m.status = msg.text
m.rows = refreshRunning(m.rows)
m.applyFilter()
return m, m.refreshSelectedLogs()
case rebuildDoneMsg:
m.rebuilding = false
m.rebuildCancel = nil
m.loading = true
if msg.canceled {
m.status = "rebuild canceled"
} else if msg.err != nil {
m.status = "rebuild failed: " + msg.err.Error()
} else {
m.status = "rebuilt " + msg.image.Reference()
}
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
case logsMsg:
if !m.showLogs || msg.name == "" || msg.name != m.logsName {
return m, nil
}
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
if m.followLogs {
m.logs.GotoBottom()
}
return m, nil
case prLogTickMsg:
if !m.showLogs {
return m, nil
}
return m, tea.Batch(m.refreshSelectedLogs(), prLogTick())
case pullReadyMsg:
return m, waitPull(m.pullEvents)
case pullEvent:
if !msg.done {
m.appendPullLine(msg.line)
return m, waitPull(m.pullEvents)
}
m.pulling = false
m.pullEvents = nil
m.pullCancel = nil
m.layout()
if msg.err != nil {
if m.pullCanceled {
m.status = "pull canceled"
} else {
m.status = "pull failed: " + msg.err.Error()
}
m.pullCanceled = false
m.pullPlan = nil
return m, nil
}
m.pullCanceled = false
plan := m.pullPlan
m.pullPlan = nil
for i := range m.rows {
if m.rows[i].pr.Number == plan.PRNumber {
m.rows[i].imageState = "yes"
}
}
m.applyFilter()
m.status = fmt.Sprintf("starting PR #%d…", plan.PRNumber)
return m, func() tea.Msg {
plan.Pull = false
if err := run.StartDetached(plan); err != nil {
return prActionMsg{"start failed: " + err.Error()}
}
return prActionMsg{fmt.Sprintf("started PR #%d → http://localhost:%d", plan.PRNumber, plan.HostPort)}
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
if m.loading || m.pulling || m.rebuilding {
return m, cmd
}
return m, nil
case tea.KeyPressMsg:
if m.rebuilding {
switch msg.String() {
case "q", "ctrl+c", "esc":
if m.rebuildCancel != nil {
m.rebuildCancel()
}
m.status = "canceling rebuild…"
}
return m, nil
}
if m.pulling {
switch msg.String() {
case "q", "ctrl+c", "esc":
if m.pullCancel != nil {
m.pullCanceled = true
m.pullCancel()
}
m.status = "canceling pull…"
}
return m, nil
}
if m.filtering {
switch msg.Key().Code {
case tea.KeyEscape:
m.filtering = false
m.filter.Reset()
m.filter.Blur()
m.applyFilter()
m.layout()
return m, nil
case tea.KeyEnter:
m.filtering = false
m.filter.Blur()
m.layout()
return m, nil
}
var cmd tea.Cmd
m.filter, cmd = m.filter.Update(msg)
m.applyFilter()
return m, cmd
}
switch msg.Key().Code {
case tea.KeyUp, tea.KeyKpUp:
m.table.MoveUp(1)
return m, m.refreshSelectedLogs()
case tea.KeyDown, tea.KeyKpDown:
m.table.MoveDown(1)
return m, m.refreshSelectedLogs()
case tea.KeyPgUp, tea.KeyKpPgUp:
if m.showLogs {
m.followLogs = false
m.logs.PageUp()
}
return m, nil
case tea.KeyPgDown, tea.KeyKpPgDown:
if m.showLogs {
m.logs.PageDown()
m.followLogs = m.logs.AtBottom()
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
case "enter", "space":
row := m.selectedRow()
if row == nil {
return m, nil
}
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
label := fmt.Sprintf("PR #%d", row.pr.Number)
if row.kind == "local" {
name = "homarr_" + row.local.Tag
label = row.local.Reference()
}
if row.running {
return m, func() tea.Msg {
if err := docker.Stop(name); err != nil {
return prActionMsg{"stop failed: " + err.Error()}
}
return prActionMsg{"stopped " + label}
}
}
if row.kind == "remote" {
if row.imageState != "yes" {
m.status = fmt.Sprintf("PR #%d cannot start: image is %s", row.pr.Number, row.imageState)
return m, nil
}
return m, m.beginPull(*row)
}
plan, err := run.BuildPlan(run.Options{Tag: row.local.Tag, Demo: m.demo})
if err != nil {
m.status = err.Error()
return m, nil
}
return m, func() tea.Msg {
if err := run.StartDetached(plan); err != nil {
return prActionMsg{"start failed: " + err.Error()}
}
return prActionMsg{fmt.Sprintf("started %s → http://localhost:%d", label, plan.HostPort)}
}
case "r":
m.loading = true
m.status = "refreshing…"
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
case "/":
m.filtering = true
m.layout()
return m, m.filter.Focus()
case "o":
row := m.selectedPR()
if row != nil {
if err := platform.OpenURL(fmt.Sprintf("https://github.com/homarr-labs/homarr/pull/%d", row.pr.Number)); err != nil {
m.status = "open failed: " + err.Error()
}
}
case "a":
row := m.selectedRow()
if row != nil && row.running && row.port != "" {
if err := platform.OpenURL("http://localhost:" + row.port); err != nil {
m.status = "open failed: " + err.Error()
}
} else {
m.status = "selected image is not running"
}
case "l":
m.showLogs = !m.showLogs
m.layout()
if m.showLogs {
m.followLogs = true
return m, tea.Batch(m.refreshSelectedLogs(), prLogTick())
}
return m, nil
case "f":
if m.showLogs {
m.followLogs = !m.followLogs
if m.followLogs {
m.logs.GotoBottom()
}
} else {
m.status = "open logs with l first"
}
case "p":
row := m.selectedPR()
if row != nil {
return m, m.beginPull(*row)
} else {
m.status = "pull is only available for remote PR images"
}
case "R":
row := m.selectedRow()
if row == nil || row.kind != "local" {
m.status = "rebuild is only available for local images"
return m, nil
}
image := row.local
ctx, cancel := context.WithCancel(context.Background())
m.rebuilding = true
m.rebuildCancel = cancel
m.status = "rebuilding " + image.Reference() + "…"
rebuild := func() tea.Msg {
err := run.RebuildImageContext(ctx, image, io.Discard, io.Discard)
return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil}
}
return m, tea.Batch(rebuild, m.spinner.Tick)
case "m":
m.demo = !m.demo
if m.demo {
m.status = "demo mode enabled for the next launch"
} else {
m.status = "demo mode disabled"
}
case "k":
m.table.MoveUp(1)
return m, m.refreshSelectedLogs()
case "j":
m.table.MoveDown(1)
return m, m.refreshSelectedLogs()
case "b":
m.includeBots = !m.includeBots
m.loading = true
if m.includeBots {
m.status = "refreshing (bots shown)…"
} else {
m.status = "refreshing (bots hidden)…"
}
return m, tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func coloredCI(state string) string {
switch state {
case "SUCCESS":
return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ success")
case "FAILURE":
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("✗ failed")
case "PENDING":
return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("● pending")
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("– no checks")
}
}
func coloredImage(state string) string {
switch state {
case "yes":
return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ available")
case "no":
return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("… not built")
case "error":
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("! check failed")
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("? checking")
}
}
func (m prsModel) selectedDetailView() string {
row := m.selectedRow()
if row == nil {
return ""
}
if row.kind == "local" {
source := row.local.Source
if source == "" {
source = "unknown source · rebuild unavailable"
}
meta := "local · " + source
if row.local.Revision != "" {
meta += " · " + truncateText(row.local.Revision, 12)
}
if row.local.PRNumber > 0 {
meta += fmt.Sprintf(" · PR #%d", row.local.PRNumber)
}
if row.running {
meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port)
}
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row.local.Reference())
return title + "\n" + meta
}
branch := row.pr.HeadRef
if branch == "" {
branch = "unknown branch"
}
meta := "@" + row.pr.Author + " · " + branch + " · CI " + coloredCI(row.pr.CIState) + " · Image " + coloredImage(row.imageState)
if row.running {
meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port)
}
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(fmt.Sprintf("#%d %s", row.pr.Number, row.pr.Title))
return title + "\n" + meta
}
func truncateText(value string, length int) string {
if len(value) <= length {
return value
}
return value[:length]
}
func (m prsModel) View() tea.View {
var b strings.Builder
header := titleStyle.Render("🦞 homarr development")
if m.loading {
header += " " + m.spinner.View()
}
running := 0
remote := 0
local := 0
for _, row := range m.rows {
if row.kind == "local" {
local++
} else {
remote++
}
if row.running {
running++
}
}
header += " " + helpStyle.Render(fmt.Sprintf("%d remote · %d local · %d running · %d shown", remote, local, running, len(m.table.Rows())))
if m.demo {
header += " " + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render("DEMO")
}
b.WriteString(header + "\n\n")
if m.filtering || m.filter.Value() != "" {
b.WriteString(m.filter.View() + "\n\n")
}
if len(m.table.Rows()) > 0 {
b.WriteString(m.table.View() + "\n")
} else if len(m.rows) > 0 {
b.WriteString(helpStyle.Render("no development images match "+m.filter.Value()) + "\n")
}
if detail := m.selectedDetailView(); detail != "" {
b.WriteString("\n" + detail + "\n")
}
if m.pulling {
label := "Pulling image"
if m.pullPlan != nil {
label = fmt.Sprintf("Pulling PR #%d", m.pullPlan.PRNumber)
}
b.WriteString("\n" + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(m.spinner.View()+" "+label) + "\n")
complete, total, percent := m.pullCompletion()
if total > 0 {
b.WriteString(m.pullProgress.ViewAs(percent) + " " + helpStyle.Render(fmt.Sprintf("%d/%d layers", complete, total)) + "\n")
}
b.WriteString(m.pullView.View() + "\n")
b.WriteString(helpStyle.Render("q/esc cancel download") + "\n")
}
if m.showLogs {
mode := "paused"
if m.followLogs {
mode = "following"
}
name := m.logsName
if name == "" {
name = "selected image"
}
b.WriteString("\n" + logHeadSt.Render("── logs: "+name+" ("+mode+") ──") + "\n")
b.WriteString(m.logs.View() + "\n")
}
if m.status != "" {
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
lower := strings.ToLower(m.status)
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") || strings.Contains(lower, "cannot") {
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
} else if m.pulling || m.rebuilding || strings.Contains(lower, "starting") || strings.Contains(lower, "refreshing") {
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
}
b.WriteString("\n" + statusStyle.Render(m.status))
}
botState := "bots hidden"
if m.includeBots {
botState = "bots shown"
}
if m.rebuilding {
b.WriteString("\n\n" + helpStyle.Render("q/esc cancel rebuild"))
} else if !m.pulling {
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R rebuild local · p pull remote · m demo · o PR · a app"))
b.WriteString("\n" + helpStyle.Render("b "+botState+" · r refresh · q quit"))
b.WriteString("\n" + helpStyle.Render("l toggle logs · f follow · pgup/pgdn scroll"))
}
b.WriteString("\n" + helpStyle.Render("SOURCE: local Docker / remote GHCR · CI: "+legendCIYes+" pass "+legendCINo+" fail "+legendCIPend+" pending "+legendNone+" none · IMAGE: "+legendImageYes+" available "+legendImageNo+" not built"))
v := tea.NewView(b.String())
v.AltScreen = true
v.WindowTitle = "🦞 Homarr Development"
return v
}
func RunDev() error {
p := tea.NewProgram(newPRsModel())
_, err := p.Run()
return err
}
+117
View File
@@ -0,0 +1,117 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
func TestPRTableRendersRows(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{{
kind: "remote",
pr: gh.PR{Number: 6441, Title: "Human PR", Author: "ajnart", CIState: "SUCCESS"},
imageState: "yes",
}}
m.table.SetRows(buildTableRows(m.rows))
if got := m.View().Content; !strings.Contains(got, "Human PR") {
t.Fatalf("table view omitted rows: %q", got)
}
if !m.View().AltScreen {
t.Fatal("development browser must use the alternate screen")
}
}
func TestPRTableArrowNavigation(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{
{kind: "remote", pr: gh.PR{Number: 1, Title: "First"}},
{kind: "remote", pr: gh.PR{Number: 2, Title: "Second"}},
}
m.table.SetRows(buildTableRows(m.rows))
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
got := updated.(prsModel).table.SelectedRow()
if len(got) < 3 || got[2] != "2" {
t.Fatalf("down arrow selected %v, want PR 2", got)
}
}
func TestPRImageChecksAreBounded(t *testing.T) {
m := newPRsModel()
prs := make([]gh.PR, 20)
for i := range prs {
prs[i] = gh.PR{Number: i + 1, Title: "PR"}
}
updated, cmd := m.Update(prsLoadedMsg{prs: prs})
got := updated.(prsModel)
if cmd == nil {
t.Fatal("expected image check commands")
}
if got.imageActive != 6 || len(got.imageQueue) != 14 {
t.Fatalf("active=%d queued=%d, want active=6 queued=14", got.imageActive, len(got.imageQueue))
}
}
func TestPRWithoutImageDoesNotLaunch(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6441, Title: "No image"}, imageState: "no"}}
m.table.SetRows(buildTableRows(m.rows))
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}))
got := updated.(prsModel)
if cmd != nil {
t.Fatal("unavailable image returned a launch command")
}
if !strings.Contains(got.status, "cannot start") {
t.Fatalf("status = %q, want launch rejection", got.status)
}
}
func TestDevTableRendersLocalImageSource(t *testing.T) {
m := newPRsModel()
m.rows = []prRow{{
kind: "local",
local: docker.Image{Tag: "feature", Source: "/tmp/homarr", Revision: "abcdef1234567890"},
imageState: "yes",
}}
m.table.SetRows(buildTableRows(m.rows))
content := m.View().Content
for _, want := range []string{"local", "homarr:feature", "/tmp/homarr", "abcdef123456"} {
if !strings.Contains(content, want) {
t.Fatalf("development view omitted %q: %q", want, content)
}
}
}
func TestRebuildCompletionRestoresLoadingState(t *testing.T) {
m := newPRsModel()
m.rebuilding = true
image := docker.Image{Tag: "feature"}
updated, cmd := m.Update(rebuildDoneMsg{image: image})
got := updated.(prsModel)
if got.rebuilding || !got.loading || got.status != "rebuilt homarr:feature" || cmd == nil {
t.Fatalf("unexpected rebuild state: rebuilding=%v loading=%v status=%q", got.rebuilding, got.loading, got.status)
}
}
func TestRebuildCanBeCanceled(t *testing.T) {
m := newPRsModel()
canceled := false
m.rebuilding = true
m.rebuildCancel = func() { canceled = true }
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'q'}))
got := updated.(prsModel)
if !canceled || cmd != nil || got.status != "canceling rebuild…" {
t.Fatalf("cancel state: canceled=%v status=%q", canceled, got.status)
}
}
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui"
)
var (
flagDemo bool
flagPR int
flagEnv []string
flagDetach bool
flagBuildPR int
)
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
var rootCmd = &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 PR test build from ghcr.io, browse open PRs,
or manage running instances from an interactive dashboard.
Examples:
homarr run dev # launch local homarr:dev image
homarr run --pr 6390 # launch remote PR test image
homarr run --pr 6390 --demo # PR + demo mode
homarr run -e FOO=bar dev # extra env vars
homarr run --detach dev # start in background
homarr dash # interactive instance dashboard
homarr dev # browse remote PRs and local images
homarr build feature # build this checkout as homarr:feature
homarr build --pr 6390 # build PR #6390 locally as homarr:pr-6390
homarr list # script-friendly instance list
homarr logs homarr_pr_6390 # follow logs
homarr doctor # verify dependencies`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
var dashCmd = &cobra.Command{
Use: "dash",
Short: "Interactive dashboard of running instances",
RunE: func(cmd *cobra.Command, args []string) error {
return tui.RunDashboard()
},
}
var runCmd = &cobra.Command{
Use: "run [tag]",
Short: "Launch a local image or remote pull-request image",
Args: cobra.MaximumNArgs(1),
RunE: runLaunch,
}
var devCmd = &cobra.Command{
Use: "dev",
Short: "Browse remote PRs and local development images",
RunE: func(cmd *cobra.Command, args []string) error {
return tui.RunDev()
},
}
var buildCmd = &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 flagBuildPR < 0 {
return fmt.Errorf("PR number must be positive")
}
if flagBuildPR > 0 {
if tag == "" {
tag = fmt.Sprintf("pr-%d", flagBuildPR)
}
return run.BuildPRImage(flagBuildPR, tag)
}
if tag == "" {
return fmt.Errorf("image name is required when --pr is not set")
}
return run.BuildCurrentImage(tag, ".")
},
}
var rebuildCmd = &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 {
images, err := docker.ListLocalImages()
if err != nil {
return err
}
for _, image := range images {
if image.Tag == args[0] || image.Reference() == args[0] {
return run.RebuildImage(image)
}
}
return fmt.Errorf("local image homarr:%s was not found", strings.TrimPrefix(args[0], "homarr:"))
},
}
var logsCmd = &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])
},
}
var stopCmd = &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])
},
}
var restartCmd = &cobra.Command{
Use: "restart [container]",
Short: "Restart a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return docker.Restart(args[0])
},
}
var removeCmd = &cobra.Command{
Use: "remove [container]",
Short: "Force-remove an instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return docker.Remove(args[0])
},
}
var openCmd = &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] && container.Running() && container.HostPort() != "" {
return platform.OpenURL("http://localhost:" + container.HostPort())
}
}
return fmt.Errorf("running container %q has no Homarr port", args[0])
},
}
var listCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List Homarr instances without opening the dashboard",
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
}
fmt.Printf("%-28s %-10s %-7s %s\n", "NAME", "STATE", "PORT", "IMAGE")
for _, container := range containers {
fmt.Printf("%-28s %-10s %-7s %s\n", container.Name, container.State, container.HostPort(), container.Image)
}
return nil
},
}
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Check Docker, GitHub CLI, and local installation",
RunE: func(cmd *cobra.Command, args []string) error {
checks := []struct {
name string
command *exec.Cmd
}{
{name: "Docker daemon", command: exec.Command("docker", "info", "--format", "{{.ServerVersion}}")},
{name: "GitHub authentication", command: exec.Command("gh", "auth", "status")},
}
failed := false
for _, check := range checks {
output, err := check.command.CombinedOutput()
if err != nil {
failed = true
fmt.Printf("✗ %s: %s\n", check.name, strings.TrimSpace(string(output)))
} else {
fmt.Printf("✓ %s\n", check.name)
}
}
if _, err := exec.LookPath("homarr"); err != nil {
failed = true
fmt.Println("✗ homarr is not on PATH")
} else {
fmt.Println("✓ homarr is on PATH")
}
if failed {
return fmt.Errorf("one or more checks failed")
}
return nil
},
}
func init() {
runCmd.Flags().BoolVarP(&flagDemo, "demo", "m", false, "Demo mode (mock integrations)")
runCmd.Flags().IntVarP(&flagPR, "pr", "p", 0, "Launch PR test image from ghcr.io")
runCmd.Flags().StringArrayVarP(&flagEnv, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
runCmd.Flags().BoolVar(&flagDetach, "detach", false, "Start in the background")
buildCmd.Flags().IntVarP(&flagBuildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
rootCmd.AddCommand(runCmd, dashCmd, devCmd, buildCmd, rebuildCmd, listCmd, logsCmd, stopCmd, restartCmd, removeCmd, openCmd, doctorCmd)
}
func runLaunch(cmd *cobra.Command, args []string) error {
tag := ""
if len(args) > 0 {
tag = args[0]
}
if flagPR < 0 {
return fmt.Errorf("PR number must be positive")
}
if flagPR > 0 && tag != "" {
return fmt.Errorf("tag and --pr cannot be used together")
}
if flagPR == 0 && tag == "" {
return cmd.Usage()
}
plan, err := run.BuildPlan(run.Options{
PR: flagPR,
Tag: tag,
Demo: flagDemo,
Env: flagEnv,
FetchTitle: flagPR > 0,
})
if err != nil {
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)
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 flagDetach {
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)
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"strings"
"testing"
)
func TestRunLaunchRejectsTagWithPR(t *testing.T) {
previousPR := flagPR
t.Cleanup(func() { flagPR = previousPR })
flagPR = 42
err := runLaunch(runCmd, []string{"feature"})
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("error = %v", err)
}
}
func TestRunLaunchRejectsNegativePR(t *testing.T) {
previousPR := flagPR
t.Cleanup(func() { flagPR = previousPR })
flagPR = -1
err := runLaunch(runCmd, nil)
if err == nil || !strings.Contains(err.Error(), "must be positive") {
t.Fatalf("error = %v", err)
}
}