Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2a1df453 | ||
|
|
ca7e50fb96 | ||
|
|
db6aa0b26e | ||
|
|
d096aecd59 | ||
|
|
49a41431b9 | ||
|
|
df587ec76a | ||
|
|
3b450eb04a | ||
|
|
406fb4c0b6 | ||
|
|
f23c35645c | ||
|
|
667bf62396 | ||
|
|
c5a056b8e5 | ||
|
|
934b6e69f4 | ||
|
|
c8c5dbc506 | ||
|
|
b1e79e4641 | ||
|
|
5b04ed1ab5 | ||
|
|
b733ed6a79 | ||
|
|
ae3e9f7793 | ||
|
|
c8485ed7fc | ||
|
|
e97eb7fc58 |
No files matched your search
@@ -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 <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
|
||||
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
|
||||
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
|
||||
@@ -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 <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
```
|
||||
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||
|
||||
## Rebuild images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
```
|
||||
|
||||
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 <name>
|
||||
homarr run --pr <number>
|
||||
```
|
||||
|
||||
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
@@ -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 <name>`: Build the current checkout as `homarr:<name>`.
|
||||
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
|
||||
- `pnpm dev:cli -- rebuild <name>`: 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.
|
||||
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
|
||||
@@ -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",
|
||||
|
||||
@@ -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:<name>`. The source checkout and revision are recorded on the image so `homarr rebuild` and the development dashboard can rebuild it later. PR builds use a temporary checkout and default to `homarr:pr-<number>`.
|
||||
|
||||
## Launch
|
||||
|
||||
```sh
|
||||
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 <name> Build this checkout as homarr:<name>
|
||||
homarr build --pr <number> Build a PR locally from a temporary checkout
|
||||
homarr rebuild <name> Rebuild from recorded provenance
|
||||
homarr list Script-friendly instance list
|
||||
homarr logs <container> Follow logs
|
||||
homarr open <container> Open in browser
|
||||
homarr stop <container> Stop
|
||||
homarr restart <container> Restart
|
||||
homarr remove <container> Force-remove
|
||||
homarr doctor Check Docker 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
|
||||
```
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 != "<none>" {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package platform
|
||||
|
||||
import "github.com/atotto/clipboard"
|
||||
|
||||
func CopyText(value string) error {
|
||||
return clipboard.WriteAll(value)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func OpenURL(url string) error {
|
||||
name, args, err := openCommand(runtime.GOOS, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command(name, args...).Start()
|
||||
}
|
||||
|
||||
func openCommand(goos, url string) (string, []string, error) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "open", []string{url}, nil
|
||||
case "linux":
|
||||
return "xdg-open", []string{url}, nil
|
||||
case "windows":
|
||||
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
goos string
|
||||
name string
|
||||
}{
|
||||
{goos: "darwin", name: "open"},
|
||||
{goos: "linux", name: "xdg-open"},
|
||||
{goos: "windows", name: "rundll32"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.goos, func(t *testing.T) {
|
||||
name, args, err := openCommand(test.goos, "https://example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != test.name || len(args) == 0 {
|
||||
t.Fatalf("command = %s %v", name, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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=<number>")
|
||||
}
|
||||
pr, err := strconv.Atoi(os.Getenv("HOMARR_TEST_PR"))
|
||||
if err != nil || pr <= 0 {
|
||||
t.Fatal("HOMARR_TEST_PR must be a positive PR number")
|
||||
}
|
||||
|
||||
plan, err := BuildPlan(Options{PR: pr})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.Name += "_integration"
|
||||
plan.Volume += "_integration"
|
||||
defer docker.Remove(plan.Name)
|
||||
|
||||
if err := StartDetached(plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !docker.IsRunning(plan.Name) {
|
||||
t.Fatalf("container %s did not remain running", plan.Name)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <tag>` 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <name>",
|
||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
images, err := docker.ListLocalImages(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 <container>",
|
||||
Short: "Follow logs of a running instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return docker.FollowLogs(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var stopCmd = &cobra.Command{
|
||||
Use: "stop <container>",
|
||||
Short: "Stop a running instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return docker.Stop(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var restartCmd = &cobra.Command{
|
||||
Use: "restart <container>",
|
||||
Short: "Restart a running instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return docker.Restart(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var removeCmd = &cobra.Command{
|
||||
Use: "remove <container>",
|
||||
Short: "Force-remove an instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return docker.Remove(args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var openCmd = &cobra.Command{
|
||||
Use: "open <container>",
|
||||
Short: "Open a running instance in the browser",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
containers, err := docker.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, container := range containers {
|
||||
if container.Name != args[0] {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user