docs(cli): update developer CLI documentation and add nerd font requirement

This commit is contained in:
Thomas Camlong committed 2026-08-21 16:21:41 +02:00
1 parent 7710abcf26
commit 84b000afda
6 files changed
+578 -46

No files matched your search

@@ -1,43 +1,175 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
## Browse development images
## Requirements
- Docker or OrbStack
- Go 1.25+ (when building the CLI from source)
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
## Interactive Development Browser
```bash
pnpm dev:cli --
# or with global binary:
homarr
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). Starting it prefers that local image without a registry pull. Outdated local builds remain separate rows.
## Build images
### Multi-Source Sidebar
Press `Tab` to cycle between sidebar tabs:
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
### Background Task Overlay
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
- Active build stages and logs.
- Task cancellation and status.
### Browser Shortcuts
| Key | Action |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
| `↑` / `↓` or `j` / `k` | Navigate rows |
| `Enter` or `Space` | Start local build / pull remote PR image; stop if running |
| `R` | Build locally in background (or rebuild local image) |
| `p` | Pull remote PR image in background and start container |
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
| `t` | Toggle background tasks overlay |
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
| `f` | Follow or pause logs |
| `M` | Toggle demo mode for next launch |
| `b` | Toggle bot pull requests |
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
| `c` | Copy instance URL to clipboard |
| `r` | Refresh data |
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
## Instance Dashboard
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
homarr dash
# or
pnpm dev:cli -- dash
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
## Rebuild images
| Key | Action |
| ------------------ | ---------------------------------------- |
| `Enter` or `Space` | Start or stop selected instance |
| `s` / `S` | Stop / Restart instance |
| `x` | Remove container (preserves data volume) |
| `m` | Manage mode for selected instance |
| `R` | Rebuild from recorded build provenance |
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
### Launch Instances
```bash
pnpm dev:cli -- rebuild <name>
homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
- `--demo` (`-m`): Start with mock integrations and sample data.
- `--detach`: Start container in background.
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
## Run images
### Build & Rebuild
```bash
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
homarr run <name>
homarr run --pr <number>
# Build current checkout as homarr:<name>
homarr build feature
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
```
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
### GitHub CI Checks
```bash
# View CI checks for a specific PR
homarr ci 6441
# View CI checks for the current branch
homarr ci
# Watch CI checks until completion
homarr ci 6441 --watch
```
### Interactive Shell
```bash
# Open interactive shell in running container
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
```
### Image & Volume Management
```bash
# List local images with build provenance (source, revision, PR)
homarr images
# Delete local image
homarr images rm feature
# List data volumes with owner PR/tag and in-use status
homarr data
# Delete data volume (safely stops and removes owning container first)
homarr data rm homarr_feature_data
# Remove stopped containers (prompts without --force)
homarr prune --force
```
### Instance Operations & Diagnostics
```bash
# List instances in a script-friendly table
homarr list
# Follow container logs
homarr logs homarr-dev
# Stop, restart, remove, or open container in browser
homarr stop homarr-dev
homarr restart homarr-dev
homarr remove homarr-dev
homarr open homarr-dev
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
homarr doctor
```
@@ -25,3 +25,27 @@ You should also create a backup of the database, in case you break something.
4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
## Build a local branch harness
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
```sh
pnpm cli harness setup feature-board
```
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
```sh
pnpm cli harness port feature-board
```
Pass environment overrides by repeating `--env`:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
+357
View File
@@ -0,0 +1,357 @@
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { command, number, positional, string } from "@drizzle-team/brocli";
const execFileAsync = promisify(execFile);
const environmentSeparator = "\u0000";
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
const demoEnvironment: ReadonlyArray<[string, string]> = [
["DEMO_MODE", "true"],
["DEMO_READ_ONLY", "false"],
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
];
type HarnessRuntimeOptions = {
name: string;
env?: string;
port?: number;
};
const runtimeOptions = () => ({
name: positional("name").required().desc("Name used for the image, container, and data volume"),
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
});
function validateHarnessName(name: string): string {
if (!validHarnessName.test(name)) {
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
}
return name;
}
function imageName(name: string): string {
return `homarr:${validateHarnessName(name)}`;
}
function containerName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}`;
}
function volumeName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}-data`;
}
function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function commandError(commandName: string, error: unknown): Error {
if (hasErrorCode(error, "ENOENT")) {
return new Error(`${commandName} is required but was not found on PATH`);
}
if (error instanceof Error) {
return new Error(`${commandName} failed: ${error.message}`);
}
return new Error(`${commandName} failed`);
}
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
try {
const result = await execFileAsync(commandName, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
return result.stdout.trim();
} catch (error) {
throw commandError(commandName, error);
}
}
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(commandName, args, {
cwd,
stdio: "inherit",
});
child.once("error", (error) => reject(commandError(commandName, error)));
child.once("close", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${commandName} failed with ${reason}`));
});
});
}
async function repositoryRoot(): Promise<string> {
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
try {
await access(join(root, "Dockerfile"), constants.R_OK);
} catch {
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
}
return root;
}
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
const values = raw?.split(environmentSeparator) ?? [];
const environment = new Map(demoEnvironment);
for (const value of values) {
const separator = value.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
}
const name = value.slice(0, separator);
if (!validEnvironmentName.test(name)) {
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
}
environment.set(name, value.slice(separator + 1));
}
return environment;
}
function isEncryptionKey(value: string | undefined): value is string {
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
}
async function readEncryptionKey(path: string): Promise<string | undefined> {
try {
const value = (await readFile(path, "utf8")).trim();
if (isEncryptionKey(value)) {
return value;
}
throw new Error(`Invalid encryption key in ${path}`);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return undefined;
}
throw error;
}
}
async function harnessEncryptionKey(name: string): Promise<string> {
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
return process.env.SECRET_ENCRYPTION_KEY;
}
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
const existingKey = await readEncryptionKey(keyPath);
if (existingKey) {
return existingKey;
}
const newKey = randomBytes(32).toString("hex");
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
try {
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
return newKey;
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error;
}
const racedKey = await readEncryptionKey(keyPath);
if (racedKey) {
return racedKey;
}
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
}
}
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
const environment = parseEnvironmentOverrides(raw);
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
}
return Array.from(environment, ([key, value]) => `${key}=${value}`);
}
async function buildHarnessImage(name: string): Promise<void> {
const root = await repositoryRoot();
const image = imageName(name);
console.log(`Building ${image} from ${root}`);
await runCommand("docker", ["build", "--tag", image, "."], root);
console.log(`Built ${image}`);
}
async function removeExistingContainer(name: string): Promise<void> {
try {
await execCommand("docker", ["rm", "--force", containerName(name)]);
} catch {
// A missing container is the normal first-run path. Docker reports it as
// an error, so the cleanup is intentionally best-effort.
}
}
async function publishedPort(name: string): Promise<number> {
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
for (const line of output.split("\n")) {
const match = line.trim().match(/:(\d+)$/);
if (match) {
return Number(match[1]);
}
}
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
}
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
const name = validateHarnessName(options.name);
const image = imageName(name);
const container = containerName(name);
const requestedPort = options.port ?? 0;
const environment = await harnessEnvironment(name, options.env);
await removeExistingContainer(name);
console.log(`Starting ${container} from ${image}`);
await runCommand("docker", [
"run",
"--detach",
"--name",
container,
"--publish",
`${requestedPort}:7575`,
"--volume",
`${volumeName(name)}:/appdata`,
...environment.flatMap((value) => ["--env", value]),
image,
]);
const port = await publishedPort(name);
console.log(`Harness ${name} is running`);
console.log(` Container : ${container}`);
console.log(` Image : ${image}`);
console.log(` Port : ${port}`);
console.log(` URL : http://127.0.0.1:${port}`);
}
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
const normalized = argv.slice(0, 2);
const args = argv.slice(2);
const environmentValues: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === undefined) {
continue;
}
if (arg === "--env" || arg === "-e") {
const value = args[index + 1];
if (value === undefined) {
normalized.push(arg);
continue;
}
environmentValues.push(value);
index += 1;
continue;
}
if (arg.startsWith("--env=")) {
environmentValues.push(arg.slice("--env=".length));
continue;
}
normalized.push(arg);
}
if (environmentValues.length > 0) {
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
}
return normalized;
};
const harnessBuild = command({
name: "build",
desc: "Build the current checkout as a named local Homarr image",
options: {
name: positional("name").required().desc("Local image tag"),
},
handler: async ({ name }) => buildHarnessImage(name),
});
const harnessRun = command({
name: "run",
aliases: ["start", "up"],
desc: "Run a named local image in writable demo mode",
options: runtimeOptions(),
handler: async (options) => startHarness(options),
});
const harnessSetup = command({
name: "setup",
aliases: ["create"],
desc: "Build the current checkout and run its writable demo harness",
options: runtimeOptions(),
handler: async (options) => {
await buildHarnessImage(options.name);
await startHarness(options);
},
});
const harnessPort = command({
name: "port",
desc: "Show the published host port and URL for a harness",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const port = await publishedPort(name);
console.log(`Port: ${port}`);
console.log(`URL: http://127.0.0.1:${port}`);
},
});
const harnessStop = command({
name: "stop",
aliases: ["down"],
desc: "Stop a local harness container and keep its data volume",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const container = containerName(name);
await execCommand("docker", ["stop", container]);
console.log(`Stopped ${container}`);
},
});
export const harnessRoot = command({
name: "harness",
aliases: ["local-harness"],
desc: "Build and run a local branch in a browser-ready demo harness",
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
});
+38 -10
View File
@@ -1,14 +1,42 @@
import { run } from "@drizzle-team/brocli";
import type { Command } from "@drizzle-team/brocli";
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
const harnessCommandNames = new Set(["harness", "local-harness"]);
void run(commands, {
name: "homarr-cli",
version: "1.0.0",
});
function isHarnessInvocation(argv: string[]): boolean {
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
}
async function main(): Promise<void> {
const harnessInvocation = isHarnessInvocation(process.argv);
let commands: Command[];
if (harnessInvocation) {
commands = [harnessRoot];
} else {
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
await Promise.all([
import("./commands/fix-usernames"),
import("./commands/integrations"),
import("./commands/recreate-admin"),
import("./commands/reset-password"),
import("./commands/users"),
]);
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
}
let argSource = process.argv;
if (harnessInvocation) {
argSource = normalizeHarnessEnvironmentArgs(process.argv);
}
await run(commands, {
argSource,
name: "homarr-cli",
version: "1.0.0",
});
}
void main();
+3 -2
View File
@@ -3,6 +3,7 @@ package ui
import (
"fmt"
"image/color"
"strings"
"charm.land/lipgloss/v2"
)
@@ -190,8 +191,8 @@ func Bar(percent float64, width int, colour color.Color) string {
}
filled := int(percent * float64(width))
empty := width - filled
return lipgloss.NewStyle().Foreground(colour).Render(repeat("█", filled)) +
lipgloss.NewStyle().Foreground(Faint).Render(repeat("░", empty))
return lipgloss.NewStyle().Foreground(colour).Render(strings.Repeat("█", filled)) +
lipgloss.NewStyle().Foreground(Faint).Render(strings.Repeat("░", empty))
}
// Percent formats a fraction for display next to a bar.
+6 -16
View File
@@ -53,25 +53,15 @@ func Panel(title string, focused bool) lipgloss.Style {
// Rule draws a labelled separator across the given width.
func Rule(label string, width int) string {
if width < 8 {
return Dim.Render(label)
return Dim.Render(Truncate(label, width))
}
prefix := "── " + label + " "
if lipgloss.Width(prefix) >= width {
return Dim.Render(prefix[:width])
prefixWidth := lipgloss.Width(prefix)
if prefixWidth >= width {
return Dim.Render(Truncate(prefix, width))
}
fill := width - lipgloss.Width(prefix)
return Dim.Render(prefix + repeat("─", fill))
}
func repeat(char string, count int) string {
if count <= 0 {
return ""
}
out := make([]byte, 0, count*len(char))
for range count {
out = append(out, char...)
}
return string(out)
fill := width - prefixWidth
return Dim.Render(prefix + strings.Repeat("─", fill))
}
// Blank reports whether a line would render as empty. Build tools emit lines