diff --git a/AGENTS.md b/AGENTS.md index be1afe9fb..6c3d36da4 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,9 @@ When modifying user-facing code, you MUST also update the corresponding document ## Monorepo Commands - `pnpm dev` — Next.js app only +- `pnpm dev:cli -- dev` — run the developer CLI without installing a global binary +- `pnpm db:seed` — seed default database data explicitly +- `pnpm docker:dev:up` — start the Redis development service in the background - `pnpm dev:docs` — Docusaurus docs site only - `pnpm turbo build` — build all packages - `pnpm turbo build --filter=@homarr/docs` — build docs only @@ -81,3 +86,7 @@ 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 `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images. +- Run `pnpm dev:cli -- build ` from a Homarr checkout to build `homarr:` with rebuild provenance. +- Run `pnpm dev:cli -- build --pr ` to build a PR locally from a temporary checkout. +- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`. diff --git a/apps/docs/docs/advanced/command-line/development/index.mdx b/apps/docs/docs/advanced/command-line/development/index.mdx new file mode 100644 index 000000000..cdbffc29a --- /dev/null +++ b/apps/docs/docs/advanced/command-line/development/index.mdx @@ -0,0 +1,41 @@ +# Developer Docker CLI + +The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images. + +To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features. + +## Browse development images + +```bash +pnpm dev:cli -- +``` + +The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen. + +A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds. + +## Build images + +```bash +pnpm dev:cli -- build +pnpm dev:cli -- build --pr [name] +``` + +The first command builds the current checkout as `homarr:`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-` when `name` is omitted. + +## Rebuild images + +```bash +pnpm dev:cli -- rebuild +``` + +Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout. + +## Run images + +```bash +homarr run +homarr run --pr +``` + +Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables. diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index 7c16813ba..e8f68ea6d 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -17,8 +17,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`. - **Git**: You can download it from the [official website](https://git-scm.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/). +- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI. +- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/). +- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`. ### Setting up the project @@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi 4. Install the dependencies with `pnpm install`. -5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service. +5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file. -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'` +6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it. -7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`. +7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`. 8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s` @@ -49,8 +50,15 @@ This guide will help you set up your development environment to start contributi #### During development - `pnpm dev`: Start the development server. +- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images. +- `pnpm dev:cli -- build `: Build the current checkout as `homarr:`. +- `pnpm dev:cli -- build --pr `: Build a pull request locally from a temporary checkout. +- `pnpm dev:cli -- rebuild `: Rebuild a local image from its recorded checkout or pull request. +- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary. - `pnpm cli`: Run cli commands. - `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file. +- `pnpm db:seed`: Explicitly seed the default database data. +- `pnpm docker:dev:up`: Start the Redis development service in the background. - `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`. - `pnpm db:studio`: Open a visual studio for the database. - `pnpm package:new`: Create a new package in the monorepo. @@ -63,6 +71,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 +- `pnpm dev:cli -- 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:`: Run the created docker image. diff --git a/package.json b/package.json index 42736863f..705cae439 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,15 @@ "db:migration:postgresql:run": "pnpm -F db migration:postgresql:run", "db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate", "db:migration:sqlite:run": "pnpm -F db migration:sqlite:run", + "db:seed": "pnpm -F db seed", "db:push": "pnpm -F db push:sqlite", "db:studio": "pnpm -F db studio", "dev": "pnpm -F nextjs dev", + "dev:cli": "go -C tools/homarr-dev run .", + "dev:cli:install": "go -C tools/homarr-dev install .", "dev:docs": "pnpm -F @homarr/docs dev", "docker:dev": "docker compose -f ./development/development.docker-compose.yml up", + "docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis", "format": "turbo format --continue -- --check", "format:fix": "turbo format --continue", "lint": "turbo lint --continue", diff --git a/tools/homarr-dev/README.md b/tools/homarr-dev/README.md new file mode 100644 index 000000000..99fe89640 --- /dev/null +++ b/tools/homarr-dev/README.md @@ -0,0 +1,103 @@ +# 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 +- Go 1.25+ to build from source +- GitHub CLI authenticated with `gh auth login` for pull-request features + +## Setup + +```sh +pnpm install +``` + +Create `.env` from `.env.example` and set `DB_URL` to an absolute path for the SQLite file before running the migration. In a POSIX shell, use `cp .env.example .env`; in PowerShell, use `Copy-Item .env.example .env`. +Then run `pnpm db:migration:sqlite:run` to create or update the database. The migration command also seeds a new database. Use `pnpm db:seed` explicitly when you need to seed an existing database. Start Redis separately with `pnpm docker:dev:up` when it is needed. +The examples below use `pnpm dev:cli -- ...`, which works from POSIX shells and PowerShell. If you prefer a global command, install it with `pnpm dev:cli:install` and add your Go bin directory to `PATH`. + +## Build + +```sh +pnpm dev:cli -- build feature +pnpm dev:cli -- build --pr 6441 +pnpm dev:cli -- 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 +pnpm dev:cli -- run dev +pnpm dev:cli -- run --detach dev +pnpm dev:cli -- run --pr 6441 +pnpm dev:cli -- run --pr 6441 --demo +pnpm dev:cli -- run --env FOO=bar --env FEATURE=true dev +``` + +PR launches always pull the latest GHCR tag before starting. Local tags remain local. +The first launch generates a per-user encryption key in the OS config directory and reuses it for later launches. Set `HOMARR_DEV_SECRET_ENCRYPTION_KEY` to a 64-character hexadecimal key to override it. + +The command reference below assumes the optional `homarr` installation. Without it, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- dev`). + +## Commands + +```text +homarr Interactive local and remote image browser +homarr dev Explicit alias for the main browser +homarr dash Start on the instance dashboard +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 and optional CLI integrations +``` + +## Development Browser + +```text +/ Filter by PR number, title, author, branch, CI, image, state, or port +↑/↓ or j/k Select +Enter or Space Start the matching local build, otherwise pull remote; stop when running +R Build a remote PR locally in the background or rebuild a local image +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 +d Switch to the instance dashboard +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. +Local PR images are folded into their remote PR row only when the recorded revision exactly matches the current GitHub head SHA. They are marked `◆ local` and preferred by Enter; outdated builds remain separate local rows. Navigation and the `d` dashboard toggle remain available while a local build runs. + +## 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 +d Switch to the development browser +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..da5f2fa92 --- /dev/null +++ b/tools/homarr-dev/internal/docker/docker.go @@ -0,0 +1,427 @@ +package docker + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const developmentEncryptionKeyEnv = "HOMARR_DEV_SECRET_ENCRYPTION_KEY" + +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 { + if start > 65535 { + return 0 + } + used := usedDockerPorts() + start = max(start, 1) + for port := start; port <= 65535; 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 + } + } + return 0 +} + +func usedDockerPorts() map[int]bool { + out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output() + if err != nil { + return make(map[int]bool) + } + return parseUsedDockerPorts(out) +} + +func parseUsedDockerPorts(out []byte) map[int]bool { + used := make(map[int]bool) + for _, line := range strings.Split(string(out), "\n") { + for _, mapping := range strings.Split(line, ",") { + left, _, found := strings.Cut(mapping, "->") + if !found { + continue + } + colon := strings.LastIndex(left, ":") + if colon < 0 { + continue + } + ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2) + first, err := strconv.Atoi(ports[0]) + if err != nil { + continue + } + last := first + if len(ports) == 2 { + if parsed, err := strconv.Atoi(ports[1]); err == nil { + last = parsed + } + } + for port := first; port <= last; port++ { + used[port] = true + } + } + } + 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 { + encryptionKey, err := developmentEncryptionKey() + if err != nil { + return err + } + + 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=" + encryptionKey, + } + 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 +} + +func developmentEncryptionKey() (string, error) { + if key := os.Getenv(developmentEncryptionKeyEnv); key != "" { + return validateDevelopmentEncryptionKey(key) + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate user config directory: %w", err) + } + return persistedDevelopmentEncryptionKey(configDir) +} + +func persistedDevelopmentEncryptionKey(configDir string) (string, error) { + dir := filepath.Join(configDir, "homarr-dev") + path := filepath.Join(dir, "secret-encryption-key") + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create development config directory: %w", err) + } + lockPath := path + ".lock" + for attempts := 0; ; attempts++ { + if err := os.Mkdir(lockPath, 0o700); err == nil { + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("lock development encryption key: %w", err) + } + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err + } + if attempts == 99 { + return "", fmt.Errorf("timed out waiting for development encryption key lock %q", lockPath) + } + time.Sleep(10 * time.Millisecond) + } + defer func() { _ = os.Remove(lockPath) }() + + if key, found, err := readDevelopmentEncryptionKey(path); found || err != nil { + return key, err + } + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return "", fmt.Errorf("generate development encryption key: %w", err) + } + key := hex.EncodeToString(keyBytes) + file, err := os.CreateTemp(dir, "secret-encryption-key-*.tmp") + if err != nil { + return "", fmt.Errorf("create development encryption key: %w", err) + } + tempPath := file.Name() + defer func() { _ = os.Remove(tempPath) }() + if _, err := file.WriteString(key + "\n"); err != nil { + _ = file.Close() + return "", fmt.Errorf("write development encryption key: %w", err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("close development encryption key: %w", err) + } + if err := os.Chmod(tempPath, 0o600); err != nil { + return "", fmt.Errorf("secure development encryption key: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return "", fmt.Errorf("persist development encryption key: %w", err) + } + return key, nil +} + +func readDevelopmentEncryptionKey(path string) (string, bool, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("read development encryption key: %w", err) + } + key, err := validateDevelopmentEncryptionKey(strings.TrimSpace(string(data))) + return key, true, err +} + +func validateDevelopmentEncryptionKey(key string) (string, error) { + decoded, err := hex.DecodeString(key) + if err != nil || len(decoded) != 32 { + return "", fmt.Errorf("development encryption key must contain exactly 64 hexadecimal characters") + } + return key, 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..3422c703d --- /dev/null +++ b/tools/homarr-dev/internal/docker/docker_test.go @@ -0,0 +1,125 @@ +package docker + +import ( + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "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") + } +} + +func TestParseUsedDockerPortsPreservesLineBoundaries(t *testing.T) { + ports := parseUsedDockerPorts([]byte("6379/tcp\n0.0.0.0:7575->7575/tcp, [::]:7575->7575/tcp\n0.0.0.0:8000-8002->8000-8002/tcp\n")) + for _, port := range []int{7575, 8000, 8001, 8002} { + if !ports[port] { + t.Fatalf("port %d was not detected", port) + } + } +} + +func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) { + if port := FindFreePort(65536); port != 0 { + t.Fatalf("port = %d, want 0", port) + } +} + +func TestDevelopmentEncryptionKeyUsesEnvironmentOverride(t *testing.T) { + want := strings.Repeat("a", 64) + t.Setenv(developmentEncryptionKeyEnv, want) + got, err := developmentEncryptionKey() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("key = %q, want environment value", got) + } +} + +func TestPersistedDevelopmentEncryptionKeyIsReused(t *testing.T) { + configDir := t.TempDir() + first, err := persistedDevelopmentEncryptionKey(configDir) + if err != nil { + t.Fatal(err) + } + second, err := persistedDevelopmentEncryptionKey(configDir) + if err != nil { + t.Fatal(err) + } + if first != second { + t.Fatal("persisted key changed between reads") + } + decoded, err := hex.DecodeString(first) + if err != nil || len(decoded) != 32 { + t.Fatalf("generated key is not 32 bytes: %q", first) + } + info, err := os.Stat(filepath.Join(configDir, "homarr-dev", "secret-encryption-key")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("key permissions = %o, want 600", info.Mode().Perm()) + } +} + +func TestPersistedDevelopmentEncryptionKeyRejectsInvalidValue(t *testing.T) { + configDir := t.TempDir() + dir := filepath.Join(configDir, "homarr-dev") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "secret-encryption-key"), []byte("invalid\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := persistedDevelopmentEncryptionKey(configDir); err == nil { + t.Fatal("expected invalid persisted key to fail") + } +} + +func TestPersistedDevelopmentEncryptionKeyConcurrentCreation(t *testing.T) { + configDir := t.TempDir() + type result struct { + key string + err error + } + results := make(chan result, 20) + for range 20 { + go func() { + key, err := persistedDevelopmentEncryptionKey(configDir) + results <- result{key: key, err: err} + }() + } + var first string + for range 20 { + result := <-results + if result.err != nil { + t.Fatal(result.err) + } + if first == "" { + first = result.key + } else if result.key != first { + t.Fatal("concurrent callers received different keys") + } + } +} diff --git a/tools/homarr-dev/internal/docker/images.go b/tools/homarr-dev/internal/docker/images.go new file mode 100644 index 000000000..cb4f16e66 --- /dev/null +++ b/tools/homarr-dev/internal/docker/images.go @@ -0,0 +1,149 @@ +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(ctx context.Context) ([]Image, error) { + out, err := exec.CommandContext(ctx, "docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output() + if err != nil { + return nil, fmt.Errorf("list local Homarr images: %w", err) + } + images, err := parseImageList(out) + if err != nil { + return nil, err + } + labelsByImage, err := inspectImageLabels(ctx, 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(ctx context.Context, 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.CommandContext(ctx, "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 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..559a9423a --- /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(t.Context(), 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(t.Context()) + 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..c4e61e6fe --- /dev/null +++ b/tools/homarr-dev/internal/gh/gh.go @@ -0,0 +1,199 @@ +package gh + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) + +const Repo = "homarr-labs/homarr" +const prCacheTTL = 5 * time.Minute + +var prListCache = struct { + sync.Mutex + entries map[int]cachedPRs +}{entries: make(map[int]cachedPRs)} + +type cachedPRs struct { + prs []PR + fetchedAt time.Time +} + +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"` + HeadSHA string `json:"headRefOid"` + 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"` + HeadRefOID string `json:"headRefOid"` + 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(ctx context.Context, limit int, includeBots bool) ([]PR, error) { + return listPRs(ctx, limit, includeBots, false) +} + +func RefreshPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) { + return listPRs(ctx, limit, includeBots, true) +} + +func listPRs(ctx context.Context, limit int, includeBots, refresh bool) ([]PR, error) { + cached, fetchedAt := cachedPRList(limit) + if !refresh && !fetchedAt.IsZero() && time.Since(fetchedAt) < prCacheTTL { + return filterPRs(cached, includeBots), nil + } + + out, err := exec.CommandContext(ctx, "gh", "pr", "list", + "--repo", Repo, + "--state", "open", + "--limit", fmt.Sprint(limit), + "--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup", + ).CombinedOutput() + if err != nil { + err = commandError(err, out) + if len(cached) > 0 { + return filterPRs(cached, includeBots), fmt.Errorf("%w; showing cached PRs from %s", err, fetchedAt.Format(time.Kitchen)) + } + 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 { + prs = append(prs, PR{ + Number: r.Number, + Title: r.Title, + Author: r.Author.Login, + HeadRef: r.HeadRefName, + HeadSHA: r.HeadRefOID, + CIState: rollupState(r.StatusCheckRollup), + IsDraft: r.IsDraft, + UpdatedAt: r.UpdatedAt, + }) + } + storePRList(limit, prs) + return filterPRs(prs, includeBots), nil +} + +func cachedPRList(limit int) ([]PR, time.Time) { + prListCache.Lock() + defer prListCache.Unlock() + entry := prListCache.entries[limit] + return append([]PR(nil), entry.prs...), entry.fetchedAt +} + +func storePRList(limit int, prs []PR) { + prListCache.Lock() + defer prListCache.Unlock() + prListCache.entries[limit] = cachedPRs{prs: append([]PR(nil), prs...), fetchedAt: time.Now()} +} + +func filterPRs(prs []PR, includeBots bool) []PR { + filtered := make([]PR, 0, len(prs)) + for _, pr := range prs { + if includeBots || !isBot(pr.Author) { + filtered = append(filtered, pr) + } + } + return filtered +} + +func commandError(err error, output []byte) error { + detail := strings.TrimSpace(string(output)) + if detail == "" { + return err + } + return fmt.Errorf("%s: %w", detail, err) +} + +func GetPR(ctx context.Context, number int) (*PR, error) { + out, err := exec.CommandContext(ctx, "gh", "pr", "view", fmt.Sprint(number), + "--repo", Repo, + "--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup", + ).CombinedOutput() + if err != nil { + return nil, commandError(err, out) + } + + 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, + HeadSHA: r.HeadRefOID, + 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..64e14e20c --- /dev/null +++ b/tools/homarr-dev/internal/gh/gh_test.go @@ -0,0 +1,91 @@ +package gh + +import ( + "errors" + "os" + "strings" + "testing" + "time" +) + +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 TestCachedPRListFiltersBotsWithoutMutatingCache(t *testing.T) { + limit := 9876 + storePRList(limit, []PR{{Number: 1, Author: "developer"}, {Number: 2, Author: "bot[bot]"}}) + + prs, fetchedAt := cachedPRList(limit) + if time.Since(fetchedAt) >= prCacheTTL { + t.Fatal("new cache entry is already expired") + } + if filtered := filterPRs(prs, false); len(filtered) != 1 || filtered[0].Number != 1 { + t.Fatalf("filtered PRs = %#v", filtered) + } + if unfiltered := filterPRs(prs, true); len(unfiltered) != 2 { + t.Fatalf("unfiltered PR count = %d, want 2", len(unfiltered)) + } +} + +func TestCommandErrorIncludesGitHubOutput(t *testing.T) { + err := commandError(errors.New("exit status 1"), []byte("GraphQL: API rate limit exceeded\n")) + if !strings.Contains(err.Error(), "API rate limit exceeded") { + t.Fatalf("error = %q", err) + } +} + +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(t.Context(), 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) + } + if pr.HeadSHA == "" { + t.Fatalf("PR #%d has no head SHA", pr.Number) + } + } +} 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..de78fd291 --- /dev/null +++ b/tools/homarr-dev/internal/run/build.go @@ -0,0 +1,105 @@ +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", prCloneArgs(checkout)...) + 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 prCloneArgs(checkout string) []string { + return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"} +} + +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..a144ee3df --- /dev/null +++ b/tools/homarr-dev/internal/run/run.go @@ -0,0 +1,119 @@ +package run + +import ( + "context" + "fmt" + "os" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" +) + +type Options struct { + Context context.Context + PR int + Tag string + Demo bool + Env []string + FetchTitle bool + FindPort func(int) int +} + +type Plan struct { + Image string + Name string + Volume string + Label string + HostPort int + Env []string + Platform string + Pull bool + PRNumber int +} + +func BuildPlan(opts Options) (*Plan, error) { + p := &Plan{} + ctx := opts.Context + if ctx == nil { + ctx = context.Background() + } + findPort := opts.FindPort + if findPort == nil { + findPort = docker.FindFreePort + } + + if opts.PR > 0 { + p.PRNumber = opts.PR + 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(ctx, opts.PR); err == nil && pr.Title != "" { + p.Label = fmt.Sprintf("PR #%d — %s", opts.PR, pr.Title) + } + } + } 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") + } + + p.HostPort = findPort(7575) + if p.HostPort == 0 { + return nil, fmt.Errorf("no free host port is available") + } + 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 p.HostPort == 0 { + return fmt.Errorf("no free host port is available: %w", err) + } + if !daemon { + fmt.Fprintf(os.Stderr, "Port %d unavailable; retrying on %d.\n", previousPort, p.HostPort) + } + } + return nil +} 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..5bed7eac9 --- /dev/null +++ b/tools/homarr-dev/internal/run/run_test.go @@ -0,0 +1,59 @@ +package run + +import ( + "os" + "slices" + "strconv" + "testing" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" +) + +func TestPRPlanUsesAMD64(t *testing.T) { + plan, err := BuildPlan(Options{PR: 1, FindPort: func(int) int { return 7575 }}) + if err != nil { + t.Fatal(err) + } + if plan.Platform != "linux/amd64" { + t.Fatalf("platform = %q, want linux/amd64", plan.Platform) + } +} + +func TestPRCloneIsShallow(t *testing.T) { + args := prCloneArgs("/tmp/checkout") + if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") { + t.Fatalf("clone args = %v", args) + } +} + +func TestBuildPlanFailsWhenNoPortIsAvailable(t *testing.T) { + _, err := BuildPlan(Options{Tag: "dev", FindPort: func(int) int { return 0 }}) + if err == nil { + t.Fatal("expected no-free-port error") + } +} + +func TestStartPRIntegration(t *testing.T) { + if os.Getenv("HOMARR_DOCKER_INTEGRATION_TEST") != "1" { + t.Skip("set HOMARR_DOCKER_INTEGRATION_TEST=1 and HOMARR_TEST_PR=") + } + 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/app.go b/tools/homarr-dev/internal/tui/app.go new file mode 100644 index 000000000..1af605250 --- /dev/null +++ b/tools/homarr-dev/internal/tui/app.go @@ -0,0 +1,122 @@ +package tui + +import ( + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" +) + +type appModel struct { + dev prsModel + dashboard dashModel + showDashboard bool + width int + height int +} + +func newAppModel(showDashboard bool) appModel { + return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard} +} + +func (m appModel) Init() tea.Cmd { + if m.showDashboard { + return m.dashboard.Init() + } + return m.dev.Init() +} + +func (m appModel) canSwitch() bool { + if m.showDashboard { + return !m.dashboard.filtering + } + return !m.dev.filtering && !m.dev.pulling +} + +func (m appModel) switchScreen() (tea.Model, tea.Cmd) { + m.showDashboard = !m.showDashboard + commands := make([]tea.Cmd, 0, 2) + if m.width > 0 && m.height > 0 { + resize := tea.WindowSizeMsg{Width: m.width, Height: m.height} + if m.showDashboard { + updated, command := m.dashboard.Update(resize) + m.dashboard = updated.(dashModel) + commands = append(commands, command) + } else { + updated, command := m.dev.Update(resize) + m.dev = updated.(prsModel) + commands = append(commands, command) + } + } + if m.showDashboard { + commands = append(commands, m.dashboard.Init()) + } else { + commands = append(commands, m.dev.Init()) + } + return m, tea.Batch(commands...) +} + +func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) { + updated, command := m.dev.Update(msg) + m.dev = updated.(prsModel) + return m, command +} + +func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) { + updated, command := m.dashboard.Update(msg) + m.dashboard = updated.(dashModel) + return m, command +} + +func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if window, ok := msg.(tea.WindowSizeMsg); ok { + m.width = window.Width + m.height = window.Height + m, devCommand := m.updateDev(msg) + m, dashboardCommand := m.updateDashboard(msg) + return m, tea.Batch(devCommand, dashboardCommand) + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if key.String() == "d" && m.canSwitch() { + return m.switchScreen() + } + if m.showDashboard { + return m.updateDashboard(msg) + } + return m.updateDev(msg) + } + + switch msg.(type) { + case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg: + return m.updateDev(msg) + case tickMsg, actionMsg, rowsMsg: + if m.showDashboard { + return m.updateDashboard(msg) + } + return m, nil + case prLogTickMsg: + if !m.showDashboard { + return m.updateDev(msg) + } + return m, nil + case logsMsg: + m, devCommand := m.updateDev(msg) + m, dashboardCommand := m.updateDashboard(msg) + return m, tea.Batch(devCommand, dashboardCommand) + } + if m.showDashboard { + return m.updateDashboard(msg) + } + return m.updateDev(msg) +} + +func (m appModel) View() tea.View { + if m.showDashboard { + return m.dashboard.View() + } + return m.dev.View() +} + +func runApp(showDashboard bool) error { + program := tea.NewProgram(newAppModel(showDashboard)) + _, err := program.Run() + return err +} diff --git a/tools/homarr-dev/internal/tui/app_test.go b/tools/homarr-dev/internal/tui/app_test.go new file mode 100644 index 000000000..1112ebb29 --- /dev/null +++ b/tools/homarr-dev/internal/tui/app_test.go @@ -0,0 +1,47 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" +) + +func TestAppTogglePreservesDevelopmentState(t *testing.T) { + m := newAppModel(false) + m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}} + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + dashboard := updated.(appModel) + if !dashboard.showDashboard { + t.Fatal("d did not open the instance dashboard") + } + updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + development := updated.(appModel) + if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 { + t.Fatalf("development state was not preserved: %#v", development.dev.rows) + } +} + +func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) { + m := newAppModel(false) + m.dev.filtering = true + m.dev.filter.Focus() + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + got := updated.(appModel) + if got.showDashboard || got.dev.filter.Value() != "d" { + t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value()) + } +} + +func TestAppCanOpenInstancesDuringBuild(t *testing.T) { + m := newAppModel(false) + m.dev.rebuilding = true + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) + if !updated.(appModel).showDashboard { + t.Fatal("background build blocked dashboard navigation") + } +} diff --git a/tools/homarr-dev/internal/tui/dashboard.go b/tools/homarr-dev/internal/tui/dashboard.go new file mode 100644 index 000000000..5cfe2d496 --- /dev/null +++ b/tools/homarr-dev/internal/tui/dashboard.go @@ -0,0 +1,415 @@ +package tui + +import ( + "context" + "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 rowsMsg []table.Row +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{} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", name).CombinedOutput() + if err != nil { + return logsMsg{name: name, content: "logs unavailable: " + err.Error()} + } + return logsMsg{name: name, content: string(out)} + } +} + +func loadRows() tea.Cmd { + return func() tea.Msg { return rowsMsg(refreshRows()) } +} + +func refreshRows() []table.Row { + containers, _ := docker.List() + rows := make([]table.Row, 0, len(containers)) + 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.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) + return dashModel{table: t, 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) selected() string { + row := m.table.SelectedRow() + if len(row) == 0 { + return "" + } + return row[0] +} + +func (m dashModel) selectedPort() string { + row := m.table.SelectedRow() + if len(row) < 4 { + return "" + } + return row[3] +} + +func (m dashModel) Init() tea.Cmd { + return tea.Batch(tick(), loadRows(), 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: + return m, tea.Batch(tick(), loadRows()) + case rowsMsg: + m.allRows = []table.Row(msg) + m.applyFilter() + return m, fetchLogs(m.selected()) + case logsMsg: + if msg.name != "" && msg.name != m.selected() { + return m, nil + } + 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 = "" + return m, loadRows() + 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 := m.selectedPort(); port != "" { + if err := platform.OpenURL("http://localhost:" + port); err != nil { + m.status = "open failed: " + err.Error() + } + } + } + case "c": + if name != "" { + if port := m.selectedPort(); 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": + return m, loadRows() + 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 (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 run ` or `homarr run --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 · d development · 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 { + return runApp(true) +} 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..4456dc117 --- /dev/null +++ b/tools/homarr-dev/internal/tui/dashboard_test.go @@ -0,0 +1,62 @@ +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", + "Up 2 minutes", + }}) + 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", "", "Up 1 minute"}, + {"homarr_second", "running", "homarr:second", "7576", "", "Up 1 minute"}, + }) + + 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", "", "Up 1 minute"}}) + 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..6a41d9d9a --- /dev/null +++ b/tools/homarr-dev/internal/tui/dev.go @@ -0,0 +1,1158 @@ +package tui + +import ( + "bufio" + "context" + "fmt" + "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 = "!" + imgLocal = "◆" + 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) + legendLocal = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Render(imgLocal) +) + +type prRow struct { + kind string + pr gh.PR + local docker.Image + imageState string + running bool + port string +} + +func (row prRow) hasCurrentLocalImage() bool { + return row.kind == "remote" && row.local.Tag != "" && row.pr.HeadSHA != "" && row.local.Revision == row.pr.HeadSHA +} + +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 + refresh bool +} +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 + action string +} +type runningRefreshedMsg struct { + rows []prRow + generation int +} +type pullReadyMsg struct{} +type prLogTickMsg struct{} +type pullEvent struct { + line string + done bool + err error +} + +type outputTail struct { + data []byte +} + +func (tail *outputTail) Write(data []byte) (int, error) { + const limit = 32 * 1024 + tail.data = append(tail.data, data...) + if len(tail.data) > limit { + tail.data = tail.data[len(tail.data)-limit:] + } + return len(data), nil +} + +func (tail *outputTail) lastLine() string { + lines := strings.FieldsFunc(string(tail.data), func(char rune) bool { return char == '\n' || char == '\r' }) + if len(lines) == 0 { + return "" + } + return strings.TrimSpace(lines[len(lines)-1]) +} + +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(ctx context.Context, 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) + lastLine := "" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lastLine = line + if ctx.Err() == nil && len(events) < cap(events)-1 { + events <- pullEvent{line: line} + } + } + } + err := cmd.Wait() + if err == nil { + err = scanner.Err() + } else { + err = commandErrorWithDetail(err, lastLine) + } + events <- pullEvent{done: true, err: err} + }() + return pullReadyMsg{} + } +} + +func commandErrorWithDetail(err error, lastLine string) error { + if lastLine == "" { + return err + } + return fmt.Errorf("%w: %s", err, lastLine) +} + +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, refresh bool) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var prs []gh.PR + var prErr error + if refresh { + prs, prErr = gh.RefreshPRs(ctx, 50, includeBots) + } else { + prs, prErr = gh.ListPRs(ctx, 50, includeBots) + } + images, imgErr := docker.ListLocalImages(ctx) + return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr, refresh: refresh} + } +} + +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 + } + if r.hasCurrentLocalImage() { + img = imgLocal + } + 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 developmentRows(prs []gh.PR, images []docker.Image, imageStates map[int]string) ([]prRow, []int) { + rows := make([]prRow, 0, len(prs)+len(images)) + prIndexes := make(map[int]int, len(prs)) + queue := make([]int, 0, len(prs)) + for _, pr := range prs { + imageState := imageStates[pr.Number] + prIndexes[pr.Number] = len(rows) + rows = append(rows, prRow{kind: "remote", pr: pr, imageState: imageState}) + } + for _, image := range images { + if index, exists := prIndexes[image.PRNumber]; exists && image.Revision != "" && image.Revision == rows[index].pr.HeadSHA { + if rows[index].local.Tag == "" { + rows[index].local = image + } + continue + } + rows = append(rows, prRow{kind: "local", local: image, imageState: "yes"}) + } + for index := range rows { + if rows[index].kind != "remote" || rows[index].imageState != "" { + continue + } + if rows[index].hasCurrentLocalImage() { + rows[index].imageState = "unchecked" + continue + } + rows[index].imageState = "checking" + queue = append(queue, rows[index].pr.Number) + } + return rows, queue +} + +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 localBuildForRow(row prRow) (docker.Image, string, string) { + if row.kind == "remote" { + image := docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number} + return image, "built", fmt.Sprintf("building PR #%d locally as %s…", row.pr.Number, image.Reference()) + } + return row.local, "rebuilt", "rebuilding " + row.local.Reference() + "…" +} + +func localPRPlan(row prRow, demo bool, findPort func(int) int) (*run.Plan, error) { + plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo, FindPort: findPort}) + if err != nil { + return nil, err + } + plan.Name = fmt.Sprintf("homarr_pr_%d", row.pr.Number) + plan.Volume = fmt.Sprintf("homarr_pr_%d_data", row.pr.Number) + plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateText(row.pr.HeadSHA, 12)) + plan.PRNumber = row.pr.Number + return plan, nil +} + +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{Context: context.Background(), 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, 128) + 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(ctx, 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, false), m.spinner.Tick) +} + +func refreshRunning(rows []prRow, generation int) tea.Cmd { + return func() tea.Msg { + containers, _ := docker.List() + updated := append([]prRow(nil), rows...) + for index, row := range updated { + name := fmt.Sprintf("homarr_pr_%d", row.pr.Number) + if row.kind == "local" { + name = "homarr_" + row.local.Tag + } + row.running = false + row.port = "" + for _, container := range containers { + if container.Name == name && container.Running() { + row.running = true + row.port = container.HostPort() + } + } + updated[index] = row + } + return runningRefreshedMsg{rows: updated, generation: generation} + } +} + +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() + } + imageStates := make(map[int]string, len(m.rows)) + if !msg.refresh || msg.prErr != nil { + for _, row := range m.rows { + if row.kind == "remote" && (row.imageState == "yes" || row.imageState == "no") { + imageStates[row.pr.Number] = row.imageState + } + } + } + m.rows, m.imageQueue = developmentRows(msg.prs, msg.images, imageStates) + m.imageGen++ + m.imageActive = 0 + m.applyFilter() + return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen)) + 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 + return m, refreshRunning(m.rows, m.imageGen) + case runningRefreshedMsg: + if msg.generation != m.imageGen { + return m, nil + } + m.rows = msg.rows + m.applyFilter() + return m, m.refreshSelectedLogs() + case rebuildDoneMsg: + m.rebuilding = false + if m.rebuildCancel != nil { + m.rebuildCancel() + } + m.rebuildCancel = nil + m.loading = true + action := msg.action + if action == "" { + action = "rebuilt" + } + if msg.canceled { + m.status = action + " canceled" + } else if msg.err != nil { + m.status = action + " failed: " + msg.err.Error() + } else { + m.status = action + " " + msg.image.Reference() + } + return m, tea.Batch(loadDev(m.includeBots, true), 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 + if m.pullCancel != nil { + m.pullCancel() + } + 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 + if plan == nil { + return m, 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.hasCurrentLocalImage() { + plan, err := localPRPlan(*row, m.demo, nil) + if err != nil { + m.status = err.Error() + return m, nil + } + m.status = fmt.Sprintf("starting PR #%d from local build…", row.pr.Number) + return m, func() tea.Msg { + if err := run.StartDetached(plan); err != nil { + return prActionMsg{"start failed: " + err.Error()} + } + return prActionMsg{fmt.Sprintf("started PR #%d from local build → http://localhost:%d", row.pr.Number, plan.HostPort)} + } + } + if row.imageState == "checking" { + m.status = fmt.Sprintf("PR #%d image check is still in progress", row.pr.Number) + return m, nil + } + return m, m.beginPull(*row) + } + plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo}) + if err != nil { + m.status = err.Error() + return m, nil + } + 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, true), 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": + if m.rebuilding { + m.status = "a local build is already running" + return m, nil + } + row := m.selectedRow() + if row == nil { + m.status = "select an image to build" + return m, nil + } + image, action, status := localBuildForRow(*row) + ctx, cancel := context.WithCancel(context.Background()) + m.rebuilding = true + m.rebuildCancel = cancel + m.status = status + rebuild := func() tea.Msg { + output := &outputTail{} + err := run.RebuildImageContext(ctx, image, output, output) + if err != nil { + err = commandErrorWithDetail(err, output.lastLine()) + } + return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil, action: action} + } + 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, false), 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" + } + runSource := "run remote" + if row.hasCurrentLocalImage() { + runSource = "run local " + truncateText(row.local.Revision, 12) + } + meta := "@" + row.pr.Author + " · " + branch + " · CI " + coloredCI(row.pr.CIState) + " · " + runSource + if !row.hasCurrentLocalImage() { + meta += " · GHCR " + 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 { + runes := []rune(value) + if len(runes) <= length { + return value + } + return string(runes[:length]) +} + +func (m prsModel) View() tea.View { + var b strings.Builder + header := titleStyle.Render("🦞 homarr development") + if m.loading || m.rebuilding { + header += " " + m.spinner.View() + } + running := 0 + remote := 0 + local := 0 + for _, row := range m.rows { + if row.kind == "local" { + local++ + } else { + remote++ + if row.hasCurrentLocalImage() { + local++ + } + } + 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.pulling { + b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R build locally · p pull remote · m demo · o PR · a app")) + b.WriteString("\n" + helpStyle.Render("b "+botState+" · r refresh · d instances · q quit")) + b.WriteString("\n" + helpStyle.Render("l toggle logs · f follow · pgup/pgdn scroll")) + if m.rebuilding { + b.WriteString("\n" + helpStyle.Render("local build running in background · q/esc cancel build")) + } + } + b.WriteString("\n" + helpStyle.Render("SOURCE: local Docker / remote GHCR · CI: "+legendCIYes+" pass "+legendCINo+" fail "+legendCIPend+" pending "+legendNone+" none · IMAGE: "+legendLocal+" local "+legendImageYes+" remote "+legendImageNo+" not built")) + v := tea.NewView(b.String()) + v.AltScreen = true + v.WindowTitle = "🦞 Homarr Development" + return v +} + +func RunDev() error { + return runApp(false) +} 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..ecd8d85bf --- /dev/null +++ b/tools/homarr-dev/internal/tui/dev_test.go @@ -0,0 +1,228 @@ +package tui + +import ( + "errors" + "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 TestPRLoadedReusesImageChecksUnlessRefreshIsForced(t *testing.T) { + m := newPRsModel() + m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 1}, imageState: "yes"}} + + updated, _ := m.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}}) + cached := updated.(prsModel) + if cached.rows[0].imageState != "yes" || len(cached.imageQueue) != 0 || cached.imageActive != 1 { + t.Fatalf("cached states were not reused: rows=%#v queue=%v active=%d", cached.rows, cached.imageQueue, cached.imageActive) + } + + updated, _ = cached.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}, refresh: true}) + refreshed := updated.(prsModel) + if refreshed.rows[0].imageState != "checking" || refreshed.imageActive != 2 { + t.Fatalf("forced refresh reused image states: rows=%#v active=%d", refreshed.rows, refreshed.imageActive) + } +} + +func TestDevelopmentRowsFoldCurrentLocalPRImage(t *testing.T) { + prs := []gh.PR{{Number: 6502, HeadSHA: "current"}} + images := []docker.Image{ + {Tag: "pr-6502", PRNumber: 6502, Revision: "current"}, + {Tag: "pr-6502-stale", PRNumber: 6502, Revision: "stale"}, + } + + rows, queue := developmentRows(prs, images, nil) + if len(rows) != 2 || !rows[0].hasCurrentLocalImage() || rows[0].local.Tag != "pr-6502" { + t.Fatalf("current image was not folded into PR row: %#v", rows) + } + if rows[1].kind != "local" || rows[1].local.Tag != "pr-6502-stale" { + t.Fatalf("stale image should remain separate: %#v", rows[1]) + } + if got := buildTableRows(rows)[0][6]; got != imgLocal { + t.Fatalf("image marker = %q, want %q", got, imgLocal) + } + if len(queue) != 0 { + t.Fatalf("current local image queued unnecessary registry checks: %v", queue) + } +} + +func TestLocalPRPlanUsesPRContainerIdentity(t *testing.T) { + row := prRow{ + kind: "remote", + pr: gh.PR{Number: 6502, HeadSHA: "abcdef1234567890"}, + local: docker.Image{Tag: "pr-6502", PRNumber: 6502, Revision: "abcdef1234567890"}, + } + plan, err := localPRPlan(row, false, func(int) int { return 7575 }) + if err != nil { + t.Fatal(err) + } + if plan.Image != "homarr:pr-6502" || plan.Name != "homarr_pr_6502" || plan.Volume != "homarr_pr_6502_data" || plan.Pull || plan.PRNumber != 6502 { + t.Fatalf("unexpected local PR plan: %#v", plan) + } +} + +func TestRemotePRBuildUsesTemporaryPRWorkflow(t *testing.T) { + image, action, status := localBuildForRow(prRow{kind: "remote", pr: gh.PR{Number: 6441}}) + if image.Tag != "pr-6441" || image.PRNumber != 6441 || action != "built" || !strings.Contains(status, "building PR #6441 locally") { + t.Fatalf("image=%#v action=%q status=%q", image, action, status) + } +} + +func TestPRImageCheckInProgressDoesNotLaunch(t *testing.T) { + m := newPRsModel() + m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6441, Title: "Pending image"}, imageState: "checking"}} + 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, "still in progress") { + t.Fatalf("status = %q, want launch rejection", got.status) + } +} + +func TestPullErrorIncludesLastDockerMessage(t *testing.T) { + err := commandErrorWithDetail(errors.New("exit status 1"), "manifest unknown") + if !strings.Contains(err.Error(), "manifest unknown") { + t.Fatalf("pull error = %v", err) + } +} + +func TestOutputTailRetainsBuildFailure(t *testing.T) { + tail := &outputTail{} + _, _ = tail.Write([]byte("building\r\nERROR: Dockerfile not found\n")) + if got := tail.lastLine(); got != "ERROR: Dockerfile not found" { + t.Fatalf("last line = %q", got) + } +} + +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) + } +} + +func TestNavigationRemainsAvailableDuringBuild(t *testing.T) { + m := newPRsModel() + m.rebuilding = true + m.rows = []prRow{ + {kind: "remote", pr: gh.PR{Number: 1}}, + {kind: "remote", pr: gh.PR{Number: 2}}, + } + m.table.SetRows(buildTableRows(m.rows)) + + updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + selected := updated.(prsModel).table.SelectedRow() + if len(selected) < 3 || selected[2] != "2" { + t.Fatalf("build blocked navigation: %v", selected) + } +} + +func TestCompletedPullWithoutPlanDoesNotPanic(t *testing.T) { + m := newPRsModel() + m.pulling = true + m.pullCancel = func() {} + + updated, cmd := m.Update(pullEvent{done: true}) + got := updated.(prsModel) + if got.pulling || cmd != nil { + t.Fatalf("pulling=%v cmd=%v", got.pulling, cmd) + } +} + +func TestTruncateTextPreservesUTF8(t *testing.T) { + if got := truncateText("🦞homarr", 2); got != "🦞h" { + t.Fatalf("truncateText() = %q", got) + } +} diff --git a/tools/homarr-dev/main.go b/tools/homarr-dev/main.go new file mode 100644 index 000000000..2380e41bd --- /dev/null +++ b/tools/homarr-dev/main.go @@ -0,0 +1,301 @@ +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 + runMain = tui.RunDev +) + +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 # check Docker and optional integrations`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runMain() + }, +} + +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 ", + 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(cmd.Context()) + 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 ", + 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 ", + 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 ", + 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 ", + 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 ", + Short: "Open a running instance in the browser", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + containers, err := docker.List() + if err != nil { + return err + } + for _, container := range containers { + if container.Name != args[0] { + continue + } + if !container.Running() { + return fmt.Errorf("container %q is not running", args[0]) + } + port := container.HostPort() + if port == "" { + return fmt.Errorf("container %q does not publish the Homarr port", args[0]) + } + return platform.OpenURL("http://localhost:" + port) + } + return fmt.Errorf("container %q was not found", args[0]) + }, +} + +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 and optional developer CLI integrations", + RunE: func(cmd *cobra.Command, args []string) error { + failed := false + output, err := exec.Command("docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput() + if err != nil { + failed = true + fmt.Printf("✗ Docker daemon: %s\n", strings.TrimSpace(string(output))) + } else { + fmt.Printf("✓ Docker daemon: %s\n", strings.TrimSpace(string(output))) + } + + if _, err := exec.LookPath("gh"); err != nil { + fmt.Println("- GitHub CLI not found (optional; required for pull-request features)") + } else if output, err := exec.Command("gh", "auth", "status").CombinedOutput(); err != nil { + fmt.Printf("- GitHub CLI is not authenticated (optional; run `gh auth login` for pull-request features): %s\n", strings.TrimSpace(string(output))) + } else { + fmt.Println("✓ GitHub CLI authentication") + } + + if _, err := exec.LookPath("homarr"); err != nil { + fmt.Println("- homarr is not on PATH (optional; use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`)") + } 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 == "" { + if err := cmd.Usage(); err != nil { + return err + } + return fmt.Errorf("provide a tag or --pr") + } + + plan, err := run.BuildPlan(run.Options{ + Context: cmd.Context(), + PR: flagPR, + Tag: tag, + Demo: flagDemo, + Env: flagEnv, + FetchTitle: flagPR > 0, + }) + if err != nil { + return err + } + + fmt.Printf("Starting Homarr (%s)\n", plan.Label) + fmt.Printf(" Image : %s\n", plan.Image) + fmt.Printf(" Container : %s\n", plan.Name) + fmt.Printf(" Volume : %s\n", plan.Volume) + fmt.Printf(" Port : %d:7575\n", plan.HostPort) + fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort) + if plan.PRNumber > 0 { + fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber) + } + fmt.Println() + + if 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..1736d98b3 --- /dev/null +++ b/tools/homarr-dev/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +func TestRootCommandLaunchesDevelopmentDashboard(t *testing.T) { + want := errors.New("dashboard started") + previous := runMain + t.Cleanup(func() { runMain = previous }) + runMain = func() error { return want } + + if err := rootCmd.RunE(rootCmd, nil); !errors.Is(err, want) { + t.Fatalf("root command error = %v", err) + } +} + +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) + } +} + +func TestRunLaunchRejectsMissingImage(t *testing.T) { + previousPR := flagPR + t.Cleanup(func() { flagPR = previousPR }) + flagPR = 0 + + err := runLaunch(runCmd, nil) + if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") { + t.Fatalf("error = %v", err) + } +}