diff --git a/cmd/installer/firstboot/firstboot.go b/cmd/installer/firstboot/firstboot.go new file mode 100644 index 000000000..687ece088 --- /dev/null +++ b/cmd/installer/firstboot/firstboot.go @@ -0,0 +1,121 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package firstboot writes the oneshot systemd service and configuration that +// completes Spinifex provisioning on the first real boot after installation. +package firstboot + +import ( + "fmt" + "os" + "path/filepath" +) + +// Config holds the values the firstboot service needs to configure the node. +type Config struct { + Hostname string + OVNInterface string + ManagementIP string + // ClusterRole is "init" or "join". + ClusterRole string + // JoinAddr is host:port of the primary node, only used when ClusterRole is "join". + JoinAddr string +} + +// Write drops the firstboot script and systemd unit into root, which should be +// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install). +func Write(root string, cfg Config) error { + if err := writeScript(root, cfg); err != nil { + return fmt.Errorf("firstboot script: %w", err) + } + if err := writeUnit(root); err != nil { + return fmt.Errorf("firstboot unit: %w", err) + } + return enableUnit(root) +} + +func writeScript(root string, cfg Config) error { + clusterCmd := buildClusterCmd(cfg) + + script := fmt.Sprintf(`#!/bin/bash +# Spinifex firstboot — runs once after ISO installation, then disables itself. +set -euo pipefail + +# Set hostname +hostnamectl set-hostname %s + +# Configure OVN networking +/usr/local/bin/spinifex-setup-ovn.sh \ + --management \ + --encap-ip=%s \ + --interface=%s + +# Cluster formation +%s + +# Start services +systemctl start spinifex.target + +# Disable this service so it never runs again +systemctl disable spinifex-firstboot.service +`, cfg.Hostname, cfg.ManagementIP, cfg.OVNInterface, clusterCmd) + + path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh") + return os.WriteFile(path, []byte(script), 0o755) +} + +func writeUnit(root string) error { + unit := `[Unit] +Description=Spinifex first-boot provisioning +After=network-online.target +Wants=network-online.target +ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/spinifex-firstboot.sh +RemainAfterExit=yes +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +` + path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service") + return os.WriteFile(path, []byte(unit), 0o644) +} + +func enableUnit(root string) error { + wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants") + if err := os.MkdirAll(wantsDir, 0o755); err != nil { + return err + } + link := filepath.Join(wantsDir, "spinifex-firstboot.service") + target := "/etc/systemd/system/spinifex-firstboot.service" + // Remove stale symlink if present. + _ = os.Remove(link) + return os.Symlink(target, link) +} + +func buildClusterCmd(cfg Config) string { + switch cfg.ClusterRole { + case "join": + return fmt.Sprintf("spx admin join --node %s --host %s", cfg.Hostname, cfg.JoinAddr) + default: + return fmt.Sprintf("spx admin init --node %s --nodes 1", cfg.Hostname) + } +} diff --git a/cmd/installer/install/config.go b/cmd/installer/install/config.go new file mode 100644 index 000000000..5bcd67d8a --- /dev/null +++ b/cmd/installer/install/config.go @@ -0,0 +1,45 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package install performs the disk installation steps after the UI has +// collected configuration. +package install + +// Config holds all values collected by the installer UI. +type Config struct { + // Disk is the block device path to install onto (e.g. /dev/sda). + Disk string + + // Network + ManagementIP string + SubnetMask string + Gateway string + OVNInterface string + + // Node identity + Hostname string + + // ClusterRole is "init" or "join". + ClusterRole string + + // JoinAddr is the primary node address (host:port) when ClusterRole is "join". + JoinAddr string + + // CA certificate (PEM), optional. + HasCACert bool + CACert string +} diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go new file mode 100644 index 000000000..831153548 --- /dev/null +++ b/cmd/installer/install/install.go @@ -0,0 +1,212 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package install + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + + "github.com/mulgadc/spinifex/cmd/installer/firstboot" +) + +const ( + mountRoot = "/mnt/spinifex-install" + efiPart = mountRoot + "/boot/efi" +) + +// Run executes all installation steps in order. It is intentionally sequential +// and explicit — each step is visible in logs so failures are easy to diagnose. +func Run(cfg *Config) error { + steps := []struct { + name string + fn func() error + }{ + {"partition disk", func() error { return partitionDisk(cfg.Disk) }}, + {"format partitions", func() error { return formatPartitions(cfg.Disk) }}, + {"mount partitions", func() error { return mountPartitions(cfg.Disk) }}, + {"debootstrap", debootstrap}, + {"install spinifex", func() error { return installSpinifex(cfg) }}, + {"write network config", func() error { return writeNetworkConfig(cfg) }}, + {"write firstboot service", func() error { return firstboot.Write(mountRoot, cfg.toFirstbootConfig()) }}, + {"install bootloader", func() error { return installBootloader(cfg.Disk) }}, + {"install CA cert", func() error { return installCACert(cfg) }}, + {"unmount", unmount}, + } + + for _, s := range steps { + slog.Info("installer", "step", s.name) + if err := s.fn(); err != nil { + return fmt.Errorf("step %q: %w", s.name, err) + } + } + + slog.Info("installation complete — rebooting") + return reboot() +} + +func partitionDisk(disk string) error { + // GPT table with a 512MB EFI partition and the rest as root. + return run("parted", "--script", disk, + "mklabel", "gpt", + "mkpart", "ESP", "fat32", "1MiB", "513MiB", + "set", "1", "esp", "on", + "mkpart", "root", "ext4", "513MiB", "100%", + ) +} + +func formatPartitions(disk string) error { + efi, root := partitionPaths(disk) + if err := run("mkfs.fat", "-F32", efi); err != nil { + return err + } + return run("mkfs.ext4", "-F", root) +} + +func mountPartitions(disk string) error { + _, root := partitionPaths(disk) + if err := os.MkdirAll(mountRoot, 0o755); err != nil { + return err + } + if err := run("mount", root, mountRoot); err != nil { + return err + } + if err := os.MkdirAll(efiPart, 0o755); err != nil { + return err + } + efi, _ := partitionPaths(disk) + return run("mount", efi, efiPart) +} + +func debootstrap() error { + return run("debootstrap", "--arch=amd64", "bookworm", mountRoot) +} + +func installSpinifex(cfg *Config) error { + // Copy the spinifex binary from the ISO's live environment into the target. + spxBin, err := exec.LookPath("spinifex") + if err != nil { + return fmt.Errorf("spinifex binary not found on ISO: %w", err) + } + dest := filepath.Join(mountRoot, "usr/local/bin/spinifex") + if err := copyFile(spxBin, dest, 0o755); err != nil { + return err + } + + // Run setup.sh inside a chroot to install dependencies and systemd units. + setupSh := "/usr/local/share/spinifex/setup.sh" + return run("chroot", mountRoot, "bash", setupSh, "--offline") +} + +func writeNetworkConfig(cfg *Config) error { + // Write a simple /etc/network/interfaces for the management interface. + // The OVN interface is left unconfigured here — setup-ovn.sh handles it + // on first boot. + content := fmt.Sprintf(`# Generated by Spinifex installer +auto lo +iface lo inet loopback + +auto eth0 +iface eth0 inet static + address %s + netmask %s + gateway %s +`, cfg.ManagementIP, cfg.SubnetMask, cfg.Gateway) + + path := filepath.Join(mountRoot, "etc/network/interfaces") + return os.WriteFile(path, []byte(content), 0o644) +} + +func installBootloader(disk string) error { + // Install GRUB for both BIOS (grub-pc) and UEFI (grub-efi-amd64). + if err := run("chroot", mountRoot, + "grub-install", "--target=x86_64-efi", + "--efi-directory=/boot/efi", + "--bootloader-id=spinifex", + "--recheck", + ); err != nil { + return err + } + if err := run("chroot", mountRoot, + "grub-install", "--target=i386-pc", disk, + ); err != nil { + return err + } + return run("chroot", mountRoot, "update-grub") +} + +func installCACert(cfg *Config) error { + if !cfg.HasCACert || cfg.CACert == "" { + return nil + } + certPath := filepath.Join(mountRoot, "usr/local/share/ca-certificates/spinifex-ca.crt") + if err := os.WriteFile(certPath, []byte(cfg.CACert), 0o644); err != nil { + return err + } + return run("chroot", mountRoot, "update-ca-certificates") +} + +func unmount() error { + if err := run("umount", efiPart); err != nil { + return err + } + return run("umount", mountRoot) +} + +func reboot() error { + return run("reboot") +} + +// toFirstbootConfig maps installer Config to the firstboot package's Config. +func (c *Config) toFirstbootConfig() firstboot.Config { + return firstboot.Config{ + Hostname: c.Hostname, + OVNInterface: c.OVNInterface, + ManagementIP: c.ManagementIP, + ClusterRole: c.ClusterRole, + JoinAddr: c.JoinAddr, + } +} + +// partitionPaths returns the EFI and root partition device paths for a given +// disk. Handles both /dev/sdX (→ /dev/sdX1, /dev/sdX2) and /dev/nvmeXnY +// (→ /dev/nvmeXnYp1, /dev/nvmeXnYp2). +func partitionPaths(disk string) (efi, root string) { + // NVMe devices use a 'p' separator before the partition number. + if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' { + return disk + "p1", disk + "p2" + } + return disk + "1", disk + "2" +} + +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func copyFile(src, dst string, mode os.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, mode) +} diff --git a/cmd/installer/main.go b/cmd/installer/main.go new file mode 100644 index 000000000..761880de6 --- /dev/null +++ b/cmd/installer/main.go @@ -0,0 +1,38 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ +package main + +import ( + "fmt" + "os" + + "github.com/mulgadc/spinifex/cmd/installer/install" + "github.com/mulgadc/spinifex/cmd/installer/ui" +) + +func main() { + cfg, err := ui.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "installer: %v\n", err) + os.Exit(1) + } + + if err := install.Run(cfg); err != nil { + fmt.Fprintf(os.Stderr, "install failed: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/installer/ui/ui.go b/cmd/installer/ui/ui.go new file mode 100644 index 000000000..6d8992ada --- /dev/null +++ b/cmd/installer/ui/ui.go @@ -0,0 +1,263 @@ +/* +Copyright © 2026 Mulga Defense Corporation + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Package ui presents the interactive installer forms using huh. +package ui + +import ( + "errors" + "fmt" + "net" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/mulgadc/spinifex/cmd/installer/install" +) + +// Run walks the operator through all installer forms and returns a completed +// Config ready to hand to install.Run. +func Run() (*install.Config, error) { + cfg := &install.Config{} + + disks, err := availableDisks() + if err != nil { + return nil, fmt.Errorf("listing disks: %w", err) + } + if len(disks) == 0 { + return nil, errors.New("no block devices found") + } + + nics, err := availableNICs() + if err != nil { + return nil, fmt.Errorf("listing network interfaces: %w", err) + } + + var joinHost string + var joinPort string + var caCert string + + form := huh.NewForm( + // Step 1: Welcome + huh.NewGroup( + huh.NewNote(). + Title("Spinifex Installer"). + Description("This will install Spinifex onto this machine.\n\nAll data on the selected disk will be erased.\nPress Enter to continue."), + ), + + // Step 2: Disk selection + huh.NewGroup( + huh.NewSelect[string](). + Title("Select installation disk"). + Description("All data on the selected disk will be permanently erased."). + Options(diskOptions(disks)...). + Value(&cfg.Disk), + huh.NewConfirm(). + Title("Confirm disk erasure"). + Description("Are you sure? This cannot be undone."). + Affirmative("Yes, erase and install"). + Negative("Cancel"). + Validate(func(v bool) error { + if !v { + return errors.New("installation cancelled") + } + return nil + }). + Value(new(bool)), + ), + + // Step 3: Network configuration + huh.NewGroup( + huh.NewInput(). + Title("Management IP address"). + Description("Static IP address for this node (e.g. 192.168.1.10)"). + Validate(validateIP). + Value(&cfg.ManagementIP), + huh.NewInput(). + Title("Subnet mask"). + Placeholder("255.255.255.0"). + Validate(validateIP). + Value(&cfg.SubnetMask), + huh.NewInput(). + Title("Default gateway"). + Validate(validateIP). + Value(&cfg.Gateway), + huh.NewSelect[string](). + Title("OVN network interface"). + Description("Physical NIC to use for VM networking"). + Options(nicOptions(nics)...). + Value(&cfg.OVNInterface), + ), + + // Step 4: Node configuration + huh.NewGroup( + huh.NewInput(). + Title("Hostname"). + Description("Name for this node (e.g. node1)"). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("hostname is required") + } + return nil + }). + Value(&cfg.Hostname), + huh.NewSelect[string](). + Title("Cluster role"). + Options( + huh.NewOption("Initialize new cluster (single or first node)", "init"), + huh.NewOption("Join existing cluster", "join"), + ). + Value(&cfg.ClusterRole), + ), + + // Step 5: Join configuration (shown when joining) + huh.NewGroup( + huh.NewInput(). + Title("Primary node IP"). + Description("IP address of the node running spx admin init"). + Validate(func(s string) error { + if cfg.ClusterRole != "join" { + return nil + } + return validateIP(s) + }). + Value(&joinHost), + huh.NewInput(). + Title("Formation port"). + Placeholder("4432"). + Value(&joinPort), + ).WithHideFunc(func() bool { + return cfg.ClusterRole != "join" + }), + + // Step 6: Optional CA certificate + huh.NewGroup( + huh.NewConfirm(). + Title("Install a custom CA certificate?"). + Description("Required for air-gapped deployments with a private certificate authority."). + Affirmative("Yes"). + Negative("No"). + Value(&cfg.HasCACert), + ), + huh.NewGroup( + huh.NewText(). + Title("CA certificate (PEM format)"). + Description("Paste the full PEM certificate block."). + Validate(func(s string) error { + if !cfg.HasCACert { + return nil + } + if !strings.Contains(s, "BEGIN CERTIFICATE") { + return errors.New("does not look like a PEM certificate") + } + return nil + }). + Value(&caCert), + ).WithHideFunc(func() bool { + return !cfg.HasCACert + }), + + // Step 7: Final confirmation + huh.NewGroup( + huh.NewConfirm(). + Title("Begin installation?"). + Description("Review your choices above. Installation will begin immediately."). + Affirmative("Install"). + Negative("Cancel"). + Validate(func(v bool) error { + if !v { + return errors.New("installation cancelled") + } + return nil + }). + Value(new(bool)), + ), + ) + + if err := form.Run(); err != nil { + return nil, err + } + + if cfg.ClusterRole == "join" { + port := strings.TrimSpace(joinPort) + if port == "" { + port = "4432" + } + cfg.JoinAddr = net.JoinHostPort(strings.TrimSpace(joinHost), port) + } + + cfg.CACert = strings.TrimSpace(caCert) + + return cfg, nil +} + +func validateIP(s string) error { + if net.ParseIP(strings.TrimSpace(s)) == nil { + return errors.New("invalid IP address") + } + return nil +} + +// availableDisks returns block device paths from /sys/block, excluding loop +// and RAM devices. +func availableDisks() ([]string, error) { + entries, err := os.ReadDir("/sys/block") + if err != nil { + return nil, err + } + var disks []string + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") { + continue + } + disks = append(disks, "/dev/"+name) + } + return disks, nil +} + +func diskOptions(disks []string) []huh.Option[string] { + opts := make([]huh.Option[string], len(disks)) + for i, d := range disks { + opts[i] = huh.NewOption(d, d) + } + return opts +} + +// availableNICs returns non-loopback network interface names. +func availableNICs() ([]string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + var nics []string + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + nics = append(nics, iface.Name) + } + return nics, nil +} + +func nicOptions(nics []string) []huh.Option[string] { + opts := make([]huh.Option[string], len(nics)) + for i, n := range nics { + opts[i] = huh.NewOption(n, n) + } + return opts +} diff --git a/go.mod b/go.mod index d17c81377..156ead98d 100644 --- a/go.mod +++ b/go.mod @@ -27,19 +27,32 @@ require ( atomicgo.dev/schedule v0.1.0 // indirect github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/boltdb/bolt v1.3.1 // indirect github.com/buraksezer/consistent v0.10.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/hub v1.0.2 // indirect github.com/cenkalti/rpc2 v1.0.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/console v1.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgraph-io/badger/v4 v4.9.1 // indirect github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect @@ -66,11 +79,17 @@ require ( github.com/klauspost/reedsolomon v1.13.3 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nkeys v0.4.15 // indirect @@ -81,6 +100,7 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/go.sum b/go.sum index c0b8e8ec9..48bef4402 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/antithesishq/antithesis-sdk-go v0.7.0/go.mod h1:FQyySiasQQM8735Ddel3M github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= @@ -67,6 +69,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfx github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -75,6 +79,8 @@ github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/buraksezer/consistent v0.10.0 h1:hqBgz1PvNLC5rkWcEBVAL9dFMBWz6I0VgUCW25rrZlU= github.com/buraksezer/consistent v0.10.0/go.mod h1:6BrVajWq7wbKZlTOUPs/XVfR8c0maujuPowduSpZqmw= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/hub v1.0.2 h1:Nqv9TNaA9boeO2wQFW8o87BY3zKthtnzXmWGmJqhAV8= @@ -84,6 +90,24 @@ github.com/cenkalti/rpc2 v1.0.5/go.mod h1:2yfU5b86vOr16+iY1jN3MvT6Kxc9Nf8j5iZWwU github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -103,6 +127,8 @@ github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa5 github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= @@ -235,6 +261,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -243,6 +271,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= @@ -251,10 +281,18 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mulgadc/predastore v1.1.4 h1:czeGQWWqmmZuGnUuvyrORavySN/SGPTwjUxBd/9swuU= github.com/mulgadc/predastore v1.1.4/go.mod h1:YP4TaathWczm9weoI8YomZ1/IgnFB9WJowkHPidh6rw= github.com/mulgadc/viperblock v1.1.5 h1:ojmoJQrY7VkDGzdQj07w2F7ICRJX9RlGAKyn87Wa55o= @@ -321,6 +359,8 @@ github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeI github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -435,6 +475,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=