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