feat: scaffold ISO installer TUI and install pipeline (mulga-859)

Adds cmd/installer with three packages:
- ui: huh-based TUI collecting disk, network, node, and cluster config
- install: sequential install steps (partition, debootstrap, chroot, bootloader)
- firstboot: oneshot systemd service that runs setup-ovn and spx admin init/join on first real boot

Supports both single-node init and multi-node join flows.
This commit is contained in:
tomnewton-mulga
2026-04-07 15:50:40 +10:00
parent 99f9949bdd
commit 1b39439cb8
7 changed files with 740 additions and 0 deletions
+121
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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)
}
}
+45
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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
}
+212
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
+38
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
+263
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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
}
+20
View File
@@ -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
+41
View File
@@ -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=