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=