Skip to content

CLI Development Guide

Overview

HomelabARR CE is undergoing modernization from shell scripts to a modern Go application with Bubble Tea TUI framework. This guide covers the development of the new CLI interface.

Current CLI Architecture

Existing Shell-Based CLI

# Current installation methods
./install.sh           # Full Mode installation
./install-local.sh     # Local Mode installation

# Current maintenance scripts
./scripts/backup.sh    # Backup functionality
./scripts/update.sh    # Update system

Planned Go CLI Architecture

homelabarr-ce/
├── cmd/
│   ├── root.go          # Root command
│   ├── install.go       # Installation commands
│   ├── deploy.go        # Deployment commands
│   ├── manage.go        # Management commands
│   └── tui.go          # Terminal UI commands
├── internal/
│   ├── config/         # Configuration management
│   ├── docker/         # Docker integration
│   ├── installer/      # Installation logic
│   ├── tui/           # Bubble Tea components
│   └── utils/          # Utility functions
├── pkg/
│   ├── applications/   # Application definitions
│   ├── compose/        # Docker Compose handling
│   └── network/        # Network management
└── main.go            # Entry point

Development Environment Setup

Prerequisites

# Install Go 1.21+
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

# Verify installation
go version

Development Dependencies

# Core dependencies
go mod init github.com/homelabarr/cli
go get github.com/spf13/cobra@latest
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest
go get github.com/docker/docker/client@latest
go get gopkg.in/yaml.v3@latest

# Development tools
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

CLI Command Structure

Root Command Design

// cmd/root.go
package cmd

import (
    "github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
    Use:   "homelabarr",
    Short: "HomelabARR CE - Self-hosted media server stack",
    Long: `HomelabARR CE provides tools for deploying and managing
a comprehensive Docker-based media server stack.`,
    Version: "2.0.0",
}

func Execute() error {
    return rootCmd.Execute()
}

func init() {
    rootCmd.AddCommand(installCmd)
    rootCmd.AddCommand(deployCmd)
    rootCmd.AddCommand(manageCmd)
    rootCmd.AddCommand(tuiCmd)
}

Installation Commands

// cmd/install.go
var installCmd = &cobra.Command{
    Use:   "install",
    Short: "Install HomelabARR CE",
    Long:  `Install and configure HomelabARR CE components.`,
}

var installFullCmd = &cobra.Command{
    Use:   "full",
    Short: "Install Full Mode (Traefik + Authelia)",
    Run: func(cmd *cobra.Command, args []string) {
        installer.InstallFullMode()
    },
}

var installLocalCmd = &cobra.Command{
    Use:   "local",
    Short: "Install Local Mode (Direct Access)",
    Run: func(cmd *cobra.Command, args []string) {
        installer.InstallLocalMode()
    },
}

func init() {
    installCmd.AddCommand(installFullCmd)
    installCmd.AddCommand(installLocalCmd)
}

Deployment Commands

// cmd/deploy.go
var deployCmd = &cobra.Command{
    Use:   "deploy",
    Short: "Deploy applications",
    Long:  `Deploy and manage containerized applications.`,
}

var deployAppCmd = &cobra.Command{
    Use:   "app [application]",
    Short: "Deploy specific application",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        deployer.DeployApplication(args[0])
    },
}

var deployStackCmd = &cobra.Command{
    Use:   "stack [stack-name]",
    Short: "Deploy application stack",
    Args:  cobra.ExactArgs(1),
    Run: func(cmd *cobra.Command, args []string) {
        deployer.DeployStack(args[0])
    },
}

Bubble Tea TUI Development

TUI Architecture

// internal/tui/model.go
package tui

import (
    "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type Model struct {
    currentView string
    apps        []Application
    selected    int
    width       int
    height      int
}

type Application struct {
    Name        string
    Category    string
    Description string
    Status      string
    URL         string
}

func (m Model) Init() tea.Cmd {
    return tea.EnterAltScreen
}

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        return m.handleKeyPress(msg)
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil
    }
    return m, nil
}

func (m Model) View() string {
    switch m.currentView {
    case "main":
        return m.renderMainMenu()
    case "apps":
        return m.renderApplicationList()
    case "deploy":
        return m.renderDeploymentView()
    }
    return "Unknown view"
}
// internal/tui/main_menu.go
func (m Model) renderMainMenu() string {
    title := titleStyle.Render("HomelabARR CE")

    options := []string{
        "1. Install HomelabARR",
        "2. Deploy Applications",
        "3. Manage Services",
        "4. System Status",
        "5. Backup & Restore",
        "6. Settings",
        "q. Quit",
    }

    menu := strings.Join(options, "\n")

    return lipgloss.JoinVertical(
        lipgloss.Center,
        title,
        "",
        menu,
    )
}

Application List View

// internal/tui/app_list.go
func (m Model) renderApplicationList() string {
    var rows []string

    header := fmt.Sprintf("%-20s %-15s %-10s %s",
        "Application", "Category", "Status", "URL")
    rows = append(rows, headerStyle.Render(header))

    for i, app := range m.apps {
        style := itemStyle
        if i == m.selected {
            style = selectedStyle
        }

        row := fmt.Sprintf("%-20s %-15s %-10s %s",
            app.Name, app.Category, app.Status, app.URL)
        rows = append(rows, style.Render(row))
    }

    return lipgloss.JoinVertical(lipgloss.Left, rows...)
}

Docker Integration

Docker Client Setup

// internal/docker/client.go
package docker

import (
    "context"
    "github.com/docker/docker/client"
    "github.com/docker/docker/api/types"
)

type Client struct {
    cli *client.Client
    ctx context.Context
}

func NewClient() (*Client, error) {
    cli, err := client.NewClientWithOpts(client.FromEnv)
    if err != nil {
        return nil, err
    }

    return &Client{
        cli: cli,
        ctx: context.Background(),
    }, nil
}

func (c *Client) ListContainers() ([]types.Container, error) {
    return c.cli.ContainerList(c.ctx, types.ContainerListOptions{})
}

func (c *Client) GetContainerLogs(containerID string) (string, error) {
    logs, err := c.cli.ContainerLogs(c.ctx, containerID, types.ContainerLogsOptions{
        ShowStdout: true,
        ShowStderr: true,
        Tail:       "50",
    })
    if err != nil {
        return "", err
    }
    defer logs.Close()

    // Read and return logs
    // Implementation details...
    return "", nil
}

Compose Integration

// internal/compose/manager.go
package compose

import (
    "os/exec"
    "gopkg.in/yaml.v3"
)

type Manager struct {
    projectPath string
    envFile     string
}

func NewManager(projectPath, envFile string) *Manager {
    return &Manager{
        projectPath: projectPath,
        envFile:     envFile,
    }
}

func (m *Manager) Deploy(composeFile string) error {
    cmd := exec.Command("docker", "compose",
        "-f", composeFile,
        "--env-file", m.envFile,
        "up", "-d")

    return cmd.Run()
}

func (m *Manager) Stop(composeFile string) error {
    cmd := exec.Command("docker", "compose",
        "-f", composeFile,
        "--env-file", m.envFile,
        "down")

    return cmd.Run()
}

Configuration Management

Configuration Structure

// internal/config/config.go
package config

type Config struct {
    Mode         string            `yaml:"mode"`         // "full" or "local"
    Domain       string            `yaml:"domain"`
    AppFolder    string            `yaml:"app_folder"`
    Environment  map[string]string `yaml:"environment"`
    Applications []AppConfig       `yaml:"applications"`
}

type AppConfig struct {
    Name     string            `yaml:"name"`
    Enabled  bool              `yaml:"enabled"`
    Image    string            `yaml:"image"`
    Port     int               `yaml:"port"`
    Env      map[string]string `yaml:"env"`
}

func Load(configPath string) (*Config, error) {
    // Load and parse YAML configuration
    return nil, nil
}

func (c *Config) Save(configPath string) error {
    // Save configuration to YAML file
    return nil
}

Application Definitions

Application Registry

// pkg/applications/registry.go
package applications

type Application struct {
    Name        string   `yaml:"name"`
    Category    string   `yaml:"category"`
    Description string   `yaml:"description"`
    Image       string   `yaml:"image"`
    Port        int      `yaml:"port"`
    Tags        []string `yaml:"tags"`
    Dependencies []string `yaml:"dependencies"`
}

var Registry = map[string]Application{
    "plex": {
        Name:        "Plex Media Server",
        Category:    "mediaserver",
        Description: "Stream your media collection",
        Image:       "plexinc/pms-docker:latest",
        Port:        32400,
        Tags:        []string{"media", "streaming"},
    },
    "radarr": {
        Name:        "Radarr",
        Category:    "mediamanager",
        Description: "Movie collection manager",
        Image:       "lscr.io/linuxserver/radarr:latest",
        Port:        7878,
        Tags:        []string{"movies", "automation"},
        Dependencies: []string{},
    },
}

func GetApplication(name string) (Application, bool) {
    app, exists := Registry[name]
    return app, exists
}

func ListByCategory(category string) []Application {
    var apps []Application
    for _, app := range Registry {
        if app.Category == category {
            apps = append(apps, app)
        }
    }
    return apps
}

Build and Distribution

Makefile

# Makefile
.PHONY: build test clean install

GO_VERSION = 1.21
BINARY_NAME = homelabarr
VERSION = 2.0.0

build:
    go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY_NAME) .

test:
    go test ./...

lint:
    golangci-lint run

clean:
    rm -f $(BINARY_NAME)

install:
    go install -ldflags "-X main.version=$(VERSION)" .

build-all:
    GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY_NAME)-linux-amd64 .
    GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY_NAME)-linux-arm64 .
    GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY_NAME)-darwin-amd64 .
    GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY_NAME)-windows-amd64.exe .

GitHub Actions CI/CD

# .github/workflows/cli-build.yml
name: CLI Build and Release

on:
  push:
    branches: [ main, develop ]
    tags: [ 'v*' ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: '1.21'

    - name: Test
      run: go test ./...

    - name: Lint
      uses: golangci/golangci-lint-action@v3

    - name: Build
      run: make build-all

    - name: Upload artifacts
      uses: actions/upload-artifact@v3
      with:
        name: binaries
        path: homelabarr-*

Migration Strategy

Phase 1: Parallel Development

  • Develop Go CLI alongside existing shell scripts
  • Implement core commands (install, deploy, manage)
  • Maintain backward compatibility

Phase 2: Feature Parity

  • Implement all existing functionality in Go
  • Add TUI interface for enhanced user experience
  • Comprehensive testing and validation

Phase 3: Replacement

  • Deprecate shell scripts gradually
  • Update documentation for new CLI
  • Provide migration guides for users

Testing Strategy

Unit Tests

// internal/config/config_test.go
package config

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestLoadConfig(t *testing.T) {
    config, err := Load("testdata/config.yml")
    assert.NoError(t, err)
    assert.Equal(t, "local", config.Mode)
    assert.Equal(t, "/opt/appdata", config.AppFolder)
}

Integration Tests

// integration/cli_test.go
package integration

import (
    "os/exec"
    "testing"
)

func TestCLIVersion(t *testing.T) {
    cmd := exec.Command("./homelabarr", "--version")
    output, err := cmd.Output()
    if err != nil {
        t.Fatal(err)
    }

    // Verify version output
}

Documentation

CLI Help System

// Cobra automatically generates help
// Additional custom help templates can be added
rootCmd.SetHelpTemplate(customHelpTemplate)

Man Pages

# Generate man pages
go run scripts/generate-man-pages.go

The new CLI will provide a modern, user-friendly interface while maintaining all the power and flexibility of the current HomelabARR CE system.