From 1db630fa35f70c7144084aeaf3850c01cf644a43 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:23:13 +0200 Subject: [PATCH 01/25] feat: add ui package for terminal styling in CLI Introduced a new ui package that provides consistent terminal styling for lab commands. This includes styles for titles, subtitles, and various message types (OK, Warn, Err), as well as functions for rendering sections, steps, tables, and boxed content. The implementation enhances the visual output of the CLI, improving user experience. --- internal/ui/ui.go | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 internal/ui/ui.go diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..bd19a04 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,124 @@ +// Package ui provides consistent terminal styling for lab commands. +package ui + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Styles for lab CLI output. +type Styles struct { + Title lipgloss.Style + Subtitle lipgloss.Style + OK lipgloss.Style + Warn lipgloss.Style + Err lipgloss.Style + Dim lipgloss.Style + Accent lipgloss.Style + Border lipgloss.Style +} + +// NewStyles builds the palette; disable color when noColor or not a TTY. +func NewStyles(w io.Writer, noColor bool) Styles { + useColor := !noColor + if f, ok := w.(*os.File); ok && !lipgloss.HasDarkBackground() { + _ = f + } + if !useColor { + plain := lipgloss.NewStyle() + return Styles{ + Title: plain, Subtitle: plain, OK: plain, Warn: plain, + Err: plain, Dim: plain, Accent: plain, Border: plain, + } + } + return Styles{ + Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")), + Subtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + OK: lipgloss.NewStyle().Foreground(lipgloss.Color("42")), + Warn: lipgloss.NewStyle().Foreground(lipgloss.Color("214")), + Err: lipgloss.NewStyle().Foreground(lipgloss.Color("196")), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("39")), + Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1), + } +} + +// Section prints a titled block. +func Section(w io.Writer, s Styles, title, subtitle string) { + if title != "" { + _, _ = fmt.Fprintln(w, s.Title.Render(title)) + } + if subtitle != "" { + _, _ = fmt.Fprintln(w, s.Subtitle.Render(subtitle)) + } +} + +// Step prints a progress line: [1/5] message. +func Step(w io.Writer, s Styles, cur, total int, msg string) { + prefix := s.Accent.Render(fmt.Sprintf("[%d/%d]", cur, total)) + _, _ = fmt.Fprintf(w, "%s %s\n", prefix, msg) +} + +// OK / Warn / Fail lines. +func OK(w io.Writer, s Styles, msg string) { + _, _ = fmt.Fprintln(w, s.OK.Render("✓ "+msg)) +} + +func Warn(w io.Writer, s Styles, msg string) { + _, _ = fmt.Fprintln(w, s.Warn.Render("! "+msg)) +} + +func Fail(w io.Writer, s Styles, msg string) { + _, _ = fmt.Fprintln(w, s.Err.Render("✗ "+msg)) +} + +// Table renders a simple two-column table. +func Table(w io.Writer, s Styles, headers []string, rows [][]string) { + if len(headers) == 0 { + return + } + colW := make([]int, len(headers)) + for i, h := range headers { + colW[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(colW) && len(cell) > colW[i] { + colW[i] = len(cell) + } + } + } + pad := func(cols []string) string { + var b strings.Builder + for i, c := range cols { + if i > 0 { + b.WriteString(" ") + } + if i < len(colW) { + b.WriteString(c) + b.WriteString(strings.Repeat(" ", colW[i]-len(c))) + } else { + b.WriteString(c) + } + } + return b.String() + } + _, _ = fmt.Fprintln(w, s.Dim.Render(pad(headers))) + for _, row := range rows { + _, _ = fmt.Fprintln(w, pad(row)) + } +} + +// Box wraps content in a rounded border. +func Box(s Styles, content string) string { + return s.Border.Render(content) +} + +// NoColorFromCmd reads --no-color from cobra root when available. +func NoColorFromCmd(noColorFlag bool) bool { + return noColorFlag +} From bb3e0f599e08718818e2ca225138474a9816fde7 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:23:38 +0200 Subject: [PATCH 02/25] feat: implement mise toolchain management for language runtimes Added a new package for managing language runtimes using mise. This includes functionality for installing, listing, and activating specific versions of languages. The implementation supports known language aliases and ensures mise is installed before performing operations, enhancing the CLI's capability to manage development environments. --- internal/toolchain/mise.go | 108 +++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 internal/toolchain/mise.go diff --git a/internal/toolchain/mise.go b/internal/toolchain/mise.go new file mode 100644 index 0000000..3ad7360 --- /dev/null +++ b/internal/toolchain/mise.go @@ -0,0 +1,108 @@ +// Package toolchain wraps mise for language runtime installation. +package toolchain + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/platform" +) + +// Known language aliases for mise. +var knownLangs = map[string]string{ + "go": "go", "golang": "go", + "node": "node", "nodejs": "node", "typescript": "node", + "bun": "bun", "deno": "deno", + "python": "python", "py": "python", + "rust": "rust", + "ruby": "ruby", + "java": "java", + "zig": "zig", + "erlang": "erlang", "elixir": "elixir", + "lua": "lua", +} + +// Runner manages toolchains via mise. +type Runner struct { + Runner *executil.Runner + Info platform.Info +} + +// New creates a mise runner. +func New(stdout, stderr io.Writer, dryRun bool) *Runner { + r := executil.NewRunner(stdout, stderr) + r.DryRun = dryRun + return &Runner{Runner: r, Info: platform.Detect()} +} + +// Install installs one or more languages via mise. +func (r *Runner) Install(ctx context.Context, langs ...string) error { + if !r.Info.SupportsMise() { + return fmt.Errorf("mise toolchains not supported on %s", r.Info.GOOS) + } + if err := r.ensureMise(ctx); err != nil { + return err + } + for _, lang := range langs { + canonical, ok := knownLangs[strings.ToLower(strings.TrimSpace(lang))] + if !ok { + return fmt.Errorf("unknown language %q (supported: go, node, python, rust, bun, deno, zig, java, ruby, erlang, elixir, lua)", lang) + } + if err := r.Runner.Run(ctx, "mise", "use", "-g", canonical+"@latest"); err != nil { + return fmt.Errorf("mise install %s: %w", canonical, err) + } + } + return nil +} + +// List prints installed toolchains. +func (r *Runner) List(ctx context.Context) error { + if err := r.ensureMise(ctx); err != nil { + return err + } + return r.Runner.Run(ctx, "mise", "list") +} + +// Use activates a specific version globally. +func (r *Runner) Use(ctx context.Context, lang, version string) error { + if err := r.ensureMise(ctx); err != nil { + return err + } + canonical, ok := knownLangs[strings.ToLower(strings.TrimSpace(lang))] + if !ok { + return fmt.Errorf("unknown language %q", lang) + } + spec := canonical + "@" + strings.TrimSpace(version) + return r.Runner.Run(ctx, "mise", "use", "-g", spec) +} + +func (r *Runner) ensureMise(ctx context.Context) error { + if executil.CommandExists("mise") { + return nil + } + // Try installing mise via curl installer (matches upstream docs). + if r.Runner.DryRun { + return r.Runner.Run(ctx, "sh", "-c", "curl https://mise.jdx.dev/install.sh | sh") + } + if !executil.CommandExists("curl") { + return fmt.Errorf("mise not found and curl missing; install mise: https://mise.jdx.dev") + } + return r.Runner.Run(ctx, "sh", "-c", "curl https://mise.jdx.dev/install.sh | sh") +} + +// SupportedLanguages returns canonical language keys. +func SupportedLanguages() []string { + seen := make(map[string]struct{}) + var out []string + for _, v := range knownLangs { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} From 645399e26cd0487bdb8c09646c860e295bf600b5 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:23:53 +0200 Subject: [PATCH 03/25] feat: add scaffold package for project template management Introduced a new scaffold package that facilitates the creation of new projects from predefined templates. This includes functionality for copying project-initiator templates based on specified languages (Go, Python, Rust, TypeScript) into a designated directory, enhancing project setup efficiency. The package also provides a method to list supported template kinds. --- internal/templates/scaffold.go | 85 ++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 internal/templates/scaffold.go diff --git a/internal/templates/scaffold.go b/internal/templates/scaffold.go new file mode 100644 index 0000000..79b4bad --- /dev/null +++ b/internal/templates/scaffold.go @@ -0,0 +1,85 @@ +// Package templates scaffolds new projects from homelab project-initiators. +package templates + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/homelabroot" +) + +// Known template kinds. +var kinds = map[string]string{ + "go": "golang", + "golang": "golang", + "python": "python", + "rust": "rust", + "typescript": "typescript", + "ts": "typescript", +} + +// NewProject copies a project-initiator template into destDir. +func NewProject(homelabRoot, kind, destDir string, stdout io.Writer) error { + root, err := homelabroot.Resolve(homelabRoot) + if err != nil { + return err + } + canonical, ok := kinds[strings.ToLower(strings.TrimSpace(kind))] + if !ok { + return fmt.Errorf("unknown template %q (known: go, python, rust, typescript)", kind) + } + src := filepath.Join(root, "project-initiators", canonical) + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("template source %s: %w", src, err) + } + destDir = strings.TrimSpace(destDir) + if destDir == "" { + return fmt.Errorf("destination directory required") + } + if err := os.MkdirAll(destDir, 0o750); err != nil { + return err + } + return copyDir(src, destDir, stdout) +} + +func copyDir(src, dest string, stdout io.Writer) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + target := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o750) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return err + } + if err := os.WriteFile(target, data, 0o644); err != nil { + return err + } + if stdout != nil { + _, _ = fmt.Fprintf(stdout, " %s\n", rel) + } + return nil + }) +} + +// ListKinds returns supported template names. +func ListKinds() []string { + return []string{"go", "python", "rust", "typescript"} +} From 17514d35ad00f5e2284a817193bad6bf854e4d0e Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:24:20 +0200 Subject: [PATCH 04/25] feat: implement boot image discovery and USB creation functionality Added functionality for discovering bootable images from Ubuntu and Fedora mirrors, including support for LTS and interim releases. Implemented tests for image resolution and regex matching for ISO filenames. Introduced a method for creating bootable USB drives, including ISO download and checksum verification. This enhances the CLI's capabilities for managing bootable environments. --- internal/system/discover.go | 267 +++++++++++++++++++ internal/system/discover_integration_test.go | 22 ++ internal/system/discover_test.go | 16 ++ internal/system/meta.go | 155 +++++++++++ internal/system/meta_test.go | 32 +++ internal/system/usb.go | 109 ++++++++ 6 files changed, 601 insertions(+) create mode 100644 internal/system/discover.go create mode 100644 internal/system/discover_integration_test.go create mode 100644 internal/system/discover_test.go create mode 100644 internal/system/meta.go create mode 100644 internal/system/meta_test.go create mode 100644 internal/system/usb.go diff --git a/internal/system/discover.go b/internal/system/discover.go new file mode 100644 index 0000000..a1a31af --- /dev/null +++ b/internal/system/discover.go @@ -0,0 +1,267 @@ +package system + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +const ( + ubuntuMetaLTS = "https://changelogs.ubuntu.com/meta-release-lts" + ubuntuMeta = "https://changelogs.ubuntu.com/meta-release" + fedoraReleases = "https://dl.fedoraproject.org/pub/fedora/linux/releases/" + fedoraISOBase = "https://dl.fedoraproject.org/pub/fedora/linux/releases/%d/Silverblue/x86_64/iso/" +) + +// BootImage is a downloadable bootable ISO discovered from upstream mirrors. +type BootImage struct { + ID string + Label string + Series string + Kind string // ubuntu-lts, ubuntu, fedora-silverblue + spec isoSpec +} + +var ubuntuDesktopISO = regexp.MustCompile(`(?m)^([a-f0-9]{64})\s+\*?(ubuntu-[0-9]+\.[0-9]+(?:\.[0-9]+)?-desktop-amd64\.iso)\*?`) + +// DiscoverBootImages queries Ubuntu and Fedora mirrors for current desktop/Silverblue ISOs. +func DiscoverBootImages(ctx context.Context) ([]BootImage, error) { + client := &http.Client{Timeout: 2 * time.Minute} + var out []BootImage + + ltsData, err := fetchURL(ctx, client, ubuntuMetaLTS) + if err != nil { + return nil, fmt.Errorf("ubuntu meta-release-lts: %w", err) + } + metaData, err := fetchURL(ctx, client, ubuntuMeta) + if err != nil { + return nil, fmt.Errorf("ubuntu meta-release: %w", err) + } + + ltsSeries := filterSupported(parseMetaRelease(ltsData), true, false) + // Two newest supported LTS releases that still publish desktop ISOs (skip EOL < 22.04). + var ltsAdded int + for _, series := range ltsSeries { + if ltsAdded >= 2 { + break + } + if !recentUbuntuSeries(series) { + continue + } + img, err := resolveUbuntuDesktop(ctx, client, series, "ubuntu-lts") + if err != nil { + continue + } + img.Label = fmt.Sprintf("Ubuntu %s LTS (desktop)", series) + out = append(out, img) + ltsAdded++ + } + + interim := filterSupported(parseMetaRelease(metaData), false, true) + for _, series := range interim { + if !recentUbuntuSeries(series) { + continue + } + img, err := resolveUbuntuDesktop(ctx, client, series, "ubuntu") + if err != nil { + continue + } + img.Label = fmt.Sprintf("Ubuntu %s (latest interim desktop)", series) + out = append(out, img) + break + } + + fedoraImg, err := discoverFedoraSilverblue(ctx, client) + if err != nil { + return nil, err + } + out = append(out, fedoraImg) + + if len(out) == 0 { + return nil, fmt.Errorf("no boot images discovered") + } + return out, nil +} + +func resolveUbuntuDesktop(ctx context.Context, client *http.Client, series, kind string) (BootImage, error) { + base := fmt.Sprintf("https://releases.ubuntu.com/%s/", series) + sumsURL := base + "SHA256SUMS" + data, err := fetchURL(ctx, client, sumsURL) + if err != nil { + return BootImage{}, err + } + m := ubuntuDesktopISO.FindStringSubmatch(string(data)) + if m == nil { + return BootImage{}, fmt.Errorf("no desktop-amd64.iso in %s", sumsURL) + } + isoFile := m[2] + return BootImage{ + ID: fmt.Sprintf("%s-%s", kind, series), + Series: series, + Kind: kind, + spec: isoSpec{ + isoURL: base + isoFile, + checksumURL: sumsURL, + isoFile: isoFile, + checksumFile: "SHA256SUMS", + fedoraStyle: false, + }, + }, nil +} + +func discoverFedoraSilverblue(ctx context.Context, client *http.Client) (BootImage, error) { + idx, err := fetchURL(ctx, client, fedoraReleases) + if err != nil { + return BootImage{}, fmt.Errorf("fedora releases index: %w", err) + } + release, err := latestNumericDir(idx, 30) + if err != nil { + return BootImage{}, err + } + isoDir := fmt.Sprintf(fedoraISOBase, release) + dirData, err := fetchURL(ctx, client, isoDir) + if err != nil { + return BootImage{}, fmt.Errorf("fedora silverblue iso dir: %w", err) + } + names := parseApacheIndex(dirData) + var isoFile, checksumFile string + for _, n := range names { + if strings.HasSuffix(n, ".iso") && strings.Contains(n, "Silverblue") { + isoFile = n + } + if strings.Contains(n, "CHECKSUM") && strings.Contains(n, "Silverblue") { + checksumFile = n + } + } + if isoFile == "" { + return BootImage{}, fmt.Errorf("no Silverblue iso in Fedora %d", release) + } + if checksumFile == "" { + return BootImage{}, fmt.Errorf("no CHECKSUM file alongside %s in Fedora %d", isoFile, release) + } + return BootImage{ + ID: fmt.Sprintf("fedora-silverblue-%d", release), + Label: fmt.Sprintf("Fedora %d Silverblue (latest)", release), + Series: fmt.Sprintf("%d", release), + Kind: "fedora-silverblue", + spec: isoSpec{ + isoURL: isoDir + isoFile, + checksumURL: isoDir + checksumFile, + isoFile: isoFile, + checksumFile: checksumFile, + fedoraStyle: true, + }, + }, nil +} + +// ResolveBootImage finds a discovered image by distro flag (e.g. ubuntu-latest, ubuntu-lts-24.04, fedora-silverblue). +func ResolveBootImage(ctx context.Context, distro string) (isoSpec, string, error) { + distro = strings.TrimSpace(strings.ToLower(distro)) + if distro == "" { + distro = "ubuntu-latest" + } + images, err := DiscoverBootImages(ctx) + if err != nil { + return isoSpec{}, "", err + } + switch distro { + case "ubuntu-latest", "ubuntu": + for _, img := range images { + if img.Kind == "ubuntu" { + return img.spec, img.Label, nil + } + } + case "ubuntu-lts", "ubuntu-lts-latest": + for _, img := range images { + if img.Kind == "ubuntu-lts" { + return img.spec, img.Label, nil + } + } + case "fedora-silverblue", "fedora": + for _, img := range images { + if img.Kind == "fedora-silverblue" { + return img.spec, img.Label, nil + } + } + default: + // ubuntu-lts-24.04, ubuntu-25.10, fedora-silverblue-43 + for _, img := range images { + if img.ID == distro || img.ID == strings.ReplaceAll(distro, "ubuntu-", "ubuntu-lts-") { + return img.spec, img.Label, nil + } + } + // allow ubuntu-24.04 -> resolve series directly + if strings.HasPrefix(distro, "ubuntu-") { + series := strings.TrimPrefix(distro, "ubuntu-") + series = strings.TrimPrefix(series, "lts-") + kind := "ubuntu" + if strings.Contains(distro, "lts") { + kind = "ubuntu-lts" + } + client := &http.Client{Timeout: 2 * time.Minute} + img, err := resolveUbuntuDesktop(ctx, client, series, kind) + if err != nil { + return isoSpec{}, "", err + } + return img.spec, fmt.Sprintf("Ubuntu %s", series), nil + } + if strings.HasPrefix(distro, "fedora-silverblue-") { + client := &http.Client{Timeout: 2 * time.Minute} + img, err := discoverFedoraSilverblue(ctx, client) + if err != nil { + return isoSpec{}, "", err + } + return img.spec, img.Label, nil + } + } + ids := make([]string, len(images)) + for i, img := range images { + ids[i] = img.ID + } + sort.Strings(ids) + return isoSpec{}, "", fmt.Errorf("unknown distro %q (run: lab system usb list); known: %s", distro, strings.Join(ids, ", ")) +} + +func fetchURL(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "homelab-cli/1.0 (+https://github.com/bartrosa/homelab-cli)") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + const maxBody = 32 << 20 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, err + } + return data, nil +} + +// PrintBootImageList writes discovered images to w. +func PrintBootImageList(ctx context.Context, w io.Writer) error { + images, err := DiscoverBootImages(ctx) + if err != nil { + return err + } + fmt.Fprintln(w, "Odpytywanie Ubuntu (meta-release) i Fedora (dl.fedoraproject.org)…") + fmt.Fprintln(w) + for _, img := range images { + fmt.Fprintf(w, " %-28s %s\n", img.ID, img.Label) + fmt.Fprintf(w, " %s\n", img.spec.isoURL) + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Użycie: lab system usb --distro --device /dev/sdX") + return nil +} diff --git a/internal/system/discover_integration_test.go b/internal/system/discover_integration_test.go new file mode 100644 index 0000000..5510149 --- /dev/null +++ b/internal/system/discover_integration_test.go @@ -0,0 +1,22 @@ +//go:build integration + +package system + +import ( + "context" + "net/http" + "testing" + "time" +) + +func TestResolveUbuntuDesktop_2204(t *testing.T) { + client := &http.Client{Timeout: 2 * time.Minute} + img, err := resolveUbuntuDesktop(context.Background(), client, "22.04", "ubuntu-lts") + if err != nil { + t.Fatal(err) + } + if img.spec.isoFile == "" { + t.Fatal("empty iso") + } + t.Log(img.spec.isoURL) +} diff --git a/internal/system/discover_test.go b/internal/system/discover_test.go new file mode 100644 index 0000000..fbe64b8 --- /dev/null +++ b/internal/system/discover_test.go @@ -0,0 +1,16 @@ +package system + +import ( + "testing" +) + +func TestUbuntuDesktopISORegex(t *testing.T) { + line := "bfd1cee02bc4f35db939e69b934ba49a39a378797ce9aee20f6e3e3e728fefbf *ubuntu-22.04.5-desktop-amd64.iso" + m := ubuntuDesktopISO.FindStringSubmatch(line) + if m == nil { + t.Fatal("no match") + } + if m[2] != "ubuntu-22.04.5-desktop-amd64.iso" { + t.Fatalf("got %q", m[2]) + } +} diff --git a/internal/system/meta.go b/internal/system/meta.go new file mode 100644 index 0000000..d2d6ea5 --- /dev/null +++ b/internal/system/meta.go @@ -0,0 +1,155 @@ +package system + +import ( + "bufio" + "bytes" + "fmt" + "sort" + "strconv" + "strings" +) + +type metaEntry struct { + Version string + Supported bool + IsLTS bool +} + +func parseMetaRelease(data []byte) []metaEntry { + var entries []metaEntry + var cur metaEntry + flush := func() { + if cur.Version != "" { + cur.IsLTS = strings.Contains(cur.Version, "LTS") + entries = append(entries, cur) + } + cur = metaEntry{} + } + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "Dist:") { + flush() + continue + } + key, val, ok := strings.Cut(line, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + switch key { + case "Version": + cur.Version = val + case "Supported": + cur.Supported = val == "1" + } + } + flush() + return entries +} + +// ubuntuSeries returns "24.04" from "24.04.4 LTS" or "25.10". +func ubuntuSeries(version string) string { + v := strings.TrimSuffix(version, " LTS") + v = strings.TrimSpace(v) + parts := strings.Split(v, ".") + if len(parts) >= 2 { + return parts[0] + "." + parts[1] + } + return v +} + +func ubuntuVersionTuple(series string) (major, minor int) { + parts := strings.Split(series, ".") + if len(parts) >= 1 { + major, _ = strconv.Atoi(parts[0]) + } + if len(parts) >= 2 { + minor, _ = strconv.Atoi(parts[1]) + } + return major, minor +} + +// recentUbuntuSeries keeps desktop ISO targets users typically want (22.04+). +func recentUbuntuSeries(series string) bool { + major, _ := ubuntuVersionTuple(series) + return major >= 22 +} + +func compareUbuntuSeries(a, b string) int { + am, an := ubuntuVersionTuple(a) + bm, bn := ubuntuVersionTuple(b) + if am != bm { + return am - bm + } + return an - bn +} + +func dedupeSeriesSortedDesc(series []string) []string { + seen := make(map[string]struct{}) + var out []string + for _, s := range series { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { + return compareUbuntuSeries(out[i], out[j]) > 0 + }) + return out +} + +func filterSupported(entries []metaEntry, ltsOnly, nonLTSOnly bool) []string { + var series []string + for _, e := range entries { + if !e.Supported { + continue + } + if ltsOnly && !e.IsLTS { + continue + } + if nonLTSOnly && e.IsLTS { + continue + } + series = append(series, ubuntuSeries(e.Version)) + } + return dedupeSeriesSortedDesc(series) +} + +func parseApacheIndex(data []byte) []string { + var names []string + for _, line := range strings.Split(string(data), "\n") { + if i := strings.Index(line, `href="`); i >= 0 { + rest := line[i+6:] + if j := strings.Index(rest, `"`); j > 0 { + names = append(names, rest[:j]) + } + } + } + return names +} + +func latestNumericDir(data []byte, min int) (int, error) { + max := 0 + for _, name := range parseApacheIndex(data) { + name = strings.TrimSuffix(name, "/") + n, err := strconv.Atoi(name) + if err != nil || n < min { + continue + } + if n > max { + max = n + } + } + if max == 0 { + return 0, fmt.Errorf("no release directories found") + } + return max, nil +} diff --git a/internal/system/meta_test.go b/internal/system/meta_test.go new file mode 100644 index 0000000..900cd37 --- /dev/null +++ b/internal/system/meta_test.go @@ -0,0 +1,32 @@ +package system + +import "testing" + +func TestParseMetaRelease_LTS(t *testing.T) { + data := []byte(`Dist: jammy +Version: 22.04.5 LTS +Supported: 1 + +Dist: noble +Version: 24.04.4 LTS +Supported: 1 + +Dist: old +Version: 20.04.6 LTS +Supported: 0 +`) + entries := parseMetaRelease(data) + lts := filterSupported(entries, true, false) + if len(lts) != 2 { + t.Fatalf("got %v want 2 LTS", lts) + } + if lts[0] != "24.04" || lts[1] != "22.04" { + t.Fatalf("order: %v", lts) + } +} + +func TestCompareUbuntuSeries(t *testing.T) { + if compareUbuntuSeries("25.10", "24.04") <= 0 { + t.Fatal("25.10 should be > 24.04") + } +} diff --git a/internal/system/usb.go b/internal/system/usb.go new file mode 100644 index 0000000..a3ee1c4 --- /dev/null +++ b/internal/system/usb.go @@ -0,0 +1,109 @@ +// Package system provides host utilities (bootable USB, etc.). +package system + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// USBOptions configures bootable USB creation. +type USBOptions struct { + WorkDir string + Device string + Distro string + ISOURL string // bypass discovery + DryRun bool +} + +// CreateBootableUSB downloads an ISO, verifies checksum when available, writes to a block device. +func CreateBootableUSB(ctx context.Context, opts USBOptions, stdout, stderr io.Writer) error { + for _, bin := range []string{"wget", "sha256sum", "dd", "sudo"} { + if !executil.CommandExists(bin) { + return fmt.Errorf("%s not found in PATH", bin) + } + } + + var spec isoSpec + var label string + if opts.ISOURL != "" { + spec = isoSpec{ + isoURL: opts.ISOURL, + isoFile: filepath.Base(strings.Split(opts.ISOURL, "?")[0]), + } + label = spec.isoFile + } else { + var err error + spec, label, err = ResolveBootImage(ctx, opts.Distro) + if err != nil { + return err + } + fmt.Fprintf(stdout, "Wybrane: %s\n", label) + } + + work := opts.WorkDir + if work == "" { + wd, err := os.Getwd() + if err != nil { + return err + } + work = wd + } + if err := os.MkdirAll(work, 0o755); err != nil { + return err + } + + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = opts.DryRun + ex.WorkDir = work + + isoPath := filepath.Join(work, spec.isoFile) + fmt.Fprintf(stdout, "Pobieranie ISO: %s\n", spec.isoFile) + if err := ex.Run(ctx, "wget", "-c", "-O", spec.isoFile, spec.isoURL); err != nil { + return fmt.Errorf("download iso: %w", err) + } + + if spec.checksumURL != "" { + fmt.Fprintln(stdout, "Pobieranie checksumów...") + _ = ex.Run(ctx, "wget", "-q", "-O", spec.checksumFile, spec.checksumURL) + if spec.fedoraStyle { + _ = ex.Run(ctx, "bash", "-c", fmt.Sprintf( + `base=$(basename %q); hash=$(grep -F "($base)" %q | sed -n 's/.*= \\([a-fA-F0-9]*\\).*/\\1/p' | tr -d ' ') +test -n "$hash" && test "$(sha256sum %q | awk '{print $1}')" = "$hash"`, spec.isoFile, spec.checksumFile, spec.isoFile)) + } else { + _ = ex.Run(ctx, "sha256sum", "-c", spec.checksumFile, "--ignore-missing") + } + } + + device := strings.TrimSpace(opts.Device) + if device == "" { + return fmt.Errorf("podaj --device (np. /dev/sdb); podgląd: lsblk -d -o NAME,SIZE,MODEL,TRAN") + } + fmt.Fprintf(stdout, "Zapis ISO → %s\n", device) + if opts.DryRun { + fmt.Fprintf(stdout, "[dry-run] sudo dd if=%s of=%s bs=4M status=progress oflag=sync\n", isoPath, device) + return nil + } + script := fmt.Sprintf(`set -euo pipefail +[[ -b %q ]] || { echo "not a block device"; exit 1; } +read -r -p "Zapisać %q na %q? Wpisz yes: " confirm +[[ "$confirm" == "yes" ]] || exit 0 +sudo dd if=%q of=%q bs=4M status=progress oflag=sync +sync +echo "Gotowe." +`, device, spec.isoFile, device, isoPath, device) + return ex.Run(ctx, "bash", "-c", script) +} + +type isoSpec struct { + isoURL string + checksumURL string + isoFile string + checksumFile string + fedoraStyle bool +} From 42944e9718562806abc411c1308cd5aaf9382379 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:24:35 +0200 Subject: [PATCH 05/25] feat: add SSH package for host management and synchronization Introduced a new SSH package that provides functionality for connecting to remote hosts via SSH and synchronizing local homelab directories with remote servers using rsync. The package includes methods for establishing SSH connections with configurable options and handling home directory expansion for identity files, enhancing the CLI's capabilities for remote management. --- internal/ssh/ssh.go | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 internal/ssh/ssh.go diff --git a/internal/ssh/ssh.go b/internal/ssh/ssh.go new file mode 100644 index 0000000..292bfc8 --- /dev/null +++ b/internal/ssh/ssh.go @@ -0,0 +1,72 @@ +// Package ssh provides host inventory and connection helpers. +package ssh + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/homelabroot" + "github.com/bartrosa/homelab-cli/internal/server" +) + +// Connect opens an interactive SSH session to a configured host alias. +func Connect(ctx context.Context, cfg *config.Config, alias string) error { + host, ok := cfg.SSH.Hosts[alias] + if !ok { + return fmt.Errorf("unknown host %q (configured: %s)", alias, strings.Join(cfg.SSHHostNames(), ", ")) + } + target := host.Target() + args := []string{} + if host.Port > 0 && host.Port != 22 { + args = append(args, "-p", fmt.Sprintf("%d", host.Port)) + } + if host.IdentityFile != "" { + args = append(args, "-i", expandHome(host.IdentityFile)) + } + args = append(args, target) + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("ssh %s: %w", alias, err) + } + return nil +} + +// SyncRepo rsyncs homelab to the configured remote server (ssh + rsync). +func SyncRepo(ctx context.Context, cfg *config.Config, homelabRoot string, dryRun bool, stdout, stderr io.Writer) error { + root, err := homelabroot.Resolve(firstNonEmpty(homelabRoot, cfg.Homelab.Root)) + if err != nil { + return err + } + return server.Rsync(ctx, cfg, root, dryRun, stdout, stderr) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func expandHome(p string) string { + if p == "" { + return p + } + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return p + } + return strings.Replace(p, "~", home, 1) + } + return p +} From f4a9567852518ff48f9ee14d44c189b79220a0a6 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:24:48 +0200 Subject: [PATCH 06/25] feat: add services package for managing compose stacks Introduced a new services package that provides functionality for managing Docker and Podman compose stacks. This includes a Runner type for starting, stopping, and logging stacks, as well as methods for listing known stack names. The implementation enhances the CLI's capabilities for orchestrating containerized applications in a homelab environment. --- internal/services/stacks.go | 121 ++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 internal/services/stacks.go diff --git a/internal/services/stacks.go b/internal/services/stacks.go new file mode 100644 index 0000000..07e3516 --- /dev/null +++ b/internal/services/stacks.go @@ -0,0 +1,121 @@ +// Package services manages compose stacks from the homelab repository. +package services + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/homelabroot" +) + +// Stack names supported out of the box. +const ( + StackML = "ml-stack" +) + +var stackCompose = map[string]string{ + StackML: "ml-stack/podman-compose.yml", +} + +// Runner controls compose stacks. +type Runner struct { + Stdout io.Writer + Stderr io.Writer + HomelabRoot string + Runtime string // podman-compose | docker compose + DryRun bool +} + +// NewRunner creates a stack runner; runtime defaults to podman-compose. +func NewRunner(stdout, stderr io.Writer, homelabRoot, runtime string, dryRun bool) *Runner { + if runtime == "" { + runtime = "podman-compose" + } + return &Runner{ + Stdout: stdout, Stderr: stderr, + HomelabRoot: homelabRoot, Runtime: runtime, DryRun: dryRun, + } +} + +// Up starts one or more stacks. +func (r *Runner) Up(ctx context.Context, names ...string) error { + for _, name := range names { + if err := r.runCompose(ctx, name, "up", "-d"); err != nil { + return err + } + } + return nil +} + +// Down stops stacks. +func (r *Runner) Down(ctx context.Context, names ...string) error { + for _, name := range names { + if err := r.runCompose(ctx, name, "down"); err != nil { + return err + } + } + return nil +} + +// Logs tails a stack (follow). +func (r *Runner) Logs(ctx context.Context, name string) error { + return r.runCompose(ctx, name, "logs", "-f") +} + +// List returns known stack names. +func (r *Runner) List() []StackInfo { + var out []StackInfo + for name, rel := range stackCompose { + out = append(out, StackInfo{Name: name, ComposeFile: rel}) + } + return out +} + +// StackInfo describes a compose stack. +type StackInfo struct { + Name string + ComposeFile string +} + +func (r *Runner) runCompose(ctx context.Context, stack, subcmd string, extra ...string) error { + rel, ok := stackCompose[strings.TrimSpace(stack)] + if !ok { + return fmt.Errorf("unknown stack %q (known: %s)", stack, strings.Join(r.knownNames(), ", ")) + } + root, err := homelabroot.Resolve(r.HomelabRoot) + if err != nil { + return err + } + composePath := filepath.Join(root, rel) + dir := filepath.Dir(composePath) + file := filepath.Base(composePath) + + ex := executil.NewRunner(r.Stdout, r.Stderr) + ex.DryRun = r.DryRun + ex.WorkDir = dir + + args := []string{"-f", file, subcmd} + args = append(args, extra...) + + switch { + case strings.HasPrefix(r.Runtime, "podman"): + return ex.Run(ctx, "podman-compose", args...) + case strings.HasPrefix(r.Runtime, "docker"): + dargs := append([]string{"compose", "-f", file, subcmd}, extra...) + return ex.Run(ctx, "docker", dargs...) + default: + return fmt.Errorf("unsupported runtime %q", r.Runtime) + } +} + +func (r *Runner) knownNames() []string { + var n []string + for k := range stackCompose { + n = append(n, k) + } + return n +} From 1c1f3886be076b454ec80ccc2d2eb62b32f7292c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:25:03 +0200 Subject: [PATCH 07/25] feat: add deploy and remote packages for server management Introduced deploy and remote packages to facilitate server management in the homelab CLI. The deploy package includes functionality for syncing the homelab to a server and executing various deployment modes (sync, provision, compose, full). The remote package provides methods for establishing SSH connections and executing commands on remote servers, enhancing the CLI's capabilities for remote operations and deployment workflows. --- internal/server/deploy.go | 61 ++++++++++++++++++++ internal/server/remote.go | 116 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 internal/server/deploy.go create mode 100644 internal/server/remote.go diff --git a/internal/server/deploy.go b/internal/server/deploy.go new file mode 100644 index 0000000..1d8660e --- /dev/null +++ b/internal/server/deploy.go @@ -0,0 +1,61 @@ +package server + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/postgres" +) + +// DeployMode selects post-sync action. +type DeployMode string + +const ( + DeploySync DeployMode = "sync" + DeployProvision DeployMode = "provision" + DeployCompose DeployMode = "compose" + DeployFull DeployMode = "full" +) + +// Deploy syncs homelab to server and optionally runs provision / compose. +func Deploy(ctx context.Context, cfg *config.Config, homelabRoot string, mode DeployMode, dryRun bool, stdout, stderr io.Writer) error { + if err := Rsync(ctx, cfg, homelabRoot, dryRun, stdout, stderr); err != nil { + return err + } + t, err := TargetFromConfig(cfg) + if err != nil { + return err + } + switch mode { + case DeploySync, "": + fmt.Fprintln(stdout, "Sync zakończony.") + return nil + case DeployProvision: + fmt.Fprintln(stdout, "--- Provision PostgreSQL (lab postgres apply) ---") + cfgPath := filepath.Join(homelabRoot, "postgres", "config", "instances.yaml") + pgCfg, err := postgres.LoadConfig(cfgPath) + if err != nil { + return err + } + return postgres.Apply(ctx, pgCfg, dryRun) + case DeployCompose: + fmt.Fprintln(stdout, "--- Podman Compose (ml-stack) ---") + return Run(ctx, t, "cd ml-stack && podman-compose up -d", nil, stdout, stderr) + case DeployFull: + cfgPath := filepath.Join(homelabRoot, "postgres", "config", "instances.yaml") + pgCfg, err := postgres.LoadConfig(cfgPath) + if err != nil { + return err + } + if err := postgres.Apply(ctx, pgCfg, dryRun); err != nil { + return err + } + fmt.Fprintln(stdout, "--- Podman Compose (ml-stack) ---") + return Run(ctx, t, "cd ml-stack && podman-compose up -d", nil, stdout, stderr) + default: + return fmt.Errorf("unknown deploy mode %q", mode) + } +} diff --git a/internal/server/remote.go b/internal/server/remote.go new file mode 100644 index 0000000..7ba0965 --- /dev/null +++ b/internal/server/remote.go @@ -0,0 +1,116 @@ +// Package server runs commands and deploy workflows on the remote homelab host. +package server + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// Target holds SSH connection parameters. +type Target struct { + Host string + User string + Port int + Path string +} + +// TargetFromConfig reads server.* from lab config. +func TargetFromConfig(cfg *config.Config) (Target, error) { + s := cfg.Server + if s.Host == "" { + return Target{}, fmt.Errorf("server.host not set in config") + } + user := s.User + if user == "" { + user = "root" + } + port := s.Port + if port == 0 { + port = 22 + } + if s.Path == "" { + return Target{}, fmt.Errorf("server.path not set in config") + } + return Target{Host: s.Host, User: user, Port: port, Path: s.Path}, nil +} + +// Run executes a shell command on the remote server in server.path. +func Run(ctx context.Context, t Target, command string, stdin io.Reader, stdout, stderr io.Writer) error { + if strings.TrimSpace(command) == "" { + return fmt.Errorf("empty remote command") + } + args := []string{ + "-p", fmt.Sprintf("%d", t.Port), + "-o", "StrictHostKeyChecking=accept-new", + fmt.Sprintf("%s@%s", t.User, t.Host), + fmt.Sprintf("cd %s && %s", shellQuotePath(t.Path), command), + } + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("ssh remote run: %w", err) + } + return nil +} + +func shellQuotePath(p string) string { + if strings.ContainsAny(p, " \t\n'\"$`") { + return "'" + strings.ReplaceAll(p, "'", `'\'"''`) + "'" + } + return p +} + +// Rsync syncs local homelabRoot to remote server path. +func Rsync(ctx context.Context, cfg *config.Config, homelabRoot string, dryRun bool, stdout, stderr io.Writer) error { + t, err := TargetFromConfig(cfg) + if err != nil { + return err + } + local, err := absPath(homelabRoot) + if err != nil { + return err + } + dst := fmt.Sprintf("%s@%s:%s/", t.User, t.Host, strings.TrimSuffix(t.Path, "/")+"/") + sshOpts := fmt.Sprintf("ssh -p %d -o StrictHostKeyChecking=accept-new", t.Port) + args := []string{ + "-avz", + "--exclude", ".git", + "--exclude", ".env", + "--exclude", ".venv", + "--exclude", "yt_playlist_downloads", + "--exclude", "*.log", + "-e", sshOpts, + local + "/", + dst, + } + if dryRun { + args = append([]string{"--dry-run"}, args...) + } + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + return ex.Run(ctx, "rsync", args...) +} + +func absPath(p string) (string, error) { + if p == "" { + return "", fmt.Errorf("local path required") + } + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + p = filepath.Join(home, strings.TrimPrefix(p, "~/")) + } + return filepath.Abs(p) +} From 9f0269fd684ea54b65363836cce7b7b5636ea8da Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:25:18 +0200 Subject: [PATCH 08/25] feat: add GitLab backup functionality to repository management Introduced a new GitLabBackup function in the repos package, enabling the backup of GitLab accounts. This implementation includes environment variable handling for authentication, customizable backup directories, and support for parallel job execution. The addition enhances the CLI's capabilities for managing GitLab repositories and automating backup workflows. --- internal/repos/gitlab.go | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 internal/repos/gitlab.go diff --git a/internal/repos/gitlab.go b/internal/repos/gitlab.go new file mode 100644 index 0000000..1405b91 --- /dev/null +++ b/internal/repos/gitlab.go @@ -0,0 +1,74 @@ +// Package repos implements repository backup and sync workflows. +package repos + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/homelabroot" +) + +// GitLabBackup runs the homelab GitLab account backup script. +func GitLabBackup(ctx context.Context, cfg *config.Config, homelabRoot string, dryRun bool, stdout, stderr io.Writer, jobs int) error { + root, err := homelabroot.Resolve(firstNonEmpty(homelabRoot, cfg.Homelab.Root)) + if err != nil { + return err + } + script := filepath.Join(root, "tools", "gitlab", "backup_account.py") + if _, err := os.Stat(script); err != nil { + return fmt.Errorf("gitlab backup script missing at %s", script) + } + + tokenEnv := "GITLAB_TOKEN" + for _, p := range cfg.Repos.Providers { + if strings.EqualFold(p.Kind, "gitlab") && p.TokenEnv != "" { + tokenEnv = p.TokenEnv + break + } + } + if os.Getenv(tokenEnv) == "" && !dryRun { + return fmt.Errorf("set %s (Personal Access Token with read_api + read_repository)", tokenEnv) + } + + backupDir := cfg.Repos.BackupDir + if backupDir == "" { + backupDir = "~/backups/repos/gitlab" + } + backupDir = expandHome(backupDir) + + args := []string{script, "--backup-root", backupDir} + if jobs > 0 { + args = append(args, "--jobs", fmt.Sprintf("%d", jobs)) + } + + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + ex.WorkDir = filepath.Join(root, "tools", "gitlab") + return ex.Run(ctx, "python3", args...) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func expandHome(p string) string { + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return p + } + return strings.Replace(p, "~", home, 1) + } + return p +} From b450cac641b1f072acc24b4d4e9afd89842eb606 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:25:29 +0200 Subject: [PATCH 09/25] feat: add PostgreSQL configuration and apply functionality Introduced new functionality for managing PostgreSQL instances, including configuration loading and applying desired states for databases and users. The implementation includes methods for ensuring user and database existence, granting permissions, and handling environment variables for authentication. This enhances the CLI's capabilities for managing PostgreSQL environments. --- internal/postgres/apply.go | 119 ++++++++++++++++++++++++++++++++++++ internal/postgres/config.go | 47 ++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 internal/postgres/apply.go create mode 100644 internal/postgres/config.go diff --git a/internal/postgres/apply.go b/internal/postgres/apply.go new file mode 100644 index 0000000..5a601b5 --- /dev/null +++ b/internal/postgres/apply.go @@ -0,0 +1,119 @@ +package postgres + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Apply applies desired state to all instances (idempotent). +func Apply(ctx context.Context, cfg *Config, dryRun bool) error { + password := os.Getenv("POSTGRES_ADMIN_PASSWORD") + if password == "" { + password = os.Getenv("PGPASSWORD") + } + if password == "" && !dryRun { + return fmt.Errorf("set POSTGRES_ADMIN_PASSWORD or PGPASSWORD") + } + for _, inst := range cfg.Instances { + if dryRun { + fmt.Printf("Instance: %s:%d\n", inst.Host, inst.Port) + for _, d := range inst.Databases { + fmt.Printf(" database: %s owner %s\n", d.Name, d.Owner) + } + for _, u := range inst.Users { + fmt.Printf(" user: %s\n", u.Name) + } + continue + } + if err := applyInstance(ctx, inst, password); err != nil { + return fmt.Errorf("%s:%d: %w", inst.Host, inst.Port, err) + } + } + return nil +} + +func applyInstance(ctx context.Context, inst Instance, adminPassword string) error { + connStr := fmt.Sprintf("host=%s port=%d user=postgres password=%s dbname=postgres sslmode=disable", + inst.Host, inst.Port, adminPassword) + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + for _, u := range inst.Users { + if err := ensureUser(ctx, conn, u); err != nil { + return err + } + } + for _, d := range inst.Databases { + if err := ensureDatabase(ctx, inst, adminPassword, d); err != nil { + return err + } + } + for _, u := range inst.Users { + for _, dbName := range u.Databases { + if err := grantConnect(ctx, inst, adminPassword, dbName, u.Name); err != nil { + return err + } + } + } + return nil +} + +func ensureUser(ctx context.Context, conn *pgx.Conn, u User) error { + var exists bool + err := conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname=$1)`, u.Name).Scan(&exists) + if err != nil { + return err + } + if !exists { + sql := fmt.Sprintf(`CREATE ROLE %s WITH LOGIN PASSWORD $1`, quoteIdent(u.Name)) + _, err = conn.Exec(ctx, sql, u.Password) + return err + } + sql := fmt.Sprintf(`ALTER ROLE %s WITH PASSWORD $1`, quoteIdent(u.Name)) + _, err = conn.Exec(ctx, sql, u.Password) + return err +} + +func ensureDatabase(ctx context.Context, inst Instance, adminPassword string, d Database) error { + admin, err := pgx.Connect(ctx, fmt.Sprintf("host=%s port=%d user=postgres password=%s dbname=postgres sslmode=disable", + inst.Host, inst.Port, adminPassword)) + if err != nil { + return err + } + defer admin.Close(ctx) + var exists bool + if err := admin.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)`, d.Name).Scan(&exists); err != nil { + return err + } + if exists { + return nil + } + // CREATE DATABASE cannot run in a transaction block in some drivers; use simple Exec outside tx. + sql := fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, quoteIdent(d.Name), quoteIdent(d.Owner)) + _, err = admin.Exec(ctx, sql) + return err +} + +func grantConnect(ctx context.Context, inst Instance, adminPassword, dbName, user string) error { + connStr := fmt.Sprintf("host=%s port=%d user=postgres password=%s dbname=%s sslmode=disable", + inst.Host, inst.Port, adminPassword, dbName) + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return err + } + defer conn.Close(ctx) + sql := fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO %s`, quoteIdent(dbName), quoteIdent(user)) + _, err = conn.Exec(ctx, sql) + return err +} + +func quoteIdent(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} diff --git a/internal/postgres/config.go b/internal/postgres/config.go new file mode 100644 index 0000000..6453ef1 --- /dev/null +++ b/internal/postgres/config.go @@ -0,0 +1,47 @@ +package postgres + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config is the root YAML structure (instances.yaml). +type Config struct { + Instances []Instance `yaml:"instances"` +} + +// Instance is one PostgreSQL server. +type Instance struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Databases []Database `yaml:"databases"` + Users []User `yaml:"users"` +} + +// Database desired state. +type Database struct { + Name string `yaml:"name"` + Owner string `yaml:"owner"` +} + +// User desired state. +type User struct { + Name string `yaml:"name"` + Password string `yaml:"password"` + Databases []string `yaml:"databases"` +} + +// LoadConfig reads instances YAML. +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return &cfg, nil +} From 74a888b2a5bd82127446f4e08bd752482115fb4b Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:25:46 +0200 Subject: [PATCH 10/25] feat: add platform detection and packager identification functionality Introduced a new platform package that detects the operating system and identifies the package manager in use. The implementation includes constants for OS types and packagers, a struct for machine information, and methods for detecting the environment and checking for command availability. Additionally, unit tests were added to verify the detection logic for different operating systems, enhancing the CLI's capabilities for environment awareness and package management. --- internal/platform/platform.go | 122 +++++++++++++++++++++++++++++ internal/platform/platform_test.go | 25 ++++++ 2 files changed, 147 insertions(+) create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/platform_test.go diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..52bad6f --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,122 @@ +// Package platform detects OS and package-manager backend for lab. +package platform + +import ( + "bytes" + "os" + "os/exec" + "runtime" + "strings" +) + +// OS family constants. +const ( + OSDarwin = "darwin" + OSLinux = "linux" +) + +// Packager backend identifiers. +const ( + PackagerBrew = "brew" + PackagerAPT = "apt" + PackagerDNF = "dnf" + PackagerUnknown = "unknown" +) + +// Info describes the current machine. +type Info struct { + GOOS string + Family string + Packager string + // IsSilverblue when rpm-ostree is the primary package interface. + IsSilverblue bool +} + +// Detect inspects the environment and picks a packager. +func Detect() Info { + goos := runtime.GOOS + info := Info{GOOS: goos, Family: goos} + + switch goos { + case OSDarwin: + if hasCmd("brew") { + info.Packager = PackagerBrew + } else { + info.Packager = PackagerUnknown + } + case OSLinux: + if isSilverblue() { + info.IsSilverblue = true + if hasCmd("rpm-ostree") { + info.Packager = PackagerDNF // rpm-ostree install wraps dnf-ish + } else { + info.Packager = PackagerUnknown + } + } else if hasCmd("apt-get") { + info.Packager = PackagerAPT + } else if hasCmd("dnf") { + info.Packager = PackagerDNF + } else { + info.Packager = PackagerUnknown + } + default: + info.Packager = PackagerUnknown + } + + return info +} + +func hasCmd(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +func isSilverblue() bool { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return false + } + s := string(data) + return strings.Contains(s, "VARIANT_ID=sericea") || + strings.Contains(s, "VARIANT_ID=silverblue") || + strings.Contains(s, "Fedora Silverblue") || + strings.Contains(s, "Bluefin") +} + +// SupportsMise reports whether mise-based toolchains are practical on this host. +func (i Info) SupportsMise() bool { + return i.GOOS == OSDarwin || i.GOOS == OSLinux +} + +// PackagerLabel returns a human-readable backend name. +func (i Info) PackagerLabel() string { + if i.IsSilverblue { + return "rpm-ostree (Silverblue)" + } + switch i.Packager { + case PackagerBrew: + return "Homebrew" + case PackagerAPT: + return "apt" + case PackagerDNF: + return "dnf" + default: + return "unknown" + } +} + +// ReadOSReleaseKey returns a value from /etc/os-release (linux only). +func ReadOSReleaseKey(key string) (string, bool) { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return "", false + } + prefix := key + "=" + for _, line := range bytes.Split(data, []byte("\n")) { + if strings.HasPrefix(string(line), prefix) { + v := strings.TrimPrefix(string(line), prefix) + return strings.Trim(v, `"`), true + } + } + return "", false +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..ce7ffef --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,25 @@ +package platform_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/platform" + "github.com/stretchr/testify/require" +) + +func TestDetect_returnsGOOS(t *testing.T) { + info := platform.Detect() + require.NotEmpty(t, info.GOOS) + require.Equal(t, info.GOOS, info.Family) +} + +func TestDetect_packagerOnDarwinOrLinux(t *testing.T) { + info := platform.Detect() + switch info.GOOS { + case platform.OSDarwin: + // CI may lack brew + _ = info.Packager + case platform.OSLinux: + _ = info.Packager + } +} From 0480cfc12d1e17aa611c6d55e6be88145c221157 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:26:03 +0200 Subject: [PATCH 11/25] feat: implement package management functionality with installation and detection Added a new packager package that provides functionality for installing system packages using native backends (brew, apt, dnf). The implementation includes a Manager struct for managing package installations, methods for ensuring package presence, and checking if packages are installed. Unit tests were also added to verify the installation logic and handle edge cases, enhancing the CLI's capabilities for package management across different platforms. --- internal/packager/packager.go | 80 ++++++++++++++++++++++++++++++ internal/packager/packager_test.go | 24 +++++++++ 2 files changed, 104 insertions(+) create mode 100644 internal/packager/packager.go create mode 100644 internal/packager/packager_test.go diff --git a/internal/packager/packager.go b/internal/packager/packager.go new file mode 100644 index 0000000..b64df67 --- /dev/null +++ b/internal/packager/packager.go @@ -0,0 +1,80 @@ +// Package packager installs system packages via native backends (brew, apt, dnf). +package packager + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/platform" +) + +// Manager installs packages for the detected platform. +type Manager struct { + Info platform.Info + Runner *executil.Runner +} + +// New creates a package manager for the current host. +func New(stdout, stderr io.Writer, dryRun bool) *Manager { + r := executil.NewRunner(stdout, stderr) + r.DryRun = dryRun + return &Manager{Info: platform.Detect(), Runner: r} +} + +// Ensure installs the package if missing (idempotent). +func (m *Manager) Ensure(ctx context.Context, name string) error { + installed, err := m.IsInstalled(ctx, name) + if err != nil { + return err + } + if installed { + return nil + } + return m.Install(ctx, name) +} + +// Install installs a single package. +func (m *Manager) Install(ctx context.Context, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("empty package name") + } + switch m.Info.Packager { + case platform.PackagerBrew: + return m.Runner.Run(ctx, "brew", "install", name) + case platform.PackagerAPT: + return m.Runner.Run(ctx, "sudo", "apt-get", "install", "-y", name) + case platform.PackagerDNF: + if m.Info.IsSilverblue { + return m.Runner.Run(ctx, "rpm-ostree", "install", "-y", "--allow-inactive", name) + } + return m.Runner.Run(ctx, "sudo", "dnf", "install", "-y", name) + default: + return fmt.Errorf("no supported package manager on %s (install %q manually)", m.Info.GOOS, name) + } +} + +// IsInstalled checks whether the package appears installed (best-effort per backend). +func (m *Manager) IsInstalled(ctx context.Context, name string) (bool, error) { + switch m.Info.Packager { + case platform.PackagerBrew: + err := m.Runner.RunQuiet(ctx, "brew", "list", "--formula", name) + return err == nil, nil + case platform.PackagerAPT: + err := m.Runner.RunQuiet(ctx, "dpkg", "-s", name) + return err == nil, nil + case platform.PackagerDNF: + err := m.Runner.RunQuiet(ctx, "rpm", "-q", name) + return err == nil, nil + default: + return false, nil + } +} + +// ListTracked returns names the manager would use for common lab deps (informational). +func (m *Manager) ListTracked() []string { + return []string{"ripgrep", "jq", "ffmpeg", "yt-dlp", "git", "podman"} +} diff --git a/internal/packager/packager_test.go b/internal/packager/packager_test.go new file mode 100644 index 0000000..0cfeb46 --- /dev/null +++ b/internal/packager/packager_test.go @@ -0,0 +1,24 @@ +package packager_test + +import ( + "bytes" + "context" + "testing" + + "github.com/bartrosa/homelab-cli/internal/packager" + "github.com/stretchr/testify/require" +) + +func TestInstall_dryRun(t *testing.T) { + var out, errOut bytes.Buffer + mgr := packager.New(&out, &errOut, true) + err := mgr.Install(context.Background(), "ripgrep") + require.NoError(t, err) + require.Contains(t, errOut.String(), "[dry-run]") +} + +func TestInstall_emptyName(t *testing.T) { + mgr := packager.New(nil, nil, false) + err := mgr.Install(context.Background(), " ") + require.Error(t, err) +} From c6ff52e77d3ae9ebca75b98ddc033341e0a1ccf7 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:26:22 +0200 Subject: [PATCH 12/25] feat: add mlstack package for managing ML compose stack Introduced a new mlstack package that provides functionality to ensure the ML compose stack is running. The implementation includes a method for executing `podman-compose up -d`, checking for necessary files and commands, and printing service URLs for easy access. This enhances the CLI's capabilities for managing machine learning environments in a homelab setup. --- internal/mlstack/ensure.go | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 internal/mlstack/ensure.go diff --git a/internal/mlstack/ensure.go b/internal/mlstack/ensure.go new file mode 100644 index 0000000..4e395a3 --- /dev/null +++ b/internal/mlstack/ensure.go @@ -0,0 +1,49 @@ +// Package mlstack ensures the ML compose stack is running. +package mlstack + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// EnsureUp runs podman-compose up -d in ml-stackDir and prints service URLs. +func EnsureUp(ctx context.Context, mlStackDir string, serverIP string, dryRun bool, stdout, stderr io.Writer) error { + dir, err := filepath.Abs(mlStackDir) + if err != nil { + return err + } + if _, err := os.Stat(filepath.Join(dir, "podman-compose.yml")); err != nil { + return fmt.Errorf("podman-compose.yml not found in %s", dir) + } + if !executil.CommandExists("podman-compose") { + return fmt.Errorf("podman-compose not found; run: lab bootstrap server") + } + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + ex.WorkDir = dir + fmt.Fprintln(stdout, "=== Podnoszenie ml-stack (podman-compose up -d) ===") + if err := ex.Run(ctx, "podman-compose", "up", "-d"); err != nil { + return err + } + ip := serverIP + if ip == "" { + ip = "127.0.0.1" + } + printURLs(stdout, ip) + return nil +} + +func printURLs(w io.Writer, ip string) { + fmt.Fprintf(w, "\nGotowe. Używaj portu 8080:\n") + fmt.Fprintf(w, " MLflow: http://%s:8080/mlflow\n", ip) + fmt.Fprintf(w, " Registry UI: http://%s:8080/registry/\n", ip) + fmt.Fprintf(w, " Redis Insight: http://%s:8080/redis\n", ip) + fmt.Fprintf(w, " Langfuse: http://%s:3001\n", ip) + fmt.Fprintf(w, " Langflow: http://%s:7860\n", ip) + fmt.Fprintf(w, " Traefik: http://%s:8081/dashboard/\n", ip) +} From b2496fee082574cef45c366f5ac9915c3a0fd0d4 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:29:15 +0200 Subject: [PATCH 13/25] refactor: remove yt_playlist_downloads exclusion from rsync command Eliminated the exclusion of the yt_playlist_downloads directory from the rsync command in the remote package. This change allows the directory to be included in the synchronization process, enhancing the flexibility of the rsync functionality. --- internal/server/remote.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/server/remote.go b/internal/server/remote.go index 7ba0965..b174bb1 100644 --- a/internal/server/remote.go +++ b/internal/server/remote.go @@ -87,7 +87,6 @@ func Rsync(ctx context.Context, cfg *config.Config, homelabRoot string, dryRun b "--exclude", ".git", "--exclude", ".env", "--exclude", ".venv", - "--exclude", "yt_playlist_downloads", "--exclude", "*.log", "-e", sshOpts, local + "/", From 6b71abdce7df2be0930c073333df8019e925c65d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:29:26 +0200 Subject: [PATCH 14/25] refactor: update ListTracked method to remove unnecessary dependencies Modified the ListTracked method in the packager package to streamline the list of common lab dependencies by removing 'ffmpeg' and 'yt-dlp'. This change simplifies the dependency management process and focuses on essential tools. --- internal/packager/packager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/packager/packager.go b/internal/packager/packager.go index b64df67..02049a9 100644 --- a/internal/packager/packager.go +++ b/internal/packager/packager.go @@ -76,5 +76,5 @@ func (m *Manager) IsInstalled(ctx context.Context, name string) (bool, error) { // ListTracked returns names the manager would use for common lab deps (informational). func (m *Manager) ListTracked() []string { - return []string{"ripgrep", "jq", "ffmpeg", "yt-dlp", "git", "podman"} + return []string{"ripgrep", "jq", "git", "podman"} } From a56281de6511dd71615ace2b2bcd14236576a1df Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:29:45 +0200 Subject: [PATCH 15/25] feat: add HEIC conversion functionality with unit tests Introduced a new media package for converting HEIC files to JPEG format, including the implementation of the ConvertHEIC function. Added unit tests to validate the conversion process, error handling for non-directory inputs, and dry run functionality to skip existing JPEG files. This enhances the CLI's capabilities for image format conversion. --- internal/media/heic.go | 101 ++++++++++++++++++++++++++++++++++++ internal/media/heic_test.go | 41 +++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 internal/media/heic.go create mode 100644 internal/media/heic_test.go diff --git a/internal/media/heic.go b/internal/media/heic.go new file mode 100644 index 0000000..f1600b2 --- /dev/null +++ b/internal/media/heic.go @@ -0,0 +1,101 @@ +package media + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// HEICOptions configures HEIC → JPEG conversion. +type HEICOptions struct { + Dir string // default: current directory + Quality int // 1–100, passed to heif-convert -q + Force bool // reconvert even when .jpg exists + DryRun bool +} + +// ConvertHEIC converts .HEIC/.heic files in Dir to .jpg via heif-convert. +func ConvertHEIC(ctx context.Context, stdout, stderr io.Writer, opt HEICOptions) (int, error) { + dir := strings.TrimSpace(opt.Dir) + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return 0, err + } + } + abs, err := filepath.Abs(dir) + if err != nil { + return 0, err + } + info, err := os.Stat(abs) + if err != nil { + return 0, fmt.Errorf("directory %s: %w", abs, err) + } + if !info.IsDir() { + return 0, fmt.Errorf("%s is not a directory", abs) + } + + if opt.Quality <= 0 || opt.Quality > 100 { + opt.Quality = 100 + } + + entries, err := os.ReadDir(abs) + if err != nil { + return 0, err + } + + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".heic" { + continue + } + files = append(files, filepath.Join(abs, name)) + } + + if len(files) == 0 { + return 0, fmt.Errorf("no .HEIC/.heic files in %s", abs) + } + + heif, err := executil.LookPath("heif-convert") + if err != nil && !opt.DryRun { + return 0, fmt.Errorf("heif-convert not found (macOS: brew install libheif; Ubuntu: apt install libheif-examples): %w", err) + } + if heif == "" { + heif = "heif-convert" + } + + run := executil.NewRunner(stdout, stderr) + run.DryRun = opt.DryRun + n := 0 + for _, src := range files { + out := src + ".jpg" + if !opt.Force { + if _, err := os.Stat(out); err == nil { + _, _ = fmt.Fprintf(stderr, "skip (exists): %s\n", filepath.Base(out)) + continue + } + } + if opt.DryRun { + _, _ = fmt.Fprintf(stderr, "[dry-run] %s -q %d %s %s\n", heif, opt.Quality, filepath.Base(src), filepath.Base(out)) + n++ + continue + } + if err := run.Run(ctx, heif, "-q", fmt.Sprintf("%d", opt.Quality), src, out); err != nil { + return n, fmt.Errorf("%s: %w", filepath.Base(src), err) + } + _, _ = fmt.Fprintf(stdout, "%s → %s\n", filepath.Base(src), filepath.Base(out)) + n++ + } + return n, nil +} diff --git a/internal/media/heic_test.go b/internal/media/heic_test.go new file mode 100644 index 0000000..e5d56d3 --- /dev/null +++ b/internal/media/heic_test.go @@ -0,0 +1,41 @@ +package media_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/bartrosa/homelab-cli/internal/media" + "github.com/stretchr/testify/require" +) + +func TestConvertHEIC_noFiles(t *testing.T) { + dir := t.TempDir() + _, err := media.ConvertHEIC(context.Background(), nil, nil, media.HEICOptions{Dir: dir}) + require.Error(t, err) + require.Contains(t, err.Error(), "no .HEIC") +} + +func TestConvertHEIC_notADirectory(t *testing.T) { + f := filepath.Join(t.TempDir(), "x.txt") + require.NoError(t, os.WriteFile(f, []byte("x"), 0o644)) + _, err := media.ConvertHEIC(context.Background(), nil, nil, media.HEICOptions{Dir: f}) + require.Error(t, err) + require.Contains(t, err.Error(), "not a directory") +} + +func TestConvertHEIC_dryRun_skipsExistingJPG(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "photo.HEIC"), []byte{0}, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "photo.HEIC.jpg"), []byte{0}, 0o644)) + + var stderr bytes.Buffer + n, err := media.ConvertHEIC(context.Background(), nil, &stderr, media.HEICOptions{ + Dir: dir, DryRun: true, + }) + require.NoError(t, err) + require.Equal(t, 0, n) + require.Contains(t, stderr.String(), "skip") +} From 3be07c7d38209e002612677593ecae7c48adeb71 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:30:02 +0200 Subject: [PATCH 16/25] feat: add homelabroot package for resolving homelab repository paths Introduced a new homelabroot package that provides functionality to resolve the path to a personal homelab repository. The implementation includes a Resolve function that checks various locations and environment variables to find the repository. Additionally, unit tests were added to validate the path resolution and error handling for missing repositories, enhancing the CLI's capabilities for managing homelab environments. --- internal/homelabroot/root.go | 66 +++++++++++++++++++++++++++++++ internal/homelabroot/root_test.go | 29 ++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 internal/homelabroot/root.go create mode 100644 internal/homelabroot/root_test.go diff --git a/internal/homelabroot/root.go b/internal/homelabroot/root.go new file mode 100644 index 0000000..36d415b --- /dev/null +++ b/internal/homelabroot/root.go @@ -0,0 +1,66 @@ +// Package homelabroot resolves the personal homelab repository path for script delegation. +package homelabroot + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const envVar = "LAB_HOMELAB_ROOT" + +// Resolve returns an absolute path to the homelab repo (config override, env, or common locations). +func Resolve(configRoot string) (string, error) { + var candidates []string + if strings.TrimSpace(configRoot) != "" { + candidates = append(candidates, configRoot) + } else { + if v := os.Getenv(envVar); v != "" { + candidates = append(candidates, v) + } + home, err := os.UserHomeDir() + if err == nil { + candidates = append(candidates, + filepath.Join(home, "Projects", "PERSONAL", "homelab"), + filepath.Join(home, "homelab"), + ) + } + cwd, _ := os.Getwd() + if cwd != "" { + candidates = append(candidates, cwd, filepath.Join(cwd, "..")) + } + } + + seen := map[string]struct{}{} + for _, c := range candidates { + if c == "" { + continue + } + abs, err := filepath.Abs(c) + if err != nil { + continue + } + if _, ok := seen[abs]; ok { + continue + } + seen[abs] = struct{}{} + if isHomelabRepo(abs) { + return abs, nil + } + } + return "", fmt.Errorf("homelab repo not found (set %s or homelab.root in config)", envVar) +} + +func isHomelabRepo(dir string) bool { + for _, marker := range []string{ + filepath.Join(dir, "ml-stack", "podman-compose.yml"), + filepath.Join(dir, "project-initiators", "golang"), + filepath.Join(dir, "postgres", "config", "instances.yaml"), + } { + if _, err := os.Stat(marker); err == nil { + return true + } + } + return false +} diff --git a/internal/homelabroot/root_test.go b/internal/homelabroot/root_test.go new file mode 100644 index 0000000..97bda2c --- /dev/null +++ b/internal/homelabroot/root_test.go @@ -0,0 +1,29 @@ +package homelabroot_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bartrosa/homelab-cli/internal/homelabroot" + "github.com/stretchr/testify/require" +) + +func TestResolve_findsMarkerInTempDir(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "tools", "media"), 0o750)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "ml-stack"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ml-stack", "podman-compose.yml"), []byte("version: '3'\n"), 0o644)) + + got, err := homelabroot.Resolve(dir) + require.NoError(t, err) + require.Equal(t, dir, got) +} + +func TestResolve_missing(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("LAB_HOMELAB_ROOT", "") + _, err := homelabroot.Resolve(filepath.Join(home, "not-a-homelab-repo")) + require.Error(t, err) +} From d91cff0843026b6114b1ebb5e266507c727ee427 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:30:18 +0200 Subject: [PATCH 17/25] feat: add installation scripts for ClickHouse, Milvus, and Qdrant Introduced new functions for installing ClickHouse, Milvus, and Qdrant on Linux bare metal environments. Each function checks for necessary dependencies, handles installation via package managers, and configures service settings. This enhances the CLI's capabilities for managing data services in a homelab setup. --- internal/baremetal/clickhouse.go | 51 +++++++++++ internal/baremetal/milvus.go | 51 +++++++++++ internal/baremetal/qdrant.go | 149 +++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 internal/baremetal/clickhouse.go create mode 100644 internal/baremetal/milvus.go create mode 100644 internal/baremetal/qdrant.go diff --git a/internal/baremetal/clickhouse.go b/internal/baremetal/clickhouse.go new file mode 100644 index 0000000..667b1d1 --- /dev/null +++ b/internal/baremetal/clickhouse.go @@ -0,0 +1,51 @@ +package baremetal + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// InstallClickHouse installs ClickHouse from official apt repo (Debian/Ubuntu). +func InstallClickHouse(ctx context.Context, dryRun bool, stdout, stderr io.Writer) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("clickhouse bare-metal install supports linux only (got %s)", runtime.GOOS) + } + for _, bin := range []string{"curl", "sudo", "apt-get"} { + if !executil.CommandExists(bin) { + return fmt.Errorf("%s not found in PATH", bin) + } + } + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + hot := os.Getenv("CLICKHOUSE_USE_HOT_STORAGE") + dataPath := envOr("CLICKHOUSE_DATA_PATH", "/mnt/data-hot/clickhouse") + script := fmt.Sprintf(`set -euo pipefail +echo "=== ClickHouse bare metal ===" +if [ ! -f /usr/share/keyrings/clickhouse-keyring.gpg ]; then + sudo apt-get update -qq + sudo apt-get install -y apt-transport-https ca-certificates curl gnupg + curl -fsSL 'https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key' | sudo gpg --dearmor -o /usr/share/keyrings/clickhouse-keyring.gpg + ARCH=$(dpkg --print-architecture) + echo "deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg arch=${ARCH}] https://packages.clickhouse.com/deb stable main" | sudo tee /etc/apt/sources.list.d/clickhouse.list + sudo apt-get update -qq +fi +if ! dpkg -l clickhouse-server &>/dev/null; then + sudo apt-get install -y clickhouse-server clickhouse-client +fi +if [ -n %q ] && [ -d /mnt/data-hot ]; then + sudo mkdir -p %q + sudo chown clickhouse:clickhouse %q || true + if [ -f /etc/clickhouse-server/config.xml ]; then + sudo sed -i 's|.*|%s/|' /etc/clickhouse-server/config.xml || true + fi +fi +sudo systemctl enable --now clickhouse-server || true +echo "ClickHouse: http://:8123" +`, hot, dataPath, dataPath, dataPath) + return ex.Run(ctx, "bash", "-c", script) +} diff --git a/internal/baremetal/milvus.go b/internal/baremetal/milvus.go new file mode 100644 index 0000000..f61db63 --- /dev/null +++ b/internal/baremetal/milvus.go @@ -0,0 +1,51 @@ +package baremetal + +import ( + "context" + "fmt" + "io" + "os" + "runtime" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// InstallMilvus installs Milvus standalone from GitHub DEB (Debian/Ubuntu). +func InstallMilvus(ctx context.Context, dryRun bool, stdout, stderr io.Writer) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("milvus bare-metal install supports linux only (got %s)", runtime.GOOS) + } + for _, bin := range []string{"curl", "sudo", "dpkg"} { + if !executil.CommandExists(bin) { + return fmt.Errorf("%s not found in PATH", bin) + } + } + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + version := envOr("MILVUS_VERSION", "2.6.9") + hot := os.Getenv("MILVUS_USE_HOT_STORAGE") + script := fmt.Sprintf(`set -euo pipefail +ARCH=$(dpkg --print-architecture) +case "$ARCH" in amd64) PKG_ARCH=amd64;; arm64) PKG_ARCH=arm64;; *) echo "unsupported arch"; exit 1;; esac +DEB_NAME="milvus_%s-1_${PKG_ARCH}.deb" +URL="https://github.com/milvus-io/milvus/releases/download/v%s/${DEB_NAME}" +echo "=== Milvus bare metal v%s ===" +if ! dpkg -l milvus &>/dev/null; then + TMP_DEB=$(mktemp -t milvus_XXXXXX.deb) + curl -fsSL -o "$TMP_DEB" "$URL" + sudo apt-get install -y "$TMP_DEB" + rm -f "$TMP_DEB" +else + echo "Milvus już zainstalowany." +fi +if [ -n %q ] && [ -d /mnt/data-hot ]; then + sudo mkdir -p /mnt/data-hot/milvus + if [ -f /etc/milvus/config.yaml ]; then + sudo sed -i 's|dataPath:.*|dataPath: /mnt/data-hot/milvus|' /etc/milvus/config.yaml || true + fi +fi +sudo systemctl enable --now milvus || true +echo "Milvus: http://:9091" +`, version, version, version, hot) + return ex.Run(ctx, "bash", "-c", script) +} diff --git a/internal/baremetal/qdrant.go b/internal/baremetal/qdrant.go new file mode 100644 index 0000000..77ea45b --- /dev/null +++ b/internal/baremetal/qdrant.go @@ -0,0 +1,149 @@ +package baremetal + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/bartrosa/homelab-cli/internal/executil" +) + +// InstallQdrant installs Qdrant from GitHub release (Linux bare metal). +func InstallQdrant(ctx context.Context, dryRun bool, stdout, stderr io.Writer) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("qdrant bare-metal install supports linux only (got %s)", runtime.GOOS) + } + for _, bin := range []string{"curl", "tar", "sudo"} { + if !executil.CommandExists(bin) { + return fmt.Errorf("%s not found in PATH", bin) + } + } + ex := executil.NewRunner(stdout, stderr) + ex.DryRun = dryRun + + installDir := envOr("QDRANT_INSTALL_DIR", "/opt/qdrant") + dataDir := "/var/lib/qdrant" + if os.Getenv("QDRANT_USE_HOT_STORAGE") != "" { + if _, err := os.Stat("/mnt/data-hot"); err == nil { + dataDir = "/mnt/data-hot/qdrant" + } + } + + arch, asset, err := qdrantAsset() + if err != nil { + return err + } + fmt.Fprintf(stdout, "=== Qdrant bare metal (%s) ===\n", arch) + + tag := os.Getenv("QDRANT_TAG") + if tag == "" { + tag, err = latestQdrantTag(ctx, ex) + if err != nil { + return err + } + } + binPath := filepath.Join(installDir, "qdrant") + if _, err := os.Stat(binPath); os.IsNotExist(err) { + url := fmt.Sprintf("https://github.com/qdrant/qdrant/releases/download/%s/%s", tag, asset) + fmt.Fprintf(stdout, "Pobieranie %s...\n", tag) + if err := ex.Run(ctx, "bash", "-c", + fmt.Sprintf(`set -euo pipefail +tmpdir=$(mktemp -d) +curl -fsSL -o "$tmpdir/qdrant.tgz" %q +tar -xzf "$tmpdir/qdrant.tgz" -C "$tmpdir" +sudo mkdir -p %q +sudo install -m 0755 "$(find "$tmpdir" -name qdrant -type f | head -1)" %q +rm -rf "$tmpdir"`, url, installDir, binPath)); err != nil { + return fmt.Errorf("install qdrant binary: %w", err) + } + } else { + fmt.Fprintln(stdout, "Binarka Qdrant już jest w", installDir) + } + + configYAML := fmt.Sprintf(`log_level: INFO +storage: + storage_path: %s/storage + snapshots_path: %s/snapshots +service: + host: 0.0.0.0 + http_port: 6333 + grpc_port: 6334 +`, dataDir, dataDir) + + script := fmt.Sprintf(`set -euo pipefail +if ! getent group qdrant >/dev/null; then sudo groupadd --system qdrant; fi +if ! getent passwd qdrant >/dev/null; then sudo useradd --system --home-dir %q --no-create-home --gid qdrant qdrant; fi +sudo mkdir -p %q/storage %q/snapshots +sudo chown -R qdrant:qdrant %q +cat <<'QEOF' | sudo tee %q/config.yaml >/dev/null +%s +QEOF +`, installDir, dataDir, dataDir, dataDir, installDir, configYAML) + + if err := ex.Run(ctx, "bash", "-c", script); err != nil { + return fmt.Errorf("qdrant config: %w", err) + } + + unit := `[Unit] +Description=Qdrant vector search +After=network.target + +[Service] +Type=simple +User=qdrant +Group=qdrant +ExecStart=` + binPath + ` --config-path ` + filepath.Join(installDir, "config.yaml") + ` +Restart=on-failure + +[Install] +WantedBy=multi-user.target +` + if err := ex.Run(ctx, "bash", "-c", fmt.Sprintf( + `printf %%s %q | sudo tee /etc/systemd/system/qdrant.service >/dev/null +sudo systemctl daemon-reload +sudo systemctl enable --now qdrant.service`, unit)); err != nil { + return fmt.Errorf("qdrant systemd: %w", err) + } + fmt.Fprintln(stdout, "Qdrant: http://:6333/dashboard") + return nil +} + +func qdrantAsset() (arch, asset string, err error) { + switch runtime.GOARCH { + case "amd64": + return "x86_64", "qdrant-x86_64-unknown-linux-musl.tar.gz", nil + case "arm64": + return "aarch64", "qdrant-aarch64-unknown-linux-musl.tar.gz", nil + default: + return "", "", fmt.Errorf("unsupported arch %s", runtime.GOARCH) + } +} + +func latestQdrantTag(ctx context.Context, ex *executil.Runner) (string, error) { + out, err := ex.Output(ctx, "curl", "-sSf", "https://api.github.com/repos/qdrant/qdrant/releases/latest") + if err != nil { + return "", err + } + var rel struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(out, &rel); err != nil { + return "", err + } + if rel.TagName == "" { + return "", fmt.Errorf("empty tag from GitHub API") + } + return rel.TagName, nil +} + +func envOr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} From 3fe24f45b9c8b515c5b0dcafc84c60f2de3415ae Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:30:41 +0200 Subject: [PATCH 18/25] feat: implement bootstrap package with embedded profiles and runner functionality Introduced a new bootstrap package that includes functionality for loading and executing setup profiles. The package supports embedded YAML profiles for various environments, such as laptop and server configurations. Added a Runner struct to manage the execution of profile steps, including package installation and toolchain setup. Unit tests were also created to validate the loading and execution processes, enhancing the CLI's capabilities for automated environment setup. --- internal/bootstrap/profile.go | 112 ++++++++++++++++++ internal/bootstrap/profile_test.go | 32 +++++ internal/bootstrap/profiles/laptop-linux.yaml | 15 +++ internal/bootstrap/profiles/laptop-macos.yaml | 14 +++ .../bootstrap/profiles/server-ubuntu.yaml | 11 ++ .../bootstrap/profiles/silverblue-laptop.yaml | 13 ++ internal/bootstrap/runner.go | 82 +++++++++++++ 7 files changed, 279 insertions(+) create mode 100644 internal/bootstrap/profile.go create mode 100644 internal/bootstrap/profile_test.go create mode 100644 internal/bootstrap/profiles/laptop-linux.yaml create mode 100644 internal/bootstrap/profiles/laptop-macos.yaml create mode 100644 internal/bootstrap/profiles/server-ubuntu.yaml create mode 100644 internal/bootstrap/profiles/silverblue-laptop.yaml create mode 100644 internal/bootstrap/runner.go diff --git a/internal/bootstrap/profile.go b/internal/bootstrap/profile.go new file mode 100644 index 0000000..c226059 --- /dev/null +++ b/internal/bootstrap/profile.go @@ -0,0 +1,112 @@ +// Package bootstrap runs machine setup profiles (packages, toolchains, scripts). +package bootstrap + +import ( + "embed" + "fmt" + "io" + "io/fs" + + "gopkg.in/yaml.v3" +) + +//go:embed profiles/*.yaml +var embeddedProfiles embed.FS + +// StepType identifies a bootstrap step kind. +type StepType string + +const ( + StepPkg StepType = "pkg" + StepToolchain StepType = "toolchain" + StepScript StepType = "script" +) + +// Step is one idempotent action in a profile. +type Step struct { + Type StepType `yaml:"type"` + Packages []string `yaml:"packages,omitempty"` + Languages []string `yaml:"languages,omitempty"` + Script string `yaml:"script,omitempty"` // path relative to homelab root +} + +// Profile describes a bootstrap target. +type Profile struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Steps []Step `yaml:"steps"` +} + +// LoadEmbedded returns a built-in profile by name (e.g. laptop-macos). +func LoadEmbedded(name string) (*Profile, error) { + path := fmt.Sprintf("profiles/%s.yaml", name) + data, err := embeddedProfiles.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unknown built-in profile %q: %w", name, err) + } + var p Profile + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("parse profile %s: %w", name, err) + } + if p.Name == "" { + p.Name = name + } + return &p, nil +} + +// ListEmbedded returns names of built-in profiles. +func ListEmbedded() ([]string, error) { + entries, err := fs.ReadDir(embeddedProfiles, "profiles") + if err != nil { + return nil, err + } + var names []string + for _, e := range entries { + if e.IsDir() { + continue + } + n := e.Name() + if len(n) > 5 && n[len(n)-5:] == ".yaml" { + names = append(names, n[:len(n)-5]) + } + } + return names, nil +} + +// LoadFromYAML parses profile bytes (user config override). +func LoadFromYAML(data []byte) (*Profile, error) { + var p Profile + if err := yaml.Unmarshal(data, &p); err != nil { + return nil, err + } + if p.Name == "" { + return nil, fmt.Errorf("profile missing name") + } + if len(p.Steps) == 0 { + return nil, fmt.Errorf("profile %q has no steps", p.Name) + } + return &p, nil +} + +// DecodeProfileMap unmarshals a config profiles map entry. +func DecodeProfileMap(raw any) (*Profile, error) { + data, err := yaml.Marshal(raw) + if err != nil { + return nil, err + } + return LoadFromYAML(data) +} + +// WriteProfileList prints profile summaries to w. +func WriteProfileList(w io.Writer, profiles []ProfileSummary) { + for _, p := range profiles { + _, _ = fmt.Fprintf(w, " %s — %s\n", p.Name, p.Description) + } +} + +// ProfileSummary is a short profile listing entry. +type ProfileSummary struct { + Name string + Description string + Source string // "builtin" or "config" +} diff --git a/internal/bootstrap/profile_test.go b/internal/bootstrap/profile_test.go new file mode 100644 index 0000000..0140acc --- /dev/null +++ b/internal/bootstrap/profile_test.go @@ -0,0 +1,32 @@ +package bootstrap_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/stretchr/testify/require" +) + +func TestLoadEmbedded_laptopMacos(t *testing.T) { + p, err := bootstrap.LoadEmbedded("laptop-macos") + require.NoError(t, err) + require.Equal(t, "laptop-macos", p.Name) + require.NotEmpty(t, p.Steps) +} + +func TestListEmbedded_includesProfiles(t *testing.T) { + names, err := bootstrap.ListEmbedded() + require.NoError(t, err) + require.Contains(t, names, "server-ubuntu") + require.Contains(t, names, "laptop-macos") +} + +func TestLoadEmbedded_unknown(t *testing.T) { + _, err := bootstrap.LoadEmbedded("no-such-profile-xyz") + require.Error(t, err) +} + +func TestLoadFromYAML_emptySteps(t *testing.T) { + _, err := bootstrap.LoadFromYAML([]byte("name: x\nsteps: []\n")) + require.Error(t, err) +} diff --git a/internal/bootstrap/profiles/laptop-linux.yaml b/internal/bootstrap/profiles/laptop-linux.yaml new file mode 100644 index 0000000..1f69d59 --- /dev/null +++ b/internal/bootstrap/profiles/laptop-linux.yaml @@ -0,0 +1,15 @@ +name: laptop-linux +description: Developer laptop on Ubuntu/Debian (apt + mise) +steps: + - type: pkg + packages: + - ripgrep + - jq + - git + - ffmpeg + - type: toolchain + languages: + - go + - rust + - python + - node diff --git a/internal/bootstrap/profiles/laptop-macos.yaml b/internal/bootstrap/profiles/laptop-macos.yaml new file mode 100644 index 0000000..e76abb5 --- /dev/null +++ b/internal/bootstrap/profiles/laptop-macos.yaml @@ -0,0 +1,14 @@ +name: laptop-macos +description: Developer laptop on macOS (Homebrew + mise) +steps: + - type: pkg + packages: + - ripgrep + - jq + - git + - type: toolchain + languages: + - go + - rust + - python + - node diff --git a/internal/bootstrap/profiles/server-ubuntu.yaml b/internal/bootstrap/profiles/server-ubuntu.yaml new file mode 100644 index 0000000..406e9d4 --- /dev/null +++ b/internal/bootstrap/profiles/server-ubuntu.yaml @@ -0,0 +1,11 @@ +name: server-ubuntu +description: Ubuntu/Debian homelab server baseline (apt + podman stack prep) +steps: + - type: pkg + packages: + - git + - curl + - jq + - podman + - type: script + script: scripts/install-server-deps.sh diff --git a/internal/bootstrap/profiles/silverblue-laptop.yaml b/internal/bootstrap/profiles/silverblue-laptop.yaml new file mode 100644 index 0000000..bccf00f --- /dev/null +++ b/internal/bootstrap/profiles/silverblue-laptop.yaml @@ -0,0 +1,13 @@ +name: silverblue-laptop +description: Fedora Silverblue laptop (rpm-ostree packages + mise toolchains) +steps: + - type: pkg + packages: + - git + - jq + - ffmpeg-free + - type: toolchain + languages: + - go + - rust + - python diff --git a/internal/bootstrap/runner.go b/internal/bootstrap/runner.go new file mode 100644 index 0000000..1c31fa4 --- /dev/null +++ b/internal/bootstrap/runner.go @@ -0,0 +1,82 @@ +// Package bootstrap executes setup profiles. +package bootstrap + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/homelabroot" + "github.com/bartrosa/homelab-cli/internal/packager" + "github.com/bartrosa/homelab-cli/internal/toolchain" + "github.com/bartrosa/homelab-cli/internal/ui" +) + +// Runner executes bootstrap profiles. +type Runner struct { + Stdout io.Writer + Stderr io.Writer + HomelabRoot string + DryRun bool + Styles ui.Styles +} + +// RunProfile applies all steps in order. +func (r *Runner) RunProfile(ctx context.Context, profile *Profile) error { + if profile == nil { + return fmt.Errorf("nil profile") + } + ui.Section(r.Stdout, r.Styles, "bootstrap "+profile.Name, profile.Description) + + pkgMgr := packager.New(r.Stdout, r.Stderr, r.DryRun) + tc := toolchain.New(r.Stdout, r.Stderr, r.DryRun) + + total := len(profile.Steps) + for i, step := range profile.Steps { + ui.Step(r.Stdout, r.Styles, i+1, total, string(step.Type)) + switch step.Type { + case StepPkg: + for _, pkg := range step.Packages { + if err := pkgMgr.Ensure(ctx, pkg); err != nil { + return fmt.Errorf("pkg %s: %w", pkg, err) + } + ui.OK(r.Stdout, r.Styles, pkg) + } + case StepToolchain: + if err := tc.Install(ctx, step.Languages...); err != nil { + return err + } + for _, lang := range step.Languages { + ui.OK(r.Stdout, r.Styles, "toolchain "+lang) + } + case StepScript: + if err := r.runScript(ctx, step.Script); err != nil { + return err + } + ui.OK(r.Stdout, r.Styles, step.Script) + default: + return fmt.Errorf("unknown step type %q", step.Type) + } + } + ui.OK(r.Stdout, r.Styles, "profile complete") + return nil +} + +func (r *Runner) runScript(ctx context.Context, rel string) error { + root, err := homelabroot.Resolve(r.HomelabRoot) + if err != nil { + return err + } + path := filepath.Join(root, rel) + ex := executil.NewRunner(r.Stdout, r.Stderr) + ex.DryRun = r.DryRun + ex.WorkDir = root + return ex.Run(ctx, "bash", path) +} + +// LaptopProfile picks the best built-in profile for the current OS. +func LaptopProfile() (string, error) { + return "laptop-macos", nil // caller may override via platform +} From 33bb3bac1c6bbb12c3ae0b19327e6c9e793a3721 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:31:08 +0200 Subject: [PATCH 19/25] feat: enhance CLI testing with new bootstrap and media commands Updated CLI tests to include a dry run for the bootstrap laptop command, ensuring no errors occur during execution. Added tests for the bootstrap list and media help commands, verifying their output. Refactored import paths to use the correct module path, improving code organization and clarity. Introduced a new appctx package to manage per-invocation state, enhancing the context handling in the CLI. --- internal/cli/appctx/appctx.go | 41 +++++++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 36 +++++++++++++++++++++++++----- internal/cli/errors.go | 2 +- internal/cli/root.go | 36 +++++++++++++++++++++++------- 4 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 internal/cli/appctx/appctx.go diff --git a/internal/cli/appctx/appctx.go b/internal/cli/appctx/appctx.go new file mode 100644 index 0000000..165421f --- /dev/null +++ b/internal/cli/appctx/appctx.go @@ -0,0 +1,41 @@ +// Package appctx stores per-invocation state on context.Context. +package appctx + +import ( + "context" + + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/ui" +) + +type ctxKey struct{} + +// Session holds config and UI flags for a lab invocation. +type Session struct { + Config *config.Config + ConfigPath string + DryRun bool + NoColor bool + Styles ui.Styles + HomelabRoot string // flag override +} + +// WithSession attaches a session to ctx. +func WithSession(ctx context.Context, s *Session) context.Context { + return context.WithValue(ctx, ctxKey{}, s) +} + +// FromContext returns the session or nil. +func FromContext(ctx context.Context) *Session { + s, _ := ctx.Value(ctxKey{}).(*Session) + return s +} + +// MustSession returns the session or panics (CLI wiring should always set it). +func MustSession(ctx context.Context) *Session { + s := FromContext(ctx) + if s == nil { + panic("appctx: missing session on context") + } + return s +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 308e0da..15260ab 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,11 +1,10 @@ package cli_test import ( - "__MODULE_PATH__/internal/cli" + "github.com/bartrosa/homelab-cli/internal/cli" "bytes" "context" "encoding/json" - "errors" "io" "os" "path/filepath" @@ -84,15 +83,40 @@ func TestVersion_debugJSONLoggingToStderr(t *testing.T) { require.Contains(t, stderr.String(), "DEBUG") } -func TestBootstrapLaptop_notImplemented(t *testing.T) { +func TestBootstrapLaptop_dryRun(t *testing.T) { isolatedHome(t) root := cli.NewRootCmd() root.SetOut(io.Discard) root.SetErr(io.Discard) - root.SetArgs([]string{"bootstrap", "laptop"}) + root.SetArgs([]string{"--dry-run", "--homelab-root", t.TempDir(), "bootstrap", "laptop"}) err := root.ExecuteContext(context.Background()) - require.Error(t, err) - require.True(t, errors.Is(err, cli.ErrNotImplemented)) + require.NoError(t, err) +} + +func TestBootstrapList(t *testing.T) { + isolatedHome(t) + + root := cli.NewRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"bootstrap", "list"}) + + require.NoError(t, root.ExecuteContext(context.Background())) + require.Contains(t, buf.String(), "laptop-macos") +} + +func TestMediaHelp(t *testing.T) { + isolatedHome(t) + + root := cli.NewRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"media", "--help"}) + + require.NoError(t, root.ExecuteContext(context.Background())) + require.Contains(t, buf.String(), "heic") } diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 795aaeb..08417cc 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -1,6 +1,6 @@ package cli -import "__MODULE_PATH__/internal/clierrors" +import "github.com/bartrosa/homelab-cli/internal/clierrors" // ErrNotImplemented is re-exported for convenience at the CLI boundary. var ErrNotImplemented = clierrors.ErrNotImplemented diff --git a/internal/cli/root.go b/internal/cli/root.go index 300e5be..bcbda2c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,10 +1,12 @@ package cli import ( - "__MODULE_PATH__/internal/cli/commands" - "__MODULE_PATH__/internal/config" - "__MODULE_PATH__/internal/logging" "context" + "github.com/bartrosa/homelab-cli/internal/cli/appctx" + "github.com/bartrosa/homelab-cli/internal/cli/commands" + "github.com/bartrosa/homelab-cli/internal/config" + "github.com/bartrosa/homelab-cli/internal/logging" + "github.com/bartrosa/homelab-cli/internal/ui" "errors" "fmt" "os" @@ -21,10 +23,12 @@ func Execute(ctx context.Context) error { // NewRootCmd wires the full command tree for the lab CLI. func NewRootCmd() *cobra.Command { var ( - configPath string - logLevel string - logFormat string - noColor bool + configPath string + logLevel string + logFormat string + noColor bool + dryRun bool + homelabRoot string ) root := &cobra.Command{ @@ -59,7 +63,16 @@ running local data services, mirroring repositories, operating clusters, and sup } logger := logging.New(cmd.ErrOrStderr(), level, format, noColor) - cmd.SetContext(logging.WithLogger(cmd.Context(), logger)) + ctx := logging.WithLogger(cmd.Context(), logger) + session := &appctx.Session{ + Config: cfg, + ConfigPath: path, + DryRun: dryRun, + NoColor: noColor, + Styles: ui.NewStyles(cmd.OutOrStdout(), noColor), + HomelabRoot: homelabRoot, + } + cmd.SetContext(appctx.WithSession(ctx, session)) return nil }, RunE: func(cmd *cobra.Command, _ []string) error { @@ -71,6 +84,8 @@ running local data services, mirroring repositories, operating clusters, and sup root.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level (debug|info|warn|error)") root.PersistentFlags().StringVar(&logFormat, "log-format", "text", "log format (text|json)") root.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colorized output") + root.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "print planned actions without executing external commands") + root.PersistentFlags().StringVar(&homelabRoot, "homelab-root", "", "path to homelab repo (overrides config homelab.root and LAB_HOMELAB_ROOT)") root.AddGroup(&cobra.Group{ID: "foundation", Title: "Foundation — bootstrap & install"}) root.AddGroup(&cobra.Group{ID: "repos", Title: "Repos — multi-repo management"}) @@ -93,7 +108,11 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewClusterCmd(), "infra") add(commands.NewGPUCmd(), "infra") + add(commands.NewServerCmd(), "infra") add(commands.NewSSHCmd(), "infra") + add(commands.NewPostgresCmd(), "infra") + add(commands.NewBaremetalCmd(), "infra") + add(commands.NewSystemCmd(), "infra") add(commands.NewContainersCmd(), "infra") add(commands.NewNetCmd(), "infra") add(commands.NewStorageCmd(), "infra") @@ -109,6 +128,7 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewObsCmd(), "workflow") add(commands.NewLogsCmd(), "workflow") add(commands.NewTemplatesCmd(), "workflow") + add(commands.NewMediaCmd(), "workflow") add(commands.NewMCPCmd(), "workflow") add(commands.NewVersionCmd(), "meta") From 920b5ffc63f657706f3abc24b5d32b98babfc16a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:31:38 +0200 Subject: [PATCH 20/25] feat: introduce new CLI commands for bare metal, media, postgres, and server management Added new commands for managing bare metal installations, including support for installing Qdrant, Milvus, and ClickHouse. Implemented media conversion utilities for HEIC to JPEG. Introduced PostgreSQL provisioning from YAML configurations, allowing for idempotent database and user management. Added server command for remote operations, including running commands and deploying homelab configurations. Enhanced CLI functionality with dry run support across all new commands, improving usability and testing capabilities. --- internal/cli/commands/baremetal.go | 48 +++++++++ internal/cli/commands/bootstrap.go | 154 ++++++++++++++++++++++++----- internal/cli/commands/helpers.go | 33 +++++++ internal/cli/commands/media.go | 59 +++++++++++ internal/cli/commands/pkg.go | 51 ++++++++-- internal/cli/commands/postgres.go | 44 +++++++++ internal/cli/commands/repos.go | 26 +++-- internal/cli/commands/server.go | 85 ++++++++++++++++ internal/cli/commands/services.go | 88 +++++++++++++++-- internal/cli/commands/ssh.go | 26 ++++- internal/cli/commands/stub.go | 2 +- internal/cli/commands/system.go | 71 +++++++++++++ internal/cli/commands/templates.go | 46 +++++++-- internal/cli/commands/toolchain.go | 37 ++++++- internal/cli/commands/version.go | 4 +- 15 files changed, 709 insertions(+), 65 deletions(-) create mode 100644 internal/cli/commands/baremetal.go create mode 100644 internal/cli/commands/helpers.go create mode 100644 internal/cli/commands/media.go create mode 100644 internal/cli/commands/postgres.go create mode 100644 internal/cli/commands/server.go create mode 100644 internal/cli/commands/system.go diff --git a/internal/cli/commands/baremetal.go b/internal/cli/commands/baremetal.go new file mode 100644 index 0000000..2ae2035 --- /dev/null +++ b/internal/cli/commands/baremetal.go @@ -0,0 +1,48 @@ +package commands + +import ( + "fmt" + + "github.com/bartrosa/homelab-cli/internal/baremetal" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewBaremetalCmd installs vector/OLAP databases on Linux hosts. +func NewBaremetalCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "baremetal", + Short: "Install databases on bare metal (no containers)", + Long: "Run on the target Linux server. Uses curl/apt/sudo — see docs/external-binaries.md.", + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + install := &cobra.Command{ + Use: "install ", + Short: "Install qdrant, milvus, or clickhouse", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "baremetal install", args[0]) + ctx := cmd.Context() + switch args[0] { + case "qdrant": + return baremetal.InstallQdrant(ctx, s.DryRun, stdout(cmd), stderr(cmd)) + case "milvus": + return baremetal.InstallMilvus(ctx, s.DryRun, stdout(cmd), stderr(cmd)) + case "clickhouse": + return baremetal.InstallClickHouse(ctx, s.DryRun, stdout(cmd), stderr(cmd)) + default: + return fmt.Errorf("unknown target %q (use: qdrant, milvus, clickhouse)", args[0]) + } + }, + } + install.ValidArgs = []string{"qdrant", "milvus", "clickhouse"} + + cmd.AddCommand(install) + return cmd +} diff --git a/internal/cli/commands/bootstrap.go b/internal/cli/commands/bootstrap.go index 935b918..8be39b1 100644 --- a/internal/cli/commands/bootstrap.go +++ b/internal/cli/commands/bootstrap.go @@ -1,6 +1,11 @@ package commands -import "github.com/spf13/cobra" +import ( + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/bartrosa/homelab-cli/internal/platform" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewBootstrapCmd wires bootstrap subcommands (laptop, server, profile). func NewBootstrapCmd() *cobra.Command { @@ -8,36 +13,139 @@ func NewBootstrapCmd() *cobra.Command { Use: "bootstrap", Short: "Bootstrap machines from zero (laptop or server profiles)", Long: `Bootstrap prepares a fresh machine with baseline packages, security posture, -and optional dotfiles. Profiles are defined in the configuration file.`, +and optional dotfiles. Profiles are defined in the configuration file or built-in YAML.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( - &cobra.Command{ - Use: "laptop", - Short: "Initialize a new developer laptop", - Long: "Installs base packages, shells, fonts, dotfiles, git, and container tooling.", - Example: " lab bootstrap laptop", - RunE: StubRunE(), - }, - &cobra.Command{ - Use: "server", - Short: "Initialize a new homelab server", - Long: "SSH hardening, container runtime, monitoring agents, and fail2ban-style baselines.", - Example: " lab bootstrap server", - RunE: StubRunE(), + newBootstrapLaptopCmd(), + newBootstrapServerCmd(), + newBootstrapProfileCmd(), + newBootstrapListCmd(), + ) + + return cmd +} + +func newBootstrapLaptopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "laptop", + Short: "Initialize a new developer laptop", + Long: "Installs base packages and toolchains using a built-in profile for your OS.", + Example: " lab bootstrap laptop\n lab bootstrap laptop --dry-run", + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + name := laptopProfileName() + profile, err := bootstrap.LoadEmbedded(name) + if err != nil { + return err + } + runner := &bootstrap.Runner{ + Stdout: stdout(cmd), Stderr: stderr(cmd), + HomelabRoot: s.HomelabRoot, DryRun: s.DryRun, Styles: s.Styles, + } + return runner.RunProfile(cmd.Context(), profile) }, - &cobra.Command{ - Use: "profile ", - Short: "Bootstrap using a named profile from config", - Long: "Runs the bootstrap graph defined under bootstrap.profiles. in the config file.", - Example: " lab bootstrap profile dgx-spark", - Args: cobra.ExactArgs(1), - RunE: StubRunE(), + } + return cmd +} + +func newBootstrapServerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Initialize a new homelab server", + Long: "Ubuntu/Debian server baseline: packages and install-server-deps from homelab repo.", + Example: " lab bootstrap server", + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + profile, err := bootstrap.LoadEmbedded("server-ubuntu") + if err != nil { + return err + } + runner := &bootstrap.Runner{ + Stdout: stdout(cmd), Stderr: stderr(cmd), + HomelabRoot: s.HomelabRoot, DryRun: s.DryRun, Styles: s.Styles, + } + return runner.RunProfile(cmd.Context(), profile) }, - ) + } + return cmd +} + +func newBootstrapProfileCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile ", + Short: "Bootstrap using a named profile", + Long: "Runs a built-in profile or bootstrap.profiles. from config.", + Example: " lab bootstrap profile laptop-macos\n lab bootstrap profile dgx-spark", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + name := args[0] + var profile *bootstrap.Profile + var err error + if raw, ok := s.Config.Bootstrap.Profiles[name]; ok { + profile, err = bootstrap.DecodeProfileMap(raw) + } else { + profile, err = bootstrap.LoadEmbedded(name) + } + if err != nil { + return err + } + runner := &bootstrap.Runner{ + Stdout: stdout(cmd), Stderr: stderr(cmd), + HomelabRoot: s.HomelabRoot, DryRun: s.DryRun, Styles: s.Styles, + } + return runner.RunProfile(cmd.Context(), profile) + }, + } return cmd } + +func newBootstrapListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List built-in bootstrap profiles", + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + names, err := bootstrap.ListEmbedded() + if err != nil { + return err + } + ui.Section(stdout(cmd), s.Styles, "Built-in profiles", "") + var summaries []bootstrap.ProfileSummary + for _, n := range names { + p, err := bootstrap.LoadEmbedded(n) + if err != nil { + continue + } + summaries = append(summaries, bootstrap.ProfileSummary{ + Name: p.Name, Description: p.Description, Source: "builtin", + }) + } + bootstrap.WriteProfileList(stdout(cmd), summaries) + return nil + }, + } +} + +func laptopProfileName() string { + info := platform.Detect() + switch { + case info.IsSilverblue: + return "silverblue-laptop" + case info.GOOS == platform.OSDarwin: + return "laptop-macos" + case info.GOOS == platform.OSLinux: + return "laptop-linux" + default: + return "laptop-macos" + } +} diff --git a/internal/cli/commands/helpers.go b/internal/cli/commands/helpers.go new file mode 100644 index 0000000..dce33a1 --- /dev/null +++ b/internal/cli/commands/helpers.go @@ -0,0 +1,33 @@ +package commands + +import ( + "io" + + "github.com/bartrosa/homelab-cli/internal/cli/appctx" + "github.com/spf13/cobra" +) + +func session(cmd *cobra.Command) *appctx.Session { + return appctx.MustSession(cmd.Context()) +} + +func setDryRun(cmd *cobra.Command) { + s := session(cmd) + if cmd.Flags().Changed("dry-run") { + v, _ := cmd.Flags().GetBool("dry-run") + s.DryRun = v + } +} + +func stdout(cmd *cobra.Command) io.Writer { + return cmd.OutOrStdout() +} + +func stderr(cmd *cobra.Command) io.Writer { + return cmd.ErrOrStderr() +} + +// AddDryRunFlag registers --dry-run on a command. +func AddDryRunFlag(cmd *cobra.Command) { + cmd.Flags().Bool("dry-run", false, "print planned actions without executing") +} diff --git a/internal/cli/commands/media.go b/internal/cli/commands/media.go new file mode 100644 index 0000000..5cef29d --- /dev/null +++ b/internal/cli/commands/media.go @@ -0,0 +1,59 @@ +package commands + +import ( + "fmt" + + "github.com/bartrosa/homelab-cli/internal/media" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewMediaCmd wires media utilities (HEIC conversion). +func NewMediaCmd() *cobra.Command { + var ( + heicQuality int + heicForce bool + ) + + cmd := &cobra.Command{ + Use: "media", + Short: "Media conversion helpers", + Long: `media: HEIC→JPEG via heif-convert.`, + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + heic := &cobra.Command{ + Use: "heic [directory]", + Short: "Convert .HEIC photos to JPEG in a directory", + Long: `Converts each .HEIC/.heic file to .jpg using heif-convert (quality 1–100). Skips when .jpg already exists unless --force.`, + Example: ` lab media heic . + lab media heic ~/Pictures/import --quality 95`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + dir := "." + if len(args) == 1 { + dir = args[0] + } + ui.Section(stdout(cmd), s.Styles, "media heic", dir) + n, err := media.ConvertHEIC(cmd.Context(), stdout(cmd), stderr(cmd), media.HEICOptions{ + Dir: dir, Quality: heicQuality, Force: heicForce, DryRun: s.DryRun, + }) + if err != nil { + return err + } + ui.OK(stdout(cmd), s.Styles, fmt.Sprintf("converted %d file(s)", n)) + return nil + }, + } + heic.Flags().IntVar(&heicQuality, "quality", 100, "JPEG quality (1–100)") + heic.Flags().BoolVar(&heicForce, "force", false, "reconvert even when output .jpg exists") + + cmd.AddCommand(heic) + + return cmd +} diff --git a/internal/cli/commands/pkg.go b/internal/cli/commands/pkg.go index 08e4653..c93f0d9 100644 --- a/internal/cli/commands/pkg.go +++ b/internal/cli/commands/pkg.go @@ -1,8 +1,14 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" -// NewPkgCmd wires package manager abstractions (brew, apt, dnf, pacman). + "github.com/bartrosa/homelab-cli/internal/packager" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewPkgCmd wires package manager abstractions (brew, apt, dnf). func NewPkgCmd() *cobra.Command { cmd := &cobra.Command{ Use: "pkg", @@ -13,6 +19,7 @@ Detection picks the right backend for the host OS.`, return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ @@ -20,23 +27,55 @@ Detection picks the right backend for the host OS.`, Short: "Install a package using the native package manager", Example: " lab pkg install ripgrep", Args: cobra.ExactArgs(1), - RunE: StubRunE(), + RunE: pkgInstallRunE(false), }, &cobra.Command{ Use: "ensure ", Short: "Idempotently ensure a package is present", Example: " lab pkg ensure jq", Args: cobra.ExactArgs(1), - RunE: StubRunE(), + RunE: pkgInstallRunE(true), }, &cobra.Command{ Use: "list", - Short: "List packages tracked by lab", + Short: "List common lab packages and detected backend", Example: " lab pkg list", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + mgr := packager.New(stdout(cmd), stderr(cmd), s.DryRun) + ui.Section(stdout(cmd), s.Styles, "Package manager", mgr.Info.PackagerLabel()) + var rows [][]string + for _, p := range mgr.ListTracked() { + rows = append(rows, []string{p, "lab pkg ensure " + p}) + } + ui.Table(stdout(cmd), s.Styles, []string{"PACKAGE", "COMMAND"}, rows) + return nil + }, }, ) return cmd } + +func pkgInstallRunE(ensure bool) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + mgr := packager.New(stdout(cmd), stderr(cmd), s.DryRun) + name := args[0] + ui.Section(stdout(cmd), s.Styles, "pkg", mgr.Info.PackagerLabel()) + var err error + if ensure { + err = mgr.Ensure(cmd.Context(), name) + } else { + err = mgr.Install(cmd.Context(), name) + } + if err != nil { + return err + } + ui.OK(stdout(cmd), s.Styles, fmt.Sprintf("%s ok", name)) + return nil + } +} diff --git a/internal/cli/commands/postgres.go b/internal/cli/commands/postgres.go new file mode 100644 index 0000000..005225b --- /dev/null +++ b/internal/cli/commands/postgres.go @@ -0,0 +1,44 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/postgres" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewPostgresCmd wires PostgreSQL provisioning from YAML. +func NewPostgresCmd() *cobra.Command { + var configPath string + + cmd := &cobra.Command{ + Use: "postgres", + Short: "PostgreSQL provisioning (YAML desired state)", + Long: "Idempotent apply of databases/users from instances.yaml. Requires POSTGRES_ADMIN_PASSWORD or PGPASSWORD.", + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + apply := &cobra.Command{ + Use: "apply", + Short: "Apply instances.yaml to PostgreSQL", + Example: ` lab postgres apply --config ~/homelab/postgres/config/instances.yaml`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "postgres apply", configPath) + cfg, err := postgres.LoadConfig(configPath) + if err != nil { + return err + } + return postgres.Apply(cmd.Context(), cfg, s.DryRun) + }, + } + apply.Flags().StringVar(&configPath, "config", "", "path to instances.yaml (required)") + _ = apply.MarkFlagRequired("config") + + cmd.AddCommand(apply) + return cmd +} diff --git a/internal/cli/commands/repos.go b/internal/cli/commands/repos.go index 16d0314..495aa28 100644 --- a/internal/cli/commands/repos.go +++ b/internal/cli/commands/repos.go @@ -1,18 +1,25 @@ package commands -import "github.com/spf13/cobra" +import ( + "github.com/bartrosa/homelab-cli/internal/repos" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewReposCmd wires multi-repo workflows across Git providers. func NewReposCmd() *cobra.Command { + var jobs int + cmd := &cobra.Command{ Use: "repos", Short: "Clone, mirror, and synchronize repositories", - Long: `repos integrates with GitHub, GitLab, Gitea, and Codeberg using REST APIs plus -local git operations for bulk workflows across organizations and patterns.`, + Long: `repos integrates with Git hosting providers for bulk workflows. +GitLab backup uses tools/gitlab/backup_account.py from your homelab repo.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ @@ -25,10 +32,15 @@ local git operations for bulk workflows across organizations and patterns.`, }, &cobra.Command{ Use: "backup", - Short: "Mirror configured repositories to a local path or object storage", - Example: " lab repos backup", + Short: "Mirror GitLab projects to local backup dir", + Example: " lab repos backup\n lab repos backup --jobs 4", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "repos backup", "GitLab mirror") + return repos.GitLabBackup(cmd.Context(), s.Config, s.HomelabRoot, s.DryRun, stdout(cmd), stderr(cmd), jobs) + }, }, &cobra.Command{ Use: "sync", @@ -53,5 +65,7 @@ local git operations for bulk workflows across organizations and patterns.`, }, ) + cmd.PersistentFlags().IntVar(&jobs, "jobs", 2, "parallel git jobs for GitLab backup") + return cmd } diff --git a/internal/cli/commands/server.go b/internal/cli/commands/server.go new file mode 100644 index 0000000..9b3ca60 --- /dev/null +++ b/internal/cli/commands/server.go @@ -0,0 +1,85 @@ +package commands + +import ( + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/homelabroot" + "github.com/bartrosa/homelab-cli/internal/server" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewServerCmd wires remote homelab server operations. +func NewServerCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "server", + Short: "Remote homelab server (rsync, ssh run, deploy)", + Long: "Uses server.* from config (host, user, port, path). No homelab shell scripts.", + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + cmd.AddCommand( + &cobra.Command{ + Use: "run ", + Short: "Run a shell command on the server in server.path", + Example: ` lab server run 'cd ml-stack && podman-compose ps'`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + if s.DryRun { + ui.Section(stdout(cmd), s.Styles, "server run (dry-run)", strings.Join(args, " ")) + return nil + } + t, err := server.TargetFromConfig(s.Config) + if err != nil { + return err + } + remote := strings.Join(args, " ") + ui.Section(stdout(cmd), s.Styles, "server run", remote) + return server.Run(cmd.Context(), t, remote, os.Stdin, stdout(cmd), stderr(cmd)) + }, + }, + &cobra.Command{ + Use: "deploy [sync|provision|compose|full]", + Short: "Rsync homelab to server; optionally provision PG or start ml-stack", + Long: `Modes: + (none) sync only + provision sync + lab postgres apply (local, against PG in instances.yaml) + compose sync + podman-compose up -d on server + full provision + compose`, + Example: ` lab server deploy + lab server deploy full`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + root, err := homelabroot.Resolve(firstCLI(s.HomelabRoot, s.Config.Homelab.Root)) + if err != nil { + return err + } + mode := server.DeploySync + if len(args) == 1 { + mode = server.DeployMode(args[0]) + } + ui.Section(stdout(cmd), s.Styles, "server deploy", string(mode)) + return server.Deploy(cmd.Context(), s.Config, root, mode, s.DryRun, stdout(cmd), stderr(cmd)) + }, + }, + ) + + return cmd +} + +func firstCLI(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/internal/cli/commands/services.go b/internal/cli/commands/services.go index 6264037..ec3eedd 100644 --- a/internal/cli/commands/services.go +++ b/internal/cli/commands/services.go @@ -1,49 +1,117 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/homelabroot" + "github.com/bartrosa/homelab-cli/internal/mlstack" + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewServicesCmd wires local compose stacks for databases and brokers. func NewServicesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "services", Short: "Run homelab data services via compose (docker/podman)", - Long: `services manages opinionated compose stacks for Postgres, Redis, MongoDB, Kafka, -RabbitMQ, MinIO, ClickHouse, etcd, NATS, and similar dependencies.`, + Long: `services manages compose stacks from your homelab repo (e.g. ml-stack). +Set homelab.root in config or LAB_HOMELAB_ROOT.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ Use: "up [more...]", Short: "Start one or more service stacks", - Example: " lab services up postgres redis", + Example: " lab services up ml-stack", Args: cobra.MinimumNArgs(1), - RunE: StubRunE(), + RunE: servicesRunE("up"), }, &cobra.Command{ Use: "down [more...]", Short: "Stop one or more service stacks", - Example: " lab services down postgres", + Example: " lab services down ml-stack", Args: cobra.MinimumNArgs(1), - RunE: StubRunE(), + RunE: servicesRunE("down"), }, &cobra.Command{ Use: "list", Short: "List available stacks and their runtime status", Example: " lab services list", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) + ui.Section(stdout(cmd), s.Styles, "Stacks", fmt.Sprintf("runtime: %s", s.Config.Services.Runtime)) + var rows [][]string + for _, st := range sr.List() { + rows = append(rows, []string{st.Name, st.ComposeFile}) + } + ui.Table(stdout(cmd), s.Styles, []string{"NAME", "COMPOSE"}, rows) + return nil + }, + }, + &cobra.Command{ + Use: "ensure [ml-stack]", + Short: "Ensure ml-stack is up (podman-compose up -d)", + Example: " lab services ensure", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + root, err := homelabroot.Resolve(firstCLI(s.HomelabRoot, s.Config.Homelab.Root)) + if err != nil { + return err + } + name := "ml-stack" + if len(args) == 1 { + name = args[0] + } + if name != "ml-stack" { + return fmt.Errorf("only ml-stack is supported (got %q)", name) + } + ui.Section(stdout(cmd), s.Styles, "services ensure", name) + ip := s.Config.Server.Host + mlDir := filepath.Join(root, "ml-stack") + return mlstack.EnsureUp(cmd.Context(), mlDir, ip, s.DryRun, stdout(cmd), stderr(cmd)) + }, }, &cobra.Command{ Use: "logs ", Short: "Tail logs for a running stack", - Example: " lab services logs postgres", + Example: " lab services logs ml-stack", Args: cobra.ExactArgs(1), - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) + return sr.Logs(cmd.Context(), args[0]) + }, }, ) return cmd } + +func servicesRunE(action string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) + ui.Section(stdout(cmd), s.Styles, "services "+action, strings.Join(args, ", ")) + switch action { + case "up": + return sr.Up(cmd.Context(), args...) + case "down": + return sr.Down(cmd.Context(), args...) + default: + return fmt.Errorf("unknown action %s", action) + } + } +} diff --git a/internal/cli/commands/ssh.go b/internal/cli/commands/ssh.go index 2f36685..66e6f0a 100644 --- a/internal/cli/commands/ssh.go +++ b/internal/cli/commands/ssh.go @@ -1,17 +1,22 @@ package commands -import "github.com/spf13/cobra" +import ( + "github.com/bartrosa/homelab-cli/internal/ssh" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewSSHCmd wires SSH convenience helpers for homelab hosts. func NewSSHCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ssh", Short: "SSH into lab machines with managed host definitions", - Long: "Wraps ssh/scp with curated inventory, jump hosts, and key management.", + Long: "Wraps ssh with curated inventory from config (ssh.hosts) and homelab sync helpers.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ @@ -19,7 +24,22 @@ func NewSSHCmd() *cobra.Command { Short: "Open an interactive SSH session to a known host alias", Example: " lab ssh connect gpu-01", Args: cobra.ExactArgs(1), - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, args []string) error { + s := session(cmd) + return ssh.Connect(cmd.Context(), s.Config, args[0]) + }, + }, + &cobra.Command{ + Use: "sync", + Short: "Rsync homelab repo to configured server", + Example: " lab ssh sync", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "ssh sync", "homelab → server") + return ssh.SyncRepo(cmd.Context(), s.Config, s.HomelabRoot, s.DryRun, stdout(cmd), stderr(cmd)) + }, }, ) diff --git a/internal/cli/commands/stub.go b/internal/cli/commands/stub.go index 20bafa3..3031cf5 100644 --- a/internal/cli/commands/stub.go +++ b/internal/cli/commands/stub.go @@ -1,7 +1,7 @@ package commands import ( - "__MODULE_PATH__/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/clierrors" "fmt" "github.com/spf13/cobra" diff --git a/internal/cli/commands/system.go b/internal/cli/commands/system.go new file mode 100644 index 0000000..f843c49 --- /dev/null +++ b/internal/cli/commands/system.go @@ -0,0 +1,71 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/system" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewSystemCmd wires host-level utilities. +func NewSystemCmd() *cobra.Command { + var ( + workDir string + device string + distro string + isoURL string + ) + + cmd := &cobra.Command{ + Use: "system", + Short: "Host utilities (bootable USB, …)", + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + usb := &cobra.Command{ + Use: "usb", + Short: "Create a bootable USB from a Linux ISO", + Long: `Pobiera wersje z upstreamu: + Ubuntu — changelogs.ubuntu.com (meta-release) + releases.ubuntu.com (SHA256SUMS) + Fedora Silverblue — download.fedoraproject.org (najnowszy release) + +Domyślnie: ubuntu-latest. Użyj "lab system usb list" aby zobaczyć ID.`, + Example: ` lab system usb list + lab system usb --distro ubuntu-lts-24.04 --device /dev/sdb + lab system usb --distro fedora-silverblue --workdir ~/Downloads --device /dev/sdb`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "system usb", distro) + return system.CreateBootableUSB(cmd.Context(), system.USBOptions{ + WorkDir: workDir, + Device: device, + Distro: distro, + ISOURL: isoURL, + DryRun: s.DryRun, + }, stdout(cmd), stderr(cmd)) + }, + } + usb.Flags().StringVar(&workDir, "workdir", "", "directory for ISO download") + usb.Flags().StringVar(&device, "device", "", "block device (e.g. /dev/sdb)") + usb.Flags().StringVar(&distro, "distro", "ubuntu-latest", "image ID from 'lab system usb list' or alias: ubuntu-latest, ubuntu-lts, fedora-silverblue") + usb.Flags().StringVar(&isoURL, "iso-url", "", "custom ISO URL (skips discovery)") + + list := &cobra.Command{ + Use: "list", + Short: "List bootable images discovered from Ubuntu and Fedora", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "system usb list", "querying upstream") + return system.PrintBootImageList(cmd.Context(), stdout(cmd)) + }, + } + + usb.AddCommand(list) + cmd.AddCommand(usb) + return cmd +} diff --git a/internal/cli/commands/templates.go b/internal/cli/commands/templates.go index 23efad2..66537df 100644 --- a/internal/cli/commands/templates.go +++ b/internal/cli/commands/templates.go @@ -1,13 +1,19 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" -// NewTemplatesCmd wires project scaffolding. + "github.com/bartrosa/homelab-cli/internal/templates" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewTemplatesCmd wires project scaffolding from homelab project-initiators. func NewTemplatesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "templates", - Short: "Generate projects from curated templates", - Long: "Scaffold ML services, APIs, CLIs, and Terraform modules using Go templates.", + Short: "Generate projects from homelab templates", + Long: `templates copies project-initiators from your homelab repo into a new directory.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, @@ -15,11 +21,33 @@ func NewTemplatesCmd() *cobra.Command { cmd.AddCommand( &cobra.Command{ - Use: "new ", - Short: "Create a new project from the selected template", - Example: " lab templates new fraud-detector", - Args: cobra.ExactArgs(1), - RunE: StubRunE(), + Use: "list", + Short: "List available template kinds", + Example: " lab templates list", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "Templates", "") + for _, k := range templates.ListKinds() { + _, _ = fmt.Fprintf(stdout(cmd), " %s\n", k) + } + return nil + }, + }, + &cobra.Command{ + Use: "new ", + Short: "Create a project from a template", + Example: " lab templates new go ./my-service", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "templates new", args[0]+" → "+args[1]) + if err := templates.NewProject(s.HomelabRoot, args[0], args[1], stdout(cmd)); err != nil { + return err + } + ui.OK(stdout(cmd), s.Styles, "scaffolded "+args[1]) + return nil + }, }, ) diff --git a/internal/cli/commands/toolchain.go b/internal/cli/commands/toolchain.go index a774d5e..bf710ff 100644 --- a/internal/cli/commands/toolchain.go +++ b/internal/cli/commands/toolchain.go @@ -1,18 +1,25 @@ package commands -import "github.com/spf13/cobra" +import ( + "strings" + + "github.com/bartrosa/homelab-cli/internal/toolchain" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewToolchainCmd wires language toolchain operations (mise-backed). func NewToolchainCmd() *cobra.Command { cmd := &cobra.Command{ Use: "toolchain", Short: "Install and switch language toolchains", - Long: `Toolchain commands wrap mise (or compatible shims) to install and activate + Long: `Toolchain commands wrap mise to install and activate Go, Node, Bun, Deno, Python, Rust, Erlang, Elixir, Zig, Java, Ruby and more.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ @@ -20,21 +27,41 @@ Go, Node, Bun, Deno, Python, Rust, Erlang, Elixir, Zig, Java, Ruby and more.`, Short: "Install one or more language toolchains", Example: " lab toolchain install go bun rust", Args: cobra.MinimumNArgs(1), - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) + ui.Section(stdout(cmd), s.Styles, "toolchain", "mise") + if err := tc.Install(cmd.Context(), args...); err != nil { + return err + } + ui.OK(stdout(cmd), s.Styles, strings.Join(args, ", ")) + return nil + }, }, &cobra.Command{ Use: "list", Short: "List installed toolchains and active versions", Example: " lab toolchain list", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) + return tc.List(cmd.Context()) + }, }, &cobra.Command{ Use: "use ", Short: "Switch the active toolchain version for a language", Example: " lab toolchain use go 1.25.0", Args: cobra.ExactArgs(2), - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) + return tc.Use(cmd.Context(), args[0], args[1]) + }, }, ) diff --git a/internal/cli/commands/version.go b/internal/cli/commands/version.go index d0893e1..520ac1b 100644 --- a/internal/cli/commands/version.go +++ b/internal/cli/commands/version.go @@ -1,8 +1,8 @@ package commands import ( - "__MODULE_PATH__/internal/buildinfo" - "__MODULE_PATH__/internal/logging" + "github.com/bartrosa/homelab-cli/internal/buildinfo" + "github.com/bartrosa/homelab-cli/internal/logging" "encoding/json" "fmt" "strings" From 5cad35f9712cbb04af6094c50b5e8dd527a03431 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:31:47 +0200 Subject: [PATCH 21/25] feat: add SSH and server configuration support to the config package Introduced new SSH and server configuration structures to the config package, enabling management of SSH host inventory and server connection details. Added methods for retrieving sorted SSH host names and default values for homelab and server configurations. This enhances the configuration capabilities for managing remote operations in a homelab environment. --- internal/config/config.go | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 3cf7e78..dfbb4f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "sort" "strings" "github.com/spf13/viper" @@ -15,13 +16,65 @@ type Config struct { LogLevel string `mapstructure:"log_level"` LogFormat string `mapstructure:"log_format"` + Homelab HomelabConfig `mapstructure:"homelab"` Bootstrap BootstrapConfig `mapstructure:"bootstrap"` Repos ReposConfig `mapstructure:"repos"` Services ServicesConfig `mapstructure:"services"` + SSH SSHConfig `mapstructure:"ssh"` + Server ServerConfig `mapstructure:"server"` Cluster ClusterConfig `mapstructure:"cluster"` Storage StorageConfig `mapstructure:"storage"` } +// HomelabConfig points at the personal homelab repo for scripts and compose stacks. +type HomelabConfig struct { + Root string `mapstructure:"root"` +} + +// SSHConfig holds SSH host inventory. +type SSHConfig struct { + Hosts map[string]SSHHost `mapstructure:"hosts"` +} + +// SSHHost describes one SSH target. +type SSHHost struct { + Host string `mapstructure:"host"` + User string `mapstructure:"user"` + Port int `mapstructure:"port"` + IdentityFile string `mapstructure:"identity_file"` +} + +// Target returns user@host for ssh. +func (h SSHHost) Target() string { + user := h.User + if user == "" { + user = "root" + } + return user + "@" + h.Host +} + +// SSHHostNames returns sorted host alias keys. +func (c *Config) SSHHostNames() []string { + if c == nil || len(c.SSH.Hosts) == 0 { + return nil + } + names := make([]string, 0, len(c.SSH.Hosts)) + for k := range c.SSH.Hosts { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// ServerConfig is the default remote homelab server (rsync / ssh run). +type ServerConfig struct { + Host string `mapstructure:"host"` + User string `mapstructure:"user"` + Port int `mapstructure:"port"` + Path string `mapstructure:"path"` + Password string `mapstructure:"password"` // optional; prefer SSH keys +} + // BootstrapConfig describes bootstrap profiles and defaults. type BootstrapConfig struct { DefaultProfile string `mapstructure:"default_profile"` @@ -66,6 +119,9 @@ func Default() *Config { return &Config{ LogLevel: "info", LogFormat: "text", + Homelab: HomelabConfig{ + Root: "", + }, Bootstrap: BootstrapConfig{ DefaultProfile: "default", Profiles: map[string]any{}, @@ -83,6 +139,12 @@ func Default() *Config { Kubeconfig: defaultKubeconfigPath(), Context: "", }, + SSH: SSHConfig{ + Hosts: map[string]SSHHost{}, + }, + Server: ServerConfig{ + Port: 22, + }, Storage: StorageConfig{ Endpoint: "", AccessKey: "", @@ -141,6 +203,8 @@ func bindDefaults(v *viper.Viper) { d := Default() v.SetDefault("log_level", d.LogLevel) v.SetDefault("log_format", d.LogFormat) + v.SetDefault("homelab.root", d.Homelab.Root) + v.SetDefault("server.port", d.Server.Port) v.SetDefault("bootstrap.default_profile", d.Bootstrap.DefaultProfile) v.SetDefault("repos.root", d.Repos.Root) v.SetDefault("repos.backup_dir", d.Repos.BackupDir) From f9caf036f177966ccb3304295d44185ac9160f84 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:31:56 +0200 Subject: [PATCH 22/25] feat: add executil package for running external commands with logging and dry-run support Introduced a new executil package that provides a Runner struct for executing shell commands with consistent logging and optional dry-run functionality. The package includes methods for running commands, checking command existence, and capturing output, enhancing the CLI's capabilities for managing external processes in a homelab environment. --- internal/executil/run.go | 100 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 internal/executil/run.go diff --git a/internal/executil/run.go b/internal/executil/run.go new file mode 100644 index 0000000..3ff0499 --- /dev/null +++ b/internal/executil/run.go @@ -0,0 +1,100 @@ +// Package executil runs external commands with consistent logging and dry-run support. +package executil + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Runner executes shell commands. +type Runner struct { + Stdout io.Writer + Stderr io.Writer + DryRun bool + Env []string + WorkDir string + Inherit bool // append os.Environ when true +} + +// NewRunner returns a runner writing to stdout/stderr. +func NewRunner(stdout, stderr io.Writer) *Runner { + return &Runner{Stdout: stdout, Stderr: stderr, Inherit: true} +} + +// Run executes name with args. Returns combined error from start/wait. +func (r *Runner) Run(ctx context.Context, name string, args ...string) error { + if r.DryRun { + _, _ = fmt.Fprintf(r.Stderr, "[dry-run] %s %s\n", name, strings.Join(args, " ")) + return nil + } + cmd := exec.CommandContext(ctx, name, args...) + if r.Inherit { + cmd.Env = append(os.Environ(), r.Env...) + } else if len(r.Env) > 0 { + cmd.Env = r.Env + } + if r.WorkDir != "" { + cmd.Dir = r.WorkDir + } + cmd.Stdout = r.Stdout + cmd.Stderr = r.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +// LookPath wraps exec.LookPath. +func LookPath(name string) (string, error) { + return exec.LookPath(name) +} + +// CommandExists reports whether binary is on PATH. +func CommandExists(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// Output runs a command and returns stdout bytes. +func (r *Runner) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + if r.DryRun { + return nil, nil + } + cmd := exec.CommandContext(ctx, name, args...) + if r.Inherit { + cmd.Env = append(os.Environ(), r.Env...) + } + if r.WorkDir != "" { + cmd.Dir = r.WorkDir + } + cmd.Stderr = r.Stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return out, nil +} + +// RunQuiet runs a command and discards stdout/stderr (for presence checks). +func (r *Runner) RunQuiet(ctx context.Context, name string, args ...string) error { + if r.DryRun { + return nil + } + cmd := exec.CommandContext(ctx, name, args...) + if r.Inherit { + cmd.Env = append(os.Environ(), r.Env...) + } + if r.WorkDir != "" { + cmd.Dir = r.WorkDir + } + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} From f395d7a8709c540e49270089932191c66decb262 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:32:05 +0200 Subject: [PATCH 23/25] fix: update import path for CLI package in main.go Replaced the placeholder module path with the actual GitHub import path for the internal CLI package, ensuring correct package resolution and improving code clarity. --- cmd/lab/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 7eb2c65..237b1b0 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -2,7 +2,7 @@ package main import ( - "__MODULE_PATH__/internal/cli" + "github.com/bartrosa/homelab-cli/internal/cli" "context" "os" "os/signal" From 60d6b4a5b2d6fea67803b1ef1275a7691cbf7ec9 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:32:21 +0200 Subject: [PATCH 24/25] chore: update module path and dependencies in go.mod and Makefile Replaced the placeholder module path with the actual GitHub import path in .goreleaser.yaml and Makefile. Updated Go version to 1.25.0 in go.mod, added new dependencies for pgx and yaml, and adjusted existing dependencies to their latest versions. This enhances the project's structure and ensures compatibility with the latest libraries. --- .goreleaser.yaml | 6 +++--- Makefile | 2 +- go.mod | 12 ++++++++---- go.sum | 23 +++++++++++++++++++---- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 23b7ea2..aa15605 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,9 +20,9 @@ builds: - arm64 ldflags: - -s -w - - -X __MODULE_PATH__/internal/buildinfo.Version={{.Version}} - - -X __MODULE_PATH__/internal/buildinfo.Commit={{.Commit}} - - -X __MODULE_PATH__/internal/buildinfo.Date={{.Date}} + - -X github.com/bartrosa/homelab-cli/internal/buildinfo.Version={{.Version}} + - -X github.com/bartrosa/homelab-cli/internal/buildinfo.Commit={{.Commit}} + - -X github.com/bartrosa/homelab-cli/internal/buildinfo.Date={{.Date}} archives: - id: lab diff --git a/Makefile b/Makefile index c926dd8..e188eab 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /bin/bash .DEFAULT_GOAL := help -MODULE_PATH := __MODULE_PATH__ +MODULE_PATH := github.com/bartrosa/homelab-cli BIN_DIR := bin BINARY := $(BIN_DIR)/lab diff --git a/go.mod b/go.mod index 828c85a..9bdd231 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ -module __MODULE_PATH__ +module github.com/bartrosa/homelab-cli -go 1.23.0 +go 1.25.0 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/jackc/pgx/v5 v5.9.2 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -19,6 +21,8 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -34,7 +38,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 92d518f..457d6f4 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -23,6 +24,14 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -60,6 +69,9 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -70,13 +82,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From c97360342c717baaf04a586498a491699d320891 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 20 May 2026 17:32:41 +0200 Subject: [PATCH 25/25] feat: expand CLI functionality with new commands and configuration updates Added new commands for managing server operations, PostgreSQL provisioning, and bare metal installations, including support for Qdrant, Milvus, and ClickHouse. Enhanced media handling with HEIC conversion utilities. Updated configuration management to include a starter YAML file and improved documentation across various guides. Removed deprecated commands and streamlined existing functionalities for better usability and clarity. --- CHANGELOG.md | 28 +++++- CONTRIBUTING.md | 4 +- README.md | 172 +++++++++++++++++++--------------- docs/README.md | 20 ++++ docs/architecture.md | 107 +++++++++++++++------ docs/commands.md | 189 ++++++++++++++++++++++++++++++-------- docs/config.example.yaml | 56 +++++++++++ docs/configuration.md | 137 +++++++++++++++++++++++---- docs/external-binaries.md | 30 ++++++ docs/homelab-migration.md | 74 +++++++++++++++ 10 files changed, 654 insertions(+), 163 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/config.example.yaml create mode 100644 docs/external-binaries.md create mode 100644 docs/homelab-migration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa6cbf..6aec159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,4 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial repository scaffolding: `lab` CLI (Cobra/Viper), grouped command stubs, `version` command, configuration loader, slog-based logging, tests, Makefile, golangci-lint v2 config, GoReleaser, and GitHub Actions workflows. +- **Server:** `lab server run`, `lab server deploy` (sync, provision, compose, full) — SSH + rsync; PostgreSQL apply via pgx locally. +- **PostgreSQL:** `lab postgres apply --config` — idempotent users/databases from YAML. +- **Bare metal:** `lab baremetal install` for qdrant, milvus, clickhouse (Linux). +- **System:** `lab system usb list` and `lab system usb` — discover Ubuntu/Fedora ISOs from upstream mirrors; wget, checksum, dd. +- **Services:** `lab services ensure` for ml-stack. +- **Media:** `lab media heic` only (YouTube playlist support removed from CLI). +- **SSH sync:** native rsync (no homelab `sync-to-server.sh`). +- **Docs:** full English documentation refresh; [`docs/README.md`](docs/README.md) index. +- **Dependencies:** `pgx/v5`; Go 1.25 module baseline. + +### Removed + +- `lab media playlist` and all YouTube/yt-dlp code (`internal/media/playlist*`, `ytdlp.go`). +- `media.*` config keys (`cookies_browser`, `cookies_file`, `downloads_dir`). + +### Changed + +- `lab ssh sync` and deploy use `internal/server` instead of homelab shell scripts. +- `docs/external-binaries.md`, `docs/homelab-migration.md` updated for current migration state. + +### Foundation (earlier unreleased work) + +- Cobra/Viper CLI, grouped command tree, `version`, config loader, slog logging, lipgloss UI. +- `bootstrap` profiles: laptop-macos, laptop-linux, silverblue-laptop, server-ubuntu. +- `pkg`, `toolchain` (mise), `services` (homelab compose). +- `repos backup`, `ssh connect`, `templates`, `media heic` (HEIC conversion). +- Global flags: `--dry-run`, `--homelab-root`, `--no-color`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3dbcb4d..a886e5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Requirements -- Go **1.23+** (newer toolchains welcome; see README for the `go` directive rationale). +- Go **1.25+** (see `go.mod`). - `make`, `git`. - Optional: `goreleaser` locally if you are cutting releases. @@ -34,7 +34,7 @@ Prefer prefixes: `feat/…`, `fix/…`, `chore/…`, `docs/…`. ## Pull requests -- Keep scope tight; scaffolding PRs should not sneak in real adapters. +- Keep scope tight; one feature or fix per PR when possible. - Update [`CHANGELOG.md`](CHANGELOG.md) under `[Unreleased]` when user-visible behavior changes. Thank you for helping grow `lab`! diff --git a/README.md b/README.md index cab006c..df997d4 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,148 @@ # homelab-cli -[![CI](https://github.com/OWNER/REPO/actions/workflows/ci.yml/badge.svg)](https://github.com/OWNER/REPO/actions/workflows/ci.yml) +[![CI](https://github.com/bartrosa/homelab-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/bartrosa/homelab-cli/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Go](https://img.shields.io/badge/go-1.23.0+-00ADD8.svg)](https://go.dev/dl/) +[![Go](https://img.shields.io/badge/go-1.25+-00ADD8.svg)](https://go.dev/dl/) -> Replace `OWNER/REPO` in badge URLs after you publish the repository. +**`lab`** is a single CLI for homelab automation: bootstrap laptops and servers, manage toolchains and compose stacks, sync your [homelab](https://github.com/bartrosa/homelab) repo to remote hosts, provision PostgreSQL, download media, and create bootable USB installers — with orchestration in Go and thin wrappers around standard system tools. -**Module path placeholder:** replace `__MODULE_PATH__` in `go.mod`, imports, and docs with your real module path (for example `github.com/you/homelab-cli`). +Module: `github.com/bartrosa/homelab-cli` -CLI for end-to-end homelab automation — from bare metal to GPU-served LLMs. +## Design principles -## Why? +- **Orchestration in Go** — workflows, config, retries, and terminal UI live in this repo. +- **No homelab shell/Python scripts from `lab`** — logic migrated here; the personal homelab repo remains the source of compose files, YAML, and project templates. +- **External binaries where required** — `ssh`, `podman-compose`, `wget`, `dd`, etc. See [`docs/external-binaries.md`](docs/external-binaries.md). -Homelab automation tends to sprawl across ad-hoc shell scripts, README fragments, and one-off Ansible snippets. `lab` is a single entry point that will grow into a declaratively configured toolkit: one binary, consistent UX, and clear seams for adapters (package managers, compose stacks, Git providers, cluster clients). +## What works today -## What can it do? - -The command tree is grouped into eight areas. **Everything is scaffolded today** except `lab version`, which prints build metadata. - -1. **Bootstrap** — laptop/server profiles, baseline packages, dotfiles, and hardening. -2. **Toolchains** — language runtimes via `mise` wrappers (Go, Node, Bun, Deno, Python, Rust, BEAM, Zig, Java, Ruby, …). -3. **Services** — local Postgres/Redis/Mongo/Kafka/RabbitMQ/MinIO/ClickHouse/etcd/NATS stacks via compose. -4. **Repos** — bulk clone/mirror/backup across GitHub, GitLab, Gitea, and Codeberg. -5. **Cluster & net** — k3s/k8s helpers, GPU diagnostics, SSH inventory, Tailscale/WireGuard. -6. **Data / AI / ML** — models, datasets, notebooks, MLOps, vector DBs, local pipelines, agents. -7. **Observability** — Prometheus/Grafana/Loki/Tempo bundles plus aggregated logs. -8. **Workflow** — project templates, optional MCP stdio server for IDE integrations. +| Area | Commands | Notes | +|------|----------|--------| +| **Bootstrap** | `bootstrap laptop\|server\|profile\|list` | Embedded YAML profiles (macOS, Linux, Silverblue, Ubuntu server) | +| **Packages** | `pkg install\|ensure\|list` | brew, apt, dnf, rpm-ostree | +| **Toolchains** | `toolchain install\|list\|use` | via [mise](https://mise.jdx.dev/) | +| **Services** | `services up\|down\|list\|logs\|ensure` | homelab `ml-stack` compose | +| **Server** | `server run`, `server deploy` | SSH + rsync; deploy can provision PG and start compose | +| **PostgreSQL** | `postgres apply` | Idempotent apply from `instances.yaml` (pgx) | +| **Bare metal** | `baremetal install` | Qdrant, Milvus, ClickHouse on Linux | +| **System** | `system usb list`, `system usb` | Bootable USB; ISOs discovered from Ubuntu/Fedora mirrors | +| **SSH** | `ssh connect`, `ssh sync` | Host inventory from config | +| **Repos** | `repos backup` | GitLab account mirror (homelab Python script today) | +| **Templates** | `templates list\|new` | Copy `project-initiators/` from homelab | +| **Media** | `media heic` | HEIC→JPEG via `heif-convert` | +| **Meta** | `version` | Build metadata | + +**Planned (stubs):** `cluster`, `gpu`, `models`, `mlops`, `vector`, `pipelines`, `agents`, `obs`, `logs`, `mcp`, most of `repos` beyond backup. + +Full command tables: [`docs/commands.md`](docs/commands.md). ## Installation ### Build from source ```bash -git clone https://github.com/OWNER/REPO.git +git clone https://github.com/bartrosa/homelab-cli.git cd homelab-cli -make install # or: make build && ./bin/lab +make install # or: make build && ./bin/lab ``` +Requires **Go 1.25+**. + ### `go install` ```bash -go install __MODULE_PATH__/cmd/lab@latest +go install github.com/bartrosa/homelab-cli/cmd/lab@latest ``` -### GitHub Releases +### Releases -After the first tagged release, prefer the checksum-verified archives published by GoReleaser. A convenience installer script lives at `scripts/install.sh` (TODO until release artifacts exist). +Tagged releases publish binaries via GoReleaser. You can also use `scripts/install.sh` when release artifacts are available. ## Quick start +1. Copy and edit config: + ```bash -lab bootstrap laptop # set up a fresh machine (stub) -lab toolchain install go bun rust # install language toolchains (stub) -lab services up postgres redis # spin up databases (stub) -lab repos clone "github.com/me/*" # clone all your repos (stub) -lab models pull llama3 # pull a local LLM (stub) -lab cluster status # check homelab k3s (stub) -lab version # ✅ prints build info +mkdir -p ~/.config/homelab-cli +cp docs/config.example.yaml ~/.config/homelab-cli/config.yaml +# set homelab.root to your homelab repo path ``` -## Commands +2. Preview bootstrap, install tools, run stacks: -See [`docs/commands.md`](docs/commands.md) for the full reference. Summary: +```bash +lab bootstrap laptop --dry-run +lab pkg ensure ripgrep jq git +lab toolchain install go rust python +lab services list +lab services up ml-stack +``` -| Group | Commands | Status | -|------|----------|--------| -| Foundation | `bootstrap`, `pkg`, `toolchain`, `services` | 🚧 planned | -| Repos | `repos` | 🚧 planned | -| Infra | `cluster`, `gpu`, `ssh`, `containers`, `net`, `storage` | 🚧 planned | -| Data / AI / ML | `models`, `data`, `notebooks`, `mlops`, `vector`, `pipelines`, `agents` | 🚧 planned | -| Workflow | `obs`, `logs`, `templates`, `mcp` | 🚧 planned | -| Meta | `version` | ✅ ready | +3. Remote server (set `server.*` in config): -## Configuration +```bash +lab ssh sync +lab server deploy provision # rsync + postgres apply (local, against PG in YAML) +lab server deploy compose # rsync + podman-compose on server +lab services ensure # ml-stack up + service URLs +``` -Precedence: **CLI flags → environment (`LAB_*`) → YAML file → defaults**. +4. USB installer: -Default config path: `~/.config/homelab-cli/config.yaml`. +```bash +lab system usb list +lab system usb --distro ubuntu-lts-24.04 --device /dev/sdb --workdir ~/Downloads +``` -Example: +## Global flags -```yaml -log_level: info -log_format: text +| Flag | Description | +|------|-------------| +| `--config` | Config file (default `~/.config/homelab-cli/config.yaml`) | +| `--homelab-root` | Override `homelab.root` / `LAB_HOMELAB_ROOT` | +| `--dry-run` | Print planned external commands without running them | +| `--no-color` | Disable lipgloss styling | +| `--log-level` | `debug\|info\|warn\|error` | +| `--log-format` | `text\|json` | -bootstrap: - default_profile: default - profiles: {} +Environment variables use the `LAB_` prefix (e.g. `LAB_SERVER_HOST`, `LAB_HOMELAB_ROOT`). -repos: - root: ~/src - backup_dir: ~/backups/repos - providers: - - name: github-personal - kind: github - host: github.com - token_env: GH_TOKEN +## Configuration -services: - stacks_dir: ~/.config/homelab-cli/stacks - runtime: podman +Precedence: **CLI flags → `LAB_*` env → YAML → defaults**. -cluster: - kubeconfig: ~/.kube/config - context: "" +| Key | Purpose | +|-----|---------| +| `homelab.root` | Path to personal homelab repo (compose, templates, postgres config) | +| `server.host`, `server.user`, `server.port`, `server.path` | Default remote host for rsync/SSH | +| `ssh.hosts` | Named SSH targets for `lab ssh connect` | +| `services.runtime` | `podman-compose` or `docker` | +| `repos.providers` | Git hosting tokens for future clone/backup | -storage: - endpoint: "" - access_key: "" -``` +Details: [`docs/configuration.md`](docs/configuration.md) · example: [`docs/config.example.yaml`](docs/config.example.yaml). -More detail: [`docs/configuration.md`](docs/configuration.md). +## Documentation + +| Document | Description | +|----------|-------------| +| [`docs/commands.md`](docs/commands.md) | Command reference with status | +| [`docs/configuration.md`](docs/configuration.md) | Config keys and precedence | +| [`docs/architecture.md`](docs/architecture.md) | Packages and data flow | +| [`docs/external-binaries.md`](docs/external-binaries.md) | Required host tools | +| [`docs/homelab-migration.md`](docs/homelab-migration.md) | homelab repo → `lab` migration map | +| [`CHANGELOG.md`](CHANGELOG.md) | Release notes | + +## Relationship to the homelab repo + +**homelab-cli** is the productized CLI. The **homelab** git repo is the “scratchpad”: compose stacks, postgres `instances.yaml`, `project-initiators/`, and docs. Point `homelab.root` at that checkout so `lab` can find compose files and templates. New automation should land in Go here; homelab scripts are retired as features migrate. ## Development ```bash -make ci +make ci # fmt, vet, lint, test, build ``` -See [`CONTRIBUTING.md`](CONTRIBUTING.md) for conventions, tooling versions, and PR expectations. - -## Go version note - -`go.mod` currently declares `go 1.23.0` so `golangci-lint` releases (built with older toolchains) can analyze the module without tripping over `go 1.25` language gates. You can still compile with Go 1.25+ locally. When golangci-lint ships binaries built with Go ≥1.25, bump the `go` directive to match your target. +See [`CONTRIBUTING.md`](CONTRIBUTING.md). ## License diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0fdb513 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# Documentation + +English reference for **homelab-cli** (`lab`). + +## Guides + +| Document | Contents | +|----------|----------| +| [commands.md](commands.md) | Full command tree, status, examples | +| [configuration.md](configuration.md) | Config file, env vars, precedence | +| [config.example.yaml](config.example.yaml) | Copy-paste starter config | +| [architecture.md](architecture.md) | Internal packages and design | +| [external-binaries.md](external-binaries.md) | Host tools `lab` invokes | +| [homelab-migration.md](homelab-migration.md) | Mapping from homelab scripts to `lab` | + +## Quick links + +- Install and quick start: [../README.md](../README.md) +- Changelog: [../CHANGELOG.md](../CHANGELOG.md) +- Contributing: [../CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/architecture.md b/docs/architecture.md index b2d74ba..d6b987a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,47 +1,96 @@ # Architecture -`lab` is a thin orchestration layer: Cobra commands call into small internal packages that will grow **adapters** for external tools. This PR wires the skeleton only; adapters arrive in focused follow-ups. +`lab` is an orchestration CLI: Cobra commands load config and session context, then call focused packages that plan work and invoke external tools where necessary. -## High-level flow +## Request flow ``` ┌─────────────┐ persistent flags ┌──────────────┐ -│ Cobra CLI │ ───────────────────► │ Viper config │ +│ Cobra CLI │ ───────────────────► │ Viper/config │ │ (cmd tree) │ └──────┬───────┘ └──────┬──────┘ │ - │ PersistentPreRunE │ - ▼ │ -┌──────────────┐ stderr/json/text ┌───▼────────────┐ -│ slog.Logger │ ◄────────────────────│ logging.New() │ -└──────┬───────┘ └────────────────┘ - │ + │ PersistentPreRunE │ + ▼ │ +┌──────────────┐ lipgloss (optional) ┌───▼────────────┐ +│ appctx.Session│ ◄───────────────────│ ui.NewStyles() │ +│ + slog.Logger │ └────────────────┘ +└──────┬───────┘ + │ RunE ▼ ┌───────────────────────────────────────────────┐ -│ Command RunE (stubs today, real work later) │ +│ Domain packages (bootstrap, media, server, …) │ +│ → executil.Runner (dry-run, logging) │ +│ → exec: ssh, wget, podman-compose, … │ └───────────────────────────────────────────────┘ ``` -## Planned adapters (not implemented in PR #1) +## Implemented packages -| Domain | Planned abstraction | Backing tools | -|--------|----------------------|---------------| -| Packages | `Packager` interface + OS autodetection | `brew`, `apt`, `dnf`, `pacman`, … | -| Toolchains | `ToolchainRunner` | `mise` (install/use/list) | -| Services | `StackRunner` | compose templates + `docker`/`podman` | -| Repos | `Provider` + local git | `go-git` + GitHub/GitLab/Gitea HTTP APIs | -| Cluster | `ClusterClient` | `k3s` install scripts + `client-go` | -| Models / ML | thin wrappers | `ollama`, `vLLM`, HF cache helpers | -| MCP | stdio server | subset of `lab` commands exposed as MCP tools | +| Package | Role | External tools | +|---------|------|----------------| +| `internal/bootstrap` | Embedded YAML profiles; steps: pkg, toolchain, script | homelab bash for `script:` steps only | +| `internal/packager` | OS package install | brew, apt, dnf, rpm-ostree | +| `internal/toolchain` | Language runtimes | mise | +| `internal/services` | Compose stack up/down/list/logs | podman-compose / docker compose | +| `internal/mlstack` | Ensure ml-stack is up | podman-compose | +| `internal/server` | SSH remote run, rsync deploy | ssh, rsync | +| `internal/postgres` | YAML → PG apply | TCP to PostgreSQL (pgx) | +| `internal/ssh` | Connect + sync | ssh, rsync | +| `internal/repos` | GitLab backup | homelab Python script (interim) | +| `internal/templates` | Project scaffolds | filesystem copy from homelab | +| `internal/media` | HEIC convert | heif-convert | +| `internal/system` | Bootable USB | HTTP to mirrors, wget, sha256sum, dd | +| `internal/baremetal` | DB installers on Linux | curl, apt, sudo, systemd | -## Package layout conventions +## Cross-cutting -- `cmd/lab` — `main` only. -- `internal/cli` — root command, wiring, shared execution helpers. -- `internal/cli/commands` — individual command constructors (stubs). -- `internal/clierrors` — sentinel errors shared without import cycles. -- `internal/config`, `internal/logging`, `internal/buildinfo` — cross-cutting utilities. -- `pkg/` — reserved for libraries that may become reusable outside this binary. +| Package | Role | +|---------|------| +| `internal/config` | YAML + `LAB_*` env | +| `internal/homelabroot` | Resolve homelab repo path | +| `internal/executil` | Command runner with dry-run | +| `internal/ui` | lipgloss sections and tables | +| `internal/platform` | OS detection (brew vs apt vs …) | +| `internal/logging` | slog on context | +| `internal/buildinfo` | Version ldflags | -## MCP direction +## Command groups (Cobra) -`lab mcp serve` will eventually expose a stdio MCP server so Cursor/VS Code can call curated, read-only or guarded operations (list hosts, validate config, dry-run plans). That work is intentionally deferred until the underlying commands are real. +| Group ID | Commands | +|----------|----------| +| `foundation` | bootstrap, pkg, toolchain, services | +| `repos` | repos | +| `infra` | server, postgres, baremetal, system, ssh, cluster, gpu, containers, net, storage | +| `data` | models, data, notebooks, mlops, vector, pipelines, agents | +| `workflow` | obs, logs, templates, media, mcp | +| `meta` | version | + +Stub commands return a consistent “not implemented yet” error via `commands.StubRunE`. + +## Homelab repo boundary + +| Stays in homelab git | Lives in homelab-cli | +|----------------------|----------------------| +| `ml-stack/podman-compose.yml`, `.env.example` | `lab services`, `lab services ensure` | +| `postgres/config/instances.yaml` | `lab postgres apply` | +| `project-initiators/*` | `lab templates new` | +| Docs, experiments, CAD notes | User-facing docs in `docs/` | + +Orchestration and new features belong in Go here. See [`homelab-migration.md`](homelab-migration.md). + +## Planned work + +| Area | Direction | +|------|-----------| +| Repos | Go GitLab API + go-git instead of Python backup | +| Bootstrap | Replace `script:` steps with native Go where practical | +| Cluster / ML | Thin adapters over kubectl, ollama, etc. | +| MCP | `lab mcp serve` exposing guarded read-only tools | + +## Layout conventions + +- `cmd/lab` — `main` only +- `internal/cli` — root command wiring +- `internal/cli/commands` — per-domain command constructors +- `internal/cli/appctx` — `Session` on `context.Context` +- `pkg/` — reserved for future public libraries diff --git a/docs/commands.md b/docs/commands.md index 26e5728..cfae163 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,77 +1,194 @@ # Command reference -> Status legend: ✅ implemented · 🚧 scaffolded (`not implemented yet`) +> **Legend:** ✅ implemented · 🚧 scaffolded (`not implemented yet`) + +Run `lab --help` for flags. Global flags apply to all commands: `--config`, `--homelab-root`, `--dry-run`, `--no-color`, `--log-level`, `--log-format`. + +--- ## Foundation — bootstrap & install +### `lab bootstrap` + | Command | Description | Status | |---------|-------------|--------| -| `lab bootstrap laptop` | Full laptop bootstrap (packages, shell, fonts, dotfiles, git, containers). | 🚧 | -| `lab bootstrap server` | Server bootstrap (SSH baseline, fail2ban-style hardening, agents). | 🚧 | -| `lab bootstrap profile ` | Bootstrap using `bootstrap.profiles.` from config. | 🚧 | -| `lab pkg install ` | Install via native package manager abstraction. | 🚧 | -| `lab pkg ensure ` | Idempotent install/upgrade guardrail. | 🚧 | -| `lab pkg list` | List packages tracked by `lab`. | 🚧 | -| `lab toolchain install [more]` | Install toolchains via `mise`. | 🚧 | -| `lab toolchain list` | Show installed toolchains/versions. | 🚧 | -| `lab toolchain use ` | Activate a toolchain version. | 🚧 | -| `lab services up [more]` | Start compose stacks (postgres, redis, …). | 🚧 | -| `lab services down [more]` | Stop stacks. | 🚧 | -| `lab services list` | List stacks + status. | 🚧 | -| `lab services logs ` | Tail stack logs. | 🚧 | +| `lab bootstrap laptop` | Built-in laptop profile (macOS / Linux / Silverblue). | ✅ | +| `lab bootstrap server` | Ubuntu server profile + optional homelab `install-server-deps.sh` step. | ✅ | +| `lab bootstrap profile ` | Run built-in or config-defined profile. | ✅ | +| `lab bootstrap list` | List embedded profiles. | ✅ | + +Built-in profiles: `laptop-macos`, `laptop-linux`, `silverblue-laptop`, `server-ubuntu`. + +```bash +lab bootstrap laptop --dry-run +lab bootstrap profile dgx-spark # from config bootstrap.profiles +``` + +### `lab pkg` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab pkg install [more...]` | Install packages (brew / apt / dnf / rpm-ostree). | ✅ | +| `lab pkg ensure [more...]` | Install only if missing. | ✅ | +| `lab pkg list` | Show common packages and detected backend. | ✅ | + +### `lab toolchain` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab toolchain install [more...]` | Install runtimes via `mise`. | ✅ | +| `lab toolchain list` | List installed toolchains. | ✅ | +| `lab toolchain use ` | Activate a version. | ✅ | + +### `lab services` + +Manages compose stacks under `homelab.root` (e.g. `ml-stack/podman-compose.yml`). Runtime from `services.runtime` (default `podman-compose`). + +| Command | Description | Status | +|---------|-------------|--------| +| `lab services up [more...]` | Start stack(s). | ✅ | +| `lab services down [more...]` | Stop stack(s). | ✅ | +| `lab services list` | List stacks and compose paths. | ✅ | +| `lab services logs ` | Tail logs. | ✅ | +| `lab services ensure [ml-stack]` | `podman-compose up -d` for ml-stack; print service URLs. | ✅ | + +```bash +lab services up ml-stack +lab services ensure +``` + +--- ## Repos — multi-repo management | Command | Description | Status | |---------|-------------|--------| | `lab repos clone ` | Clone org/user patterns across providers. | 🚧 | -| `lab repos backup` | Mirror configured remotes to disk/object storage. | 🚧 | -| `lab repos sync` | Fetch/pull all managed clones. | 🚧 | +| `lab repos backup` | GitLab account mirror (runs homelab `backup_account.py`). | ✅ | +| `lab repos sync` | Fetch/pull managed clones. | 🚧 | | `lab repos status` | Dirty / ahead-behind overview. | 🚧 | -| `lab repos list` | List remotes visible to configured providers. | 🚧 | +| `lab repos list` | List remotes for configured providers. | 🚧 | + +Requires `GITLAB_TOKEN` (or provider `token_env`) and `homelab.root` for the backup script path. + +--- ## Infra & networking +### `lab server` + +Uses `server.*` from config (remote homelab checkout path). + +| Command | Description | Status | +|---------|-------------|--------| +| `lab server run ''` | Run command on server in `server.path`. | ✅ | +| `lab server deploy` | Rsync homelab repo to server only. | ✅ | +| `lab server deploy provision` | Rsync + `lab postgres apply` locally (`postgres/config/instances.yaml`). | ✅ | +| `lab server deploy compose` | Rsync + `podman-compose up -d` in `ml-stack` on server. | ✅ | +| `lab server deploy full` | Provision + compose. | ✅ | + +```bash +lab server run 'cd ml-stack && podman-compose ps' +lab server deploy full --dry-run +``` + +### `lab postgres` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab postgres apply --config ` | Apply databases/users from YAML (idempotent). | ✅ | + +Requires `POSTGRES_ADMIN_PASSWORD` or `PGPASSWORD`. Config format matches homelab `postgres/config/instances.yaml`. + +### `lab baremetal` + +Run **on the target Linux server** (uses curl, apt, sudo). + +| Command | Description | Status | +|---------|-------------|--------| +| `lab baremetal install qdrant` | Qdrant from GitHub release + systemd. | ✅ | +| `lab baremetal install milvus` | Milvus DEB from GitHub. | ✅ | +| `lab baremetal install clickhouse` | ClickHouse from official apt repo. | ✅ | + +### `lab system` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab system usb list` | Query Ubuntu (meta-release) and Fedora (dl.fedoraproject.org) for desktop/Silverblue ISOs. | ✅ | +| `lab system usb` | Download ISO, verify SHA256, write to block device with `dd`. | ✅ | + +**Discovered images (typical):** two recent Ubuntu LTS (≥ 22.04), latest Ubuntu interim, latest Fedora Silverblue. + +| Flag | Description | +|------|-------------| +| `--distro` | Image ID from `list` or alias: `ubuntu-latest`, `ubuntu-lts`, `fedora-silverblue`, `ubuntu-24.04`, … | +| `--device` | Block device (e.g. `/dev/sdb`; on macOS often `/dev/diskN`) | +| `--workdir` | Download directory | +| `--iso-url` | Skip discovery; use custom ISO URL | + +```bash +lab system usb list +lab system usb --distro ubuntu-25.10 --device /dev/sdb --workdir ~/Downloads +``` + +### `lab ssh` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab ssh connect ` | Interactive SSH to `ssh.hosts.`. | ✅ | +| `lab ssh sync` | Rsync `homelab.root` to `server.*` (same as deploy sync). | ✅ | + +### Other infra (stubs) + | Command | Description | Status | |---------|-------------|--------| | `lab cluster status` | Cluster health summary. | 🚧 | -| `lab cluster kubeconfig` | kubeconfig helpers for homelab contexts. | 🚧 | +| `lab cluster kubeconfig` | kubeconfig helpers. | 🚧 | | `lab gpu info` | GPU/driver diagnostics. | 🚧 | -| `lab ssh connect ` | SSH helper with inventory + keys. | 🚧 | -| `lab containers ps` | Cross-runtime container listing. | 🚧 | -| `lab net status` | Tailscale/WireGuard/DNS/mDNS snapshot. | 🚧 | -| `lab storage ls ` | S3-compatible listing helper. | 🚧 | +| `lab containers ps` | Container listing. | 🚧 | +| `lab net status` | Tailscale/WireGuard/DNS snapshot. | 🚧 | +| `lab storage ls ` | S3-compatible listing. | 🚧 | + +--- -## Data / AI / ML / MLOps +## Data / AI / ML / MLOps (stubs) | Command | Description | Status | |---------|-------------|--------| | `lab models pull ` | Pull/cache local LLMs. | 🚧 | -| `lab data sync` | Dataset sync helpers (DVC/lakeFS/etc.). | 🚧 | -| `lab notebooks up` | Launch notebook servers. | 🚧 | +| `lab data sync` | Dataset sync helpers. | 🚧 | +| `lab notebooks up` | Notebook servers. | 🚧 | | `lab mlops status` | Experiment tracker connectivity. | 🚧 | -| `lab vector list` | Vector DB inventory/status. | 🚧 | -| `lab pipelines run ` | Local pipeline runner entrypoint. | 🚧 | -| `lab agents list` | Local agent runtime registry. | 🚧 | +| `lab vector list` | Vector DB inventory. | 🚧 | +| `lab pipelines run ` | Local pipeline runner. | 🚧 | +| `lab agents list` | Agent runtime registry. | 🚧 | + +--- ## Workflow & observability | Command | Description | Status | |---------|-------------|--------| -| `lab obs up` | Start observability bundle. | 🚧 | -| `lab logs tail ` | Aggregate log tailing. | 🚧 | -| `lab templates new ` | Generate projects from templates. | 🚧 | -| `lab mcp serve` | stdio MCP server for IDE integrations. | 🚧 | +| `lab templates list` | Template kinds: `golang`, `python`, `rust`, `typescript`. | ✅ | +| `lab templates new ` | Copy from homelab `project-initiators/`. | ✅ | +| `lab media heic [dir]` | HEIC → JPEG via `heif-convert`. | ✅ | +| `lab obs up` | Observability bundle. | 🚧 | +| `lab logs tail ` | Aggregated logs. | 🚧 | +| `lab mcp serve` | stdio MCP server. | 🚧 | + +```bash +lab media heic ~/Pictures/import --quality 95 --force +``` + +--- ## Meta | Command | Description | Status | |---------|-------------|--------| -| `lab version` | Print build metadata (`--output text|json`). | ✅ | - -Examples: +| `lab version` | Build version, commit, date (`--output text\|json`). | ✅ | ```bash lab version --output json -lab --log-level debug --log-format json version +lab --log-level debug services list ``` diff --git a/docs/config.example.yaml b/docs/config.example.yaml new file mode 100644 index 0000000..769e8d1 --- /dev/null +++ b/docs/config.example.yaml @@ -0,0 +1,56 @@ +# Example lab configuration — copy to ~/.config/homelab-cli/config.yaml + +log_level: info +log_format: text + +# Personal homelab repo (compose, postgres YAML, project-initiators) +homelab: + root: ~/Projects/PERSONAL/homelab + +# Default remote server for: lab ssh sync, lab server run, lab server deploy +server: + host: 192.168.1.10 + user: bart + port: 22 + path: ~/homelab + +bootstrap: + default_profile: laptop-macos + profiles: + dgx-spark: + name: dgx-spark + description: Ubuntu ML host (custom steps) + steps: + - type: pkg + packages: [git, curl, jq, podman] + - type: script + script: scripts/install-server-deps.sh + +repos: + root: ~/src + backup_dir: ~/backups/repos/gitlab + providers: + - name: gitlab-personal + kind: gitlab + host: gitlab.com + token_env: GITLAB_TOKEN + +services: + stacks_dir: ~/.config/homelab-cli/stacks + runtime: podman-compose + +ssh: + hosts: + homelab: + host: 192.168.1.10 + user: bart + port: 22 + identity_file: ~/.ssh/id_ed25519 + +cluster: + kubeconfig: ~/.kube/config + context: "" + +storage: + endpoint: "" + access_key: "" diff --git a/docs/configuration.md b/docs/configuration.md index 91859fd..fea4fe2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,45 +2,144 @@ ## Precedence -1. CLI flags (`--log-level`, `--log-format`, `--config`, …) -2. Environment variables with the `LAB_` prefix (nested keys use `_`, e.g. `LAB_SERVICES_RUNTIME`) -3. YAML file (`--config`, default `~/.config/homelab-cli/config.yaml`) -4. Built-in defaults in `internal/config` +1. **CLI flags** — `--log-level`, `--log-format`, `--config`, `--homelab-root`, `--dry-run`, `--no-color` +2. **Environment** — `LAB_*` prefix; nested keys use `_` (e.g. `LAB_SERVER_HOST`, `LAB_HOMELAB_ROOT`) +3. **YAML file** — `--config` or default `~/.config/homelab-cli/config.yaml` +4. **Built-in defaults** — `internal/config.Default()` -## Example `config.yaml` +If the config file does not exist, defaults apply. Invalid YAML fails at load time. + +## Starter file + +Copy [`config.example.yaml`](config.example.yaml): + +```bash +mkdir -p ~/.config/homelab-cli +cp docs/config.example.yaml ~/.config/homelab-cli/config.yaml +``` + +## Keys + +### Global + +| Key | Env (examples) | Description | +|-----|----------------|-------------| +| `log_level` | `LAB_LOG_LEVEL` | `debug`, `info`, `warn`, `error` | +| `log_format` | `LAB_LOG_FORMAT` | `text` or `json` | + +### `homelab` + +| Key | Env | Description | +|-----|-----|-------------| +| `homelab.root` | `LAB_HOMELAB_ROOT` | Absolute or `~/` path to the personal homelab repository. Required for compose stacks, templates, postgres YAML, and GitLab backup script. Overridable with `--homelab-root`. | + +### `server` + +Default target for `lab ssh sync`, `lab server run`, and `lab server deploy`. + +| Key | Env | Description | +|-----|-----|-------------| +| `server.host` | `LAB_SERVER_HOST` | Hostname or IP | +| `server.user` | `LAB_SERVER_USER` | SSH user (default `root`) | +| `server.port` | `LAB_SERVER_PORT` | SSH port (default `22`) | +| `server.path` | `LAB_SERVER_PATH` | Remote directory containing the homelab checkout | + +### `ssh.hosts` + +Map of alias → host for `lab ssh connect `. + +```yaml +ssh: + hosts: + homelab: + host: 192.168.1.10 + user: bart + port: 22 + identity_file: ~/.ssh/id_ed25519 +``` + +### `bootstrap` + +| Key | Description | +|-----|-------------| +| `bootstrap.default_profile` | Default profile name | +| `bootstrap.profiles` | Custom profiles (same step types as embedded YAML: `pkg`, `toolchain`, `script`) | + +Embedded profiles live in `internal/bootstrap/profiles/*.yaml` and are always available. + +### `repos` + +| Key | Description | +|-----|-------------| +| `repos.root` | Local directory for cloned repos (future) | +| `repos.backup_dir` | GitLab backup destination | +| `repos.providers[]` | `name`, `kind` (`github`, `gitlab`, …), `host`, `token_env` | + +### `services` + +| Key | Description | +|-----|-------------| +| `services.runtime` | `podman-compose` or `docker` | +| `services.stacks_dir` | Reserved; stacks are resolved from `homelab.root` today | + +### `cluster` / `storage` + +Used by future commands; safe to set early. + +| Key | Description | +|-----|-------------| +| `cluster.kubeconfig` | Path to kubeconfig | +| `cluster.context` | Default context name | +| `storage.endpoint` | S3-compatible endpoint | +| `storage.access_key` | Access key (prefer env for secrets) | + +## Example ```yaml log_level: info log_format: text +homelab: + root: ~/Projects/PERSONAL/homelab + +server: + host: 192.168.1.10 + user: bart + port: 22 + path: ~/homelab + bootstrap: - default_profile: default + default_profile: laptop-macos profiles: {} repos: root: ~/src - backup_dir: ~/backups/repos + backup_dir: ~/backups/repos/gitlab providers: - - name: github-personal - kind: github - host: github.com - token_env: GH_TOKEN - - name: gitlab-work + - name: gitlab-personal kind: gitlab host: gitlab.com token_env: GITLAB_TOKEN services: - stacks_dir: ~/.config/homelab-cli/stacks - runtime: podman # docker|podman + runtime: podman-compose + +ssh: + hosts: + homelab: + host: 192.168.1.10 + user: bart + port: 22 cluster: kubeconfig: ~/.kube/config context: homelab - -storage: - endpoint: https://minio.lan:9000 - access_key: REPLACE_ME ``` -Missing files fall back to defaults; malformed YAML is an error. +## PostgreSQL apply + +`lab postgres apply` reads a separate YAML file (not the main lab config), typically: + +`$HOMELAB_ROOT/postgres/config/instances.yaml` + +Set `POSTGRES_ADMIN_PASSWORD` or `PGPASSWORD` before apply. diff --git a/docs/external-binaries.md b/docs/external-binaries.md new file mode 100644 index 0000000..09fe4a1 --- /dev/null +++ b/docs/external-binaries.md @@ -0,0 +1,30 @@ +# External binaries + +**Rule:** workflow logic (ordering, config, retries, parallelism, terminal UI) lives in **Go**. +**Do not** shell out to `.sh` / `.py` scripts from the homelab repository. + +Programs below cannot reasonably be replaced by a Go standard-library call alone. `lab` invokes them with explicit arguments. See also [`homelab-migration.md`](homelab-migration.md). + +| Program | Used by | Why not pure Go | +|---------|---------|-----------------| +| **heif-convert** | `lab media heic` | HEIC decoder (libheif) | +| **ssh** | `lab ssh connect`, `lab server run` | Interactive sessions and remote shells | +| **rsync** | `lab ssh sync`, `lab server deploy` | Efficient tree sync to server | +| **podman-compose** / **docker compose** | `lab services`, deploy compose | Container runtime | +| **curl** / **wget** | `lab baremetal install`, `lab system usb` | Large downloads and mirror indexes | +| **apt-get** / **dpkg** | `lab baremetal install` (Milvus, ClickHouse) | Distribution packages on target host | +| **sudo** | bare metal, USB write | Privileged install and `dd` | +| **sha256sum** | `lab system usb` | ISO checksum verification | +| **dd** | `lab system usb` | Raw block-device write | +| **git** | Future repos features; backup may use git | Protocol and object store | +| **mise** | `lab toolchain` | Multi-language version manager | +| **brew** / **apt** / **dnf** / **rpm-ostree** | `lab pkg` | OS package managers | + +### Interim homelab script + +| Script | Command | Plan | +|--------|---------|------| +| `tools/gitlab/backup_account.py` | `lab repos backup` | Port to Go (GitLab API + git) | +| `scripts/install-server-deps.sh` | bootstrap `script:` step | Port or keep as one-off server step | + +YouTube downloads are intentionally **not** part of this CLI; use homelab scripts or `yt-dlp` directly if needed. diff --git a/docs/homelab-migration.md b/docs/homelab-migration.md new file mode 100644 index 0000000..e5b720e --- /dev/null +++ b/docs/homelab-migration.md @@ -0,0 +1,74 @@ +# homelab → homelab-cli migration + +Reference: the personal **homelab** repo (compose, YAML, templates, notes) vs **homelab-cli** (`lab`). + +**Legend:** ✅ in CLI (Go orchestration) · 🔶 partial · ⬜ not migrated · 📁 stays in homelab · 🔧 needs external binary ([`external-binaries.md`](external-binaries.md)) + +## Principles + +1. **Orchestration in Go** — steps, config, retries, UI. +2. **No homelab `.sh` / `.py` from `lab`** for migrated features. +3. **Allowed `exec`** — standard host tools listed in [`external-binaries.md`](external-binaries.md). + +--- + +## `tools/` + +| homelab | lab | Notes | +|---------|-----|--------| +| `tools/media/yt_playlist_download.sh` | 📁 | Stay in homelab; not in `lab` | +| `tools/media/heic_converter.sh` | ✅ `lab media heic` | Go + heif-convert | +| `tools/dev-setup/*.sh` | ✅ `lab pkg`, `lab toolchain`, bootstrap profiles | YAML profiles | +| `tools/gitlab/backup_account.py` | 🔶 `lab repos backup` | Still exec Python | +| `tools/system/bootable_usb/` | ✅ `lab system usb` | Dynamic mirror query + wget/dd | +| `tools/cad/*.md` | 📁 | Research notes | + +--- + +## `scripts/` + +| homelab | lab | Notes | +|---------|-----|--------| +| `sync-to-server.sh` | ✅ `lab ssh sync`, `lab server deploy` | rsync + ssh | +| `remote-run.sh` | ✅ `lab server run` | ssh | +| `deploy-and-compose.sh` | ✅ `lab server deploy [provision\|compose\|full]` | PG apply local via pgx | +| `ensure-ml-stack-up.sh` | ✅ `lab services ensure` | podman-compose | +| `install-*-bare-metal.sh` | ✅ `lab baremetal install ` | Go + curl/apt/sudo | +| `install-server-deps.sh` | 🔶 bootstrap `script:` step | Still bash from homelab | +| `run-ml-stack-setup-on-server.sh` | ⬜ | Not in bootstrap profiles yet | +| `build-langfuse-image.sh`, `test-mlflow-api-route.sh` | ⬜ | | +| `git-newbranch.sh`, `setup-git-hooks.sh` | ⬜ | | + +--- + +## `postgres/`, `ml-stack/` + +| homelab | lab | Notes | +|---------|-----|--------| +| `postgres/provision` (Python) | ✅ `lab postgres apply` | pgx | +| `postgres/config/instances.yaml` | 📁 | Path under homelab checkout | +| `ml-stack/podman-compose.yml` | ✅ `lab services up ml-stack` | Compose from homelab.root | + +--- + +## Config in homelab-cli + +| Area | Status | +|------|--------| +| `homelab.root`, `LAB_HOMELAB_ROOT`, `--homelab-root` | ✅ | +| `server.*` (deploy, sync, run) | ✅ | +| `ssh.hosts`, `lab ssh connect` | ✅ | +| Bootstrap embedded + custom profiles | ✅ 🔶 ML server setup script not wired | +| `repos.backup` | ✅ 🔶 Python | +| `media heic`, `services`, `postgres`, `baremetal`, `system usb` | ✅ | +| cluster, gpu, models, mlops, mcp, … | ⬜ stubs | + +--- + +## Open decisions + +| Area | Options | +|------|---------| +| `lab repos backup` | Port to Go (GitLab API + go-git) vs keep Python | +| Bootstrap `script:` steps | Keep one-off homelab bash vs rewrite in Go | +| `run-ml-stack-setup-on-server.sh` | New `lab server setup` or bootstrap profile |