From 4322cede87512ab3dd5965eb7d8ee5e07257c880 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 10:07:37 +0200 Subject: [PATCH 01/10] fix: auto-configure PATH in install.sh Append install bin directory to shell rc when not already on PATH. Add --no-path opt-out; uninstall removes the marked PATH block. Update README and CHANGELOG. Co-authored-by: Cursor --- CHANGELOG.md | 46 +++++++++--------------- README.md | 7 ++-- scripts/install.sh | 85 +++++++++++++++++++++++++++++++++++++------- scripts/uninstall.sh | 42 ++++++++++++++++++++++ 4 files changed, 136 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4369d36..3ec412e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ 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 +## [Unreleased] + +### Changed + +- `scripts/install.sh`: automatically configure shell PATH when installing to a non-standard prefix; `--no-path` to opt out. +- `scripts/uninstall.sh`: remove homelab-cli PATH block from shell rc on uninstall. + +## [0.1.0] - YYYY-MM-DD ### Added @@ -16,40 +23,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 - -- **Server:** `lab server run`, `lab server deploy` (sync, provision, compose, full) — SSH + rsync; PostgreSQL apply via pgx locally. +- **Server:** `lab server run`, `lab server deploy` (sync, provision, compose, full). - **PostgreSQL:** `lab postgres apply --config` — idempotent users/databases from YAML. - **Bare metal:** `lab baremetal install` for qdrant, milvus, clickhouse (Linux). -- **System:** `lab system usb list` and `lab system usb` — discover Ubuntu/Fedora ISOs from upstream mirrors; wget, checksum, dd. +- **System:** `lab system usb list` and `lab system usb`. - **Services:** `lab services ensure` for ml-stack. -- **Media:** `lab media heic` only (YouTube playlist support removed from CLI). -- **SSH sync:** native rsync (no homelab `sync-to-server.sh`). -- **Docs:** full English documentation refresh; [`docs/README.md`](docs/README.md) index. -- **Dependencies:** `pgx/v5`; Go 1.25 module baseline. - -### Removed - -- `lab media playlist` and all YouTube/yt-dlp code (`internal/media/playlist*`, `ytdlp.go`). -- `media.*` config keys (`cookies_browser`, `cookies_file`, `downloads_dir`). +- **Media:** `lab media heic`. +- Cobra/Viper CLI, bootstrap profiles, `pkg`, `toolchain`, `repos backup`, `ssh`, `templates`. ### Changed +- GoReleaser config: nfpms, changelog filters, release mode. +- GitHub release workflow unchanged in trigger but documents full artifact set. - `lab ssh sync` and deploy use `internal/server` instead of homelab shell scripts. -- `docs/external-binaries.md`, `docs/homelab-migration.md` updated for current migration state. -### Foundation (earlier unreleased work) +### Removed -- Cobra/Viper CLI, grouped command tree, `version`, config loader, slog logging, lipgloss UI. -- `bootstrap` profiles: laptop-macos, laptop-linux, silverblue-laptop, server-ubuntu. -- `pkg`, `toolchain` (mise), `services` (homelab compose). -- `repos backup`, `ssh connect`, `templates`, `media heic` (HEIC conversion). -- Global flags: `--dry-run`, `--homelab-root`, `--no-color`. +- `lab media playlist` and YouTube/yt-dlp code. +- `media.*` config keys (`cookies_browser`, `cookies_file`, `downloads_dir`). diff --git a/README.md b/README.md index 1a5e5a6..2dfd99a 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,14 @@ Full command tables: [`docs/commands.md`](docs/commands.md). curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash ``` -Pin a version or install to `$HOME/.local`: +The install script downloads the release tarball, verifies SHA256 checksums, and installs `lab`. If `~/.local/bin` is used (when `/usr/local/bin` is not writable), it **automatically appends** the install directory to your shell rc (`~/.bashrc`, `~/.zshrc`, or `~/.profile`). Open a new terminal or run `source ~/.bashrc` afterward. + +Pin a version or install to a custom prefix: ```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 -- --version v0.1.0 curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --prefix "$HOME/.local" +curl -sSL .../install.sh | bash -s -- --no-path # skip automatic PATH setup ``` ### Upgrading diff --git a/scripts/install.sh b/scripts/install.sh index 587001a..dcf2508 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -eu # 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 -- --version v0.1.0 # curl -sSL ... | sh -s -- --prefix "$HOME/.local" REPO="bartrosa/homelab-cli" @@ -13,6 +13,9 @@ VERSION="" PREFIX="" FORCE=0 CHECK=0 +NO_PATH=0 + +PATH_MARKER="# homelab-cli: PATH" log() { printf '%s\n' "$*" >&2; } die() { log "error: $*"; exit 1; } @@ -23,8 +26,9 @@ while [ $# -gt 0 ]; do --prefix) PREFIX="$2"; shift 2 ;; --force) FORCE=1; shift ;; --check) CHECK=1; shift ;; + --no-path) NO_PATH=1; shift ;; -h|--help) - log "Usage: install.sh [--version TAG] [--prefix PATH] [--force] [--check]" + log "Usage: install.sh [--version TAG] [--prefix PATH] [--force] [--check] [--no-path]" exit 0 ;; *) die "unknown argument: $1" ;; @@ -56,6 +60,26 @@ writable_dir() { return 0 } +detect_shell_rc() { + case "${SHELL:-}" in + */zsh) + printf '%s' "$HOME/.zshrc" + ;; + */bash) + printf '%s' "$HOME/.bashrc" + ;; + *) + if [ -f "$HOME/.bashrc" ]; then + printf '%s' "$HOME/.bashrc" + elif [ -f "$HOME/.profile" ]; then + printf '%s' "$HOME/.profile" + else + printf '%s' "$HOME/.profile" + fi + ;; + esac +} + choose_prefix() { if [ -n "$PREFIX" ]; then printf '%s' "$PREFIX" @@ -68,7 +92,6 @@ choose_prefix() { 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" } @@ -87,6 +110,41 @@ in_path() { esac } +path_configured_in_rc() { + rc=$1 + bindir=$2 + [ -f "$rc" ] || return 1 + grep -qF "$PATH_MARKER" "$rc" 2>/dev/null && return 0 + grep -qF "$bindir" "$rc" 2>/dev/null +} + +ensure_path_in_rc() { + bindir=$1 + + if in_path "$bindir"; then + log "info: $bindir already in PATH for this session" + return 0 + fi + + rc=$(detect_shell_rc) + line="export PATH=\"$bindir:\$PATH\"" + + if path_configured_in_rc "$rc" "$bindir"; then + log "info: PATH already configured in $rc" + log "info: run: source $rc (or open a new terminal)" + return 0 + fi + + mkdir -p "$(dirname "$rc")" + { + printf '\n%s\n' "$PATH_MARKER" + printf '%s\n' "$line" + } >> "$rc" + + log "info: added $bindir to PATH in $rc" + log "info: run: source $rc (or open a new terminal)" +} + main() { need_cmd curl need_cmd tar @@ -109,9 +167,9 @@ main() { bindir="$prefix/bin" dest="$bindir/lab" - ver="${tag#v}" - asset="${PROJECT}_${ver}_${os}_${arch}.tar.gz" - base="https://github.com/${REPO}/releases/download/${tag}" + 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" @@ -121,6 +179,9 @@ main() { log "[check] would download $url" log "[check] would verify with $checksums_url" log "[check] would install to $dest" + if [ "$NO_PATH" -eq 0 ] && ! in_path "$bindir"; then + log "[check] would append $bindir to $(detect_shell_rc)" + fi exit 0 fi @@ -153,17 +214,15 @@ main() { install -m 0755 "$tmp/lab" "$dest" fi + if [ "$NO_PATH" -eq 0 ]; then + ensure_path_in_rc "$bindir" + fi + log "" - log "Next steps:" + log "Installed successfully." 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 index 9896c6a..94864fc 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -4,6 +4,8 @@ set -eu PREFIX="/usr/local" BIN="$PREFIX/bin/lab" +PATH_MARKER="# homelab-cli: PATH" + while [ $# -gt 0 ]; do case "$1" in --prefix) @@ -19,6 +21,44 @@ while [ $# -gt 0 ]; do esac done +detect_shell_rc() { + case "${SHELL:-}" in + */zsh) printf '%s' "$HOME/.zshrc" ;; + */bash) printf '%s' "$HOME/.bashrc" ;; + *) + if [ -f "$HOME/.bashrc" ]; then printf '%s' "$HOME/.bashrc" + elif [ -f "$HOME/.profile" ]; then printf '%s' "$HOME/.profile" + else printf '%s' "$HOME/.profile" + fi + ;; + esac +} + +remove_path_from_rc() { + bindir=$(dirname "$BIN") + rc=$(detect_shell_rc) + [ -f "$rc" ] || return 0 + grep -qF "$PATH_MARKER" "$rc" || return 0 + + tmp=$(mktemp) + skip=0 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" = "$PATH_MARKER" ]; then + skip=1 + continue + fi + if [ "$skip" -eq 1 ]; then + case "$line" in + *"$bindir"*) skip=0; continue ;; + *) skip=0 ;; + esac + fi + printf '%s\n' "$line" + done < "$rc" > "$tmp" + mv "$tmp" "$rc" + echo "Removed PATH entry from $rc" >&2 +} + if [ ! -f "$BIN" ]; then echo "lab not found at $BIN" >&2 exit 0 @@ -31,4 +71,6 @@ else sudo rm -f "$BIN" fi +remove_path_from_rc + echo "Removed $BIN" From 95c7b6932fd0af2272408eb45046a88aadf0a13d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:39:29 +0200 Subject: [PATCH 02/10] feat: add interactive prompt functions for ISO selection and input handling Introduced new functions in iso_prompt.go to facilitate interactive user prompts for selecting ISO images and entering text inputs. Enhanced the iso.go file with a new command for listing cached ISO images and improved the ISO writing command to support interactive selection of images and devices. Updated command descriptions for clarity and usability. --- internal/cli/commands/iso.go | 233 +++++++++++++++++++++++++--- internal/cli/commands/iso_prompt.go | 64 ++++++++ internal/cli/commands/version.go | 40 +---- 3 files changed, 285 insertions(+), 52 deletions(-) create mode 100644 internal/cli/commands/iso_prompt.go diff --git a/internal/cli/commands/iso.go b/internal/cli/commands/iso.go index d5dfdee..1d76f22 100644 --- a/internal/cli/commands/iso.go +++ b/internal/cli/commands/iso.go @@ -1,12 +1,16 @@ package commands import ( + "context" "fmt" + "io" "os" "path/filepath" "github.com/bartrosa/homelab-cli/internal/exec" "github.com/bartrosa/homelab-cli/internal/iso" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" ) @@ -19,7 +23,11 @@ func NewISOCmd() *cobra.Command { 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`, + 4. lab iso write — burn a cached ISO to USB (interactive or by name) + +Quick burn: + lab iso write pick image + USB drive interactively + lab iso write ubuntu-desktop --usb`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, @@ -28,6 +36,7 @@ func NewISOCmd() *cobra.Command { cmd.AddCommand( newISOListCmd(), newISODownloadCmd(), + newISOImagesCmd(), newISODisksCmd(), newISOWriteCmd(), ) @@ -44,7 +53,20 @@ func newISOListCmd() *cobra.Command { if err != nil { return err } - return iso.WriteList(stdout(cmd), entries) + s := session(cmd) + w := stdout(cmd) + ui.Section(w, s.Styles, "ISO catalog", "Installer images from upstream mirrors") + headers := []string{"DISTRO", "VERSION", "SIZE", "ARCHITECTURES"} + rows := make([][]string, len(entries)) + for i, e := range entries { + ver := e.Version + if ver == "planned" { + ver = s.Styles.Dim.Render("planned") + } + rows[i] = []string{e.ID, ver, e.ApproxSize, e.Architectures} + } + ui.Table(w, s.Styles, headers, rows) + return nil }, } } @@ -78,6 +100,7 @@ func newISODownloadCmd() *cobra.Command { OutputDir: output, NoVerify: noVerify, Force: force, + NoColor: session(cmd).NoColor, Stdout: stdout(cmd), }) return err @@ -103,61 +126,231 @@ func newISODisksCmd() *cobra.Command { if err != nil { return err } - iso.WriteDisksTable(stdout(cmd), disks) + s := session(cmd) + w := stdout(cmd) + ui.Section(w, s.Styles, "Block devices", "Write only to USB — never to SYSTEM disks") + headers := []string{"DEVICE", "SIZE", "MODEL", "TRAN", "TYPE"} + rows := make([][]string, len(disks)) + for i, d := range disks { + typ := string(d.Type) + switch d.Type { + case iso.DiskUSB: + typ = s.Styles.OK.Render("USB") + case iso.DiskSystem: + typ = s.Styles.Warn.Render("SYSTEM") + } + rows[i] = []string{d.Device, d.Size, d.Model, d.Tran, typ} + } + ui.Table(w, s.Styles, headers, rows) + if len(disks) == 0 { + ui.Warn(w, s.Styles, "no block devices found (is a USB drive connected?)") + } + for _, d := range disks { + if d.Type == iso.DiskSystem { + ui.Warn(w, s.Styles, "do not write ISOs to "+d.Device) + } + } + return nil + }, + } +} + +func newISOImagesCmd() *cobra.Command { + var cacheDir string + cmd := &cobra.Command{ + Use: "images", + Short: "List ISO files in the local cache", + RunE: func(cmd *cobra.Command, _ []string) error { + if cacheDir == "" { + var err error + cacheDir, err = iso.DefaultCacheDir() + if err != nil { + return err + } + } + images, err := iso.ListCachedImages(cacheDir) + if err != nil { + return err + } + s := session(cmd) + w := stdout(cmd) + ui.Section(w, s.Styles, "Cached ISO images", cacheDir) + if len(images) == 0 { + ui.Warn(w, s.Styles, "no images yet — run: lab iso download ubuntu-desktop") + return nil + } + headers := []string{"FILE", "SIZE"} + rows := make([][]string, len(images)) + for i, img := range images { + rows[i] = []string{img.Name, img.Size} + } + ui.Table(w, s.Styles, headers, rows) return nil }, } + cmd.Flags().StringVar(&cacheDir, "cache", "", "ISO cache directory (default: ~/.cache/homelab-cli/iso/)") + return cmd } func newISOWriteCmd() *cobra.Command { var ( device string + usb bool yes bool force bool blockSize string + cacheDir 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), + Use: "write [distro|iso-file]", + Short: "Write a cached ISO to a USB drive", + Long: `Burn an installer ISO to a USB stick. + +Without arguments, pick interactively from cached images and USB drives. + +Examples: + lab iso write + lab iso write ubuntu-desktop --usb + lab iso write ubuntu-desktop --to /dev/sda + lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.4-desktop-amd64.iso --to sda`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if device == "" { - return fmt.Errorf("--to is required (see: lab iso disks)") + if cacheDir == "" { + var err error + cacheDir, err = iso.DefaultCacheDir() + if err != nil { + return err + } } - isoPath, err := expandPath(args[0]) + + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + in := cmd.InOrStdin() + interactive := terminalInteractive(cmd) + + isoPath, err := resolveWriteISO(args, cacheDir, in, stdout(cmd), interactive) + if err != nil { + return err + } + + target, err := resolveWriteDevice(cmd.Context(), runner, device, usb, in, stdout(cmd), interactive) if err != nil { return err } + return iso.WriteISO(cmd.Context(), iso.WriteOptions{ ISOPath: isoPath, - Device: device, + Device: target, Yes: yes, Force: force, + NoColor: session(cmd).NoColor, BlockSize: blockSize, Stdout: stdout(cmd), Stderr: stderr(cmd), - Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + Runner: runner, }) }, } - cmd.Flags().StringVar(&device, "to", "", "target block device (e.g. /dev/sdb)") + cmd.Flags().StringVar(&device, "to", "", "target block device (e.g. /dev/sda or sda)") + cmd.Flags().StringVar(&device, "device", "", "alias for --to") + cmd.Flags().BoolVar(&usb, "usb", false, "use the only connected USB drive (or pick if several)") 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") + cmd.Flags().StringVar(&cacheDir, "cache", "", "ISO cache directory (default: ~/.cache/homelab-cli/iso/)") return cmd } -func expandPath(p string) (string, error) { - if len(p) >= 2 && p[:2] == "~/" { - home, err := os.UserHomeDir() - if err != nil { - return "", err +func resolveWriteISO(args []string, cacheDir string, in io.Reader, w io.Writer, interactive bool) (string, error) { + if len(args) == 1 { + return iso.ResolveISORef(args[0], cacheDir) + } + + images, err := iso.ListCachedImages(cacheDir) + if err != nil { + return "", err + } + if len(images) == 0 { + return "", fmt.Errorf("no cached ISO images in %s (run: lab iso download ubuntu-desktop)", cacheDir) + } + if !interactive { + return "", fmt.Errorf("specify an image: lab iso write ubuntu-desktop (or: lab iso images)") + } + + opts := make([]string, len(images)) + for i, img := range images { + opts[i] = fmt.Sprintf("%s (%s)", img.Name, img.Size) + } + idx, err := promptChoice(in, w, "Select ISO to write:", opts, 0) + if err != nil { + return "", err + } + return images[idx].Path, nil +} + +func resolveWriteDevice(ctx context.Context, runner exec.Runner, device string, usbAuto bool, in io.Reader, w io.Writer, interactive bool) (string, error) { + device = normalizeDevice(device) + if device != "" { + return device, nil + } + + disks, err := iso.ListDisks(ctx, runner) + if err != nil { + return "", err + } + var usbs []iso.Disk + for _, d := range disks { + if d.Type == iso.DiskUSB { + usbs = append(usbs, d) + } + } + + if usbAuto { + switch len(usbs) { + case 0: + return "", fmt.Errorf("no USB drives found (plug in a stick and run: lab iso disks)") + case 1: + fmt.Fprintf(w, "Using USB drive %s (%s)\n", usbs[0].Device, usbs[0].Model) + return usbs[0].Device, nil + } + } + + pickFrom := usbs + title := "Select USB drive:" + if len(usbs) == 0 { + if !interactive { + return "", fmt.Errorf("--to is required (no USB drives detected; run: lab iso disks)") + } + pickFrom = disks + title = "No USB drives detected — select device (CAUTION):" + } + + if !interactive { + if usbAuto && len(usbs) > 1 { + return "", fmt.Errorf("multiple USB drives — pick one with --to (run: lab iso disks)") } - return filepath.Join(home, p[2:]), nil + return "", fmt.Errorf("--to or --usb is required (run: lab iso disks)") } - return p, nil + + opts := make([]string, len(pickFrom)) + for i, d := range pickFrom { + opts[i] = fmt.Sprintf("%s %s %s [%s]", d.Device, d.Size, d.Model, d.Type) + } + idx, err := promptChoice(in, w, title, opts, 0) + if err != nil { + return "", err + } + return pickFrom[idx].Device, nil +} + +func terminalInteractive(cmd *cobra.Command) bool { + in, okIn := cmd.InOrStdin().(*os.File) + out, okOut := cmd.OutOrStdout().(*os.File) + return okIn && okOut && isatty.IsTerminal(in.Fd()) && isatty.IsTerminal(out.Fd()) +} + +func normalizeDevice(d string) string { + return iso.BlockDevicePath(d) } diff --git a/internal/cli/commands/iso_prompt.go b/internal/cli/commands/iso_prompt.go new file mode 100644 index 0000000..ef555f9 --- /dev/null +++ b/internal/cli/commands/iso_prompt.go @@ -0,0 +1,64 @@ +package commands + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" +) + +func promptChoice(r io.Reader, w io.Writer, title string, options []string, defaultIdx int) (int, error) { + if len(options) == 0 { + return -1, fmt.Errorf("no options for %s", title) + } + if defaultIdx < 0 || defaultIdx >= len(options) { + defaultIdx = 0 + } + fmt.Fprintln(w, title) + for i, o := range options { + marker := " " + if i == defaultIdx { + marker = "*" + } + fmt.Fprintf(w, " %s [%d] %s\n", marker, i+1, o) + } + fmt.Fprintf(w, "Choice [%d]: ", defaultIdx+1) + + sc := bufio.NewScanner(r) + if !sc.Scan() { + if err := sc.Err(); err != nil { + return -1, err + } + return defaultIdx, nil + } + line := strings.TrimSpace(sc.Text()) + if line == "" { + return defaultIdx, nil + } + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(options) { + return -1, fmt.Errorf("invalid choice %q (enter 1-%d)", line, len(options)) + } + return n - 1, nil +} + +func promptLine(r io.Reader, w io.Writer, prompt, defaultVal string) (string, error) { + if defaultVal != "" { + fmt.Fprintf(w, "%s [%s]: ", prompt, defaultVal) + } else { + fmt.Fprint(w, prompt) + } + sc := bufio.NewScanner(r) + if !sc.Scan() { + if err := sc.Err(); err != nil { + return "", err + } + return defaultVal, nil + } + line := strings.TrimSpace(sc.Text()) + if line == "" { + return defaultVal, nil + } + return line, nil +} diff --git a/internal/cli/commands/version.go b/internal/cli/commands/version.go index 0a823d4..96a5ec1 100644 --- a/internal/cli/commands/version.go +++ b/internal/cli/commands/version.go @@ -2,13 +2,12 @@ package commands import ( "encoding/json" - "fmt" "strings" "github.com/bartrosa/homelab-cli/internal/buildinfo" "github.com/bartrosa/homelab-cli/internal/logging" + "github.com/bartrosa/homelab-cli/internal/ui" - "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -43,35 +42,12 @@ func NewVersionCmd() *cobra.Command { } func printVersionText(cmd *cobra.Command, info buildinfo.Info) error { - out := cmd.OutOrStdout() - - noColor, err := cmd.Root().PersistentFlags().GetBool("no-color") - if err != nil { - noColor = false - } - - title := "lab" - if !noColor { - title = lipgloss.NewStyle().Bold(true).Render("lab") - } - - _, werr := fmt.Fprintf(out, "%s version\n", title) - if werr != nil { - return werr - } - - lines := []string{ - fmt.Sprintf("Version: %s", info.Version), - fmt.Sprintf("Commit: %s", info.Commit), - fmt.Sprintf("Date: %s", info.Date), - fmt.Sprintf("GoVersion: %s", info.GoVersion), - } - - for _, line := range lines { - if _, err := fmt.Fprintln(out, line); err != nil { - return err - } - } - + s := session(cmd) + ui.KeyValue(stdout(cmd), s.Styles, "lab", [][2]string{ + {"Version", info.Version}, + {"Commit", info.Commit}, + {"Date", info.Date}, + {"GoVersion", info.GoVersion}, + }) return nil } From ab6ed1bb7abe29e7a9e5e6758d79d887ab379e3c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:40:06 +0200 Subject: [PATCH 03/10] feat: implement ISO management functions for Linux Added new functionalities for managing ISO files on Linux, including block device path handling, writing ISO images to USB drives with progress reporting, and verifying downloads with GPG signatures. Introduced tests for these features to ensure reliability. Enhanced existing functions for listing cached images and resolving ISO references, improving overall usability and functionality of the ISO management system. --- internal/iso/block_linux.go | 31 ++++++ internal/iso/cache.go | 130 +++++++++++++++++++++++++ internal/iso/cache_test.go | 39 ++++++++ internal/iso/catalog.go | 32 ++++--- internal/iso/catalog_test.go | 16 ++++ internal/iso/dd_linux.go | 159 +++++++++++++++++++++++++++++++ internal/iso/dd_linux_test.go | 28 ++++++ internal/iso/device.go | 15 +++ internal/iso/device_test.go | 12 +++ internal/iso/disks_linux.go | 32 ++++--- internal/iso/download.go | 105 +++++++++------------ internal/iso/fedora.go | 11 ++- internal/iso/lsblk.go | 52 ++++++++-- internal/iso/lsblk_test.go | 28 ++++++ internal/iso/priv_linux.go | 30 ++++++ internal/iso/size.go | 37 +++++++- internal/iso/size_test.go | 31 ++++++ internal/iso/ubuntu.go | 6 +- internal/iso/verify.go | 172 +++++++++++++++++++++++++++++++--- internal/iso/verify_test.go | 27 ++++++ internal/iso/write.go | 43 ++++++--- 21 files changed, 902 insertions(+), 134 deletions(-) create mode 100644 internal/iso/block_linux.go create mode 100644 internal/iso/cache.go create mode 100644 internal/iso/cache_test.go create mode 100644 internal/iso/dd_linux.go create mode 100644 internal/iso/dd_linux_test.go create mode 100644 internal/iso/device.go create mode 100644 internal/iso/device_test.go create mode 100644 internal/iso/lsblk_test.go create mode 100644 internal/iso/priv_linux.go create mode 100644 internal/iso/size_test.go create mode 100644 internal/iso/verify_test.go diff --git a/internal/iso/block_linux.go b/internal/iso/block_linux.go new file mode 100644 index 0000000..8607ec4 --- /dev/null +++ b/internal/iso/block_linux.go @@ -0,0 +1,31 @@ +//go:build linux + +package iso + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +const sectorSize = 512 + +// blockWriteBytes returns lifetime bytes written to a block device (from sysfs). +func blockWriteBytes(device string) (int64, error) { + name := strings.TrimPrefix(BlockDevicePath(device), "/dev/") + data, err := os.ReadFile(filepath.Join("/sys/block", name, "stat")) + if err != nil { + return 0, err + } + fields := strings.Fields(string(data)) + if len(fields) < 7 { + return 0, fmt.Errorf("short /sys/block/%s/stat", name) + } + sectors, err := strconv.ParseUint(fields[6], 10, 64) + if err != nil { + return 0, err + } + return int64(sectors) * sectorSize, nil +} diff --git a/internal/iso/cache.go b/internal/iso/cache.go new file mode 100644 index 0000000..f71cf71 --- /dev/null +++ b/internal/iso/cache.go @@ -0,0 +1,130 @@ +package iso + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// CachedImage is an ISO file in the local cache directory. +type CachedImage struct { + Path string + Name string + Size string + SizeBytes int64 +} + +// DefaultCacheDir returns ~/.cache/homelab-cli/iso. +func DefaultCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cache", "homelab-cli", "iso"), nil +} + +// ListCachedImages scans dir for .iso files (newest first). +func ListCachedImages(dir string) ([]CachedImage, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []CachedImage + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".iso") { + continue + } + path := filepath.Join(dir, e.Name()) + st, err := os.Stat(path) + if err != nil || st.Size() == 0 { + continue + } + out = append(out, CachedImage{ + Path: path, + Name: e.Name(), + Size: formatBytes(st.Size()), + SizeBytes: st.Size(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// ResolveISORef resolves a distro id, filename fragment, or path to a cached/existing ISO. +func ResolveISORef(ref, cacheDir string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return "", fmt.Errorf("empty ISO reference") + } + + if strings.Contains(ref, string(os.PathSeparator)) || strings.HasPrefix(ref, "~") { + p, err := expandHome(ref) + if err != nil { + return "", err + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + + if d, ok := LookupDistro(ref); ok { + arch := "amd64" + if len(d.Architectures) > 0 { + arch = d.Architectures[0] + } + rel, err := d.Resolve(arch, "") + if err == nil { + candidate := filepath.Join(cacheDir, rel.ISOFilename) + if st, err := os.Stat(candidate); err == nil && st.Size() > 0 { + return candidate, nil + } + return "", fmt.Errorf("cached ISO for %q not found at %s (run: lab iso download %s)", ref, candidate, ref) + } + } + + images, err := ListCachedImages(cacheDir) + if err != nil { + return "", err + } + refLower := strings.ToLower(ref) + var matches []CachedImage + for _, img := range images { + if strings.EqualFold(img.Name, ref) || strings.EqualFold(img.Name, ref+".iso") { + return img.Path, nil + } + if strings.Contains(strings.ToLower(img.Name), refLower) { + matches = append(matches, img) + } + } + switch len(matches) { + case 1: + return matches[0].Path, nil + case 0: + return "", fmt.Errorf("no cached ISO matching %q (run: lab iso download …)", ref) + default: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return "", fmt.Errorf("ambiguous ISO reference %q matches: %s", ref, strings.Join(names, ", ")) + } +} + +func expandHome(p string) (string, error) { + if p == "~" { + return os.UserHomeDir() + } + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, p[2:]), nil + } + return p, nil +} diff --git a/internal/iso/cache_test.go b/internal/iso/cache_test.go new file mode 100644 index 0000000..be53f7d --- /dev/null +++ b/internal/iso/cache_test.go @@ -0,0 +1,39 @@ +package iso + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListCachedImages(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.iso"), []byte("x"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("x"), 0o644)) + + imgs, err := ListCachedImages(dir) + require.NoError(t, err) + require.Len(t, imgs, 1) + require.Equal(t, "a.iso", imgs[0].Name) +} + +func TestResolveISORef_byPath(t *testing.T) { + dir := t.TempDir() + iso := filepath.Join(dir, "test.iso") + require.NoError(t, os.WriteFile(iso, []byte("data"), 0o644)) + + got, err := ResolveISORef(iso, dir) + require.NoError(t, err) + require.Equal(t, iso, got) +} + +func TestResolveISORef_byFragment(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ubuntu-24.04.4-desktop-amd64.iso"), []byte("data"), 0o644)) + + got, err := ResolveISORef("24.04.4-desktop", dir) + require.NoError(t, err) + require.Contains(t, got, "ubuntu-24.04.4-desktop-amd64.iso") +} diff --git a/internal/iso/catalog.go b/internal/iso/catalog.go index ef71771..0b2ba3f 100644 --- a/internal/iso/catalog.go +++ b/internal/iso/catalog.go @@ -13,13 +13,26 @@ import ( // ErrNotImplemented indicates a stub resolver. var ErrNotImplemented = errors.New("not implemented yet") +// ChecksumSigKind describes how checksum signatures are published. +type ChecksumSigKind int + +const ( + // SigDetached — separate signature file (e.g. Ubuntu SHA256SUMS.gpg). + SigDetached ChecksumSigKind = iota + // SigClearsigned — signature embedded in the checksum file (e.g. Fedora CHECKSUM). + SigClearsigned +) + // Release describes a resolved ISO download. type Release struct { - Version string - ISOURL string - ChecksumURL string - GPGKeyURL string - ISOFilename string + Version string + ISOURL string + ChecksumURL string + ChecksumSigURL string // detached signature URL (SigDetached only) + ChecksumSigKind ChecksumSigKind + SigningKeyIDs []string // hex key IDs, fetched from upstream keyservers + SigningKeyURLs []string // direct .gpg keyring URLs (e.g. fedoraproject.org/fedora.gpg) + ISOFilename string } // Distro describes a supported distribution. @@ -184,12 +197,5 @@ func ClassifyDisk(rm bool, tran string) DiskType { // 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) + return fmt.Sprintf("%-12s %-8s %-24s %-6s %s", d.Device, d.Size, d.Model, d.Tran, d.Type) } diff --git a/internal/iso/catalog_test.go b/internal/iso/catalog_test.go index 7f6532f..6a26e2a 100644 --- a/internal/iso/catalog_test.go +++ b/internal/iso/catalog_test.go @@ -29,6 +29,22 @@ func TestParseLSBLKJSON_classifiesUSB(t *testing.T) { require.Equal(t, "usb", disks[2].Tran) } +func TestParseLSBLKJSON_withoutTypeColumn(t *testing.T) { + const noType = `{ + "blockdevices": [ + {"name":"nvme0n1","size":"1T","model":"WD_BLACK","tran":"nvme","rm":false}, + {"name":"sdb","size":"58G","model":"SanDisk","tran":"usb","rm":true}, + {"name":"loop0","size":"4K","model":null,"tran":null,"rm":false} + ] +}` + disks, err := iso.ParseLSBLKJSON(noType) + require.NoError(t, err) + require.Len(t, disks, 2) + require.Equal(t, "/dev/nvme0n1", disks[0].Device) + require.Equal(t, "/dev/sdb", disks[1].Device) + require.Equal(t, iso.DiskUSB, disks[1].Type) +} + func TestClassifyDisk(t *testing.T) { require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(true, "sata")) require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(false, "usb")) diff --git a/internal/iso/dd_linux.go b/internal/iso/dd_linux.go new file mode 100644 index 0000000..3b1f991 --- /dev/null +++ b/internal/iso/dd_linux.go @@ -0,0 +1,159 @@ +//go:build linux + +package iso + +import ( + "context" + "fmt" + "io" + "os" + osexec "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/bartrosa/homelab-cli/internal/ui" +) + +const ddPollInterval = 250 * time.Millisecond + +var ddBytesRe = regexp.MustCompile(`(?i)(\d+)\s+(?:bytes|bajt)`) + +func parseDDBytes(line string) (int64, bool) { + lower := strings.ToLower(line) + if !strings.Contains(lower, "bytes") && !strings.Contains(lower, "bajt") { + return 0, false + } + m := ddBytesRe.FindStringSubmatch(line) + if len(m) < 2 { + return 0, false + } + n, err := strconv.ParseInt(m[1], 10, 64) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +func readDDStatus(r io.Reader) error { + buf := make([]byte, 4096) + var rem []byte + for { + n, err := r.Read(buf) + if n > 0 { + rem = append(rem, buf[:n]...) + for { + split := -1 + for i, b := range rem { + if b == '\r' || b == '\n' { + split = i + break + } + } + if split < 0 { + break + } + rem = rem[split+1:] + } + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + +func pollBlockWriteProgress(ctx context.Context, device string, baseline, total int64, rep *ui.DownloadReporter, spinner *sync.Once) { + t := time.NewTicker(ddPollInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w, err := blockWriteBytes(device) + if err != nil { + continue + } + written := w - baseline + if written < 0 { + continue + } + if total > 0 && written > total { + written = total + } + spinner.Do(func() { rep.EndConnect() }) + rep.UpdateBytes(written) + } + } +} + +func buildDDCommand(ddArgs []string) (string, []string) { + useSudo := os.Geteuid() != 0 + if _, err := osexec.LookPath("stdbuf"); err == nil { + argv := append([]string{"-oL", "-eL", "dd"}, ddArgs...) + if useSudo { + return "sudo", append([]string{"stdbuf"}, argv...) + } + return "stdbuf", argv + } + if useSudo { + return "sudo", append([]string{"dd"}, ddArgs...) + } + return "dd", ddArgs +} + +func runDDWithProgress(ctx context.Context, out io.Writer, noColor bool, totalBytes int64, device string, ddArgs []string) error { + baseline, err := blockWriteBytes(device) + if err != nil { + return fmt.Errorf("read block stats for %s: %w", device, err) + } + + rep := ui.NewDownloadReporter(out, noColor) + rep.SetTotal(totalBytes) + rep.BeginConnect("Writing ISO to USB") + defer rep.EndConnect() + + name, argv := buildDDCommand(ddArgs) + cmd := osexec.CommandContext(ctx, name, argv...) + cmd.Stdout = io.Discard + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return err + } + + pollCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var spinner sync.Once + go pollBlockWriteProgress(pollCtx, device, baseline, totalBytes, rep, &spinner) + + readDone := make(chan error, 1) + go func() { + readDone <- readDDStatus(stderrPipe) + }() + + waitErr := cmd.Wait() + cancel() + readErr := <-readDone + rep.EndConnect() + if waitErr == nil && totalBytes > 0 { + rep.UpdateBytes(totalBytes) + } + rep.Finish() + if waitErr != nil { + return fmt.Errorf("dd: %w", waitErr) + } + if readErr != nil { + return readErr + } + return nil +} diff --git a/internal/iso/dd_linux_test.go b/internal/iso/dd_linux_test.go new file mode 100644 index 0000000..6d97ed8 --- /dev/null +++ b/internal/iso/dd_linux_test.go @@ -0,0 +1,28 @@ +//go:build linux + +package iso + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseDDBytes(t *testing.T) { + b, ok := parseDDBytes("597688464 bytes (598 MB, 570 MiB) copied, 17.1 s, 34.9 MB/s") + require.True(t, ok) + require.Equal(t, int64(597688464), b) + + b, ok = parseDDBytes("skopiowane 578813952 bajtów (579 MB, 552 MiB), 16 s, 35,2 MB/s") + require.True(t, ok) + require.Equal(t, int64(578813952), b) + + _, ok = parseDDBytes("579+0 records in") + require.False(t, ok) +} + +func TestReadDDStatus_carriageReturn(t *testing.T) { + err := readDDStatus(strings.NewReader("skopiowane 1000 bajtów\rskopiowane 2000 bajtów\n")) + require.NoError(t, err) +} diff --git a/internal/iso/device.go b/internal/iso/device.go new file mode 100644 index 0000000..c996638 --- /dev/null +++ b/internal/iso/device.go @@ -0,0 +1,15 @@ +package iso + +import "strings" + +// BlockDevicePath ensures a block device path has a /dev/ prefix. +func BlockDevicePath(device string) string { + device = strings.TrimSpace(device) + if device == "" { + return "" + } + if !strings.HasPrefix(device, "/dev/") { + return "/dev/" + strings.TrimPrefix(device, "/dev/") + } + return device +} diff --git a/internal/iso/device_test.go b/internal/iso/device_test.go new file mode 100644 index 0000000..e8d2bc2 --- /dev/null +++ b/internal/iso/device_test.go @@ -0,0 +1,12 @@ +package iso + +import "testing" + +func TestBlockDevicePath(t *testing.T) { + if got := BlockDevicePath("sda"); got != "/dev/sda" { + t.Fatalf("got %q", got) + } + if got := BlockDevicePath("/dev/sda"); got != "/dev/sda" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/iso/disks_linux.go b/internal/iso/disks_linux.go index 7ed51de..b86d568 100644 --- a/internal/iso/disks_linux.go +++ b/internal/iso/disks_linux.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/bartrosa/homelab-cli/internal/exec" ) @@ -18,7 +17,7 @@ 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") + out, err := runner.RunWithOutput(ctx, "lsblk", "-d", "-J", "-o", "NAME,SIZE,MODEL,TRAN,RM,TYPE,VENDOR") if err != nil { return nil, err } @@ -31,15 +30,15 @@ func WriteDisksTable(w io.Writer, disks []Disk) { 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) + 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) + device = BlockDevicePath(device) + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-o", "NAME,SIZE,MODEL,TRAN,RM,TYPE,MOUNTPOINT", device) if err != nil { return DeviceInfo{}, err } @@ -56,8 +55,8 @@ func InspectDevice(ctx context.Context, runner exec.Runner, device string) (Devi return DeviceInfo{ Device: device, Size: dev.Size, - Model: dev.Model, - Tran: dev.Tran, + Model: strVal(dev.Model), + Tran: strVal(dev.Tran), RM: dev.RM, Mountpoints: mps, }, nil @@ -74,8 +73,8 @@ type DeviceInfo struct { } 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) + device = BlockDevicePath(device) + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-o", "NAME,MOUNTPOINT", device) if err != nil { return err } @@ -83,15 +82,20 @@ func unmountDevice(ctx context.Context, runner exec.Runner, device string) error if err := json.Unmarshal([]byte(out), &payload); err != nil { return err } - var parts []string + var targets []string for _, d := range payload.BlockDevices { - collectPartNames(d, &parts) + collectUmountTargets(d, &targets) } - for _, p := range parts { - if p == "" { + seen := make(map[string]struct{}) + for _, t := range targets { + if t == "" { continue } - _ = runner.Run(ctx, "umount", "/dev/"+p) + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + tryUmount(ctx, runner, t) } return nil } diff --git a/internal/iso/download.go b/internal/iso/download.go index ca25308..6afd2db 100644 --- a/internal/iso/download.go +++ b/internal/iso/download.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/bartrosa/homelab-cli/internal/ui" ) // DownloadOptions configures ISO download. @@ -20,6 +22,7 @@ type DownloadOptions struct { OutputDir string NoVerify bool Force bool + NoColor bool Stdout io.Writer Client *http.Client } @@ -60,41 +63,58 @@ func DownloadISO(ctx context.Context, distro Distro, opts DownloadOptions) (Down } } - fmt.Fprintf(opts.Stdout, "Downloading %s\n", rel.ISOURL) - if err := downloadFile(ctx, opts.Client, rel.ISOURL, dest, opts.Stdout); err != nil { + fmt.Fprintf(opts.Stdout, "Downloading %s\n", rel.ISOFilename) + if err := downloadFile(ctx, opts.Client, rel.ISOURL, dest, opts.Stdout, opts.NoColor); 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 { + if err := ui.RunWithSpinner(opts.Stdout, opts.NoColor, "Verifying SHA256", func() error { + sumData, err := fetchBytes(ctx, opts.Client, rel.ChecksumURL) + if err != nil { + return fmt.Errorf("checksum file: %w", err) + } + want, ok := findSHA256(string(sumData), rel.ISOFilename) + if !ok { + want, ok = findFedoraChecksum(string(sumData), rel.ISOFilename) + } + if !ok { + return fmt.Errorf("checksum entry not found for %s", rel.ISOFilename) + } + return 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, " SHA256 verified") + + needsGPG := rel.ChecksumSigKind == SigClearsigned || rel.ChecksumSigURL != "" + if !opts.NoVerify && needsGPG { + styles := ui.NewStyles(opts.Stdout, opts.NoColor) + if err := ui.RunWithSpinner(opts.Stdout, opts.NoColor, "Verifying GPG signature", func() error { + return VerifyGPG(ctx, rel) + }); err != nil { + ui.SecurityWarning(opts.Stdout, styles, opts.NoColor, + "GPG VERIFICATION FAILED", + "The checksum signature could NOT be verified with upstream signing keys.", + "This may indicate a tampered download, compromised mirror, or man-in-the-middle attack.", + "DO NOT install from this ISO: "+dest, + "Delete the file and re-download, or verify manually (see ubuntu.com/tutorials/how-to-verify-ubuntu).", + "", + "Details: "+err.Error(), + ) + return DownloadResult{}, fmt.Errorf("gpg verification failed: %w", err) } - fmt.Fprintln(opts.Stdout, "GPG signature verified") + 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 { +func downloadFile(ctx context.Context, client *http.Client, url, dest string, out io.Writer, noColor bool) error { + rep := ui.NewDownloadReporter(out, noColor) + rep.BeginConnect("Connecting to server") + defer rep.EndConnect() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err @@ -114,47 +134,14 @@ func downloadFile(ctx context.Context, client *http.Client, url, dest string, pr } defer closeFile(f) - pr := &byteProgress{w: progress, total: resp.ContentLength} - if _, err := io.Copy(f, io.TeeReader(resp.Body, pr)); err != nil { + rep.SetTotal(resp.ContentLength) + if _, err := io.Copy(f, io.TeeReader(resp.Body, rep.Writer())); err != nil { return err } - pr.finish() + rep.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 { diff --git a/internal/iso/fedora.go b/internal/iso/fedora.go index 67bd278..2ea3fb3 100644 --- a/internal/iso/fedora.go +++ b/internal/iso/fedora.go @@ -70,11 +70,12 @@ func resolveFedoraSilverblue(arch, version string) (Release, error) { 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, + Version: release, + ISOURL: isoDir + isoFile, + ChecksumURL: isoDir + checksumFile, + ChecksumSigKind: SigClearsigned, + SigningKeyURLs: []string{"https://fedoraproject.org/fedora.gpg"}, + ISOFilename: isoFile, }, nil } diff --git a/internal/iso/lsblk.go b/internal/iso/lsblk.go index 56177a6..eca148b 100644 --- a/internal/iso/lsblk.go +++ b/internal/iso/lsblk.go @@ -13,11 +13,11 @@ type lsblkRoot struct { type lsblkNode struct { Name string `json:"name"` Size string `json:"size"` - Model string `json:"model"` - Tran string `json:"tran"` + Model *string `json:"model"` + Tran *string `json:"tran"` RM bool `json:"rm"` Type string `json:"type"` - Vendor string `json:"vendor"` + Vendor *string `json:"vendor"` Mountpoint *string `json:"mountpoint"` Children []lsblkNode `json:"children"` } @@ -30,25 +30,48 @@ func ParseLSBLKJSON(raw string) ([]Disk, error) { } var disks []Disk for _, dev := range payload.BlockDevices { - if dev.Type != "disk" { + if !isBlockDisk(dev) { continue } - model := strings.TrimSpace(dev.Model) + model := strVal(dev.Model) if model == "" { - model = strings.TrimSpace(dev.Vendor) + model = strVal(dev.Vendor) } - diskType := ClassifyDisk(dev.RM, dev.Tran) + tran := strVal(dev.Tran) + diskType := ClassifyDisk(dev.RM, tran) disks = append(disks, Disk{ Device: "/dev/" + dev.Name, Size: dev.Size, Model: model, - Tran: dev.Tran, + Tran: tran, Type: diskType, }) } return disks, nil } +func isBlockDisk(dev lsblkNode) bool { + if dev.Type == "disk" { + return true + } + // Older lsblk without TYPE column: skip known non-disk prefixes. + if dev.Type != "" { + return false + } + name := dev.Name + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "ram") { + return false + } + return name != "" +} + +func strVal(s *string) string { + if s == nil { + return "" + } + return strings.TrimSpace(*s) +} + func collectMountpoints(dev lsblkNode, out *[]string) { if dev.Mountpoint != nil && *dev.Mountpoint != "" { *out = append(*out, *dev.Mountpoint) @@ -64,3 +87,16 @@ func collectPartNames(dev lsblkNode, out *[]string) { collectPartNames(c, out) } } + +func collectUmountTargets(dev lsblkNode, out *[]string) { + if dev.Mountpoint != nil { + mp := strings.TrimSpace(*dev.Mountpoint) + if mp != "" { + *out = append(*out, mp) + *out = append(*out, "/dev/"+dev.Name) + } + } + for _, c := range dev.Children { + collectUmountTargets(c, out) + } +} diff --git a/internal/iso/lsblk_test.go b/internal/iso/lsblk_test.go new file mode 100644 index 0000000..dedcb39 --- /dev/null +++ b/internal/iso/lsblk_test.go @@ -0,0 +1,28 @@ +package iso + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCollectUmountTargets(t *testing.T) { + const raw = `{ + "blockdevices": [{ + "name": "sda", + "mountpoint": null, + "children": [{ + "name": "sda1", + "mountpoint": "/media/brosa/UBUNTU 24_0" + }] + }] +}` + var payload lsblkRoot + require.NoError(t, json.Unmarshal([]byte(raw), &payload)) + var targets []string + for _, d := range payload.BlockDevices { + collectUmountTargets(d, &targets) + } + require.Equal(t, []string{"/media/brosa/UBUNTU 24_0", "/dev/sda1"}, targets) +} diff --git a/internal/iso/priv_linux.go b/internal/iso/priv_linux.go new file mode 100644 index 0000000..e1da9c9 --- /dev/null +++ b/internal/iso/priv_linux.go @@ -0,0 +1,30 @@ +//go:build linux + +package iso + +import ( + "context" + "fmt" + "os" + osexec "os/exec" + + iexec "github.com/bartrosa/homelab-cli/internal/exec" +) + +func runPrivileged(ctx context.Context, runner iexec.Runner, name string, args ...string) error { + if os.Geteuid() == 0 { + return runner.Run(ctx, name, args...) + } + if _, err := osexec.LookPath("sudo"); err != nil { + return fmt.Errorf("%s requires root privileges; install sudo or run: sudo lab iso write …", name) + } + sudoArgs := append([]string{name}, args...) + return runner.Run(ctx, "sudo", sudoArgs...) +} + +func tryUmount(ctx context.Context, runner iexec.Runner, target string) { + _ = runner.Run(ctx, "umount", target) + if os.Geteuid() != 0 { + _ = runner.Run(ctx, "sudo", "umount", target) + } +} diff --git a/internal/iso/size.go b/internal/iso/size.go index 5da048b..9b40d6a 100644 --- a/internal/iso/size.go +++ b/internal/iso/size.go @@ -6,7 +6,7 @@ import ( "strings" ) -// ParseSizeBytes converts lsblk size strings (e.g. 58G, 500M) to bytes. +// ParseSizeBytes converts lsblk size strings (e.g. 58G, 58,6G, 500M) to bytes. func ParseSizeBytes(size string) (int64, error) { size = strings.TrimSpace(size) if size == "" { @@ -19,15 +19,42 @@ func ParseSizeBytes(size string) (int64, error) { 'T': 1024 * 1024 * 1024 * 1024, } last := size[len(size)-1] - if u, ok := units[last]; ok { - numStr := strings.TrimSpace(size[:len(size)-1]) + unitKey := byte(strings.ToUpper(string(last))[0]) + if u, ok := units[unitKey]; ok { + numStr := normalizeSizeNumber(size[:len(size)-1]) f, err := strconv.ParseFloat(numStr, 64) if err != nil { - return 0, err + return 0, fmt.Errorf("parse size %q: %w", size, err) } return int64(f * float64(u)), nil } - return strconv.ParseInt(size, 10, 64) + // Plain integer bytes (no unit suffix). + plain := normalizeSizeNumber(size) + if strings.Contains(plain, ".") { + f, err := strconv.ParseFloat(plain, 64) + if err != nil { + return 0, fmt.Errorf("parse size %q: %w", size, err) + } + return int64(f), nil + } + n, err := strconv.ParseInt(plain, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse size %q: %w", size, err) + } + return n, nil +} + +// normalizeSizeNumber accepts European decimal commas from locale-aware lsblk output. +func normalizeSizeNumber(s string) string { + s = strings.TrimSpace(s) + if !strings.Contains(s, ",") { + return s + } + if strings.Contains(s, ".") { + // e.g. 1.234,5 → 1234.5 + s = strings.ReplaceAll(s, ".", "") + } + return strings.ReplaceAll(s, ",", ".") } const minUSBCapacity = 4 * 1024 * 1024 * 1024 // 4 GiB diff --git a/internal/iso/size_test.go b/internal/iso/size_test.go new file mode 100644 index 0000000..acb34d8 --- /dev/null +++ b/internal/iso/size_test.go @@ -0,0 +1,31 @@ +package iso + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseSizeBytes(t *testing.T) { + tests := []struct { + in string + want float64 + }{ + {"58G", 58 * 1024 * 1024 * 1024}, + {"58,6G", 58.6 * 1024 * 1024 * 1024}, + {"1,8T", 1.8 * 1024 * 1024 * 1024 * 1024}, + {"500M", 500 * 1024 * 1024}, + {"326,9M", 326.9 * 1024 * 1024}, + {"4096", 4096}, + } + for _, tc := range tests { + got, err := ParseSizeBytes(tc.in) + require.NoError(t, err, tc.in) + require.InDelta(t, tc.want, float64(got), 1024*1024, tc.in) + } +} + +func TestValidateDeviceCapacity_polishLocale(t *testing.T) { + require.NoError(t, ValidateDeviceCapacity("58,6G")) + require.Error(t, ValidateDeviceCapacity("2,0G")) +} diff --git a/internal/iso/ubuntu.go b/internal/iso/ubuntu.go index dbe14dd..8f82fdc 100644 --- a/internal/iso/ubuntu.go +++ b/internal/iso/ubuntu.go @@ -41,7 +41,11 @@ func resolveUbuntuDesktop(arch, version string) (Release, error) { Version: ver + " LTS", ISOURL: base + isoFile, ChecksumURL: base + "SHA256SUMS", - GPGKeyURL: base + "SHA256SUMS.gpg", + ChecksumSigURL: base + "SHA256SUMS.gpg", + SigningKeyIDs: []string{ + "843938DF228D22F7B3742BC0D94AA3F0EFE21092", // Ubuntu CD Image Automatic Signing Key (2012) + "46181433FBB75451", // Ubuntu CD Image Signing Key (legacy) + }, ISOFilename: isoFile, }, nil } diff --git a/internal/iso/verify.go b/internal/iso/verify.go index 8946bf2..70f6d2e 100644 --- a/internal/iso/verify.go +++ b/internal/iso/verify.go @@ -1,55 +1,199 @@ package iso import ( + "bytes" "context" "fmt" "io" "net/http" "os" "path/filepath" + "strings" "time" "github.com/bartrosa/homelab-cli/internal/exec" ) -// VerifyGPG downloads checksums and verifies GPG signature using system gpg. +// VerifyGPG verifies the checksum file signature using upstream signing keys. func VerifyGPG(ctx context.Context, rel Release) error { - if rel.GPGKeyURL == "" { + needsVerify := rel.ChecksumSigKind == SigClearsigned || rel.ChecksumSigURL != "" + if !needsVerify { return nil } - runner := exec.NewOSRunner(os.Stdout, os.Stderr) + if rel.ChecksumSigKind == SigClearsigned && len(rel.SigningKeyIDs) == 0 && len(rel.SigningKeyURLs) == 0 { + return fmt.Errorf("clearsigned checksum requires signing keys") + } + if rel.ChecksumSigKind != SigClearsigned && rel.ChecksumSigURL == "" { + return fmt.Errorf("detached signature URL missing") + } + + client := &http.Client{Timeout: 2 * time.Minute} 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") + gpgHome := filepath.Join(tmp, "gnupg") + if err := os.MkdirAll(gpgHome, 0o700); err != nil { + return err + } + var gpgOut bytes.Buffer + runner := exec.NewOSRunner(io.Discard, &gpgOut) + runner.Env = []string{"GNUPGHOME=" + gpgHome} + + if err := importSigningKeys(ctx, client, runner, tmp, rel); err != nil { + return fmt.Errorf("import signing keys: %w", err) + } + + sumPath := filepath.Join(tmp, "checksums") sumData, err := fetchBytes(ctx, client, rel.ChecksumURL) if err != nil { - return err + return fmt.Errorf("checksum file: %w", err) } if err := os.WriteFile(sumPath, sumData, 0o644); err != nil { return err } - gpgData, err := fetchBytes(ctx, client, rel.GPGKeyURL) - if err != nil { - return err + switch rel.ChecksumSigKind { + case SigClearsigned: + return verifyClearsigned(ctx, runner, &gpgOut, tmp, sumPath) + default: + sigPath := filepath.Join(tmp, "checksums.sig") + sigData, err := fetchBytes(ctx, client, rel.ChecksumSigURL) + if err != nil { + return fmt.Errorf("signature file: %w", err) + } + if err := os.WriteFile(sigPath, sigData, 0o644); err != nil { + return err + } + return verifyDetached(ctx, runner, &gpgOut, tmp, sigPath, sumPath) } - if err := os.WriteFile(gpgPath, gpgData, 0o644); err != nil { - return err +} + +func importSigningKeys(ctx context.Context, client *http.Client, runner *exec.OSRunner, tmp string, rel Release) error { + for i, url := range rel.SigningKeyURLs { + keyData, err := fetchBytes(ctx, client, url) + if err != nil { + return fmt.Errorf("fetch key %s: %w", url, err) + } + keyPath := filepath.Join(tmp, fmt.Sprintf("keyring-%d.gpg", i)) + if err := os.WriteFile(keyPath, keyData, 0o600); err != nil { + return err + } + if err := runner.Run(ctx, "gpg", "--batch", "--import", keyPath); err != nil { + return fmt.Errorf("gpg import %s: %w", url, err) + } } - if err := runner.Run(ctx, "gpg", "--verify", gpgPath, sumPath); err != nil { - return fmt.Errorf("gpg verify: %w", err) + for _, rawID := range rel.SigningKeyIDs { + keyID := normalizeKeyID(rawID) + if keyID == "" { + continue + } + if err := importKeyByID(ctx, client, runner, tmp, keyID); err != nil { + return err + } } return nil } +func importKeyByID(ctx context.Context, client *http.Client, runner *exec.OSRunner, tmp, keyID string) error { + keyURL := fmt.Sprintf("https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x%s", keyID) + keyData, err := fetchBytes(ctx, client, keyURL) + if err == nil && looksLikePGPKey(keyData) { + keyPath := filepath.Join(tmp, keyID+".asc") + if err := os.WriteFile(keyPath, keyData, 0o600); err != nil { + return err + } + if err := runner.Run(ctx, "gpg", "--batch", "--import", keyPath); err != nil { + return fmt.Errorf("gpg import 0x%s: %w", keyID, err) + } + return nil + } + + servers := []string{ + "hkp://keyserver.ubuntu.com:80", + "hkp://keys.openpgp.org:80", + } + var lastErr error + for _, srv := range servers { + if err := runner.Run(ctx, "gpg", "--batch", "--keyserver", srv, "--recv-keys", "0x"+keyID); err != nil { + lastErr = err + continue + } + return nil + } + return fmt.Errorf("import key 0x%s: %w", keyID, lastErr) +} + +func verifyDetached(ctx context.Context, runner *exec.OSRunner, gpgOut *bytes.Buffer, tmp, sigPath, sumPath string) error { + return runGPGVerify(ctx, runner, gpgOut, tmp, sigPath, sumPath) +} + +func verifyClearsigned(ctx context.Context, runner *exec.OSRunner, gpgOut *bytes.Buffer, tmp, sumPath string) error { + return runGPGVerify(ctx, runner, gpgOut, tmp, "", sumPath) +} + +func runGPGVerify(ctx context.Context, runner *exec.OSRunner, gpgOut *bytes.Buffer, tmp, sigPath, sumPath string) error { + gpgOut.Reset() + statusPath := filepath.Join(tmp, "gpg.status") + args := []string{"--batch", "--keyid-format", "long", "--status-file", statusPath, "--verify"} + if sigPath != "" { + args = append(args, sigPath, sumPath) + } else { + args = append(args, sumPath) + } + err := runner.Run(ctx, "gpg", args...) + status, _ := os.ReadFile(statusPath) + return interpretGPGResult(string(status), gpgOut.String(), err) +} + +func interpretGPGResult(status, textOut string, err error) error { + if strings.Contains(status, "BADSIG") || strings.Contains(status, "ERRSIG") { + return fmt.Errorf("bad signature (checksum file may be tampered): %s", strings.TrimSpace(firstNonEmpty(textOut, status))) + } + if strings.Contains(status, "GOODSIG") || strings.Contains(status, "VALIDSIG") { + return nil + } + out := strings.ToLower(textOut) + if strings.Contains(out, "bad signature") || strings.Contains(out, "zła sygnatura") { + return fmt.Errorf("bad signature (checksum file may be tampered): %s", strings.TrimSpace(textOut)) + } + if strings.Contains(out, "good signature") || strings.Contains(out, "poprawny podpis") { + return nil + } + if err != nil { + detail := strings.TrimSpace(firstNonEmpty(textOut, status)) + if detail != "" { + return fmt.Errorf("%w: %s", err, detail) + } + return err + } + return fmt.Errorf("gpg did not report a good signature: %s", strings.TrimSpace(firstNonEmpty(textOut, status))) +} + +func firstNonEmpty(parts ...string) string { + for _, p := range parts { + if strings.TrimSpace(p) != "" { + return p + } + } + return "" +} + +func normalizeKeyID(id string) string { + id = strings.TrimSpace(id) + id = strings.TrimPrefix(id, "0x") + id = strings.TrimPrefix(id, "0X") + return strings.ToUpper(id) +} + +func looksLikePGPKey(b []byte) bool { + return strings.Contains(string(b), "BEGIN PGP PUBLIC KEY BLOCK") +} + // ParseSHA256SUMS extracts hash for filename from Ubuntu-style SHA256SUMS. func ParseSHA256SUMS(content, filename string) (string, bool) { return findSHA256(content, filename) diff --git a/internal/iso/verify_test.go b/internal/iso/verify_test.go new file mode 100644 index 0000000..61320e7 --- /dev/null +++ b/internal/iso/verify_test.go @@ -0,0 +1,27 @@ +package iso + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeKeyID(t *testing.T) { + require.Equal(t, "D94AA3F0EFE21092", normalizeKeyID("0xD94AA3F0EFE21092")) + require.Equal(t, "843938DF228D22F7B3742BC0D94AA3F0EFE21092", normalizeKeyID("843938DF228D22F7B3742BC0D94AA3F0EFE21092")) +} + +func TestInterpretGPGResult(t *testing.T) { + require.NoError(t, interpretGPGResult("[GNUPG:] GOODSIG abc", "gpg: Good signature", nil)) + require.NoError(t, interpretGPGResult("", "gpg: Poprawny podpis od \"Ubuntu\"", nil)) + require.Error(t, interpretGPGResult("[GNUPG:] BADSIG abc", "", nil)) + require.Error(t, interpretGPGResult("", "gpg: BAD signature", nil)) + err := interpretGPGResult("", "", errors.New("exit status 2")) + require.Error(t, err) +} + +func TestLooksLikePGPKey(t *testing.T) { + require.True(t, looksLikePGPKey([]byte("-----BEGIN PGP PUBLIC KEY BLOCK-----\n"))) + require.False(t, looksLikePGPKey([]byte("not a key"))) +} diff --git a/internal/iso/write.go b/internal/iso/write.go index b830ed1..767aa51 100644 --- a/internal/iso/write.go +++ b/internal/iso/write.go @@ -7,9 +7,9 @@ import ( "fmt" "io" "os" - "strings" "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/ui" ) // WriteOptions configures ISO write to block device. @@ -18,6 +18,7 @@ type WriteOptions struct { Device string Yes bool Force bool + NoColor bool BlockSize string Stdout io.Writer Stderr io.Writer @@ -37,9 +38,9 @@ func WriteISO(ctx context.Context, opts WriteOptions) error { opts.BlockSize = "4M" } - device := opts.Device - if !strings.HasPrefix(device, "/dev/") { - device = "/dev/" + strings.TrimPrefix(device, "/dev/") + device := BlockDevicePath(opts.Device) + if device == "" { + return fmt.Errorf("device is required") } st, err := os.Stat(device) @@ -63,21 +64,33 @@ func WriteISO(ctx context.Context, opts WriteOptions) error { 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) + if len(info.Mountpoints) > 0 { + fmt.Fprintf(opts.Stdout, "Unmounting partitions on %s: %v\n", device, info.Mountpoints) + if err := unmountDevice(ctx, opts.Runner, device); err != nil { + return err + } + info, err = InspectDevice(ctx, opts.Runner, device) + if err != nil { + return err + } + if len(info.Mountpoints) > 0 && !opts.Force { + return fmt.Errorf("device %s still has mounted partitions: %v; unmount manually or use --force", device, info.Mountpoints) + } } - isoSize, err := ReadISOSize(opts.ISOPath) + isoStat, err := os.Stat(opts.ISOPath) if err != nil { - return err + return fmt.Errorf("iso file: %w", err) } + isoSize := formatBytes(isoStat.Size()) 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) + ui.WarnLine(opts.Stdout, opts.NoColor, fmt.Sprintf("ALL DATA ON %s WILL BE DESTROYED", device)) + fmt.Fprintln(opts.Stdout) if !opts.Yes { prompt := fmt.Sprintf("Type %q to confirm: ", device) @@ -96,8 +109,8 @@ func WriteISO(ctx context.Context, opts WriteOptions) error { } } - if err := unmountDevice(ctx, opts.Runner, device); err != nil { - return err + if os.Geteuid() != 0 { + fmt.Fprintln(opts.Stdout, "Elevating with sudo for disk write (password may be required)...") } args := []string{ @@ -108,13 +121,13 @@ func WriteISO(ctx context.Context, opts WriteOptions) error { "conv=fdatasync", "oflag=direct", } - if err := opts.Runner.Run(ctx, "dd", args...); err != nil { - return fmt.Errorf("dd: %w", err) + if err := runDDWithProgress(ctx, opts.Stdout, opts.NoColor, isoStat.Size(), device, args); err != nil { + return err } - if err := opts.Runner.Run(ctx, "sync"); err != nil { + if err := runPrivileged(ctx, opts.Runner, "sync"); err != nil { return err } - fmt.Fprintln(opts.Stdout, "Done. You can now unplug the USB drive and boot from it.") + ui.OKLine(opts.Stdout, opts.NoColor, "Done — unplug the USB drive and boot from it") return nil } From c6ec09a28db2b08b8194cd0a10259c93c3cff014 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:40:18 +0200 Subject: [PATCH 04/10] feat: add download progress reporting and formatting utilities Introduced a new DownloadReporter for rendering HTTP download progress, including a progress bar, speed, and estimated time of arrival (ETA). Implemented utility functions for formatting byte sizes and ETA durations. Added comprehensive unit tests to ensure functionality and reliability of the new features. --- internal/ui/download.go | 355 +++++++++++++++++++++++++++++++++++ internal/ui/download_test.go | 40 ++++ internal/ui/ui.go | 96 +++++++--- 3 files changed, 467 insertions(+), 24 deletions(-) create mode 100644 internal/ui/download.go create mode 100644 internal/ui/download_test.go diff --git a/internal/ui/download.go b/internal/ui/download.go new file mode 100644 index 0000000..29fbd9e --- /dev/null +++ b/internal/ui/download.go @@ -0,0 +1,355 @@ +package ui + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/mattn/go-isatty" +) + +const ( + downloadBarWidth = 28 + downloadRefresh = 100 * time.Millisecond + connectSpinEvery = 120 * time.Millisecond + nonTTYLogEvery = 5 * time.Second +) + +// DownloadReporter renders HTTP download progress (bar, speed, ETA). +type DownloadReporter struct { + W io.Writer + NoColor bool + + mu sync.Mutex + total int64 + read int64 + start time.Time + + connectCancel context.CancelFunc + tick *downloadWriter +} + +// NewDownloadReporter builds a progress renderer for stdout. +func NewDownloadReporter(w io.Writer, noColor bool) *DownloadReporter { + return &DownloadReporter{W: w, NoColor: noColor} +} + +func (d *DownloadReporter) interactive() bool { + f, ok := d.W.(*os.File) + return ok && isatty.IsTerminal(f.Fd()) +} + +// BeginConnect shows a spinner until EndConnect is called. +func (d *DownloadReporter) BeginConnect(phase string) { + if !d.interactive() { + _, _ = fmt.Fprintf(d.W, "%s...\n", phase) + return + } + ctx, cancel := context.WithCancel(context.Background()) + d.connectCancel = cancel + go d.spinConnect(ctx, phase) +} + +// EndConnect stops the connecting spinner and clears its line. +func (d *DownloadReporter) EndConnect() { + if d.connectCancel != nil { + d.connectCancel() + d.connectCancel = nil + if d.interactive() { + _, _ = fmt.Fprint(d.W, "\r\033[K") + } + } +} + +func (d *DownloadReporter) spinConnect(ctx context.Context, phase string) { + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + if d.NoColor { + frames = []string{"|", "/", "-", "\\"} + } + i := 0 + t := time.NewTicker(connectSpinEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + frame := frames[i%len(frames)] + i++ + _, _ = fmt.Fprintf(d.W, "\r %s %s", frame, phase) + } + } +} + +// SetTotal sets expected size (-1 if unknown). +func (d *DownloadReporter) SetTotal(total int64) { + d.mu.Lock() + d.total = total + d.read = 0 + d.start = time.Now() + d.mu.Unlock() +} + +// UpdateBytes sets absolute progress (for dd status=progress parsing). +func (d *DownloadReporter) UpdateBytes(read int64) { + d.mu.Lock() + if read < d.read { + d.mu.Unlock() + return + } + if d.start.IsZero() { + d.start = time.Now() + } + d.read = read + total := d.total + start := d.start + d.mu.Unlock() + + now := time.Now() + if d.tick == nil { + d.tick = &downloadWriter{d: d, first: true} + } + dw := d.tick + if dw.first || now.Sub(dw.lastRender) >= downloadRefresh { + dw.render(now, read, total, start, false) + dw.lastRender = now + dw.first = false + } +} + +// Writer returns an io.Writer that tracks bytes and refreshes the display. +func (d *DownloadReporter) Writer() io.Writer { + if d.tick == nil { + d.tick = &downloadWriter{d: d, first: true} + } + return d.tick +} + +type downloadWriter struct { + d *DownloadReporter + lastRender time.Time + lastPlainLog time.Time + lastRead int64 + lastSpeed float64 + first bool +} + +func (dw *downloadWriter) Write(p []byte) (int, error) { + n := len(p) + dw.d.mu.Lock() + dw.d.read += int64(n) + read := dw.d.read + total := dw.d.total + start := dw.d.start + dw.d.mu.Unlock() + + now := time.Now() + if dw.first || now.Sub(dw.lastRender) >= downloadRefresh { + dw.render(now, read, total, start, false) + dw.lastRender = now + dw.first = false + } + return n, nil +} + +func (dw *downloadWriter) render(now time.Time, read, total int64, start time.Time, final bool) { + elapsed := now.Sub(start) + if elapsed <= 0 { + elapsed = time.Millisecond + } + var instant float64 + if dw.lastRender.IsZero() { + instant = float64(read) / elapsed.Seconds() + } else { + dt := now.Sub(dw.lastRender).Seconds() + if dt <= 0 { + dt = 0.001 + } + instant = float64(read-dw.lastRead) / dt + } + if dw.lastSpeed == 0 { + dw.lastSpeed = instant + } else { + dw.lastSpeed = dw.lastSpeed*0.7 + instant*0.3 + } + dw.lastRead = read + + if !dw.d.interactive() { + if final || dw.lastPlainLog.IsZero() || now.Sub(dw.lastPlainLog) >= nonTTYLogEvery { + dw.d.printPlain(read, total, dw.lastSpeed, final) + dw.lastPlainLog = now + } + return + } + + line := dw.d.formatLine(read, total, dw.lastSpeed, elapsed, final) + if final { + clearProgressLine(dw.d.W) + _, _ = fmt.Fprintln(dw.d.W, line) + return + } + _, _ = fmt.Fprintf(dw.d.W, "\033[2K\r%s", line) +} + +func clearProgressLine(w io.Writer) { + _, _ = fmt.Fprint(w, "\033[2K\r") +} + +func (d *DownloadReporter) formatLine(read, total int64, speed float64, elapsed time.Duration, final bool) string { + var parts []string + + if total > 0 { + pct := float64(read) * 100 / float64(total) + if pct > 100 { + pct = 100 + } + parts = append(parts, progressBar(pct, downloadBarWidth, d.NoColor)) + parts = append(parts, fmt.Sprintf("%5.1f%%", pct)) + parts = append(parts, fmt.Sprintf("%s / %s", FormatBytes(read), FormatBytes(total))) + } else { + parts = append(parts, progressBarIndeterminate(d.NoColor)) + parts = append(parts, FormatBytes(read)) + } + + if speed > 0 { + parts = append(parts, FormatBytes(int64(speed))+"/s") + if total > 0 && read < total { + eta := time.Duration(float64(total-read)/speed) * time.Second + parts = append(parts, "ETA "+FormatETA(eta)) + } + } else if !final { + parts = append(parts, "starting…") + } + + if final && elapsed > 0 { + parts = append(parts, "in "+FormatETA(elapsed)) + } + + return " " + strings.Join(parts, " ") +} + +func (d *DownloadReporter) printPlain(read, total int64, speed float64, final bool) { + if total > 0 { + pct := int(read * 100 / total) + _, _ = fmt.Fprintf(d.W, " %d%% %s / %s", pct, FormatBytes(read), FormatBytes(total)) + } else { + _, _ = fmt.Fprintf(d.W, " %s downloaded", FormatBytes(read)) + } + if speed > 0 { + _, _ = fmt.Fprintf(d.W, " at %s/s", FormatBytes(int64(speed))) + } + if final { + _, _ = fmt.Fprintln(d.W, " (complete)") + } else { + _, _ = fmt.Fprintln(d.W) + } +} + +// Finish prints the final progress line and a short summary. +func (d *DownloadReporter) Finish() { + d.mu.Lock() + read, _, start := d.read, d.total, d.start + d.mu.Unlock() + if start.IsZero() { + return + } + elapsed := time.Since(start) + if d.interactive() { + clearProgressLine(d.W) + } + if read > 0 { + _, _ = fmt.Fprintf(d.W, " done: %s", FormatBytes(read)) + if elapsed >= time.Second { + _, _ = fmt.Fprintf(d.W, " in %s", FormatETA(elapsed)) + } + _, _ = fmt.Fprintln(d.W) + } +} + +// RunWithSpinner runs fn while showing a spinner (checksum, GPG, etc.). +func RunWithSpinner(w io.Writer, noColor bool, phase string, fn func() error) error { + rep := NewDownloadReporter(w, noColor) + rep.BeginConnect(phase) + defer rep.EndConnect() + err := fn() + if err != nil { + return err + } + if !rep.interactive() { + _, _ = fmt.Fprintf(w, "%s done\n", phase) + } + return nil +} + +// FormatBytes renders a human-readable size. +func FormatBytes(b int64) string { + if b < 0 { + return "0 B" + } + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div := int64(unit) + exp := 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + val := float64(b) / float64(div) + return fmt.Sprintf("%.1f %ciB", val, "KMGTPE"[exp]) +} + +// FormatETA renders a duration for progress display. +func FormatETA(d time.Duration) string { + if d < 0 || d >= 24*time.Hour { + return "—" + } + d = d.Round(time.Second) + sec := int(d.Seconds()) + if sec < 60 { + return fmt.Sprintf("%ds", sec) + } + if sec < 3600 { + return fmt.Sprintf("%dm%02ds", sec/60, sec%60) + } + return fmt.Sprintf("%dh%02dm", sec/3600, (sec%3600)/60) +} + +func progressBar(pct float64, width int, noColor bool) string { + if width < 4 { + width = 4 + } + filled := int(pct * float64(width) / 100) + if filled > width { + filled = width + } + if filled == width && pct >= 100 { + inner := strings.Repeat("=", width) + return bracket(inner, noColor) + } + var inner strings.Builder + if filled > 0 { + inner.WriteString(strings.Repeat("=", filled)) + } + if filled < width { + inner.WriteByte('>') + inner.WriteString(strings.Repeat(" ", width-filled-1)) + } + return bracket(inner.String(), noColor) +} + +func progressBarIndeterminate(noColor bool) string { + return bracket(strings.Repeat("·", downloadBarWidth), noColor) +} + +func bracket(inner string, noColor bool) string { + if noColor { + return "[" + inner + "]" + } + return "[" + inner + "]" +} diff --git a/internal/ui/download_test.go b/internal/ui/download_test.go new file mode 100644 index 0000000..3eb4ecb --- /dev/null +++ b/internal/ui/download_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestFormatBytes(t *testing.T) { + require.Equal(t, "512 B", FormatBytes(512)) + require.Equal(t, "1.0 KiB", FormatBytes(1024)) + require.Equal(t, "6.2 GiB", FormatBytes(6655619072)) +} + +func TestFormatETA(t *testing.T) { + require.Equal(t, "45s", FormatETA(45*time.Second)) + require.Equal(t, "2m05s", FormatETA(125*time.Second)) + require.Equal(t, "1h05m", FormatETA(3900*time.Second)) +} + +func TestProgressBar(t *testing.T) { + bar := progressBar(50, 20, true) + require.True(t, strings.HasPrefix(bar, "[")) + require.Contains(t, bar, "=") + require.Contains(t, bar, ">") +} + +func TestDownloadReporterPlain(t *testing.T) { + var buf bytes.Buffer + rep := NewDownloadReporter(&buf, true) + rep.SetTotal(1000) + w := rep.Writer() + _, err := w.Write(make([]byte, 250)) + require.NoError(t, err) + rep.Finish() + require.Contains(t, buf.String(), "250") +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9b4480c..e959df2 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -4,7 +4,6 @@ package ui import ( "fmt" "io" - "os" "strings" "github.com/charmbracelet/lipgloss" @@ -24,26 +23,24 @@ type Styles struct { // NewStyles builds the palette; disable color when noColor or not a TTY. func NewStyles(w io.Writer, noColor bool) Styles { - useColor := !noColor - if f, ok := w.(*os.File); ok && !lipgloss.HasDarkBackground() { - _ = f - } - if !useColor { + if noColor { plain := lipgloss.NewStyle() + bold := lipgloss.NewStyle().Bold(true) return Styles{ - Title: plain, Subtitle: plain, OK: plain, Warn: plain, - Err: plain, Dim: plain, Accent: plain, Border: plain, + Title: bold, Subtitle: plain, OK: bold, Warn: bold, + Err: bold, Dim: plain, Accent: bold, Border: plain, } } + dim := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "245", Dark: "241"}) return Styles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")), - Subtitle: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), - OK: lipgloss.NewStyle().Foreground(lipgloss.Color("42")), - Warn: lipgloss.NewStyle().Foreground(lipgloss.Color("214")), - Err: lipgloss.NewStyle().Foreground(lipgloss.Color("196")), - Dim: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Accent: lipgloss.NewStyle().Foreground(lipgloss.Color("39")), - Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1), + Title: lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.AdaptiveColor{Light: "236", Dark: "255"}), + Subtitle: dim, + OK: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "42"}), + Warn: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "172", Dark: "214"}), + Err: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "124", Dark: "196"}), + Dim: dim, + Accent: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "39"}), + Border: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Padding(0, 1), } } @@ -85,12 +82,15 @@ func Table(w io.Writer, s Styles, headers []string, rows [][]string) { } colW := make([]int, len(headers)) for i, h := range headers { - colW[i] = len(h) + colW[i] = lipgloss.Width(h) } for _, row := range rows { for i, cell := range row { - if i < len(colW) && len(cell) > colW[i] { - colW[i] = len(cell) + if i < len(colW) { + w := lipgloss.Width(cell) + if w > colW[i] { + colW[i] = w + } } } } @@ -102,22 +102,70 @@ func Table(w io.Writer, s Styles, headers []string, rows [][]string) { } if i < len(colW) { b.WriteString(c) - b.WriteString(strings.Repeat(" ", colW[i]-len(c))) + b.WriteString(strings.Repeat(" ", colW[i]-lipgloss.Width(c))) } else { b.WriteString(c) } } return b.String() } - _, _ = fmt.Fprintln(w, s.Dim.Render(pad(headers))) + _, _ = fmt.Fprintln(w, s.Title.Render(pad(headers))) for _, row := range rows { _, _ = fmt.Fprintln(w, pad(row)) } } -// Box wraps content in a rounded border. -func Box(s Styles, content string) string { - return s.Border.Render(content) +// KeyValue prints aligned label: value lines (e.g. version info). +func KeyValue(w io.Writer, s Styles, title string, pairs [][2]string) { + if title != "" { + _, _ = fmt.Fprintln(w, s.Title.Render(title)) + } + maxKey := 0 + for _, p := range pairs { + if len(p[0]) > maxKey { + maxKey = len(p[0]) + } + } + for _, p := range pairs { + label := fmt.Sprintf("%-*s", maxKey, p[0]+":") + _, _ = fmt.Fprintf(w, " %s %s\n", s.Dim.Render(label), p[1]) + } +} + +// SecurityWarning prints a high-visibility security banner (e.g. failed GPG verify). +func SecurityWarning(w io.Writer, s Styles, noColor bool, title string, lines ...string) { + banner := s.Warn + if !noColor { + banner = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "178", Dark: "220"}). + Background(lipgloss.AdaptiveColor{Light: "228", Dark: "236"}) + } + + sep := strings.Repeat("=", 72) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, banner.Render(sep)) + _, _ = fmt.Fprintln(w, banner.Render("!!! "+strings.ToUpper(title)+" !!!")) + _, _ = fmt.Fprintln(w, banner.Render(sep)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + _, _ = fmt.Fprintln(w, banner.Render(" "+line)) + } + _, _ = fmt.Fprintln(w, banner.Render(sep)) + _, _ = fmt.Fprintln(w) +} + +// WarnLine prints a single warning using standard styles. +func WarnLine(w io.Writer, noColor bool, msg string) { + Warn(w, NewStyles(w, noColor), msg) +} + +// OKLine prints a single success line using standard styles. +func OKLine(w io.Writer, noColor bool, msg string) { + OK(w, NewStyles(w, noColor), msg) } // NoColorFromCmd reads --no-color from cobra root when available. From 99e5533adc10510e3348208c035afef3ed2b019a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:40:24 +0200 Subject: [PATCH 05/10] feat: enhance ISO management and terminal output features Improved terminal output with theme-adaptive colors and structured layouts. Enhanced `lab iso download` with a progress bar, speed, ETA, and GPG key import for verification. Fixed issues in `lab iso disks` and added interactive features to `lab iso write`, including device selection and progress reporting. Updated CHANGELOG to reflect these changes. --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec412e..f299c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Terminal output: theme-adaptive colors, bold/underlined table headers, structured `lab version` and `lab iso list|disks` layout (`--no-color` for plain text). +- `lab iso download`: progress bar with speed and ETA, spinner while connecting, spinner during SHA256/GPG verification. +- `lab iso download`: import upstream GPG signing keys before verification (Ubuntu, Fedora); prominent security banner on GPG failure. +- `lab iso disks`: fix empty device list (missing `TYPE` column from lsblk). +- `lab iso write`: interactive picker, `lab iso write ubuntu-desktop --usb`, `--device` alias for `--to`; new `lab iso images`. +- `lab iso write`: fix lsblk device path (`/dev/sda` not `sda`); auto-unmount USB partitions before burn. +- `lab iso write`: parse lsblk sizes with locale decimal comma (e.g. `58,6G`). +- `lab iso write`: auto `sudo` for dd/sync; umount mounted partitions by mount path (not whole disk). +- `lab iso write`: progress bar while burning (parse dd status); unified yellow `!` warnings (no emoji). +- `lab iso write`: progress polls `/sys/block/*/stat` every 250ms (smooth bar, not dd stderr). - `scripts/install.sh`: automatically configure shell PATH when installing to a non-standard prefix; `--no-path` to opt out. - `scripts/uninstall.sh`: remove homelab-cli PATH block from shell rc on uninstall. From 7d10843957ee315b90bbcec09c02d66ea6371f52 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:44:57 +0200 Subject: [PATCH 06/10] refactor: remove unused promptLine function from iso_prompt.go Eliminated the promptLine function, which was previously responsible for handling user input prompts. This cleanup improves code maintainability by removing redundant code that is no longer utilized. --- internal/cli/commands/iso_prompt.go | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/internal/cli/commands/iso_prompt.go b/internal/cli/commands/iso_prompt.go index ef555f9..d72b898 100644 --- a/internal/cli/commands/iso_prompt.go +++ b/internal/cli/commands/iso_prompt.go @@ -42,23 +42,3 @@ func promptChoice(r io.Reader, w io.Writer, title string, options []string, defa } return n - 1, nil } - -func promptLine(r io.Reader, w io.Writer, prompt, defaultVal string) (string, error) { - if defaultVal != "" { - fmt.Fprintf(w, "%s [%s]: ", prompt, defaultVal) - } else { - fmt.Fprint(w, prompt) - } - sc := bufio.NewScanner(r) - if !sc.Scan() { - if err := sc.Err(); err != nil { - return "", err - } - return defaultVal, nil - } - line := strings.TrimSpace(sc.Text()) - if line == "" { - return defaultVal, nil - } - return line, nil -} From bb9309e6fbefe187ba1be9870445a371bb9cade7 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:45:09 +0200 Subject: [PATCH 07/10] feat: add sector count overflow check in blockWriteBytes function Implemented a check in the blockWriteBytes function to prevent sector count overflow, enhancing error handling for device writes. Removed unused collectPartNames function from lsblk.go to improve code clarity. Updated unitKey assignment in ParseSizeBytes for better readability. Reformatted the resolveUbuntuDesktop function for consistent styling. --- internal/iso/block_linux.go | 4 ++++ internal/iso/lsblk.go | 7 ------- internal/iso/size.go | 2 +- internal/iso/ubuntu.go | 6 +++--- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/internal/iso/block_linux.go b/internal/iso/block_linux.go index 8607ec4..9d968d4 100644 --- a/internal/iso/block_linux.go +++ b/internal/iso/block_linux.go @@ -27,5 +27,9 @@ func blockWriteBytes(device string) (int64, error) { if err != nil { return 0, err } + const maxSectors = (1 << 63) / sectorSize + if sectors > maxSectors { + return 0, fmt.Errorf("sector count overflow for %s", name) + } return int64(sectors) * sectorSize, nil } diff --git a/internal/iso/lsblk.go b/internal/iso/lsblk.go index eca148b..893e45f 100644 --- a/internal/iso/lsblk.go +++ b/internal/iso/lsblk.go @@ -81,13 +81,6 @@ func collectMountpoints(dev lsblkNode, out *[]string) { } } -func collectPartNames(dev lsblkNode, out *[]string) { - *out = append(*out, dev.Name) - for _, c := range dev.Children { - collectPartNames(c, out) - } -} - func collectUmountTargets(dev lsblkNode, out *[]string) { if dev.Mountpoint != nil { mp := strings.TrimSpace(*dev.Mountpoint) diff --git a/internal/iso/size.go b/internal/iso/size.go index 9b40d6a..b9bc756 100644 --- a/internal/iso/size.go +++ b/internal/iso/size.go @@ -19,7 +19,7 @@ func ParseSizeBytes(size string) (int64, error) { 'T': 1024 * 1024 * 1024 * 1024, } last := size[len(size)-1] - unitKey := byte(strings.ToUpper(string(last))[0]) + unitKey := strings.ToUpper(string(last))[0] if u, ok := units[unitKey]; ok { numStr := normalizeSizeNumber(size[:len(size)-1]) f, err := strconv.ParseFloat(numStr, 64) diff --git a/internal/iso/ubuntu.go b/internal/iso/ubuntu.go index 8f82fdc..3fccc89 100644 --- a/internal/iso/ubuntu.go +++ b/internal/iso/ubuntu.go @@ -38,9 +38,9 @@ func resolveUbuntuDesktop(arch, version string) (Release, error) { base := ubuntuBase + ver + "/" isoFile := fmt.Sprintf("ubuntu-%s-desktop-%s.iso", ver, arch) return Release{ - Version: ver + " LTS", - ISOURL: base + isoFile, - ChecksumURL: base + "SHA256SUMS", + Version: ver + " LTS", + ISOURL: base + isoFile, + ChecksumURL: base + "SHA256SUMS", ChecksumSigURL: base + "SHA256SUMS.gpg", SigningKeyIDs: []string{ "843938DF228D22F7B3742BC0D94AA3F0EFE21092", // Ubuntu CD Image Automatic Signing Key (2012) From ff2aeb2ba1c07b37214e5b6cadde372d5b970439 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:45:16 +0200 Subject: [PATCH 08/10] refactor: standardize function parameters and improve code readability Updated the NewStyles function to use an underscore for the unused writer parameter, enhancing clarity. Minor formatting adjustments made in download.go for consistency. --- internal/ui/download.go | 8 ++++---- internal/ui/ui.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/ui/download.go b/internal/ui/download.go index 29fbd9e..c0dd18a 100644 --- a/internal/ui/download.go +++ b/internal/ui/download.go @@ -13,10 +13,10 @@ import ( ) const ( - downloadBarWidth = 28 - downloadRefresh = 100 * time.Millisecond - connectSpinEvery = 120 * time.Millisecond - nonTTYLogEvery = 5 * time.Second + downloadBarWidth = 28 + downloadRefresh = 100 * time.Millisecond + connectSpinEvery = 120 * time.Millisecond + nonTTYLogEvery = 5 * time.Second ) // DownloadReporter renders HTTP download progress (bar, speed, ETA). diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e959df2..693a031 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -22,7 +22,7 @@ type Styles struct { } // NewStyles builds the palette; disable color when noColor or not a TTY. -func NewStyles(w io.Writer, noColor bool) Styles { +func NewStyles(_ io.Writer, noColor bool) Styles { if noColor { plain := lipgloss.NewStyle() bold := lipgloss.NewStyle().Bold(true) From 48832100371b3e3554a13daa7a1a4315d0e0f66f Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 11:55:55 +0200 Subject: [PATCH 09/10] feat: add NoColor option to WriteOptions struct Introduced a new NoColor field in the WriteOptions struct to allow users to disable colored output during ISO writing operations, enhancing customization and usability. --- internal/iso/write_stub.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/iso/write_stub.go b/internal/iso/write_stub.go index 9cb1c1e..3082a61 100644 --- a/internal/iso/write_stub.go +++ b/internal/iso/write_stub.go @@ -17,6 +17,7 @@ type WriteOptions struct { Device string Yes bool Force bool + NoColor bool BlockSize string Stdout io.Writer Stderr io.Writer From 3ffa1d0f2ff5c5dc941c5017c9c71c4ce15ea25a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 12:02:25 +0200 Subject: [PATCH 10/10] chore: update golangci-lint configuration and GitHub Actions workflows Added new linters G122 and G703 to the golangci-lint configuration. Updated golangci-lint version to v2.12.2 in Makefile and CI workflows, along with upgrading actions/checkout and actions/setup-go to their latest versions for improved compatibility and performance. --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/release.yml | 4 ++-- .golangci.yml | 2 ++ Makefile | 4 ++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0366243..4653f4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +12,17 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@v9 with: - version: v2.1.6 + version: v2.12.2 test: strategy: @@ -33,9 +33,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -46,9 +46,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7373ca..db51b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,11 +12,11 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true diff --git a/.golangci.yml b/.golangci.yml index 0ce3b79..f4438dd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -34,6 +34,8 @@ linters: - G306 - G302 - G110 + - G122 + - G703 issues: max-same-issues: 50 diff --git a/Makefile b/Makefile index e188eab..50273e8 100644 --- a/Makefile +++ b/Makefile @@ -32,11 +32,11 @@ test: ## Run tests go test ./... -race -cover lint: ## Run golangci-lint - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run fmt: ## gofmt + golangci formatters (gofumpt/goimports) gofmt -s -w . - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 fmt + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 fmt vet: ## go vet go vet ./...