diff --git a/apps/docs/docs/advanced/command-line/development/index.mdx b/apps/docs/docs/advanced/command-line/development/index.mdx index ee5036a72..f17a754db 100644 --- a/apps/docs/docs/advanced/command-line/development/index.mdx +++ b/apps/docs/docs/advanced/command-line/development/index.mdx @@ -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 -pnpm dev:cli -- build --pr [name] +homarr dash +# or +pnpm dev:cli -- dash ``` -The first command builds the current checkout as `homarr:`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-` when `name` is omitted. +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 +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 `: Pull and run a PR image from GHCR (shorthand: `homarr --pr `). +- `--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 --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/ -homarr run -homarr run --pr +# Build current checkout as homarr: +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 ` form is shorthand for `homarr run --pr `. Add `--detach` to run in the -background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables. +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 +``` diff --git a/apps/docs/docs/advanced/command-line/index.mdx b/apps/docs/docs/advanced/command-line/index.mdx index 287985739..04024254f 100644 --- a/apps/docs/docs/advanced/command-line/index.mdx +++ b/apps/docs/docs/advanced/command-line/index.mdx @@ -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 `, `harness run `, and `harness stop `. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`. diff --git a/packages/cli/src/commands/harness.ts b/packages/cli/src/commands/harness.ts new file mode 100644 index 000000000..aa375f85a --- /dev/null +++ b/packages/cli/src/commands/harness.ts @@ -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 { + 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 { + await new Promise((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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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], +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f1b0c658d..1aa483261 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 { + 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(); diff --git a/tools/homarr-dev/internal/ui/badge.go b/tools/homarr-dev/internal/ui/badge.go index 4f3cce497..250aa79cb 100644 --- a/tools/homarr-dev/internal/ui/badge.go +++ b/tools/homarr-dev/internal/ui/badge.go @@ -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. diff --git a/tools/homarr-dev/internal/ui/theme.go b/tools/homarr-dev/internal/ui/theme.go index b55e28b2c..452649287 100644 --- a/tools/homarr-dev/internal/ui/theme.go +++ b/tools/homarr-dev/internal/ui/theme.go @@ -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