From e97eb7fc58dd117260f45b390c1660b6bf45ed56 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 31 Jul 2026 14:28:31 +0200 Subject: [PATCH] feat(dev): add Docker development CLI --- .github/workflows/ci.yml | 10 + AGENTS.md | 8 + .../advanced/development/getting-started.mdx | 24 +- package.json | 1 + scripts/setup-dev.sh | 45 + tools/homarr-dev/README.md | 94 ++ tools/homarr-dev/go.mod | 32 + tools/homarr-dev/go.sum | 56 + tools/homarr-dev/internal/docker/docker.go | 317 ++++++ .../homarr-dev/internal/docker/docker_test.go | 27 + tools/homarr-dev/internal/docker/images.go | 153 +++ .../homarr-dev/internal/docker/images_test.go | 69 ++ tools/homarr-dev/internal/gh/gh.go | 135 +++ tools/homarr-dev/internal/gh/gh_test.go | 62 ++ .../homarr-dev/internal/platform/clipboard.go | 7 + tools/homarr-dev/internal/platform/open.go | 28 + .../homarr-dev/internal/platform/open_test.go | 25 + tools/homarr-dev/internal/run/build.go | 101 ++ tools/homarr-dev/internal/run/run.go | 138 +++ tools/homarr-dev/internal/run/run_test.go | 44 + tools/homarr-dev/internal/tui/dashboard.go | 417 ++++++++ .../homarr-dev/internal/tui/dashboard_test.go | 61 ++ tools/homarr-dev/internal/tui/dev.go | 998 ++++++++++++++++++ tools/homarr-dev/internal/tui/dev_test.go | 117 ++ tools/homarr-dev/main.go | 291 +++++ tools/homarr-dev/main_test.go | 28 + 26 files changed, 3277 insertions(+), 11 deletions(-) create mode 100755 scripts/setup-dev.sh create mode 100644 tools/homarr-dev/README.md create mode 100644 tools/homarr-dev/go.mod create mode 100644 tools/homarr-dev/go.sum create mode 100644 tools/homarr-dev/internal/docker/docker.go create mode 100644 tools/homarr-dev/internal/docker/docker_test.go create mode 100644 tools/homarr-dev/internal/docker/images.go create mode 100644 tools/homarr-dev/internal/docker/images_test.go create mode 100644 tools/homarr-dev/internal/gh/gh.go create mode 100644 tools/homarr-dev/internal/gh/gh_test.go create mode 100644 tools/homarr-dev/internal/platform/clipboard.go create mode 100644 tools/homarr-dev/internal/platform/open.go create mode 100644 tools/homarr-dev/internal/platform/open_test.go create mode 100644 tools/homarr-dev/internal/run/build.go create mode 100644 tools/homarr-dev/internal/run/run.go create mode 100644 tools/homarr-dev/internal/run/run_test.go create mode 100644 tools/homarr-dev/internal/tui/dashboard.go create mode 100644 tools/homarr-dev/internal/tui/dashboard_test.go create mode 100644 tools/homarr-dev/internal/tui/dev.go create mode 100644 tools/homarr-dev/internal/tui/dev_test.go create mode 100644 tools/homarr-dev/main.go create mode 100644 tools/homarr-dev/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcbb352c3..afde5781d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index be1afe9fb..2dd902685 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` from a Homarr checkout to build `homarr:` with rebuild provenance. +- Run `homarr build --pr ` 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`. diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index 7c16813ba..da02effa6 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -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 `: Build the current checkout as `homarr:`. +- `homarr build --pr `: Build a pull request locally from a temporary checkout. +- `homarr rebuild `: 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. \ No newline at end of file +- `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. diff --git a/package.json b/package.json index 7bf83f0d9..76e61b0cb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100755 index 000000000..9a159605d --- /dev/null +++ b/scripts/setup-dev.sh @@ -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' diff --git a/tools/homarr-dev/README.md b/tools/homarr-dev/README.md new file mode 100644 index 000000000..43b7f2470 --- /dev/null +++ b/tools/homarr-dev/README.md @@ -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:`. 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-`. + +## 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 Build this checkout as homarr: +homarr build --pr Build a PR locally from a temporary checkout +homarr rebuild Rebuild from recorded provenance +homarr list Script-friendly instance list +homarr logs Follow logs +homarr open Open in browser +homarr stop Stop +homarr restart Restart +homarr remove 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 +``` diff --git a/tools/homarr-dev/go.mod b/tools/homarr-dev/go.mod new file mode 100644 index 000000000..6a40e27cf --- /dev/null +++ b/tools/homarr-dev/go.mod @@ -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 +) diff --git a/tools/homarr-dev/go.sum b/tools/homarr-dev/go.sum new file mode 100644 index 000000000..cf4f9f13b --- /dev/null +++ b/tools/homarr-dev/go.sum @@ -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= diff --git a/tools/homarr-dev/internal/docker/docker.go b/tools/homarr-dev/internal/docker/docker.go new file mode 100644 index 000000000..009093703 --- /dev/null +++ b/tools/homarr-dev/internal/docker/docker.go @@ -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() +} diff --git a/tools/homarr-dev/internal/docker/docker_test.go b/tools/homarr-dev/internal/docker/docker_test.go new file mode 100644 index 000000000..d477b6d9d --- /dev/null +++ b/tools/homarr-dev/internal/docker/docker_test.go @@ -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") + } +} diff --git a/tools/homarr-dev/internal/docker/images.go b/tools/homarr-dev/internal/docker/images.go new file mode 100644 index 000000000..21511d8b5 --- /dev/null +++ b/tools/homarr-dev/internal/docker/images.go @@ -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 != "" { + 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 +} diff --git a/tools/homarr-dev/internal/docker/images_test.go b/tools/homarr-dev/internal/docker/images_test.go new file mode 100644 index 000000000..ad1363569 --- /dev/null +++ b/tools/homarr-dev/internal/docker/images_test.go @@ -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) +} diff --git a/tools/homarr-dev/internal/gh/gh.go b/tools/homarr-dev/internal/gh/gh.go new file mode 100644 index 000000000..bf302b054 --- /dev/null +++ b/tools/homarr-dev/internal/gh/gh.go @@ -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 +} diff --git a/tools/homarr-dev/internal/gh/gh_test.go b/tools/homarr-dev/internal/gh/gh_test.go new file mode 100644 index 000000000..061777587 --- /dev/null +++ b/tools/homarr-dev/internal/gh/gh_test.go @@ -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) + } + } +} diff --git a/tools/homarr-dev/internal/platform/clipboard.go b/tools/homarr-dev/internal/platform/clipboard.go new file mode 100644 index 000000000..1dfdc7fdf --- /dev/null +++ b/tools/homarr-dev/internal/platform/clipboard.go @@ -0,0 +1,7 @@ +package platform + +import "github.com/atotto/clipboard" + +func CopyText(value string) error { + return clipboard.WriteAll(value) +} diff --git a/tools/homarr-dev/internal/platform/open.go b/tools/homarr-dev/internal/platform/open.go new file mode 100644 index 000000000..3be91d562 --- /dev/null +++ b/tools/homarr-dev/internal/platform/open.go @@ -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) + } +} diff --git a/tools/homarr-dev/internal/platform/open_test.go b/tools/homarr-dev/internal/platform/open_test.go new file mode 100644 index 000000000..413d9ee97 --- /dev/null +++ b/tools/homarr-dev/internal/platform/open_test.go @@ -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) + } + }) + } +} diff --git a/tools/homarr-dev/internal/run/build.go b/tools/homarr-dev/internal/run/build.go new file mode 100644 index 000000000..bf3a45b3c --- /dev/null +++ b/tools/homarr-dev/internal/run/build.go @@ -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 +} diff --git a/tools/homarr-dev/internal/run/run.go b/tools/homarr-dev/internal/run/run.go new file mode 100644 index 000000000..9a7e4e77c --- /dev/null +++ b/tools/homarr-dev/internal/run/run.go @@ -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]) + "…" +} diff --git a/tools/homarr-dev/internal/run/run_test.go b/tools/homarr-dev/internal/run/run_test.go new file mode 100644 index 000000000..7289be74c --- /dev/null +++ b/tools/homarr-dev/internal/run/run_test.go @@ -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=") + } + 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) + } +} diff --git a/tools/homarr-dev/internal/tui/dashboard.go b/tools/homarr-dev/internal/tui/dashboard.go new file mode 100644 index 000000000..5642dde0a --- /dev/null +++ b/tools/homarr-dev/internal/tui/dashboard.go @@ -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 ` 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 +} diff --git a/tools/homarr-dev/internal/tui/dashboard_test.go b/tools/homarr-dev/internal/tui/dashboard_test.go new file mode 100644 index 000000000..412b6c669 --- /dev/null +++ b/tools/homarr-dev/internal/tui/dashboard_test.go @@ -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) + } +} diff --git a/tools/homarr-dev/internal/tui/dev.go b/tools/homarr-dev/internal/tui/dev.go new file mode 100644 index 000000000..37ac675ce --- /dev/null +++ b/tools/homarr-dev/internal/tui/dev.go @@ -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 +} diff --git a/tools/homarr-dev/internal/tui/dev_test.go b/tools/homarr-dev/internal/tui/dev_test.go new file mode 100644 index 000000000..51cdcc8ce --- /dev/null +++ b/tools/homarr-dev/internal/tui/dev_test.go @@ -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) + } +} diff --git a/tools/homarr-dev/main.go b/tools/homarr-dev/main.go new file mode 100644 index 000000000..3199f4660 --- /dev/null +++ b/tools/homarr-dev/main.go @@ -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) +} diff --git a/tools/homarr-dev/main_test.go b/tools/homarr-dev/main_test.go new file mode 100644 index 000000000..dc6d0bcf0 --- /dev/null +++ b/tools/homarr-dev/main_test.go @@ -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) + } +}