From 736914fb3abd94428961c722a7107f4923b80b3e Mon Sep 17 00:00:00 2001 From: kwabenadarkwa Date: Mon, 27 Apr 2026 14:44:34 +0100 Subject: [PATCH] add interactive form to skipper add Running 'skipper add' with no positional args now opens a huh form prompting for alias, user, host name, and port. Alias stays optional and is derived from host[:port] when blank. Existing two-arg non-interactive flow is unchanged; passing only one arg now errors explicitly instead of failing on arg count. --- README.md | 4 +- cmd/add.go | 64 +++++++++++++-- cmd/add_test.go | 107 +++++++++++++++++++++++- go.mod | 17 ++-- go.sum | 32 +++++++- internal/ui/addform/form.go | 134 +++++++++++++++++++++++++++++++ internal/ui/addform/form_test.go | 128 +++++++++++++++++++++++++++++ 7 files changed, 466 insertions(+), 20 deletions(-) create mode 100644 internal/ui/addform/form.go create mode 100644 internal/ui/addform/form_test.go diff --git a/README.md b/README.md index b058e82..d9c7bcc 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,13 @@ skipper [command] [flags] | Command | Description | |---------|-------------| -| `add ` | Add a host entry to the SSH config under the given alias | +| `add` | Launch an interactive form (alias, user, host name, port) to add a host entry | +| `add ` | Non-interactively add a host entry to the SSH config under the given alias | Examples: ```bash +skipper add skipper add devone user@ipaddress:9000 skipper add bastion admin@10.0.0.5 ``` diff --git a/cmd/add.go b/cmd/add.go index 897c300..15faf15 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -3,16 +3,26 @@ package cmd import ( "fmt" + "github.com/jerryagbesi/skipper/internal/sshconfig" + "github.com/jerryagbesi/skipper/internal/ui/addform" + "github.com/spf13/cobra" ) +var addFormRunner = addform.Run + var addCmd = &cobra.Command{ - Use: "add ", + Use: "add [ ]", Short: "Add a host entry to the SSH config", - Long: "Add a host entry to the SSH config under the given alias. The target must be in the form user@host[:port].", - Example: ` skipper add devone user@10.0.0.8:9000 + Long: `Add a host entry to the SSH config. + +Run with no arguments to launch an interactive form prompting for alias, +user, host name, and port. Alternatively pass an alias and a target in +the form user@host[:port] for a non-interactive add.`, + Example: ` skipper add + skipper add devone user@10.0.0.8:9000 skipper add bastion admin@10.0.0.5`, - Args: cobra.ExactArgs(2), + Args: cobra.MaximumNArgs(2), RunE: runAdd, } @@ -22,17 +32,55 @@ func runAdd(_ *cobra.Command, args []string) error { return err } - host, created, err := addHost(path, args[0], args[1]) + switch len(args) { + case 2: + return addFromArgs(path, args[0], args[1]) + case 1: + return fmt.Errorf("expected either no arguments or both and ") + default: + return addInteractive(path) + } +} + +func addFromArgs(path, alias, target string) error { + host, created, err := addHost(path, alias, target) + if err != nil { + return err + } + printAddResult(host, created) + return nil +} + +func addInteractive(path string) error { + result, err := addFormRunner(addform.Input{}) + if err != nil { + return err + } + if result.Cancelled { + return nil + } + + host := sshconfig.Host{ + Alias: result.Alias, + Hostname: result.Hostname, + User: result.User, + Port: result.Port, + } + + added, created, err := sshconfig.AddHost(path, host) if err != nil { return err } + printAddResult(added, created) + return nil +} +func printAddResult(host *sshconfig.Host, created bool) { if created { fmt.Printf("added host %q for %s\n", host.Alias, hostTarget(host)) - } else { - fmt.Printf("host %q already exists for %s, no change\n", host.Alias, hostTarget(host)) + return } - return nil + fmt.Printf("host %q already exists for %s, no change\n", host.Alias, hostTarget(host)) } func init() { diff --git a/cmd/add_test.go b/cmd/add_test.go index ef9da0e..6ab6339 100644 --- a/cmd/add_test.go +++ b/cmd/add_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/jerryagbesi/skipper/internal/sshconfig" + "github.com/jerryagbesi/skipper/internal/ui/addform" ) func TestAddCommandWritesHost(t *testing.T) { @@ -31,7 +32,7 @@ func TestAddCommandWritesHost(t *testing.T) { } } -func TestAddCommandRejectsWrongArgCount(t *testing.T) { +func TestAddCommandRejectsSingleArg(t *testing.T) { path := filepath.Join(t.TempDir(), "config") out := new(bytes.Buffer) @@ -41,10 +42,108 @@ func TestAddCommandRejectsWrongArgCount(t *testing.T) { err := rootCmd.Execute() if err == nil { - t.Fatal("expected error for missing target argument") + t.Fatal("expected error for partial argument set") } - if !strings.Contains(err.Error(), "accepts 2 arg") { - t.Fatalf("expected arg-count error, got %v", err) + if !strings.Contains(err.Error(), "no arguments or both") { + t.Fatalf("expected partial-args error, got %v", err) + } +} + +func TestAddCommandInteractiveWritesHost(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + + originalRunner := addFormRunner + t.Cleanup(func() { addFormRunner = originalRunner }) + + addFormRunner = func(_ addform.Input) (addform.Result, error) { + return addform.Result{ + Alias: "interactive", + User: "alice", + Hostname: "10.0.0.42", + Port: 2222, + }, nil + } + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add", "-c", path}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected interactive add to succeed, got %v", err) + } + + hosts, err := sshconfig.ParseHosts(path) + if err != nil { + t.Fatalf("expected config to parse, got %v", err) + } + + if len(hosts) != 1 { + t.Fatalf("expected 1 host, got %d (%+v)", len(hosts), hosts) + } + got := hosts[0] + if got.Alias != "interactive" || got.User != "alice" || got.Hostname != "10.0.0.42" || got.Port != 2222 { + t.Fatalf("unexpected host written: %+v", got) + } +} + +func TestAddCommandInteractiveDerivesAliasWhenBlank(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + + originalRunner := addFormRunner + t.Cleanup(func() { addFormRunner = originalRunner }) + + addFormRunner = func(_ addform.Input) (addform.Result, error) { + return addform.Result{ + User: "alice", + Hostname: "10.0.0.42", + Port: 2222, + }, nil + } + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add", "-c", path}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected interactive add to succeed, got %v", err) + } + + hosts, err := sshconfig.ParseHosts(path) + if err != nil { + t.Fatalf("expected config to parse, got %v", err) + } + + if len(hosts) != 1 { + t.Fatalf("expected 1 host, got %d (%+v)", len(hosts), hosts) + } + if hosts[0].Alias != "10.0.0.42-2222" { + t.Fatalf("expected alias derived from host:port, got %q", hosts[0].Alias) + } +} + +func TestAddCommandInteractiveCancelledWritesNothing(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + + originalRunner := addFormRunner + t.Cleanup(func() { addFormRunner = originalRunner }) + + addFormRunner = func(_ addform.Input) (addform.Result, error) { + return addform.Result{Cancelled: true}, nil + } + + out := new(bytes.Buffer) + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"add", "-c", path}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("expected cancelled add to succeed, got %v", err) + } + + if _, err := sshconfig.ParseHosts(path); err == nil { + t.Fatal("expected no config file to be created when form is cancelled") } } diff --git a/go.mod b/go.mod index b8ed711..a6588d7 100644 --- a/go.mod +++ b/go.mod @@ -2,32 +2,39 @@ module github.com/jerryagbesi/skipper go 1.25.4 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 + github.com/kevinburke/ssh_config v1.6.0 + github.com/spf13/cobra v1.10.2 +) + require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // 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.22 // 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/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.42.0 // indirect diff --git a/go.sum b/go.sum index e06e29c..535f40b 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,58 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +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.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -37,6 +61,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/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= @@ -56,6 +82,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= diff --git a/internal/ui/addform/form.go b/internal/ui/addform/form.go new file mode 100644 index 0000000..a5a6651 --- /dev/null +++ b/internal/ui/addform/form.go @@ -0,0 +1,134 @@ +// Package addform renders the interactive prompt used by `skipper add` +// when invoked without positional arguments. +package addform + +import ( + "errors" + "fmt" + "strconv" + "strings" + "unicode" + + "github.com/charmbracelet/huh" +) + +type Input struct { + Alias string + User string + Hostname string + Port string +} + +type Result struct { + Alias string + User string + Hostname string + Port int + Cancelled bool +} + +// Run displays the interactive add-host form and returns the collected values. +// If the user aborts (Ctrl+C / Esc) Result.Cancelled is true and err is nil. +func Run(in Input) (Result, error) { + var ( + alias = in.Alias + user = in.User + hostname = in.Hostname + port = in.Port + ) + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Alias"). + Description("Optional. Defaults to host name (or host-port)."). + Placeholder("devone"). + Value(&alias). + Validate(validateAlias), + huh.NewInput(). + Title("User"). + Placeholder("root"). + Value(&user). + Validate(validateUser), + huh.NewInput(). + Title("HostName"). + Placeholder("10.0.0.8"). + Value(&hostname). + Validate(validateHostname), + huh.NewInput(). + Title("Port"). + Placeholder("22 (leave blank to omit)"). + Value(&port). + Validate(validatePort), + ), + ) + + if err := form.Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return Result{Cancelled: true}, nil + } + return Result{}, err + } + + parsedPort, err := parsePort(port) + if err != nil { + return Result{}, err + } + + return Result{ + Alias: strings.TrimSpace(alias), + User: strings.TrimSpace(user), + Hostname: strings.TrimSpace(hostname), + Port: parsedPort, + }, nil +} + +func validateAlias(value string) error { return validateOptionalField("alias", value) } +func validateUser(value string) error { return validateRequiredField("user", value) } +func validateHostname(value string) error { return validateRequiredField("host name", value) } + +func validatePort(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + + port, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("port must be a number") + } + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + return nil +} + +func parsePort(value string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + return strconv.Atoi(value) +} + +func validateRequiredField(name, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("%s is required", name) + } + if strings.ContainsFunc(trimmed, unicode.IsSpace) { + return fmt.Errorf("%s cannot contain whitespace", name) + } + return nil +} + +func validateOptionalField(name, value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + if strings.ContainsFunc(trimmed, unicode.IsSpace) { + return fmt.Errorf("%s cannot contain whitespace", name) + } + return nil +} diff --git a/internal/ui/addform/form_test.go b/internal/ui/addform/form_test.go new file mode 100644 index 0000000..4e8b85c --- /dev/null +++ b/internal/ui/addform/form_test.go @@ -0,0 +1,128 @@ +package addform + +import ( + "strings" + "testing" +) + +func TestValidateRequiredField(t *testing.T) { + tests := []struct { + name string + value string + wantError string + }{ + {name: "non-empty no whitespace", value: "devone"}, + {name: "empty", value: "", wantError: "is required"}, + {name: "only whitespace", value: " ", wantError: "is required"}, + {name: "internal whitespace", value: "dev one", wantError: "cannot contain whitespace"}, + {name: "tab character", value: "dev\tone", wantError: "cannot contain whitespace"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRequiredField("alias", tt.value) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected error containing %q, got %v", tt.wantError, err) + } + }) + } +} + +func TestValidateOptionalField(t *testing.T) { + tests := []struct { + name string + value string + wantError string + }{ + {name: "empty allowed", value: ""}, + {name: "whitespace only allowed", value: " "}, + {name: "valid value", value: "devone"}, + {name: "internal whitespace", value: "dev one", wantError: "cannot contain whitespace"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOptionalField("alias", tt.value) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected error containing %q, got %v", tt.wantError, err) + } + }) + } +} + +func TestValidatePort(t *testing.T) { + tests := []struct { + name string + value string + wantError string + }{ + {name: "empty allowed", value: ""}, + {name: "whitespace only allowed", value: " "}, + {name: "valid mid-range", value: "22"}, + {name: "valid edge low", value: "1"}, + {name: "valid edge high", value: "65535"}, + {name: "zero out of range", value: "0", wantError: "between 1 and 65535"}, + {name: "too high", value: "70000", wantError: "between 1 and 65535"}, + {name: "negative", value: "-1", wantError: "between 1 and 65535"}, + {name: "non-numeric", value: "abc", wantError: "must be a number"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePort(tt.value) + if tt.wantError == "" { + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("expected error containing %q, got %v", tt.wantError, err) + } + }) + } +} + +func TestParsePort(t *testing.T) { + tests := []struct { + name string + value string + want int + wantErr bool + }{ + {name: "empty", value: "", want: 0}, + {name: "whitespace", value: " ", want: 0}, + {name: "number", value: "2222", want: 2222}, + {name: "non-numeric", value: "abc", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePort(tt.value) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != tt.want { + t.Fatalf("expected %d, got %d", tt.want, got) + } + }) + } +}