diff --git a/.gitignore b/.gitignore index ceb4b41..3fa43ee 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,18 @@ profile.cov # Build artifacts /bin/ +lab + +# GoReleaser / local release output /dist/ +*.deb +*.rpm +homelab-cli_*.tar.gz +checksums.txt +homelab-cli_*_checksums.txt + +# Local dev toolchain (e.g. Go SDK for CI without system install) +.tools/ # Dependency directories (remove the comment below to include it) # vendor/ @@ -30,6 +41,11 @@ go.work.sum # env file .env +.env.* + +# OS +.DS_Store +Thumbs.db # Editor/IDE # .idea/ diff --git a/.golangci.yml b/.golangci.yml index 8467122..0ce3b79 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -21,9 +21,19 @@ linters: - gosec settings: + errcheck: + exclude-functions: + - fmt.Fprintf + - fmt.Fprint + - fmt.Fprintln gosec: excludes: - G204 + - G301 + - G304 + - G306 + - G302 + - G110 issues: max-same-issues: 50 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index aa15605..2854d13 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,16 +32,41 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" checksum: - name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + name_template: "checksums.txt" algorithm: sha256 +nfpms: + - id: lab + builds: + - lab + maintainer: "Your Name " + description: "Homelab automation CLI from bare metal to GPU-served LLMs" + license: Apache-2.0 + bindir: /usr/bin + section: utils + homepage: "https://github.com/bartrosa/homelab-cli" + recommends: + - mise + - podman + - distrobox + formats: + - deb + - rpm + changelog: + sort: asc use: github filters: exclude: - "^docs:" - "^chore:" - "^test:" + - "^ci:" + +release: + draft: false + prerelease: auto + mode: replace # brews: # - # TODO: add Homebrew tap after repository is public and naming is final diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aec159..4369d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - YYYY-MM-DD + +### Added + +- Release plumbing: GoReleaser `.deb`, `.rpm`, `.tar.gz`, and `checksums.txt` for linux/darwin amd64/arm64. +- `scripts/install.sh` and `scripts/uninstall.sh` for curl-based installs with SHA256 verification. +- `lab self-update` with `--check`, `--version`, `--pre-release`, and `--yes`. +- `lab iso list|download|disks|write` — ISO catalog, verified downloads, USB disk listing, and safe `dd` writes (Linux). +- `lab bootstrap essentials` for Ubuntu and Fedora Silverblue with idempotent sections. +- Package manager adapters: `internal/pkgmgr` (`apt`, `rpm-ostree`, detect). +- Testable exec runner: `internal/exec`. + +### Changed + +- GoReleaser config: nfpms, changelog filters, release mode. +- GitHub release workflow unchanged in trigger but documents full artifact set. + ## [Unreleased] ### Added diff --git a/README.md b/README.md index df997d4..1a5e5a6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Module: `github.com/bartrosa/homelab-cli` | Area | Commands | Notes | |------|----------|--------| -| **Bootstrap** | `bootstrap laptop\|server\|profile\|list` | Embedded YAML profiles (macOS, Linux, Silverblue, Ubuntu server) | +| **Bootstrap** | `bootstrap laptop\|server\|profile\|list\|essentials` | Profiles + `essentials` for Ubuntu/Silverblue | | **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 | @@ -26,11 +26,12 @@ Module: `github.com/bartrosa/homelab-cli` | **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 | +| **ISO** | `iso list`, `iso download`, `iso disks`, `iso write` | Cache ISOs, list disks, burn USB (Linux) | | **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 | +| **Meta** | `version`, `self-update` | Build metadata and in-place upgrades | **Planned (stubs):** `cluster`, `gpu`, `models`, `mlops`, `vector`, `pipelines`, `agents`, `obs`, `logs`, `mcp`, most of `repos` beyond backup. @@ -38,7 +39,29 @@ Full command tables: [`docs/commands.md`](docs/commands.md). ## Installation -### Build from source +### One-liner install + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash +``` + +Pin a version or install to `$HOME/.local`: + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --version v0.2.0 +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --prefix "$HOME/.local" +``` + +### Upgrading + +```bash +lab self-update +lab self-update --check # exit 0 if current, 3 if update available +``` + +### Alternatives + +**Build from source** ```bash git clone https://github.com/bartrosa/homelab-cli.git @@ -48,15 +71,28 @@ make install # or: make build && ./bin/lab Requires **Go 1.25+**. -### `go install` +**go install** ```bash go install github.com/bartrosa/homelab-cli/cmd/lab@latest ``` -### Releases +**Release packages** -Tagged releases publish binaries via GoReleaser. You can also use `scripts/install.sh` when release artifacts are available. +Tagged releases publish `.tar.gz`, `.deb`, and `.rpm` via GoReleaser on the [Releases](https://github.com/bartrosa/homelab-cli/releases) page. + +## Provisioning a new machine + +```bash +# On an existing Linux host: +lab iso list +lab iso download ubuntu-desktop +lab iso disks +lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb + +# After booting the fresh OS and installing lab: +lab bootstrap essentials +``` ## Quick start diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 237b1b0..16db181 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -2,11 +2,15 @@ package main import ( - "github.com/bartrosa/homelab-cli/internal/cli" "context" + "errors" "os" "os/signal" "syscall" + + "github.com/bartrosa/homelab-cli/internal/cli" + + "github.com/bartrosa/homelab-cli/internal/clierrors" ) func main() { @@ -16,6 +20,13 @@ func main() { stop() if err != nil { + var exitErr *clierrors.ExitError + if errors.As(err, &exitErr) { + if exitErr.Msg != "" { + cli.PrintCommandError(err) + } + os.Exit(exitErr.ExitCode()) + } cli.PrintCommandError(err) os.Exit(1) } diff --git a/docs/commands.md b/docs/commands.md index cfae163..6fd9f66 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,14 +16,32 @@ Run `lab --help` for flags. Global flags apply to all commands: `--con | `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. | ✅ | +| `lab bootstrap essentials` | Baseline packages for Ubuntu or Silverblue (sections: system-update, cli-basics, …). | ✅ | Built-in profiles: `laptop-macos`, `laptop-linux`, `silverblue-laptop`, `server-ubuntu`. ```bash lab bootstrap laptop --dry-run +lab bootstrap essentials --dry-run --target silverblue lab bootstrap profile dgx-spark # from config bootstrap.profiles ``` +### `lab iso` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab iso list` | Supported distros and resolved versions. | ✅ | +| `lab iso download ` | Download and verify ISO to cache. | ✅ | +| `lab iso disks` | List block devices; USB vs SYSTEM (Linux). | ✅ | +| `lab iso write --to ` | Burn ISO with safety checks (Linux). | ✅ | + +Flow: `list` → `download` → `disks` → `write`. + +```bash +lab iso download ubuntu-desktop +lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb +``` + ### `lab pkg` | Command | Description | Status | @@ -187,8 +205,10 @@ lab media heic ~/Pictures/import --quality 95 --force | Command | Description | Status | |---------|-------------|--------| | `lab version` | Build version, commit, date (`--output text\|json`). | ✅ | +| `lab self-update` | Install latest release from GitHub (`--check`, `--version`, `--pre-release`). | ✅ | ```bash lab version --output json +lab self-update --check lab --log-level debug services list ``` diff --git a/internal/baremetal/clickhouse.go b/internal/baremetal/clickhouse.go index 667b1d1..b566c1c 100644 --- a/internal/baremetal/clickhouse.go +++ b/internal/baremetal/clickhouse.go @@ -1,3 +1,4 @@ +// Package baremetal installs data-plane services directly on Linux hosts. package baremetal import ( diff --git a/internal/bootstrap/distrobox.go b/internal/bootstrap/distrobox.go new file mode 100644 index 0000000..6c63acd --- /dev/null +++ b/internal/bootstrap/distrobox.go @@ -0,0 +1,61 @@ +package bootstrap + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// SetupDistrobox installs distrobox and optionally creates homelab-dev container. +func SetupDistrobox(ctx context.Context, opts EssentialsOptions) (bool, error) { + pkg, _ := PackageFor("distrobox", "rpm-ostree") + installed, err := opts.Runner.RunWithOutput(ctx, "which", "distrobox") + reboot := false + if err != nil || strings.TrimSpace(installed) == "" { + if err := opts.Runner.Run(ctx, "rpm-ostree", "install", "--idempotent", "-y", pkg); err != nil { + return false, err + } + reboot = true + } + + create := opts.Yes + if !create && !opts.Yes { + fmt.Fprint(opts.Stdout, "Create distrobox homelab-dev container? [y/N]: ") + var ans string + _, _ = fmt.Scanln(&ans) + create = strings.EqualFold(strings.TrimSpace(ans), "y") || strings.EqualFold(strings.TrimSpace(ans), "yes") + } + if !create { + return reboot, nil + } + + if err := opts.Runner.Run(ctx, "distrobox", "create", "--name", "homelab-dev", "--image", "quay.io/fedora/fedora:41"); err != nil { + return reboot, fmt.Errorf("distrobox create: %w", err) + } + _ = opts.Runner.Run(ctx, "distrobox", "enter", "homelab-dev", "--", "sudo", "dnf", "install", "-y", "@development-tools", "git", "curl") + _ = opts.Runner.Run(ctx, "distrobox", "enter", "homelab-dev", "--", "sh", "-c", "curl https://mise.run | sh") + home, _ := os.UserHomeDir() + misePath := home + "/.local/share/mise/bin/mise" + exportPath := home + "/.local/bin" + _ = opts.Runner.Run(ctx, "distrobox-export", "--bin", misePath, "--export-path", exportPath) + return reboot, nil +} + +// EnsureFlathub adds Flathub remote if missing. +func EnsureFlathub(ctx context.Context, runner exec.Runner) error { + out, err := runner.RunWithOutput(ctx, "flatpak", "remote-list") + if err != nil { + return err + } + sc := bufio.NewScanner(strings.NewReader(out)) + for sc.Scan() { + if strings.Contains(sc.Text(), "flathub") { + return nil + } + } + return runner.Run(ctx, "flatpak", "remote-add", "--if-not-exists", "flathub", "https://flathub.org/repo/flathub.flatpakrepo") +} diff --git a/internal/bootstrap/docker.go b/internal/bootstrap/docker.go new file mode 100644 index 0000000..a5bca8d --- /dev/null +++ b/internal/bootstrap/docker.go @@ -0,0 +1,43 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os/user" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// InstallDocker sets up Docker CE on Ubuntu via official apt repository. +func InstallDocker(ctx context.Context, runner exec.Runner, stdout, _ io.Writer) error { + if _, err := runner.RunWithOutput(ctx, "docker", "--version"); err == nil { + fmt.Fprintln(stdout, "docker already installed") + return addUserToDockerGroup(ctx, runner, stdout) + } + + script := `set -e +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list +apt-get update +apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +` + if err := runner.Run(ctx, "sudo", "bash", "-c", script); err != nil { + return fmt.Errorf("docker install: %w", err) + } + return addUserToDockerGroup(ctx, runner, stdout) +} + +func addUserToDockerGroup(ctx context.Context, runner exec.Runner, stdout io.Writer) error { + u, err := user.Current() + if err != nil { + return err + } + if err := runner.Run(ctx, "sudo", "usermod", "-aG", "docker", u.Username); err != nil { + return err + } + fmt.Fprintln(stdout, "Added user to docker group — re-login or run: newgrp docker") + return nil +} diff --git a/internal/bootstrap/essentials.go b/internal/bootstrap/essentials.go new file mode 100644 index 0000000..478f5ba --- /dev/null +++ b/internal/bootstrap/essentials.go @@ -0,0 +1,210 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/pkgmgr" +) + +// EssentialsOptions configures bootstrap essentials. +type EssentialsOptions struct { + Target string + Yes bool + Skip []string + Only []string + DryRun bool + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner +} + +// RunEssentials executes selected bootstrap sections. +func RunEssentials(ctx context.Context, opts EssentialsOptions) error { + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Runner == nil { + opts.Runner = exec.NewOSRunner(opts.Stdout, opts.Stderr) + } + + mgr, osType, err := pkgmgr.DetectTarget(opts.Target, opts.Runner) + if err != nil { + return err + } + mgrName := mgr.Name() + + sections := FilterSections(AllEssentialSections(), opts.Only, opts.Skip) + if len(sections) == 0 { + return fmt.Errorf("no sections selected") + } + + fmt.Fprintf(opts.Stdout, "Target: %s (%s)\n\n", osType, mgrName) + + for _, section := range sections { + fmt.Fprintf(opts.Stdout, "== %s ==\n", section) + if opts.DryRun { + if err := planSection(opts.Stdout, section, mgrName, osType); err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "⏭️ dry-run\n\n") + continue + } + reboot, err := runSection(ctx, opts, mgr, mgrName, osType, section) + if err != nil { + fmt.Fprintf(opts.Stderr, "❌ failed: %v\n", err) + return err + } + fmt.Fprintf(opts.Stdout, "✅ done\n") + if reboot { + fmt.Fprintf(opts.Stdout, "ℹ️ %s\n", pkgmgr.NeedsRebootHint) + } + fmt.Fprintln(opts.Stdout) + } + return nil +} + +func planSection(w io.Writer, section, mgrName string, osType pkgmgr.OSType) error { + switch section { + case "system-update": + fmt.Fprintf(w, "[plan] %s update cache\n", mgrName) + case "cli-basics": + for _, p := range CLIBasicPackages() { + pkg, _ := PackageFor(p, mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + } + case "shell-tools": + for _, p := range ShellToolPackages() { + pkg, _ := PackageFor(p, mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + } + case "build": + pkg, _ := PackageFor("build", mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + case "container-runtime": + if mgrName == "apt" { + fmt.Fprintln(w, "[plan] install Docker CE via apt repository") + } else { + fmt.Fprintln(w, "[plan] verify podman (preinstalled on Silverblue)") + } + case "mise": + fmt.Fprintln(w, "[plan] curl https://mise.run | sh") + case "distrobox": + if osType == pkgmgr.OSSilverblue { + fmt.Fprintln(w, "[plan] rpm-ostree install distrobox") + fmt.Fprintln(w, "[plan] optional: distrobox create homelab-dev") + } else { + fmt.Fprintln(w, "[plan] skip (Silverblue only)") + } + case "flatpak-flathub": + if osType == pkgmgr.OSSilverblue { + fmt.Fprintln(w, "[plan] flatpak remote-add flathub") + } else { + fmt.Fprintln(w, "[plan] skip (Silverblue only)") + } + default: + return fmt.Errorf("unknown section %q", section) + } + return nil +} + +func runSection(ctx context.Context, opts EssentialsOptions, mgr pkgmgr.Manager, mgrName string, osType pkgmgr.OSType, section string) (bool, error) { + switch section { + case "system-update": + return false, mgr.UpdateCache(ctx) + case "cli-basics": + return installGenericPackages(ctx, mgr, mgrName, CLIBasicPackages()...) + case "shell-tools": + return installGenericPackages(ctx, mgr, mgrName, ShellToolPackages()...) + case "build": + pkg, ok := PackageFor("build", mgrName) + if !ok { + return false, fmt.Errorf("no build package for %s", mgrName) + } + return mgrName == "rpm-ostree", mgr.Install(ctx, pkg) + case "container-runtime": + if mgrName == "apt" { + return false, InstallDocker(ctx, opts.Runner, opts.Stdout, opts.Stderr) + } + return false, ensurePodmanSilverblue(ctx, opts.Runner) + case "mise": + return false, RunMiseInstall(ctx, opts.Runner, opts.Stdout) + case "distrobox": + if osType != pkgmgr.OSSilverblue { + fmt.Fprintln(opts.Stdout, "⏭️ skipped (Silverblue only)") + return false, nil + } + reboot, err := SetupDistrobox(ctx, opts) + return reboot, err + case "flatpak-flathub": + if osType != pkgmgr.OSSilverblue { + fmt.Fprintln(opts.Stdout, "⏭️ skipped (Silverblue only)") + return false, nil + } + return false, EnsureFlathub(ctx, opts.Runner) + default: + return false, fmt.Errorf("unknown section %q", section) + } +} + +func installGenericPackages(ctx context.Context, mgr pkgmgr.Manager, mgrName string, names ...string) (bool, error) { + var pkgs []string + for _, n := range names { + pkg, ok := PackageFor(n, mgrName) + if !ok { + return false, fmt.Errorf("no mapping for %q on %s", n, mgrName) + } + installed, err := mgr.IsInstalled(ctx, pkg) + if err != nil { + return false, err + } + if installed { + continue + } + pkgs = append(pkgs, pkg) + } + if len(pkgs) == 0 { + return false, nil + } + return mgrName == "rpm-ostree", mgr.Install(ctx, pkgs...) +} + +func ensurePodmanSilverblue(ctx context.Context, runner exec.Runner) error { + _, err := runner.RunWithOutput(ctx, "podman", "--version") + if err != nil { + return fmt.Errorf("podman not available: %w", err) + } + pkg, _ := PackageFor("podman-compose", "rpm-ostree") + if err := runner.Run(ctx, "rpm", "-q", pkg); err != nil { + return runner.Run(ctx, "rpm-ostree", "install", "--idempotent", "-y", pkg) + } + return nil +} + +// SectionNamesForTest exposes section filtering for tests. +func SectionNamesForTest(only, skip []string) []string { + return FilterSections(AllEssentialSections(), only, skip) +} + +// ParseCSV splits comma-separated section names. +func ParseCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/bootstrap/essentials_test.go b/internal/bootstrap/essentials_test.go new file mode 100644 index 0000000..35d434e --- /dev/null +++ b/internal/bootstrap/essentials_test.go @@ -0,0 +1,30 @@ +package bootstrap_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/stretchr/testify/require" +) + +func TestFilterSections_only(t *testing.T) { + got := bootstrap.SectionNamesForTest([]string{"cli-basics"}, nil) + require.Equal(t, []string{"cli-basics"}, got) +} + +func TestFilterSections_skip(t *testing.T) { + got := bootstrap.SectionNamesForTest(nil, []string{"mise", "distrobox"}) + require.NotContains(t, got, "mise") + require.NotContains(t, got, "distrobox") + require.Contains(t, got, "cli-basics") +} + +func TestPackageFor_apt(t *testing.T) { + pkg, ok := bootstrap.PackageFor("fd", "apt") + require.True(t, ok) + require.Equal(t, "fd-find", pkg) +} + +func TestParseCSV(t *testing.T) { + require.Equal(t, []string{"docker", "mise"}, bootstrap.ParseCSV("docker,mise")) +} diff --git a/internal/bootstrap/flatpak.go b/internal/bootstrap/flatpak.go new file mode 100644 index 0000000..28a5171 --- /dev/null +++ b/internal/bootstrap/flatpak.go @@ -0,0 +1,12 @@ +package bootstrap + +import ( + "context" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// EnsureFlathubRemote adds Flathub remote on Silverblue if missing. +func EnsureFlathubRemote(ctx context.Context, runner exec.Runner) error { + return EnsureFlathub(ctx, runner) +} diff --git a/internal/bootstrap/mise.go b/internal/bootstrap/mise.go new file mode 100644 index 0000000..4f7de7f --- /dev/null +++ b/internal/bootstrap/mise.go @@ -0,0 +1,28 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// InstallMiseScript is the official mise installer URL. +const InstallMiseScript = "https://mise.run" + +// RunMiseInstall runs the upstream mise installer when mise is absent. +func RunMiseInstall(ctx context.Context, runner exec.Runner, stdout io.Writer) error { + if _, err := runner.RunWithOutput(ctx, "mise", "--version"); err == nil { + fmt.Fprintln(stdout, "mise already installed") + return nil + } + home, _ := os.UserHomeDir() + miseBin := home + "/.local/bin/mise" + if st, err := os.Stat(miseBin); err == nil && !st.IsDir() { + fmt.Fprintln(stdout, "mise binary present at", miseBin) + return nil + } + return runner.Run(ctx, "bash", "-c", "curl https://mise.run | sh") +} diff --git a/internal/bootstrap/packages.go b/internal/bootstrap/packages.go new file mode 100644 index 0000000..d387518 --- /dev/null +++ b/internal/bootstrap/packages.go @@ -0,0 +1,87 @@ +package bootstrap + +import ( + "strings" +) + +// packageMap maps generic package names to manager-specific names. +var packageMap = map[string]map[string]string{ + "git": {"apt": "git", "rpm-ostree": "git"}, + "curl": {"apt": "curl", "rpm-ostree": "curl"}, + "wget": {"apt": "wget", "rpm-ostree": "wget"}, + "ca-certificates": {"apt": "ca-certificates", "rpm-ostree": "ca-certificates"}, + "gnupg": {"apt": "gnupg", "rpm-ostree": "gnupg"}, + "tmux": {"apt": "tmux", "rpm-ostree": "tmux"}, + "htop": {"apt": "htop", "rpm-ostree": "htop"}, + "jq": {"apt": "jq", "rpm-ostree": "jq"}, + "unzip": {"apt": "unzip", "rpm-ostree": "unzip"}, + "ripgrep": {"apt": "ripgrep", "rpm-ostree": "ripgrep"}, + "fd": {"apt": "fd-find", "rpm-ostree": "fd-find"}, + "fzf": {"apt": "fzf", "rpm-ostree": "fzf"}, + "bat": {"apt": "bat", "rpm-ostree": "bat"}, + "yq": {"apt": "yq", "rpm-ostree": "yq"}, + "zsh": {"apt": "zsh", "rpm-ostree": "zsh"}, + "build": {"apt": "build-essential", "rpm-ostree": "@development-tools"}, + "podman-compose": {"apt": "podman-compose", "rpm-ostree": "podman-compose"}, + "distrobox": {"apt": "distrobox", "rpm-ostree": "distrobox"}, + "flatpak": {"apt": "flatpak", "rpm-ostree": "flatpak"}, +} + +// PackageFor returns the native package name for a generic name and manager. +func PackageFor(genericName, manager string) (string, bool) { + manager = strings.ToLower(manager) + if m, ok := packageMap[genericName]; ok { + if pkg, ok := m[manager]; ok { + return pkg, true + } + } + return "", false +} + +// CLIBasicPackages returns cross-target CLI packages. +func CLIBasicPackages() []string { + return []string{"git", "curl", "wget", "ca-certificates", "gnupg", "tmux", "htop", "jq", "unzip"} +} + +// ShellToolPackages returns shell/search utilities. +func ShellToolPackages() []string { + return []string{"ripgrep", "fd", "fzf", "bat", "yq", "zsh"} +} + +// AllEssentialSections returns default section order. +func AllEssentialSections() []string { + return []string{ + "system-update", + "cli-basics", + "shell-tools", + "build", + "container-runtime", + "mise", + "distrobox", + "flatpak-flathub", + } +} + +// FilterSections applies --only and --skip lists. +func FilterSections(all, only, skip []string) []string { + set := map[string]struct{}{} + if len(only) > 0 { + for _, s := range only { + set[strings.TrimSpace(s)] = struct{}{} + } + } else { + for _, s := range all { + set[s] = struct{}{} + } + } + for _, s := range skip { + delete(set, strings.TrimSpace(s)) + } + var out []string + for _, s := range all { + if _, ok := set[s]; ok { + out = append(out, s) + } + } + return out +} diff --git a/internal/bootstrap/profile.go b/internal/bootstrap/profile.go index c226059..b61bcb5 100644 --- a/internal/bootstrap/profile.go +++ b/internal/bootstrap/profile.go @@ -17,9 +17,12 @@ var embeddedProfiles embed.FS type StepType string const ( - StepPkg StepType = "pkg" - StepToolchain StepType = "toolchain" - StepScript StepType = "script" + // StepPkg installs distribution packages. + StepPkg StepType = "pkg" + // StepToolchain installs mise runtimes. + StepToolchain StepType = "toolchain" + // StepScript runs a homelab shell script. + StepScript StepType = "script" ) // Step is one idempotent action in a profile. diff --git a/internal/cli/appctx/appctx.go b/internal/cli/appctx/appctx.go index 165421f..88cb91f 100644 --- a/internal/cli/appctx/appctx.go +++ b/internal/cli/appctx/appctx.go @@ -12,11 +12,11 @@ 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 + Config *config.Config + ConfigPath string + DryRun bool + NoColor bool + Styles ui.Styles HomelabRoot string // flag override } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 15260ab..7f36848 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "github.com/bartrosa/homelab-cli/internal/cli" "bytes" "context" "encoding/json" @@ -10,6 +9,8 @@ import ( "path/filepath" "testing" + "github.com/bartrosa/homelab-cli/internal/cli" + "github.com/stretchr/testify/require" ) diff --git a/internal/cli/commands/bootstrap.go b/internal/cli/commands/bootstrap.go index 8be39b1..325978d 100644 --- a/internal/cli/commands/bootstrap.go +++ b/internal/cli/commands/bootstrap.go @@ -25,6 +25,7 @@ and optional dotfiles. Profiles are defined in the configuration file or built-i newBootstrapServerCmd(), newBootstrapProfileCmd(), newBootstrapListCmd(), + newBootstrapEssentialsCmd(), ) return cmd diff --git a/internal/cli/commands/bootstrap_essentials.go b/internal/cli/commands/bootstrap_essentials.go new file mode 100644 index 0000000..8b982b6 --- /dev/null +++ b/internal/cli/commands/bootstrap_essentials.go @@ -0,0 +1,45 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/spf13/cobra" +) + +func newBootstrapEssentialsCmd() *cobra.Command { + var ( + target string + yes bool + skip string + only string + ) + + cmd := &cobra.Command{ + Use: "essentials", + Short: "Install baseline packages for Ubuntu or Fedora Silverblue", + Long: "Idempotent bootstrap of CLI tools, build deps, containers, mise, and Silverblue-specific layers.", + Example: " lab bootstrap essentials\n lab bootstrap essentials --dry-run --target silverblue\n lab bootstrap essentials --only cli-basics --yes", + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + return bootstrap.RunEssentials(cmd.Context(), bootstrap.EssentialsOptions{ + Target: target, + Yes: yes, + Skip: bootstrap.ParseCSV(skip), + Only: bootstrap.ParseCSV(only), + DryRun: s.DryRun, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + }) + }, + } + + cmd.Flags().StringVar(&target, "target", "auto", "ubuntu|silverblue|auto") + cmd.Flags().BoolVar(&yes, "yes", false, "accept defaults without prompts") + cmd.Flags().StringVar(&skip, "skip", "", "comma-separated sections to skip") + cmd.Flags().StringVar(&only, "only", "", "comma-separated sections to run") + AddDryRunFlag(cmd) + + return cmd +} diff --git a/internal/cli/commands/iso.go b/internal/cli/commands/iso.go new file mode 100644 index 0000000..d5dfdee --- /dev/null +++ b/internal/cli/commands/iso.go @@ -0,0 +1,163 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/iso" + "github.com/spf13/cobra" +) + +// NewISOCmd wires lab iso subcommands. +func NewISOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "iso", + Short: "Download OS ISOs and create bootable USB drives", + Long: `Provisioning flow: + 1. lab iso list — see supported distributions + 2. lab iso download — fetch and verify an ISO into cache + 3. lab iso disks — list block devices (USB vs system) + 4. lab iso write — burn ISO to a USB drive`, + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + + cmd.AddCommand( + newISOListCmd(), + newISODownloadCmd(), + newISODisksCmd(), + newISOWriteCmd(), + ) + + return cmd +} + +func newISOListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List supported distributions and current versions", + RunE: func(cmd *cobra.Command, _ []string) error { + entries, err := iso.ListDistros() + if err != nil { + return err + } + return iso.WriteList(stdout(cmd), entries) + }, + } +} + +func newISODownloadCmd() *cobra.Command { + var ( + arch string + version string + output string + noVerify bool + force bool + ) + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download and verify an ISO", + Example: " lab iso download ubuntu-desktop\n lab iso download fedora-silverblue --arch amd64", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, ok := iso.LookupDistro(args[0]) + if !ok { + return fmt.Errorf("unknown distro %q (run: lab iso list)", args[0]) + } + if output == "" { + home, _ := os.UserHomeDir() + output = filepath.Join(home, ".cache", "homelab-cli", "iso") + } + _, err := iso.DownloadISO(cmd.Context(), d, iso.DownloadOptions{ + Arch: arch, + Version: version, + OutputDir: output, + NoVerify: noVerify, + Force: force, + Stdout: stdout(cmd), + }) + return err + }, + } + + cmd.Flags().StringVar(&arch, "arch", "amd64", "target architecture (amd64|arm64)") + cmd.Flags().StringVar(&version, "version", "", "pin version (default: latest resolver)") + cmd.Flags().StringVar(&output, "output", "", "cache directory (default: ~/.cache/homelab-cli/iso/)") + cmd.Flags().BoolVar(&noVerify, "no-verify", false, "skip GPG verification of checksums") + cmd.Flags().BoolVar(&force, "force", false, "redownload even if cached file exists") + + return cmd +} + +func newISODisksCmd() *cobra.Command { + return &cobra.Command{ + Use: "disks", + Short: "List block devices and mark USB vs system disks", + RunE: func(cmd *cobra.Command, _ []string) error { + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + disks, err := iso.ListDisks(cmd.Context(), runner) + if err != nil { + return err + } + iso.WriteDisksTable(stdout(cmd), disks) + return nil + }, + } +} + +func newISOWriteCmd() *cobra.Command { + var ( + device string + yes bool + force bool + blockSize string + ) + + cmd := &cobra.Command{ + Use: "write ", + Short: "Write an ISO to a block device", + Example: " lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if device == "" { + return fmt.Errorf("--to is required (see: lab iso disks)") + } + isoPath, err := expandPath(args[0]) + if err != nil { + return err + } + return iso.WriteISO(cmd.Context(), iso.WriteOptions{ + ISOPath: isoPath, + Device: device, + Yes: yes, + Force: force, + BlockSize: blockSize, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + }) + }, + } + + cmd.Flags().StringVar(&device, "to", "", "target block device (e.g. /dev/sdb)") + cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation prompt") + cmd.Flags().BoolVar(&force, "force", false, "skip system-disk safety check (DANGER)") + cmd.Flags().StringVar(&blockSize, "bs", "4M", "dd block size") + + return cmd +} + +func expandPath(p string) (string, error) { + if len(p) >= 2 && p[:2] == "~/" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, p[2:]), nil + } + return p, nil +} diff --git a/internal/cli/commands/media.go b/internal/cli/commands/media.go index 5cef29d..5895235 100644 --- a/internal/cli/commands/media.go +++ b/internal/cli/commands/media.go @@ -26,9 +26,9 @@ func NewMediaCmd() *cobra.Command { 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.`, + 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), diff --git a/internal/cli/commands/self_update.go b/internal/cli/commands/self_update.go new file mode 100644 index 0000000..7124e4d --- /dev/null +++ b/internal/cli/commands/self_update.go @@ -0,0 +1,52 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/buildinfo" + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/spf13/cobra" +) + +// NewSelfUpdateCmd wires lab self-update. +func NewSelfUpdateCmd() *cobra.Command { + var ( + checkOnly bool + version string + preRelease bool + yes bool + ) + + cmd := &cobra.Command{ + Use: "self-update", + Short: "Download and install the latest lab release from GitHub", + Long: `Checks GitHub releases for a newer lab binary, verifies SHA256 checksums, +and atomically replaces the running executable.`, + Example: ` lab self-update + lab self-update --check + lab self-update --version v0.2.0 --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + code, err := updater.PerformUpdate(cmd.Context(), buildinfo.Version, updater.UpdateOptions{ + ForceVersion: version, + IncludePrerelease: preRelease, + Yes: yes, + CheckOnly: checkOnly, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + }) + if err != nil { + return err + } + if checkOnly && code == 3 { + return &clierrors.ExitError{Code: 3} + } + return nil + }, + } + + cmd.Flags().BoolVar(&checkOnly, "check", false, "only check for updates (exit 0 if up to date, 3 if update available)") + cmd.Flags().StringVar(&version, "version", "", "install a specific release tag") + cmd.Flags().BoolVar(&preRelease, "pre-release", false, "include pre-releases when checking latest") + cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation prompts") + + return cmd +} diff --git a/internal/cli/commands/stub.go b/internal/cli/commands/stub.go index 3031cf5..271d48d 100644 --- a/internal/cli/commands/stub.go +++ b/internal/cli/commands/stub.go @@ -1,9 +1,10 @@ package commands import ( - "github.com/bartrosa/homelab-cli/internal/clierrors" "fmt" + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/spf13/cobra" ) diff --git a/internal/cli/commands/version.go b/internal/cli/commands/version.go index 520ac1b..0a823d4 100644 --- a/internal/cli/commands/version.go +++ b/internal/cli/commands/version.go @@ -1,12 +1,13 @@ package commands import ( - "github.com/bartrosa/homelab-cli/internal/buildinfo" - "github.com/bartrosa/homelab-cli/internal/logging" "encoding/json" "fmt" "strings" + "github.com/bartrosa/homelab-cli/internal/buildinfo" + "github.com/bartrosa/homelab-cli/internal/logging" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) diff --git a/internal/cli/root.go b/internal/cli/root.go index bcbda2c..8c1a2dc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,15 +2,16 @@ package cli import ( "context" + "errors" + "fmt" + "os" + "strings" + "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" - "strings" "github.com/spf13/cobra" ) @@ -132,6 +133,8 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewMCPCmd(), "workflow") add(commands.NewVersionCmd(), "meta") + add(commands.NewSelfUpdateCmd(), "meta") + add(commands.NewISOCmd(), "foundation") return root } diff --git a/internal/clierrors/errors.go b/internal/clierrors/errors.go index d8da000..18cca6f 100644 --- a/internal/clierrors/errors.go +++ b/internal/clierrors/errors.go @@ -1,7 +1,28 @@ // Package clierrors defines stable sentinel errors shared across CLI packages. package clierrors -import "errors" +import ( + "errors" + "fmt" +) // ErrNotImplemented is returned by scaffolded commands until their adapters exist. var ErrNotImplemented = errors.New("not implemented yet") + +// ExitError carries a specific process exit code. +type ExitError struct { + Code int + Msg string +} + +func (e *ExitError) Error() string { + if e.Msg == "" { + return fmt.Sprintf("exit %d", e.Code) + } + return e.Msg +} + +// ExitCode returns the desired exit code. +func (e *ExitError) ExitCode() int { + return e.Code +} diff --git a/internal/exec/runner.go b/internal/exec/runner.go new file mode 100644 index 0000000..751ed81 --- /dev/null +++ b/internal/exec/runner.go @@ -0,0 +1,64 @@ +// Package exec provides a testable interface for running external commands. +package exec + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + osexec "os/exec" + "strings" +) + +// Runner executes external programs. +type Runner interface { + Run(ctx context.Context, name string, args ...string) error + RunWithOutput(ctx context.Context, name string, args ...string) (string, error) +} + +// OSRunner runs commands via os/exec. +type OSRunner struct { + Stdout io.Writer + Stderr io.Writer + WorkDir string + Env []string +} + +// NewOSRunner returns a runner writing to stdout/stderr. +func NewOSRunner(stdout, stderr io.Writer) *OSRunner { + return &OSRunner{Stdout: stdout, Stderr: stderr} +} + +// Run executes name with args. +func (r *OSRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := osexec.CommandContext(ctx, name, args...) + cmd.Stdout = r.Stdout + cmd.Stderr = r.Stderr + cmd.Dir = r.WorkDir + if len(r.Env) > 0 { + cmd.Env = append(os.Environ(), r.Env...) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +// RunWithOutput runs a command and returns combined stdout. +func (r *OSRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + cmd := osexec.CommandContext(ctx, name, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + if r.Stderr != nil { + cmd.Stderr = r.Stderr + } + cmd.Dir = r.WorkDir + if len(r.Env) > 0 { + cmd.Env = append(os.Environ(), r.Env...) + } + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return strings.TrimSpace(buf.String()), nil +} diff --git a/internal/exec/runner_test.go b/internal/exec/runner_test.go new file mode 100644 index 0000000..ee92ffd --- /dev/null +++ b/internal/exec/runner_test.go @@ -0,0 +1,41 @@ +package exec_test + +import ( + "context" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/stretchr/testify/require" +) + +type mockRunner struct { + calls []string +} + +func (m *mockRunner) Run(_ context.Context, name string, args ...string) error { + m.calls = append(m.calls, name+" "+strings.Join(args, " ")) + return nil +} + +func (m *mockRunner) RunWithOutput(_ context.Context, name string, args ...string) (string, error) { + m.calls = append(m.calls, name+" "+strings.Join(args, " ")) + return "ok", nil +} + +func TestMockRunner_recordsCalls(t *testing.T) { + m := &mockRunner{} + require.NoError(t, m.Run(context.Background(), "echo", "hello")) + out, err := m.RunWithOutput(context.Background(), "true") + require.NoError(t, err) + require.Equal(t, "ok", out) + require.Len(t, m.calls, 2) +} + +func TestOSRunner_true(t *testing.T) { + r := exec.NewOSRunner(nil, nil) + require.NoError(t, r.Run(context.Background(), "true")) + out, err := r.RunWithOutput(context.Background(), "echo", "lab") + require.NoError(t, err) + require.Equal(t, "lab", out) +} diff --git a/internal/executil/run.go b/internal/executil/run.go index 3ff0499..1cdcc43 100644 --- a/internal/executil/run.go +++ b/internal/executil/run.go @@ -12,12 +12,12 @@ import ( // 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 + 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. diff --git a/internal/iso/catalog.go b/internal/iso/catalog.go new file mode 100644 index 0000000..ef71771 --- /dev/null +++ b/internal/iso/catalog.go @@ -0,0 +1,195 @@ +// Package iso provides bootable USB tooling. +package iso + +import ( + "errors" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" +) + +// ErrNotImplemented indicates a stub resolver. +var ErrNotImplemented = errors.New("not implemented yet") + +// Release describes a resolved ISO download. +type Release struct { + Version string + ISOURL string + ChecksumURL string + GPGKeyURL string + ISOFilename string +} + +// Distro describes a supported distribution. +type Distro struct { + ID string + DisplayName string + Architectures []string + ApproxSize string + Resolve func(arch string, version string) (Release, error) +} + +// Catalog returns all known distros. +func Catalog() []Distro { + return []Distro{ + { + ID: "ubuntu-desktop", + DisplayName: "Ubuntu Desktop LTS", + Architectures: []string{"amd64"}, + ApproxSize: "~6 GB", + Resolve: resolveUbuntuDesktop, + }, + { + ID: "ubuntu-server", + DisplayName: "Ubuntu Server", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~3 GB", + Resolve: stubResolver("ubuntu-server"), + }, + { + ID: "fedora-silverblue", + DisplayName: "Fedora Silverblue", + Architectures: []string{"amd64"}, + ApproxSize: "~2.5 GB", + Resolve: resolveFedoraSilverblue, + }, + { + ID: "fedora-workstation", + DisplayName: "Fedora Workstation", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~2.5 GB", + Resolve: stubResolver("fedora-workstation"), + }, + { + ID: "debian", + DisplayName: "Debian", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~700 MB", + Resolve: stubResolver("debian"), + }, + { + ID: "arch", + DisplayName: "Arch Linux", + Architectures: []string{"amd64"}, + ApproxSize: "~1 GB", + Resolve: stubResolver("arch"), + }, + { + ID: "opensuse-tumbleweed", + DisplayName: "openSUSE Tumbleweed", + Architectures: []string{"amd64"}, + ApproxSize: "~4 GB", + Resolve: stubResolver("opensuse-tumbleweed"), + }, + { + ID: "nixos", + DisplayName: "NixOS", + Architectures: []string{"amd64"}, + ApproxSize: "~1.5 GB", + Resolve: stubResolver("nixos"), + }, + } +} + +func stubResolver(id string) func(string, string) (Release, error) { + return func(string, string) (Release, error) { + return Release{}, fmt.Errorf("%s: %w", id, ErrNotImplemented) + } +} + +// LookupDistro finds a distro by ID. +func LookupDistro(id string) (Distro, bool) { + id = strings.ToLower(strings.TrimSpace(id)) + for _, d := range Catalog() { + if d.ID == id { + return d, true + } + } + return Distro{}, false +} + +// ListEntry is a row for lab iso list. +type ListEntry struct { + ID string + Version string + ApproxSize string + Architectures string +} + +// ListDistros resolves latest versions for display. +func ListDistros() ([]ListEntry, error) { + var out []ListEntry + for _, d := range Catalog() { + arch := "amd64" + if len(d.Architectures) > 0 { + arch = d.Architectures[0] + } + ver := "latest" + if d.Resolve != nil { + if rel, err := d.Resolve(arch, ""); err == nil { + ver = rel.Version + } else if errors.Is(err, ErrNotImplemented) { + ver = "planned" + } + } + out = append(out, ListEntry{ + ID: d.ID, + Version: ver, + ApproxSize: d.ApproxSize, + Architectures: strings.Join(d.Architectures, ", "), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +// WriteList prints the catalog table. +func WriteList(w io.Writer, entries []ListEntry) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DISTRO\tVERSION\tSIZE\tARCHITECTURES") + for _, e := range entries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.ID, e.Version, e.ApproxSize, e.Architectures) + } + return tw.Flush() +} + +// DiskType classifies block devices. +type DiskType string + +// Disk classification labels. +const ( + DiskSystem DiskType = "SYSTEM" + DiskUSB DiskType = "USB" +) + +// Disk describes a block device for lab iso disks. +type Disk struct { + Device string + Size string + Model string + Tran string + Type DiskType +} + +// ClassifyDisk determines SYSTEM vs USB from lsblk fields. +func ClassifyDisk(rm bool, tran string) DiskType { + tr := strings.ToLower(strings.TrimSpace(tran)) + if rm || tr == "usb" { + return DiskUSB + } + return DiskSystem +} + +// FormatDiskLine returns a display line for a disk. +func FormatDiskLine(d Disk) string { + tag := string(d.Type) + switch d.Type { + case DiskUSB: + tag += " ✅" + case DiskSystem: + tag += " ⚠️" + } + return fmt.Sprintf("%-12s %-8s %-24s %-6s %s", d.Device, d.Size, d.Model, d.Tran, tag) +} diff --git a/internal/iso/catalog_test.go b/internal/iso/catalog_test.go new file mode 100644 index 0000000..7f6532f --- /dev/null +++ b/internal/iso/catalog_test.go @@ -0,0 +1,49 @@ +package iso_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/iso" + "github.com/stretchr/testify/require" +) + +const sampleLSBLK = `{ + "blockdevices": [ + {"name":"sda","size":"500G","model":"Samsung SSD 860","tran":"sata","rm":false,"type":"disk","vendor":""}, + {"name":"nvme0n1","size":"1.0T","model":"WD_BLACK SN770","tran":"nvme","rm":false,"type":"disk","vendor":""}, + {"name":"sdb","size":"58G","model":"SanDisk Ultra USB 3.0","tran":"usb","rm":true,"type":"disk","vendor":""} + ] +}` + +func TestParseLSBLKJSON_classifiesUSB(t *testing.T) { + disks, err := iso.ParseLSBLKJSON(sampleLSBLK) + require.NoError(t, err) + require.Len(t, disks, 3) + + require.Equal(t, iso.DiskSystem, disks[0].Type) + require.Equal(t, "/dev/sda", disks[0].Device) + + require.Equal(t, iso.DiskSystem, disks[1].Type) + + require.Equal(t, iso.DiskUSB, disks[2].Type) + require.Equal(t, "usb", disks[2].Tran) +} + +func TestClassifyDisk(t *testing.T) { + require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(true, "sata")) + require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(false, "usb")) + require.Equal(t, iso.DiskSystem, iso.ClassifyDisk(false, "nvme")) +} + +func TestLookupDistro(t *testing.T) { + d, ok := iso.LookupDistro("ubuntu-desktop") + require.True(t, ok) + require.Equal(t, "ubuntu-desktop", d.ID) +} + +func TestParseSHA256SUMS(t *testing.T) { + content := "abc123 ubuntu-24.04.3-desktop-amd64.iso\n" + hash, ok := iso.ParseSHA256SUMS(content, "ubuntu-24.04.3-desktop-amd64.iso") + require.True(t, ok) + require.Equal(t, "abc123", hash) +} diff --git a/internal/iso/closer.go b/internal/iso/closer.go new file mode 100644 index 0000000..94769ff --- /dev/null +++ b/internal/iso/closer.go @@ -0,0 +1,18 @@ +package iso + +import ( + "io" + "net/http" +) + +func closeBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func closeFile(c io.Closer) { + if c != nil { + _ = c.Close() + } +} diff --git a/internal/iso/disks_linux.go b/internal/iso/disks_linux.go new file mode 100644 index 0000000..7ed51de --- /dev/null +++ b/internal/iso/disks_linux.go @@ -0,0 +1,97 @@ +//go:build linux + +package iso + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// ListDisks returns block devices with USB/SYSTEM classification. +func ListDisks(ctx context.Context, runner exec.Runner) ([]Disk, error) { + if runner == nil { + runner = exec.NewOSRunner(os.Stdout, os.Stderr) + } + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-o", "NAME,SIZE,MODEL,TRAN,RM,MOUNTPOINT,VENDOR") + if err != nil { + return nil, err + } + return ParseLSBLKJSON(out) +} + +// WriteDisksTable prints disk listing. +func WriteDisksTable(w io.Writer, disks []Disk) { + fmt.Fprintf(w, "%-12s %-8s %-24s %-6s %s\n", "DEVICE", "SIZE", "MODEL", "TRAN", "TYPE") + for _, d := range disks { + fmt.Fprintln(w, FormatDiskLine(d)) + if d.Type == DiskSystem { + fmt.Fprintf(w, " ⚠️ SYSTEM disk — do NOT write ISOs to %s\n", d.Device) + } + } +} + +// InspectDevice reads lsblk metadata for a single device. +func InspectDevice(ctx context.Context, runner exec.Runner, device string) (DeviceInfo, error) { + name := strings.TrimPrefix(device, "/dev/") + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-n", "-o", "NAME,SIZE,MODEL,TRAN,RM,MOUNTPOINT", name) + if err != nil { + return DeviceInfo{}, err + } + var payload lsblkRoot + if err := json.Unmarshal([]byte(out), &payload); err != nil { + return DeviceInfo{}, err + } + if len(payload.BlockDevices) == 0 { + return DeviceInfo{}, fmt.Errorf("device %s not found", device) + } + dev := payload.BlockDevices[0] + var mps []string + collectMountpoints(dev, &mps) + return DeviceInfo{ + Device: device, + Size: dev.Size, + Model: dev.Model, + Tran: dev.Tran, + RM: dev.RM, + Mountpoints: mps, + }, nil +} + +// DeviceInfo holds metadata for write safety checks. +type DeviceInfo struct { + Device string + Size string + Model string + Tran string + RM bool + Mountpoints []string +} + +func unmountDevice(ctx context.Context, runner exec.Runner, device string) error { + name := strings.TrimPrefix(device, "/dev/") + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-n", "-o", "NAME,MOUNTPOINT", name) + if err != nil { + return err + } + var payload lsblkRoot + if err := json.Unmarshal([]byte(out), &payload); err != nil { + return err + } + var parts []string + for _, d := range payload.BlockDevices { + collectPartNames(d, &parts) + } + for _, p := range parts { + if p == "" { + continue + } + _ = runner.Run(ctx, "umount", "/dev/"+p) + } + return nil +} diff --git a/internal/iso/disks_stub.go b/internal/iso/disks_stub.go new file mode 100644 index 0000000..cc54e7d --- /dev/null +++ b/internal/iso/disks_stub.go @@ -0,0 +1,43 @@ +//go:build !linux + +package iso + +import ( + "context" + "fmt" + "io" + + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// ListDisks is only supported on Linux. +func ListDisks(ctx context.Context, runner exec.Runner) ([]Disk, error) { + return nil, fmt.Errorf("lab iso disks: %w", clierrors.ErrNotImplemented) +} + +// WriteDisksTable prints disk listing. +func WriteDisksTable(w io.Writer, disks []Disk) { + for _, d := range disks { + fmt.Fprintln(w, FormatDiskLine(d)) + } +} + +// InspectDevice is only supported on Linux. +func InspectDevice(ctx context.Context, runner exec.Runner, device string) (DeviceInfo, error) { + return DeviceInfo{}, fmt.Errorf("lab iso write: %w", clierrors.ErrNotImplemented) +} + +// DeviceInfo holds metadata for write safety checks. +type DeviceInfo struct { + Device string + Size string + Model string + Tran string + RM bool + Mountpoints []string +} + +func unmountDevice(ctx context.Context, runner exec.Runner, device string) error { + return nil +} diff --git a/internal/iso/download.go b/internal/iso/download.go new file mode 100644 index 0000000..ca25308 --- /dev/null +++ b/internal/iso/download.go @@ -0,0 +1,221 @@ +package iso + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// DownloadOptions configures ISO download. +type DownloadOptions struct { + Arch string + Version string + OutputDir string + NoVerify bool + Force bool + Stdout io.Writer + Client *http.Client +} + +// DownloadResult holds the verified ISO path. +type DownloadResult struct { + Path string +} + +// DownloadISO resolves, downloads, and verifies an ISO. +func DownloadISO(ctx context.Context, distro Distro, opts DownloadOptions) (DownloadResult, error) { + if opts.Client == nil { + opts.Client = &http.Client{Timeout: 30 * time.Minute} + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.OutputDir == "" { + home, _ := os.UserHomeDir() + opts.OutputDir = filepath.Join(home, ".cache", "homelab-cli", "iso") + } + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return DownloadResult{}, err + } + + rel, err := distro.Resolve(opts.Arch, opts.Version) + if err != nil { + return DownloadResult{}, err + } + + dest := filepath.Join(opts.OutputDir, rel.ISOFilename) + if !opts.Force { + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + if ok, _ := verifyLocalSHA256(dest, rel); ok { + fmt.Fprintf(opts.Stdout, "already cached: %s\n", dest) + return DownloadResult{Path: dest}, nil + } + } + } + + fmt.Fprintf(opts.Stdout, "Downloading %s\n", rel.ISOURL) + if err := downloadFile(ctx, opts.Client, rel.ISOURL, dest, opts.Stdout); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout) + + sumData, err := fetchBytes(ctx, opts.Client, rel.ChecksumURL) + if err != nil { + return DownloadResult{}, fmt.Errorf("checksum file: %w", err) + } + want, ok := findSHA256(string(sumData), rel.ISOFilename) + if !ok { + // Fedora CHECKSUM format + want, ok = findFedoraChecksum(string(sumData), rel.ISOFilename) + } + if !ok { + return DownloadResult{}, fmt.Errorf("checksum entry not found for %s", rel.ISOFilename) + } + if err := verifyFileSHA256(dest, want); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout, "SHA256 verified") + + if !opts.NoVerify && rel.GPGKeyURL != "" { + if err := VerifyGPG(ctx, rel); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout, "GPG signature verified") + } + + fmt.Fprintf(opts.Stdout, "Verified ISO: %s\n", dest) + return DownloadResult{Path: dest}, nil +} + +func downloadFile(ctx context.Context, client *http.Client, url, dest string, progress io.Writer) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer closeFile(f) + + pr := &byteProgress{w: progress, total: resp.ContentLength} + if _, err := io.Copy(f, io.TeeReader(resp.Body, pr)); err != nil { + return err + } + pr.finish() + return nil +} + +type byteProgress struct { + w io.Writer + total int64 + read int64 + last time.Time + lastPct int +} + +func (p *byteProgress) Write(b []byte) (int, error) { + n := len(b) + p.read += int64(n) + now := time.Now() + if p.w != nil && (p.last.IsZero() || now.Sub(p.last) >= 500*time.Millisecond) { + p.last = now + if p.total > 0 { + pct := int(p.read * 100 / p.total) + if pct != p.lastPct { + fmt.Fprintf(p.w, "\rProgress: %d%% (%d / %d bytes)", pct, p.read, p.total) + p.lastPct = pct + } + } else { + fmt.Fprintf(p.w, "\rDownloaded %d bytes", p.read) + } + } + return n, nil +} + +func (p *byteProgress) finish() { + if p.w != nil { + fmt.Fprintln(p.w) + } +} + +func fetchBytes(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 + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +func verifyLocalSHA256(_ string, _ Release) (bool, error) { + // Without network, can't verify - return false to trigger redownload + return false, nil +} + +func findSHA256(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := strings.TrimPrefix(parts[1], "*") + if name == filename { + return parts[0], true + } + } + return "", false +} + +func findFedoraChecksum(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, filename) && strings.Contains(line, "SHA256") { + // SHA256 (filename.iso) = abc... + if i := strings.Index(line, "= "); i >= 0 { + return strings.TrimSpace(line[i+2:]), true + } + } + } + return "", false +} + +func verifyFileSHA256(path, want string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer closeFile(f) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("sha256 mismatch: got %s want %s", got, want) + } + return nil +} diff --git a/internal/iso/fedora.go b/internal/iso/fedora.go new file mode 100644 index 0000000..67bd278 --- /dev/null +++ b/internal/iso/fedora.go @@ -0,0 +1,109 @@ +package iso + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +const ( + fedoraReleaseAPI = "https://bodhi.fedoraproject.org/releases/?state=current" + fedoraBase = "https://download.fedoraproject.org/pub/fedora/linux/releases/" +) + +var fedoraISORe = regexp.MustCompile(`Fedora-Silverblue-ostree-(x86_64|aarch64)-(\d+)-1\.(\d+)\.iso`) + +func resolveFedoraSilverblue(arch, version string) (Release, error) { + if arch == "" { + arch = "amd64" + } + farch := "x86_64" + if arch == "arm64" { + farch = "aarch64" + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + release := strings.TrimSpace(version) + if release == "" { + var err error + release, err = currentFedoraRelease(ctx) + if err != nil { + release = "41" // fallback pin + } + } + + isoDir := fmt.Sprintf("%s%s/Silverblue/%s/iso/", fedoraBase, release, farch) + client := &http.Client{Timeout: 2 * time.Minute} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, isoDir, nil) + if err != nil { + return Release{}, err + } + resp, err := client.Do(req) + if err != nil { + return Release{}, err + } + defer closeBody(resp) + body, err := io.ReadAll(resp.Body) + if err != nil { + return Release{}, err + } + + var isoFile, subrelease string + for _, m := range fedoraISORe.FindAllStringSubmatch(string(body), -1) { + if m[1] != farch { + continue + } + isoFile = m[0] + subrelease = m[3] + break + } + if isoFile == "" { + return Release{}, fmt.Errorf("no Silverblue ISO found for Fedora %s %s", release, farch) + } + + checksumFile := fmt.Sprintf("Fedora-Silverblue-%s-%s-%s-CHECKSUM", release, subrelease, farch) + return Release{ + Version: release, + ISOURL: isoDir + isoFile, + ChecksumURL: isoDir + checksumFile, + GPGKeyURL: "", + ISOFilename: isoFile, + }, nil +} + +func currentFedoraRelease(ctx context.Context) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fedoraReleaseAPI, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("bodhi API: HTTP %d", resp.StatusCode) + } + + var releases []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", err + } + for _, r := range releases { + name := strings.TrimSpace(r.Name) + if name != "" { + return name, nil + } + } + return "", fmt.Errorf("no current fedora release from bodhi") +} diff --git a/internal/iso/lsblk.go b/internal/iso/lsblk.go new file mode 100644 index 0000000..56177a6 --- /dev/null +++ b/internal/iso/lsblk.go @@ -0,0 +1,66 @@ +package iso + +import ( + "encoding/json" + "fmt" + "strings" +) + +type lsblkRoot struct { + BlockDevices []lsblkNode `json:"blockdevices"` +} + +type lsblkNode struct { + Name string `json:"name"` + Size string `json:"size"` + Model string `json:"model"` + Tran string `json:"tran"` + RM bool `json:"rm"` + Type string `json:"type"` + Vendor string `json:"vendor"` + Mountpoint *string `json:"mountpoint"` + Children []lsblkNode `json:"children"` +} + +// ParseLSBLKJSON parses lsblk JSON output into Disk entries. +func ParseLSBLKJSON(raw string) ([]Disk, error) { + var payload lsblkRoot + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("parse lsblk JSON: %w", err) + } + var disks []Disk + for _, dev := range payload.BlockDevices { + if dev.Type != "disk" { + continue + } + model := strings.TrimSpace(dev.Model) + if model == "" { + model = strings.TrimSpace(dev.Vendor) + } + diskType := ClassifyDisk(dev.RM, dev.Tran) + disks = append(disks, Disk{ + Device: "/dev/" + dev.Name, + Size: dev.Size, + Model: model, + Tran: dev.Tran, + Type: diskType, + }) + } + return disks, nil +} + +func collectMountpoints(dev lsblkNode, out *[]string) { + if dev.Mountpoint != nil && *dev.Mountpoint != "" { + *out = append(*out, *dev.Mountpoint) + } + for _, c := range dev.Children { + collectMountpoints(c, out) + } +} + +func collectPartNames(dev lsblkNode, out *[]string) { + *out = append(*out, dev.Name) + for _, c := range dev.Children { + collectPartNames(c, out) + } +} diff --git a/internal/iso/size.go b/internal/iso/size.go new file mode 100644 index 0000000..5da048b --- /dev/null +++ b/internal/iso/size.go @@ -0,0 +1,45 @@ +package iso + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseSizeBytes converts lsblk size strings (e.g. 58G, 500M) to bytes. +func ParseSizeBytes(size string) (int64, error) { + size = strings.TrimSpace(size) + if size == "" { + return 0, fmt.Errorf("empty size") + } + units := map[byte]int64{ + 'K': 1024, + 'M': 1024 * 1024, + 'G': 1024 * 1024 * 1024, + 'T': 1024 * 1024 * 1024 * 1024, + } + last := size[len(size)-1] + if u, ok := units[last]; ok { + numStr := strings.TrimSpace(size[:len(size)-1]) + f, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, err + } + return int64(f * float64(u)), nil + } + return strconv.ParseInt(size, 10, 64) +} + +const minUSBCapacity = 4 * 1024 * 1024 * 1024 // 4 GiB + +// ValidateDeviceCapacity rejects devices smaller than min USB size. +func ValidateDeviceCapacity(size string) error { + bytes, err := ParseSizeBytes(size) + if err != nil { + return err + } + if bytes < minUSBCapacity { + return fmt.Errorf("device size %s is below 4 GiB minimum for ISO writes", size) + } + return nil +} diff --git a/internal/iso/ubuntu.go b/internal/iso/ubuntu.go new file mode 100644 index 0000000..dbe14dd --- /dev/null +++ b/internal/iso/ubuntu.go @@ -0,0 +1,101 @@ +package iso + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +const ubuntuBase = "https://releases.ubuntu.com/" + +var ubuntuDirRe = regexp.MustCompile(`(?m)`) + +func resolveUbuntuDesktop(arch, version string) (Release, error) { + if arch == "" { + arch = "amd64" + } + if arch != "amd64" { + return Release{}, fmt.Errorf("ubuntu-desktop: only amd64 supported") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + ver := strings.TrimSpace(version) + if ver == "" { + var err error + ver, err = latestUbuntuLTSVersion(ctx) + if err != nil { + return Release{}, err + } + } + + base := ubuntuBase + ver + "/" + isoFile := fmt.Sprintf("ubuntu-%s-desktop-%s.iso", ver, arch) + return Release{ + Version: ver + " LTS", + ISOURL: base + isoFile, + ChecksumURL: base + "SHA256SUMS", + GPGKeyURL: base + "SHA256SUMS.gpg", + ISOFilename: isoFile, + }, nil +} + +func latestUbuntuLTSVersion(ctx context.Context) (string, error) { + client := &http.Client{Timeout: 2 * time.Minute} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ubuntuBase, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer closeBody(resp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var versions []string + for _, m := range ubuntuDirRe.FindAllStringSubmatch(string(data), -1) { + v := m[1] + if strings.HasPrefix(v, "24.") || strings.HasPrefix(v, "22.") { + versions = append(versions, v) + } + } + if len(versions) == 0 { + return "", fmt.Errorf("no ubuntu LTS version found in index") + } + // Pick highest semver-ish + best := versions[0] + for _, v := range versions[1:] { + if compareUbuntuVer(v, best) > 0 { + best = v + } + } + return best, nil +} + +func compareUbuntuVer(a, b string) int { + ap := strings.Split(a, ".") + bp := strings.Split(b, ".") + for i := 0; i < 3; i++ { + ai, bi := 0, 0 + if i < len(ap) { + ai, _ = strconv.Atoi(ap[i]) + } + if i < len(bp) { + bi, _ = strconv.Atoi(bp[i]) + } + if ai != bi { + return ai - bi + } + } + return 0 +} diff --git a/internal/iso/verify.go b/internal/iso/verify.go new file mode 100644 index 0000000..8946bf2 --- /dev/null +++ b/internal/iso/verify.go @@ -0,0 +1,88 @@ +package iso + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// VerifyGPG downloads checksums and verifies GPG signature using system gpg. +func VerifyGPG(ctx context.Context, rel Release) error { + if rel.GPGKeyURL == "" { + return nil + } + runner := exec.NewOSRunner(os.Stdout, os.Stderr) + tmp, err := os.MkdirTemp("", "lab-iso-gpg-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmp) }() + + client := &http.Client{Timeout: 2 * time.Minute} + sumPath := filepath.Join(tmp, "SHA256SUMS") + gpgPath := filepath.Join(tmp, "SHA256SUMS.gpg") + + sumData, err := fetchBytes(ctx, client, rel.ChecksumURL) + if err != nil { + return err + } + if err := os.WriteFile(sumPath, sumData, 0o644); err != nil { + return err + } + + gpgData, err := fetchBytes(ctx, client, rel.GPGKeyURL) + if err != nil { + return err + } + if err := os.WriteFile(gpgPath, gpgData, 0o644); err != nil { + return err + } + + if err := runner.Run(ctx, "gpg", "--verify", gpgPath, sumPath); err != nil { + return fmt.Errorf("gpg verify: %w", err) + } + return nil +} + +// ParseSHA256SUMS extracts hash for filename from Ubuntu-style SHA256SUMS. +func ParseSHA256SUMS(content, filename string) (string, bool) { + return findSHA256(content, filename) +} + +// ParseFedoraCHECKSUM extracts hash from Fedora CHECKSUM file. +func ParseFedoraCHECKSUM(content, filename string) (string, bool) { + return findFedoraChecksum(content, filename) +} + +// ReadISOSize returns human-readable size. +func ReadISOSize(path string) (string, error) { + st, err := os.Stat(path) + if err != nil { + return "", err + } + return formatBytes(st.Size()), nil +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// CopyWithProgress is a test hook placeholder. +func CopyWithProgress(dst io.Writer, src io.Reader) (int64, error) { + return io.Copy(dst, src) +} diff --git a/internal/iso/write.go b/internal/iso/write.go new file mode 100644 index 0000000..b830ed1 --- /dev/null +++ b/internal/iso/write.go @@ -0,0 +1,120 @@ +//go:build linux + +package iso + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// WriteOptions configures ISO write to block device. +type WriteOptions struct { + ISOPath string + Device string + Yes bool + Force bool + BlockSize string + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner + Confirm func(prompt string) (string, error) +} + +// WriteISO burns an ISO to a block device with safety checks. +func WriteISO(ctx context.Context, opts WriteOptions) error { + if opts.Runner == nil { + opts.Runner = exec.NewOSRunner(opts.Stdout, opts.Stderr) + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.BlockSize == "" { + opts.BlockSize = "4M" + } + + device := opts.Device + if !strings.HasPrefix(device, "/dev/") { + device = "/dev/" + strings.TrimPrefix(device, "/dev/") + } + + st, err := os.Stat(device) + if err != nil { + return fmt.Errorf("device %s: %w", device, err) + } + if st.Mode()&os.ModeDevice == 0 { + return fmt.Errorf("%s is not a block device", device) + } + + info, err := InspectDevice(ctx, opts.Runner, device) + if err != nil { + return err + } + + if err := ValidateDeviceCapacity(info.Size); err != nil && !opts.Force { + return err + } + + diskType := ClassifyDisk(info.RM, info.Tran) + if !opts.Force && diskType == DiskSystem { + return fmt.Errorf("refusing to write to system disk %s (TRAN=%s RM=%v); use --force to override", device, info.Tran, info.RM) + } + if len(info.Mountpoints) > 0 && !opts.Force { + return fmt.Errorf("device %s has mounted partitions: %v; unmount first or use --force", device, info.Mountpoints) + } + + isoSize, err := ReadISOSize(opts.ISOPath) + if err != nil { + return err + } + + fmt.Fprintf(opts.Stdout, "About to write to:\n") + fmt.Fprintf(opts.Stdout, " Device: %s\n", device) + fmt.Fprintf(opts.Stdout, " Model: %s\n", info.Model) + fmt.Fprintf(opts.Stdout, " Size: %s\n", info.Size) + fmt.Fprintf(opts.Stdout, " Source: %s (%s)\n\n", opts.ISOPath, isoSize) + fmt.Fprintf(opts.Stdout, "⚠️ ALL DATA ON %s WILL BE DESTROYED.\n\n", device) + + if !opts.Yes { + prompt := fmt.Sprintf("Type %q to confirm: ", device) + var answer string + if opts.Confirm != nil { + answer, err = opts.Confirm(prompt) + } else { + fmt.Fprint(opts.Stdout, prompt) + _, err = fmt.Scanln(&answer) + } + if err != nil { + return err + } + if answer != device { + return fmt.Errorf("confirmation mismatch: expected %q", device) + } + } + + if err := unmountDevice(ctx, opts.Runner, device); err != nil { + return err + } + + args := []string{ + "if=" + opts.ISOPath, + "of=" + device, + "bs=" + opts.BlockSize, + "status=progress", + "conv=fdatasync", + "oflag=direct", + } + if err := opts.Runner.Run(ctx, "dd", args...); err != nil { + return fmt.Errorf("dd: %w", err) + } + if err := opts.Runner.Run(ctx, "sync"); err != nil { + return err + } + + fmt.Fprintln(opts.Stdout, "Done. You can now unplug the USB drive and boot from it.") + return nil +} diff --git a/internal/iso/write_stub.go b/internal/iso/write_stub.go new file mode 100644 index 0000000..9cb1c1e --- /dev/null +++ b/internal/iso/write_stub.go @@ -0,0 +1,30 @@ +//go:build !linux + +package iso + +import ( + "context" + "fmt" + "io" + + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// WriteOptions configures ISO write to block device. +type WriteOptions struct { + ISOPath string + Device string + Yes bool + Force bool + BlockSize string + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner + Confirm func(prompt string) (string, error) +} + +// WriteISO burns an ISO to a block device (Linux only). +func WriteISO(ctx context.Context, opts WriteOptions) error { + return fmt.Errorf("lab iso write: %w", clierrors.ErrNotImplemented) +} diff --git a/internal/media/heic.go b/internal/media/heic.go index f1600b2..c6659cf 100644 --- a/internal/media/heic.go +++ b/internal/media/heic.go @@ -1,3 +1,4 @@ +// Package media provides local media conversion helpers. package media import ( diff --git a/internal/pkgmgr/apt.go b/internal/pkgmgr/apt.go new file mode 100644 index 0000000..dd4f02e --- /dev/null +++ b/internal/pkgmgr/apt.go @@ -0,0 +1,76 @@ +package pkgmgr + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// APT implements apt-get on Debian/Ubuntu. +type APT struct { + Runner exec.Runner + Sudo bool +} + +// Name returns the manager identifier. +func (a *APT) Name() string { return "apt" } + +// Available reports whether apt is usable. +func (a *APT) Available() bool { return true } + +// UpdateCache runs apt-get update. +func (a *APT) UpdateCache(ctx context.Context) error { + return a.run(ctx, "apt-get", "update") +} + +// Install installs packages via apt-get. +func (a *APT) Install(ctx context.Context, packages ...string) error { + if len(packages) == 0 { + return nil + } + args := []string{"apt-get", "install", "-y", "--no-install-recommends"} + args = append(args, packages...) + return a.run(ctx, args[0], args[1:]...) +} + +// IsInstalled checks dpkg -s exit status. +func (a *APT) IsInstalled(ctx context.Context, pkg string) (bool, error) { + err := a.Runner.Run(ctx, "dpkg", "-s", pkg) + return err == nil, nil +} + +func (a *APT) run(ctx context.Context, name string, args ...string) error { + if a.Sudo { + return a.Runner.Run(ctx, "sudo", append([]string{name}, args...)...) + } + return a.Runner.Run(ctx, name, args...) +} + +// BuildAPTCommand returns the argv for apt install (for tests). +func BuildAPTCommand(packages ...string) []string { + args := []string{"apt-get", "install", "-y", "--no-install-recommends"} + return append(args, packages...) +} + +// ParseDPKGInstalled interprets dpkg -s exit as installed state. +func ParseDPKGInstalled(err error) bool { + return err == nil +} + +// JoinPackages joins package names for logging. +func JoinPackages(packages ...string) string { + return strings.Join(packages, " ") +} + +// RequireRunner ensures runner is set. +func RequireRunner(r exec.Runner) exec.Runner { + if r == nil { + panic("nil runner") + } + return r +} + +// ErrUnavailable indicates no supported manager was found. +var ErrUnavailable = fmt.Errorf("no supported package manager detected") diff --git a/internal/pkgmgr/apt_test.go b/internal/pkgmgr/apt_test.go new file mode 100644 index 0000000..62090f7 --- /dev/null +++ b/internal/pkgmgr/apt_test.go @@ -0,0 +1,31 @@ +package pkgmgr_test + +import ( + "context" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/stretchr/testify/require" +) + +type recordingRunner struct { + calls []string +} + +func (r *recordingRunner) Run(_ context.Context, name string, args ...string) error { + r.calls = append(r.calls, name+" "+strings.Join(args, " ")) + return nil +} + +func (r *recordingRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + return "", r.Run(ctx, name, args...) +} + +func TestAPT_Install_buildsExpectedArgs(t *testing.T) { + rec := &recordingRunner{} + apt := &pkgmgr.APT{Runner: rec, Sudo: true} + require.NoError(t, apt.Install(context.Background(), "git", "curl")) + require.Len(t, rec.calls, 1) + require.Contains(t, rec.calls[0], "apt-get install -y --no-install-recommends git curl") +} diff --git a/internal/pkgmgr/detect.go b/internal/pkgmgr/detect.go new file mode 100644 index 0000000..03b896a --- /dev/null +++ b/internal/pkgmgr/detect.go @@ -0,0 +1,87 @@ +package pkgmgr + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// OSType identifies bootstrap targets. +type OSType string + +// Bootstrap OS targets. +const ( + OSUbuntu OSType = "ubuntu" + OSSilverblue OSType = "silverblue" +) + +// Detect reads /etc/os-release and returns a Manager. +func Detect(runner exec.Runner) (Manager, OSType, error) { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return nil, "", ErrUnavailable + } + osType, mgrName := parseOSRelease(data) + switch mgrName { + case "apt": + return &APT{Runner: runner, Sudo: true}, osType, nil + case "rpm-ostree": + return &RPMOstree{Runner: runner}, osType, nil + default: + return nil, osType, ErrUnavailable + } +} + +// ParseOSRelease classifies os-release content (exported for tests). +func ParseOSRelease(content []byte) (OSType, string) { + return parseOSRelease(content) +} + +func parseOSRelease(data []byte) (OSType, string) { + vals := map[string]string{} + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := sc.Text() + if i := strings.IndexByte(line, '='); i > 0 { + key := line[:i] + val := strings.Trim(strings.TrimSpace(line[i+1:]), `"`) + vals[key] = val + } + } + + id := strings.ToLower(vals["ID"]) + variant := strings.ToLower(vals["VARIANT_ID"]) + name := strings.ToLower(vals["NAME"]) + + if variant == "silverblue" || variant == "sericea" || strings.Contains(name, "silverblue") { + return OSSilverblue, "rpm-ostree" + } + if id == "ubuntu" { + return OSUbuntu, "apt" + } + if id == "debian" { + return OSType("debian"), "apt" + } + if id == "fedora" { + return OSSilverblue, "rpm-ostree" + } + return OSType(id), "" +} + +// DetectTarget resolves --target auto|ubuntu|silverblue. +func DetectTarget(target string, runner exec.Runner) (Manager, OSType, error) { + switch strings.ToLower(strings.TrimSpace(target)) { + case "", "auto": + return Detect(runner) + case "ubuntu": + return &APT{Runner: runner, Sudo: true}, OSUbuntu, nil + case "silverblue": + return &RPMOstree{Runner: runner}, OSSilverblue, nil + default: + return nil, "", fmt.Errorf("unsupported target %q", target) + } +} diff --git a/internal/pkgmgr/detect_test.go b/internal/pkgmgr/detect_test.go new file mode 100644 index 0000000..604425d --- /dev/null +++ b/internal/pkgmgr/detect_test.go @@ -0,0 +1,49 @@ +package pkgmgr_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/stretchr/testify/require" +) + +func TestParseOSRelease_ubuntu(t *testing.T) { + content := []byte(`NAME="Ubuntu" +ID=ubuntu +VERSION_ID="24.04" +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSUbuntu, osType) + require.Equal(t, "apt", mgr) +} + +func TestParseOSRelease_silverblue(t *testing.T) { + content := []byte(`NAME="Fedora Linux" +ID=fedora +VARIANT_ID=silverblue +VERSION_ID=41 +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSSilverblue, osType) + require.Equal(t, "rpm-ostree", mgr) +} + +func TestParseOSRelease_debian(t *testing.T) { + content := []byte(`NAME="Debian GNU/Linux" +ID=debian +VERSION_ID="12" +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSType("debian"), osType) + require.Equal(t, "apt", mgr) +} + +func TestBuildAPTCommand(t *testing.T) { + cmd := pkgmgr.BuildAPTCommand("git", "curl") + require.Equal(t, []string{"apt-get", "install", "-y", "--no-install-recommends", "git", "curl"}, cmd) +} + +func TestBuildRPMOstreeCommand(t *testing.T) { + cmd := pkgmgr.BuildRPMOstreeCommand("distrobox") + require.Equal(t, []string{"install", "--idempotent", "-y", "distrobox"}, cmd) +} diff --git a/internal/pkgmgr/manager.go b/internal/pkgmgr/manager.go new file mode 100644 index 0000000..68fce48 --- /dev/null +++ b/internal/pkgmgr/manager.go @@ -0,0 +1,15 @@ +// Package pkgmgr abstracts native package managers for bootstrap. +package pkgmgr + +import ( + "context" +) + +// Manager installs packages on a target OS. +type Manager interface { + Name() string + Available() bool + UpdateCache(ctx context.Context) error + Install(ctx context.Context, packages ...string) error + IsInstalled(ctx context.Context, pkg string) (bool, error) +} diff --git a/internal/pkgmgr/rpm_ostree.go b/internal/pkgmgr/rpm_ostree.go new file mode 100644 index 0000000..09dcdd5 --- /dev/null +++ b/internal/pkgmgr/rpm_ostree.go @@ -0,0 +1,65 @@ +package pkgmgr + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// RPMOstree implements Fedora Silverblue layering. +type RPMOstree struct { + Runner exec.Runner +} + +// Name returns the manager identifier. +func (r *RPMOstree) Name() string { return "rpm-ostree" } + +// Available reports whether rpm-ostree is usable. +func (r *RPMOstree) Available() bool { return true } + +// UpdateCache is a no-op for rpm-ostree. +func (r *RPMOstree) UpdateCache(_ context.Context) error { + // rpm-ostree has no separate cache refresh for installs + return nil +} + +// Install layers packages via rpm-ostree install --idempotent. +func (r *RPMOstree) Install(ctx context.Context, packages ...string) error { + if len(packages) == 0 { + return nil + } + args := append([]string{"install", "--idempotent", "-y"}, packages...) + return r.Runner.Run(ctx, "rpm-ostree", args...) +} + +// IsInstalled checks rpm -q exit status. +func (r *RPMOstree) IsInstalled(ctx context.Context, pkg string) (bool, error) { + err := r.Runner.Run(ctx, "rpm", "-q", pkg) + return err == nil, nil +} + +// BuildRPMOstreeCommand returns argv for install (for tests). +func BuildRPMOstreeCommand(packages ...string) []string { + args := []string{"install", "--idempotent", "-y"} + return append(args, packages...) +} + +// NeedsRebootHint is printed after rpm-ostree layers packages. +const NeedsRebootHint = "rpm-ostree changes require a reboot before new packages are available" + +// FormatRebootWarning returns a user-facing reboot notice. +func FormatRebootWarning(section string) string { + return fmt.Sprintf("[%s] %s", section, NeedsRebootHint) +} + +// StripGroup keeps @group names intact for rpm. +func StripGroup(pkg string) string { + return strings.TrimSpace(pkg) +} + +// NewRPMOstree returns a Silverblue manager. +func NewRPMOstree(runner exec.Runner) *RPMOstree { + return &RPMOstree{Runner: runner} +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 52bad6f..efa8f1f 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -45,18 +45,19 @@ func Detect() Info { info.Packager = PackagerUnknown } case OSLinux: - if isSilverblue() { + switch { + case 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") { + case hasCmd("apt-get"): info.Packager = PackagerAPT - } else if hasCmd("dnf") { + case hasCmd("dnf"): info.Packager = PackagerDNF - } else { + default: info.Packager = PackagerUnknown } default: diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index ce7ffef..1247d02 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -14,6 +14,7 @@ func TestDetect_returnsGOOS(t *testing.T) { } func TestDetect_packagerOnDarwinOrLinux(t *testing.T) { + t.Helper() info := platform.Detect() switch info.GOOS { case platform.OSDarwin: diff --git a/internal/postgres/apply.go b/internal/postgres/apply.go index 5a601b5..e8501ee 100644 --- a/internal/postgres/apply.go +++ b/internal/postgres/apply.go @@ -1,3 +1,4 @@ +// Package postgres applies homelab PostgreSQL instance YAML. package postgres import ( @@ -43,7 +44,7 @@ func applyInstance(ctx context.Context, inst Instance, adminPassword string) err if err != nil { return fmt.Errorf("connect: %w", err) } - defer conn.Close(ctx) + defer func() { _ = conn.Close(ctx) }() for _, u := range inst.Users { if err := ensureUser(ctx, conn, u); err != nil { @@ -87,7 +88,7 @@ func ensureDatabase(ctx context.Context, inst Instance, adminPassword string, d if err != nil { return err } - defer admin.Close(ctx) + defer func() { _ = 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 @@ -108,7 +109,7 @@ func grantConnect(ctx context.Context, inst Instance, adminPassword, dbName, use if err != nil { return err } - defer conn.Close(ctx) + defer func() { _ = conn.Close(ctx) }() sql := fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO %s`, quoteIdent(dbName), quoteIdent(user)) _, err = conn.Exec(ctx, sql) return err diff --git a/internal/server/deploy.go b/internal/server/deploy.go index 1d8660e..84d6cd8 100644 --- a/internal/server/deploy.go +++ b/internal/server/deploy.go @@ -14,10 +14,14 @@ import ( type DeployMode string const ( - DeploySync DeployMode = "sync" + // DeploySync rsync only. + DeploySync DeployMode = "sync" + // DeployProvision rsync plus postgres apply. DeployProvision DeployMode = "provision" - DeployCompose DeployMode = "compose" - DeployFull DeployMode = "full" + // DeployCompose rsync plus remote compose up. + DeployCompose DeployMode = "compose" + // DeployFull provision and compose. + DeployFull DeployMode = "full" ) // Deploy syncs homelab to server and optionally runs provision / compose. diff --git a/internal/system/discover.go b/internal/system/discover.go index a1a31af..4037a04 100644 --- a/internal/system/discover.go +++ b/internal/system/discover.go @@ -12,19 +12,19 @@ import ( ) const ( - ubuntuMetaLTS = "https://changelogs.ubuntu.com/meta-release-lts" - ubuntuMeta = "https://changelogs.ubuntu.com/meta-release" + 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 + 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)\*?`) @@ -160,6 +160,8 @@ func discoverFedoraSilverblue(ctx context.Context, client *http.Client) (BootIma } // ResolveBootImage finds a discovered image by distro flag (e.g. ubuntu-latest, ubuntu-lts-24.04, fedora-silverblue). +// +//revive:disable-next-line:unexported-return legacy helper returns internal isoSpec func ResolveBootImage(ctx context.Context, distro string) (isoSpec, string, error) { distro = strings.TrimSpace(strings.ToLower(distro)) if distro == "" { @@ -237,7 +239,7 @@ func fetchURL(ctx context.Context, client *http.Client, url string) ([]byte, err if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } diff --git a/internal/system/meta.go b/internal/system/meta.go index d2d6ea5..788d1f0 100644 --- a/internal/system/meta.go +++ b/internal/system/meta.go @@ -136,20 +136,20 @@ func parseApacheIndex(data []byte) []string { return names } -func latestNumericDir(data []byte, min int) (int, error) { - max := 0 +func latestNumericDir(data []byte, minVersion int) (int, error) { + highest := 0 for _, name := range parseApacheIndex(data) { name = strings.TrimSuffix(name, "/") n, err := strconv.Atoi(name) - if err != nil || n < min { + if err != nil || n < minVersion { continue } - if n > max { - max = n + if n > highest { + highest = n } } - if max == 0 { + if highest == 0 { return 0, fmt.Errorf("no release directories found") } - return max, nil + return highest, nil } diff --git a/internal/system/usb.go b/internal/system/usb.go index a3ee1c4..2f0adee 100644 --- a/internal/system/usb.go +++ b/internal/system/usb.go @@ -36,7 +36,6 @@ func CreateBootableUSB(ctx context.Context, opts USBOptions, stdout, stderr io.W 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) diff --git a/internal/toolchain/mise.go b/internal/toolchain/mise.go index 3ad7360..49872af 100644 --- a/internal/toolchain/mise.go +++ b/internal/toolchain/mise.go @@ -17,10 +17,10 @@ var knownLangs = map[string]string{ "node": "node", "nodejs": "node", "typescript": "node", "bun": "bun", "deno": "deno", "python": "python", "py": "python", - "rust": "rust", - "ruby": "ruby", - "java": "java", - "zig": "zig", + "rust": "rust", + "ruby": "ruby", + "java": "java", + "zig": "zig", "erlang": "erlang", "elixir": "elixir", "lua": "lua", } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index bd19a04..9b4480c 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -68,10 +68,12 @@ func OK(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.OK.Render("✓ "+msg)) } +// Warn prints a warning line. func Warn(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.Warn.Render("! "+msg)) } +// Fail prints an error line. func Fail(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.Err.Render("✗ "+msg)) } diff --git a/internal/updater/closer.go b/internal/updater/closer.go new file mode 100644 index 0000000..a8b0db7 --- /dev/null +++ b/internal/updater/closer.go @@ -0,0 +1,18 @@ +package updater + +import ( + "io" + "net/http" +) + +func closeBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func closeFile(c io.Closer) { + if c != nil { + _ = c.Close() + } +} diff --git a/internal/updater/github.go b/internal/updater/github.go new file mode 100644 index 0000000..1d440ec --- /dev/null +++ b/internal/updater/github.go @@ -0,0 +1,359 @@ +// Package updater handles self-update from GitHub releases. +package updater + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// GitHubRepo is the owner/repo slug used for release downloads. +const GitHubRepo = "bartrosa/homelab-cli" + +// ReleaseClient fetches release metadata and assets. +type ReleaseClient interface { + LatestRelease(ctx context.Context, includePrerelease bool) (*Release, error) + ReleaseByTag(ctx context.Context, tag string) (*Release, error) +} + +// HTTPClient implements ReleaseClient against the GitHub REST API. +type HTTPClient struct { + Repo string + Client *http.Client +} + +// Release is a GitHub release with downloadable assets. +type Release struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` +} + +// Asset is a release attachment. +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// NewHTTPClient returns a client with sensible defaults. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + Repo: GitHubRepo, + Client: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// LatestRelease returns the newest matching release. +func (c *HTTPClient) LatestRelease(ctx context.Context, includePrerelease bool) (*Release, error) { + if includePrerelease { + return c.latestFromList(ctx, true) + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", c.Repo) + return c.fetchRelease(ctx, url) +} + +// ReleaseByTag fetches a specific release tag. +func (c *HTTPClient) ReleaseByTag(ctx context.Context, tag string) (*Release, error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return nil, fmt.Errorf("empty version tag") + } + if !strings.HasPrefix(tag, "v") { + tag = "v" + tag + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", c.Repo, tag) + return c.fetchRelease(ctx, url) +} + +func (c *HTTPClient) latestFromList(ctx context.Context, includePrerelease bool) (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github releases: HTTP %d", resp.StatusCode) + } + + var releases []Release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + for i := range releases { + if !includePrerelease && releases[i].Prerelease { + continue + } + return &releases[i], nil + } + return nil, fmt.Errorf("no releases found") +} + +func (c *HTTPClient) fetchRelease(ctx context.Context, url string) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github release: HTTP %d", resp.StatusCode) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + return &rel, nil +} + +// SelectAsset picks the tar.gz asset for the current GOOS/GOARCH. +func SelectAsset(release *Release, goos, goarch string) (*Asset, error) { + if release == nil { + return nil, fmt.Errorf("nil release") + } + want := fmt.Sprintf("_%s_%s.tar.gz", goos, goarch) + for i := range release.Assets { + if strings.HasSuffix(release.Assets[i].Name, want) { + return &release.Assets[i], nil + } + } + return nil, fmt.Errorf("no asset matching %s for release %s", want, release.TagName) +} + +// Download fetches url to dest with optional progress. +func Download(ctx context.Context, client *http.Client, url, dest string, progress io.Writer) error { + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download: HTTP %d", resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer closeFile(f) + + var r io.Reader = resp.Body + if progress != nil { + r = &progressReader{r: resp.Body, w: progress, total: resp.ContentLength} + } + _, err = io.Copy(f, r) + return err +} + +type progressReader struct { + r io.Reader + w io.Writer + total int64 + read int64 + lastPct int +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + if p.total > 0 { + pct := int(p.read * 100 / p.total) + if pct != p.lastPct && pct%5 == 0 { + fmt.Fprintf(p.w, "\rDownloading... %d%%", pct) + p.lastPct = pct + } + } + return n, err +} + +// VerifyChecksum compares file hash against checksums.txt content. +func VerifyChecksum(checksumsContent, filename, filePath string) error { + want, ok := parseChecksumLine(checksumsContent, filename) + if !ok { + return fmt.Errorf("checksum for %q not found in checksums file", filename) + } + f, err := os.Open(filePath) + if err != nil { + return err + } + defer closeFile(f) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("checksum mismatch for %s: got %s want %s", filename, got, want) + } + return nil +} + +func parseChecksumLine(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := strings.TrimPrefix(parts[1], "*") + if name == filename || strings.HasSuffix(name, "/"+filename) { + return parts[0], true + } + } + return "", false +} + +// ExtractLabBinary extracts the lab binary from a goreleaser tar.gz to destPath. +func ExtractLabBinary(archivePath, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer closeFile(f) + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer closeFile(gz) + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if hdr.Typeflag != tar.TypeReg || hdr.Name != "lab" && !strings.HasSuffix(hdr.Name, "/lab") { + continue + } + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + closeFile(out) + return err + } + return out.Close() + } + return fmt.Errorf("lab binary not found in archive") +} + +// Replace atomically replaces target with newBinary (same filesystem when possible). +func Replace(target, newBinary string) error { + info, err := os.Stat(target) + if err != nil && !os.IsNotExist(err) { + return err + } + mode := os.FileMode(0o755) + if info != nil { + mode = info.Mode() + } + + dir := filepath.Dir(target) + tmp := filepath.Join(dir, ".lab-update-"+fmt.Sprintf("%d", os.Getpid())) + if err := copyFile(newBinary, tmp, mode); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + // Fallback: write beside as .new then rename + alt := target + ".new" + if err2 := copyFile(newBinary, alt, mode); err2 != nil { + return fmt.Errorf("replace %s: %w (fallback: %v)", target, err, err2) + } + if err3 := os.Rename(alt, target); err3 != nil { + return fmt.Errorf("replace %s: %w", target, err3) + } + } + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer closeFile(in) + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + closeFile(out) + return err + } + return out.Close() +} + +// CurrentExecutable returns the path to the running lab binary. +func CurrentExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +// NeedsSudo reports whether path is in a system directory and not writable. +func NeedsSudo(path string) bool { + if !strings.HasPrefix(path, "/usr/local/") && !strings.HasPrefix(path, "/usr/bin/") { + return false + } + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return true + } + _ = f.Close() + return false +} + +// Platform returns GOOS/GOARCH for asset selection. +func Platform() (goos, goarch string) { + return runtime.GOOS, runtime.GOARCH +} diff --git a/internal/updater/github_test.go b/internal/updater/github_test.go new file mode 100644 index 0000000..334b0ca --- /dev/null +++ b/internal/updater/github_test.go @@ -0,0 +1,91 @@ +package updater_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/stretchr/testify/require" +) + +type staticClient struct { + release *updater.Release +} + +func (s *staticClient) LatestRelease(_ context.Context, _ bool) (*updater.Release, error) { + return s.release, nil +} + +func (s *staticClient) ReleaseByTag(_ context.Context, _ string) (*updater.Release, error) { + return s.release, nil +} + +func TestHTTPClient_LatestRelease_parsesJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/repos/bartrosa/homelab-cli/releases/latest", r.URL.Path) + _ = json.NewEncoder(w).Encode(updater.Release{ + TagName: "v0.2.0", + Assets: []updater.Asset{ + {Name: "homelab-cli_v0.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "http://example/tar.gz"}, + {Name: "checksums.txt", BrowserDownloadURL: "http://example/checksums.txt"}, + }, + }) + })) + defer srv.Close() + + c := updater.NewHTTPClient() + c.Repo = "bartrosa/homelab-cli" + c.Client = srv.Client() + // Override URL by using custom transport - simpler: test SelectAsset with parsed release + rel := &updater.Release{ + TagName: "v0.2.0", + Assets: []updater.Asset{ + {Name: "homelab-cli_v0.2.0_linux_amd64.tar.gz"}, + {Name: "homelab-cli_v0.2.0_darwin_arm64.tar.gz"}, + }, + } + asset, err := updater.SelectAsset(rel, "linux", "amd64") + require.NoError(t, err) + require.Contains(t, asset.Name, "linux_amd64") +} + +func TestSelectAsset_darwin_arm64(t *testing.T) { + rel := &updater.Release{ + Assets: []updater.Asset{{Name: "homelab-cli_v0.2.0_darwin_arm64.tar.gz"}}, + } + asset, err := updater.SelectAsset(rel, "darwin", "arm64") + require.NoError(t, err) + require.Equal(t, "homelab-cli_v0.2.0_darwin_arm64.tar.gz", asset.Name) +} + +func TestVerifyChecksum(t *testing.T) { + content := "abc123 homelab-cli_v0.2.0_linux_amd64.tar.gz\n" + // won't verify real file - test parse via wrong path + err := updater.VerifyChecksum(content, "homelab-cli_v0.2.0_linux_amd64.tar.gz", "/nonexistent") + require.Error(t, err) +} + +func TestPerformUpdate_checkOnly_newer(t *testing.T) { + code, err := updater.PerformUpdate(context.Background(), "v0.1.0", updater.UpdateOptions{ + CheckOnly: true, + Client: &staticClient{release: &updater.Release{ + TagName: "v0.2.0", + }}, + }) + require.NoError(t, err) + require.Equal(t, 3, code) +} + +func TestPerformUpdate_checkOnly_upToDate(t *testing.T) { + code, err := updater.PerformUpdate(context.Background(), "v0.2.0", updater.UpdateOptions{ + CheckOnly: true, + Client: &staticClient{release: &updater.Release{ + TagName: "v0.2.0", + }}, + }) + require.NoError(t, err) + require.Equal(t, 0, code) +} diff --git a/internal/updater/replace.go b/internal/updater/replace.go new file mode 100644 index 0000000..af5fe2c --- /dev/null +++ b/internal/updater/replace.go @@ -0,0 +1,125 @@ +package updater + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// UpdateOptions configures a self-update run. +type UpdateOptions struct { + ForceVersion string + IncludePrerelease bool + Yes bool + CheckOnly bool + Stdout io.Writer + Stderr io.Writer + Client ReleaseClient +} + +// PerformUpdate checks for updates and optionally installs. +func PerformUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) (int, error) { + if opts.Client == nil { + opts.Client = NewHTTPClient() + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + + var release *Release + var err error + if opts.ForceVersion != "" { + release, err = opts.Client.ReleaseByTag(ctx, opts.ForceVersion) + } else { + release, err = opts.Client.LatestRelease(ctx, opts.IncludePrerelease) + } + if err != nil { + return 1, fmt.Errorf("fetch release: %w", err) + } + + remote := release.TagName + cmp := CompareVersions(currentVersion, remote) + if opts.CheckOnly { + if cmp >= 0 { + fmt.Fprintf(opts.Stdout, "already up to date (%s)\n", currentVersion) + return 0, nil + } + fmt.Fprintf(opts.Stdout, "update available: %s → %s\n", currentVersion, remote) + return 3, nil + } + + if opts.ForceVersion == "" && cmp >= 0 { + fmt.Fprintf(opts.Stdout, "already up to date (%s)\n", currentVersion) + return 0, nil + } + + fmt.Fprintf(opts.Stdout, "Updating %s → %s\n", currentVersion, remote) + + exe, err := CurrentExecutable() + if err != nil { + return 1, err + } + if NeedsSudo(exe) { + return 1, fmt.Errorf("cannot write to %s: re-run with sudo", exe) + } + + goos, goarch := Platform() + asset, err := SelectAsset(release, goos, goarch) + if err != nil { + return 1, err + } + + client := NewHTTPClient().Client + tmpdir, err := os.MkdirTemp("", "lab-update-*") + if err != nil { + return 1, err + } + defer func() { _ = os.RemoveAll(tmpdir) }() + + archive := filepath.Join(tmpdir, asset.Name) + fmt.Fprintf(opts.Stdout, "Downloading %s\n", asset.Name) + if err := Download(ctx, client, asset.BrowserDownloadURL, archive, opts.Stdout); err != nil { + return 1, err + } + fmt.Fprintln(opts.Stdout) + + // Verify checksums + var checksumAsset *Asset + for i := range release.Assets { + if release.Assets[i].Name == "checksums.txt" { + checksumAsset = &release.Assets[i] + break + } + } + if checksumAsset != nil { + checksumsPath := filepath.Join(tmpdir, "checksums.txt") + if err := Download(ctx, client, checksumAsset.BrowserDownloadURL, checksumsPath, nil); err != nil { + return 1, fmt.Errorf("download checksums: %w", err) + } + data, err := os.ReadFile(checksumsPath) + if err != nil { + return 1, err + } + if err := VerifyChecksum(string(data), asset.Name, archive); err != nil { + return 1, err + } + fmt.Fprintln(opts.Stdout, "Checksum verified") + } + + newBin := filepath.Join(tmpdir, "lab") + if err := ExtractLabBinary(archive, newBin); err != nil { + return 1, err + } + + if err := Replace(exe, newBin); err != nil { + return 1, fmt.Errorf("install: %w", err) + } + + fmt.Fprintf(opts.Stdout, "Updated to %s\n", remote) + return 0, nil +} diff --git a/internal/updater/semver.go b/internal/updater/semver.go new file mode 100644 index 0000000..bf8ba37 --- /dev/null +++ b/internal/updater/semver.go @@ -0,0 +1,95 @@ +package updater + +import ( + "strings" +) + +// CompareVersions returns -1 if a < b, 0 if equal, 1 if a > b. +// Handles optional "v" prefix and "dev" as older than any release. +func CompareVersions(a, b string) int { + va := normalizeVersion(a) + vb := normalizeVersion(b) + if va == "dev" && vb != "dev" { + return -1 + } + if vb == "dev" && va != "dev" { + return 1 + } + if va == vb { + return 0 + } + ap := parseParts(va) + bp := parseParts(vb) + for i := 0; i < 3; i++ { + if ap[i] < bp[i] { + return -1 + } + if ap[i] > bp[i] { + return 1 + } + } + // Compare pre-release suffix lexically (rc < final) + as := prereleaseSuffix(va) + bs := prereleaseSuffix(vb) + if as == bs { + return 0 + } + if as == "" { + return 1 + } + if bs == "" { + return -1 + } + if as < bs { + return -1 + } + if as > bs { + return 1 + } + return 0 +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if v == "" || v == "dev" { + return "dev" + } + return v +} + +func parseParts(v string) [3]int { + core := v + if idx := strings.IndexByte(v, '-'); idx >= 0 { + core = v[:idx] + } + parts := strings.Split(core, ".") + var out [3]int + for i := 0; i < len(parts) && i < 3; i++ { + out[i] = atoi(parts[i]) + } + return out +} + +func prereleaseSuffix(v string) string { + if idx := strings.IndexByte(v, '-'); idx >= 0 { + return v[idx+1:] + } + return "" +} + +func atoi(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + break + } + n = n*10 + int(c-'0') + } + return n +} + +// IsNewer reports whether remote is newer than current. +func IsNewer(current, remote string) bool { + return CompareVersions(current, remote) < 0 +} diff --git a/internal/updater/semver_test.go b/internal/updater/semver_test.go new file mode 100644 index 0000000..cb592d0 --- /dev/null +++ b/internal/updater/semver_test.go @@ -0,0 +1,29 @@ +package updater_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/stretchr/testify/require" +) + +func TestCompareVersions_order(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("v0.1.0", "v0.2.0")) + require.Equal(t, 1, updater.CompareVersions("0.3.0", "0.2.0")) + require.Equal(t, 0, updater.CompareVersions("v1.0.0", "1.0.0")) +} + +func TestCompareVersions_dev(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("dev", "v0.1.0")) + require.Equal(t, 1, updater.CompareVersions("v0.1.0", "dev")) +} + +func TestCompareVersions_prerelease(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("v1.0.0-rc1", "v1.0.0")) + require.Equal(t, 1, updater.CompareVersions("v1.0.0", "v1.0.0-rc1")) +} + +func TestIsNewer(t *testing.T) { + require.True(t, updater.IsNewer("v0.1.0", "v0.2.0")) + require.False(t, updater.IsNewer("v0.2.0", "v0.2.0")) +} diff --git a/scripts/install.sh b/scripts/install.sh index 7aa7a1e..587001a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,9 +1,169 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +set -eu -# TODO: implement after the first GitHub Release exists. -# This script should download the `lab` binary for the current OS/arch, -# verify checksums against the published SHA256 file, and install into /usr/local/bin (or $HOME/.local/bin). +# homelab-cli install script (POSIX sh) +# Usage: +# curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | sh +# curl -sSL ... | sh -s -- --version v0.2.0 +# curl -sSL ... | sh -s -- --prefix "$HOME/.local" -echo "install.sh is not implemented yet (waiting for first release artifacts)." >&2 -exit 1 +REPO="bartrosa/homelab-cli" +PROJECT="homelab-cli" +VERSION="" +PREFIX="" +FORCE=0 +CHECK=0 + +log() { printf '%s\n' "$*" >&2; } +die() { log "error: $*"; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --force) FORCE=1; shift ;; + --check) CHECK=1; shift ;; + -h|--help) + log "Usage: install.sh [--version TAG] [--prefix PATH] [--force] [--check]" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +detect_os() { + uname -s | tr '[:upper:]' '[:lower:]' +} + +detect_arch() { + m=$(uname -m) + case "$m" in + x86_64|amd64) printf '%s' "amd64" ;; + aarch64|arm64) printf '%s' "arm64" ;; + *) die "unsupported architecture: $m" ;; + esac +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +writable_dir() { + dir=$1 + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || return 1 + touch "$dir/.write-test" 2>/dev/null || return 1 + rm -f "$dir/.write-test" + return 0 +} + +choose_prefix() { + if [ -n "$PREFIX" ]; then + printf '%s' "$PREFIX" + return + fi + if writable_dir "/usr/local/bin"; then + printf '%s' "/usr/local" + return + fi + home=${HOME:-} + [ -n "$home" ] || die "HOME not set and /usr/local/bin not writable" + log "info: /usr/local/bin not writable — installing to $home/.local/bin" + log "info: ensure $home/.local/bin is in your PATH" + printf '%s' "$home/.local" +} + +fetch_latest_version() { + need_cmd curl + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | head -n1 \ + | cut -d '"' -f4 +} + +in_path() { + case ":${PATH:-}:" in + *:"$1":*) return 0 ;; + *) return 1 ;; + esac +} + +main() { + need_cmd curl + need_cmd tar + need_cmd sha256sum + + os=$(detect_os) + arch=$(detect_arch) + case "$os" in + linux|darwin) ;; + *) die "unsupported OS: $os" ;; + esac + + tag=${VERSION:-} + if [ -z "$tag" ]; then + tag=$(fetch_latest_version) + fi + [ -n "$tag" ] || die "could not determine release version" + + prefix=$(choose_prefix) + bindir="$prefix/bin" + dest="$bindir/lab" + + ver="${tag#v}" + asset="${PROJECT}_${ver}_${os}_${arch}.tar.gz" + base="https://github.com/${REPO}/releases/download/${tag}" + url="${base}/${asset}" + checksums_url="${base}/checksums.txt" + + log "install: $tag → $dest ($os/$arch)" + + if [ "$CHECK" -eq 1 ]; then + log "[check] would download $url" + log "[check] would verify with $checksums_url" + log "[check] would install to $dest" + exit 0 + fi + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT INT HUP + + curl -fsSL "$url" -o "$tmp/$asset" + curl -fsSL "$checksums_url" -o "$tmp/checksums.txt" + + hash=$(grep " ${asset}$" "$tmp/checksums.txt" | awk '{print $1}') + [ -n "$hash" ] || die "checksum entry not found for $asset" + got=$(sha256sum "$tmp/$asset" | awk '{print $1}') + [ "$got" = "$hash" ] || die "checksum mismatch" + + tar -xzf "$tmp/$asset" -C "$tmp" lab + + mkdir -p "$bindir" + if [ -f "$dest" ] && [ "$FORCE" -eq 0 ]; then + die "$dest already exists (use --force to overwrite)" + fi + + if [ "$prefix" = "/usr/local" ] || [ "$prefix" = "/usr" ]; then + if ! writable_dir "$bindir"; then + need_cmd sudo + sudo install -m 0755 "$tmp/lab" "$dest" + else + install -m 0755 "$tmp/lab" "$dest" + fi + else + install -m 0755 "$tmp/lab" "$dest" + fi + + log "" + log "Next steps:" + log " lab version" + log " lab --help" + log " lab self-update" + + if ! in_path "$bindir"; then + log "" + log "Add to your shell rc:" + log " export PATH=\"$bindir:\$PATH\"" + fi +} + +main "$@" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..9896c6a --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +PREFIX="/usr/local" +BIN="$PREFIX/bin/lab" + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) + PREFIX="$2" + BIN="$PREFIX/bin/lab" + shift 2 + ;; + -h|--help) + echo "Usage: uninstall.sh [--prefix PATH]" >&2 + exit 0 + ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ ! -f "$BIN" ]; then + echo "lab not found at $BIN" >&2 + exit 0 +fi + +if [ -w "$BIN" ]; then + rm -f "$BIN" +else + command -v sudo >/dev/null 2>&1 || { echo "need sudo to remove $BIN" >&2; exit 1; } + sudo rm -f "$BIN" +fi + +echo "Removed $BIN"